mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip lets people manage AI agents and govern their access to outside services. > - Apps connections keep provider credentials in the instance vault and mediate every action through grants, policies, and audit. > - Agents currently have no Apps connection for reading or managing 1Password items with an operator-supplied service-account token. > - 1Password supports this through its official SDK; its documentation MCP server does not expose vault operations. > - This pull request adds a one-field service-account connection and registers eight reviewed SDK actions through the existing native-provider catalog. > - Operators can select vault permissions in 1Password and agent/action access in Paperclip without installing a CLI or handing durable credentials to agents. ## Linked Issues or Issue Description Related: #16144 adds 1Password as Paperclip's secret-storage backend. This PR adds the separate Apps/tool-access flow with a per-connection credential; it does not change secret providers or use a server-wide ambient token. **Subsystem affected** Apps catalog, native tool-provider registry, and shared connection setup. **Problem or motivation** People need to paste their own `OP_SERVICE_ACCOUNT_TOKEN`, find official service-account setup instructions, and authorize agents to use the selected vaults through ordinary Paperclip governance. **Proposed solution** Add `1password/service-account`, with a password field labeled **Paste your 1Password Service Account token** and a link to [1Password's creation instructions](https://www.1password.dev/service-accounts/get-started#create-a-service-account). Use the pinned official `@1password/sdk` 0.5.0 for vault/item listing, item and secret-reference reads, and item create/update/archive/delete. **Alternatives considered** A remote MCP card would not provide vault access. A local CLI or a third-party MCP package adds installation and credential surfaces that the official SDK avoids. ## What Changed - Added the source and generated catalog definition, permission review, official light/dark artwork, documentation, and capability-map references. - Added a provider-owned SDK bridge with strict schemas, account-domain validation, contract hashes, fixed errors, bounded output, and disposable workers with cancellation/deadlines. - Registered the provider through master's native-provider catalog; existing setup, health, discovery, gateway, policy, and audit owners remain in place. - Labeled the single password field **Paste your 1Password Service Account token**. The shared formatter preserves explicit credential prompts; ordinary key labels retain their existing presentation. - Added credential-ownership, gateway read/write, policy/revocation, worker, setup UI, and interactive Storybook coverage. ## Verification - Final review follow-up: added two deterministic worker-entry tests for nested exact-token redaction and oversized-output rejection. Removing each guard caused exactly its regression test to fail. The focused provider suite passed **15 tests**, and the official server typecheck passed after rebuilding stale workspace SDK artifacts. Current head `569cb7f9bd57db791fb13a6034ce69bf50878cb1` passed the full [CI run](https://github.com/paperclipai/paperclip/actions/runs/40016166260), including typecheck, build, unit/integration tests, and all eight browser shards. The required `ci / verify` and `ci / e2e` gates passed. [Greptile](https://github.com/paperclipai/paperclip/pull/17249#issuecomment-6353731805) and [Paperclip Review](https://github.com/paperclipai/paperclip/pull/17249#issuecomment-6372165895) each rated this exact head **5/5**, with no open findings. Paperclip Review completed with a neutral check because its scoped reviewer could not inspect CI; the full CI result was verified separately. Other neutral checks and intentionally skipped Storybook jobs contain no actionable findings. - CI recovery on the same commit: the first attempt lost six jobs. Five logged simultaneous runner shutdowns at 18:49 UTC on 2026-10-23; the sixth stopped logging before a test result and its root cause was not established. No assertion failure appeared in the retrieved logs. All six jobs and the aggregate gates passed one failed-job retry. [CI run](https://github.com/paperclipai/paperclip/actions/runs/40005754936). - Copy follow-up: the catalog, provider, and setup UI suites passed **275 tests**. The governed gateway lifecycle passed **3 tests** (one live-only test skipped), including create, update, archive, and delete. UI token gates and map checks passed. The branch was rebased onto `9c442cf48e` to resolve additive catalog conflicts while retaining the Vercel and Supabase entries. The existing live write proof below remains applicable; this follow-up changes presentation only. - Five affected suites, including the existing Daytona consumer, passed **290 tests** (one opt-in live test skipped in fixture mode). Full `pnpm -r typecheck` and `pnpm build` passed. The local broad-suite attempt could not complete because PostgreSQL semaphore exhaustion prevented a competing integration suite from starting; the complete affected-file retry passed after disposable-process cleanup. The prior commit `f208a8aff6dc28859cc3f343497e18d85f124579` passed the complete CI matrix. All CI build, typecheck, test, and browser gates also passed on the earlier commit `9e2f3d3140368a08da43b004b2e8d3a0f4c0fa4d`. The separate Paperclip Review reported a worker-result regression-test gap on that head; the follow-up above addresses it. Its review thread is resolved, and both fresh current-head reviews are clean as recorded above. - Token gates, module boundaries, brand validation, map checks, and Storybook catalog checks passed. - Direct SDK create/archive/delete and rejected-call coverage passed (13 provider tests); the Storybook Permissions/Test play completed successfully in Chromium. Server and UI typechecks passed after those fixes. Greptile passed prior commit `9e2f3d3140368a08da43b004b2e8d3a0f4c0fa4d` at **5/5**. Both reviewers also passed the worker-test follow-up at **5/5** as recorded above. - Live 1Password proof on 2026-10-22: connect, eight-action discovery, vault listing through an actual run-scoped gateway, refresh, reconnect preserving identity, another read, removal, and denial through the retained session passed. Credential absence was asserted in responses/audit; an exact-token scan of inspected logs and changed files passed. - Real browser: Connectors → 1Password → token entry → personal access limited to one disposable agent → completion → Permissions → Test `onepassword_list_vaults` succeeded. The disposable connection was removed afterward. - Compiled-worker smoke initially exposed inherited Node launch flags. Workers now use explicit empty `execArgv`; the compiled worker then authenticated successfully and listed one vault. - Live write proof on **2026-10-23**: the replacement Keychain token passed all eight actions through the actual run-scoped gateway for both personal and shared credentials. The test created, listed, read, updated, resolved a secret from, archived, and deleted two synthetic items. Both were absent from subsequent item lists. Refresh/reconnect, identity preservation, removal, retained-session denial, and credential absence passed. The live suite passed **3 tests** (one fixture-only test skipped) in 116.65 seconds. The expanded fixture suite passed **3 tests** (one live-only test skipped); the 13 provider tests and server typecheck passed. An exact-token scan passed over 64 source/log files. - Failure history: the original token denied creation on 2026-10-22 and created no item. The first 2026-10-23 attempt stopped before provider calls because host PostgreSQL semaphore limits prevented test-database startup. A temporary ten-connection test profile allowed the complete retry to pass without changing shared services or host limits. A new delete-result assertion also exposed an outdated fixture response (`success` instead of `deleted`); the fixture was corrected and the complete fixture suite passed. Official contract sources: [service accounts](https://www.1password.dev/service-accounts/get-started), [SDKs](https://www.1password.dev/sdks), [item management](https://www.1password.dev/sdks/manage-items), [official JavaScript SDK](https://github.com/1Password/onepassword-sdk-js). ## Risks - Authorized item/secret reads intentionally return secret values to the agent and ordinary tool-result history. Select trusted agents and use restricted service-account vaults; the connection token itself is redacted. - New connections retain existing Paperclip action defaults, including Allowed writes/destructive actions for selected agents. 1Password still enforces the token's permissions. - Service-account access cannot be changed in place. Create a replacement account and reconnect to change vault permissions; read-only keys can connect but cannot write. - SDK authentication happens per action in a disposable worker. Calls have a 30-second maximum; cancellation stops local work, while a mutation already accepted by 1Password can still finish. Writes are never automatically retried. - Additive dependency and catalog change; no database migration. Disable/remove the connection to withdraw access, or remove the provider from catalog visibility for rollback. ## Model Used OpenAI Codex, based on GPT-6, with code editing, shell/browser tools, and live provider verification. A more specific runtime model ID and context-window limit were not exposed in this session. ## 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>
175 lines
12 KiB
TypeScript
175 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, companies, companyMemberships, companySecrets, connectionGrants, createDb,
|
|
heartbeatRuns, issues, toolAccessAuditEvents, toolConnections, userSecretDeclarations } from "@paperclipai/db";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { createToolGatewayService } from "../services/tool-gateway.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
|
|
const live = process.env.PAPERCLIP_ONEPASSWORD_LIVE === "1";
|
|
const fixtureState = vi.hoisted(() => ({ calls: 0, items: new Map<string, Record<string, unknown>>() }));
|
|
vi.mock("../services/connectors/onepassword/client.js", async importOriginal => {
|
|
const actual = await importOriginal<typeof import("../services/connectors/onepassword/client.js")>();
|
|
const callOnePassword = async (headers: Record<string, string>, tool: string, parameters: unknown, ms?: number) => {
|
|
if (process.env.PAPERCLIP_ONEPASSWORD_LIVE === "1") return actual.callOnePassword(headers, tool, parameters, ms);
|
|
const { onePasswordArguments } = await import("../services/connectors/onepassword/catalog.js");
|
|
const args = onePasswordArguments(tool, parameters);
|
|
fixtureState.calls++;
|
|
if (headers.Authorization === "Bearer ops_rejected") throw new Error("1Password rejected the fixture credential");
|
|
if (tool === "onepassword_list_vaults") return [{ id: "a".repeat(26), title: "Disposable test vault" }];
|
|
if (tool === "onepassword_create_item") {
|
|
const item = { ...args, id: "b".repeat(26), version: 1 };
|
|
fixtureState.items.set(String(item.id), item); return item;
|
|
}
|
|
if (tool === "onepassword_update_item") {
|
|
const item = { ...fixtureState.items.get(String(args.itemId)), ...args };
|
|
fixtureState.items.set(String(args.itemId), item); return item;
|
|
}
|
|
if (tool === "onepassword_get_item") return fixtureState.items.get(String(args.itemId));
|
|
if (tool === "onepassword_list_items") return [...fixtureState.items.values()].filter(item => item.vaultId === args.vaultId && !item.archived);
|
|
if (tool === "onepassword_resolve_secret") return { value: "disposable-secret" };
|
|
if (tool === "onepassword_archive_item") {
|
|
const item = fixtureState.items.get(String(args.itemId));
|
|
if (item) item.archived = true;
|
|
return { archived: true };
|
|
}
|
|
if (tool === "onepassword_delete_item") {
|
|
fixtureState.items.delete(String(args.itemId));
|
|
return { deleted: true };
|
|
}
|
|
return { success: true };
|
|
};
|
|
return { ...actual, callOnePassword, executeOnePasswordTool: (input: Parameters<typeof actual.executeOnePasswordTool>[0]) =>
|
|
process.env.PAPERCLIP_ONEPASSWORD_LIVE === "1" ? actual.executeOnePasswordTool(input)
|
|
: callOnePassword(input.headers, input.toolName, input.parameters) };
|
|
});
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const actor = { actorType: "user" as const, actorId: "onepassword-reviewer" };
|
|
const credential = live ? process.env.OP_SERVICE_ACCOUNT_TOKEN! : "ops_fixture-credential";
|
|
|
|
(support.supported ? describe : describe.skip)(live ? "1Password LIVE governed lifecycle" : "1Password governed lifecycle", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
beforeAll(async () => {
|
|
if (!credential) throw new Error("Live proof requires OP_SERVICE_ACCOUNT_TOKEN");
|
|
temp = await startEmbeddedPostgresTestDatabase("onepassword-connector-");
|
|
db = createDb(temp.connectionString);
|
|
}, 60_000);
|
|
afterAll(async () => { await db?.$client.end(); await temp?.cleanup(); });
|
|
|
|
async function fixture(personal = false) {
|
|
const [company] = await db.insert(companies).values({ name: "1Password connector test", issuePrefix: `OP${randomUUID().slice(0, 6)}` }).returning();
|
|
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, membershipRole: "admin", status: "active" });
|
|
const service = toolAccessService(db, { deploymentMode: "authenticated" });
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "1password", methodKey: "service-account",
|
|
...(personal ? { grantKind: "user" as const } : {}), credentialValues: { "credentials.authorization": credential } }, actor);
|
|
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Vault test agent", role: "engineer", adapterType: "process" }).returning();
|
|
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
|
enabledCatalogEntryIds: connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
|
}, actor);
|
|
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Disposable connector proof", assigneeAgentId: agent.id }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id,
|
|
scopeKind: "issue", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId, contextSnapshot: { issueId: issue.id } }).returning();
|
|
const gateway = createToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, issueId: issue.id });
|
|
const tools = await gateway.listToolsForSession(session.token);
|
|
const call = async (name: string, parameters: unknown) => {
|
|
if (live) console.info(`Live 1Password action: ${name}`);
|
|
const tool = tools.find(entry => entry.upstreamToolName === name);
|
|
if (!tool) throw new Error(`Missing reviewed tool: ${name}`);
|
|
const output = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters, idempotencyKey: randomUUID() });
|
|
expect(JSON.stringify(output).includes(credential)).toBe(false);
|
|
return output;
|
|
};
|
|
return { company, service, connected, agent, run, issue, gateway, session, tools, call };
|
|
}
|
|
|
|
it.each([false, true])("vaults credentials with the selected ownership and runs reads/writes (personal=%s)", async personal => {
|
|
const f = await fixture(personal);
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connected.connectionId));
|
|
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0]!.secretId));
|
|
expect(secret.scope).toBe(personal ? "user" : "company");
|
|
expect(secret.ownerUserId).toBe(personal ? actor.actorId : null);
|
|
if (personal) {
|
|
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.companyId, f.company.id));
|
|
expect(declarations.some(entry => entry.configPath === "credentials.authorization")).toBe(true);
|
|
}
|
|
expect(f.tools).toHaveLength(8);
|
|
expect(f.connected.catalog.find(entry => entry.toolName === "onepassword_delete_item")?.riskLevel).toBe("destructive");
|
|
expect(f.connected.catalog.find(entry => entry.toolName === "onepassword_create_item")?.riskLevel).toBe("write");
|
|
expect(JSON.stringify(await f.service.getConnection(f.connected.connectionId)).includes(credential)).toBe(false);
|
|
const vaults = await f.call("onepassword_list_vaults", {});
|
|
const data = vaults.result?.data?.structuredContent as Array<{ id: string }>;
|
|
expect(Array.isArray(data) && data.length > 0).toBe(true);
|
|
const vaultId = data[0]!.id;
|
|
let itemId: string | undefined;
|
|
try {
|
|
const created = await f.call("onepassword_create_item", { vaultId, title: `Paperclip disposable connector proof ${randomUUID()}`,
|
|
category: "SecureNote", notes: "Synthetic connector test data", fields: [{ id: "test_secret", title: "Test secret", fieldType: "Concealed", value: "disposable-secret" }] });
|
|
itemId = (created.result?.data?.structuredContent as { id: string }).id;
|
|
expect(typeof itemId === "string").toBe(true);
|
|
const listed = await f.call("onepassword_list_items", { vaultId });
|
|
expect((listed.result?.data?.structuredContent as Array<{ id: string }>).some(item => item.id === itemId)).toBe(true);
|
|
const read = await f.call("onepassword_get_item", { vaultId, itemId });
|
|
expect(JSON.stringify(read).includes("Synthetic connector test data")).toBe(true);
|
|
await f.call("onepassword_update_item", { vaultId, itemId, notes: "Updated synthetic connector test data" });
|
|
const reread = await f.call("onepassword_get_item", { vaultId, itemId });
|
|
expect(JSON.stringify(reread).includes("Updated synthetic connector test data")).toBe(true);
|
|
const resolved = await f.call("onepassword_resolve_secret", { reference: `op://${vaultId}/${itemId}/test_secret` });
|
|
expect(JSON.stringify(resolved).includes("disposable-secret")).toBe(true);
|
|
await f.service.refreshCatalog(f.connected.connectionId, actor);
|
|
await f.service.reconnectGalleryApp(f.connected.connectionId, f.company.id, { credentialValues: { "credentials.authorization": credential } }, actor);
|
|
await f.call("onepassword_get_item", { vaultId, itemId });
|
|
const archived = await f.call("onepassword_archive_item", { vaultId, itemId });
|
|
expect(archived.result?.data?.structuredContent).toEqual({ archived: true });
|
|
} finally {
|
|
if (itemId) {
|
|
const deleted = await f.call("onepassword_delete_item", { vaultId, itemId });
|
|
expect(deleted.result?.data?.structuredContent).toEqual({ deleted: true });
|
|
}
|
|
}
|
|
const remaining = await f.call("onepassword_list_items", { vaultId });
|
|
expect((remaining.result?.data?.structuredContent as Array<{ id: string }>).some(item => item.id === itemId)).toBe(false);
|
|
const audit = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id));
|
|
expect(audit.length > 0).toBe(true);
|
|
expect(JSON.stringify(audit).includes(credential)).toBe(false);
|
|
await f.service.archiveConnection(f.connected.connectionId, f.company.id, actor);
|
|
await expect(f.call("onepassword_list_vaults", {})).rejects.toThrow();
|
|
expect(await f.gateway.listToolsForSession(f.session.token)).toHaveLength(0);
|
|
}, 180_000);
|
|
|
|
it.skipIf(live)("enforces action policy, company scope, and revocation on retained sessions", async () => {
|
|
const f = await fixture();
|
|
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
|
enabledCatalogEntryIds: f.connected.catalog.filter(entry => entry.riskLevel === "read").map(entry => entry.id),
|
|
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
|
|
}, actor);
|
|
let before = fixtureState.calls;
|
|
await expect(f.call("onepassword_create_item", { vaultId: "a".repeat(26), title: "Blocked", category: "SecureNote" })).rejects.toThrow();
|
|
expect(fixtureState.calls).toBe(before);
|
|
await expect(f.gateway.createSession({ companyId: randomUUID(), agentId: f.agent.id, runId: f.run.id, issueId: f.issue.id })).rejects.toThrow();
|
|
await db.update(toolConnections).set({ enabled: false }).where(and(eq(toolConnections.id, f.connected.connectionId), eq(toolConnections.companyId, f.company.id)));
|
|
before = fixtureState.calls;
|
|
await expect(f.call("onepassword_list_vaults", {})).rejects.toThrow();
|
|
expect(fixtureState.calls).toBe(before);
|
|
});
|
|
|
|
it.skipIf(!live)("qualifies a read-only service account through connect, gateway, reconnect and removal", async () => {
|
|
const f = await fixture(true);
|
|
await f.call("onepassword_list_vaults", {});
|
|
await f.service.refreshCatalog(f.connected.connectionId, actor);
|
|
const reconnected = await f.service.reconnectGalleryApp(f.connected.connectionId, f.company.id, {
|
|
credentialValues: { "credentials.authorization": credential },
|
|
}, actor);
|
|
expect(reconnected.connection.id).toBe(f.connected.connectionId);
|
|
await f.call("onepassword_list_vaults", {});
|
|
const audit = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id));
|
|
expect(audit.length > 0 && !JSON.stringify(audit).includes(credential)).toBe(true);
|
|
await f.service.archiveConnection(f.connected.connectionId, f.company.id, actor);
|
|
await expect(f.call("onepassword_list_vaults", {})).rejects.toThrow();
|
|
expect(await f.gateway.listToolsForSession(f.session.token)).toHaveLength(0);
|
|
}, 120_000);
|
|
});
|