diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 2aefa947fc..a6abb2605e 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -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)}`; diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 10cbcb8d39..d5b5a1076d 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -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) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 341079db14..b917d045b3 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -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); diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 055e66d9ea..50f2247d60 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -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 }> = []; 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 }) => { 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", }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 54b72aa3bd..c4fb1b1c63 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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, diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index ae26b719ba..5543a4bb83 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -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 diff --git a/server/src/services/computer-environment-driver.test.ts b/server/src/services/computer-environment-driver.test.ts new file mode 100644 index 0000000000..f3c3dd6cd2 --- /dev/null +++ b/server/src/services/computer-environment-driver.test.ts @@ -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 } }) })); + }); +}); diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index 1a1fea64e7..fba7139f1b 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -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.