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
6 changes: 3 additions & 3 deletions VENDORED.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
"@modelcontextprotocol/sdk": "catalog:",
"@workbench/access-policy": "workspace:*",
"@corbits/connections": "workspace:*",
"@corbits/codex-provider": "workspace:*",
"@corbits/xai-provider": "workspace:*",
"@corbits/hub-api-client": "workspace:*",
"@corbits/seeding": "workspace:*",
"@workbench/onboarding": "workspace:*",
Expand Down
53 changes: 53 additions & 0 deletions apps/hub/src/credential-material-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
import { pushSourceUpdates, type SidecarRouter } from "@intx/hub-sessions";
import { credentialAad, type CredentialCipher } from "@intx/types";
import { mcpSlugOf, refreshMcpOAuthTokens } from "@corbits/connections";
import { refreshCodexTokens } from "@corbits/codex-provider";
import { refreshXaiTokens } from "@corbits/xai-provider";
import type {
OAuthClientInformationMixed,
OAuthTokens,
Expand All @@ -56,6 +58,15 @@ const CredentialMetadata = type({
"clientInformation?": "unknown",
});

/** The codex credential's metadata slice: the `chatgpt_account_id` the
* connect flow derived from the id_token (CL-7508). */
const CodexCredentialMetadata = type({ "accountId?": "string" });

function metadataAccountId(metadata: unknown): string | undefined {
const parsed = CodexCredentialMetadata(metadata);
return parsed instanceof type.errors ? undefined : parsed.accountId;
}

/** The row shape the serving seam hands the hook (a `credential` row
* subset; `refreshSecret` still ciphertext). */
export type ServingCredentialRow = {
Expand Down Expand Up @@ -205,6 +216,48 @@ export function createServingRefresh(deps: ServingRefreshDeps): ServingRefresh {
if (full === null || row.refreshSecret === null) {
throw new Error(`credential ${row.id} lost its refresh secret`);
}
// The two loopback-OAuth inference providers (CL-7508) refresh
// through their provider packages' own grants, not the generalized
// MCP `auth()` refresh: their endpoints, public client ids, and
// token shapes live in @corbits/{codex,xai}-provider, and the
// provider row's name is the connector id those packages key on.
if (full.providerName === "codex") {
const accountId = metadataAccountId(full.metadata);
const refreshed = await refreshCodexTokens(row.refreshSecret, now(), {
access: row.secret,
refresh: row.refreshSecret,
...(row.expiresAt === null
? {}
: { expiresAt: row.expiresAt.getTime() }),
// The account id rides the credential's metadata (written at
// connect from the id_token); carry it forward so a refresh
// response without an id_token never drops chatgpt-account-id.
...(accountId !== undefined ? { accountId } : {}),
});
return {
secret: refreshed.access,
...(refreshed.refresh === undefined
? {}
: { refreshSecret: refreshed.refresh }),
expiresAt:
refreshed.expiresAt === undefined
? null
: new Date(refreshed.expiresAt),
};
}
if (full.providerName === "xai-oauth") {
const refreshed = await refreshXaiTokens(row.refreshSecret, now());
return {
secret: refreshed.access,
...(refreshed.refresh === undefined
? {}
: { refreshSecret: refreshed.refresh }),
expiresAt:
refreshed.expiresAt === undefined
? null
: new Date(refreshed.expiresAt),
};
}
const parsed = CredentialMetadata(full.metadata ?? {});
const serverUrl =
full.apiBaseUrl ??
Expand Down
32 changes: 32 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ import {
createMcpOAuthRoutes,
createMcpServerRoutes,
createOAuthConnectRoutes,
createOAuthLoopbackRoutes,
createTenantConnectCredential,
createWorkflowConnectionRoutes,
DEFAULT_RETURN_PATH_ALLOWLIST,
Expand Down Expand Up @@ -887,6 +888,22 @@ export async function createHub(config: HubConfig) {
authenticateSidecar: async ({ token }) => sidecarCredentials.resolve(token),
validateSidecarIdentity: sidecarCredentials.isCurrent,
lookups,
// CL-7508: a loopback login may only run on a sidecar whose allocation
// was provisioned by the `process` backend — the only one that runs on
// this host, where the user's browser can reach the pinned ports
// (1455/1456). A docker/e2b-provisioned sidecar's localhost is the
// container or the sandbox, never this machine, so it fails the gate
// and the connect request gets the typed gate outcome.
oauthLogin: {
isLocalSidecar: async (identity) => {
const allocation = await db.query.sidecarAllocation.findFirst({
where: (allocation, { eq: equals }) =>
equals(allocation.id, identity.allocationId),
columns: { provisionerId: true },
});
return allocation?.provisionerId === "process";
},
},
});
// A finalized turn's persisted-artifact tool-call results become
// delivery file parts (CL-6000) via `createArtifactDeliveryHandler`,
Expand Down Expand Up @@ -2652,6 +2669,21 @@ export async function createHub(config: HubConfig) {
],
}),
);
// Loopback OAuth connect (CL-7508): codex/xai-oauth connect through a
// sidecar-hosted pinned-port login, not a hub redirect. The gate (a local
// sidecar) lives on the router; this mount only ships the authorize URL
// back to the caller and lets the sidecar's terminal result frame persist
// through the shared connect sequence.
app.route(
`${TENANT_PREFIX}/connections/oauth`,
createOAuthLoopbackRoutes({
hubUrl: config.baseUrl,
log: (line) => log.info`${line}`,
registry: CONNECTOR_REGISTRY,
requestOAuthLogin: (args) => sidecarRouter.requestOAuthLogin(args),
providerHealth: providerHealthStore,
}),
);
// GitHub connect card (CL-6344): the code-review template's inline
// room card reads its live state and starts reviews through here.
// Connecting the PAT itself stays on `connections` above (`github` is
Expand Down
1 change: 1 addition & 0 deletions apps/sidecar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@corbits/codex-provider": "workspace:*",
"@corbits/credential-providers": "workspace:*",
"@corbits/error-sink": "workspace:*",
"@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#v0.1.1",
"@corbits/ollama-adapter": "workspace:*",
"@corbits/xai-provider": "workspace:*",
"@intx/agent": "workspace:*",
Expand Down
6 changes: 6 additions & 0 deletions apps/sidecar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { createWorkflowClosureMaterializer } from "./workflow-closure-materializ
import { MAX_INLINE_ASSET_PAYLOAD_BYTES } from "./source-asset-delivery";
import { createDefaultHarnessBuilder } from "./default-harness";
import { createHubLinkWatchdog } from "./hub-link-watchdog";
import { createOAuthLoopbackLoginService } from "./oauth-login";
import { attachShutdownRejectionHandler, runSidecarShutdown } from "./shutdown";
import { loadOrMintSidecarKeypair } from "./signing-keypair";
import {
Expand Down Expand Up @@ -216,6 +217,10 @@ const workflowProbeExecutor = createWorkflowProbeExecutor({
}),
});

