diff --git a/plugins/web-ui/src/connectors.ts b/plugins/web-ui/src/connectors.ts index 75667baed..425cb9a62 100644 --- a/plugins/web-ui/src/connectors.ts +++ b/plugins/web-ui/src/connectors.ts @@ -1,5 +1,5 @@ import { html, render, type TemplateResult } from "lit"; -import { Activity, KeyRound, Link, LockKeyhole, Plug, Plus, RefreshCw, ShieldCheck } from "lucide"; +import { Activity, KeyRound, LockKeyhole, Plug, Plus, RefreshCw, ShieldCheck } from "lucide"; import { api } from "./core-bridge"; import { errMessage } from "../../chassis/src/errors"; import { icon } from "./ui"; @@ -499,6 +499,51 @@ function drawConnectors(loading = false): void { `; }); + const onePasswordCredential = keychainCredentials.find((credential) => credential.service === "1password"); + const onePasswordCard = html` +
+
+ ${connectorLogo("1password")} +
+
+

1Password

+ ${onePasswordCredential ? "" : html`Not connected`} +
+
Vault items, fetched when needed
+
+
+

+ Optional — the agent already asks you for a credential the moment a task needs one. Connect a vault only if you + want it to help itself instead of asking: + create a service account + scoped to that vault and paste its token once. Nothing from the vault is copied here. +

+
+ ${ + onePasswordCredential + ? html`` + : html`` + } +
+
+ `; if (!appState.mainEl) return; const host = document.createElement("div"); host.className = scopedSession.active ? "pane keychain-page scoped-view" : "pane keychain-page"; @@ -554,23 +599,11 @@ function drawConnectors(loading = false): void {

Linked accounts

- ${entries.length} + ${entries.length + 1}

Provider APIs the agent can use as you.

-
- ${ - connectorCards.length - ? connectorCards - : html`
- ${icon(Link, 20)} -
- No accounts availableYour workspace has not configured any account providers yet. -
-
` - } -
+
${connectorCards}${onePasswordCard}
@@ -770,6 +803,16 @@ async function createDrop(): Promise { } } +async function connectOnePassword(): Promise { + addingCredential = { + service: "1password", + envKey: "OP_SERVICE_ACCOUNT_TOKEN", + purpose: "Fetch items from my 1Password vault when a task needs them", + }; + secureDropUrl = null; + await createDrop(); +} + async function startConnector(provider: string): Promise { const stateEpoch = keychainOperations.captureEpoch(); connectorNotice = ""; diff --git a/plugins/web-ui/test/keychain-flow.test.ts b/plugins/web-ui/test/keychain-flow.test.ts index a1073748d..024ea1841 100644 --- a/plugins/web-ui/test/keychain-flow.test.ts +++ b/plugins/web-ui/test/keychain-flow.test.ts @@ -148,6 +148,13 @@ test("destructive controls settle duplicate attempts while a mutation is busy", ); }); +test("connecting 1Password rides the secret-drop flow with a fixed service-account shape", () => { + assert.match(connectorsSource, /service: "1password"/); + assert.match(connectorsSource, /envKey: "OP_SERVICE_ACCOUNT_TOKEN"/); + assert.match(connectorsSource, /developer\.1password\.com\/docs\/service-accounts/); + assert.match(connectorsSource, /credential\.service === "1password"/); +}); + test("keychain rows reserve success badges for actionable states", () => { assert.doesNotMatch(connectorsSource, /Stored securely/); assert.doesNotMatch(connectorsSource, />Connected<\/span>/); diff --git a/skills-seed/1password/SKILL.md b/skills-seed/1password/SKILL.md new file mode 100644 index 000000000..937c030ff --- /dev/null +++ b/skills-seed/1password/SKILL.md @@ -0,0 +1,57 @@ +--- +name: 1password +description: Fetch a credential out of a teammate's 1Password vault with the op CLI at the moment a task needs it — after their `1password` keychain credential (a vault-scoped service-account token) is in your shell. Covers getting the token, installing op, finding the right item, reading one field, and secret hygiene. +--- + +# 1Password: fetch credentials when needed + +A teammate who connected 1Password has a `1password` entry in their keychain. That entry is +NOT a single secret — it is a **service-account token scoped to a vault they chose to share +with agents**. With it in your shell as `OP_SERVICE_ACCOUNT_TOKEN`, the `op` CLI reads items +from that vault, live from 1Password, at the moment of use. Nothing from the vault is stored +on the platform, so an item the owner rotates or revokes in 1Password changes for you +instantly too. + +## Getting the token into your shell + +The same rules as every keychain credential — your keychain manifest is the source of truth: + +- In the owner's own DM it is already in your environment. +- In a shared conversation it needs a grant from the owner. A standing grant injects it on + every turn; otherwise run the `use.command` the grant response gives you and work in that + same shell. + +Never ask anyone to paste the token — or any vault item — into chat. + +## Using it + +Check for the CLI first; if missing, install it into `$HOME`, never a system path +(1Password's install page: https://developer.1password.com/docs/cli/get-started/): + +```bash +command -v op || echo "not installed" +``` + +Then fetch only what the task needs: `op vault list` shows what the token can read, +`op item list` finds the item, and `op read` loads ONE field. + +```bash +op vault list --format json +op item list --vault "" --format json +op read "op:////" +``` + +Prefer feeding the value straight to the command that needs it, so it never lands in a file +or in output: + +```bash +STRIPE_API_KEY="$(op read 'op://Agents/Stripe/credential')" ./deploy.sh +``` + +## Boundaries + +- Fetch the single field a task needs, when it needs it. Never dump whole items or vaults, + never echo or log a value, and never copy one into the workspace, a file backup, or chat. +- If an item you need is not in the shared vault, ask the owner to add it in 1Password (or + register it with a secret-drop link) — do not hunt for another way in. +- Stay within the grant's purpose, like any other credential use. diff --git a/src/api/routes/secret-drop.ts b/src/api/routes/secret-drop.ts index e6253f6c2..06c5661b4 100644 --- a/src/api/routes/secret-drop.ts +++ b/src/api/routes/secret-drop.ts @@ -101,7 +101,7 @@ function dropFormHtml( const inputs = fields .map( (f) => - ``, + ``, ) .join("\n"); const keys = JSON.stringify(fields.map((f) => f.key)); diff --git a/src/credentials/keychain.ts b/src/credentials/keychain.ts index d7b088fa6..bbe5fcbff 100644 --- a/src/credentials/keychain.ts +++ b/src/credentials/keychain.ts @@ -379,7 +379,8 @@ function credId(ownerId: string, service: string, slot: string): string { } function defaultEnvKey(service: string): string { - return `${service.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_TOKEN`; + const base = service.toUpperCase().replace(/[^A-Z0-9]/g, "_"); + return `${/^[0-9]/.test(base) ? "_" : ""}${base}_TOKEN`; } const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -1472,6 +1473,19 @@ export function renderKeychainManifest(input: KeychainManifestInput, now: number lines.push("", "No keychain credentials registered yet for the people here."); } + const hasOnePassword = + [...input.entriesByOwner.values()].some((creds) => creds.some((c) => c.service === "1password")) || + input.injected.some((m) => m.service === "1password"); + if (hasOnePassword) { + lines.push( + "", + "A `1password` credential is a vault-scoped 1Password service-account token, not a single secret. " + + "Once `OP_SERVICE_ACCOUNT_TOKEN` is in your shell, fetch exactly the item a task needs, at the moment it needs it, with the `op` CLI: " + + '`op item list --format json` to find it, `op read "op:////"` to load one field. ' + + "If `op` is missing, install it into $HOME first. Read single fields — never dump whole vaults or items, and never echo what you read.", + ); + } + if (hasOwn) { lines.push( "", diff --git a/test/keychain.test.ts b/test/keychain.test.ts index 4994676b7..751faa2a3 100644 --- a/test/keychain.test.ts +++ b/test/keychain.test.ts @@ -81,6 +81,36 @@ test("envKey defaults from the service name (github → GITHUB_TOKEN)", async () assert.equal(meta.service, "github"); }); +test("envKey derived from a digit-leading service name is still a valid env var (1password → _1PASSWORD_TOKEN)", async () => { + const k = kc(); + const meta = await k.save({ ownerId: "U1", service: "1password", secret: "ops_token" }); + assert.equal(meta.envKey, "_1PASSWORD_TOKEN"); +}); + +test("the manifest teaches on-demand op reads exactly when a 1password credential is visible", async () => { + const k = kc(); + const op = await k.save({ + ownerId: "U1", + service: "1password", + secret: "ops_token", + envKey: "OP_SERVICE_ACCOUNT_TOKEN", + }); + const base = { + scopeId: "channel:C1" as const, + conversationKind: "channel" as const, + actorId: "U2", + members: [{ id: "U1", displayName: "Alice" }, { id: "U2" }], + scopeGrants: [], + injected: [], + }; + const withOp = renderKeychainManifest({ ...base, entriesByOwner: new Map([["U1", [op]]]) }); + assert.match(withOp, /service-account token/); + assert.match(withOp, /op read "op:\/\/\/\/"/); + + const withoutOp = renderKeychainManifest({ ...base, entriesByOwner: new Map([["U1", [await k.save(GH)]]]) }); + assert.doesNotMatch(withoutOp, /op read/); +}); + test("only the owner can grant; materialize is scope-checked; once-grants are consumed", async () => { const k = kc(); const cred = await k.save(GH);