Skip to content

Add reusable OAuth lifecycle custody - #2602

Merged
3mdistal merged 26 commits into
mainfrom
codex/oauth-lifecycle-foundation
Aug 13, 2026
Merged

Add reusable OAuth lifecycle custody#2602
3mdistal merged 26 commits into
mainfrom
codex/oauth-lifecycle-foundation

Conversation

@3mdistal

@3mdistal 3mdistal commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Agent Native already supports OAuth-enabled remote MCP providers such as Linear. What it lacked was one reusable credential lifecycle that later integrations can trust when tokens expire, multiple server processes race to refresh them, a user reconnects, or a provider revokes access.

Without this foundation, later managed-AI and Fusion lanes would duplicate security-sensitive logic or drift back toward the obsolete installation-key design from closed PR #2515.

Approach

Add a provider-, resource-, and owner-scoped lifecycle over the encrypted OAuth token store, then route the existing MCP OAuth adapter through it without changing the visible connection journey.

The product can eventually present one Builder sign-in while retaining separate capability grants underneath. This PR deliberately adds no Builder connection UI, managed-AI or Fusion consumer, BuilderSync migration, feature enablement, credential, or deployment change.

What changed

  • Explicit missing, malformed, connected, expired, and reconnect_required credential states.
  • Resource-derived, owner-bound storage identities with atomic owner-conflict handling and a user-only fallback that recovers matching legacy mixed-case owners without case-folding organization ids.
  • A database-backed refresh lease with heartbeat renewal, waiter reload, and lease-owned success/failure fencing.
  • Revision compare-and-swap writes for refresh and revocation, bound to the exact encrypted stored value so stale mixed-version writers cannot overwrite or delete a newer credential.
  • Null-safe atomic revision advancement and additive migrations for SQLite and Postgres.
  • Provider revocation hooks that report remote and local outcomes separately; failed MCP registration now attempts remote revocation before removing local custody.
  • Server removal preserves its setting when a concurrent reauthorization wins the credential cleanup race, keeping the replacement connection reachable and manageable.
  • MCP OAuth compatibility through a narrow legacy-key bridge, canonicalizing both requested and stored resource URLs across reads, authenticated use, cleanup, and revocation.
  • DNS-aware SSRF protection for discovery, registration, exchange, refresh, redirects, and revocation. Node runtimes also bind the checked address at connection time; edge runtimes retain deterministic URL checks, DNS preflight when available, and validation of every manual redirect hop.
  • A deterministic standalone-Chat smoke warmup that preloads the /agent dependency graph before strict browser assertions and retries only recognized transient dev-server database restarts.

Safety and product boundary

  • The generic foundation is dormant until a consumer calls it. Existing remote MCP OAuth uses it behind the unchanged UI.
  • Tokens remain AES-256-GCM encrypted at rest. Public lifecycle state does not expose the internal ciphertext CAS signal.
  • Existing MCP credentials remain readable; BuilderSync and legacy Builder authentication are untouched.
  • No UI diff exists in this PR, so there are no changed visual states or screenshots to approve.
  • This PR is open for review, but that does not authorize a real Builder consumer, deployment, merge, or provider credential change.

Verification

Current exact head: 3d925f6a7.

Passed locally against this exact head:

  • Focused lifecycle, token-store, MCP, and URL-safety suite: 99 tests passed and one environment-gated Postgres test skipped across seven files.
  • Full workspace build under the repository's Node 24 runtime.
  • All 53 repository guards, formatting, and git diff --check.
  • The exact-head Review Agent accepted the abandoned rotating-token lease behavior, verified private-origin revocation parity, and found no new or regressed security issues.
  • The exact-head standalone Chat scaffold/install/dev/browser smoke passed after its CI warmup repair.

The full GitHub Actions matrix passed on 3d925f6a7, including build, fast tests, Core integration, Postgres locking, security guards, scaffold E2E, and the repaired standalone Chat smoke.

