Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 58 additions & 15 deletions plugins/web-ui/src/connectors.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -499,6 +499,51 @@ function drawConnectors(loading = false): void {
</article>
`;
});
const onePasswordCredential = keychainCredentials.find((credential) => credential.service === "1password");
const onePasswordCard = html`
<article class="kc-resource kc-account">
<div class="kc-resource-main">
${connectorLogo("1password")}
<div class="kc-resource-copy">
<div class="kc-resource-title-row">
<h3>1Password</h3>
${onePasswordCredential ? "" : html`<span class="kc-state neutral">Not connected</span>`}
</div>
<div class="kc-resource-meta">Vault items, fetched when needed</div>
</div>
</div>
<p class="kc-resource-description">
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:
<a href="https://developer.1password.com/docs/service-accounts/" target="_blank" rel="noopener noreferrer"
>create a service account</a
>
scoped to that vault and paste its token once. Nothing from the vault is copied here.
</p>
<div class="kc-resource-actions">
${
onePasswordCredential
? html`<button
class="kc-text-action danger"
type="button"
data-confirm-key="disconnect:1password"
?disabled=${keychainOperations.mutationInFlight}
@click=${() => void deleteCredential(onePasswordCredential)}
>
Disconnect
</button>`
: html`<button
class="btn"
type="button"
?disabled=${keychainOperations.dropInFlight}
@click=${() => void connectOnePassword()}
>
Connect account
</button>`
}
</div>
</article>
`;
if (!appState.mainEl) return;
const host = document.createElement("div");
host.className = scopedSession.active ? "pane keychain-page scoped-view" : "pane keychain-page";
Expand Down Expand Up @@ -554,23 +599,11 @@ function drawConnectors(loading = false): void {
<div class="kc-section-head">
<div class="kc-section-title">
<h2 id="kc-accounts-title">Linked accounts</h2>
<span>${entries.length}</span>
<span>${entries.length + 1}</span>
</div>
<p>Provider APIs the agent can use as you.</p>
</div>
<div class="kc-resource-list">
${
connectorCards.length
? connectorCards
: html`<div class="kc-empty">
${icon(Link, 20)}
<div>
<strong>No accounts available</strong
><span>Your workspace has not configured any account providers yet.</span>
</div>
</div>`
}
</div>
<div class="kc-resource-list">${connectorCards}${onePasswordCard}</div>
</section>
<section class="kc-section" aria-labelledby="kc-credentials-title">
<div class="kc-section-head">
Expand Down Expand Up @@ -770,6 +803,16 @@ async function createDrop(): Promise<void> {
}
}

async function connectOnePassword(): Promise<void> {
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<void> {
const stateEpoch = keychainOperations.captureEpoch();
connectorNotice = "";
Expand Down
7 changes: 7 additions & 0 deletions plugins/web-ui/test/keychain-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>/);
Expand Down
57 changes: 57 additions & 0 deletions skills-seed/1password/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<vault>" --format json
op read "op://<vault>/<item>/<field>"
```

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.
2 changes: 1 addition & 1 deletion src/api/routes/secret-drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function dropFormHtml(
const inputs = fields
.map(
(f) =>
`<input type=${f.secret ? "password" : "text"} autocomplete=off autocapitalize=off spellcheck=false placeholder="${escapeHtml(f.label)}" style="width:100%;font-size:1rem;padding:.5rem;box-sizing:border-box;margin-bottom:.6rem">`,
`<input type=${f.secret ? "password" : "text"} name="${escapeHtml(f.key ?? "password")}" autocomplete=${f.secret ? '"current-password"' : "off"} autocapitalize=off spellcheck=false placeholder="${escapeHtml(f.label)}" style="width:100%;font-size:1rem;padding:.5rem;box-sizing:border-box;margin-bottom:.6rem">`,
)
.join("\n");
const keys = JSON.stringify(fields.map((f) => f.key));
Expand Down
16 changes: 15 additions & 1 deletion src/credentials/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_]*$/;
Expand Down Expand Up @@ -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://<vault>/<item>/<field>"` 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(
"",
Expand Down
30 changes: 30 additions & 0 deletions test/keychain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:\/\/<vault>\/<item>\/<field>"/);

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);
Expand Down