feat(connections): add 1Password service-account connector (#17249)

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-23 13:27:33 -07:00
1 parent 17f1e386e8
commit 4d90634f74
25 files changed
+965 -8

No files matched your search

+154
View File
@@ -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.
+1 -1
View File
@@ -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).
@@ -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."
}
]
}
@@ -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`.
+4
View File
@@ -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;
@@ -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[];
+11 -1
View File
@@ -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),
);
+1
View File
@@ -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",
@@ -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"
]
}
]
}
@@ -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"
]
}
]
}
+1
View File
@@ -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",
@@ -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<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);
});
@@ -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<string, unknown> }) {
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<string, { versionHash: string; schemaHash: string; riskLevel: "read" | "write" | "destructive" }>(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<string, unknown> {
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;
}
@@ -0,0 +1,74 @@
import { Worker } from "node:worker_threads";
import { HttpError } from "../../../errors.js";
import { onePasswordArguments } from "./catalog.js";
const messages: Record<string, [number, string]> = {
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<string, string>, tool: string, parameters: unknown, timeoutMs = 30_000, signal?: AbortSignal): Promise<unknown> {
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<string, string>; 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);
}
@@ -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 });
});
});
@@ -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<Client> = 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");
}
}
@@ -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" });
});
});
@@ -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" });
}
@@ -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,
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_3856_134126)"><path fill-rule="evenodd" clip-rule="evenodd" d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0ZM6.532 3.494c-.051.1-.051.23-.051.49v2.054c0 .065 0 .098.008.128.008.026.02.052.036.074a.503.503 0 0 0 .093.087l.54.438c.06.05.091.074.102.104a.11.11 0 0 1 0 .08c-.01.03-.041.055-.102.105l-.54.438a.45.45 0 0 0-.093.087.232.232 0 0 0-.036.074c-.008.03-.008.062-.008.127v4.236c0 .26 0 .39.05.49a.464.464 0 0 0 .204.203c.1.05.23.05.49.05h1.55c.26 0 .39 0 .49-.05a.464.464 0 0 0 .203-.203c.051-.1.051-.23.051-.49V9.962c0-.065 0-.098-.008-.128a.233.233 0 0 0-.036-.074.512.512 0 0 0-.093-.087l-.54-.438c-.06-.05-.091-.074-.102-.104a.116.116 0 0 1 0-.08c.011-.03.041-.055.102-.105l.54-.438a.45.45 0 0 0 .093-.087.233.233 0 0 0 .036-.074c.008-.03.008-.062.008-.127V3.984c0-.26 0-.39-.05-.49a.465.465 0 0 0-.204-.203c-.1-.05-.23-.05-.49-.05h-1.55c-.26 0-.39 0-.49.05a.465.465 0 0 0-.203.203Z" fill="#FFFEFB"/></g><defs><clipPath id="clip0_3856_134126"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 32 32" width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 16c0-8.836-7.164-16-16-16S0 7.164 0 16c0 8.837 7.164 16 16 16s16-7.163 16-16ZM13.063 6.989c-.101.199-.101.459-.101.98v4.107c0 .13 0 .195.017.256a.464.464 0 0 0 .07.148c.036.05.087.092.188.174l1.079.876c.121.099.182.148.204.208.02.052.02.11 0 .162-.022.059-.083.108-.204.207l-1.08.877c-.1.082-.15.123-.187.173a.463.463 0 0 0-.07.149c-.017.06-.017.125-.017.255v8.47c0 .521 0 .782.101.98a.93.93 0 0 0 .407.407c.198.101.459.101.98.101h3.1c.521 0 .781 0 .98-.101a.93.93 0 0 0 .407-.407c.101-.198.101-.459.101-.98v-4.107c0-.13 0-.195-.016-.255a.465.465 0 0 0-.071-.149 1.019 1.019 0 0 0-.188-.174l-1.079-.876c-.121-.099-.182-.148-.204-.208a.232.232 0 0 1 0-.161c.022-.06.083-.11.204-.208l1.08-.877c.1-.081.15-.122.187-.173a.466.466 0 0 0 .07-.149c.017-.06.017-.125.017-.255V7.97c0-.521 0-.781-.101-.98a.93.93 0 0 0-.407-.407c-.199-.101-.459-.101-.98-.101h-3.1c-.521 0-.781 0-.98.101a.93.93 0 0 0-.407.407Z" fill="#303136"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h32v32H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+7
View File
@@ -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",
+1
View File
@@ -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`;
+17
View File
@@ -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, <ConnectionSetupFlow serviceSlug="1password" />);
const fields = container.querySelectorAll<HTMLInputElement>('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 });
@@ -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<string>());
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<ToolConnectionAccessSummary>(() => {
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 <QueryClientProvider client={client}><div className="min-h-screen bg-background text-foreground">
<p role="note" className="border-b border-border bg-muted p-4 text-sm">Storybook simulation. Use fake credentials. Provider calls and storage are simulated.</p>
<div className="mx-auto max-w-4xl space-y-4 p-6">
{step === "catalog" ? <ConnectorCard row={{ key: app.slug, slug: app.slug, name: app.name, description: app.description,
brandKey: app.slug, logoUrl: app.branding.logoUrl, darkLogoUrl: app.branding.darkLogoUrl, entry: app,
applications: [], connections: [], chatEndpoints: [] }} userProfileById={new Map()} chatConnectorsEnabled={false}
onNavigate={() => setStep("connect")} onRequestRemove={() => {}} />
: step === "connect" && ready ? <ConnectionSetupFlow serviceSlug="1password" host={inline ? "dialog" : "page"}
onComplete={() => setStep("permissions")} onCancel={() => setStep("catalog")} />
: step === "permissions" ? <ActionsSection connectionId="onepassword-review" appName={app.name}
readOnly={tools.filter(tool => 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}
</div>
</div></QueryClientProvider>;
}
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<typeof OnePasswordReview>;
export default meta;
type Story = StoryObj<typeof meta>;
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 } } };