diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 20f874a17ae..8621466701f 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1804,6 +1804,13 @@ overrides, scoped credentials, managed AI identity, and runtime skill delivery. The executor receives credential ownership and failure-redaction updates as soon as they are available, so later setup failures still clean up acquired resources. Busy-account deferral returns explicitly before session/workspace preparation. + +On Cloud-managed sandbox connections, an ACPX platform check can defer runtime +preparation to the normal Runner launch. After the selected account completes +its provider hello probe or live API-key check, that specific deferred check is +informational. Authentication failures, unsupported platforms, other warnings, +and self-hosted or SSH runtime checks retain their status. A passed connection +test does not replace Runner package verification before execution. Public-path tests are in `server/src/__tests__/heartbeat-execution-configuration.test.ts`. Concurrent skill preparations join the company inventory refresh before taking diff --git a/server/src/services/agent-environment-test-status.test.ts b/server/src/services/agent-environment-test-status.test.ts new file mode 100644 index 00000000000..1290fae263d --- /dev/null +++ b/server/src/services/agent-environment-test-status.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as executionTargets from "@paperclipai/adapter-utils/execution-target"; +import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils"; +import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; +import type { Db } from "@paperclipai/db"; + +const { runtimeProbe, claudeProbe, verifyKey, markAuthFailed } = vi.hoisted(() => ({ + runtimeProbe: vi.fn(), claudeProbe: vi.fn(), verifyKey: vi.fn(), markAuthFailed: vi.fn(), +})); +vi.mock("../adapters/index.js", () => ({ requireServerAdapter: (type: string) => ({ testEnvironment: type === "paperclip_runner" ? runtimeProbe : claudeProbe }) })); +vi.mock("./ai-api-key-test.js", () => ({ validateAiApiKey: verifyKey })); +vi.mock("./ai-connections.js", async original => ({ ...await original(), aiConnectionService: () => ({ markAuthenticationFailed: markAuthFailed }) })); +import { agentEnvironmentTestService } from "./agent-environment-test.js"; + +const checkedAt = new Date(0).toISOString(); +function readiness(): AdapterEnvironmentTestResult { + return { adapterType: "paperclip_runner", status: "warn", testedAt: checkedAt, checks: [{ + code: "acpx_remote_runtime_unverified", level: "warn", message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch.", + }] }; +} +const context: AdapterEnvironmentTestContext = { companyId: "company-1", adapterType: "paperclip_runner", environmentName: "Paperclip Computer", + config: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", env: { CLAUDE_CODE_OAUTH_TOKEN: "fixture-token" }, managedAiConnection: { method: "subscription" } }, + executionTarget: { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", leaseId: "lease-1", remoteCwd: "/workspace", runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "", signal: null }) } }, +}; +async function testConnection(overrides: Partial = {}) { + const service = agentEnvironmentTestService({} as Db); + return service.testManagedEnvironment("paperclip_runner", { ...context, ...overrides }, + { provider: "anthropic", method: "subscription", mode: "responsible_user" }, + { identity: "fixture-identity", attribution: { provider: "anthropic", connectionId: "connection-1", grantId: "grant-1" } } as Parameters[3]); +} +afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); vi.restoreAllMocks(); }); +function verifiedClaude() { + runtimeProbe.mockResolvedValue(readiness()); + claudeProbe.mockResolvedValue({ adapterType: "claude_local", status: "pass", testedAt: checkedAt, + checks: [{ code: "claude_hello_probe_passed", level: "info", message: "Claude hello probe succeeded." }] }); +} + +describe("managed Runner connection test status", () => { + it("passes a healthy Cloud Claude connection without asking the user to verify launch-time runtime preparation", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); + verifiedClaude(); + const result = await testConnection(); + expect(result.status).toBe("pass"); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed" })); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "acpx_remote_runtime_managed", level: "info" })); + expect(claudeProbe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: context.executionTarget, config: expect.objectContaining({ engine: "cli" }) })); + }); + it("keeps remote runtime readiness warnings for self-hosted connections", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", ""); vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + verifiedClaude(); + const result = await testConnection(); + expect(result.status).toBe("warn"); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "acpx_remote_runtime_unverified", level: "warn" })); + }); + it("does not turn a failed hello into a successful Cloud connection", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + claudeProbe.mockResolvedValue({ adapterType: "claude_local", status: "fail", testedAt: checkedAt, + checks: [{ code: "claude_hello_probe_auth_required", level: "error", message: "Authentication required" }] }); + expect((await testConnection()).status).toBe("fail"); + expect(markAuthFailed).toHaveBeenCalledTimes(1); + }); + it("does not promote a runtime error before the provider probe", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + runtimeProbe.mockResolvedValue({ adapterType: "paperclip_runner", status: "fail", testedAt: checkedAt, + checks: [{ code: "acpx_runtime_package_missing", level: "error", message: "Runtime missing" }] }); + expect((await testConnection()).status).toBe("fail"); + expect(claudeProbe).not.toHaveBeenCalled(); + }); + it("does not promote a missing hello receipt", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + claudeProbe.mockResolvedValue({ adapterType: "claude_local", status: "pass", testedAt: checkedAt, checks: [] }); + const result = await testConnection(); + expect(result.status).toBe("fail"); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "ai_connection_validation_incomplete" })); + }); + it("passes a managed API-key connection only after provider re-verification", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + const result = await testConnection({ config: { ...context.config, env: { ANTHROPIC_API_KEY: "fixture-key" }, managedAiConnection: { method: "api_key" } } }); + expect(result.status).toBe("pass"); + expect(verifyKey).toHaveBeenCalledExactlyOnceWith("anthropic", "fixture-key"); + expect(claudeProbe).not.toHaveBeenCalled(); + }); + it("keeps a rejected API key failed", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + verifyKey.mockRejectedValueOnce(new Error("Rejected key")); + expect((await testConnection({ config: { ...context.config, env: { ANTHROPIC_API_KEY: "fixture-key" }, managedAiConnection: { method: "api_key" } } })).status).toBe("fail"); + }); + it("keeps SSH runtime warnings even on a Cloud instance", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + vi.spyOn(executionTargets, "runAdapterExecutionTargetProcess").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null }); + const result = await testConnection({ executionTarget: { kind: "remote", transport: "ssh", remoteCwd: "/workspace", spec: { host: "example.test", port: 22, username: "test", remoteCwd: "/workspace", remoteWorkspacePath: "/workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true } } }); + expect(result.status).toBe("warn"); + }); + it("never promotes an unexplained warning without the exact deferred check", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + runtimeProbe.mockResolvedValue({ adapterType: "paperclip_runner", status: "warn", testedAt: checkedAt, checks: [] }); + expect((await testConnection()).status).toBe("warn"); + }); + it("preserves an unexplained provider warning even with a hello receipt", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + claudeProbe.mockResolvedValue({ adapterType: "claude_local", status: "warn", testedAt: checkedAt, + checks: [{ code: "claude_hello_probe_passed", level: "info", message: "Hello completed with an unresolved warning." }] }); + expect((await testConnection()).status).toBe("warn"); + }); + it("keeps actionable warnings after a successful hello", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "managed-fixture"); verifiedClaude(); + const result = readiness(); result.checks.push({ code: "provider_configuration_warning", level: "warn", message: "Check the selected provider configuration." }); runtimeProbe.mockResolvedValue(result); + expect((await testConnection()).status).toBe("warn"); + }); +}); diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index 59063b8b90e..95abd33d244 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -5,11 +5,12 @@ import { randomUUID } from "node:crypto"; import { agents, companies, type Db } from "@paperclipai/db"; import { and, eq } from "drizzle-orm"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; -import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; +import type { AdapterEnvironmentCheck, AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils"; import { parseObject } from "@paperclipai/adapter-utils/server-utils"; import { aiRoutingHarness, ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, type AiConnectionBinding } from "@paperclipai/shared"; import { requireServerAdapter } from "../adapters/index.js"; import { forbidden, HttpError, unprocessable } from "../errors.js"; +import { isCloudManagedInstance } from "./cloud-instance.js"; import { instanceSettingsService } from "./instance-settings.js"; import { environmentService } from "./environments.js"; import { environmentRuntimeService } from "./environment-runtime.js"; @@ -22,6 +23,19 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js"; const asRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +function finishManagedConnectionTest(adapterType: string, context: AdapterEnvironmentTestContext, result: AdapterEnvironmentTestResult): AdapterEnvironmentTestResult { + const target = context.executionTarget; + if (result.status === "fail" || adapterType !== "paperclip_runner" || context.config.provider !== "acpx" + || !isCloudManagedInstance() || target?.kind !== "remote" || target.transport !== "sandbox" || target.runnerTask + || !result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code) || check.code === "ai_connection_api_key_reverified") + || !result.checks.some(check => check.code === "acpx_remote_runtime_unverified" && check.level === "warn")) return result; + const checks = result.checks.map(check => check.code === "acpx_remote_runtime_unverified" && check.level === "warn" + ? { ...check, code: "acpx_remote_runtime_managed", level: "info" as const, + message: "The remote platform is supported. Paperclip manages Runner preparation and verifies its runtime before execution." } + : check); + return { ...result, checks, status: checks.some(check => check.level === "error") ? "fail" : checks.some(check => check.level === "warn") ? "warn" : "pass" }; +} + export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: PluginWorkerManager) { const instanceSettings = instanceSettingsService(db); const environmentsSvc = environmentService(db); @@ -591,11 +605,13 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected"; result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." }); } - return result; + return finishManagedConnectionTest(adapterType, context, result); } + let providerTestPassed = false; if (!result.checks.some(check => check.code.includes("hello_probe"))) { const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider]; const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } }); + providerTestPassed = probe.status === "pass"; result.checks.push(...probe.checks); result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass"; } @@ -603,7 +619,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin result.status = "fail"; result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." }); } - return result; + return providerTestPassed ? finishManagedConnectionTest(adapterType, context, result) : result; }