From e153e8ff4d5e58a7edd74eb293ef7d14110e919b Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:48:53 +0000 Subject: [PATCH] add experiments, turned on per organization An organization's `experiments` hold the ids of the experiments on for it and `insider` turns all of them on, both set from the internal admin. The session and both ways an agent signs in read them in the same query, so `request.user`, `SignedInAgent` and `/api/me` carry the experiments on, asked with `hasExperiment` in the api and `useExperiment` in the web app. `EXPERIMENTS` in @proxy/utils names each; none yet. --- AGENTS.md | 4 +- packages/api/src/agent-auth/session.test.ts | 2 +- packages/api/src/agent-side/feedback.test.ts | 2 +- packages/api/src/agent-side/route.test.ts | 2 +- packages/api/src/agent-side/tools.test.ts | 2 +- packages/api/src/mcp/route.test.ts | 2 +- packages/api/src/me/route.test.ts | 3 +- packages/api/src/me/route.ts | 5 ++- packages/api/src/utils/auth.ts | 5 ++- .../migration.sql | 4 ++ packages/db/prisma/schema.prisma | 4 ++ packages/db/src/agent.test.ts | 26 ++++++++++++ packages/db/src/agent.ts | 24 +++-------- packages/db/src/auth.ts | 12 +++++- packages/db/src/oauth.ts | 12 ++---- packages/db/src/signed-in-agent.ts | 41 +++++++++++++++++++ packages/utils/src/experiments.test.ts | 24 +++++++++++ packages/utils/src/experiments.ts | 33 +++++++++++++++ packages/utils/src/index.ts | 2 + packages/web/src/client/me-client.ts | 4 +- packages/web/src/hooks/use-experiment.ts | 11 +++++ 21 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 packages/db/prisma/migrations/v019_add_organization_experiments/migration.sql create mode 100644 packages/db/src/signed-in-agent.ts create mode 100644 packages/utils/src/experiments.test.ts create mode 100644 packages/utils/src/experiments.ts create mode 100644 packages/web/src/hooks/use-experiment.ts diff --git a/AGENTS.md b/AGENTS.md index 4216aee9..9fec195e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,9 @@ - `packages/docs` - The docs, a Next app styled with `@proxy/ui` and exported as static files (`output: 'export'`, every page `/index.html` with trailing-slash URLs). Pages are MDX in `content/` (the path is the URL, a folder's `index.mdx` is the folder) with a `title` and `description` in their frontmatter, listed in order in `src/nav.ts`, the sidebar, where a page can have pages nested under it, folded in behind a chevron unless one of them is open or the entry sets `open`. `src/lib/docs.ts` compiles them at build time (GFM, heading ids, Shiki), and `src/components/mdx-components.tsx` styles each element and gives pages `` and `` without an import. Every page is prerendered; a page missing from `src/nav.ts`, or bad frontmatter, fails the build. - `packages/website` - The website at personalagentproxy.com, a Next app styled with `@proxy/ui` and exported as static files like the docs. Its buttons lead to the app's two sign-in pages, `/login` and `/agent/login`, on `http://localhost:5173` in development and `https://app.personalagentproxy.com` once built. Above the headline, `LogoDock` (`src/components/logo-dock.tsx`) shows Personal Agent Proxy between the agents and the services, each side scrolling through its logos (in `public/logos/`); the tiles' shadow and lighting are `.logo-tile` in `src/app/globals.css`. `/privacy` and `/terms` are the privacy policy (which Google's OAuth verification reads, so what it says about the product must stay true of the code) and the terms, on one shell (`src/components/legal-page.tsx`), with the contact address, legal entity and jurisdiction in `src/lib/legal.ts`; every page shares `SiteFooter`. - `packages/map` - The screen map, a Vite + React app styled with `@proxy/ui`, development only and never built: `scripts/crawl.ts` writes `generated/map.json` and the screenshots, `src/map-page.tsx` lays them out as a tree on a React Flow canvas (dagre, `src/layout.ts`), each screen its screenshot with its route above it, the same size at any zoom. The screens are the routes `src/router-paths.ts` reads from the web app's `router.tsx` as text; the crawl sees the links on each page (a sidebar's or breadcrumbs' as `nav`, hidden unless ticked), and `src/button-edges.ts` lists by hand what happens after a button or a form, which a test checks against the router. Not started by `bun run dev`. -- `packages/utils` - `@proxy/utils`, the Result helpers shared by every package: `Do`, `requirePresent` and `parseSchema` (`src/parse.ts`), and `httpRequest`, the fetch wrapper that returns a `FetchError` union instead of throwing (`src/fetch.ts`). Also `ApiError`/`ApiErr` and `wrapDb` (`src/api-error.ts`), the error every server-side function returns, and the session cookie's name and parsing (`src/session-cookie.ts`) +- `packages/utils` - `@proxy/utils`, the Result helpers shared by every package: `Do`, `requirePresent` and `parseSchema` (`src/parse.ts`), and `httpRequest`, the fetch wrapper that returns a `FetchError` union instead of throwing (`src/fetch.ts`). Also `ApiError`/`ApiErr` and `wrapDb` (`src/api-error.ts`), the error every server-side function returns, the session cookie's name and parsing (`src/session-cookie.ts`), and the experiments (`src/experiments.ts`): features turned on for some organizations before everyone, `EXPERIMENTS` naming each by its id, `hasExperiment` asking whether a signed-in person (`request.user`), agent (`SignedInAgent`) or the web app's `Me` has one on (`useExperiment` in the web app, off outside the human side's layout; hiding something there is only for looks, the api checks it too). An experiment that ships or is dropped is deleted from `EXPERIMENTS`, and the compiler points at what asked for it - `packages/integrations` - `@proxy/integrations`, the catalog the api and the web app share: each integration's actions, collections and fields (`src/catalog.ts`), the agents a login can be made for (`src/agent-providers.ts`), the email providers' servers (`src/email-providers.ts`), and access (`src/access.ts`): per action of a connection, an agent's own setting, else the connection's default, an action counting only with the one it `requires`, and tools (`src/tools.ts`): what an agent can do with a connection, generated from the catalog, one per list, open, write and command of each collection (`emails_list`, `emails_archive`), each with the action it needs and a JSON Schema of its params. A custom MCP server (`mcp` in the catalog, `custom`) has no actions of its own: `integrationFor(connection)` (`src/mcp-tools.ts`) builds its integration from the tools the connection keeps (`McpTool`, as the server lists them), one action per tool named by the tool, its risk from the tool's annotations (low when it only reads, medium when what it changes can be undone, high when it can't, and `unknown`, shown without a label, when it says nothing), and `integrationTools` passes them on as `call` tools with the server's own input schema (`ServerTool`, beside the catalog's `CollectionTool`). The web app adds the icons (`src/lib/integrations.ts`, whose `integrationOf(connection)` is what every page uses) -- `packages/db` - `@proxy/db`: Prisma 6 on Postgres. `src/index.ts` is the client; each module (`auth.ts`, `user.ts`, `organization.ts`, `connection.ts`, `info.ts`, `agent.ts`, `audit.ts`) exports functions returning `Result` through `wrapDb`. A connection's `tools` column holds a custom MCP server's tools as JSON, and `name` what the person called it; both null for every other integration. `relationMode = "prisma"`: no foreign keys in the database, so Prisma runs the cascades and every relation column has its own index. Locally the database is Prisma's embedded Postgres (PGlite, `scripts/dev-db.ts`), which needs `connection_limit=1&pgbouncer=true` in `DATABASE_URL` +- `packages/db` - `@proxy/db`: Prisma 6 on Postgres. `src/index.ts` is the client; each module (`auth.ts`, `user.ts`, `organization.ts`, `connection.ts`, `info.ts`, `agent.ts`, `audit.ts`) exports functions returning `Result` through `wrapDb`. A connection's `tools` column holds a custom MCP server's tools as JSON, and `name` what the person called it; both null for every other integration. An organization's `experiments` are the ids of those on for it and `insider` turns all of them on, both set from the internal admin and nothing in the app; the session (`auth.ts`) and both ways an agent signs in (`signed-in-agent.ts`) read them in the same query, ids no longer in `EXPERIMENTS` dropped. `relationMode = "prisma"`: no foreign keys in the database, so Prisma runs the cascades and every relation column has its own index. Locally the database is Prisma's embedded Postgres (PGlite, `scripts/dev-db.ts`), which needs `connection_limit=1&pgbouncer=true` in `DATABASE_URL` - `packages/api` - `@proxy/api`: Express on Bun. Routes are `handleXRoute` functions mounted in `src/server/create-api-router.ts`; JSON routes return `Result` through `withAuthResult`, which puts the signed-in user on `req.user`. Every environment variable is in `src/utils/env-schema.ts` with its docs, which the docs' Environment variables page is built from; add new ones there. Personal Agent Proxy is one origin everywhere: when `packages/web/dist` exists, as in the Docker image, the api serves the web app's build too (`src/server/serve-web.ts`), and every address the api builds starts from `APP_URL`. Browser-called writes take `requireBrowserOrigin` (CSRF); routes that read a cookie have no CORS. `src/connections/` lists, deletes and sets defaults on an organization's connections, connects a mailbox with an app password after signing in to its IMAP and SMTP servers (`email/`), and connects Granola, Notion and Linear by signing in to their MCP servers (`mcp/`, shared by all three: OAuth with PKCE against the server's authorization server, a client registered for each sign-in so nothing is set up beforehand, the handshake in an encrypted cookie, `mcpConnectRoutes` making both routes on the sign-in flow every OAuth integration shares, `connect-flow.ts`; tokens are refreshed as they run out, one refresh at a time per connection on each server, read again first so ones another call or server saved meanwhile are used rather than refreshed twice, and saved only over the ones they replace, whatever else the credential holds kept beside them, by `connections/token-session.ts`, which Google's connection will share; each tool call is one stateless `tools/call`, `mcp-client.ts`, which also lists a server's tools and, for a server that may keep a session (`handshake`), initializes one around each call as the spec asks). Every request to an MCP server, its metadata or its authorization server goes through `utils/checked-request.ts`, which checks the address as a database's host is checked (`utils/host-check.ts`: nothing private unless `ALLOW_PRIVATE_HOSTS`, and `https` unless the address is private) before each request, since a custom server and its endpoints are wherever it said, and never follows a redirect. A custom MCP server is connected by its address (`custom/`): `POST /api/connections/mcp` with `{name, url, token?, returnTo?}` (the name is the person's, kept through the sign-in in its cookie, since a server says nothing about itself that would do) asks the server for its tools, connects a server that answers at once, with no sign-in or the token typed in as a bearer token (as written when it has its scheme in front, `Basic …`), and for one that turns a request without a token down begins its sign-in there, under the browser's origin check, answering `{kind: 'signIn', url}` with the sign-in page the browser goes to: `mcp-discovery.ts` finds the authorization server from the server's protected resource metadata (the `WWW-Authenticate` header's `resource_metadata`, else the well-known paths), its endpoints from that server's metadata (else `/authorize`, `/token` and `/register` under it) and the scopes the server's own metadata names, and the sign-in, begun with `signInFlow` (`connect-flow.ts`, which `mcpConnectRoutes` builds on for the servers known in advance), keeps the endpoints in the handshake cookie and in the credential beside the tokens. The connection's account is the address without its scheme (`mcp.example.com/mcp`), connecting the same address again replaces its credential and tools, a new connection's defaults are the tools that say they only read, `POST /api/connections/:id/tools` asks the server for its tools again (replacing the tools drops the defaults and agents' settings for tools the server no longer lists, `updateConnectionTools`), and a tool is run as it is (`custom-mcp.ts`): its params go to the server as given, a tool that fails answers the agent with the server's own text as a `validation_error`, and the run is logged under the collection `tools` by the tool's name, with its params as JSON, cut to 200 characters. Granola's notes and transcripts are on by default, Notion's Read pages and Linear's Read issues alone; a Notion or Linear sign-in is for one workspace, and a Notion one lasts at most 180 days, however often it is refreshed. `src/agents/` makes, revokes and deletes agent logins (the password is sent once, only its argon2id hash is kept) and sets an agent's own settings per action of a connection where they differ from the default. `src/records/` reads and writes a collection's records through the integration's `Connector` (`connectors.ts`): Information's live in the database (`info-connector.ts`), a mailbox's are fetched over IMAP on every request (`email-connector.ts`), which merges the inbox and the folders the server marks as Drafts and Sent into one list (ids start with the folder: `inbox-…`, `drafts-…`, `sent-…`) and runs the commands: flags, moves to the folders the server marks as Archive (Gmail's All Mail) or Trash, and sending over SMTP, a draft or a new email, filing a copy in Sent where the server doesn't (Gmail does). Received emails are never edited or deleted for good; only drafts are. Granola's notes are fetched from its MCP server on every request (`granola-connector.ts`; the reading of the XML-like text Granola answers in is in `connections/granola/granola-mcp.ts`): its Notes and Transcripts collections are the same meetings by Granola's meeting id, and since Granola's tools have no pages, a page is 30 days of meetings; one that finds none skips back to the newest meeting before it, so the list ends where the meetings do. Granola's dates and attendees are shown as Granola writes them. Notion's workspace is fetched from its MCP server the same way (`notion-connector.ts`, the reading of its answers in `connections/notion/notion-mcp.ts`), as the person who signed in, as a tree in one nested collection: Notion has no list of every page, so the top is their Recents and a search (Notion's own, AI search where the workspace has it) finds the rest, one page of 50; inside a page are the pages and databases in its content, inside a database its saved views (`view-…` ids), or the rows of its only one, and inside a view its rows, paged with Notion's cursor (view queries have no plan quota; Notion's row and SQL queries do). A page reads as Notion-flavored Markdown, a row's properties beside it as "Name: value" lines (`notion-properties.ts`), and only a page's, database's or view's id is ever sent to Notion's fetch; a new page is a private draft at the top, or goes inside the page, database or view it is made in, a row's title in the database's title property; an edit sends the title, the properties written differently and the content, each only where it changed, and pages are never deleted. Notion's MCP server has no tool that deletes or trashes a page. Linear's issues are fetched from its MCP server the same way (`linear-connector.ts`, the reading of its answers in `connections/linear/linear-mcp.ts`), as the person who signed in, by their identifier (`ENG-123`): a list is the issues updated last first, 50 to a page with Linear's cursor, narrowed by the type of status (`?statusType=Started`) and searched in titles and descriptions, and an issue opens with its comments, oldest first. Teams, statuses, people, projects and labels are written by name, as Linear shows them and finds them, its refusals passed on to the agent; an edit sends only the fields written differently, an empty one clearing it. Linear's MCP server has no tool that archives or deletes an issue. Google is connected by signing in to Google with the app's own Google client, the one Google sign-in uses (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, a second redirect URI `/api/connections/google/callback`), asking for every scope at once, Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts, with offline access (`connections/google/`: `google-oauth.ts` the sign-in and refresh, with the client's secret, keeping the refresh token Google doesn't send again; `google-client.ts` the calls with a bearer token and Google's errors, a disabled API passing Google's own message on, which names the API and where to enable it); the credential holds the tokens, the scopes Google granted, since the consent screen lets a person untick some, and the address; the connection keeps Google's id for the account (`Connection.providerAccountId`, its `sub`), so signing in again renews the same connection, and its address, even after the address changed (`findAccountConnection`, by address for one without an id and for the MCP integrations). Each action needs one of the sets of scopes allowing it (`google/scopes.ts`, where Calendar's actions need `calendar.events` and `calendar.calendarlist.readonly` together, and `PRODUCT_SCOPES` is what a sign-in asks for), so an action whose scope wasn't granted is unavailable: `connections/unavailable.ts`, the one place the rest of the api asks, reads it from the credential on every request (`unavailable` on a connection; one that can't be read leaves every action unavailable rather than failing the list), no default or agent setting can turn it on, `runAgentTool` refuses it saying to sign in to Google again, and the connection page shows its checkbox disabled with a "Sign in again" link (`?account=` opening Google on the account with `login_hint`). An integration's `disclosure`, Google's Limited Use line, is shown above a connection's permissions. Disconnecting a Google connection revokes its grant at Google first (`connections/disconnect.ts`), unless another connection in any organization is for the same account, since a grant covers all of the client's tokens for it, and deletes it even when Google can't be reached. Its Gmail is the `emails` collection, the same fields and commands as a mailbox's under the Google action ids (`readEmails` … `sendEmails`, both built by `emailsCollection` in the catalog), fetched live from Gmail's API (`records/google-emails.ts` over `connections/google/gmail-api.ts`): one list, 50 to a page with Gmail's page token, all mail but Spam and Trash, or one folder, each a label (Inbox `INBOX`, Draft `DRAFT`, Sent `SENT`, Trash `TRASH`, Spam `SPAM`) or, for Archive, a search for what is in none of them; an email's labels say its folder, so ids are Gmail's message ids, a draft's `draft-`, the id Gmail edits, deletes and sends it by and which stays as it is edited. `messages.list` gives only ids, so each is asked for with its headers, 8 at once, again twice when Gmail answers busy; Gmail's per-user quota of 6,000 units a minute makes a page cost about 1,000. A search is Gmail's own. An email opens as its text part, or its HTML part as text (read in one pass, `htmlToText`), with its attachments' names; its `url` is Gmail's page for it in the account (`?authuser=`), `#all/`, a draft's `#drafts?compose=`. Marking takes `UNREAD` off or puts it on, starring `STARRED`, archiving takes `INBOX` off, and Move to Trash is `messages.trash`, never `messages.delete`: nothing is deleted for good but drafts, the only email edited or deleted, an edit keeping the draft's Cc, Bcc and thread, its recipients' names where they are written as they read and its HTML where its text is, and refusing a draft with attachments, which it would drop; a draft is edited, deleted and sent by its message id too. Email goes out from the connection's own address, a draft with `drafts.send` and a new one with `messages.send`, written by `MailComposer` as the IMAP connector writes it; Gmail files it in Sent. Its Drive is one nested collection, `files` (`records/google-files.ts`, behind `google-connector.ts`, which dispatches on the collection), fetched live from Drive's and Sheets' APIs (`drive-api.ts`, `sheets-api.ts`): at the top four places, My Drive, Shared with me, Shared drives (`drive-…` ids, of the kind Shared drive, which is never edited or trashed) and Recent, inside them and inside folders their files, folders first, 50 to a page with Drive's page token, and a search is Drive's full-text one, anywhere or inside the folder opened; the trail is walked up a file's parents to My Drive, its shared drive, or, where the account can't see further up, Shared with me. A document opens as Markdown and a presentation as text, as Drive exports them, up to its 10 MB export cap, beyond which its content says the file is too large, so it can still be renamed or trashed; a spreadsheet opens into its tabs (`sheet--`) and a tab into its rows (`row---`), 50 to a page, the page token the next row's number, each row's values "Header: value" lines under the tab's first row, a header repeated or a column without one going by its letter (`Status (E)`); a page past the tab's last row is empty and a row past it not found without asking Sheets, which refuses a range outside the grid. Folders, documents and spreadsheets are made in My Drive (`root`, where one made without a parent goes too), a folder or a shared drive, never in Shared with me, Shared drives or Recent, the record's `kind` saying which, the one select a create writes and an edit can't change: a document is uploaded as Markdown and a spreadsheet as CSV with the Google type Drive converts it to (a multipart upload, `drive-api.ts`), and a row made inside a tab goes after the last of its rows holding anything (`values.append` on the whole tab, written again from column A where Sheets started it further right), its "Header: value" lines put under the headers with `readValueLines`. An edit renames a file or a tab (`updateSheetProperties`), replaces a document's content whole by uploading Markdown again, only where it is written differently from the record as `runAgentTool` read it (`before`, passed to every connector's update), which drops its comments and suggestions, and changes a row only in the cells written differently (`values:batchUpdate`); a presentation's and a spreadsheet's content are only read, and the places and shared drives aren't changed. Files go to Trash with the `trash` command (`trashed: true`), never `files.delete`, and tabs and rows don't. A second collection, `ranges` (`records/google-ranges.ts`), is only run, as a database's queries are: Read (`readOnly`, Read files) and Write (Edit files) take a spreadsheet's id or link and a range in A1 notation, checked against a strict pattern before it reaches Google's address, and read or write its values as CSV (`records/csv.ts`; a read answers 1000 rows at most), written as if typed in, so a formula stays one, and answered as Sheets shows them afterwards. Every files action takes the `drive` scope, the Sheets API's writes included. Its Calendar is the nested `events` collection (`records/google-events.ts` over `connections/google/calendar-api.ts`): at the top the calendars in the account's list, primary first, inside each its events from now on, soonest first (the collection is `upcoming`, so lists say so and the agent side's next page is Later), 50 to a page with Google's token wrapped with where the list began, every time a repeating event comes round its own (`singleEvents`), a search being Google's `q`, one beginning with a day (`2026-10-15 standup`) listing from that day's start on the calendar's clock. A calendar's id is Google's; an event's is `:`. Times read as Google writes them, with their offset, an all-day event's end as its last day; they are written with an offset or on the calendar's clock (sent with its time zone), a missing end an hour or the day, and a start moved without a new end keeps the event's length. Guests are a field with an action of its own (Invite guests): writing guests, or changing an event that has any, needs it beyond Create and edit events, and Google emails them (`sendUpdates=all`; none for an event without guests); the account and rooms are kept when the guests are rewritten, as are the answers of those still invited. Accept, Decline and Maybe (Answer invitations) set the account's own answer on an event someone else invited it to. Events are never deleted. Its Contacts are the `contacts` collection (`records/google-contacts.ts` over `connections/google/people-api.ts`): the account's contacts, most recently changed first, 50 to a page, or the first 30 of Google's prefix search (after the empty search Google asks for first); ids are the resource name without `people/`. An edit sends only the fields written differently, with the contact's etag, an email, number or address still there keeping its label, and what the person's own Google profile shows beside the contact is read, never written. Contacts are never deleted, and Other contacts aren't listed. `google-connector.ts` runs a command on the collection it was run on, since `emails` and `files` both have `trash`. PostgreSQL and MySQL (MariaDB too) are two integrations on one code path: a database is connected with its host, port, database, user, password and encryption (`connections/sql/`: `route.ts` makes both routes per dialect, `sql-client.ts` opens one connection per request with `Bun.sql`, to the address `utils/host-check.ts` resolves the host to, refusing private addresses unless `ALLOW_PRIVATE_HOSTS` is set, and `sql-statement.ts` checks an agent's query is one statement that reads), and `records/sql-connector.ts` reads and writes it through `records/sql-dialect.ts`, where the two differ: the tables are one nested collection, a table holding its rows, a row read and written as "column: value" lines and found again by its primary key (ids `public.users:42`, `pairs:3,4`; a table without a key is never written to), and a query, a second collection that is only ever run, goes through a read-only transaction with a time limit, a cursor on Postgres and `sql_select_limit` on MySQL, 200 rows at most, answered as JSON lines. MySQL's read-only transaction doesn't stop DDL, so the statement check is what keeps a query to reading; the docs tell people to make a read-only database user as well. Lists take `?search=` (only where the collection has a `searchHint`; Granola has none), `?filter=` (the collection's `filterField`, such as `?filter=Draft`), `?parent=` (only in a `nested` collection: the list inside a record, answered with its `trail`, the records above it; a record made there goes in it) and `?page=` (`list-query.ts`) and answer a page, newest first (a mailbox's 50 emails, Granola's 30 days), with the next page's token; a mailbox's token holds where each folder goes on, so paging through the merged list shows every email once, and its search uses Gmail's own syntax on Gmail (`X-GM-EXT-1`) and IMAP text search elsewhere. Agents sign in at `/agent-auth/login` with a cookie of their own (`src/agent-auth/`); `src/agent-side/` serves them: the connections an agent can use, the tools it has with each, and `runAgentTool` (`tools.ts`), the one place a tool runs, which checks it against the agent's access as it is now, validates its params against the tool's schema (an edit changes only the fields given), calls the connector, or a custom server's tool as it is, answering `{text}`, and logs it, denied ones too, to the activity log that `src/activity/` lists. Over HTTP that is `/api/agent/me`, `GET /api/agent/connections/:id/tools` and `POST /api/agent/connections/:id/tools/:tool` with `{params}`; `src/mcp/` serves the same over MCP at `/mcp` (Streamable HTTP without sessions, JSON answers, no stream): five tools whatever the login may do, `list_connections` (the entry point), `list_tools`, `read` (only tools that read, marked read-only), `run` and `send_feedback`, and server instructions naming the agent's connections, telling it to point the person to a record by its `url`, never by Personal Agent Proxy's own addresses, and to tell us with `send_feedback` when something doesn't work as it expected. Feedback (`agent-side/feedback.ts`, also `POST /api/agent/feedback` with `{message, connection?, tool?}`) is kept in `AgentFeedback` for us to read; nothing in the app shows it. MCP clients sign in with OAuth 2.1 (`src/oauth/`), Personal Agent Proxy being its own authorization server: metadata under `/.well-known/`, Dynamic Client Registration at `/oauth/register` (open to anyone, each registration first deleting the clients that registered over a day ago and never finished a sign-in), `/oauth/authorize` sealing the request into the web app's `/connect-agent` page, where the person chooses the agent login the client works as or makes one, and `/oauth/token` with PKCE (S256 only) and refresh tokens replaced on every use; codes and tokens are kept as their SHA-256, and revoking an agent ends its clients' tokens. These endpoints take any origin (CORS), since none reads a cookie; credentials and Information records are stored encrypted with `ENCRYPTION_KEY` (`src/utils/secret-crypto.ts`). Has its own `.prettierrc.json` with a 200-column print width - `skills/` - Agent skills, symlinked into `.claude/skills` and `.codex/skills` by `bun run setup` diff --git a/packages/api/src/agent-auth/session.test.ts b/packages/api/src/agent-auth/session.test.ts index 771eb445..ac06e2eb 100644 --- a/packages/api/src/agent-auth/session.test.ts +++ b/packages/api/src/agent-auth/session.test.ts @@ -92,7 +92,7 @@ describe('handleAgentLoginRoute', () => { describe('authenticateAgentRequest', () => { test('looks the session up by the hash of the cookie', async () => { - getSignedInAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'})); + getSignedInAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []})); const {authenticateAgentRequest} = await import('./session'); const result = await authenticateAgentRequest({cookie: 'proxy.agent-session=abc'}); diff --git a/packages/api/src/agent-side/feedback.test.ts b/packages/api/src/agent-side/feedback.test.ts index 3b23a086..1e580a2e 100644 --- a/packages/api/src/agent-side/feedback.test.ts +++ b/packages/api/src/agent-side/feedback.test.ts @@ -5,7 +5,7 @@ const saveAgentFeedback = mock(); mock.module('@proxy/db/feedback', () => ({saveAgentFeedback})); -const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude'}; +const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude', experiments: []}; beforeEach(() => { mock.clearAllMocks(); diff --git a/packages/api/src/agent-side/route.test.ts b/packages/api/src/agent-side/route.test.ts index 4789ae67..70522c9a 100644 --- a/packages/api/src/agent-side/route.test.ts +++ b/packages/api/src/agent-side/route.test.ts @@ -11,7 +11,7 @@ const sendAgentFeedback = mock(); mock.module('./feedback', () => ({sendAgentFeedback})); -const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'}; +const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []}; function makeRequest(params: Record, body: unknown = undefined) { return {agent: signedIn, params, body, query: {}} as never; diff --git a/packages/api/src/agent-side/tools.test.ts b/packages/api/src/agent-side/tools.test.ts index 0580633f..a3d95f97 100644 --- a/packages/api/src/agent-side/tools.test.ts +++ b/packages/api/src/agent-side/tools.test.ts @@ -38,7 +38,7 @@ function integration(id: string): Integration { return found; } -const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'}; +const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []}; const caller = {agent: signedIn, via: 'mcp' as const}; const infoConnection = {id: 'info-1', integrationId: 'info', account: "Alex's Workspace", createdAt: new Date(), tools: null, name: null, defaults: [{actionId: 'readAddresses'}], credential: null}; diff --git a/packages/api/src/mcp/route.test.ts b/packages/api/src/mcp/route.test.ts index dd7e7d33..0c3fa367 100644 --- a/packages/api/src/mcp/route.test.ts +++ b/packages/api/src/mcp/route.test.ts @@ -24,7 +24,7 @@ mock.module('../observability/log', () => ({ mock.module('../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com'}})); -const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude'}; +const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude', experiments: []}; const mailbox = {id: 'mail-1', integrationId: 'email', account: 'alex@example.com', actions: ['read', 'archive'], tools: null, name: null}; function makeRes() { diff --git a/packages/api/src/me/route.test.ts b/packages/api/src/me/route.test.ts index d46e04e0..9236bec8 100644 --- a/packages/api/src/me/route.test.ts +++ b/packages/api/src/me/route.test.ts @@ -42,7 +42,7 @@ const user = { function makeRequest() { return { - user: {userId: 'user-1', email: 'test@example.com', name: 'Test User'}, + user: {userId: 'user-1', email: 'test@example.com', name: 'Test User', experiments: []}, }; } @@ -70,6 +70,7 @@ describe('handleMeRoute', () => { image: 'https://example.com/avatar.png', }, onboarded: true, + experiments: [], }); expect(getUserFromId).toHaveBeenCalledWith('user-1'); expect(getUserOrganization).toHaveBeenCalledWith('user-1'); diff --git a/packages/api/src/me/route.ts b/packages/api/src/me/route.ts index a4b312b2..5bea6633 100644 --- a/packages/api/src/me/route.ts +++ b/packages/api/src/me/route.ts @@ -1,7 +1,7 @@ import type {Response} from 'express'; import {Err, type Result} from 'ts-results-es'; -import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, type Experiment, requirePresent} from '@proxy/utils'; import {listConnectionsWithCredential} from '@proxy/db/connection'; import {getUserOrganization, markOrganizationOnboarded} from '@proxy/db/organization'; import {deleteUser, getUserFromId} from '@proxy/db/user'; @@ -16,6 +16,8 @@ export type MeResponse = { user: {id: string; email: string; name: string | null; image: string | null}; /** False until the welcome flow is finished or skipped; the web app shows it instead of the app until then. */ onboarded: boolean; + /** The experiments on for the organization, for the web app's `useExperiment`. */ + experiments: Experiment[]; }; /** Who is signed in. The web app's guard: 401 means "send them to /login". */ @@ -31,6 +33,7 @@ export function handleMeRoute(request: AuthenticatedRequest): Promise { expect(isProviderConflict(ApiErr.forbidden())).toBe(false); }); }); + +describe('toSignedInAgent', () => { + const agent = { + id: 'agent-1', + orgId: 'org-1', + providerId: 'claude', + name: 'Claude', + revokedAt: null, + org: {insider: false, experiments: ['shipped-long-ago']}, + }; + + test("carries its organization's experiments, without ids that are no longer experiments", () => { + expect(toSignedInAgent(agent)).toEqual({ + agentId: 'agent-1', + orgId: 'org-1', + providerId: 'claude', + name: 'Claude', + experiments: [], + }); + }); + + test('is no one once revoked', () => { + expect(toSignedInAgent({...agent, revokedAt: new Date()})).toBeNull(); + }); +}); diff --git a/packages/db/src/agent.ts b/packages/db/src/agent.ts index 4d36d0c8..a373f034 100644 --- a/packages/db/src/agent.ts +++ b/packages/db/src/agent.ts @@ -4,6 +4,7 @@ import type {Result} from 'ts-results-es'; import {type ApiError, wrapDb} from '@proxy/utils'; import {db} from '.'; +import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent'; export type AgentRow = { id: string; @@ -161,12 +162,7 @@ export async function createAgentSession(data: { return (await wrapDb(() => db.agentSession.create({data}))).map(() => undefined); } -export type SignedInAgent = { - agentId: string; - orgId: string; - providerId: string; - name: string; -}; +export type {SignedInAgent} from './signed-in-agent'; /** `Ok(null)` means no live session: unknown, expired, or the agent has been revoked. */ export async function getSignedInAgent( @@ -176,24 +172,14 @@ export async function getSignedInAgent( await wrapDb(() => db.agentSession.findUnique({ where: {tokenHash}, - select: { - expires: true, - agent: { - select: {id: true, orgId: true, providerId: true, name: true, revokedAt: true}, - }, - }, + select: {expires: true, agent: {select: signedInAgentSelect}}, }), ) ).map((session) => { - if (!session || session.expires <= new Date() || session.agent.revokedAt !== null) { + if (!session || session.expires <= new Date()) { return null; } - return { - agentId: session.agent.id, - orgId: session.agent.orgId, - providerId: session.agent.providerId, - name: session.agent.name, - }; + return toSignedInAgent(session.agent); }); } diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index 541262e0..7dcb7c25 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -1,6 +1,6 @@ import {Ok, Result} from 'ts-results-es'; -import {wrapDb, type ApiError} from '@proxy/utils'; +import {type ApiError, type Experiment, organizationExperiments, wrapDb} from '@proxy/utils'; import {db, Prisma} from '.'; @@ -11,6 +11,8 @@ export type AuthenticatedSession = { email: string; name: string | null; }; + /** The experiments on for the user's organization. */ + experiments: Experiment[]; expires: Date; }; @@ -28,6 +30,12 @@ export async function getAuthenticatedSessionDatabase( select: { name: true, email: true, + // Their organization, the first they joined as getUserOrganization reads it. + orgMembers: { + select: {org: {select: {insider: true, experiments: true}}}, + orderBy: {createdAt: 'asc'}, + take: 1, + }, }, }, expires: true, @@ -47,6 +55,7 @@ export async function getAuthenticatedSessionDatabase( return Ok(null); } + const organization = session.user.orgMembers[0]?.org; return Ok({ sessionToken: session.sessionToken, userId: session.userId, @@ -54,6 +63,7 @@ export async function getAuthenticatedSessionDatabase( email: session.user.email, name: session.user.name, }, + experiments: organization ? organizationExperiments(organization) : [], expires: session.expires, }); } diff --git a/packages/db/src/oauth.ts b/packages/db/src/oauth.ts index f59ea837..bb050e54 100644 --- a/packages/db/src/oauth.ts +++ b/packages/db/src/oauth.ts @@ -3,7 +3,7 @@ import type {Result} from 'ts-results-es'; import {type ApiError, wrapDb} from '@proxy/utils'; import {db} from '.'; -import type {SignedInAgent} from './agent'; +import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent'; // Agents signing in over MCP with OAuth: the clients that registered themselves, the codes they // exchange once, and the tokens of each client signed in as an agent. Codes and tokens are kept @@ -131,18 +131,14 @@ export async function getAgentByAccessToken( await wrapDb(() => db.oAuthGrant.findUnique({ where: {accessTokenHash}, - select: { - accessExpires: true, - agent: {select: {id: true, orgId: true, providerId: true, name: true, revokedAt: true}}, - }, + select: {accessExpires: true, agent: {select: signedInAgentSelect}}, }), ) ).map((grant) => { - if (!grant || grant.accessExpires <= new Date() || grant.agent.revokedAt !== null) { + if (!grant || grant.accessExpires <= new Date()) { return null; } - const {agent} = grant; - return {agentId: agent.id, orgId: agent.orgId, providerId: agent.providerId, name: agent.name}; + return toSignedInAgent(grant.agent); }); } diff --git a/packages/db/src/signed-in-agent.ts b/packages/db/src/signed-in-agent.ts new file mode 100644 index 00000000..f4deb8f9 --- /dev/null +++ b/packages/db/src/signed-in-agent.ts @@ -0,0 +1,41 @@ +import type {Prisma} from '@prisma/client'; + +import {type Experiment, organizationExperiments} from '@proxy/utils'; + +// An agent signed in with its cookie (agent.ts) or an MCP client's token (oauth.ts), read the same +// way by both. A module of its own, so tests that mock either of them still get it. + +export type SignedInAgent = { + agentId: string; + orgId: string; + providerId: string; + name: string; + /** The experiments on for the agent's organization. */ + experiments: Experiment[]; +}; + +/** What a signed-in agent is read from, with a cookie or an MCP client's token. */ +export const signedInAgentSelect = { + id: true, + orgId: true, + providerId: true, + name: true, + revokedAt: true, + org: {select: {insider: true, experiments: true}}, +} satisfies Prisma.AgentSelect; + +/** `null` for a revoked agent. */ +export function toSignedInAgent( + agent: Prisma.AgentGetPayload<{select: typeof signedInAgentSelect}>, +): SignedInAgent | null { + if (agent.revokedAt !== null) { + return null; + } + return { + agentId: agent.id, + orgId: agent.orgId, + providerId: agent.providerId, + name: agent.name, + experiments: organizationExperiments(agent.org), + }; +} diff --git a/packages/utils/src/experiments.test.ts b/packages/utils/src/experiments.test.ts new file mode 100644 index 00000000..451c5d36 --- /dev/null +++ b/packages/utils/src/experiments.test.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from 'bun:test'; + +import {EXPERIMENTS, isExperiment, organizationExperiments} from './experiments'; + +describe('organizationExperiments', () => { + it('drops ids that are no longer experiments', () => { + expect(organizationExperiments({insider: false, experiments: ['shipped-long-ago']})).toEqual( + [], + ); + }); + + it('turns every experiment on for an insider, whatever it has of its own', () => { + expect(organizationExperiments({insider: true, experiments: ['shipped-long-ago']})).toEqual( + Object.keys(EXPERIMENTS).filter(isExperiment), + ); + }); +}); + +describe('isExperiment', () => { + it('does not take what every object has', () => { + expect(isExperiment('toString')).toBe(false); + expect(isExperiment('constructor')).toBe(false); + }); +}); diff --git a/packages/utils/src/experiments.ts b/packages/utils/src/experiments.ts new file mode 100644 index 00000000..a4f36459 --- /dev/null +++ b/packages/utils/src/experiments.ts @@ -0,0 +1,33 @@ +/** + * Features being tried out, each turned on for some organizations before everyone gets it, from + * the internal admin (`Organization.experiments`, or all of them for an insider organization). The + * key is the id kept in the database, the value what the admin calls it. When an experiment ships + * or is dropped, delete it here, and the compiler points at every place that asked for it; its id + * left in the database is ignored. + */ +export const EXPERIMENTS = {} as const satisfies Record; + +export type Experiment = keyof typeof EXPERIMENTS; + +export function isExperiment(id: string): id is Experiment { + return Object.hasOwn(EXPERIMENTS, id); +} + +/** What is on for an organization: every experiment for an insider, else its own that still exist. */ +export function organizationExperiments(organization: { + insider: boolean; + experiments: string[]; +}): Experiment[] { + if (organization.insider) { + return Object.keys(EXPERIMENTS).filter(isExperiment); + } + return organization.experiments.filter(isExperiment); +} + +/** Whether a signed-in person or agent (`request.user`, `SignedInAgent`, the web app's `Me`) has the experiment on. */ +export function hasExperiment( + holder: {experiments: Experiment[]}, + experiment: Experiment, +): boolean { + return holder.experiments.includes(experiment); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index da7b3e68..97d66fd7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,8 @@ export {ApiErr, setDbLogger, wrapDb} from './api-error'; export type {ApiError, DbLogger} from './api-error'; export {Do} from './do'; +export {EXPERIMENTS, hasExperiment, isExperiment, organizationExperiments} from './experiments'; +export type {Experiment} from './experiments'; export {formatFetchError, httpRequest, readJsonValidated} from './fetch'; export type {FetchError} from './fetch'; export {parseSchema, requirePresent} from './parse'; diff --git a/packages/web/src/client/me-client.ts b/packages/web/src/client/me-client.ts index bc6ee835..448f4a61 100644 --- a/packages/web/src/client/me-client.ts +++ b/packages/web/src/client/me-client.ts @@ -1,4 +1,4 @@ -import {httpRequest, type FetchError} from '@proxy/utils'; +import {httpRequest, isExperiment, type FetchError} from '@proxy/utils'; import {z} from 'zod'; import {apiSend} from '@/client/request'; @@ -11,6 +11,8 @@ const meSchema = z.object({ }), // False until the welcome flow is finished or skipped; the human side shows it until then. onboarded: z.boolean(), + // The experiments on for the organization; read them with useExperiment. + experiments: z.array(z.string()).transform((ids) => ids.filter(isExperiment)), }); export type Me = z.infer; diff --git a/packages/web/src/hooks/use-experiment.ts b/packages/web/src/hooks/use-experiment.ts new file mode 100644 index 00000000..d726fe12 --- /dev/null +++ b/packages/web/src/hooks/use-experiment.ts @@ -0,0 +1,11 @@ +import {hasExperiment, type Experiment} from '@proxy/utils'; +import {useRouteLoaderData} from 'react-router'; +import type {Me} from '@/client/me-client'; + +// Whether the experiment is on for the signed-in person's organization, from the human side's +// loader; off outside it, as in the welcome flow. Hiding something here is only for looks: the +// api checks the experiment itself. +export function useExperiment(experiment: Experiment): boolean { + const me = useRouteLoaderData('human'); + return me ? hasExperiment(me, experiment) : false; +}