mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
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:
1 parent
17f1e386e8
commit
4d90634f74
25 files changed
+965
-8
No files matched your search
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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[];
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 } } };
|
||||
Reference in new issue
Block a user