Additional acceptance evidence on the same lifecycle design before the current-main refresh:

  • Real shared-Postgres concurrency test: two independent Node processes with separate database connections raced one rotating refresh token, produced exactly one redemption, and both reloaded the same winner.
  • Real-provider smoke through the existing MCP journey: Linear consent and callback completed, the connection survived reload, and Agent Native made an authenticated read-only Linear lookup.

Review focus

  • Is provider/resource/owner identity the right durable custody boundary?
  • Are the lease, refresh failure, reconnect, revocation, and waiter-reload transitions safe under overlapping server processes?
  • Does mixed-version and legacy recovery preserve existing credentials without weakening organization or cross-user isolation?
  • Is the edge-runtime SSRF fallback the right portability/security tradeoff when a connect-time Node dispatcher is unavailable?

Follow-ups (separate lanes)

  • Managed AI with its own Builder OAuth resource/scopes, default-off rollout, and acceptance story.
  • Fusion remote MCP with its own capability grant and contract checks.
  • Optional CI wiring for the dedicated multi-process Postgres refresh test; its real two-process run is already recorded, and ordinary CI remains the accepted non-blocking boundary.
  • No BuilderSync or legacy-auth removal until replacement lanes are proven in production.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

@3mdistal
3mdistal requested a review from steve8708 August 11, 2026 16:50
@steve8708

Copy link
Copy Markdown
Contributor

@3mdistal should we get this in? also some agent review stuff if you agree w/ it:


Review summary

