+ 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.
+
- 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);