Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ jobs:
env:
GITHUB_APP_CLIENT_ID: ${{ vars.DIFFITY_GITHUB_APP_CLIENT_ID }}
GITHUB_APP_SLUG: ${{ vars.DIFFITY_GITHUB_APP_SLUG }}
GITHUB_APP_ID: ${{ vars.DIFFITY_GITHUB_APP_ID }}
# /projects/<project number>/global/backendServices/<id of the `diffity` backend service>
IAP_AUDIENCE: ${{ vars.DIFFITY_IAP_AUDIENCE }}
run: |
Expand All @@ -100,6 +101,11 @@ jobs:
if [ -n "$GITHUB_APP_SLUG" ]; then
env_vars+=",DIFFITY_GITHUB_APP_SLUG=${GITHUB_APP_SLUG}"
fi
# Without the App's id and key, agents cannot post reviews as the App.
if [ -n "$GITHUB_APP_ID" ]; then
env_vars+=",DIFFITY_GITHUB_APP_ID=${GITHUB_APP_ID}"
secrets+=",DIFFITY_GITHUB_APP_PRIVATE_KEY=diffity-github-app-private-key:latest"
fi

gcloud run deploy diffity \
--project "$PROJECT" \
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: test
on:
pull_request:
push:
branches: [hosted-server]
branches: [develop]

permissions:
contents: read
Expand All @@ -13,7 +13,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: NaturalCycles/ci-typescript/actions/setup-node-pnpm@v2
# The shared NaturalCycles/ci-typescript actions live in a private repository, which a public
# repository cannot use; these are the public equivalents (pnpm's version comes from packageManager).
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# Typecheck resolves workspace imports through built dists, so the build comes first.
- run: pnpm build
- run: pnpm test
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ The first tool call opens the browser to sign in and allow the connection. Then:
- **Posting.** From the page the user posts the findings to the pull request as one review, and
pulls GitHub's review threads in. Posting is only for pull request sessions, and refused once the
pull request has moved past the session's head. GitHub is reached as the user: through the GitHub
App they connect on **Settings**, or a token pasted there when no App is configured.
App they connect on **Settings**, or a token pasted there when no App is configured. An agent
posts as the App instead (see Automated reviews).

The **Sessions** page also lists the open pull requests that ask for the user's review, asked of
GitHub's search with the user's own access (cached for a minute). Each row links the user's newest
Expand Down Expand Up @@ -85,6 +86,41 @@ cookie, no MCP token. A live token is stored hashed, expires after 12 hours, and
nothing else: on its one session, or for a queue token on the user's queue and any of the user's
sessions. Once it is refused (401), the command exits with status 1.

### Automated reviews

Humans post as themselves, from the page; agents only ever post as the bot, `diffity[bot]`, and the
bot's review names the human whose token asked for it.

An agent that cannot sign in, such as a scheduled routine on every new pull request, reaches the
MCP endpoint with an automation token: a signed-in agent mints one with `automation_token { name }`,
and the routine holds it as a secret. It acts as the user who minted it, with every tool, until
revoked; its sessions are that user's and show in their list. It is stored as a sha256 hash.

```bash
claude mcp add --transport http diffity https://diffity.prod.naturalcycles.net/mcp \
--header "Authorization: Bearer ${DIFFITY_AUTOMATION_TOKEN}"
```

or in `.mcp.json`:

```json
{ "mcpServers": { "diffity": { "type": "http", "url": "https://diffity.prod.naturalcycles.net/mcp",
"headers": { "Authorization": "Bearer ${DIFFITY_AUTOMATION_TOKEN}" } } } }
```

The run is `create_session { repo, pr }`, the server's `review` prompt, `post_review { session,
event: "COMMENT" }`, then `review_done`. `post_review` sends the session's open findings, with its
general comments as the summary, as one review by the App, on an installation token the server
mints from the App's private key; it takes `COMMENT` or `REQUEST_CHANGES`, never an approval. A
second run on the same head finds the existing session, and what is already on GitHub is skipped.

| Tool | |
|---|---|
| `post_review { session, event, body? }` | Post the open findings as the App; answers `{ posted, skipped, url }` |
| `automation_token { name }` | Mint a token; it is shown once |
| `automation_tokens {}` | The user's tokens: name, created, last used |
| `automation_token_revoke { name }` | Revoke one |

A repository can name its review standards in a `.diffity.json` at its root, which the
`get_standards` tool reads:

Expand Down Expand Up @@ -130,6 +166,8 @@ environment runs the server from source and restarts it when that changes; after
| `DIFFITY_ALLOWED_EMAILS` | — | Comma-separated addresses; replaces the domain rule when set |
| `DIFFITY_GITHUB_APP_CLIENT_ID`, `DIFFITY_GITHUB_APP_CLIENT_SECRET` | — | The GitHub App users connect on **Settings**; without it, users paste a token there instead |
| `DIFFITY_GITHUB_APP_SLUG` | — | The App's URL name, for the install link on **Settings** |
| `DIFFITY_GITHUB_APP_ID` | — | The App's numeric id; with the private key, agents post reviews as the App (`post_review`) |
| `DIFFITY_GITHUB_APP_PRIVATE_KEY` | — | The App's private key (PEM; one line with literal `\n` works too), for the App's own installation tokens |
| `DIFFITY_DEV_GITHUB_TOKEN` | — | A token used for users who have not connected GitHub; localhost only |
| `GITHUB_API_URL` | `https://api.github.com` | GitHub REST and GraphQL API |
| `GITHUB_URL` | `https://github.com` | Where the App's authorization and token exchange happen |
Expand Down Expand Up @@ -159,7 +197,8 @@ Cloud Run service `diffity` in `nc-innovation-496314`. The infrastructure (IAP l
`diffity.prod.naturalcycles.net`, Cloud SQL Postgres over the VPC connector, runtime service
account, secrets) is in NaturalCycles/NCInfraIaC. On the load balancer, `/mcp`, `/token`,
`/register`, `/revoke`, `/live/await` and the OAuth `/.well-known/*` metadata bypass IAP, since
agents authenticate with diffity's own OAuth or a live token; everything else is behind IAP. The
agents authenticate with diffity's own OAuth, an automation token or a live token; everything else
is behind IAP. The
service accepts unauthenticated invocations for those open paths, so the deploy limits its ingress
to the load balancer and disables the `run.app` URL.

Expand All @@ -176,6 +215,8 @@ The deploy runs one instance (pending OAuth consents are held in memory), sets
`DIFFITY_GITHUB_APP_SLUG`) for the GitHub App; with it, the secret
`diffity-github-app-client-secret` is mounted as `DIFFITY_GITHUB_APP_CLIENT_SECRET`. Without it,
users paste a GitHub token on **Settings**.
- optionally the repository variable `DIFFITY_GITHUB_APP_ID`, for agents to post as the App; with
it, the secret `diffity-github-app-private-key` is mounted as `DIFFITY_GITHUB_APP_PRIVATE_KEY`.

## Access and isolation

