diff --git a/doc/testing/new-agent-prerequisites.md b/doc/testing/new-agent-prerequisites.md new file mode 100644 index 0000000000..b01286df92 --- /dev/null +++ b/doc/testing/new-agent-prerequisites.md @@ -0,0 +1,33 @@ +# New-agent setup prerequisites + +Reviewed against the adapter builders, runtime probes, and provider documentation on 2026-09-08. + +| Adapter | Setup connection/prerequisites | Model and effort in setup | +| --- | --- | --- | +| Claude Code / Codex | Existing subscription/API connection step | Searchable model; supported effort options | +| Paperclip Runner | The selected Codex, Claude ACPX, or OpenCode connection | Provider model; no generic effort setting | +| Cursor CLI | `CURSOR_API_KEY`, existing organization secret, or host `agent login` | Model; no generic effort (Cursor uses modes) | +| Cursor Cloud | Enter a new `CURSOR_API_KEY` and repository URL; optional starting branch/ref. The key is saved as a new organization secret; setup does not reuse existing keys. | Account-default model; no generic effort | +| Gemini CLI | `GEMINI_API_KEY`, existing organization secret, or supported host login | Model; no effort control | +| Kimi Code | Host login, or `KIMI_MODEL_API_KEY` plus `KIMI_MODEL_NAME`; optional protocol/base URL | Existing model alias for host login; API model name for API auth; no effort on default ACP lane | +| Grok Build | Host `grok login` | Model and reasoning effort | +| Hermes | Provider API key or existing host configuration | Model; keep optional CLI tuning in full configuration | +| Hermes Gateway | API base URL and `API_SERVER_KEY` | Model is controlled by the gateway; no effort control | +| OpenCode / Pi | Provider/model ID and optional provider key/organization secret | Model; Pi thinking levels; OpenCode-specific variants remain in full configuration | + +## Contracts and regression coverage + +- Cursor Cloud sends `repoUrl` and `repoStartingRef`, matching its SDK adapter. It never sends the old, ignored `repository`/`branch` properties. +- New runtime keys are stored as distinct organization secrets when setup completes. They do not rotate existing keys. Existing Claude/Codex connection flows retain their user-specific credential behavior. +- A draft key goes only into the allowlisted `testCredentials` request field during testing. It does not pass through persistence normalization. Hermes Gateway's one-shot key maps to its top-level `apiKey` only after normalization. +- Failed tests and abandoned forms do not create secrets. A failed hire removes the newly created secret; cleanup errors remain visible. +- Kimi API mode omits `config.model`: passing `--model` would override the model synthesized from `KIMI_MODEL_*` variables. +- `ui/src/pages/NewAgent.test.tsx` covers field visibility, request payloads, secret reuse/storage, failure cleanup, and Kimi/Hermes mappings. `server/src/__tests__/agent-test-environment-routes.test.ts` verifies transient credential handling and rejects arbitrary environment variables. + +UI and mocked contract tests do not prove a provider account can run a task. Successful live authentication requires the user's credential and, for Cursor Cloud, a repository connected to that Cursor account. The live invalid-key browser check verifies that a populated repository no longer produces the missing-repository diagnostic. + +## Provider references + +- [Cursor Cloud API](https://prod.cursor.com/docs/cloud-agent/api/endpoints) and [source-control prerequisites](https://prod.cursor.com/docs/cloud-agent). +- [Kimi environment-defined models](https://www.kimi.com/code/docs/en/kimi-code-cli/configuration/env-vars.html). +- [Grok CLI sign-in](https://docs.x.ai/build/cli/reference). diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md index 9e962b8801..90d3753767 100644 --- a/docs/specs/agent-config-ui.md +++ b/docs/specs/agent-config-ui.md @@ -1,5 +1,19 @@ # Agent Configuration & Activity UI +## Current implementation (2026-09-07) + +The shipped new-agent flow starts from **Agents → New Agent**. A small dialog collects a name and an enabled adapter, then opens a setup page with numbered navigation. Claude and Codex have a subscription/API-key connection step. Configuration provides a searchable, free-text model selector and an environment selector; additional settings remain on the full agent page. **Finish setup** submits the existing governed hire request. Confirmation links to configuration and opens a new task with the created agent assigned; agents awaiting approval cannot be assigned work yet. + +Paperclip Runner offers native Codex (app server), Claude via ACPX, and OpenCode. The selected provider is passed through the existing runner configuration builder. Codex's default is the adapter catalog default. OpenCode and Pi require a provider/model ID; OpenRouter uses `openrouter//` and `OPENROUTER_API_KEY`. Entered keys are tested through the probe-only `testCredentials` field without storage. Finishing setup saves each key as an isolated user secret so a failed test cannot overwrite another agent’s credential, and an existing organization secret can also be bound. Agent configuration and revisions contain references, never the entered key. + +**Run test** uses the chosen environment and current configuration. For Claude/Codex readiness checks that do not make a model request, setup also invokes the adapter's existing CLI hello probe with the same environment and credentials. Runtime failures, provider failures, warnings, and in-progress tests use the same compact result card in setup and full configuration. Warnings remain distinguishable from blocking failures. + +The new-agent dialog also retains an external-agent invitation link, with an optional message, one-time onboarding prompt, and clipboard fallback. External agents still require organization-admin approval. + +The full configuration page retains the contextual navigation and existing instruction-file editor, skills, tools, permissions, API keys, and revision behavior. **Secrets & variables** groups environment bindings with API secret-access grants. Its page-level Save/Discard actions include editor-local environment drafts. Activity, runs, costs, and budgets link to the company audit views scoped to the agent. + +The sections below are the original design reference; the implementation summary above supersedes their creation-dialog layout. + ## Context Agents are the employees of a Paperclip company. Each agent has an adapter type (`claude_local`, `codex_local`, `process`, `http`) that determines how it runs, a position in the org chart (who it reports to), a heartbeat policy (how/when it wakes up), and a budget. The UI at `/agents` needs to support creating and configuring agents, viewing their org hierarchy, and inspecting what they've been doing -- their run history, live logs, and accumulated costs. @@ -284,3 +298,8 @@ All endpoints already exist. No new server work needed for V1. 9. **Properties panel updates** -- session ID, last error Steps 1-5 are the core. Steps 6-9 are polish. + +Native ACPX connection tests reject unsupported local platforms before a host CLI +login can incorrectly mark the runner connected. The existing verified Claude +ACPX runtime requires Linux x64; remote environments are evaluated independently +of the control-plane host platform. diff --git a/packages/adapters/cursor-cloud/src/server/execute.test.ts b/packages/adapters/cursor-cloud/src/server/execute.test.ts index 1486302a38..4d0ba57501 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.test.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.test.ts @@ -207,6 +207,33 @@ describe("cursor_cloud execute", () => { ); }); + it("omits empty environment values while preserving nonempty values exactly", async () => { + createMock.mockResolvedValue(createMockSdkAgent()); + const ctx = createContext(); + ctx.config.env = { + CURSOR_API_KEY: "cursor-secret", + GH_TOKEN: "", + GITHUB_TOKEN: "", + GIT_AUTHOR_NAME: "", + SSH_AUTH_SOCK: "", + EMPTY_PLAIN: { type: "plain", value: "" }, + EXTRA_FLAG: "0", + PADDED_VALUE: " retain whitespace ", + }; + + await execute(ctx); + + const env = createMock.mock.calls[0]?.[0]?.cloud?.envVars; + expect(env).not.toHaveProperty("GH_TOKEN"); + expect(env).not.toHaveProperty("GITHUB_TOKEN"); + expect(env).not.toHaveProperty("GIT_AUTHOR_NAME"); + expect(env).not.toHaveProperty("SSH_AUTH_SOCK"); + expect(env).not.toHaveProperty("EMPTY_PLAIN"); + expect(env).not.toHaveProperty("CURSOR_API_KEY"); + expect(env).toMatchObject({ EXTRA_FLAG: "0", PADDED_VALUE: " retain whitespace " }); + expect(Object.values(env).every((value) => value !== "")).toBe(true); + }); + it("reports dispatch before starting the first remote SDK operation", async () => { const run = createMockRun({ agentId: "agent-dispatch" }); const sdkAgent = createMockSdkAgent({ agentId: "agent-dispatch", sendRun: run }); @@ -354,6 +381,36 @@ describe("cursor_cloud execute", () => { }); }); + it("explains a rejected Cursor default without silently choosing another model", async () => { + const sdkAgent = createMockSdkAgent(); + sdkAgent.send.mockRejectedValue(new Error("[invalid_model] Model 'gpt-5' is not available or invalid.")); + createMock.mockResolvedValue(sdkAgent); + const ctx = createContext(); + delete ctx.config.model; + + const result = await execute(ctx); + + expect(createMock.mock.calls[0]?.[0]).not.toHaveProperty("model"); + expect(sdkAgent.send).toHaveBeenCalledWith(expect.any(String), {}); + expect(result.exitCode).toBe(1); + expect(result.errorMessage).toContain("Cursor rejected its configured default model"); + expect(result.errorMessage).toContain("https://cursor.com/dashboard/cloud-agents"); + expect(sdkAgent.send).toHaveBeenCalledTimes(1); + }); + + it("makes Cursor repository setup failures actionable without launching another run", async () => { + const sdkAgent = createMockSdkAgent(); + sdkAgent.send.mockRejectedValue(new Error("[validation_error] Failed to determine repository default branch")); + createMock.mockResolvedValue(sdkAgent); + + const result = await execute(createContext()); + + expect(result.exitCode).toBe(1); + expect(result.errorMessage).toContain("Cursor's GitHub integration can access https://github.com/paperclipai/paperclip.git"); + expect(result.errorMessage).toContain("https://cursor.com/dashboard/cloud-agents"); + expect(sdkAgent.send).toHaveBeenCalledTimes(1); + }); + it("maps non-finished Cursor results to failing Paperclip runs", async () => { const cancelledRun = createMockRun({ id: "run-cancelled", diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index 95a9fc045d..b3300e50b6 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -168,7 +168,10 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record value.length > 0)); } async function buildInstructionsPrefix( @@ -591,7 +594,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(isPiUnknownSessionError("working fine", "no errors")).toBe(false); }); }); + + +describe("terminal provider failures", () => { + it("surfaces and deduplicates errors from Pi assistant messages", () => { + const message = { role: "assistant", content: [], stopReason: "error", errorMessage: "400 Context limit exceeded" }; + const parsed = parsePiJsonl([ + { type: "message_end", message }, + { type: "turn_end", message }, + { type: "agent_end", messages: [message] }, + ].map(event => JSON.stringify(event)).join("\n")); + expect(parsed.errors).toEqual(["400 Context limit exceeded"]); + }); + it("reports an error even when the provider omitted its message", () => { + expect(parsePiJsonl(JSON.stringify({ type: "turn_end", message: { role: "assistant", stopReason: "error" } })).errors) + .toEqual(["Pi provider request failed."]); + }); +}); diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts index 5ab0d4cd9a..b4d769e159 100644 --- a/packages/adapters/pi-local/src/server/parse.ts +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -54,6 +54,20 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { const eventType = asString(event.type, ""); + // Pi can exit successfully after a provider failure. The terminal assistant + // message carries that failure in both message_end and turn_end envelopes. + const terminalMessages = eventType === "agent_end" + ? (Array.isArray(event.messages) ? event.messages : []) + : eventType === "message_end" || eventType === "turn_end" + ? [event.message] + : []; + for (const rawMessage of terminalMessages) { + const message = asRecord(rawMessage); + if (message?.role !== "assistant" || message.stopReason !== "error") continue; + const error = asString(message.errorMessage, "").trim() || "Pi provider request failed."; + if (!result.errors.includes(error)) result.errors.push(error); + } + // RPC protocol messages - skip these (internal implementation detail) if (eventType === "response" || eventType === "extension_ui_request" || eventType === "extension_ui_response" || eventType === "extension_error") { continue; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 4a9416d14a..6904034557 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -228,6 +228,22 @@ export const resetAgentSessionSchema = z.object({ export type ResetAgentSession = z.infer; export const testAdapterEnvironmentSchema = z.object({ + /** One-shot provider keys for a probe. Never persist these in agent config. */ + testCredentials: z.object({ + ANTHROPIC_API_KEY: z.string().max(16384), + OPENAI_API_KEY: z.string().max(16384), + OPENROUTER_API_KEY: z.string().max(16384), + GEMINI_API_KEY: z.string().max(16384), + XAI_API_KEY: z.string().max(16384), + GROQ_API_KEY: z.string().max(16384), + OPENCODE_API_KEY: z.string().max(16384), + CURSOR_API_KEY: z.string().max(16384), + KIMI_MODEL_API_KEY: z.string().max(16384), + API_SERVER_KEY: z.string().max(16384), + ZAI_API_KEY: z.string().max(16384), + KIMI_API_KEY: z.string().max(16384), + MINIMAX_API_KEY: z.string().max(16384), + }).partial().strict().optional(), adapterConfig: adapterConfigSchema.optional().default({}), /** * Optional environment to run the adapter test inside. When omitted, the diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index e47f818f3d..c4f830c2b6 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -277,11 +277,18 @@ describe("server adapter registry", () => { it.each([ ["claude", "claude-sonnet-5"], ["codex", "gpt-5.6-sol"], - ] as const)("accepts the qualified ACPX %s environment profile", async (acpxAgent, model) => { + ] as const)("accepts the qualified remote ACPX %s environment profile", async (acpxAgent, model) => { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", config: { provider: "acpx", acpxAgent, model }, + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: "/workspace", + providerKey: "test-provider", + runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) }, + }, }); expect(result).toMatchObject({ @@ -291,6 +298,28 @@ describe("server adapter registry", () => { }); }); + it.each([ + ["linux", "x64", "pass", "acpx_profile_qualified"], + ["darwin", "arm64", "fail", "acpx_runtime_platform_unsupported"], + ["linux", "arm64", "fail", "acpx_runtime_platform_unsupported"], + ])("checks local ACPX support on %s %s", async (platform, arch, status, code) => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + const archDescriptor = Object.getOwnPropertyDescriptor(process, "arch")!; + try { + Object.defineProperty(process, "platform", { ...platformDescriptor, value: platform }); + Object.defineProperty(process, "arch", { ...archDescriptor, value: arch }); + const result = await requireServerAdapter("paperclip_runner").testEnvironment({ + companyId: "company-1", + adapterType: "paperclip_runner", + config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, + }); + expect(result).toMatchObject({ status, checks: [expect.objectContaining({ code })] }); + } finally { + Object.defineProperty(process, "platform", platformDescriptor); + Object.defineProperty(process, "arch", archDescriptor); + } + }); + it("keeps the ACPX Pi profile unavailable", async () => { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 699785dc11..4a4fa125ac 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -189,6 +189,56 @@ describe("agent test-environment route", () => { await unregisterTestAdapter("external_test"); }); + it.each(["CURSOR_API_KEY", "KIMI_MODEL_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY"])("accepts %s as a probe-only credential", async (key) => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/external_test/test-environment") + .send({ testCredentials: { [key]: "probe-only-key" } }); + expect(res.status).toBe(200); + expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({}); + expect(testEnvironmentSpy.mock.calls[0]?.[0].config.env).toEqual({ [key]: "probe-only-key" }); + }); + + it("maps the Hermes gateway probe key without passing it to persistence", async () => { + const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js"); + const previous = getServerAdapter("hermes_gateway"); + unregisterServerAdapter("hermes_gateway"); + registerServerAdapter({ ...externalAdapter, type: "hermes_gateway" }); + try { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/hermes_gateway/test-environment") + .send({ adapterConfig: { apiBaseUrl: "https://hermes.example.com" }, testCredentials: { API_SERVER_KEY: "gateway-probe-key" } }); + expect(res.status).toBe(200); + expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({ apiBaseUrl: "https://hermes.example.com" }); + expect(testEnvironmentSpy.mock.calls[0]?.[0].config.apiKey).toBe("gateway-probe-key"); + expect(JSON.stringify(res.body)).not.toContain("gateway-probe-key"); + } finally { + unregisterServerAdapter("hermes_gateway"); + if (previous) registerServerAdapter(previous); + } + }); + + it("passes one-shot provider credentials only to the probe, never persistence normalization", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/external_test/test-environment") + .send({ adapterConfig: { env: { KEEP: "value" } }, testCredentials: { OPENROUTER_API_KEY: "probe-only-key" } }); + expect(res.status).toBe(200); + expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({ env: { KEEP: "value" } }); + expect(testEnvironmentSpy.mock.calls[0]?.[0].config.env).toEqual({ KEEP: "value", OPENROUTER_API_KEY: "probe-only-key" }); + expect(JSON.stringify(res.body)).not.toContain("probe-only-key"); + }); + + it("rejects non-provider variables in one-shot credentials", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/external_test/test-environment") + .send({ testCredentials: { NODE_OPTIONS: "--require unsafe" } }); + expect(res.status).toBe(400); + expect(testEnvironmentSpy).not.toHaveBeenCalled(); + }); + it("does not fall back to a host probe when a requested environment cannot produce an execution target", async () => { const app = await createApp(); diff --git a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts index b0aad06888..3d1de80d61 100644 --- a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts +++ b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts @@ -13,6 +13,7 @@ import { toolApplications, toolConnectionInstalls, toolConnections, + toolCatalogEntries, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, @@ -25,6 +26,8 @@ import { } from "./helpers/embedded-postgres.js"; import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js"; +import { toolAccessService } from "../services/tool-access.js"; + const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -203,6 +206,84 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { ).resolves.toBeNull(); }); + it("preserves exact permissions when an aggregate assignment exceeds the public 250-entry edit limit", async () => { + process.env.PAPERCLIP_API_URL = "https://paperclip.example.test"; + const [company] = await db.insert(companies).values({ + name: "Large MCP assignment", + issuePrefix: `LM${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning(); + const [agent, gatewayReader] = await db.insert(agents).values([ + { companyId: company!.id, name: "Cursor Cloud", role: "engineer", adapterType: "cursor_cloud" }, + { companyId: company!.id, name: "Gateway reader", role: "engineer", adapterType: "cursor_cloud" }, + ]).returning(); + const [application] = await db.insert(toolApplications).values({ + companyId: company!.id, applicationKey: "large-mcp", name: "Large MCP", type: "mcp_http", + }).returning(); + const [connection] = await db.insert(toolConnections).values({ + companyId: company!.id, applicationId: application!.id, + name: "Large MCP", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, + config: { url: "https://large.example.test/mcp" }, + }).returning(); + const catalogInput = (name: string) => ({ + companyId: company!.id, applicationId: application!.id, connectionId: connection!.id, + name, toolName: name, versionHash: "fixture", status: "active" as const, + }); + const catalog = await db.insert(toolCatalogEntries).values([ + ...Array.from({ length: 251 }, (_, index) => catalogInput(`allowed_${index}`)), + catalogInput("excluded"), catalogInput("unassigned"), + ]).returning(); + const allowed = catalog.slice(0, 251); + const excluded = catalog[251]!; + // Multiple valid profiles can each have fewer than 250 entries while their + // union exceeds the HTTP edit-request limit. + const profiles = await db.insert(toolProfiles).values(["first", "second"].map((key) => ({ + companyId: company!.id, profileKey: key, name: key, defaultAction: "deny" as const, + }))).returning(); + await db.insert(toolProfileEntries).values([ + ...[...allowed, excluded].map((tool, index) => ({ + companyId: company!.id, profileId: profiles[index < 200 ? 0 : 1]!.id, + selectorType: "catalog_entry" as const, effect: "include" as const, + applicationId: application!.id, connectionId: connection!.id, catalogEntryId: tool.id, + })), + { + companyId: company!.id, profileId: profiles[1]!.id, + selectorType: "catalog_entry" as const, effect: "exclude" as const, + applicationId: application!.id, connectionId: connection!.id, catalogEntryId: excluded.id, + }, + ]); + await db.insert(toolProfileBindings).values(profiles.map((profile) => ({ + companyId: company!.id, profileId: profile.id, targetType: "agent" as const, targetId: agent!.id, + }))); + await db.insert(toolConnectionInstalls).values({ + companyId: company!.id, connectionId: connection!.id, targetType: "agent", targetId: agent!.id, + }); + + const servers = await buildPaperclipRuntimeMcpServers({ db, agent: agent!, runId: randomUUID() }); + expect(servers).toHaveLength(1); + const [gateway] = await db.select().from(toolMcpGateways); + const generatedEntries = await db.select().from(toolProfileEntries) + .where(eq(toolProfileEntries.profileId, gateway!.profileId!)); + expect(generatedEntries).toHaveLength(251); + expect(generatedEntries.every((entry) => entry.selectorType === "catalog_entry")).toBe(true); + expect(generatedEntries.map((entry) => entry.catalogEntryId).sort()).toEqual(allowed.map((tool) => tool.id).sort()); + + // Evaluate the generated profile independently of the original assignments, + // including a tool discovered after the immutable profile was created. + await db.insert(toolCatalogEntries).values(catalogInput("new_after_snapshot")); + await db.insert(toolProfileBindings).values({ + companyId: company!.id, profileId: gateway!.profileId!, targetType: "agent", targetId: gatewayReader!.id, + }); + const effective = await toolAccessService(db).getEffectiveProfilesForAgent(company!.id, gatewayReader!.id); + expect(effective.allowedTools.map((tool) => tool.id).sort()).toEqual(allowed.map((tool) => tool.id).sort()); + expect(effective.allowedToolNames).not.toContain("excluded"); + expect(effective.allowedToolNames).not.toContain("unassigned"); + expect(effective.allowedToolNames).not.toContain("new_after_snapshot"); + + const reused = await buildPaperclipRuntimeMcpServers({ db, agent: agent!, runId: randomUUID() }); + expect(reused[0]!.connectionId).toBe(servers[0]!.connectionId); + expect(await db.select().from(toolMcpGateways)).toHaveLength(1); + }); + it("exposes only the dedicated GitHub connection when a personal connection is also installed", async () => { process.env.PAPERCLIP_API_URL = "https://paperclip.example.test"; const [company] = await db.insert(companies).values({ diff --git a/server/src/__tests__/http-log-redaction.test.ts b/server/src/__tests__/http-log-redaction.test.ts index 9028ba3746..e86b6210b2 100644 --- a/server/src/__tests__/http-log-redaction.test.ts +++ b/server/src/__tests__/http-log-redaction.test.ts @@ -6,6 +6,7 @@ import { pinoHttp } from "pino-http"; import request from "supertest"; import { describe, expect, it } from "vitest"; import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js"; +import { testAdapterEnvironmentSchema } from "@paperclipai/shared"; import { createHttpLogger } from "../middleware/logger.js"; describe("HTTP logger redaction", () => { @@ -165,4 +166,31 @@ describe("HTTP logger redaction", () => { metadata: { token: "[REDACTED]" }, }); }); + + it.each([400, 500])("redacts the complete probe credential container on HTTP %s", async (status) => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const app = express(); + app.use(express.json()); + app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream))); + app.post("/probe", (req, res) => { + if (status === 500) { + (res as any).__errorContext = { error: { message: "probe failed" }, reqBody: req.body }; + } + res.status(status).json({ error: "probe failed" }); + }); + const keys = Object.keys(testAdapterEnvironmentSchema.shape.testCredentials.unwrap().shape); + const credentials = Object.fromEntries([...keys, "UNKNOWN_PROVIDER_KEY"].map((key) => [key, `canary-${key}`])); + await request(app).post("/probe").send({ adapterConfig: { model: "default" }, testCredentials: credentials }); + const output = chunks.join(""); + expect(output).not.toContain("canary-"); + expect(JSON.parse(output.trim()).reqBody).toEqual({ + adapterConfig: { model: "default" }, testCredentials: "[REDACTED]", + }); + }); }); diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index a5c8037629..85a8c23e7b 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils"; import { listServerAdapters, requireServerAdapter } from "./registry.js"; +import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; // The registry registers a login capability for the two built-in interactive @@ -79,3 +80,81 @@ describe("built-in runtime connection tool delivery", () => { expect(requireServerAdapter(type).runtimeToolDelivery).toBe(strategy); }); }); + + +describe("native ACPX environment checks", () => { + afterEach(() => vi.restoreAllMocks()); + + const context = { + companyId: "company-test", + adapterType: "paperclip_runner", + config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, + }; + + it("reports unsupported local platforms before a successful CLI login can mask them", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); + expect(result.status).toBe("fail"); + expect(result.checks).toEqual([expect.objectContaining({ + code: "acpx_runtime_platform_unsupported", + level: "error", + })]); + }); + + it("keeps the qualified Linux x64 profile available", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); + expect(result.status).toBe("pass"); + }); + + it("does not use the host platform to reject a remote environment", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ + ...context, + executionTarget: { + kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "test", + runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) }, + }, + }); + expect(result.status).toBe("pass"); + }); + + const sshTarget = { + kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/workspace", + spec: { + host: "example.test", port: 22, username: "tester", remoteCwd: "/workspace", + remoteWorkspacePath: "/workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true, + }, + }; + + it.each([ + ["Linux\nx86_64\n", "pass"], + ["Darwin\nx86_64\n", "fail"], + ["Linux\naarch64\n", "fail"], + ["", "fail"], + ])("qualifies the SSH platform from its own uname output %j", async (stdout, status) => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + const probe = vi.spyOn(executionTarget, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ + exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date(0).toISOString(), + }); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, executionTarget: sshTarget }); + expect(result.status).toBe(status); + expect(probe).toHaveBeenCalledWith(expect.any(String), sshTarget, "uname -s && uname -m", { + cwd: "/workspace", env: {}, timeoutSec: 15, + }); + }); + + it.each(["timeout", "exit", "exception"])("does not qualify an SSH target after a probe %s", async (failure) => { + const probe = vi.spyOn(executionTarget, "runAdapterExecutionTargetShellCommand"); + if (failure === "exception") probe.mockRejectedValue(new Error("connection unavailable")); + else probe.mockResolvedValue({ + exitCode: failure === "exit" ? 1 : 0, timedOut: failure === "timeout", + stdout: "Linux\nx86_64\n", stderr: "", signal: null, pid: null, startedAt: new Date(0).toISOString(), + }); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, executionTarget: sshTarget }); + expect(result.status).toBe("fail"); + expect(result.checks[0].code).toBe("acpx_runtime_platform_unverified"); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 86791b6933..c926b227cc 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -7,6 +7,7 @@ import { PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, } from "@paperclipai/adapter-utils"; import type { AdapterLoginCapability } from "@paperclipai/adapter-utils"; +import { runAdapterExecutionTargetShellCommand } from "@paperclipai/adapter-utils/execution-target"; import { execute as claudeExecute, listClaudeSkills, @@ -403,6 +404,48 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } if (profile.provider === "acpx") { + // The pinned ACPX executables are qualified for Linux x64. A host CLI + // login probe can succeed on macOS even though runner admission cannot. + let supported = process.platform === "linux" && process.arch === "x64"; + const target = context.executionTarget; + if (target?.kind === "remote") { + try { + const probe = await runAdapterExecutionTargetShellCommand( + `acpx-platform-${crypto.randomUUID()}`, + target, + "uname -s && uname -m", + { cwd: target.remoteCwd, env: {}, timeoutSec: 15 }, + ); + if (probe.timedOut || probe.exitCode !== 0) throw new Error("Platform probe failed"); + const [os, arch] = probe.stdout.trim().split(/\s+/); + supported = os === "Linux" && arch === "x86_64"; + } catch { + return { + adapterType: "paperclip_runner", + status: "fail" as const, + testedAt: new Date().toISOString(), + checks: [{ + code: "acpx_runtime_platform_unverified", + level: "error" as const, + message: "Could not verify the remote ACPX runner platform.", + hint: "Check the environment connection and retry. The native ACPX runner requires Linux x64.", + }], + }; + } + } + if (!supported) { + return { + adapterType: "paperclip_runner", + status: "fail" as const, + testedAt: new Date().toISOString(), + checks: [{ + code: "acpx_runtime_platform_unsupported", + level: "error" as const, + message: `The native ACPX ${profile.acpxAgent} runner requires a Linux x64 environment.`, + hint: `Select a Linux x64 environment, or use the regular ${profile.acpxAgent === "claude" ? "Claude Code" : "Codex"} adapter on this machine.`, + }], + }; + } return { adapterType: "paperclip_runner", status: "pass" as const, diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index 3af3d4ee9d..ce483c74aa 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -37,6 +37,9 @@ const SENSITIVE_KEYS = new Set([ "idtoken", "api_key", "apikey", + // Probe keys are transient, including rejected or future provider names. + // Redact the whole container instead of maintaining a second key allowlist. + "testcredentials", "authorization", "auth_token", "authtoken", diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7586991550..6b2c7c0235 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3128,6 +3128,19 @@ export function agentRoutes( return; } + // Probe-only credentials bypass storage, not authorization. The schema + // allows only provider key names; they never enter persisted config. + if (req.body.testCredentials) { + effectiveAdapterConfig = { + ...effectiveAdapterConfig, + env: { ...parseObject(effectiveAdapterConfig.env), ...req.body.testCredentials }, + }; + // Hermes authenticates the gateway itself through a top-level field. + // Keep its draft key out of persistence normalization, like env keys. + if (type === "hermes_gateway" && req.body.testCredentials.API_SERVER_KEY) { + effectiveAdapterConfig.apiKey = req.body.testCredentials.API_SERVER_KEY; + } + } const result = await adapter.testEnvironment({ companyId, adapterType: type, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ca370ddda5..1f82a00793 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4291,11 +4291,10 @@ export async function buildPaperclipRuntimeMcpServers(input: { catalogEntryId: tool.id, })), ]; - if (entries.length > 250) { - throw new Error( - "native MCP assignment exceeds the 250-entry gateway profile limit", - ); - } + // The 250-entry limit bounds a public profile-edit request, not the + // effective assignment assembled from existing profiles. Keep every exact + // selector here: truncating or replacing them with connection-wide grants + // would either lose assigned tools or authorize tools outside this snapshot. try { const created = await access.createProfile(input.agent.companyId, { profileKey, diff --git a/ui/public/brands/adapters/LICENSE b/ui/public/brands/adapters/LICENSE new file mode 100644 index 0000000000..1dd53d2a9d --- /dev/null +++ b/ui/public/brands/adapters/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/public/brands/adapters/README.md b/ui/public/brands/adapters/README.md new file mode 100644 index 0000000000..3349c611d4 --- /dev/null +++ b/ui/public/brands/adapters/README.md @@ -0,0 +1,7 @@ +# Adapter brand icons + +Source: [Lobe Icons](https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons), pinned to commit `a94750e3f5f8fc33757b839d85030e742284e43a`. + +Gemini and Kimi use the color variants. Cursor, Grok, Hermes Agent, and Pi Agent use their monochrome brand marks. Dark variants replace `currentColor` with white for use as standalone images; the Kimi light variant changes its white letterform to black. All other artwork is unchanged. Pi is the coding agent at pi.dev. + +Artwork is distributed under the accompanying MIT license. Brand names and marks belong to their respective owners. diff --git a/ui/public/brands/adapters/cursor-dark.svg b/ui/public/brands/adapters/cursor-dark.svg new file mode 100644 index 0000000000..4b6f0ed522 --- /dev/null +++ b/ui/public/brands/adapters/cursor-dark.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/ui/public/brands/adapters/cursor.svg b/ui/public/brands/adapters/cursor.svg new file mode 100644 index 0000000000..a5b2ee3b1e --- /dev/null +++ b/ui/public/brands/adapters/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/ui/public/brands/adapters/gemini-color.svg b/ui/public/brands/adapters/gemini-color.svg new file mode 100644 index 0000000000..62681dfa7c --- /dev/null +++ b/ui/public/brands/adapters/gemini-color.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/ui/public/brands/adapters/grok-dark.svg b/ui/public/brands/adapters/grok-dark.svg new file mode 100644 index 0000000000..cb1537fd2d --- /dev/null +++ b/ui/public/brands/adapters/grok-dark.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/ui/public/brands/adapters/grok.svg b/ui/public/brands/adapters/grok.svg new file mode 100644 index 0000000000..efb1a6183c --- /dev/null +++ b/ui/public/brands/adapters/grok.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/ui/public/brands/adapters/hermesagent-dark.svg b/ui/public/brands/adapters/hermesagent-dark.svg new file mode 100644 index 0000000000..d94036a3fc --- /dev/null +++ b/ui/public/brands/adapters/hermesagent-dark.svg @@ -0,0 +1 @@ +Hermes Agent \ No newline at end of file diff --git a/ui/public/brands/adapters/hermesagent.svg b/ui/public/brands/adapters/hermesagent.svg new file mode 100644 index 0000000000..2521a105c1 --- /dev/null +++ b/ui/public/brands/adapters/hermesagent.svg @@ -0,0 +1 @@ +Hermes Agent \ No newline at end of file diff --git a/ui/public/brands/adapters/kimi-color-light.svg b/ui/public/brands/adapters/kimi-color-light.svg new file mode 100644 index 0000000000..60719d5cee --- /dev/null +++ b/ui/public/brands/adapters/kimi-color-light.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/ui/public/brands/adapters/kimi-color.svg b/ui/public/brands/adapters/kimi-color.svg new file mode 100644 index 0000000000..83878fa284 --- /dev/null +++ b/ui/public/brands/adapters/kimi-color.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/ui/public/brands/adapters/pi-dark.svg b/ui/public/brands/adapters/pi-dark.svg new file mode 100644 index 0000000000..7ad4ed1c49 --- /dev/null +++ b/ui/public/brands/adapters/pi-dark.svg @@ -0,0 +1 @@ +Pi \ No newline at end of file diff --git a/ui/public/brands/adapters/pi.svg b/ui/public/brands/adapters/pi.svg new file mode 100644 index 0000000000..5122fb5e3e --- /dev/null +++ b/ui/public/brands/adapters/pi.svg @@ -0,0 +1 @@ +Pi \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e5ab3a3062..ed46baf3b3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -114,9 +114,6 @@ const CompanyExport = lazy(() => const ProductionAgents = lazy(() => import("./pages/Agents.production").then((module) => ({ default: module.Agents })), ); -const ProductionAgentDetail = lazy(() => - import("./pages/AgentDetail.production").then((module) => ({ default: module.AgentDetail })), -); const ProductionRoutines = lazy(() => import("./pages/Routines.production").then((module) => ({ default: module.Routines })), ); @@ -267,9 +264,9 @@ function boardRoutes(streamlinedUiEnabled: boolean) { /> ))} } /> - : } /> - : } /> - : } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 5f7cc46cf9..f53e9c0528 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -221,6 +221,7 @@ export const agentsApi = { type: string, data: { adapterConfig: Record; + testCredentials?: Record; environmentId?: string | null; }, ) => diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index f5ea136e60..6ed44aaeb1 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -162,6 +162,7 @@ export function AgentActionButtons({ assignLabel = "Assign Task", runLabel = "Run now", showStatus = true, + showRun = true, actionsDisabled = false, workActionsDisabled = false, workActionsDisabledReason, @@ -182,6 +183,7 @@ export function AgentActionButtons({ assignLabel?: string; runLabel?: string; showStatus?: boolean; + showRun?: boolean; actionsDisabled?: boolean; workActionsDisabled?: boolean; workActionsDisabledReason?: string; @@ -398,7 +400,7 @@ export function AgentActionButtons({ {assignLabel} - { if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return; agentAction.mutate("invoke"); @@ -406,7 +408,7 @@ export function AgentActionButtons({ disabled={assignAndRunDisabled} label={runLabel} size={size} - /> + />} {canRunWithProviderTrace && ( - - -
- {mode === "choices" ? ( - <> - {/* Recommendation */} -
-
- -
-

- Ask a leader to propose the hire, configure a runtime yourself, - or send an onboarding prompt to an external agent. -

-
- - - -
- -
- -

- (OpenClaw, Hermes, or any agent that can call the invite API.) -

-
-
- - ) : mode === "runtime" ? ( - <> -
- -

- Choose the runtime Paperclip should start or resume directly. -

-
- -
- {adapterGrid.map((opt) => ( - - ))} -
- - ) : mode === "invite" ? ( -
-
- -
-

Invite an external agent

-

- Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key. -

-
-
- -