From 4d90634f747c75e8bc557c4c221dea267cea9ab2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:27:33 -0700 Subject: [PATCH] feat(connections): add 1Password service-account connector (#17249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/connections/1PASSWORD.md | 154 ++++++++++++++++ doc/connections/README.md | 2 +- .../tool-method-permission-reviews.json | 19 ++ maps/agent-access-map/connection-setup.md | 6 + maps/feature-map/connection-setup.md | 4 + .../shared/src/app-definitions.generated.ts | 13 +- packages/shared/src/app-definitions.test.ts | 12 +- packages/shared/src/app-definitions.ts | 1 + .../shared/src/app-definitions/1password.json | 61 ++++++ .../app-definition-overrides/1password.json | 60 ++++++ server/package.json | 1 + .../__tests__/onepassword-connection.test.ts | 174 ++++++++++++++++++ .../connectors/onepassword/catalog.ts | 62 +++++++ .../services/connectors/onepassword/client.ts | 74 ++++++++ .../onepassword/onepassword.test.ts | 80 ++++++++ .../services/connectors/onepassword/sdk.ts | 28 +++ .../connectors/onepassword/worker.test.ts | 49 +++++ .../services/connectors/onepassword/worker.ts | 15 ++ .../src/services/native-provider-catalogs.ts | 8 + ui/public/brands/apps/1password-dark.svg | 1 + ui/public/brands/apps/1password.svg | 1 + ui/public/brands/apps/manifest.json | 7 + ui/src/lib/app-gallery-copy.ts | 1 + ui/src/pages/apps/AppsConnect.test.tsx | 17 ++ .../OnePasswordConnection.stories.tsx | 123 +++++++++++++ 25 files changed, 965 insertions(+), 8 deletions(-) create mode 100644 doc/connections/1PASSWORD.md create mode 100644 packages/shared/src/app-definitions/1password.json create mode 100644 scripts/app-definition-overrides/1password.json create mode 100644 server/src/__tests__/onepassword-connection.test.ts create mode 100644 server/src/services/connectors/onepassword/catalog.ts create mode 100644 server/src/services/connectors/onepassword/client.ts create mode 100644 server/src/services/connectors/onepassword/onepassword.test.ts create mode 100644 server/src/services/connectors/onepassword/sdk.ts create mode 100644 server/src/services/connectors/onepassword/worker.test.ts create mode 100644 server/src/services/connectors/onepassword/worker.ts create mode 100644 ui/public/brands/apps/1password-dark.svg create mode 100644 ui/public/brands/apps/1password.svg create mode 100644 ui/storybook/stories/connection-setup/OnePasswordConnection.stories.tsx diff --git a/doc/connections/1PASSWORD.md b/doc/connections/1PASSWORD.md new file mode 100644 index 00000000000..2898eec85e2 --- /dev/null +++ b/doc/connections/1PASSWORD.md @@ -0,0 +1,154 @@ +# 1Password + +Connect from **Connectors → 1Password**, choose who can use the connection, and +fill **Paste your 1Password Service Account token**. Setup links directly to +[Create a service account](https://www.1password.dev/service-accounts/get-started#create-a-service-account). +No desktop sign-in, CLI installation, Connect server, or OAuth application is +required. Change agent access and action policies on **Permissions** after setup. + +## Provider contract + +Reviewed on 2026-10-22 against the official +[service-account guide](https://www.1password.dev/service-accounts/get-started), +[SDK documentation](https://www.1password.dev/sdks), and +[item-management guide](https://www.1password.dev/sdks/manage-items). +The implementation pins the official `@1password/sdk` to **0.5.0**. 1Password's +documentation MCP server searches documentation; it is not an authenticated vault +integration. This connector uses a provider-owned SDK bridge through the existing +`rest_api` connection path, rather than advertising an unsupported remote MCP URL. + +Create a service account with access only to the vaults the selected agents need. +Grant **Read Items** and **Write Items** for reading and managing items. An existing +read-only token can connect; provider permissions still deny its writes. Vault +access and permissions are immutable: create a replacement service account and +reconnect in Paperclip to change them. Built-in Personal, Private, Employee, and +default Shared vaults cannot be selected. Creating a service account requires a +1Password subscription and the appropriate account permissions. + +The method is `1password/service-account`. Its configured URL +`https://my.1password.com/` is a routing identity, not a public REST or MCP endpoint. +The SDK uses the account address in the token and handles regional API access. +Paperclip accepts only HTTPS account addresses under `1password.com`, +`1password.ca`, or `1password.eu`, without custom ports, paths, or URL credentials. +There are no OAuth metadata, registration, callback, refresh, or revocation +endpoints in this method. Reconnect replaces the token through the normal vault +credential writer; provider revocation is managed in 1Password. + +## Actions and governance + +| Action | Risk | Result | +| --- | --- | --- | +| `onepassword_list_vaults` | Read | Vaults authorized by the service account | +| `onepassword_list_items` | Read | Item summaries in one vault | +| `onepassword_get_item` | Read | An item including its secret fields | +| `onepassword_resolve_secret` | Read | The value of one `op://vault/item/field` reference | +| `onepassword_create_item` | Write | A new Login, Password, API Credentials, or Secure Note item | +| `onepassword_update_item` | Write | An item with specified properties updated | +| `onepassword_archive_item` | Destructive | An archived item | +| `onepassword_delete_item` | Destructive | A deleted item | + +Updates preserve the SDK's item version and omitted properties. Supplied `fields` +and `tags` arrays replace their previous arrays. Supported editable field types +are Text, Concealed, Totp, Url, and Email. Vault and item operations require IDs; +secret references support the provider's ordinary `op://` format. Vault +administration, sharing, document/file exports, and arbitrary commands are not +exposed. 1Password enforces the service account's vault boundary on every call. + +All actions use existing company, agent, responsible-user, connection-grant, +profile, Allowed / Ask first / Off, quarantine, and audit checks. Setup uses the +existing default of all discovered actions Allowed for the chosen agents. The +credential field explicitly describes required vault permissions. Select trusted +agents: item reads and secret-reference resolution intentionally return secret +values to the agent and ordinary tool-result history. The service-account token +itself is always redacted, including if it is stored in an item being read. + +Personal setup creates user-owned secrets and declarations; shared setup creates +company-owned secrets. Credentials never enter connection configuration or agent +environment variables. The worker receives only the selected invocation token +and validated arguments. It has no inherited environment or desktop identity. + +Each invocation starts a disposable worker to isolate SDK initialization and +cryptography from the API event loop. There is no background polling or retained +SDK client. The worker is terminated on completion or deadline (15 seconds for +discovery, at most 30 seconds for calls). Termination stops local work; an already +accepted provider mutation can finish remotely. Writes are never retried +automatically. Provider errors use fixed actionable messages, and worker output +is bounded to 1 MiB; raw SDK errors and stdout/stderr never enter server logs. + +## Verification + +`server/src/services/connectors/onepassword/onepassword.test.ts` exercises strict +arguments, patch preservation, forged endpoint rejection, and actual worker +deadline termination with unrelated event-loop progress. +`server/src/services/connectors/onepassword/worker.test.ts` executes the worker +entry point with synthetic SDK results to verify nested token redaction and +oversized-result rejection before posting a result to the gateway. +`server/src/__tests__/onepassword-connection.test.ts` exercises encrypted +credential ownership, personal declarations, reads and writes through a running +agent's gateway session, classification, refresh, reconnect, action policy, +cross-company denial, and revocation of a retained session. The ordinary suite +uses provider fixtures. `PAPERCLIP_ONEPASSWORD_LIVE=1` plus an environment-supplied +`OP_SERVICE_ACCOUNT_TOKEN` enables the account-bound variant, which creates and +deletes only synthetic test items. Never put a token in a command argument. + +Setup and management use the production components; the 1Password Storybook +stories provide catalog, token, rejection, inline task setup, narrow-screen, and +Permissions/Test simulations. These fixtures do not establish provider access. + +Live qualification status is recorded in +[`tool-method-permission-reviews.json`](./tool-method-permission-reviews.json). + +On 2026-10-22, the supplied account passed live connect, eight-action discovery, +vault listing through a running agent's gateway, catalog refresh, token +replacement/reconnect preserving the connection ID, another gateway read, +removal, and denial through the retained gateway session. Audit and connection +responses did not contain the credential. The account could list one vault but +1Password denied a minimal synthetic item creation for lack of permission; no +item was created. + +On 2026-10-23, a replacement token with Write Items permission passed the live +governed lifecycle for both personal and shared credentials. All eight actions +ran through an actual run-scoped gateway: list vaults, create and list a +synthetic item, read it, update it and verify the update, resolve its secret +reference, archive it, and delete it. Catalog refresh, reconnect, another read, +connection removal, and retained-session denial also passed. Both synthetic +items were deleted, and their absence from item listings was verified. Audit, +responses, and the captured log did not contain the credential. + +The first 2026-10-23 attempt stopped before any provider call because the host +had exhausted PostgreSQL semaphores. The passing run used a temporary test-only +PostgreSQL profile with ten connections and fewer workers; no shared service or +host limit was changed. The deterministic provider suite covers mutations and +the ordinary policy boundaries separately. + +The browser walkthrough used a fresh local test instance, a personal credential, +and one explicitly selected disposable agent. It reached the normal Permissions +screen with all eight actions. Initial setup failures during development (a +canonical URL mismatch and a rewritten token label) were corrected before the +passing walkthrough. An inherited Node `--input-type` flag also broke the first +compiled-worker smoke; workers now start with an explicit empty Node argument +list, and packaging is verified separately from source execution. + +## Branding provenance + +Official source: 1Password's product site, retrieved 2026-10-22: + +- Light theme: `https://1password.com/favicon/icon-v2.svg` +- Dark theme: `https://1password.com/favicon/icon-v2-light.svg` + +The native icon shapes and colors are preserved. Each SVG adds only an explicit +matching `viewBox` so the official icon scales inside Paperclip's standard gray +frame. The browser-served manifest contains asset paths only. + +## Shared changes + +1Password owns its schemas, SDK mapping, account-address validation, errors, +worker lifecycle, and result sanitization under +`server/src/services/connectors/onepassword/`. The existing native-provider +catalog registry registers its probe, tools, classification, contract hashes, +and executor. Shared setup, health, discovery, and gateway code dispatch that +existing contract; existing providers retain their behavior. No schema or database migration is needed. +The shared credential-label formatter preserves explicit "Paste your" prompts +from the manifest, so setup shows **Paste your 1Password Service Account token** +without a provider-specific UI branch. Ordinary key labels keep their existing +presentation. diff --git a/doc/connections/README.md b/doc/connections/README.md index 24231865a90..017c332e3b2 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -14,7 +14,7 @@ Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), [Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Braintrust](./BRAINTRUST.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [Ditto](./DITTO.md), -[Unikraft](./UNIKRAFT.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), +[1Password](./1PASSWORD.md), [Unikraft](./UNIKRAFT.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), [Enterpret](./ENTERPRET.md), [Supabase](./SUPABASE.md), and [Vercel](./vercel.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 2824a30f31e..3ee97832ed1 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -2923,6 +2923,25 @@ "reviewedAt": "2026-10-22", "liveProof": "2026-10-22: Approved one-hour team token connected in an isolated Paperclip instance with 17 actions. Actual process-agent gateway runs listed and created disposable projects, wrote a synthetic preview environment secret, read metadata without its value, and deployed a preview that reached READY. The signed-in browser displayed the exact deployed marker; anonymous access retained the existing protection redirect. Gateway cleanup deleted both disposable projects and confirmed absence. The temporary provider token and local connection were removed; a fresh agent saw no tools and an old-name call returned404. Production, GitHub-source deployments, domains, forced refresh, and all account plans were not live-tested.", "evidenceLimit": "Official documentation/OpenAPI plus local fixtures. No authenticated account-bound deployment/configuration/revocation proof; no disposable project credential supplied." + }, + { + "app": "1password", + "method": "service-account", + "auth": "api_key", + "policy": "explicit", + "requestedScopes": [], + "capability": "write", + "supportedActions": "List authorized vaults and items; read items and op:// references; create, update, archive and delete items. No vault administration, sharing, file export, desktop authorization or arbitrary commands.", + "keyPermissions": "Create a service account with access only to the required vaults. Grant Read Items and Write Items for reading and managing items. Service-account vault access and permissions are fixed at creation; create a new token and reconnect to change them. Read-only tokens can connect, but writes will be denied by 1Password.", + "providerRestrictions": "Requires a 1Password subscription and permission to create service accounts. Built-in Personal, Private, Employee and default Shared vaults cannot be granted. The official SDK handles regional accounts. Token rotation requires reconnecting.", + "evidence": [ + "https://www.1password.dev/service-accounts/get-started", + "https://www.1password.dev/sdks/manage-items", + "https://github.com/1Password/onepassword-sdk-js" + ], + "reviewedAt": "2026-10-22", + "liveProof": "read-write-verified", + "evidenceLimit": "2026-10-23: a replacement token passed all eight actions through a real run-scoped gateway for personal and shared credentials, including create/list/read/update/secret resolution/archive/delete on synthetic items. Both test items were deleted and absent from subsequent listings. Catalog refresh, reconnect preserving identity, removal, retained-session denial and credential absence passed. The 2026-10-22 token denied creation; the first 2026-10-23 attempt stopped before provider calls due to host PostgreSQL semaphore exhaustion. A temporary ten-connection test profile allowed the complete live retry to pass without changing shared services." } ] } diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md index e921d392b1f..b3bab180519 100644 --- a/maps/agent-access-map/connection-setup.md +++ b/maps/agent-access-map/connection-setup.md @@ -16,6 +16,12 @@ Settings → Connections. It adds no Apps provider or transport. Credential owne and audience rules are the existing connection rules; saving a connection alone does not activate the service. See [service configuration](../../doc/prompt-injection-prevention.md). +1Password adds a service-account catalog method. Agents request access through +the existing connection tools and invoke its reviewed item actions through the +run-scoped gateway. The service-account token stays in the selected user/company +vault; vault boundaries are enforced by 1Password. Setup remains board-owned. +Evidence and limits: [1Password connector](../../doc/connections/1PASSWORD.md). + ## `catalog` Capability ID: `connection-setup/catalog`. diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md index 423802cd094..22306048e8a 100644 --- a/maps/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -18,6 +18,10 @@ Settings → Connections. It adds no Apps provider or transport. Credential owne and audience rules are the existing connection rules; saving a connection alone does not activate the service. See [service configuration](../../doc/prompt-injection-prevention.md). +1Password is available from Apps with a service-account token and an official +service-account creation link. Its vault access is chosen in 1Password; action +policies use the ordinary Permissions screen. See [connector notes](../../doc/connections/1PASSWORD.md). + ## Sub-features - `catalog`: `connection-setup/catalog` — find a provider, inspect its available methods, and open setup; diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 8d4e0208511..d74319a5c96 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -102,10 +102,11 @@ import a100 from "./app-definitions/responses-api.json" with { type: "json" }; import a101 from "./app-definitions/messages-api.json" with { type: "json" }; import a102 from "./app-definitions/chat-completions-api.json" with { type: "json" }; import a103 from "./app-definitions/local.json" with { type: "json" }; -import a104 from "./app-definitions/daytona.json" with { type: "json" }; -import a105 from "./app-definitions/monday.json" with { type: "json" }; -import a106 from "./app-definitions/paperclip-notebook.json" with { type: "json" }; -import a107 from "./app-definitions/unikraft.json" with { type: "json" }; -import a108 from "./app-definitions/speko.json" with { type: "json" }; +import a104 from "./app-definitions/1password.json" with { type: "json" }; +import a105 from "./app-definitions/daytona.json" with { type: "json" }; +import a106 from "./app-definitions/monday.json" with { type: "json" }; +import a107 from "./app-definitions/paperclip-notebook.json" with { type: "json" }; +import a108 from "./app-definitions/unikraft.json" with { type: "json" }; +import a109 from "./app-definitions/speko.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106,a107,a108] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106,a107,a108,a109] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 136c49378b2..e0584a8d9eb 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -230,6 +230,16 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ writeTools: readonly string[]; }>; describe("AppDefinition catalog", () => { + it("offers 1Password service-account setup with official instructions and a vaulted token", () => { + const app = getAppStoreDefinition("1password")!; + expect(app.name).toBe("1Password"); + expect(app.methods).toHaveLength(1); + expect(app.methods[0]).toMatchObject({ key: "service-account", transport: "rest_api", auth: "api_key", + credentialFields: [{ key: "authorization", label: "Paste your 1Password Service Account token", type: "password", secret: true, required: true }], + keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " }, riskTier: "S4" }); + expect(app.methods[0]!.guidanceMd).toContain("https://www.1password.dev/service-accounts/get-started#create-a-service-account"); + expect(app.methods[0]!.tenantFields ?? []).toHaveLength(0); + }); it("namespaces Browser Use's hosted service as browser-use-cloud", () => { expect(CONNECTABLE_APP_DEFINITIONS.find(app => app.slug === "browser-use-cloud")) .toMatchObject({ name: "Browser Use Cloud", methods: [{ key: "cloud-v4", transport: "rest_api" }] }); @@ -886,7 +896,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(87); + expect(APP_STORE_DEFINITIONS).toHaveLength(88); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index aa76c332876..eaadd6b7831 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -11,6 +11,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "daytona", "unikraft", "vercel", + "1password", "cognee", ...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug), "monday", diff --git a/packages/shared/src/app-definitions/1password.json b/packages/shared/src/app-definitions/1password.json new file mode 100644 index 00000000000..c11a4777de2 --- /dev/null +++ b/packages/shared/src/app-definitions/1password.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 1, + "slug": "1password", + "name": "1Password", + "description": "Read and manage items and secrets in the vaults you authorize.", + "categories": [ + "developer" + ], + "branding": { + "logoUrl": "/brands/apps/1password.svg", + "darkLogoUrl": "/brands/apps/1password-dark.svg" + }, + "urlPatterns": [ + "https://*.1password.com/*", + "https://*.1password.ca/*", + "https://*.1password.eu/*" + ], + "docsUrl": "https://www.1password.dev/service-accounts/get-started", + "methods": [ + { + "key": "service-account", + "label": "Connect 1Password", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a service account scoped to the vaults your agents need.", + "defaults": { + "serverUrl": "https://my.1password.com/" + }, + "credentialFields": [ + { + "key": "authorization", + "label": "Paste your 1Password Service Account token", + "type": "password", + "required": true, + "secret": true, + "placeholder": "ops_…", + "helperMd": "Create a service account with access only to the required vaults. Grant Read Items and Write Items for reading and managing items. Service-account vault access and permissions are fixed at creation; create a new token and reconnect to change them. Read-only tokens can connect, but writes will be denied by 1Password." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "guidanceMd": "[Create a 1Password service account](https://www.1password.dev/service-accounts/get-started#create-a-service-account), choose its vaults and permissions, then paste its token below.", + "consoleLinks": { + "docs": "https://www.1password.dev/service-accounts/get-started#create-a-service-account" + }, + "warnings": [ + "Authorized agents can read secret values and change or delete items in the selected vaults. Grant access only to trusted agents." + ], + "riskTier": "S4", + "requiredResourceFilters": [ + "vault" + ] + } + ] +} diff --git a/scripts/app-definition-overrides/1password.json b/scripts/app-definition-overrides/1password.json new file mode 100644 index 00000000000..6523bcc1f00 --- /dev/null +++ b/scripts/app-definition-overrides/1password.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "slug": "1password", + "name": "1Password", + "description": "Read and manage items and secrets in the vaults you authorize.", + "categories": [ + "developer" + ], + "branding": { + "logoUrl": "/brands/apps/1password.svg", + "darkLogoUrl": "/brands/apps/1password-dark.svg" + }, + "urlPatterns": [ + "https://*.1password.com/*", + "https://*.1password.ca/*", + "https://*.1password.eu/*" + ], + "docsUrl": "https://www.1password.dev/service-accounts/get-started", + "methods": [ + { + "key": "service-account", + "label": "Connect 1Password", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a service account scoped to the vaults your agents need.", + "defaults": { + "serverUrl": "https://my.1password.com/" + }, + "credentialFields": [ + { + "key": "authorization", + "label": "Paste your 1Password Service Account token", + "type": "password", + "required": true, + "secret": true, + "placeholder": "ops_…" + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "guidanceMd": "[Create a 1Password service account](https://www.1password.dev/service-accounts/get-started#create-a-service-account), choose its vaults and permissions, then paste its token below.", + "consoleLinks": { + "docs": "https://www.1password.dev/service-accounts/get-started#create-a-service-account" + }, + "warnings": [ + "Authorized agents can read secret values and change or delete items in the selected vaults. Grant access only to trusted agents." + ], + "riskTier": "S4", + "requiredResourceFilters": [ + "vault" + ] + } + ] +} diff --git a/server/package.json b/server/package.json index 3181ae0f200..46f9beff067 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "postinstall": "node scripts/install-copilot-assets.mjs" }, "dependencies": { + "@1password/sdk": "0.5.0", "@agentclientprotocol/claude-agent-acp": "0.73.0", "@agentclientprotocol/codex-acp": "1.6.2", "@ai-sdk/amazon-bedrock": "5.0.113", diff --git a/server/src/__tests__/onepassword-connection.test.ts b/server/src/__tests__/onepassword-connection.test.ts new file mode 100644 index 00000000000..5cea78b5657 --- /dev/null +++ b/server/src/__tests__/onepassword-connection.test.ts @@ -0,0 +1,174 @@ +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>() })); +vi.mock("../services/connectors/onepassword/client.js", async importOriginal => { + const actual = await importOriginal(); + const callOnePassword = async (headers: Record, 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[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; + let temp: Awaited>; + 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); +}); diff --git a/server/src/services/connectors/onepassword/catalog.ts b/server/src/services/connectors/onepassword/catalog.ts new file mode 100644 index 00000000000..89d1381642f --- /dev/null +++ b/server/src/services/connectors/onepassword/catalog.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { badRequest } from "../../../errors.js"; +import { descriptorHash, stableHash } from "../../tool-catalog-hash.js"; + +export const ONEPASSWORD_METHOD = "service-account"; +export const ONEPASSWORD_URL = "https://my.1password.com/"; +export function isOnePasswordConnection(connection: { transport: string; config?: Record }) { + return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "1password" + && connection.config.connectionMethodKey === ONEPASSWORD_METHOD && connection.config.url === ONEPASSWORD_URL; +} + +const id = z.string().regex(/^[a-z0-9]{26}$/i); +const text = z.string().max(32_768); +const field = z.object({ + id: z.string().min(1).max(128), title: z.string().max(256), value: text, + fieldType: z.enum(["Text", "Concealed", "Totp", "Url", "Email"]), +}).strict(); +const itemChanges = { + title: z.string().min(1).max(1024).optional(), notes: text.optional(), + fields: z.array(field).max(100).optional(), tags: z.array(z.string().max(128)).max(50).optional(), +}; +const target = { vaultId: id, itemId: id }; +const operations = [ + { name: "onepassword_list_vaults", description: "List vaults accessible to this service account.", risk: "read", schema: z.object({}).strict() }, + { name: "onepassword_list_items", description: "List item summaries in an accessible vault, without secret field values.", risk: "read", schema: z.object({ vaultId: id }).strict() }, + { name: "onepassword_get_item", description: "Read an item, including its secret fields, from an accessible vault.", risk: "read", schema: z.object(target).strict() }, + { name: "onepassword_resolve_secret", description: "Resolve one op://vault/item/field reference. Returns the requested secret to the agent.", risk: "read", schema: z.object({ reference: z.string().min(8).max(2048).regex(/^op:\/\/[^\s]+$/) }).strict() }, + { name: "onepassword_create_item", description: "Create a Login, Password, API Credentials, or Secure Note item in an accessible vault.", risk: "write", schema: z.object({ ...itemChanges, vaultId: id, title: z.string().min(1).max(1024), category: z.enum(["Login", "Password", "ApiCredentials", "SecureNote"]) }).strict() }, + { name: "onepassword_update_item", description: "Update specified item properties. Supplied fields and tags replace their existing arrays; omitted properties are preserved.", risk: "write", schema: z.object({ ...target, ...itemChanges }).strict() }, + { name: "onepassword_archive_item", description: "Archive an item in an accessible vault.", risk: "destructive", schema: z.object(target).strict() }, + { name: "onepassword_delete_item", description: "Delete an item from an accessible vault.", risk: "destructive", schema: z.object(target).strict() }, +] as const; + +export const ONEPASSWORD_TOOLS = operations.map(({ name, description, risk, schema }) => ({ + name, title: name, description, inputSchema: z.toJSONSchema(schema), + annotations: { readOnlyHint: risk === "read", destructiveHint: risk === "destructive", idempotentHint: risk === "read", openWorldHint: true }, +})); + +export function onePasswordRisk(name: string): "read" | "write" | "destructive" { + return operations.find(operation => operation.name === name)?.risk ?? "destructive"; +} + +const contracts = new Map(ONEPASSWORD_TOOLS.map(tool => [tool.name, { + versionHash: descriptorHash(tool, onePasswordRisk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: onePasswordRisk(tool.name), +}])); +export const isOnePasswordToolName = (name: string) => contracts.has(name); +export function isCurrentOnePasswordEntry(entry: { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string }) { + const current = contracts.get(entry.toolName); + return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel); +} + +export function onePasswordArguments(tool: string, parameters: unknown): Record { + const operation = operations.find(entry => entry.name === tool); + const parsed = operation?.schema.safeParse(parameters); + if (!parsed?.success || JSON.stringify(parameters).length > 64 * 1024) { + throw badRequest("Invalid 1Password action arguments", { code: "onepassword_arguments_invalid" }); + } + if (tool === "onepassword_update_item" && !Object.keys(itemChanges).some(key => key in parsed.data)) { + throw badRequest("Specify at least one item property to update", { code: "onepassword_arguments_invalid" }); + } + return parsed.data; +} diff --git a/server/src/services/connectors/onepassword/client.ts b/server/src/services/connectors/onepassword/client.ts new file mode 100644 index 00000000000..e69a6216ea7 --- /dev/null +++ b/server/src/services/connectors/onepassword/client.ts @@ -0,0 +1,74 @@ +import { Worker } from "node:worker_threads"; +import { HttpError } from "../../../errors.js"; +import { onePasswordArguments } from "./catalog.js"; + +const messages: Record = { + onepassword_request_failed: [502, "1Password could not complete the action. Check the service account token and its vault permissions. A write may have completed; inspect the item before retrying."], + onepassword_auth_expired: [401, "The 1Password service account token expired or was revoked. Reconnect with a new token."], + onepassword_access_denied: [403, "1Password denied this action. Check the service account's vault access and Read Items / Write Items permissions. Create a new service account and reconnect if its permissions need to change."], + onepassword_rate_limited: [429, "1Password's service account request limit was reached. Try again later."], + onepassword_output_limit: [502, "The 1Password result exceeds the size limit. Request a single item or field."], + onepassword_timeout: [504, "The 1Password action timed out. A write may have completed; inspect the item before retrying."], +}; +function failure(code: string) { + const [status, message] = messages[code] ?? messages.onepassword_request_failed!; + return new HttpError(status, message, { code: code in messages ? code : "onepassword_request_failed" }); +} + +function serviceAccountToken(authorization: string | undefined): string { + const invalid = () => new HttpError(422, "Enter a valid 1Password service account token for a 1Password.com, .ca, or .eu account.", { code: "onepassword_credential_invalid" }); + if (!authorization?.startsWith("Bearer ops_") || authorization.length > 32_768 || /\s/.test(authorization.slice(7))) throw invalid(); + const token = authorization.slice(7); + try { + const { signInAddress } = JSON.parse(Buffer.from(token.slice(4), "base64url").toString("utf8")); + if (typeof signInAddress !== "string") throw invalid(); + const url = new URL(signInAddress.includes("://") ? signInAddress : `https://${signInAddress}`); + if (url.protocol !== "https:" || url.port || url.username || url.password || url.search || url.hash || url.pathname !== "/" + || !/(^|\.)1password\.(com|ca|eu)$/.test(url.hostname)) throw invalid(); + } catch { throw invalid(); } + return token; +} + +export async function callOnePassword(headers: Record, tool: string, parameters: unknown, timeoutMs = 30_000, signal?: AbortSignal): Promise { + const args = onePasswordArguments(tool, parameters); + if (signal?.aborted) throw failure("onepassword_timeout"); + const token = serviceAccountToken(headers.Authorization); + const source = import.meta.url.endsWith(".ts"); + const url = new URL(source ? "./worker.ts" : "./worker.js", import.meta.url); + const worker = source + ? new Worker(`import(${JSON.stringify(import.meta.resolve("tsx/esm/api"))}).then(({tsImport}) => tsImport(${JSON.stringify(url.href)}, ${JSON.stringify(import.meta.url)}));`, { + eval: true, workerData: { token, tool, parameters: args }, env: {}, execArgv: [], stdout: true, stderr: true, + }) + : new Worker(url, { workerData: { token, tool, parameters: args }, env: {}, execArgv: [], stdout: true, stderr: true }); + worker.stdout.resume(); worker.stderr.resume(); + let abort: (() => void) | undefined; + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(failure("onepassword_timeout")), Math.max(1, Math.min(timeoutMs, 30_000))); + abort = () => { clearTimeout(timer); reject(failure("onepassword_timeout")); }; + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + worker.once("message", (message: { code?: string; result?: unknown }) => { + clearTimeout(timer); + if (message.code) reject(failure(message.code)); else resolve(message.result); + }); + worker.once("error", () => { clearTimeout(timer); reject(failure("onepassword_request_failed")); }); + worker.once("exit", () => { clearTimeout(timer); reject(failure("onepassword_request_failed")); }); + }); + } finally { + if (abort) signal?.removeEventListener("abort", abort); + await worker.terminate(); + } +} + +export function executeOnePasswordTool(input: { + binding: { companyId: string; agentId?: string | null }; connection: { companyId: string }; + headers: Record; toolName: string; parameters: unknown; deadlineAt?: number; signal?: AbortSignal; +}) { + if (!input.binding.agentId || input.binding.companyId !== input.connection.companyId) { + throw new HttpError(404, "Connection not found", { code: "onepassword_connection_not_found" }); + } + const remainingMs = (input.deadlineAt ?? Date.now() + 30_000) - Date.now(); + if (remainingMs <= 0 || input.signal?.aborted) throw failure("onepassword_timeout"); + return callOnePassword(input.headers, input.toolName, input.parameters, remainingMs, input.signal); +} diff --git a/server/src/services/connectors/onepassword/onepassword.test.ts b/server/src/services/connectors/onepassword/onepassword.test.ts new file mode 100644 index 00000000000..53649db5309 --- /dev/null +++ b/server/src/services/connectors/onepassword/onepassword.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Client, Item } from "@1password/sdk"; +import { ONEPASSWORD_TOOLS, isOnePasswordConnection, onePasswordArguments } from "./catalog.js"; +import { executeOnePasswordSdk } from "./sdk.js"; +import { callOnePassword, executeOnePasswordTool } from "./client.js"; + +const vaultId = "a".repeat(26), itemId = "b".repeat(26); +describe("1Password provider contract", () => { + it("rejects another company and an expired dispatch before starting the SDK", () => { + const input = { binding: { companyId: "company-a", agentId: "agent" }, connection: { companyId: "company-b" }, + headers: {}, toolName: "onepassword_list_vaults", parameters: {} }; + expect(() => executeOnePasswordTool(input)).toThrow("Connection not found"); + expect(() => executeOnePasswordTool({ ...input, connection: { companyId: "company-a" }, deadlineAt: Date.now() - 1 })).toThrow("timed out"); + }); + it("rejects unreviewed actions, unknown input, invalid IDs and empty updates before authentication", async () => { + const authenticate = vi.fn(); + for (const [tool, args] of [ + ["onepassword_run_command", {}], ["onepassword_list_vaults", { token: "ambient" }], + ["onepassword_get_item", { vaultId: "../escape", itemId }], + ["onepassword_update_item", { vaultId, itemId }], + ["onepassword_create_item", { vaultId, title: "test", category: "Login", extra: true }], + ] as const) await expect(executeOnePasswordSdk("unused", tool, args, authenticate)).rejects.toThrow(); + expect(authenticate).not.toHaveBeenCalled(); + expect(ONEPASSWORD_TOOLS).toHaveLength(8); + expect(isOnePasswordConnection({ transport: "rest_api", config: { sourceTemplateKey: "1password", connectionMethodKey: "service-account", url: "https://attacker.example" } })).toBe(false); + }); + + it("preserves item version and omitted properties when applying a patch", async () => { + const current = { id: itemId, vaultId, title: "Keep", version: 4, fields: [], tags: ["keep"], notes: "before" } as unknown as Item; + const put = vi.fn(async item => item); + const client = { items: { get: vi.fn(async () => current), put } } as unknown as Client; + const authenticate = vi.fn(async () => client); + await executeOnePasswordSdk("ops_selected", "onepassword_update_item", { vaultId, itemId, notes: "after" }, authenticate); + expect(authenticate).toHaveBeenCalledWith("ops_selected"); + expect(put).toHaveBeenCalledWith({ ...current, notes: "after" }); + expect(onePasswordArguments("onepassword_resolve_secret", { reference: `op://${vaultId}/${itemId}/password` })).toBeTruthy(); + }); + + it("sends reviewed create, archive and delete operations to the correct SDK boundary", async () => { + const params = { vaultId, title: "Disposable item", category: "SecureNote", notes: "Synthetic data", + fields: [{ id: "secret", title: "Test secret", fieldType: "Concealed", value: "synthetic-secret" }] }; + const item = { ...params, id: itemId, version: 1 } as unknown as Item; + const create = vi.fn(async (_params: unknown) => item); + const archive = vi.fn(async (_vaultId: string, _itemId: string) => {}), remove = vi.fn(async (_vaultId: string, _itemId: string) => {}); + const client = { items: { create, archive, delete: remove } } as unknown as Client; + const authenticate = vi.fn(async () => client); + expect(await executeOnePasswordSdk("ops_selected", "onepassword_create_item", params, authenticate)).toBe(item); + expect(create).toHaveBeenCalledExactlyOnceWith(params); + expect(await executeOnePasswordSdk("ops_selected", "onepassword_archive_item", { vaultId, itemId }, authenticate)).toEqual({ archived: true }); + expect(archive).toHaveBeenCalledExactlyOnceWith(vaultId, itemId); + expect(remove).not.toHaveBeenCalled(); + expect(await executeOnePasswordSdk("ops_selected", "onepassword_delete_item", { vaultId, itemId }, authenticate)).toEqual({ deleted: true }); + expect(remove).toHaveBeenCalledExactlyOnceWith(vaultId, itemId); + }); + + it.each(["create", "archive", "delete"] as const)("propagates a rejected SDK %s without retrying or reporting success", async method => { + const denied = new Error("Provider denied the synthetic mutation"); + const operation = vi.fn(async () => { throw denied; }); + const client = { items: { [method]: operation } } as unknown as Client; + const args = method === "create" ? { vaultId, title: "Disposable", category: "SecureNote" } : { vaultId, itemId }; + await expect(executeOnePasswordSdk("ops_selected", `onepassword_${method}_item`, args, async () => client)).rejects.toBe(denied); + expect(operation).toHaveBeenCalledOnce(); + }); + + it("terminates real SDK work on deadline without blocking unrelated work or leaking the token", async () => { + const token = `ops_${Buffer.from(JSON.stringify({ signInAddress: "my.1password.com" })).toString("base64url")}`; + const start = Date.now(); + const unrelated = new Promise(resolve => setTimeout(() => resolve("responsive"), 5)); + const result = await callOnePassword({ Authorization: `Bearer ${token}` }, "onepassword_list_vaults", {}, 1).catch(error => error); + expect(result).toMatchObject({ status: 504 }); + expect(String(result).includes(token)).toBe(false); + expect(await unrelated).toBe("responsive"); + expect(Date.now() - start).toBeLessThan(5000); + }); + + it.each(["http://my.1password.com", "https://127.0.0.1", "https://my.1password.com.attacker.example", "https://my.1password.com:8443", "https://my.1password.com/path"])("rejects a forged account endpoint: %s", async signInAddress => { + const token = `ops_${Buffer.from(JSON.stringify({ signInAddress })).toString("base64url")}`; + await expect(callOnePassword({ Authorization: `Bearer ${token}` }, "onepassword_list_vaults", {})).rejects.toMatchObject({ status: 422 }); + }); +}); diff --git a/server/src/services/connectors/onepassword/sdk.ts b/server/src/services/connectors/onepassword/sdk.ts new file mode 100644 index 00000000000..311fd14b089 --- /dev/null +++ b/server/src/services/connectors/onepassword/sdk.ts @@ -0,0 +1,28 @@ +import { createClient, type Client, type ItemCreateParams, type ItemField } from "@1password/sdk"; +import { onePasswordArguments } from "./catalog.js"; + +export async function executeOnePasswordSdk(token: string, tool: string, parameters: unknown, + authenticate: (token: string) => Promise = token => createClient({ auth: token, integrationName: "Paperclip", integrationVersion: "1.0.0" })) { + const args = onePasswordArguments(tool, parameters); + const client = await authenticate(token); + const vaultId = args.vaultId as string, itemId = args.itemId as string; + switch (tool) { + case "onepassword_list_vaults": return client.vaults.list(); + case "onepassword_list_items": return client.items.list(vaultId); + case "onepassword_get_item": return client.items.get(vaultId, itemId); + case "onepassword_resolve_secret": return { value: await client.secrets.resolve(args.reference as string) }; + case "onepassword_create_item": return client.items.create(args as unknown as ItemCreateParams); + case "onepassword_update_item": { + const current = await client.items.get(vaultId, itemId); + return client.items.put({ ...current, + ...(args.title !== undefined ? { title: args.title as string } : {}), + ...(args.notes !== undefined ? { notes: args.notes as string } : {}), + ...(args.fields !== undefined ? { fields: args.fields as ItemField[] } : {}), + ...(args.tags !== undefined ? { tags: args.tags as string[] } : {}), + }); + } + case "onepassword_archive_item": await client.items.archive(vaultId, itemId); return { archived: true }; + case "onepassword_delete_item": await client.items.delete(vaultId, itemId); return { deleted: true }; + default: throw new Error("Unknown 1Password action"); + } +} diff --git a/server/src/services/connectors/onepassword/worker.test.ts b/server/src/services/connectors/onepassword/worker.test.ts new file mode 100644 index 00000000000..0be79210640 --- /dev/null +++ b/server/src/services/connectors/onepassword/worker.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fixture = vi.hoisted(() => ({ + token: "ops_synthetic-worker-token", + execute: vi.fn(), + postMessage: vi.fn(), +})); + +vi.mock("node:worker_threads", () => ({ + parentPort: { postMessage: fixture.postMessage }, + workerData: { token: fixture.token, tool: "onepassword_get_item", parameters: { vaultId: "a".repeat(26), itemId: "b".repeat(26) } }, +})); +vi.mock("./sdk.js", () => ({ executeOnePasswordSdk: fixture.execute })); + +describe("1Password worker results", () => { + beforeEach(() => { + vi.resetModules(); + fixture.execute.mockReset(); + fixture.postMessage.mockReset(); + }); + + it("redacts every exact connection token in nested successful results before posting", async () => { + fixture.execute.mockResolvedValue({ + title: "Synthetic item", + fields: [{ value: `before ${fixture.token} after ${fixture.token}`, nested: { value: fixture.token } }], + unchanged: "another item value", + }); + + await import("./worker.js"); + + expect(fixture.postMessage).toHaveBeenCalledExactlyOnceWith({ result: { + title: "Synthetic item", + fields: [{ value: "before [REDACTED] after [REDACTED]", nested: { value: "[REDACTED]" } }], + unchanged: "another item value", + } }); + expect(JSON.stringify(fixture.postMessage.mock.calls)).not.toContain(fixture.token); + }); + + it("rejects an oversized SDK result without posting its contents", async () => { + fixture.execute.mockResolvedValue({ value: "x".repeat(1024 * 1024) }); + + await import("./worker.js"); + + expect(fixture.postMessage).toHaveBeenCalledTimes(1); + const reply = fixture.postMessage.mock.calls[0]![0]; + expect("result" in reply).toBe(false); + expect(reply).toEqual({ code: "onepassword_output_limit" }); + }); +}); diff --git a/server/src/services/connectors/onepassword/worker.ts b/server/src/services/connectors/onepassword/worker.ts new file mode 100644 index 00000000000..94c703a52c6 --- /dev/null +++ b/server/src/services/connectors/onepassword/worker.ts @@ -0,0 +1,15 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { AuthExpiredError, RateLimitExceededError } from "@1password/sdk"; +import { executeOnePasswordSdk } from "./sdk.js"; + +try { + const result = await executeOnePasswordSdk(workerData.token, workerData.tool, workerData.parameters); + const json = JSON.stringify(result ?? null).split(workerData.token).join("[REDACTED]"); + parentPort!.postMessage(json.length <= 1024 * 1024 + ? { result: JSON.parse(json) } : { code: "onepassword_output_limit" }); +} catch (error) { + parentPort!.postMessage({ code: error instanceof RateLimitExceededError ? "onepassword_rate_limited" + : error instanceof AuthExpiredError ? "onepassword_auth_expired" + : error instanceof Error && /403|forbidden|permission|access denied|not authorized/i.test(error.message) + ? "onepassword_access_denied" : "onepassword_request_failed" }); +} diff --git a/server/src/services/native-provider-catalogs.ts b/server/src/services/native-provider-catalogs.ts index a37681c4168..da5e4f9ef65 100644 --- a/server/src/services/native-provider-catalogs.ts +++ b/server/src/services/native-provider-catalogs.ts @@ -1,5 +1,7 @@ import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, executeDaytonaTool, isCurrentDaytonaCatalogEntry, isDaytonaConnection, isDaytonaToolName } from "./daytona.js"; import type { NativeCatalogEntry, NativeRestRequest } from "./native-rest-client.js"; +import { ONEPASSWORD_TOOLS, isOnePasswordConnection, isOnePasswordToolName, onePasswordRisk, isCurrentOnePasswordEntry } from "./connectors/onepassword/catalog.js"; +import { callOnePassword, executeOnePasswordTool } from "./connectors/onepassword/client.js"; import { UNIKRAFT_DEFAULT_METRO, UNIKRAFT_TOOLS, executeUnikraftTool, isCurrentUnikraftCatalogEntry, isUnikraftConnection, isUnikraftToolName, unikraftClient, unikraftRisk } from "./connectors/unikraft/client.js"; @@ -25,6 +27,12 @@ export type NativeProviderCatalog = { }; export const NATIVE_PROVIDER_CATALOGS: readonly NativeProviderCatalog[] = [ + { + slug: "1password", name: "1Password", tools: ONEPASSWORD_TOOLS, + isConnection: isOnePasswordConnection, isToolName: isOnePasswordToolName, risk: onePasswordRisk, isCurrentEntry: isCurrentOnePasswordEntry, + probe: (_connection, headers) => callOnePassword(headers, "onepassword_list_vaults", {}, 15_000), + execute: executeOnePasswordTool, + }, { slug: "daytona", name: "Daytona", tools: DAYTONA_TOOLS, isConnection: isDaytonaConnection, isToolName: isDaytonaToolName, risk: daytonaRisk, isCurrentEntry: isCurrentDaytonaCatalogEntry, diff --git a/ui/public/brands/apps/1password-dark.svg b/ui/public/brands/apps/1password-dark.svg new file mode 100644 index 00000000000..a3877aaeaf4 --- /dev/null +++ b/ui/public/brands/apps/1password-dark.svg @@ -0,0 +1 @@ + diff --git a/ui/public/brands/apps/1password.svg b/ui/public/brands/apps/1password.svg new file mode 100644 index 00000000000..064cde161c1 --- /dev/null +++ b/ui/public/brands/apps/1password.svg @@ -0,0 +1 @@ + diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 70f0ef04614..0316bb2d05d 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -1,6 +1,13 @@ { "schemaVersion": 1, "providers": [ + { + "slug": "1password", + "provider": "1Password", + "catalogVisible": true, + "localAsset": "/brands/apps/1password.svg", + "darkAsset": "/brands/apps/1password-dark.svg" + }, { "slug": "agentmail", "provider": "AgentMail", diff --git a/ui/src/lib/app-gallery-copy.ts b/ui/src/lib/app-gallery-copy.ts index eafbc9391d4..925163b4429 100644 --- a/ui/src/lib/app-gallery-copy.ts +++ b/ui/src/lib/app-gallery-copy.ts @@ -179,6 +179,7 @@ export function credentialFieldLabel( rawLabel: string, fieldCount: number, ): string { + if (/^Paste your\b/.test(rawLabel)) return sanitizeProsumerCopy(rawLabel); if (fieldCount <= 1) return `Your ${appName} key`; const cleaned = sanitizeProsumerCopy(rawLabel); return cleaned || `Your ${appName} key`; diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 96d3fe7c6b4..2c5fc57505a 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -428,6 +428,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { return root; } + it("connects 1Password using only its service-account token and links official creation instructions", async () => { + const app = CONNECTABLE_APP_DEFINITIONS.find(entry => entry.slug === "1password")!; + listGalleryMock.mockResolvedValue({ apps: [app] }); + await render(undefined, false, ); + const fields = container.querySelectorAll('input[type="password"]'); + expect(fields).toHaveLength(1); + expect(fields[0]!.getAttribute("aria-label")).toBe("Paste your 1Password Service Account token"); + expect(container.querySelector('a[href="https://www.1password.dev/service-accounts/get-started#create-a-service-account"]')).not.toBeNull(); + expect(buttonByText("Connect")?.disabled).toBe(true); + await act(async () => setInputValue(fields[0]!, "ops_disposable-fixture")); + await act(async () => buttonByText("Connect")?.click()); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "1password", + connectionMethodKey: "service-account", credentialValues: { "credentials.authorization": "ops_disposable-fixture" } })); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + it("keeps Honcho setup blocked until the provider workspace is supplied", async () => { const honcho = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "honcho")!; experimentalMock.mockResolvedValue({ enableMemoryConnectors: true }); diff --git a/ui/storybook/stories/connection-setup/OnePasswordConnection.stories.tsx b/ui/storybook/stories/connection-setup/OnePasswordConnection.stories.tsx new file mode 100644 index 00000000000..a205b45d63a --- /dev/null +++ b/ui/storybook/stories/connection-setup/OnePasswordConnection.stories.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getAppStoreDefinition, type ToolCatalogEntry, type ToolConnectionAccessSummary, type ToolConnectionTestAgent } from "@paperclipai/shared"; +import { queryKeys } from "@/lib/queryKeys"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; +import { ConnectorCard } from "@/pages/apps/Browse"; +import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel"; + +const app = getAppStoreDefinition("1password")!; +const companyId = "company-storybook"; +const testAgent: ToolConnectionTestAgent = { id: "onepassword-review-agent", name: "Vault test agent", role: "engineer", title: null, status: "active", orgDepth: 0 }; +const tools = [ + { name: "onepassword_list_vaults", risk: "read", description: "List authorized vaults.", properties: {} }, + { name: "onepassword_create_item", risk: "write", description: "Create a disposable item.", properties: { vaultId: { type: "string" }, title: { type: "string" }, category: { type: "string", enum: ["SecureNote"] } } }, + { name: "onepassword_delete_item", risk: "destructive", description: "Delete a disposable item.", properties: { vaultId: { type: "string" }, itemId: { type: "string" } } }, +].map(({ name, risk, description, properties }) => ({ id: name, toolName: name, title: name, description, + companyId, connectionId: "onepassword-review", status: "active", riskLevel: risk, + isReadOnly: risk === "read", isWrite: risk === "write", isDestructive: risk === "destructive", + inputSchema: { type: "object", properties, additionalProperties: false }, +}) as unknown as ToolCatalogEntry); + +function OnePasswordReview({ screen = "catalog", rejected = false, inline = false }: { + screen?: "catalog" | "connect" | "permissions"; rejected?: boolean; inline?: boolean; +}) { + const [step, setStep] = useState(screen); + const [ready, setReady] = useState(false); + const [enabledIds, setEnabledIds] = useState(new Set(tools.map(tool => tool.id))); + const [askFirstIds, setAskFirstIds] = useState(new Set()); + const client = useMemo(() => { + const result = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + result.setQueryData(queryKeys.apps.gallery(companyId), { apps: [app], capabilities: { canSetCompanyInstall: true } }); + return result; + }, []); + const access = useMemo(() => { + const actions: ToolConnectionAccessSummary["tools"] = tools.map(tool => ({ toolName: tool.toolName, gatewayToolName: `onepassword__${tool.toolName}`, displayName: tool.title, + risk: tool.isDestructive ? "destructive" : tool.isReadOnly ? "read" : "write", matchedPolicyIds: [], reasonCode: null, + decision: !enabledIds.has(tool.id) ? "off" as const : askFirstIds.has(tool.id) ? "ask_first" as const : "allowed" as const })); + return { connectionId: "onepassword-review", tools: actions, toolCount: actions.length, + allowedCount: actions.filter(action => action.decision === "allowed").length, + askFirstCount: actions.filter(action => action.decision === "ask_first").length, + offCount: actions.filter(action => action.decision === "off").length, + lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null }; + }, [enabledIds, askFirstIds]); + useEffect(() => { + client.setQueryData(queryKeys.tools.testAgents("onepassword-review"), { agents: [testAgent] }); + client.setQueryData(queryKeys.tools.testAgentAccess("onepassword-review", testAgent.id), { access }); + }, [client, access]); + useEffect(() => { + const original = window.fetch; + const fixture: typeof window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); + if (url.pathname === `/api/companies/${companyId}/tools/applications`) return Response.json({ applications: [] }); + if (url.pathname === `/api/companies/${companyId}/tools/connections`) return Response.json({ connections: [] }); + if (url.pathname === `/api/companies/${companyId}/tools/apps/connect`) { + if (rejected) return Response.json({ error: "1Password could not verify access. Check the token and its vault permissions." }, { status: 422 }); + return Response.json({ connectionId: "onepassword-review", application: { id: "onepassword-app", name: app.name }, + connection: { id: "onepassword-review", name: app.name, status: "active" }, catalog: tools, + actions: { readOnly: tools.filter(tool => tool.isReadOnly), canMakeChanges: tools.filter(tool => !tool.isReadOnly) }, suggestedDefaults: {} }); + } + if (url.pathname === `/api/companies/${companyId}/tools/apps/onepassword-review/finish`) return Response.json({ connectionId: "onepassword-review", installs: [] }); + if (url.pathname === "/api/tool-connections/onepassword-review/test-agents") return Response.json({ agents: [testAgent] }); + if (url.pathname === `/api/tool-connections/onepassword-review/test-agents/${testAgent.id}/access`) return Response.json({ access }); + if (url.pathname === "/api/tool-connections/onepassword-review/test-calls") { + const body = JSON.parse(String(init?.body ?? "{}")); + const permission = access.tools.find(tool => tool.toolName === body.toolName); + if (body.agentId !== testAgent.id || !permission || permission.decision === "off") return Response.json({ decision: "off", invocationId: "review-off", error: { message: "This agent cannot run this action.", reasonCode: "policy_blocked" } }); + if (permission.decision === "ask_first") return Response.json({ decision: "ask_first", invocationId: "review-approval", actionRequestId: "review-approval" }); + const data = body.toolName === "onepassword_list_vaults" ? [{ id: "aaaaaaaaaaaaaaaaaaaaaaaaaa", title: "Disposable test vault" }] : { simulated: true }; + return Response.json({ decision: "allowed", invocationId: "review-call", result: { content: JSON.stringify(data), data: { structuredContent: data } } }); + } + if (url.pathname === "/api/tool-connections/onepassword-review/test-calls/review-approval") return Response.json({ actionRequestId: "review-approval", invocationId: "review-approval", phase: "waiting", requestedAt: new Date().toISOString(), resolvedAt: null }); + if (url.pathname === "/api/tool-connections/onepassword-review/catalog") return Response.json({ catalog: tools }); + if (url.pathname === "/api/tool-connections/onepassword-review/installs") return Response.json({ installs: [] }); + if (url.pathname.startsWith("/api/tool-connections/onepassword-review/")) return Response.json({ error: "No Storybook fixture for this request." }, { status: 501 }); + return original(input, init); + }; + window.fetch = fixture; setReady(true); + return () => { if (window.fetch === fixture) window.fetch = original; }; + }, [rejected, access]); + return
+

Storybook simulation. Use fake credentials. Provider calls and storage are simulated.

+
+ {step === "catalog" ? setStep("connect")} onRequestRemove={() => {}} /> + : step === "connect" && ready ? setStep("permissions")} onCancel={() => setStep("catalog")} /> + : step === "permissions" ? tool.isReadOnly)} canChange={tools.filter(tool => !tool.isReadOnly)} quarantined={[]} + enabledIds={enabledIds} askFirstIds={askFirstIds} disabled={false} refreshPending={false} canConfigure + onSetPermission={(ids, permission) => { + setEnabledIds(current => { const next = new Set(current); ids.forEach(id => permission === "off" ? next.delete(id) : next.add(id)); return next; }); + setAskFirstIds(current => { const next = new Set(current); ids.forEach(id => permission === "ask" ? next.add(id) : next.delete(id)); return next; }); + }} onReviewQuarantined={() => {}} onRefreshActions={() => {}} /> : null} +
+
; +} + +const meta = { id: "apps-connections-onepassword", title: "Apps, channels, and extensions/Connection setup/Apps/Connections/1Password", + component: OnePasswordReview, parameters: { layout: "fullscreen", paperclip: { implementation: "production", boundary: "fixtures", + capabilities: ["connection-setup/catalog", "connection-setup/credentials", "connection-setup/failure", "app-permissions/action-policy", "app-permissions/test-action"], + notes: "Production catalog card, token setup and Actions/Test controls with synthetic fixtures. No provider proof or task execution is claimed." } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const CatalogAndSetup: Story = {}; +export const ServiceAccountToken: Story = { args: { screen: "connect" } }; +export const RejectedCredentials: Story = { args: { screen: "connect", rejected: true } }; +export const PermissionsAndTest: Story = { args: { screen: "permissions" }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole("textbox", { name: "Find an action" }), "onepassword_list_vaults"); + await userEvent.click(canvas.getByRole("button", { name: /^Test$/ })); + const dialog = await within(document.body).findByRole("dialog"); + const view = within(dialog); + await waitFor(() => expect(view.getByRole("button", { name: /^Run$/ })).toBeEnabled()); + await userEvent.click(view.getByRole("button", { name: /^Run$/ })); + await waitFor(() => expect(view.getByText("Worked. 1 row came back.")).toBeVisible()); +} }; +export const InlineTaskSetup: Story = { args: { screen: "connect", inline: true } }; +export const NarrowSetup: Story = { args: { screen: "connect" }, globals: { viewport: { value: "mobile1", isRotated: false } } };