Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ CLOUDFLARE_AUTHORIZATION_ORIGIN=https://dash.cloudflare.com
CLOUDFLARE_CLIENT_ID=
CLOUDFLARE_CLIENT_SECRET=
CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY=
CONTEXT7_API_ORIGIN=https://context7.com/api
CONTEXT7_OAUTH_ISSUER=https://clerk.context7.com
CONTEXT7_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key
LINEAR_API_ORIGIN=https://api.linear.app
LINEAR_AUTHORIZATION_ORIGIN=https://linear.app
LINEAR_CLIENT_ID=replace-with-linear-oauth-client-id
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ identity model has already passed a capability review.
| GitHub | Provider-delegated application actor | Shared GitHub App actor with trusted Agent attribution | 1 | Alpha |
| Linear | Provider-delegated native App actor | Shared App user with trusted per-operation Agent attribution | 1 | Experimental |
| Cloudflare | Native service principal | Dedicated account-owned token actor in audit logs | 1 | Design |
| Context7 | Provider-delegated user | Shared OAuth user grant with Agent-attributed adapter audit | 1 | Experimental |
| GitLab | Native service principal | Dedicated service account visible in groups, projects, and audit records | 2 | Proposal |
| Bitbucket | Native service principal | Repository, project, or workspace access-token actor | 2 | Proposal |
| Vercel | Native service principal | Dedicated integration identity with provider-side audit correlation | 2 | Proposal |
Expand Down Expand Up @@ -170,7 +171,7 @@ specs/
github-adapter.feature
linear-adapter.feature
src/
core/ Shared HTTP lifecycle, DPoP, Agent Profile, and errors
core/ Shared HTTP lifecycle, DPoP, managed OpenAPI runtime, Agent Profile, and errors
providers/ Isolated provider connections, permission translation, proxy, and transformations
storage/ Worker-owned D1 runtime state
worker.ts Cloudflare Worker entrypoint
Expand Down Expand Up @@ -214,6 +215,12 @@ Set `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, `GITHUB_CLIENT_ID`, and
`GITHUB_CLIENT_SECRET` in the ignored `.dev.vars` file. Both GitHub-downloaded
PKCS#1 keys and unencrypted PKCS#8 PEM keys are accepted.

Context7 uses the reusable managed OpenAPI runtime. It does not need a
provisioned client ID or client secret: the Adapter dynamically registers a
public OAuth client and uses S256 PKCE. Set only a base64-encoded 32-byte
`CONTEXT7_CREDENTIAL_ENCRYPTION_KEY`; the Adapter persists the resulting public
client ID and encrypts controller credentials in D1.

Configure the GitHub App callbacks as:

```text
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Agent 能以自己的稳定身份直接进入各种平台。详见
| GitHub | 代理应用身份 | 共享 GitHub App actor,并注入可信 Agent 角标 | 1 | Alpha |
| Linear | 代理的原生 App actor | 共享 App user,以及逐次操作中的可信 Agent 名称/头像 | 1 | 实验性 |
| Cloudflare | 原生 service principal | 独立 account-owned token actor 出现在审计日志中 | 1 | 设计中 |
| Context7 | 代理用户身份 | 共享 OAuth 用户授权,并由 Adapter 审计记录具体 Agent | 1 | 实验性 |
| GitLab | 原生 service principal | 独立 service account 出现在 group、project 与审计记录中 | 2 | 提案 |
| Bitbucket | 原生 service principal | repository、project 或 workspace access-token actor | 2 | 提案 |
| Vercel | 原生 service principal | 独立 integration 身份,并可关联平台侧审计 | 2 | 提案 |
Expand Down
24 changes: 24 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ into Realmroot, adds another authorization model, or introduces a private
Realmroot-to-Adapter protocol requires an architecture decision and security
review before implementation.

## Managed OpenAPI adapters

Information-query providers often have a useful OpenAPI contract and ordinary
OAuth, but none of the Agent-facing discovery or proof machinery. They use a
shared managed runtime instead of duplicating that machinery per provider.

A managed provider definition supplies a fixed upstream origin, a canonical
resource-oriented OpenAPI document, an operation allowlist, scope mapping, and
a credential resolver. The runtime supplies RFC 9728 metadata, service
description discovery, Adapter-issued DPoP authentication, scope enforcement,
credential/header isolation, response streaming, and privacy-preserving audit.
Provider OpenAPI is input to the mapping; it is not permission to expose every
upstream path.

Upstream OAuth is composed separately. Providers with RFC 7591 dynamic client
registration can use the shared public-client implementation with S256 PKCE,
so deployment needs an encryption key but no manually provisioned client ID or
secret. More complex provider consent, context selection, or lifecycle rules
remain isolated in a provider module while reusing the same Agent boundary.

Context7 is the first managed provider. Its action-shaped upstream search and
context endpoints are published as the `/libraries` collection and the
`/documentation` derived representation, both under `documentation:read`.

