From ebaeba40eeac0bff9d746b1308c1a9d16753c9eb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:22:32 -0500 Subject: [PATCH] feat: simplify agent onboarding and configuration (#13011) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators create agents and configure their runtimes in the board UI. > - The old creation flow presents several choices and a large form before an agent can start. > - The existing onboarding controls already provide clear provider connection steps. > - This pull request uses those controls in a new-agent wizard and organizes the full configuration pages. > - Operators can connect, test, save, and assign a first task while keeping the existing configuration tools. ## Linked Issues or Issue Description Related: #10974. That earlier open PR also reorganizes agent configuration. This PR follows the reviewed Storybook designs for agent creation and the current configuration tabs. **What existing behavior does this improve?** Agent creation, provider connection, runtime tests, and full agent configuration. **Current behavior** The creation dialog leads to a large manual configuration form. Provider login controls differ from onboarding. Environment variables and secret access appear in separate places. **Proposed behavior** Choose a name and adapter. Connect Claude or Codex through the existing onboarding controls. Configure and test the runtime, save the agent, and open a task dialog with that agent assigned. Use the same design on the existing configuration tabs. **Reason and benefit** The first setup asks for fewer decisions. The full editor keeps instructions, skills, runtime controls, secret access, permissions, keys, and revisions available in clear sections. **Breaking changes** The board creation and configuration layouts change. The test-environment API adds an optional, allowlisted `testCredentials` field for one-shot probes. Database contracts stay the same. Native ACPX tests now reject unsupported local platforms before a CLI login can mask the runtime restriction. ## What Changed - Added a new-agent wizard with numbered steps, adapter branding, provider connections, editable model choices, runtime tests, and confirmation. - Added Codex app-server, Claude ACPX, and OpenCode runner choices. - Stored API credentials through existing secret APIs and persisted references in agent configuration. New setup keys are isolated from credentials used by existing agents. - Preserved external-agent invitations beside the wizard, including optional messages, one-time prompts, and clipboard fallback. - Added OpenRouter provider and secret bindings for Pi and OpenCode. - Added adapter-specific prerequisite fields for Cursor, Gemini, Kimi, and Hermes. Cursor Cloud keys are saved as new organization secrets. - Fixed Cursor Cloud repository field mapping, omitted empty remote environment values, and added useful model and repository error messages. - Preserved complete MCP assignments when multiple valid profiles contain more than 250 tools in total. Generated profiles retain exact tool selectors. - Added service branding and deployment-aware adapter choices. Cloud setup offers Claude, Codex, and OpenCode; local native runners require the experimental setting. - Made the agent list responsive at intermediate widths. - Applied the reviewed design to the real agent configuration pages. Kept the instruction editor, skills, and existing mutations. - Combined secret access and environment variables under one Save and Discard action. - Added interactive Storybook screens for setup, configuration, confirmation, authentication, and test results. - Fixed Pi provider-error parsing and thinking-effort persistence. Native ACPX validates Linux x64 on the actual local, SSH, or sandbox target. - Redacted the complete transient probe-credential field from HTTP error logs, including rejected provider names. ## Verification - Current head `df0292fe6` has a fresh Greptile 5/5 review with no unresolved findings. All 31 executed CI checks passed, including the aggregate verification gate and all browser E2E shards. Storybook visual regression is skipped by its workflow; the local Storybook build passed. - Browser tests completed real assigned tasks with direct Codex, Claude, OpenCode, Pi, and native Codex. - Verified external-agent invitation generation and automatic prompt copying in the live browser. - Pi and OpenCode used an existing OpenRouter secret. Browser checks covered save and reload, instruction edits, skill selection, environment-variable Save and Discard, and assigned task creation. - Invalid Claude API credentials remained on the connection step with an error. A live Pi/OpenRouter invalid-key probe returned a provider failure and left the user-secret inventory unchanged (zero entries before and after). - Full workspace typecheck and build passed after rebasing onto current master. After review fixes, server and UI typechecks, token gates, and the full build passed again. Storybook built successfully. - All 5,542 local UI tests passed. The Cursor Cloud and Pi adapter regressions passed all 24 tests. Review regressions passed 69 server tests and all 18 agent-list tests. - The local full test command ran 6,971 general server tests successfully. Editing review fixes during that long run caused nine tests to use stale modules; fresh isolated runs passed. An unrelated embedded-Postgres fixture hit the host shared-memory limit; its 15 affected tests passed when the fixture groups ran separately. - Local workspace groups passed after rerunning 18 CLI tests sequentially to avoid host database limits and parallel-load timeouts. The local full command stopped at the general server phase, so serialized server verification comes from the five passing CI shards. - Browser testing at 390px confirmed that the agent action menu opens and the page has no horizontal overflow. CI browser E2E shards passed. - Review the `Onboarding / New agent` and `Agents / Configuration refresh` Storybook groups. In the real app, create an agent, run its connection test, save it, assign a task, and reload its configuration. ## Risks - This changes the main agent setup and configuration UI. Regression tests cover routing, persistence, secret bindings, and form actions. - Native Claude ACPX requires Linux x64. Direct Claude works on macOS. Remote checks execute a bounded platform probe and reject unsupported or unverified targets. - A native OpenCode task reached the provider context limit because of its tool payload. Its provider connection test passed. Direct OpenCode completed a task. This existing native execution limit is not fixed here. - Claude and Codex connection keys use the existing user-secret store. Other runtime setup keys use distinct organization secrets. Existing credentials are never rotated. Probes do not store entered keys. Failed agent creation removes newly staged credentials. - Cursor Cloud has not completed a live task. Its authenticated account still needs GitHub repository access. The live run passed MCP provisioning, remote environment validation, and explicit Auto model selection before the repository prerequisite blocked execution. - Generated runtime MCP profiles can exceed the public profile-edit request limit. They still contain exact catalog selectors and preserve permission boundaries. - No database migration, dependency, lockfile, or workflow changes are included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell execution, and browser automation. The runtime did not expose the exact model ID or context window size. ## 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 --- doc/testing/new-agent-prerequisites.md | 33 + docs/specs/agent-config-ui.md | 19 + .../cursor-cloud/src/server/execute.test.ts | 57 + .../cursor-cloud/src/server/execute.ts | 12 +- .../pi-local/src/server/parse.test.ts | 17 + .../adapters/pi-local/src/server/parse.ts | 14 + packages/shared/src/validators/agent.ts | 16 + server/src/__tests__/adapter-registry.test.ts | 31 +- .../agent-test-environment-routes.test.ts | 50 + .../heartbeat-runtime-mcp-servers.test.ts | 81 ++ .../src/__tests__/http-log-redaction.test.ts | 28 + server/src/adapters/registry.test.ts | 81 +- server/src/adapters/registry.ts | 43 + server/src/middleware/redact-sensitive.ts | 3 + server/src/routes/agents.ts | 13 + server/src/services/heartbeat.ts | 9 +- ui/public/brands/adapters/LICENSE | 21 + ui/public/brands/adapters/README.md | 7 + ui/public/brands/adapters/cursor-dark.svg | 1 + ui/public/brands/adapters/cursor.svg | 1 + ui/public/brands/adapters/gemini-color.svg | 1 + ui/public/brands/adapters/grok-dark.svg | 1 + ui/public/brands/adapters/grok.svg | 1 + .../brands/adapters/hermesagent-dark.svg | 1 + ui/public/brands/adapters/hermesagent.svg | 1 + .../brands/adapters/kimi-color-light.svg | 1 + ui/public/brands/adapters/kimi-color.svg | 1 + ui/public/brands/adapters/pi-dark.svg | 1 + ui/public/brands/adapters/pi.svg | 1 + ui/src/App.tsx | 9 +- ui/src/api/agents.ts | 1 + ui/src/components/AgentActionButtons.tsx | 6 +- .../AgentConfigForm.render.test.tsx | 55 + ui/src/components/AgentConfigForm.tsx | 236 ++-- ui/src/components/AgentContextualSidebar.tsx | 4 +- ui/src/components/Layout.test.tsx | 5 +- ui/src/components/Layout.tsx | 2 +- ui/src/components/NewAgentDialog.test.tsx | 451 +++---- ui/src/components/NewAgentDialog.tsx | 431 +------ ui/src/components/RuntimeTestCard.tsx | 157 +++ .../environment-variables-editor/index.tsx | 5 +- .../new-agent/AgentBasicsDialog.tsx | 303 +++++ .../new-agent/AgentProviderConnection.tsx | 245 ++++ .../new-agent/ExternalAgentInviteDialog.tsx | 87 ++ ui/src/components/new-agent/NewAgentSetup.tsx | 1149 +++++++++++++++++ ui/src/index.css | 57 + ui/src/lib/agent-setup-fields.ts | 55 + ui/src/lib/new-agent-adapters.ts | 17 + ui/src/lib/provider-credential.ts | 68 + ui/src/lib/test-agent-setup.test.ts | 79 ++ ui/src/lib/test-agent-setup.ts | 61 + ui/src/pages/AgentDetail.tsx | 181 +-- ui/src/pages/Agents.test.tsx | 30 +- ui/src/pages/Agents.tsx | 72 +- ui/src/pages/NewAgent.test.tsx | 851 ++++++------ ui/src/pages/NewAgent.tsx | 443 +------ ui/storybook/.storybook/tailwind-entry.css | 1 + ui/storybook/prototypes/NewAgentWizard.tsx | 370 ++++++ .../prototypes/ProviderConnectionPreview.tsx | 80 ++ ui/storybook/prototypes/README.md | 117 ++ ui/storybook/prototypes/RuntimeTestCard.tsx | 1 + .../agent-settings/AgentSettingsPreview.tsx | 479 +++++++ .../prototypes/agent-settings/fixtures.ts | 487 +++++++ .../prototypes/agent-settings/settings.css | 1 + ui/storybook/prototypes/new-agent-fixtures.ts | 63 + .../agent-settings-redesign.stories.tsx | 86 ++ .../stories/new-agent-onboarding.stories.tsx | 73 ++ .../stories/onboarding-agent-arc.stories.tsx | 48 + 68 files changed, 5626 insertions(+), 1786 deletions(-) create mode 100644 doc/testing/new-agent-prerequisites.md create mode 100644 ui/public/brands/adapters/LICENSE create mode 100644 ui/public/brands/adapters/README.md create mode 100644 ui/public/brands/adapters/cursor-dark.svg create mode 100644 ui/public/brands/adapters/cursor.svg create mode 100644 ui/public/brands/adapters/gemini-color.svg create mode 100644 ui/public/brands/adapters/grok-dark.svg create mode 100644 ui/public/brands/adapters/grok.svg create mode 100644 ui/public/brands/adapters/hermesagent-dark.svg create mode 100644 ui/public/brands/adapters/hermesagent.svg create mode 100644 ui/public/brands/adapters/kimi-color-light.svg create mode 100644 ui/public/brands/adapters/kimi-color.svg create mode 100644 ui/public/brands/adapters/pi-dark.svg create mode 100644 ui/public/brands/adapters/pi.svg create mode 100644 ui/src/components/RuntimeTestCard.tsx create mode 100644 ui/src/components/new-agent/AgentBasicsDialog.tsx create mode 100644 ui/src/components/new-agent/AgentProviderConnection.tsx create mode 100644 ui/src/components/new-agent/ExternalAgentInviteDialog.tsx create mode 100644 ui/src/components/new-agent/NewAgentSetup.tsx create mode 100644 ui/src/lib/agent-setup-fields.ts create mode 100644 ui/src/lib/new-agent-adapters.ts create mode 100644 ui/src/lib/provider-credential.ts create mode 100644 ui/src/lib/test-agent-setup.test.ts create mode 100644 ui/src/lib/test-agent-setup.ts create mode 100644 ui/storybook/prototypes/NewAgentWizard.tsx create mode 100644 ui/storybook/prototypes/ProviderConnectionPreview.tsx create mode 100644 ui/storybook/prototypes/README.md create mode 100644 ui/storybook/prototypes/RuntimeTestCard.tsx create mode 100644 ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx create mode 100644 ui/storybook/prototypes/agent-settings/fixtures.ts create mode 100644 ui/storybook/prototypes/agent-settings/settings.css create mode 100644 ui/storybook/prototypes/new-agent-fixtures.ts create mode 100644 ui/storybook/stories/agent-settings-redesign.stories.tsx create mode 100644 ui/storybook/stories/new-agent-onboarding.stories.tsx 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. -

-
-
- -