diff --git a/maps/agent-access-map/secrets.md b/maps/agent-access-map/secrets.md index aab1faa0ee7..c1242b56fd1 100644 --- a/maps/agent-access-map/secrets.md +++ b/maps/agent-access-map/secrets.md @@ -4,6 +4,11 @@ Product map: [Secrets, personal credentials, and proposals](../feature-map/secre Feature ID: `secrets`. +The agent **Secrets** page shows inline environment variables alongside company +and user secret references. Inline values stay redacted on configuration reads. +This presentation uses the existing agent configuration APIs and permissions; +it does not grant another agent access to those values. + Source revision: `3bbc2104b65caf9f7763938a01260a5673e12f7a`. Review depth and executed proof are separate. The operation records enumerate the named behavior and access boundaries. Their evidence states describe the reviewed sources; run results and environment limits belong in the PR or verification artifacts. A listed API or source test does not establish a live authorized journey. Follow the [map contract](../README.md). diff --git a/maps/feature-map/secrets.md b/maps/feature-map/secrets.md index 36bb8f13d04..e04c20bb5bd 100644 --- a/maps/feature-map/secrets.md +++ b/maps/feature-map/secrets.md @@ -26,6 +26,12 @@ Agent access: [companion](../agent-access-map/secrets.md). Open company Settings → Secrets and the company/personal secret views. +An agent's **Secrets** page also shows its environment variables, including +masked inline values, company secret references, and user secret references. +Environment bindings remain available from **Harness / Runtime** as well. +Save an added variable and reload to confirm that the existing masked bindings +and their secret references remain present. + ### `proposal-import` Use the Proposals tab or Import from vault where available. diff --git a/tests/e2e/agent-configuration.spec.ts b/tests/e2e/agent-configuration.spec.ts index f0ce7810e32..de4427c6739 100644 --- a/tests/e2e/agent-configuration.spec.ts +++ b/tests/e2e/agent-configuration.spec.ts @@ -78,6 +78,53 @@ async function save(page: Page) { await expect(page.getByRole("alert")).toHaveCount(0); } +test("Secrets shows masked inline variables and preserves them when another variable is saved", async ({ page, request }) => { + const { company, agent, url } = await setup(request); + const secret = await json(await request.post(`/api/companies/${company.id}/secrets`, { data: { + name: "Release credential", value: "disposable-reference-credential", + } })); + await json(await request.patch(`/api/agents/${agent.id}`, { data: { adapterConfig: { + command: "true", + env: { + RELEASE_WEBHOOK: { type: "plain", value: "disposable-inline-credential" }, + SHARED_TOKEN: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + } } })); + + await page.goto(`${url}/secrets`); + const variableNames = () => page.getByRole("textbox", { name: "Variable name", exact: true }) + .evaluateAll(elements => elements.map(element => (element as HTMLInputElement).value).sort()); + await expect(page.getByRole("heading", { name: "Environment variables", exact: true })).toBeVisible(); + await expect.poll(variableNames).toEqual(["RELEASE_WEBHOOK", "SHARED_TOKEN"]); + await expect(page.getByLabel("Variable value", { exact: true })).toHaveValue("***REDACTED***"); + await expect(page.getByText("Release credential", { exact: true }).first()).toBeVisible(); + expect(await page.locator("body").textContent()).not.toContain("disposable-inline-credential"); + const before = await json(await request.get(`/api/agents/${agent.id}/configuration`)); + expect(before.configurationRevision).toMatch(/^[a-f0-9]{64}$/); + + await page.getByRole("button", { name: "Add variable", exact: true }).click(); + await page.getByRole("textbox", { name: "Variable name", exact: true }).last().fill("RELEASE_CHANNEL"); + await page.getByLabel("Variable value", { exact: true }).last().fill("drafts"); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled(); + const config = await json(await request.get(`/api/agents/${agent.id}/configuration`)); + expect(config.adapterConfig.env).toMatchObject({ + RELEASE_WEBHOOK: { type: "plain", value: "***REDACTED***" }, + SHARED_TOKEN: { type: "secret_ref", secretId: secret.id, version: "latest" }, + RELEASE_CHANNEL: { type: "plain", value: "***REDACTED***" }, + }); + await page.reload(); + await expect.poll(variableNames).toEqual(["RELEASE_CHANNEL", "RELEASE_WEBHOOK", "SHARED_TOKEN"]); + await expect(page.getByLabel("Variable value", { exact: true }).first()).toHaveValue("***REDACTED***"); + await page.getByRole("button", { name: "Remove RELEASE_CHANNEL", exact: true }).click(); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled(); + const after = await json(await request.get(`/api/agents/${agent.id}/configuration`)); + // The server's opaque revision includes the actual hidden values. Returning + // to the same revision proves they survived both saves without reading them. + expect(after.configurationRevision).toBe(before.configurationRevision); +}); + test("Advanced YAML persists settings and commands through their real resource APIs", async ({ page, request, toolProvider }, info) => { test.setTimeout(120_000); const { company, agent, url } = await setup(request); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index ff200b73b96..ffc195cca87 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { useState } from "react"; +import { MemoryRouter } from "react-router-dom"; import { createRoot, type Root } from "react-dom/client"; import { flushSync } from "react-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -20,6 +21,8 @@ import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields"; import type { AdapterConfigFieldsProps } from "../adapters/types"; import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; +import { ConfigurationTab } from "../pages/AgentDetail"; +import { ConfigurationTab as ClassicConfigurationTab } from "../pages/AgentDetail.production"; const mockAgentsApi = vi.hoisted(() => ({ get: vi.fn(), @@ -103,9 +106,9 @@ vi.mock("../lib/clipboard", () => ({ vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ - companies: [{ id: "company-1", name: "Paperclip" }], + companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "CO" }], selectedCompanyId: "company-1", - selectedCompany: { id: "company-1", name: "Paperclip" }, + selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "CO" }, selectionSource: "bootstrap", loading: false, error: null, @@ -1480,10 +1483,40 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); expect(result.container.textContent).toContain("Secret access"); - expect(result.container.textContent).toContain("No secrets are bound to this agent yet."); + expect(result.container.textContent).toContain("No company secret references are bound to this agent yet."); expect(result.container.textContent).not.toContain("Environment variables"); }); + it.each([ + { variant: "current", adapterType: "paperclip_runner" }, + { variant: "current", adapterType: "claude_local" }, + { variant: "classic", adapterType: "paperclip_runner" }, + { variant: "classic", adapterType: "claude_local" }, + ])("shows saved $adapterType environment credentials on the $variant agent Secrets page", async ({ variant, adapterType }) => { + mockEnvironmentsApi.list.mockResolvedValue([]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const agent = { ...makeAgent({ adapterType, adapterConfig: { + provider: "acpx", acpxAgent: "claude", + env: { RELEASE_WEBHOOK: { type: "plain", value: "***REDACTED***" } }, + } }), chainOfCommand: [], access: { canAssignTasks: false, taskAssignSource: "none" as const, membership: null, grants: [] } }; + const Settings = variant === "current" ? ConfigurationTab : ClassicConfigurationTab; + await act(async () => renderFormView(root, createFormQueryClient(), + + )); + await flushReact(); + + expect(container.querySelector('input[aria-label="Variable name"]')?.value).toBe("RELEASE_WEBHOOK"); + expect(container.querySelector('input[aria-label="Variable value"]')?.value).toBe("***REDACTED***"); + expect(container.textContent).toContain("Environment variables"); + expect(container.textContent).toContain("Secret access"); + expect(container.textContent).not.toContain("No environment variables"); + }); + it("shows concise Environment copy when one runnable non-local environment exists", async () => { const result = await renderForm(e2bEnvironments()); roots.push(result.root); diff --git a/ui/src/components/AgentSecretAccessEditor.tsx b/ui/src/components/AgentSecretAccessEditor.tsx index 272e02568fb..d5223382e11 100644 --- a/ui/src/components/AgentSecretAccessEditor.tsx +++ b/ui/src/components/AgentSecretAccessEditor.tsx @@ -285,7 +285,7 @@ export function AgentSecretAccessEditor({ ))} ) : ( -

No secrets are bound to this agent yet.

+

No company secret references are bound to this agent yet.

)} {/* Pending binding proposals targeting this agent (PAP-14731). */} diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index 07d349389cd..2bc1ee7838d 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -2083,7 +2083,7 @@ function AgentConfigurePage({ /* ---- Configuration Tab ---- */ -function ConfigurationTab({ +export function ConfigurationTab({ agent, companyId, onDirtyChange, @@ -2209,6 +2209,7 @@ function ConfigurationTab({ hideInstructionsFile={hideInstructionsFile} content={content} sectionLayout="cards" + environmentVariablesPlacement={content === "secrets" ? "secrets" : "configuration"} /> {content === "configuration" ? (

diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 49f53a7af89..0fc95f3d046 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -2135,7 +2135,7 @@ export function ConfigurationTab({ hideInstructionsFile={hideInstructionsFile} content={content === "runtime" ? "configuration" : "secrets"} sectionLayout="cards" - environmentVariablesPlacement="configuration" + environmentVariablesPlacement={content === "secrets" ? "secrets" : "configuration"} compactTestFeedback sectionOrder={["identity", "adapter", "configuration", "environment", "environment-variables", "run-policy"]} sectionTitles={{ adapter: "Adapter", configuration: "Configuration", identity: "Agent identity" }}