mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/175/changes/paperclip/175 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators configure each agent through several settings tabs. > - Operators also need one complete, editable configuration document. > - The document must retain each resource service's access and validation rules. > - This pull request defines the `paperclip/v1` Agent YAML format and adds an Advanced editor. > - The editor previews changes, rejects stale core writes, and explains partial saves. > - Operators can edit agent settings in one place and verify the saved state after reload. ## Linked Issues or Issue Description Refs #2219. Related settings work: #10974. **Problem or motivation** Agent configuration spans profile, runtime, instructions, skills, tools, channels, permissions, secrets, keys, and revision history. Operators cannot inspect and edit those settings as one versioned YAML document. **Proposed solution** Add an Advanced agent tab with a YAML editor. Define a shared document schema. Read and write resources through their existing APIs. Separate editable settings, explicit commands, and read-only observed state. **Alternatives considered** A raw agent-row editor would omit related resources. A generic database update would bypass resource ownership. The editor therefore uses the existing resource contracts and records partial results when a later request fails. **Roadmap alignment** This extends the shipped agent configuration capability. It relates to easy AGENTS.md configuration, multi-harness teams, governed tool access, and secrets management. Stored credentials remain redacted. This format is an editable settings view; it does not add company portability or provider consent. ## What Changed - Add the `paperclip/v1` Agent document schema and format documentation. - Add the Advanced route, sidebar entry, YAML editor, validation, change summary, save progress, and reload recovery. - Cover core settings, instruction files, skill pins, tool installs/profiles/policies, channels, permissions, secret operations, API keys, and revision restore. - Add private-keyed configuration revisions checked under the agent write lock. Read the current key through its owner so file-based rotation does not require restarting the server. Preserve unchanged secret markers at their original paths. - Retain drafts and returned credentials after partial saves. Require reload before retrying commands. Prevent duplicate submissions. Keep new key tokens visible through rename and disable saves with an empty change plan. - Send only changed channel settings. Require explicit intent for shared profile edits. Load destination instruction files when changing their location. - Update Storybook fixtures, verification maps, and regression tests. Add browser acceptance through real APIs and PostgreSQL. ## Verification - `pnpm -r typecheck` passes. - `pnpm build` passes. - The focused UI suite passes all 50 tests. The API/revision helper suites pass all 93 tests. - All 65 lifecycle tests pass with a dedicated PostgreSQL database. They exercise real row locks, redaction, revision history, audit, and company access. - All six browser acceptance cases pass. They check saved settings and clears, secret redaction, key creation/revocation, stale drafts, lost-response recovery, instruction relocation, revision restore, and key-token retention through rename. The tests use real resource APIs and storage. The MCP target uses the existing local provider fixture. No model run or provider login is claimed. - Signing-key regressions cover both environment changes and real in-place key-file rotation. The protected database save/fencing cases pass after removing the cache. A local measurement of 1,000 file-backed revision calculations took 48 ms. - `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and Storybook catalog checks pass. - The full [PR CI run](https://github.com/paperclipai/paperclip/actions/runs/40725425470) passes on `34ed97bbf9`: all 48 jobs, including all eight browser shards. Greptile is 5/5 on this commit, with no unresolved review threads. This final commit refreshes CI after a runner-routing fallback; its source tree matches `fb4cf488cf`. - Paperclip Review and Superagent Security Scan ended neutral because their external agents did not complete a valid assessment/receipt. Superagent passed on `fb4cf488cf`, which has the identical source tree. One request to retry the final scan returned HTTP 404. Neither check reports a code finding. - The local full-suite attempt was blocked by host System V semaphore exhaustion. Acceptance and lifecycle verification used an isolated PostgreSQL 18 container. Earlier CI runners were interrupted. A local combined run hit hook/test timeouts; the full 88-test route file passed after the builds finished. The final full CI run passes. Earlier failure logs and browser traces remain retained outside the repository. To run the browser regression: ```sh pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/agent-configuration.spec.ts ``` ## Risks - Multi-resource saves are sequential. Earlier operations can remain saved if a later operation fails. The editor reports the completed steps and requires reload. - Core settings and managed instruction files have atomic revision/hash checks. Other unversioned resource writes and external supporting files retain a race window after preflight. - Shared profile definitions affect other bindings. The editor requires `updateSharedDefinition: true` for those edits. - The editor follows native service normalization and experimental settings. Canonical reload can add generated access profiles or mask environment values. - New editor dependencies require the repository's normal automated lockfile update. This pull request does not edit the lockfile or add a database migration. ## Model Used OpenAI Codex, GPT-6 family. The exact current model version was not exposed. The work used reasoning, repository tools, code execution, and browser automation. Earlier work in this chat used the configured Codex model before a model switch; its exact model ID and context window were not exposed. The current context window size is also not exposed. ## 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 <noreply@paperclip.ing>
242 lines
15 KiB
TypeScript
242 lines
15 KiB
TypeScript
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expect, test as base, type APIRequestContext, type APIResponse, type Page } from "@playwright/test";
|
|
import { parse, stringify } from "yaml";
|
|
import type { AgentConfigurationDocument } from "@paperclipai/shared";
|
|
import { startReviewProvider } from "../fixtures/connection-review-provider";
|
|
|
|
const test = base.extend<{ toolProvider: Awaited<ReturnType<typeof startReviewProvider>>; settings: void }>({
|
|
settings: [async ({ request }, use) => {
|
|
const previous = await json(await request.get("/api/instance/settings/experimental"));
|
|
try {
|
|
await json(await request.patch("/api/instance/settings/experimental", { data: { enableBetaSkills: true, enableChatConnectors: true } }));
|
|
await use();
|
|
} finally {
|
|
await json(await request.patch("/api/instance/settings/experimental", { data: {
|
|
enableBetaSkills: previous.enableBetaSkills, enableChatConnectors: previous.enableChatConnectors,
|
|
} }));
|
|
}
|
|
}, { auto: true }],
|
|
toolProvider: async ({}, use) => {
|
|
const provider = await startReviewProvider();
|
|
try { await use(provider); }
|
|
finally { await provider.close(); }
|
|
},
|
|
});
|
|
|
|
test.use({ permissions: ["clipboard-read", "clipboard-write"] });
|
|
|
|
async function json(response: APIResponse) {
|
|
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
|
|
return response.json();
|
|
}
|
|
|
|
async function setup(request: APIRequestContext) {
|
|
const company = await json(await request.post("/api/companies", { data: { name: `Agent YAML ${Date.now()}` } }));
|
|
const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
|
|
name: "YAML engineer", role: "engineer", adapterType: "process", adapterConfig: { command: "true", removable: "old value" },
|
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } },
|
|
} }));
|
|
return { company, agent, url: `/${company.issuePrefix}/agents/${agent.urlKey}` };
|
|
}
|
|
|
|
const editor = (page: Page) => page.getByRole("textbox", { name: "Agent YAML configuration" });
|
|
async function document(page: Page): Promise<AgentConfigurationDocument> {
|
|
await expect(editor(page)).toBeVisible();
|
|
await expect(editor(page)).toHaveAttribute("contenteditable", "true");
|
|
await editor(page).press("ControlOrMeta+a");
|
|
await editor(page).press("ControlOrMeta+c");
|
|
const source = await page.evaluate(() => navigator.clipboard.readText());
|
|
expect(source).toContain("apiVersion: paperclip/v1");
|
|
return parse(source);
|
|
}
|
|
async function edit(page: Page, change: (value: AgentConfigurationDocument) => void) {
|
|
const value = await document(page);
|
|
change(value);
|
|
await page.evaluate(source => navigator.clipboard.writeText(source), stringify(value, { lineWidth: 0 }));
|
|
await editor(page).press("ControlOrMeta+v");
|
|
}
|
|
async function save(page: Page) {
|
|
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
|
await expect(page.getByText("Unsaved changes", { exact: true })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Reload saved", exact: true })).toBeEnabled();
|
|
await expect(page.getByRole("alert")).toHaveCount(0);
|
|
}
|
|
|
|
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);
|
|
await json(await request.patch(`/api/agents/${agent.id}/instructions-bundle`, { data: { mode: "managed" } }));
|
|
const skill = await json(await request.post(`/api/companies/${company.id}/skills`, { data: {
|
|
name: "YAML proof", slug: "yaml-proof", markdown: "---\nname: yaml-proof\ndescription: Verify saved configuration.\n---\nCheck the persisted state.\n",
|
|
} }));
|
|
const connection = await json(await request.post(`/api/companies/${company.id}/tools/connections`, { data: {
|
|
name: "YAML tools", transport: "mcp_remote", config: { url: toolProvider.url }, status: "draft", enabled: false,
|
|
} }));
|
|
await page.goto(`${url}/overview`);
|
|
await page.getByRole("link", { name: "Advanced", exact: true }).click();
|
|
await edit(page, value => {
|
|
value.spec.title = "Saved YAML engineer";
|
|
value.spec.budgetMonthlyCents = 12300;
|
|
delete value.spec.adapterConfig.removable;
|
|
value.spec.adapterConfig.env = { YAML_VARIABLE: { type: "plain", value: "saved variable" } };
|
|
value.spec.instructions!.files["workflow.md"] = "Saved through YAML.\n";
|
|
value.spec.skills = [{ key: skill.key, versionId: skill.currentVersionId }];
|
|
value.spec.tools!.connections.find(row => row.connectionId === connection.id)!.installed = true;
|
|
value.spec.tools!.profiles = [{ profileId: null, priority: 100, metadata: null, configuration: {
|
|
profileKey: "yaml-profile", name: "YAML profile", description: null, status: "active", defaultAction: "deny", metadata: null,
|
|
entries: [{ selectorType: "tool_name", toolName: "read_repository", effect: "include" }],
|
|
} }];
|
|
value.spec.tools!.policies = [{ id: null, configuration: { name: "YAML block", policyType: "block", priority: 100, enabled: true, selectors: { agentIds: [agent.id], toolNames: ["delete_repository"] } } }];
|
|
value.spec.permissions!.canCreateSkills = false;
|
|
value.spec.secrets!.create = [{ secret: { name: "YAML secret", value: "disposable-yaml-secret" }, bind: { env: ["YAML_SECRET"], api: [] } }];
|
|
value.spec.apiKeys!.create = [{ name: "YAML automation", scope: { kind: "standard" } }];
|
|
value.spec.channelOperations!.create = [{ provider: "telegram" }];
|
|
});
|
|
await save(page);
|
|
const token = page.getByRole("textbox", { name: "API key: YAML automation token" });
|
|
await expect(token).toBeVisible();
|
|
expect(await token.inputValue()).toMatch(/^pcp_/);
|
|
await page.reload();
|
|
const saved = await document(page);
|
|
expect(saved.spec).toMatchObject({ title: "Saved YAML engineer", budgetMonthlyCents: 12300, permissions: { canCreateSkills: false } });
|
|
expect(saved.spec.adapterConfig).not.toHaveProperty("removable");
|
|
expect(saved.spec.adapterConfig.env).toMatchObject({ YAML_VARIABLE: { type: "plain", value: "***REDACTED***" }, YAML_SECRET: { type: "secret_ref" } });
|
|
expect(stringify(saved)).not.toContain("disposable-yaml-secret");
|
|
expect(saved.spec.instructions!.files["workflow.md"]).toBe("Saved through YAML.\n");
|
|
expect(saved.spec.skills).toEqual([{ key: skill.key, versionId: skill.currentVersionId }]);
|
|
expect(saved.spec.tools!.profiles).toEqual(expect.arrayContaining([expect.objectContaining({ profileId: expect.any(String), configuration: expect.objectContaining({ name: "YAML profile", entries: [expect.objectContaining({ toolName: "read_repository" })] }) })]));
|
|
expect(saved.spec.tools!.policies).toEqual([expect.objectContaining({ id: expect.any(String), configuration: expect.objectContaining({ name: "YAML block" }) })]);
|
|
expect(saved.spec.channels).toEqual([expect.objectContaining({ provider: "telegram" })]);
|
|
expect(saved.spec.apiKeys!.create).toEqual([]);
|
|
expect(saved.spec.secrets!.create).toEqual([]);
|
|
expect(saved.spec.channelOperations!.create).toEqual([]);
|
|
expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).adapterConfig.env.YAML_SECRET.type).toBe("secret_ref");
|
|
expect((await json(await request.get(`/api/tool-connections/${connection.id}/installs`))).installs).toContainEqual(expect.objectContaining({ targetType: "agent", targetId: agent.id }));
|
|
const key = (await json(await request.get(`/api/agents/${agent.id}/keys`))).find((row: { name: string }) => row.name === "YAML automation");
|
|
await edit(page, value => {
|
|
value.spec.apiKeys!.revoke = [key.id];
|
|
value.spec.channels![0].settings.allowUnlinkedPeople = true;
|
|
delete value.spec.instructions!.files["workflow.md"];
|
|
value.spec.tools!.profiles = [];
|
|
value.spec.tools!.policies = [];
|
|
value.spec.tools!.connections.find(row => row.connectionId === connection.id)!.installed = false;
|
|
value.spec.skills = [];
|
|
});
|
|
await save(page);
|
|
await page.reload();
|
|
const cleared = await document(page);
|
|
expect(cleared.spec.instructions!.files).not.toHaveProperty("workflow.md");
|
|
expect(cleared.spec.skills).toEqual([]);
|
|
expect(cleared.spec.tools!.profiles).toEqual([]);
|
|
expect(cleared.spec.tools!.policies).toEqual([]);
|
|
expect(cleared.spec.channels![0].settings.allowUnlinkedPeople).toBe(true);
|
|
expect(cleared.spec.channels![0].settings).not.toHaveProperty("communicationInstructions");
|
|
expect((await json(await request.get(`/api/agents/${agent.id}/keys`))).find((row: { id: string }) => row.id === key.id).revokedAt).not.toBeNull();
|
|
await page.screenshot({ path: info.outputPath("saved-agent-yaml.png") });
|
|
});
|
|
|
|
test("stale drafts and invalid commands leave saved configuration unchanged", async ({ page, request }) => {
|
|
const { agent, url } = await setup(request);
|
|
await page.goto(`${url}/advanced`);
|
|
await edit(page, value => { value.spec.title = "Local draft"; });
|
|
await json(await request.patch(`/api/agents/${agent.id}`, { data: { title: "Other operator" } }));
|
|
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
|
await expect(page.getByRole("alert")).toContainText("changed while you were editing");
|
|
expect((await document(page)).spec.title).toBe("Local draft");
|
|
expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).title).toBe("Other operator");
|
|
await page.getByRole("button", { name: "Reload saved", exact: true }).click();
|
|
await expect.poll(async () => (await document(page)).spec.title).toBe("Other operator");
|
|
await edit(page, value => { value.observed = {}; });
|
|
await expect(page.getByRole("alert")).toContainText("observed is read-only");
|
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
|
});
|
|
|
|
test("renaming an agent keeps newly created key tokens visible through the route change", async ({ page, request }) => {
|
|
const { agent, url } = await setup(request);
|
|
await page.goto(`${url}/advanced`);
|
|
await edit(page, value => {
|
|
value.spec.name = "Renamed YAML engineer";
|
|
value.spec.apiKeys!.create = [{ name: "Rename key", scope: { kind: "standard" } }];
|
|
});
|
|
await save(page);
|
|
await expect(page).toHaveURL(/\/agents\/renamed-yaml-engineer\/advanced$/);
|
|
const token = page.getByRole("textbox", { name: "API key: Rename key token" });
|
|
await expect(token).toBeVisible();
|
|
const created = await token.inputValue();
|
|
expect(created).toMatch(/^pcp_/);
|
|
expect(stringify(await document(page))).not.toContain(created);
|
|
expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(1);
|
|
await edit(page, value => { value.spec.title = "Another saved edit"; });
|
|
await save(page);
|
|
await expect(token).toHaveValue(created);
|
|
});
|
|
|
|
test("a lost creation response requires reload and cannot blindly replay commands", async ({ page, request }) => {
|
|
const { agent, url } = await setup(request);
|
|
let creates = 0;
|
|
await page.route(`**/api/agents/${agent.id}/keys?*`, async route => {
|
|
if (route.request().method() !== "POST") return route.continue();
|
|
creates++;
|
|
if (creates === 1) return route.continue();
|
|
await route.fetch();
|
|
return route.abort("failed");
|
|
});
|
|
await page.goto(`${url}/advanced`);
|
|
await edit(page, value => {
|
|
value.spec.name = "Partially renamed YAML engineer";
|
|
value.spec.title = "Saved before lost response";
|
|
value.spec.apiKeys!.create = ["First key", "Second key"].map(name => ({ name, scope: { kind: "standard" } }));
|
|
});
|
|
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
|
await expect(page.getByRole("alert")).toContainText("Reload saved before saving again");
|
|
await expect(page.getByRole("textbox", { name: "API key: First key token" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
|
expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).title).toBe("Saved before lost response");
|
|
expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(2);
|
|
expect((await document(page)).spec.apiKeys!.create).toHaveLength(2);
|
|
await page.getByRole("button", { name: "Reload saved", exact: true }).click();
|
|
await expect.poll(async () => (await document(page)).spec.apiKeys!.create).toEqual([]);
|
|
await expect(page).toHaveURL(/\/agents\/partially-renamed-yaml-engineer\/advanced$/);
|
|
await expect(page.getByRole("textbox", { name: "API key: First key token" })).toBeVisible();
|
|
await edit(page, value => { value.spec.title = "Recovered save"; });
|
|
await save(page);
|
|
expect(creates).toBe(2);
|
|
expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(2);
|
|
});
|
|
|
|
test("switching instructions location loads existing destination files without overwriting them", async ({ page, request }) => {
|
|
const { agent, url } = await setup(request);
|
|
await json(await request.patch(`/api/agents/${agent.id}/instructions-bundle`, { data: { mode: "managed" } }));
|
|
const destination = await mkdtemp(path.join(os.tmpdir(), "agent-yaml-instructions-"));
|
|
await writeFile(path.join(destination, "AGENTS.md"), "Keep destination instructions.\n");
|
|
await page.goto(`${url}/advanced`);
|
|
await edit(page, value => { value.spec.instructions!.mode = "external"; value.spec.instructions!.rootPath = destination; });
|
|
await save(page);
|
|
expect((await document(page)).spec.instructions!.files["AGENTS.md"]).toBe("Keep destination instructions.\n");
|
|
expect(await readFile(path.join(destination, "AGENTS.md"), "utf8")).toBe("Keep destination instructions.\n");
|
|
});
|
|
|
|
test("revision restore is an explicit separate save and reloads the restored configuration", async ({ page, request }) => {
|
|
const { agent, url } = await setup(request);
|
|
await page.goto(`${url}/advanced`);
|
|
await edit(page, value => { value.spec.title = "Before restore"; });
|
|
await save(page);
|
|
const revisions = await json(await request.get(`/api/agents/${agent.id}/config-revisions`));
|
|
const revision = revisions.find((row: { changedKeys: string[] }) => row.changedKeys.includes("title"));
|
|
expect(revision).toBeTruthy();
|
|
await edit(page, value => { value.spec.title = "After restore target"; });
|
|
await save(page);
|
|
await edit(page, value => { value.spec.revisions!.restore = revision.id; value.spec.title = "Conflicting edit"; });
|
|
await expect(page.getByRole("alert")).toContainText("Restore a revision in its own save");
|
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
|
await edit(page, value => { value.spec.title = "After restore target"; });
|
|
await save(page);
|
|
await page.reload();
|
|
const restored = await document(page);
|
|
expect(restored.spec.title).toBe("Before restore");
|
|
expect(restored.spec.revisions!.restore).toBeNull();
|
|
});
|