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
52 changes: 37 additions & 15 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1304,19 +1304,19 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
attached Vault or anonymous MCP. Already-resolved tokens and running Sessions
are not revoked. Exact hosted archive, visibility, overlapping-mutation and error
semantics remain unverified; row removal does not prove physical storage erasure.
- Static-bearer Credentials are children of tenant-owned Vaults in the execution
- Static-bearer and OAuth Credentials are children of tenant-owned Vaults in the execution
database. Creation admits the owner in the same SQL statement as the insert;
retrieval joins the owning Vault and selects public metadata only. No public
operation decrypts or returns a token. Encrypt before passing secret values to
retrieval joins the owning Vault and selects public metadata only. Public reads do not decrypt or return a token. OAuth replacement authenticates
the stored grant before applying a partial update. Encrypt before passing secret values to
SQL, using the execution service's separately configured random 32-byte key and
the standard library's random-nonce AES-GCM. The versioned authenticated binding
includes tenant, Vault, Credential, authentication purpose and exact destination.
Never reuse product master-key conventions or daemon transport encryption for
this storage boundary. Missing key configuration disables credential writes;
malformed explicit configuration fails startup. See
[`services/agents-api/credentials.md`](services/agents-api/credentials.md) for
key persistence and current limits. OAuth and storage-key rotation remain
separate gaps; resource creation never contacts the destination.
key persistence and current limits. Storage-key rotation remains
separate work; resource creation never contacts the destination.
- `GET /v1/vaults/{vault_id}/credentials` lists safe metadata only, with both
project and Vault ownership enforced on the parent, cursor and row query. An
inaccessible parent returns not-found, even when the collection would be empty.
Expand All @@ -1327,10 +1327,10 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
Synthetic archived fixtures prove filtering only. There is no public archive writer,
timestamp or delete-to-archive inference; existing create/retrieve/token replacement,
Session bindings and dispatch keep their rules. Migration rollback refuses to lose
archived classification. Archive/revocation lifecycle, OAuth and hosted
query/concurrency semantics remain gaps.
- Credential `POST /v1/vaults/{vault_id}/credentials/{credential_id}` replaces only
the static-bearer token and update time. Require `auth.type=static_bearer` and a
archived classification. Archive lifecycle and hosted query/concurrency
semantics remain gaps.
- For static auth, Credential `POST /v1/vaults/{vault_id}/credentials/{credential_id}`
replaces only the token and update time. Require `auth.type=static_bearer` and a
string `auth.token`, preserving opaque bytes; reject extra mutation fields before
writing. Reuse safe metadata for the immutable encryption binding, then scope the
atomic SQL mutation independently by tenant, Vault, Credential, static auth type
Expand All @@ -1340,7 +1340,29 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
and Session snapshots stay unchanged. Subsequent dispatch reads use the committed
replacement through existing scoped lookup; already-resolved requests may retain
the old token. This is not storage-key rotation, in-flight revocation or hot reload.
OAuth and exact hosted concurrent-update/retry/timestamp semantics remain gaps.
Exact hosted concurrent-update/retry/timestamp semantics remain gaps.
- OAuth grant ownership stays in Core. The application performs authorization and
provider revocation; do not add public login/callback/refresh/revoke routes.
Store access/refresh/client secrets together under existing authenticated tenant,
Vault, Credential, auth-type and destination encryption. Authenticate refresh
metadata against its encrypted copy before using an endpoint or grant. Read/list
queries still select safe metadata only. Shared MCP selection admits both auth
types and freezes one identity without changing native adapter contracts.
At dispatch, a known-expired grant is refreshed through the declared endpoint
auth method, with stored scope/resource, then persisted before returning access.
Serialize refresh and replacement with the same PostgreSQL Credential row lock;
deletion and Vault cascade cannot be undone by a stale refresh. Network exchanges
are bounded and fail closed; never return provider error bodies or claim an
uncertain grant exchange was committed. No background scheduler, 401 retry,
hot replacement, output repair or harness-specific OAuth path is introduced.
Refresh uses verified HTTPS, rejects redirects, and checks resolved addresses
before dialing them. Private issuer origins need explicit operator configuration
in `AGENTS_API_OAUTH_TRUSTED_ORIGINS`; tenants cannot relax that boundary and TLS
verification remains mandatory. Keycloak is acceptance infrastructure only.
Preserve the pinned update omission/null and immutable-field rules described in
[OAuth credentials](services/agents-api/oauth-credentials.md); record unspecified
hosted semantics. Native processes receive only access tokens. Provider revocation,
withdrawal of already-dispatched tokens and Session cancellation remain distinct.
- Credential `DELETE /v1/vaults/{vault_id}/credentials/{credential_id}` removes one
owned row, including ciphertext, with tenant/Vault/ID checked in the same SQL
mutation. It needs no encryption key, secret read or network call. Local reads,
Expand Down Expand Up @@ -1387,7 +1409,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
pinned resource types. Saved-Agent updates never change existing Session
snapshots; per-Session tools replace the whole field. The initial profile admits
HTTP(S), boolean `required` (default false), empty/null metadata and empty/null headers.
Static bearer authentication requires HTTPS and the attached-Vault rules below.
Static and OAuth bearer authentication require HTTPS and the attached-Vault rules below.
Inline authorization, URL userinfo/query/fragment,
other origins and stdio remain explicitly unsupported.
- Codex required MCP initialization additionally needs `mcp_http_required`, advertised
Expand Down Expand Up @@ -1426,19 +1448,19 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
with TLS verification.
This execution profile rejects empty values and bytes outside RFC 6750 b64token
syntax with generic errors; it never trims tokens or narrows opaque Credential
storage. OAuth and hosted redirect/error equivalence
remain separate work.
storage. Core-managed OAuth uses this same access-token path; native OAuth
login/refresh and hosted redirect/error equivalence remain separate work.
- Session `vault_ids` omission/null/empty means `[]`; nonempty attachments must all
belong to the authenticated tenant. Preserve caller order and public MCP
`credential_id`. Saved Agents may store a nullable/nonempty credential reference
without authorizing its use. Session admission resolves an explicit credential
only inside attached Vaults for the exact declared URL, or selects the unique
matching static credential when the ID is omitted/null. No match remains
matching static or OAuth credential when the ID is omitted/null. No match remains
anonymous; ambiguity is a local 400 and unavailable references use the same 404.
Resolve before any Session, initial input or event write. Freeze safe bindings,
including anonymous decisions, in private Session configuration; never populate
the public credential field from implicit resolution. At actual dispatch, recheck
tenant, attached Vault, selected ID, static auth type and exact URL before scoped
tenant, attached Vault, selected ID, frozen auth type and exact URL before scoped
decryption. Metadata queries select no ciphertext; tokens enter only the existing
transient daemon request. Selected authentication requires `mcp_http_bearer_auth`
during device selection and the final preclaim check. Missing/wrong keys or
Expand Down
40 changes: 40 additions & 0 deletions apps/web/e2e/vault-oauth-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";

const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`;

test("loads mixed OAuth and static credentials without offering OAuth static replacement", async ({ page, request }) => {
expect((await request.post(`${fixtureBaseUrl}/__fixture/reset`)).ok()).toBe(true);
const vaultId = "11111111-1111-4111-8111-111111111111";
const vault = { id: vaultId, object: "vault", created_at: 1, name: "Mixed credentials", metadata: {} };
const oauth = {
id: "22222222-2222-4222-8222-222222222222", vault_id: vaultId,
object: "vault.credential", name: "OAuth MCP", created_at: 2, updated_at: 2,
auth: {
type: "mcp_oauth", mcp_server_url: "https://oauth.example/tools", expires_at: null,
refresh: {
client_id: "public-client-id", token_endpoint: "https://issuer.example/token",
token_endpoint_auth: { type: "client_secret_basic" }, resource: null, scope: null,
},
},
};
const staticCredential = {
id: "33333333-3333-4333-8333-333333333333", vault_id: vaultId,
object: "vault.credential", name: "Static MCP", created_at: 2, updated_at: 2,
auth: { type: "static_bearer", mcp_server_url: "https://static.example/tools" },
};
await page.route("**/v1/vaults?*", (route) => route.fulfill({ json: {
object: "list", data: [vault], has_more: false, first_id: vaultId, last_id: vaultId,
} }));
await page.route(`**/v1/vaults/${vaultId}/credentials?*`, (route) => route.fulfill({ json: {
object: "list", data: [oauth, staticCredential], has_more: false, first_id: oauth.id, last_id: staticCredential.id,
} }));
await page.goto("/");
await page.getByRole("button", { name: "Vaults", exact: true }).click();
await expect(page.getByRole("heading", { name: "Mixed credentials" })).toBeVisible();
await expect(page.getByText("OAuth MCP", { exact: true })).toBeVisible();
await expect(page.getByText("OAuth · Manage authorization and token replacement in your application.")).toBeVisible();
await expect(page.getByRole("button", { name: "Replace token for OAuth MCP" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Delete OAuth MCP" })).toBeVisible();
await page.getByRole("button", { name: "Replace token for Static MCP" }).click();
await expect(page.getByRole("dialog", { name: "Replace token · Static MCP" })).toBeVisible();
});
24 changes: 24 additions & 0 deletions apps/web/src/features/vaults/VaultsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ describe("Vaults view", () => {
expect(html).not.toContain("archived");
});

it("shows OAuth metadata with application guidance and only offers static token replacement", () => {
const vaultId = "11111111-1111-4111-8111-111111111111";
const html = renderToStaticMarkup(<VaultsView
busy={false} coreError={null} coreState="ready" operations={operations}
catalog={{
vaults: [{ id: vaultId, object: "vault", created_at: 1, name: "Runtime", metadata: {} }],
credentials: [{
id: "22222222-2222-4222-8222-222222222222", vault_id: vaultId,
object: "vault.credential", name: "OAuth MCP", created_at: 2, updated_at: 2,
auth: { type: "mcp_oauth", mcp_server_url: "https://oauth.example/tools", expires_at: null, refresh: null },
}, {
id: "33333333-3333-4333-8333-333333333333", vault_id: vaultId,
object: "vault.credential", name: "Static MCP", created_at: 2, updated_at: 2,
auth: { type: "static_bearer", mcp_server_url: "https://static.example/tools" },
}],
}}
/>);
expect(html).toContain("OAuth MCP");
expect(html).toContain("Manage authorization and token replacement in your application.");
expect(html).not.toContain('aria-label="Replace token for OAuth MCP"');
expect(html).toContain('aria-label="Replace token for Static MCP"');
expect(html).toContain('aria-label="Delete OAuth MCP"');
});

it("keeps an incomplete catalog in a durable failed state", () => {
const html = renderToStaticMarkup(<VaultsView busy={false} catalog={null} coreError="safe failure" coreState="failed" operations={operations} />);
expect(html).toContain("Couldn’t load Vaults");
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/vaults/VaultsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ export function VaultsView({ busy, catalog, coreError, coreState, operations }:
{credentials.length ? credentials.map((credential) => (
<article className="credential-row" key={credential.id}>
<KeyRound size={15} strokeWidth={1.5} aria-hidden="true" />
<div><strong>{credential.name}</strong><code>{credential.auth.mcp_server_url}</code><small>Updated {formatTimestamp(credential.updated_at)} · token hidden</small></div>
<div><strong>{credential.name}</strong><code>{credential.auth.mcp_server_url}</code><small>Updated {formatTimestamp(credential.updated_at)} · token hidden</small>{credential.auth.type === "mcp_oauth" ? <small>OAuth · Manage authorization and token replacement in your application.</small> : null}</div>
<div className="credential-actions">
<button className="icon-button outline" type="button" aria-label={`Replace token for ${credential.name}`} title="Replace token" onClick={() => { setActionError(null); setCredentialDialog({ mode: "replace", vault, credential }); }} disabled={busy}><RotateCcw size={13} /></button>
{credential.auth.type === "static_bearer" ? <button className="icon-button outline" type="button" aria-label={`Replace token for ${credential.name}`} title="Replace token" onClick={() => { setActionError(null); setCredentialDialog({ mode: "replace", vault, credential }); }} disabled={busy}><RotateCcw size={13} /></button> : null}
<button className="icon-button danger" type="button" aria-label={`Delete ${credential.name}`} onClick={() => { setActionError(null); setDeleteTarget({ kind: "credential", vault, credential }); }} disabled={busy}><Trash2 size={13} /></button>
</div>
</article>
Expand Down
Loading
Loading