Expand All @@ -185,8 +226,8 @@ The deploy runs one instance (pending OAuth consents are held in memory), sets
request against Google's keys and `DIFFITY_IAP_AUDIENCE`; a request without it gets 401.
- Before a session is created, GitHub is asked whether the user's token can read the repository.
Mirror fetches use that token through the environment, never the mirror's config.
- OAuth client secrets, codes, access and refresh tokens and the web session cookie are stored as
sha256 hashes; GitHub tokens (pasted, or the App's access and refresh tokens) are encrypted with
- OAuth client secrets, codes, access and refresh tokens, automation tokens and the web session
cookie are stored as sha256 hashes; GitHub tokens (pasted, or the App's access and refresh tokens) are encrypted with
`DIFFITY_SECRET_KEY`.
- Posting is only for pull request sessions, and refused once the pull request has moved past the
session's head: the user asks for a new session, which the open findings carry over to.
Expand Down
23 changes: 21 additions & 2 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { GENERAL_THREAD_FILE_PATH, type LiveStatusResponse } from '@diffity/api'
import type { Config } from './config.js';
import { WEB_SESSION_MAX_AGE_SECONDS, type Users, type User, type WebSessions } from './users.js';
import { hashPresentedClientSecret, type OAuthProvider } from './oauth.js';
import type { AutomationTokens } from './automation.js';
import { ServiceError, describeSession, type ReviewRequests, type ReviewService } from './service.js';
import type { DevLoginProvider, IapLogin } from './login.js';
import { GitHubConnectError, type GitHubAppAccess } from './github-app.js';
Expand All @@ -33,6 +34,7 @@ export interface AppDeps {
users: Users;
webSessions: WebSessions;
oauth: OAuthProvider;
automationTokens: AutomationTokens;
service: ReviewService;
live: Live;
devLogin: DevLoginProvider | null;
Expand Down Expand Up @@ -203,7 +205,22 @@ export function createApp(deps: AppDeps): express.Express {
});

const bearer = requireBearerAuth({
verifier: oauth,
verifier: {
verifyAccessToken: async token => {
const automation = await deps.automationTokens.verify(token);
if (!automation) {
return oauth.verifyAccessToken(token);
}
return {
token,
clientId: `automation:${automation.name}`,
scopes: [],
// The SDK requires an expiry; an automation token lasts until it is revoked.
expiresAt: Number.MAX_SAFE_INTEGER,
extra: { userId: automation.userId, automation: true, automationName: automation.name },
};
},
},
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
});

Expand All @@ -213,11 +230,13 @@ export function createApp(deps: AppDeps): express.Express {
res.status(401).json({ error: 'The token belongs to no user' });
return;
}
const automationName = req.auth!.extra!.automation === true ? String(req.auth!.extra!.automationName) : null;
const server = createMcpServer({
service,
live,
automationTokens: deps.automationTokens,
userId,
agentName: (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude',
agentName: automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude',
version: deps.version,
});
// Stateless: every request is complete in itself, so nothing is held between them.
Expand Down
66 changes: 66 additions & 0 deletions packages/server/src/automation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Db } from './db.js';
import { randomToken, sha256 } from './crypto.js';
import { ServiceError } from './service.js';

/** Recognisable in a secret store or a leaked log, and cheap to tell from an OAuth token. */
export const AUTOMATION_TOKEN_PREFIX = 'dfa_';
const LAST_USED_RESOLUTION_MS = 60 * 1000;

export interface AutomationTokenInfo {
name: string;
createdAt: string;
lastUsedAt: string | null;
}

/**
* Bearer tokens for agents that cannot do OAuth, such as a scheduled routine: they act as the user
* who minted them, with every MCP tool, until revoked. Only their hash is stored.
*/
export class AutomationTokens {
constructor(private readonly db: Db) {}

async mint(userId: string, name: string, now = Date.now()): Promise<string> {
const token = `${AUTOMATION_TOKEN_PREFIX}${randomToken()}`;
const inserted = await this.db.one(
`INSERT INTO automation_tokens (token_hash, user_id, name, created_at) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, name) DO NOTHING RETURNING token_hash`,
[sha256(token), userId, name, new Date(now).toISOString()],
);
if (!inserted) {
throw new ServiceError(`You already have an automation token named "${name}"; revoke it first or pick another name`);
}
return token;
}

async list(userId: string): Promise<AutomationTokenInfo[]> {
const rows = await this.db.query<{ name: string; created_at: string; last_used_at: string | null }>(
'SELECT name, created_at, last_used_at FROM automation_tokens WHERE user_id = $1 ORDER BY created_at, name',
[userId],
);
return rows.map(row => ({ name: row.name, createdAt: row.created_at, lastUsedAt: row.last_used_at }));
}

async revoke(userId: string, name: string): Promise<boolean> {
const rows = await this.db.query('DELETE FROM automation_tokens WHERE user_id = $1 AND name = $2 RETURNING name', [userId, name]);
return rows.length > 0;
}

/** Null for anything that is not a live automation token, including every OAuth token. */
async verify(token: string, now = Date.now()): Promise<{ userId: string; name: string } | null> {
if (!token.startsWith(AUTOMATION_TOKEN_PREFIX)) {
return null;
}
const hash = sha256(token);
const row = await this.db.one<{ user_id: string; name: string; last_used_at: string | null }>(
'SELECT user_id, name, last_used_at FROM automation_tokens WHERE token_hash = $1',
[hash],
);
if (!row) {
return null;
}
if (!row.last_used_at || Date.parse(row.last_used_at) + LAST_USED_RESOLUTION_MS <= now) {
await this.db.query('UPDATE automation_tokens SET last_used_at = $1 WHERE token_hash = $2', [new Date(now).toISOString(), hash]);
}
return { userId: row.user_id, name: row.name };
}
}
35 changes: 33 additions & 2 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomBytes } from 'node:crypto';
import { createPrivateKey, randomBytes } from 'node:crypto';

export interface Config {
publicUrl: URL;
Expand Down Expand Up @@ -34,6 +34,13 @@ export interface GitHubAppConfig {
clientSecret: string;
/** For the install link; the app's name in its github.com URL. */
slug: string | null;
/** Lets agents post reviews as the App itself; null without the App id and private key. */
bot: GitHubBotConfig | null;
}

export interface GitHubBotConfig {
appId: number;
privateKey: string;
}

export class ConfigError extends Error {}
Expand Down Expand Up @@ -114,12 +121,36 @@ function parseGitHubApp(env: NodeJS.ProcessEnv): GitHubAppConfig | null {
const clientId = env.DIFFITY_GITHUB_APP_CLIENT_ID?.trim();
const clientSecret = env.DIFFITY_GITHUB_APP_CLIENT_SECRET?.trim();
if (!clientId && !clientSecret) {
if (env.DIFFITY_GITHUB_APP_ID?.trim() || env.DIFFITY_GITHUB_APP_PRIVATE_KEY?.trim()) {
throw new ConfigError('DIFFITY_GITHUB_APP_ID needs the App configured: DIFFITY_GITHUB_APP_CLIENT_ID and DIFFITY_GITHUB_APP_CLIENT_SECRET');
}
return null;
}
if (!clientId || !clientSecret) {
throw new ConfigError('DIFFITY_GITHUB_APP_CLIENT_ID and DIFFITY_GITHUB_APP_CLIENT_SECRET go together');
}
return { clientId, clientSecret, slug: env.DIFFITY_GITHUB_APP_SLUG?.trim() || null };
return { clientId, clientSecret, slug: env.DIFFITY_GITHUB_APP_SLUG?.trim() || null, bot: parseGitHubBot(env) };
}

function parseGitHubBot(env: NodeJS.ProcessEnv): GitHubBotConfig | null {
const appId = env.DIFFITY_GITHUB_APP_ID?.trim();
// A secret pasted as one line keeps its line breaks as literal \n.
const privateKey = env.DIFFITY_GITHUB_APP_PRIVATE_KEY?.trim().replace(/\\n/g, '\n');
if (!appId && !privateKey) {
return null;
}
if (!appId || !privateKey) {
throw new ConfigError('DIFFITY_GITHUB_APP_ID and DIFFITY_GITHUB_APP_PRIVATE_KEY go together');
}
if (!/^\d+$/.test(appId)) {
throw new ConfigError(`DIFFITY_GITHUB_APP_ID must be the App's numeric id, got ${appId}`);
}
try {
createPrivateKey(privateKey);
} catch {
throw new ConfigError('DIFFITY_GITHUB_APP_PRIVATE_KEY is not a PEM private key');
}
return { appId: Number(appId), privateKey };
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
Expand Down
13 changes: 13 additions & 0 deletions packages/server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,19 @@ export const MIGRATIONS: Migration[] = [
ALTER TABLE users ADD COLUMN live_polled_at BIGINT;
`,
},
{
version: 5,
sql: `
CREATE TABLE automation_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
UNIQUE (user_id, name)
);
`,
},
];

/** Any constant; two instances starting together take turns migrating. */
Expand Down
48 changes: 47 additions & 1 deletion packages/server/src/github-app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { GitHubAppConfig } from './config.js';
import { sign } from 'node:crypto';
import type { GitHubAppConfig, GitHubBotConfig } from './config.js';
import type { Db, Queryable } from './db.js';
import { decrypt, encrypt, randomToken, sha256 } from './crypto.js';
import { keyedSerializer } from './git.js';
import type { GitHubAccess, GitHubApi } from './github.js';

type Fetch = typeof fetch;
Expand Down Expand Up @@ -183,3 +185,47 @@ export class GitHubAppAccess implements GitHubAccess {
);
}
}

/** GitHub refuses an App JWT that expires more than 10 minutes out; issued a minute back for clock drift. */
export function appJwt(bot: GitHubBotConfig, now: number): string {
const seconds = Math.floor(now / 1000);
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
const unsigned = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ iat: seconds - 60, exp: seconds + 9 * 60, iss: bot.appId })}`;
return `${unsigned}.${sign('RSA-SHA256', Buffer.from(unsigned), bot.privateKey).toString('base64url')}`;
}

/**
* The App acting as itself, `<slug>[bot]` on GitHub, with installation tokens minted from its
* private key. A token is good for an hour and kept per installation until shortly before then.
*/
export class GitHubAppBot {
private readonly tokens = new Map<number, { token: string; expiresAt: number }>();
private readonly lock = keyedSerializer();

constructor(
private readonly bot: GitHubBotConfig,
private readonly options: { api: GitHubApi; slug: string | null; now?: () => number },
) {}

private now(): number {
return this.options.now?.() ?? Date.now();
}

/** The account its comments are under; unknown without the slug. */
get login(): string | null {
return this.options.slug ? `${this.options.slug}[bot]` : null;
}

async installationToken(owner: string, repo: string): Promise<string> {
const installationId = await this.options.api.repoInstallationId(appJwt(this.bot, this.now()), owner, repo);
return this.lock(String(installationId))(async () => {
const cached = this.tokens.get(installationId);
if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > this.now()) {
return cached.token;
}
const minted = await this.options.api.createInstallationToken(appJwt(this.bot, this.now()), installationId);
this.tokens.set(installationId, { token: minted.token, expiresAt: Date.parse(minted.expiresAt) });
return minted.token;
});
}
}
Loading
Loading