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`
+
+
+ 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.
+
- 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.