// Sidecar-hosted loopback OAuth logins (CL-7508): stages the pinned-port
// PKCE login for codex/xai-oauth on this machine when the hub requests it.
const oauthLoopbackLogin = createOAuthLoopbackLoginService();

const watchdogLog = getLogger(["sidecar", "hub-link-watchdog"]);
const watchdog = createHubLinkWatchdog({
stallDeadlineMs: 60_000,
Expand Down Expand Up @@ -252,6 +257,7 @@ const orchestrator = createSidecarOrchestrator({
// never echoed back to the Hub as a new sidecar-authored update.
applyWorkflowRunPack: restoreWorkflowRunPack,
workflowProbeExecutor,
oauthLoginExecutor: oauthLoopbackLogin.start,
// Called from every connection's open handler -- the watchdog's
// aliveness signal -- and from the close path, which immediately
// re-schedules a reconnect that re-arms the deadline.
Expand Down
89 changes: 89 additions & 0 deletions apps/sidecar/src/oauth-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// CL-7508: the sidecar-hosted loopback login service binds exactly the
// pinned provider ports, surfaces a busy port as the typed
// `OAuthCallbackPortInUseError`, and never opens a browser sidecar-side.
import { afterAll, describe, expect, test } from "bun:test";
import { createServer, type Server } from "node:http";
import {
CODEX_AUTHORIZE_URL,
CODEX_REDIRECT_URI,
} from "@corbits/codex-provider/constants";
import { XAI_REDIRECT_URI } from "@corbits/xai-provider";
import { OAuthCallbackPortInUseError } from "@corbits/oauth-core";
import { createOAuthLoopbackLoginService } from "./oauth-login";

const occupied: Server[] = [];
afterAll(() => {
for (const server of occupied) server.close();
});

/** Occupies a pinned port so a login attempt must fail with the typed
* port-in-use error. Resolves false when the port was already busy (the
* assertion we are about to make is then trivially proven by whatever
* holds the port). */
function occupy(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
occupied.push(server);
resolve(true);
});
});
}

function redirectOf(authorizeUrl: string): string {
return new URL(authorizeUrl).searchParams.get("redirect_uri") ?? "";
}

describe("oauth loopback login service", () => {
test("codex pins localhost:1455 in the authorize URL it returns", async () => {
const service = createOAuthLoopbackLoginService();
const handle = await service.start("codex");
try {
expect(handle.authorizeUrl.startsWith(CODEX_AUTHORIZE_URL)).toBe(true);
expect(redirectOf(handle.authorizeUrl)).toBe(CODEX_REDIRECT_URI);
expect(new URL(CODEX_REDIRECT_URI).port).toBe("1455");
} finally {
handle.cancel();
}
});

test("xai-oauth pins 127.0.0.1:1456 in the authorize URL it returns", async () => {
const service = createOAuthLoopbackLoginService();
const handle = await service.start("xai-oauth");
try {
expect(redirectOf(handle.authorizeUrl)).toBe(XAI_REDIRECT_URI);
expect(new URL(XAI_REDIRECT_URI).hostname).toBe("127.0.0.1");
expect(new URL(XAI_REDIRECT_URI).port).toBe("1456");
} finally {
handle.cancel();
}
});

test("a bound pinned port surfaces OAuthCallbackPortInUseError, never a fallback port", async () => {
// The xai pin shares this machine's port space, so holding 1456 forces
// the bind failure path for whichever login reaches it.
const held = await occupy(1456);
if (!held) return; // something else already proves the port is busy
const service = createOAuthLoopbackLoginService();
// The bind happens while the handle is being staged, so `start` itself
// is what rejects.
const outcome = await service.start("xai-oauth").then(
() => "completed",
(cause: unknown) => cause,
);
expect(outcome).toBeInstanceOf(OAuthCallbackPortInUseError);
expect((outcome as OAuthCallbackPortInUseError).port).toBe(1456);
});

test("a cancelled login frees the pinned port for the next one", async () => {
const service = createOAuthLoopbackLoginService();
const first = await service.start("codex");
first.cancel();
// If cancel did not close 1455's listener, this rebind would fail with
// the typed port-in-use error instead of staging a fresh login.
const second = await service.start("codex");
expect(redirectOf(second.authorizeUrl)).toBe(CODEX_REDIRECT_URI);
second.cancel();
});
});
Loading
Loading