Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
281 changes: 281 additions & 0 deletions docs/plans/2026-07-21-onepassword-credentials.md

Large diffs are not rendered by default.

326 changes: 326 additions & 0 deletions docs/specs/2026-07-21-onepassword-credentials-design.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dogfood": "tsx scripts/dogfood.ts"
},
"dependencies": {
"@1password/sdk": "^0.4.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/sso": "1.6.23",
"@electric-sql/pglite": "^0.5.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { notificationsRouter } from "./routes/notifications.js";
import { workflowsRouter } from "./routes/workflows.js";
import { pluginsRouter } from "./routes/plugins.js";
import { credentialsRouter } from "./routes/credentials.js";
import { onePasswordRouter } from "./routes/onepassword.js";
import { credentialConnectRouter } from "./routes/credential-connect.js";
import { identityLinksRouter } from "./routes/identity-links.js";
import { meRouter } from "./routes/me.js";
Expand Down Expand Up @@ -190,6 +191,7 @@ export function createApp(
app.route("/api/plugins", pluginsRouter);
app.route("/api/credentials", credentialConnectRouter);
app.route("/api/credentials", credentialsRouter);
app.route("/api/onepassword", onePasswordRouter);
// Mounted BEFORE /api/me — defensive ordering only: meRouter today
// registers just GET / and PATCH / (no wildcard/param routes), so there is
// no actual collision to lose. Revisit this ordering if /api/me ever grows
Expand Down
169 changes: 169 additions & 0 deletions packages/api/src/channels/host.onepassword-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Owner-precedence contract (1Password credential provider plan, Task 6):
* `ChannelHost.start()`'s bot-token read must resolve org-owned rows
* carrying `metadata.onepassword` through `OnePasswordService`, and a
* resolution failure (`OnePasswordAuthError`) must be caught — logged, that
* transport simply doesn't start — rather than crashing boot. Drives a real
* `ChannelHost.start()` against a fake transport factory and a fake
* `OnePasswordService`, mirroring `engine/host.onepassword-credential.test.ts`'s
* fakes.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelTransport, CredentialOwner, CredentialStore, StoredCredential, ValetPlugin } from "@valet/engine";
import { VirtualSandboxProvider } from "@valet/engine";
import { PgSessionStore, PgEventStream } from "@valet/store-postgres";
import { freshTestPgDb, type TestPgDb } from "../test-helpers/pg-test-db.js";
import { EngineHost } from "../engine/host.js";
import { OnePasswordAuthError, type OnePasswordCtx, type OnePasswordService } from "../services/onepassword.js";
import { ChannelHost } from "./host.js";

const orgId = "op-channel-org";

/** Minimal in-memory `CredentialStore` — keyed by `${owner.type}:${owner.id}:${service}`. */
function fakeCredentialStore(): CredentialStore {
const rows = new Map<string, StoredCredential>();
const key = (owner: CredentialOwner, service: string) => `${owner.type}:${owner.id}:${service}`;
return {
async get(owner, service) {
return rows.get(key(owner, service)) ?? null;
},
async save(owner, service, credential) {
rows.set(key(owner, service), credential);
},
async delete(owner, service) {
rows.delete(key(owner, service));
},
async list() {
return [];
},
};
}

/** Fake `OnePasswordService` — only `resolveCredential` is exercised by `ChannelHost.start()`. */
function fakeOnePassword(
resolveCredential: OnePasswordService["resolveCredential"],
): OnePasswordService {
const unused = () => {
throw new Error("not exercised by this suite");
};
return {
tokenConnected: unused,
listVaults: unused,
listItems: unused,
getItem: unused,
resolveReference: unused,
resolveCredential,
};
}

class FakeTransport implements ChannelTransport {
readonly channelType = "fake";
verifyWebhook(): null {
return null;
}
parseUpdate(): null {
return null;
}
async send() {
return { conversationKey: "fake:dm:1", messageId: "1" };
}
async sendMedia() {
return { conversationKey: "fake:dm:1", messageId: "1" };
}
async sendGatePrompt() {
return { conversationKey: "fake:dm:1", messageId: "1" };
}
async updateGatePrompt() {}
}

describe("ChannelHost.start() 1Password bot-token resolution", () => {
let testDb: TestPgDb | undefined;
let engineHost: EngineHost | undefined;
let host: ChannelHost | undefined;

afterEach(async () => {
await host?.stop();
await engineHost?.destroyAll();
host = undefined;
engineHost = undefined;
testDb = undefined;
});

async function makeHost(credentials: CredentialStore, onePassword: OnePasswordService): Promise<ChannelHost> {
testDb = await freshTestPgDb();
const { pgdb, appDb } = testDb;
const engineStore = new PgSessionStore(pgdb);
const eventStream = new PgEventStream(pgdb);
const createdTransport = new FakeTransport();
const fakePlugin: ValetPlugin = {
name: "fake",
version: "0",
transports: [{ channelType: "fake", create: () => createdTransport }],
};
engineHost = new EngineHost({
engineStore,
sandboxProvider: new VirtualSandboxProvider(),
eventStream,
engineCredentials: credentials,
db: appDb,
apiBaseUrl: "http://127.0.0.1:1",
plugins: [fakePlugin],
});
host = new ChannelHost({
db: appDb,
engineHost,
engineStore,
eventStream,
engineCredentials: credentials,
plugins: [fakePlugin],
resolveOrgId: async () => orgId,
onePassword,
});
return host;
}

it("reference-backed bot token starts the transport with the resolved token", async () => {
const credentials = fakeCredentialStore();
const orgRow: StoredCredential = {
type: "bot_token",
metadata: { onepassword: { reference: "op://Shared/Bot/token", tokenScope: "org" } },
};
await credentials.save({ type: "org", id: orgId }, "fake", orgRow);
let sawRow: StoredCredential | undefined;
let sawCtx: OnePasswordCtx | undefined;
const onePassword = fakeOnePassword(async (row, ctx) => {
sawRow = row;
sawCtx = ctx;
return { type: row.type, metadata: row.metadata, accessToken: "resolved-bot-token" };
});

const h = await makeHost(credentials, onePassword);
await h.start();

expect(sawRow).toBe(orgRow);
expect(sawCtx).toEqual({ orgId, userId: "" });
expect(h.isRunning("fake")).toBe(true);
});

it("a failed resolution logs and skips the transport — does not crash boot", async () => {
const credentials = fakeCredentialStore();
await credentials.save({ type: "org", id: orgId }, "fake", {
type: "bot_token",
metadata: { onepassword: { reference: "op://Shared/Bot/token", tokenScope: "org" } },
});
const authError = new OnePasswordAuthError("This org has no organization 1Password service account token connected.");
const onePassword = fakeOnePassword(async () => {
throw authError;
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const h = await makeHost(credentials, onePassword);
await expect(h.start()).resolves.toBeUndefined();

expect(h.isRunning("fake")).toBe(false);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(`[channels] fake: bot token resolution failed: ${authError.message}`),
);
errorSpy.mockRestore();
});
});
34 changes: 33 additions & 1 deletion packages/api/src/channels/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type InboundChannelEvent,
type PromptAttachment,
type SessionStore,
type StoredCredential,
type Unsubscribe,
type ValetPlugin,
} from "@valet/engine";
Expand All @@ -36,6 +37,8 @@ import { agentSessions } from "../schema/index.js";
import { ensureOrchestratorSession } from "../orchestrator/ensure.js";
import { writeDropLog } from "../orchestrator/signals.js";
import type { AttentionChannelDeliverer, AttentionEvent } from "../orchestrator/attention.js";
import { resolveOrgCredentialRead } from "../services/credential-resolution.js";
import { OnePasswordAuthError, type OnePasswordService } from "../services/onepassword.js";
import { consumeLinkCode, identityForExternal, identityForUser, linkIdentity } from "./identity-links.js";

export interface ChannelHostDeps {
Expand All @@ -50,6 +53,15 @@ export interface ChannelHostDeps {
/** Resolves the single org id (single-org assumption, same as auth middleware). */
resolveOrgId: () => Promise<string>;
now?: () => number;
/**
* 1Password reference-credential resolver (owner-precedence contract,
* Task 6). Threaded into `resolveOrgCredentialRead` so an org-owned bot
* token row carrying `metadata.onepassword` resolves through the org's
* shared 1Password token instead of surfacing the raw reference string.
* Optional — omit for deployments/tests with no 1Password service wired;
* rows then pass through raw, byte-identical to before this task.
*/
onePassword?: OnePasswordService;
}

const DEDUP_CAP = 2048;
Expand Down Expand Up @@ -174,7 +186,27 @@ export class ChannelHost {
const orgId = this.orgId;
for (const plugin of this.deps.plugins) {
for (const factory of plugin.transports ?? []) {
const credential = await this.deps.engineCredentials.get({ type: "org", id: orgId }, factory.channelType);
// Owner-precedence contract (Task 6): org-row-only read + 1Password
// reference resolution, so an admin-configured reference-backed bot
// token (e.g. `metadata.onepassword` pointing at a shared vault
// item) resolves the same way a plain pasted token does. A failed
// resolution (missing/disabled 1Password token, SDK error) must NOT
// crash boot — logged and this transport simply doesn't start, same
// as the pre-existing "no bot token" branch below.
let credential: StoredCredential | null;
try {
credential = await resolveOrgCredentialRead(
{ credentials: this.deps.engineCredentials, onePassword: this.deps.onePassword },
{ orgId },
factory.channelType,
);
} catch (err) {
if (err instanceof OnePasswordAuthError) {
console.error(`[channels] ${factory.channelType}: bot token resolution failed: ${err.message}`);
continue;
}
throw err;
}
if (!credential) {
console.log(`[channels] ${factory.channelType}: no bot token, transport not started`);
continue;
Expand Down
Loading