## Target architecture

```text
Expand Down
16 changes: 16 additions & 0 deletions migrations/0010_managed_openapi_context7.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE managed_oauth_client (
provider_id TEXT PRIMARY KEY NOT NULL,
client_id TEXT NOT NULL,
created_at INTEGER NOT NULL
);

CREATE TABLE context7_external_credential (
subject TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL,
access_token_ciphertext TEXT NOT NULL,
refresh_token_ciphertext TEXT NOT NULL,
token_expires_at INTEGER NOT NULL,
provider_scope_json TEXT NOT NULL,
credential_version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
40 changes: 40 additions & 0 deletions providers/context7/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Context7 capability report

Status: Experimental

Context7 exposes a small read-only OpenAPI API and an OAuth 2 authorization
server with dynamic client registration. It does not expose the Realmroot
Resource Server contract directly, so the managed OpenAPI runtime supplies the
Agent-facing boundary.

## Published resources

| Agent operation | Upstream operation | Scope |
| --- | --- | --- |
| `GET /context7/libraries` | `GET /api/v2/libs/search` | `documentation:read` |
| `GET /context7/documentation` | `GET /api/v2/context` | `documentation:read` |

Both operations preserve Context7 query and response semantics. Unlisted
Context7 paths are not reachable through the Adapter.

## Authorization and identity

The Adapter dynamically registers a public OAuth client with Context7 and uses
authorization code with S256 PKCE. The registered client ID is public state in
D1. PKCE verifier state and controller access/refresh tokens are encrypted with
`CONTEXT7_CREDENTIAL_ENCRYPTION_KEY`.

Context7 sees the connected OAuth user. Realmroot and Adapter audit retain the
originating Agent. Context7 does not natively enforce or display that Agent, so
the declared identity level is `provider-delegated` with audit-only
attribution.

## Native readiness gaps

- no Realmroot Agent principal at the Context7 API boundary;
- no Agent-visible attribution in Context7;
- no DPoP-bound Context7 access token;
- no Realmroot protected-resource or service-description discovery.

The Adapter can retire when Context7 accepts Realmroot Agent identity and
proof-bound delegated authority directly.
30 changes: 30 additions & 0 deletions public/.well-known/agent-skills/context7/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: use-context7
description: Use Context7 through Realmroot Toolbox to resolve a software library and retrieve current, task-relevant documentation without handling Context7 credentials.
---

# Use Context7

Use the `realmroot` command so every Context7 request runs as the Agent with
controller-approved authority. Never call Context7 with a user token or ask the
user for an API key.

Before the first operation, run:

```bash
realmroot toolbox context7
```

If the command reports missing access, request the exact
`documentation:read` scope and continue after controller approval.

Resolve the library before requesting documentation:

1. Call `GET /libraries` with `libraryName` and the current task in `query`.
2. Select the best matching `id` from the ranked `results`; do not invent or
infer an identifier when multiple matches exist.
3. Call `GET /documentation` with the selected `libraryId`, a specific `query`,
and `type=json` when structured snippets are useful or `type=txt` for prose.

Keep each documentation query focused on one concrete question. Repeat the
library lookup when the package or ecosystem changes.
12 changes: 12 additions & 0 deletions public/.well-known/agent-skills/index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "use-context7",
"type": "skill-md",
"description": "Use Context7 through Realmroot Toolbox to resolve a software library and retrieve current, task-relevant documentation without handling Context7 credentials.",
"url": "/.well-known/agent-skills/context7/SKILL.md",
"digest": "sha256:4393c50b664853900c0d42b26842fb736be05a89e34d9bfb4b00ef98b69d9869"
}
]
}
36 changes: 36 additions & 0 deletions specs/context7-adapter.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Feature: Context7 managed OpenAPI adapter
Realmroot Agents query Context7 through a reusable authenticated OpenAPI boundary.

@journey:context7-contract @entrypoint:http
Scenario: Realmroot discovers the Context7 Resource Server
Given Context7 is configured as a managed OpenAPI adapter
When Realmroot reads its protected-resource metadata and service description
Then both published operations require the documentation:read scope
And no operation outside the configured allowlist is forwarded

@journey:context7-provider-oauth @entrypoint:http
Scenario: The controller connects Context7 without provisioned client credentials
Given Context7 supports OAuth dynamic client registration
When the controller begins the Adapter authorization flow
Then the Adapter registers a public OAuth client once and uses S256 PKCE
And the PKCE verifier and resulting Context7 tokens are encrypted at rest

@journey:context7-library-discovery @entrypoint:http
Scenario: An Agent resolves a library before reading documentation
Given the Agent has approved Context7 documentation access
When it lists libraries by library name and task query
Then the managed adapter forwards the request to the configured Context7 search operation
And it returns Context7's ranked library resources unchanged

@journey:context7-documentation @entrypoint:http
Scenario: An Agent retrieves documentation for a resolved library
Given the Agent selected a Context7 library identifier
When it reads documentation using that identifier and a task query
Then the managed adapter forwards the request to the configured Context7 context operation
And it preserves the upstream content type, status, and body

@journey:context7-audit-privacy @entrypoint:http
Scenario: Context7 transport audit excludes credentials and query content
When a published Context7 operation completes
Then the audit records the Agent, operation ID, path template, selected scope, status, request ID, and duration
But it does not record tokens, authorization headers, query values, or response content
160 changes: 160 additions & 0 deletions src/core/dynamic-oauth-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { z } from 'zod'
import { sha256Base64Url } from './digest.js'
import { failedDependency } from './problem.js'

const registrationSchema = z.object({ client_id: z.string().min(1) })
const tokenSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1).optional(),
expires_in: z.number().int().positive(),
scope: z.union([z.string(), z.array(z.string())]).optional(),
})

export type DynamicOAuthRegistrationStore = {
clientId(providerId: string): Promise<string | null>
saveClientId(providerId: string, clientId: string): Promise<string>
}

export type DynamicOAuthToken = Readonly<{
accessToken: string
refreshToken?: string
expiresAt: number
scopes: readonly string[]
}>

export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistrationStore {
constructor(private readonly db: D1Database) {}

async clientId(providerId: string) {
const row = await this.db
.prepare('SELECT client_id AS clientId FROM managed_oauth_client WHERE provider_id = ?')
.bind(providerId)
.first<{ clientId: string }>()
return row?.clientId ?? null
}

async saveClientId(providerId: string, clientId: string) {
await this.db
.prepare('INSERT OR IGNORE INTO managed_oauth_client (provider_id, client_id, created_at) VALUES (?, ?, ?)')
.bind(providerId, clientId, Date.now())
.run()
const stored = await this.clientId(providerId)
if (!stored) throw new Error(`Could not persist the ${providerId} OAuth client registration.`)
return stored
}
}

export function createDynamicOAuthClient(input: {
providerId: string
clientName: string
issuer: string
redirectUri: string
scopes: readonly string[]
registrationStore: DynamicOAuthRegistrationStore
fetcher?: typeof fetch
now?: () => number
}) {
const fetcher = input.fetcher ?? fetch
const now = input.now ?? Date.now

return {
async authorizationUrl(state: string) {
const verifier = randomVerifier()
const clientId = await registeredClientId()
const url = new URL('/oauth/authorize', input.issuer)
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', input.redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', input.scopes.join(' '))
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', await sha256Base64Url(verifier))
url.searchParams.set('code_challenge_method', 'S256')
return { url: url.toString(), verifier }
},
exchangeCode(code: string, verifier: string) {
return tokenRequest({
grant_type: 'authorization_code',
code,
redirect_uri: input.redirectUri,
code_verifier: verifier,
})
},
refresh(refreshToken: string) {
return tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken })
},
async revoke(token: string) {
const response = await fetcher(new URL('/oauth/token/revoke', input.issuer), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token, token_type_hint: 'refresh_token', client_id: await registeredClientId() }),
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw providerFailure(response, 'OAuth token revocation')
},
async userInfo(accessToken: string) {
const response = await fetcher(new URL('/oauth/userinfo', input.issuer), {
headers: { authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw providerFailure(response, 'OAuth userinfo request')
return response.json() as Promise<unknown>
},
}

async function registeredClientId() {
const existing = await input.registrationStore.clientId(input.providerId)
if (existing) return existing
const response = await fetcher(new URL('/oauth/register', input.issuer), {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({
client_name: input.clientName,
redirect_uris: [input.redirectUri],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: input.scopes.join(' '),
}),
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw providerFailure(response, 'dynamic client registration')
return input.registrationStore.saveClientId(
input.providerId,
registrationSchema.parse(await response.json()).client_id,
)
}

async function tokenRequest(parameters: Record<string, string>): Promise<DynamicOAuthToken> {
const response = await fetcher(new URL('/oauth/token', input.issuer), {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ ...parameters, client_id: await registeredClientId() }),
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw providerFailure(response, 'OAuth token request')
const token = tokenSchema.parse(await response.json())
return {
accessToken: token.access_token,
...(token.refresh_token ? { refreshToken: token.refresh_token } : {}),
expiresAt: now() + token.expires_in * 1000,
scopes: normalizeScopes(token.scope ?? input.scopes),
}
}
}

function randomVerifier() {
const bytes = crypto.getRandomValues(new Uint8Array(48))
return btoa(String.fromCharCode(...bytes))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '')
}

function normalizeScopes(value: string | readonly string[]) {
const scopes = typeof value === 'string' ? value.split(/\s+/) : value
return [...new Set(scopes.filter(Boolean))].sort()
}

function providerFailure(response: Response, operation: string) {
return failedDependency(`The upstream provider rejected ${operation} with ${response.status}.`)
}
Loading