mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
Verify agent harnesses through bounded computer probe owners
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ebb60670c1
commit
d29e2a8bfe
8 files changed
+70
-12
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsCommandBacked,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
runAdapterExecutionTargetProcess,
|
||||
@@ -90,7 +91,7 @@ export async function testEnvironment(
|
||||
const command = asString(config.command, "claude");
|
||||
const target = ctx.executionTarget ?? null;
|
||||
const targetIsRemote = target?.kind === "remote";
|
||||
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
|
||||
const targetIsSandbox = adapterExecutionTargetIsCommandBacked(target);
|
||||
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
|
||||
const runId = `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsCommandBacked,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
maybeRunSandboxInstallCommand,
|
||||
@@ -277,7 +278,7 @@ export async function testEnvironment(
|
||||
const command = asString(config.command, "codex");
|
||||
const target = ctx.executionTarget ?? null;
|
||||
const targetIsRemote = target?.kind === "remote";
|
||||
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
|
||||
const targetIsSandbox = adapterExecutionTargetIsCommandBacked(target);
|
||||
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
|
||||
const targetLabel = targetIsRemote
|
||||
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
|
||||
|
||||
@@ -313,6 +313,25 @@ describe("server adapter registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies the Claude harness on a computer after checking its platform", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1", adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6", env: { ANTHROPIC_API_KEY: "test-key" } },
|
||||
executionTarget: { kind: "remote", transport: "computer", remoteCwd: "/workspace", resourceAuthority: { computerId: "computer" },
|
||||
runner: { execute: async (input: { command: string; args?: string[] }) => {
|
||||
calls.push(input.command);
|
||||
const stdout = input.command === "claude" ? JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, output_tokens: 1 } })
|
||||
: input.args?.some(arg => arg.includes("uname -s")) ? "Linux\nx86_64\n" : "";
|
||||
return { exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() };
|
||||
} },
|
||||
} as never,
|
||||
});
|
||||
expect(calls).toContain("claude");
|
||||
expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" }));
|
||||
expect(result.checks.some(check => check.level === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => {
|
||||
const probe = vi.mocked(probeAcpxClaudeInstallation);
|
||||
if (ready) probe.mockResolvedValueOnce(undefined);
|
||||
|
||||
@@ -298,7 +298,7 @@ describe("claude_local environment diagnostics", () => {
|
||||
expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses full permission bypass for sandbox hello probes", async () => {
|
||||
it.each(["sandbox", "computer"] as const)("uses full permission bypass for %s hello probes", async (transport) => {
|
||||
const executeCalls: Array<{ command: string; args?: string[]; env?: Record<string, string> }> = [];
|
||||
|
||||
const result = await testEnvironment({
|
||||
@@ -310,11 +310,12 @@ describe("claude_local environment diagnostics", () => {
|
||||
},
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
transport,
|
||||
resourceAuthority: { computerId: "probe-computer" },
|
||||
providerKey: "cloudflare",
|
||||
remoteCwd: "/workspace/paperclip",
|
||||
runner: {
|
||||
execute: async (input) => {
|
||||
execute: async (input: { command: string; args?: string[]; env?: Record<string, string> }) => {
|
||||
executeCalls.push({ command: input.command, args: input.args, env: input.env });
|
||||
if (input.command === "claude") {
|
||||
return {
|
||||
@@ -345,7 +346,7 @@ describe("claude_local environment diagnostics", () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
environmentName: "QA Cloudflare",
|
||||
});
|
||||
|
||||
|
||||
@@ -432,6 +432,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) {
|
||||
throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`);
|
||||
}
|
||||
if (target.transport === "computer" && profile.acpxAgent === "claude") {
|
||||
const hello = await claudeTestEnvironment({ ...context, adapterType: "claude_local",
|
||||
config: { ...context.config, engine: "cli", model: profile.model,
|
||||
dangerouslySkipPermissions: context.config.acpxPermissionMode === "approve-all" } });
|
||||
return { ...hello, adapterType: "paperclip_runner" };
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const,
|
||||
|
||||
@@ -361,7 +361,11 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
sandboxIdentityCheck: buildSandboxIdentityCheck({
|
||||
sandboxIdentityCheck: environment.driver === "computer" ? {
|
||||
code: "computer_test_identity", level: "info",
|
||||
message: `Environment test ran on "${environment.name}" with a bounded computer owner.`,
|
||||
detail: `paperclipLeaseId=${leaseRecord.lease.id}; ownerId=${leaseRecord.lease.providerLeaseId}`,
|
||||
} : buildSandboxIdentityCheck({
|
||||
environmentName: environment.name,
|
||||
lease: leaseRecord.lease,
|
||||
}),
|
||||
@@ -433,7 +437,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
|
||||
|
||||
// The environment drivers the adapter Test route accepts. A local, SSH, or
|
||||
// sandbox environment can host a probe; a plugin environment cannot.
|
||||
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"];
|
||||
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox", "computer"];
|
||||
|
||||
// The fail-closed tenant-binding guard for the adapter Test route. A caller
|
||||
// may name any instance environment by id, so the route must reject an
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createComputerEnvironmentDriver } from "./computer-environment-driver.js";
|
||||
|
||||
const state = vi.hoisted(() => {
|
||||
const binding = { owner: { computerId: "computer", ownerId: "owner", generation: 1 },
|
||||
listenerPort: 43127, remoteCwd: "/remote/workspace", placementId: "placement", agentHome: "/remote/home" };
|
||||
return { admit: vi.fn(async () => binding), admitProbe: vi.fn(async () => binding),
|
||||
retire: vi.fn(async () => ({ retired: true })), acquireLease: vi.fn(async (input: unknown) => input) };
|
||||
});
|
||||
vi.mock("../modules/computers/index.js", () => ({ computerService: () => state }));
|
||||
vi.mock("./environments.js", () => ({ environmentService: () => state }));
|
||||
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ getExperimental: async () => ({ enableBoatEnvironments: true }) }) }));
|
||||
|
||||
describe("computer environment probe ownership", () => {
|
||||
it("admits a bounded probe without fabricating a heartbeat run", async () => {
|
||||
const db = { update: () => ({ set: () => ({ where: async () => undefined }) }) };
|
||||
const driver = createComputerEnvironmentDriver(db as never);
|
||||
await driver.acquireRunLease({ companyId: "company", agentId: "agent", heartbeatRunId: null,
|
||||
environment: { id: "environment", config: {} }, adapterType: "paperclip_runner" } as never);
|
||||
expect(state.admit).not.toHaveBeenCalled();
|
||||
expect(state.admitProbe).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company", environmentId: "environment", agentId: "agent", probeId: expect.any(String) }));
|
||||
expect(state.acquireLease).toHaveBeenCalledWith(expect.objectContaining({ heartbeatRunId: null, providerLeaseId: "owner", metadata: expect.objectContaining({ driver: "computer", computerOwner: { computerId: "computer", ownerId: "owner", generation: 1 } }) }));
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { environmentLeases } from "@paperclipai/db";
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { EnvironmentLease } from "@paperclipai/shared";
|
||||
import type { AdapterComputerExecutionTarget, EffectiveExecutionCapabilities } from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -45,11 +45,13 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive
|
||||
driver: "computer",
|
||||
async acquireRunLease(input) {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableBoatEnvironments) throw new Error("boat_environments_disabled");
|
||||
if (!input.agentId || !input.heartbeatRunId) throw new Error("computer_agent_run_required");
|
||||
if (!input.agentId) throw new Error("computer_agent_required");
|
||||
const idleTimeoutMs = resolvePaperclipRunnerIdleTimeoutMs(input.environment.config.runnerIdleTimeoutMs);
|
||||
const binding = await computers.admit({ companyId: input.companyId, environmentId: input.environment.id,
|
||||
const binding = input.heartbeatRunId ? await computers.admit({ companyId: input.companyId, environmentId: input.environment.id,
|
||||
agentId: input.agentId, runId: input.heartbeatRunId, idleTimeoutMs,
|
||||
sessionKey: createHash("sha256").update(JSON.stringify([input.agentId, input.executionWorkspaceId ?? input.issueId, input.adapterType])).digest("hex") });
|
||||
sessionKey: createHash("sha256").update(JSON.stringify([input.agentId, input.executionWorkspaceId ?? input.issueId, input.adapterType])).digest("hex") })
|
||||
: await computers.admitProbe({ companyId: input.companyId, environmentId: input.environment.id,
|
||||
agentId: input.agentId, probeId: randomUUID(), idleTimeoutMs });
|
||||
try {
|
||||
// The durable owner advanced generation; prior retained bookkeeping no
|
||||
// longer owns the process and must not be reaped as an allocation.
|
||||
|
||||
Reference in new issue
Block a user