The overall OAuth custody design looks solid, but I found four remaining issues:

  1. High - failed OAuth setup can orphan provider grants.
    addOAuthRemoteServer saves credentials before creating the server row. If creation fails, cleanup calls the local-only delete path and never revokes the provider grant. Use remote revocation in both cleanup paths and add a failure-after-save test. ([code](

    export async function addOAuthRemoteServer(
    scope: RemoteMcpScope,
    scopeId: string,
    input: {
    name: string;
    url: string;
    description?: string;
    credentials: McpOAuthCredentialBundle;
    },
    ): Promise<
    { ok: true; server: StoredRemoteMcpServer } | { ok: false; error: string }
    > {
    const serverUrl = validateRemoteUrl(input.url);
    const credentialResource = validateRemoteUrl(input.credentials.serverUrl);
    if (
    !serverUrl.ok ||
    !serverUrl.url ||
    !credentialResource.ok ||
    !credentialResource.url ||
    serverUrl.url.toString() !== credentialResource.url.toString()
    ) {
    return {
    ok: false,
    error: "MCP server URL must match the OAuth credential resource URL",
    };
    }
    const credentials = {
    ...input.credentials,
    serverUrl: credentialResource.url.toString(),
    };
    const oauthSecretKey = `mcp_oauth:${shortId()}`;
    try {
    await saveMcpOAuthCredentials({
    key: oauthSecretKey,
    scope,
    scopeId,
    credentials,
    });
    const result = await addRemoteServerInternal(scope, scopeId, {
    name: input.name,
    url: credentials.serverUrl,
    description: input.description,
    oauthSecretKey,
    });
    if (!result.ok) {
    await deleteMcpOAuthCredentials({
    key: oauthSecretKey,
    scope,
    scopeId,
    serverUrl: credentials.serverUrl,
    });
    }
    return result;
    } catch (err: any) {
    await deleteMcpOAuthCredentials({
    key: oauthSecretKey,
    scope,
    scopeId,
    serverUrl: credentials.serverUrl,
    }).catch(() => {});
    return {
    ok: false,
    error: `Failed to save MCP OAuth credentials: ${err?.message ?? err}`,
    };
    }
    ))

  2. High if edge/Cloudflare is supported - ssrfSafeFetch now requires Node-only dependencies.
    The shared fetch helper requests a required undici/node:dns dispatcher, so edge runtimes may fail before making any request. Please clarify the supported runtime matrix or preserve a safe fallback, with an edge regression test. ([code](

    export async function createSsrfSafeDispatcher(
    allowedPrivateOrigins: readonly string[] = [],
    destinationUrl?: string,
    options: { required?: boolean } = {},
    ): Promise<unknown> {
    // Keep the undici import opaque to Vite/Rolldown. A literal dynamic import
    // makes browser builds try to resolve and bundle this server-only package.
    let undici: any;
    let dnsModule: any;
    try {
    const undiciSpecifier = "undici";
    undici = await import(/* @vite-ignore */ undiciSpecifier);
    dnsModule = await import("node:dns");
    } catch (error) {
    if (options.required) {
    throw new Error(
    "SSRF protection is unavailable because the server dispatcher could not be loaded.",
    { cause: error },
    );
    }
    return null;
    }
    const { Agent } = undici;
    const { lookup } = dnsModule;
    const allowedPrivateOriginKeys = normalizeAllowedPrivateOriginKeys(
    allowedPrivateOrigins,
    );
    let destinationPort = "";
    if (destinationUrl) {
    const parsed = new URL(destinationUrl);
    destinationPort =
    parsed.port || (parsed.protocol === "https:" ? "443" : "80");
    }
    if (!Agent || !lookup) {
    if (options.required) {
    throw new Error(
    "SSRF protection is unavailable because the server dispatcher is incomplete.",
    );
    }
    return null;
    }
    return new Agent({
    connect: {
    // Override DNS lookup at connect time so the IP we hand to undici's
    // socket is the one we authorized. Reject any record in the private
    // set BEFORE the TCP handshake.
    lookup: (
    hostname: string,
    options: any,
    callback: (
    err: NodeJS.ErrnoException | null,
    address?: string | { address: string; family: number }[],
    family?: number,
    ) => void,
    ) => {
    lookup(
    hostname,
    { all: true, verbatim: true },
    (err: NodeJS.ErrnoException | null, addresses: any) => {
    if (err) return callback(err);
    const list: { address: string; family: number }[] = Array.isArray(
    addresses,
    )
    ? addresses
    : [{ address: addresses, family: 4 }];
    for (const record of list) {
    const allowedOrigin = allowedPrivateOriginKeys.has(
    `${normalizeLookupHostname(hostname)}:${destinationPort}`,
    );
    if (isPrivateHost(record.address) && !allowedOrigin) {
    const e = new Error(
    `Connect blocked: ${hostname} resolved to private address ${record.address}`,
    ) as NodeJS.ErrnoException;
    e.code = "EAI_BLOCKED";
    return callback(e);
    }
    }
    // Mirror Node's lookup behavior: when `all` is true, return the
    // array; otherwise the first entry. undici's connect honors
    // `options.all`.
    if (options && options.all) {
    return callback(null, list as any);
    }
    const first = list[0];
    return callback(null, first.address, first.family);
    },
    );
    },
    },
    });
    }
    /**
    * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
    *
    * Applies the same protections the extension proxy uses, so every call site
    * that fetches an untrusted URL gets them without re-implementing the loop:
    * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
    * on the initial URL and on every redirect hop.
    * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
    * time (closes the DNS-rebinding TOCTOU). This strict fetch path fails
    * closed when the server dispatcher is unavailable.
    * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
    * private network because each hop is re-validated before it is followed.
    *
    * Throws an Error whose message starts with "SSRF blocked:" when a target
    * (initial or via redirect) resolves to a private/internal address, or when the
    * redirect limit is exceeded. Otherwise returns the final Response.
    *
    * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
    * followed only to `https:` targets, so an HTTPS-only caller cannot be
    * downgraded to plain HTTP by a 30x from the (untrusted) origin.
    *
    * `assertUrlAllowed` lets callers layer a stricter destination policy (for
    * example, a credential's origin allowlist) on top of the SSRF checks. It runs
    * before the initial request and before every redirect hop, so sensitive
    * headers and bodies are never forwarded to a destination the caller rejects.
    */
    export async function ssrfSafeFetch(
    url: string,
    init: RequestInit = {},
    options: {
    maxRedirects?: number;
    followRedirects?: boolean;
    httpsOnly?: boolean;
    assertUrlAllowed?: (url: string) => void | Promise<void>;
    /**
    * Exact origins that may resolve to a private address. A workspace runs
    * every app on loopback behind one gateway, so sibling A2A calls are
    * private by construction; without this they are indistinguishable from an
    * SSRF attempt and get blocked. Only ever pass origins the deployment
    * itself configured (never a request-supplied value).
    */
    allowedPrivateOrigins?: readonly string[];
    } = {},
    ): Promise<Response> {
    const maxRedirects = options.maxRedirects ?? 3;
    const allowedPrivateOrigins = normalizeAllowedPrivateOriginOriginKeys(
    options.allowedPrivateOrigins ?? [],
    );
    const isAllowedPrivateOrigin = (candidate: string): boolean => {
    if (allowedPrivateOrigins.size === 0) return false;
    try {
    const parsed = new URL(candidate);
    const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
    return allowedPrivateOrigins.has(
    `${parsed.protocol}//${normalizeLookupHostname(parsed.hostname)}:${port}`,
    );
    } catch {
    return false;
    }
    };
    let currentUrl = url;
    for (let hop = 0; hop <= maxRedirects; hop++) {
    await options.assertUrlAllowed?.(currentUrl);
    if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
    throw new Error(
    `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
    );
    }
    if (
    !isAllowedPrivateOrigin(currentUrl) &&
    (await isBlockedExtensionUrlWithDns(currentUrl))
    ) {
    throw new Error(
    `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
    );
    }
    const fetchOpts: RequestInit & { dispatcher?: unknown } = {
    ...init,
    redirect: "manual",
    };
    fetchOpts.dispatcher = await createSsrfSafeDispatcher(
    options.allowedPrivateOrigins,
    currentUrl,
    { required: true },
    );
    ))

  3. Medium - URL canonicalization is inconsistent across MCP reads and cleanup.
    URLs are canonicalized when saving, but read, refresh, revoke, and delete paths still compare raw strings. Equivalent forms such as a trailing slash can therefore make credentials appear missing or prevent cleanup. ([code](

    export async function saveMcpOAuthCredentials(options: {
    key: string;
    scope: "user" | "org";
    scopeId: string;
    credentials: McpOAuthCredentialBundle;
    }): Promise<void> {
    const serverUrl = checkedRemoteUrl(
    options.credentials.serverUrl,
    "server",
    ).toString();
    const credentials = { ...options.credentials, serverUrl };
    if (credentials.discoveryState) {
    validateDiscoveryUrls(credentials.discoveryState);
    }
    await saveOAuthCredential(
    credentialIdentity({
    ...options,
    serverUrl,
    }),
    credentials,
    { legacyAccountKey: true },
    );
    }
    export async function readMcpOAuthCredentials(options: {
    key: string;
    scope: "user" | "org";
    scopeId: string;
    serverUrl?: string;
    }): Promise<McpOAuthCredentialBundle | null> {
    if (!options.serverUrl) return null;
    const state = await getMcpOAuthConnectionState({
    ...options,
    serverUrl: options.serverUrl,
    });
    if (state.kind === "missing" || state.kind === "malformed") return null;
    const parsed = state.credential;
    if (parsed.serverUrl !== options.serverUrl) return null;
    if (!validateRemoteUrl(parsed.serverUrl).ok) return null;
    if (parsed.discoveryState) {
    try {
    validateDiscoveryUrls(parsed.discoveryState);
    } catch {
    return null;
    }
    }
    return parsed;
    }
    export async function getMcpOAuthConnectionState(options: {
    key: string;
    scope: "user" | "org";
    scopeId: string;
    serverUrl: string;
    }): Promise<OAuthCredentialState<McpOAuthCredentialBundle>> {
    return readOAuthCredentialState<McpOAuthCredentialBundle>(
    credentialIdentity(options),
    {
    allowLegacy: true,
    legacyAccountKey: true,
    validateCredential: (credential) =>
    credential.serverUrl === options.serverUrl,
    },
    );
    }
    export async function deleteMcpOAuthCredentials(options: {
    key: string;
    scope: "user" | "org";
    scopeId: string;
    serverUrl?: string;
    }): Promise<boolean> {
    if (!options.serverUrl) return false;
    const identity = credentialIdentity({
    ...options,
    serverUrl: options.serverUrl,
    });
    const result = await revokeOAuthCredential(identity, {
    allowLegacy: true,
    legacyAccountKey: true,
    validateCredential: (credential: McpOAuthCredentialBundle) =>
    credential.serverUrl === options.serverUrl,
    });
    return result.local === "deleted";
    }
    export async function revokeMcpOAuthCredentials(options: {
    key: string;
    scope: "user" | "org";
    scopeId: string;
    serverUrl: string;
    }): Promise<OAuthRevocationResult> {
    const identity = credentialIdentity(options);
    return revokeOAuthCredential<McpOAuthCredentialBundle>(identity, {
    allowLegacy: true,
    legacyAccountKey: true,
    validateCredential: (credential) =>
    credential.serverUrl === options.serverUrl,
    revoke: async ({ credential }) => {
    const endpoint = (
    credential.discoveryState?.authorizationServerMetadata as
    | (AuthorizationServerMetadata & { revocation_endpoint?: string })
    | undefined
    )?.revocation_endpoint;
    if (!endpoint) return "unsupported";
    const token =
    credential.tokens.refresh_token ?? credential.tokens.access_token;
    const body = new URLSearchParams({
    token,
    token_type_hint: credential.tokens.refresh_token
    ? "refresh_token"
    : "access_token",
    client_id: credential.clientInformation.client_id,
    });
    const response = await guardedRevocationFetch(endpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body,
    });
    if (!response.ok) {
    await response.body?.cancel().catch(() => undefined);
    throw new Error("MCP OAuth revocation failed.");
    }
    await response.body?.cancel().catch(() => undefined);
    return "succeeded";
    },
    });
    }
    /**
    * Resolve an access token for the MCP manager. Refreshing happens only when a
    * token is near expiry, so ordinary manager reconfiguration does not perform
    * a network request for every connector.
    */
    export async function getMcpOAuthAccessToken(options: {
    key: string;
    scope: "user" | "org";
    scopeId: string;
    serverUrl: string;
    }): Promise<string | null> {
    if (!validateRemoteUrl(options.serverUrl).ok) return null;
    const result = await resolveOAuthCredentialAccess<McpOAuthCredentialBundle>(
    credentialIdentity(options),
    {
    allowLegacy: true,
    legacyAccountKey: true,
    validateCredential: (credential) =>
    credential.serverUrl === options.serverUrl,
    expirySkewMs: TOKEN_EXPIRY_SKEW_MS,
    refresh: async ({ credential: credentials }) => {
    const refreshToken = credentials.tokens.refresh_token;
    const discovery = credentials.discoveryState;
    if (!refreshToken || !discovery?.authorizationServerUrl) {
    throw new Error("MCP OAuth refresh is unavailable.");
    }
    const expectedIssuer = issuerForDiscovery(discovery);
    if (
    !expectedIssuer ||
    credentials.clientInformation.issuer !== expectedIssuer ||
    credentials.tokens.issuer !== expectedIssuer
    ) {
    throw new Error("MCP OAuth refresh issuer binding is invalid.");
    }
    const resource = discovery.resourceMetadata?.resource
    ? checkedRemoteUrl(discovery.resourceMetadata.resource, "resource")
    : undefined;
    const authorizationServerUrl = checkedRemoteUrl(
    discovery.authorizationServerUrl,
    "authorization server",
    );
    const refreshed = await refreshAuthorization(authorizationServerUrl, {
    metadata: discovery.authorizationServerMetadata,
    clientInformation: credentials.clientInformation,
    refreshToken,
    resource,
    fetchFn: guardedOAuthFetch(),
    });
    const nextTokens: StoredOAuthTokens = {
    ...credentials.tokens,
    ...refreshed,
    issuer: expectedIssuer,
    ...(refreshed.refresh_token
    ? { refresh_token: refreshed.refresh_token }
    : credentials.tokens.refresh_token
    ? { refresh_token: credentials.tokens.refresh_token }
    : {}),
    };
    const next: McpOAuthCredentialBundle = {
    ...credentials,
    tokens: nextTokens,
    tokenExpiresAt: tokenExpiresAt(nextTokens),
    };
    return next;
    },
    },
    );
    return result.accessToken;
    }
    ))

  4. Medium - mixed-case legacy owners are not fully recoverable.
    The fallback preserves the current caller’s casing, so a legacy row stored under user:Alice@Example.com is still missed when the current identity is normalized to lowercase. Add a case-insensitive migration/read path and a regression test. ([code](

    assertIdentity(identity);
    const accountId = storageAccountId(identity, options.legacyAccountKey);
    const canonicalOwner = ownerKey(identity.owner);
    let storageOwner = canonicalOwner;
    let stored = await getOAuthTokenSnapshot(
    identity.provider,
    accountId,
    storageOwner,
    );
    const legacyOwner = legacyOwnerKey(identity.owner);
    if (!stored && options.allowLegacy && legacyOwner !== canonicalOwner) {
    storageOwner = legacyOwner;
    stored = await getOAuthTokenSnapshot(
    identity.provider,
    accountId,
    storageOwner,
    );
    }
    ))

The latest Postgres bot comment also appears stale: this head writes seconds to updated_at and uses a BIGINT revision for millisecond CAS. It is still worth retaining the prior widening safeguard and verifying the dedicated Postgres migration test lane.

…-foundation

# Conflicts:
#	packages/core/src/oauth-tokens/migrations.spec.ts
#	packages/core/src/oauth-tokens/migrations.ts
#	packages/core/src/server/release-migrations.ts
@3mdistal

Copy link
Copy Markdown
Contributor Author

Addressed all four findings on the refreshed head:

  • Failed post-grant MCP registration now uses the remote revocation lifecycle in both cleanup paths, with tests for ordinary registration failure and an exception after credential save.
  • Edge runtimes no longer require the Node-only dispatcher. They retain deterministic URL checks, DNS preflight when available, and validation on every manual redirect hop; Node keeps connect-time DNS binding. Added an edge-runtime regression.
  • Requested and stored MCP resource URLs are both canonicalized across reads, authenticated access, refresh, revoke, and delete, including legacy credentials stored without the canonical trailing slash.
  • Legacy user owners now have a case-insensitive recovery read constrained to user: ownership; organization ids remain case-sensitive.

The timestamp concern remains stale: updated_at is epoch seconds, while the monotonic CAS revision is additive BIGINT. The current migration tests cover SQLite, and the existing dedicated two-process Postgres run remains the concurrency evidence.

Local exact-head evidence: 96 focused tests plus the Node 22 SQLite migration test, Core typecheck, all 51 guards, and a fresh independent security review with no remaining blocker. GitHub CI is rerunning now.

  • Codex AI

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

…-foundation

# Conflicts:
#	scripts/qa-standalone-chat-dev-smoke.ts
builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes — looks good ✅

Review Details

Incremental Code Review Summary

I reviewed the latest PR #2602 head with two parallel agents. Previously reported issues covering lease recovery, private-origin revocation, legacy Postgres timestamps, and MCP removal races were not reposted and remain addressed.

The current OAuth custody implementation continues to hold up across lifecycle transitions, owner/resource handling, refresh and revocation fencing, migration compatibility, SSRF protections, MCP integration, and the standalone smoke adjustment. Focused agent verification passed 83–99 tests across OAuth, token-store, migration, MCP, and URL-safety suites. No new or regressed confirmed bugs or security issues were found.

This remains a high-risk authentication and credential-lifecycle change.

🧪 Browser testing: Skipped — PR only modifies backend/config/docs/tests, no UI impact.

@3mdistal
3mdistal merged commit a71862e into main Aug 13, 2026
93 of 96 checks passed
@3mdistal
3mdistal deleted the codex/oauth-lifecycle-foundation branch August 13, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants