From 737c479d226326a5d6b06d497fb940e5a1070845 Mon Sep 17 00:00:00 2001 From: Sina Matian <89218912+time-attack@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:02:06 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20login=20with=201Password=20?= =?UTF-8?q?=E2=80=94=20connect=20a=20vault,=20agents=20fetch=20items=20whe?= =?UTF-8?q?n=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 1Password card on the keychain page mints the existing one-time secret-drop page for a vault-scoped service-account token (1Password's sanctioned path for agent access; no consumer OAuth exists). The token lands as a normal keychain credential, so every existing consent surface — grants, asks, standing vs once, audit, revocation — gates it unchanged. Once it is in the agent's shell, the agent fetches exactly the item a task needs at the moment of use with the op CLI; vault contents are never copied into the platform's store. The keychain manifest teaches the op-read flow whenever a 1password credential is visible, and a seeded skill carries the full guidance (install, find, read one field, hygiene). defaultEnvKey now prefixes an underscore for services whose name starts with a digit, which previously produced an invalid env-var name and made saving such a credential without an explicit envKey fail. --- plugins/web-ui/src/connectors.ts | 73 ++++++++++++++++++----- plugins/web-ui/test/keychain-flow.test.ts | 7 +++ skills-seed/1password/SKILL.md | 57 ++++++++++++++++++ src/credentials/keychain.ts | 16 ++++- test/keychain.test.ts | 30 ++++++++++ 5 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 skills-seed/1password/SKILL.md diff --git a/plugins/web-ui/src/connectors.ts b/plugins/web-ui/src/connectors.ts index 75667baed..18d127497 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
+
+
+

+ Lets the agent fetch credentials out of a 1Password vault you choose, at the moment a task needs them — nothing + from the vault is copied here. + Create a service account + scoped to that vault, then paste its token on the one-time page. +

+
+ ${ + 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/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); From 590f48696544111a7c30b53806acadcc08a763bc Mon Sep 17 00:00:00 2001 From: Sina Matian <89218912+time-attack@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:45:17 -0700 Subject: [PATCH 2/2] fix: let password managers fill the drop page, and say the vault card is optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-time drop page set autocomplete=off on every field, which told the user's password manager to stand down at exactly the moment they are asked to produce a secret. Secret fields now advertise current-password and carry a field name, so a manager (1Password, browser built-ins) offers to fill. The 1Password card's copy now leads with the fact that connecting a vault is optional — the agent already asks for a credential just in time and the user pastes it once — so the card reads as the stop-asking-me upgrade, not a setup requirement. --- plugins/web-ui/src/connectors.ts | 8 ++++---- src/api/routes/secret-drop.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/web-ui/src/connectors.ts b/plugins/web-ui/src/connectors.ts index 18d127497..425cb9a62 100644 --- a/plugins/web-ui/src/connectors.ts +++ b/plugins/web-ui/src/connectors.ts @@ -513,12 +513,12 @@ function drawConnectors(loading = false): void {

- Lets the agent fetch credentials out of a 1Password vault you choose, at the moment a task needs them — nothing - from the vault is copied here. + 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 accountcreate a service account - scoped to that vault, then paste its token on the one-time page. + scoped to that vault and paste its token once. Nothing from the vault is copied here.

${ 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));