Skip to content

Commit 1e9ec57

Browse files
authored
Merge branch 'main' into cl-543-sidebar-avatar-activity
2 parents 90cbe50 + 0ddc916 commit 1e9ec57

35 files changed

Lines changed: 1853 additions & 37 deletions

‎VENDORED.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎apps/hub/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
"@modelcontextprotocol/sdk": "catalog:",
6363
"@workbench/access-policy": "workspace:*",
6464
"@corbits/connections": "workspace:*",
65+
"@corbits/codex-provider": "workspace:*",
66+
"@corbits/xai-provider": "workspace:*",
6567
"@corbits/hub-api-client": "workspace:*",
6668
"@corbits/seeding": "workspace:*",
6769
"@workbench/onboarding": "workspace:*",

‎apps/hub/src/credential-material-refresh.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import {
3737
import { pushSourceUpdates, type SidecarRouter } from "@intx/hub-sessions";
3838
import { credentialAad, type CredentialCipher } from "@intx/types";
3939
import { mcpSlugOf, refreshMcpOAuthTokens } from "@corbits/connections";
40+
import { refreshCodexTokens } from "@corbits/codex-provider";
41+
import { refreshXaiTokens } from "@corbits/xai-provider";
4042
import type {
4143
OAuthClientInformationMixed,
4244
OAuthTokens,
@@ -56,6 +58,15 @@ const CredentialMetadata = type({
5658
"clientInformation?": "unknown",
5759
});
5860

61+
/** The codex credential's metadata slice: the `chatgpt_account_id` the
62+
* connect flow derived from the id_token (CL-7508). */
63+
const CodexCredentialMetadata = type({ "accountId?": "string" });
64+
65+
function metadataAccountId(metadata: unknown): string | undefined {
66+
const parsed = CodexCredentialMetadata(metadata);
67+
return parsed instanceof type.errors ? undefined : parsed.accountId;
68+
}
69+
5970
/** The row shape the serving seam hands the hook (a `credential` row
6071
* subset; `refreshSecret` still ciphertext). */
6172
export type ServingCredentialRow = {
@@ -205,6 +216,48 @@ export function createServingRefresh(deps: ServingRefreshDeps): ServingRefresh {
205216
if (full === null || row.refreshSecret === null) {
206217
throw new Error(`credential ${row.id} lost its refresh secret`);
207218
}
219+
// The two loopback-OAuth inference providers (CL-7508) refresh
220+
// through their provider packages' own grants, not the generalized
221+
// MCP `auth()` refresh: their endpoints, public client ids, and
222+
// token shapes live in @corbits/{codex,xai}-provider, and the
223+
// provider row's name is the connector id those packages key on.
224+
if (full.providerName === "codex") {
225+
const accountId = metadataAccountId(full.metadata);
226+
const refreshed = await refreshCodexTokens(row.refreshSecret, now(), {
227+
access: row.secret,
228+
refresh: row.refreshSecret,
229+
...(row.expiresAt === null
230+
? {}
231+
: { expiresAt: row.expiresAt.getTime() }),
232+
// The account id rides the credential's metadata (written at
233+
// connect from the id_token); carry it forward so a refresh
234+
// response without an id_token never drops chatgpt-account-id.
235+
...(accountId !== undefined ? { accountId } : {}),
236+
});
237+
return {
238+
secret: refreshed.access,
239+
...(refreshed.refresh === undefined
240+
? {}
241+
: { refreshSecret: refreshed.refresh }),
242+
expiresAt:
243+
refreshed.expiresAt === undefined
244+
? null
245+
: new Date(refreshed.expiresAt),
246+
};
247+
}
248+
if (full.providerName === "xai-oauth") {
249+
const refreshed = await refreshXaiTokens(row.refreshSecret, now());
250+
return {
251+
secret: refreshed.access,
252+
...(refreshed.refresh === undefined
253+
? {}
254+
: { refreshSecret: refreshed.refresh }),
255+
expiresAt:
256+
refreshed.expiresAt === undefined
257+
? null
258+
: new Date(refreshed.expiresAt),
259+
};
260+
}
208261
const parsed = CredentialMetadata(full.metadata ?? {});
209262
const serverUrl =
210263
full.apiBaseUrl ??

‎apps/hub/src/index.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ import {
293293
createMcpOAuthRoutes,
294294
createMcpServerRoutes,
295295
createOAuthConnectRoutes,
296+
createOAuthLoopbackRoutes,
296297
createTenantConnectCredential,
297298
createWorkflowConnectionRoutes,
298299
DEFAULT_RETURN_PATH_ALLOWLIST,
@@ -887,6 +888,22 @@ export async function createHub(config: HubConfig) {
887888
authenticateSidecar: async ({ token }) => sidecarCredentials.resolve(token),
888889
validateSidecarIdentity: sidecarCredentials.isCurrent,
889890
lookups,
891+
// CL-7508: a loopback login may only run on a sidecar whose allocation
892+
// was provisioned by the `process` backend — the only one that runs on
893+
// this host, where the user's browser can reach the pinned ports
894+
// (1455/1456). A docker/e2b-provisioned sidecar's localhost is the
895+
// container or the sandbox, never this machine, so it fails the gate
896+
// and the connect request gets the typed gate outcome.
897+
oauthLogin: {
898+
isLocalSidecar: async (identity) => {
899+
const allocation = await db.query.sidecarAllocation.findFirst({
900+
where: (allocation, { eq: equals }) =>
901+
equals(allocation.id, identity.allocationId),
902+
columns: { provisionerId: true },
903+
});
904+
return allocation?.provisionerId === "process";
905+
},
906+
},
890907
});
891908
// A finalized turn's persisted-artifact tool-call results become
892909
// delivery file parts (CL-6000) via `createArtifactDeliveryHandler`,
@@ -2652,6 +2669,21 @@ export async function createHub(config: HubConfig) {
26522669
],
26532670
}),
26542671
);
2672+
// Loopback OAuth connect (CL-7508): codex/xai-oauth connect through a
2673+
// sidecar-hosted pinned-port login, not a hub redirect. The gate (a local
2674+
// sidecar) lives on the router; this mount only ships the authorize URL
2675+
// back to the caller and lets the sidecar's terminal result frame persist
2676+
// through the shared connect sequence.
2677+
app.route(
2678+
`${TENANT_PREFIX}/connections/oauth`,
2679+
createOAuthLoopbackRoutes({
2680+
hubUrl: config.baseUrl,
2681+
log: (line) => log.info`${line}`,
2682+
registry: CONNECTOR_REGISTRY,
2683+
requestOAuthLogin: (args) => sidecarRouter.requestOAuthLogin(args),
2684+
providerHealth: providerHealthStore,
2685+
}),
2686+
);
26552687
// GitHub connect card (CL-6344): the code-review template's inline
26562688
// room card reads its live state and starts reviews through here.
26572689
// Connecting the PAT itself stays on `connections` above (`github` is

‎apps/sidecar/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@corbits/codex-provider": "workspace:*",
2020
"@corbits/credential-providers": "workspace:*",
2121
"@corbits/error-sink": "workspace:*",
22+
"@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#v0.1.1",
2223
"@corbits/ollama-adapter": "workspace:*",
2324
"@corbits/xai-provider": "workspace:*",
2425
"@intx/agent": "workspace:*",

‎apps/sidecar/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { createWorkflowClosureMaterializer } from "./workflow-closure-materializ
4242
import { MAX_INLINE_ASSET_PAYLOAD_BYTES } from "./source-asset-delivery";
4343
import { createDefaultHarnessBuilder } from "./default-harness";
4444
import { createHubLinkWatchdog } from "./hub-link-watchdog";
45+
import { createOAuthLoopbackLoginService } from "./oauth-login";
4546
import { attachShutdownRejectionHandler, runSidecarShutdown } from "./shutdown";
4647
import { loadOrMintSidecarKeypair } from "./signing-keypair";
4748
import {
@@ -216,6 +217,10 @@ const workflowProbeExecutor = createWorkflowProbeExecutor({
216217
}),
217218
});
218219

220+
// Sidecar-hosted loopback OAuth logins (CL-7508): stages the pinned-port
221+
// PKCE login for codex/xai-oauth on this machine when the hub requests it.
222+
const oauthLoopbackLogin = createOAuthLoopbackLoginService();
223+
219224
const watchdogLog = getLogger(["sidecar", "hub-link-watchdog"]);
220225
const watchdog = createHubLinkWatchdog({
221226
stallDeadlineMs: 60_000,
@@ -252,6 +257,7 @@ const orchestrator = createSidecarOrchestrator({
252257
// never echoed back to the Hub as a new sidecar-authored update.
253258
applyWorkflowRunPack: restoreWorkflowRunPack,
254259
workflowProbeExecutor,
260+
oauthLoginExecutor: oauthLoopbackLogin.start,
255261
// Called from every connection's open handler -- the watchdog's
256262
// aliveness signal -- and from the close path, which immediately
257263
// re-schedules a reconnect that re-arms the deadline.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// CL-7508: the sidecar-hosted loopback login service binds exactly the
2+
// pinned provider ports, surfaces a busy port as the typed
3+
// `OAuthCallbackPortInUseError`, and never opens a browser sidecar-side.
4+
import { afterAll, describe, expect, test } from "bun:test";
5+
import { createServer, type Server } from "node:http";
6+
import {
7+
CODEX_AUTHORIZE_URL,
8+
CODEX_REDIRECT_URI,
9+
} from "@corbits/codex-provider/constants";
10+
import { XAI_REDIRECT_URI } from "@corbits/xai-provider";
11+
import { OAuthCallbackPortInUseError } from "@corbits/oauth-core";
12+
import { createOAuthLoopbackLoginService } from "./oauth-login";
13+
14+
const occupied: Server[] = [];
15+
afterAll(() => {
16+
for (const server of occupied) server.close();
17+
});
18+
19+
/** Occupies a pinned port so a login attempt must fail with the typed
20+
* port-in-use error. Resolves false when the port was already busy (the
21+
* assertion we are about to make is then trivially proven by whatever
22+
* holds the port). */
23+
function occupy(port: number): Promise<boolean> {
24+
return new Promise((resolve) => {
25+
const server = createServer();
26+
server.once("error", () => resolve(false));
27+
server.listen(port, "127.0.0.1", () => {
28+
occupied.push(server);
29+
resolve(true);
30+
});
31+
});
32+
}
33+
34+
function redirectOf(authorizeUrl: string): string {
35+
return new URL(authorizeUrl).searchParams.get("redirect_uri") ?? "";
36+
}
37+
38+
describe("oauth loopback login service", () => {
39+
test("codex pins localhost:1455 in the authorize URL it returns", async () => {
40+
const service = createOAuthLoopbackLoginService();
41+
const handle = await service.start("codex");
42+
try {
43+
expect(handle.authorizeUrl.startsWith(CODEX_AUTHORIZE_URL)).toBe(true);
44+
expect(redirectOf(handle.authorizeUrl)).toBe(CODEX_REDIRECT_URI);
45+
expect(new URL(CODEX_REDIRECT_URI).port).toBe("1455");
46+
} finally {
47+
handle.cancel();
48+
}
49+
});
50+
51+
test("xai-oauth pins 127.0.0.1:1456 in the authorize URL it returns", async () => {
52+
const service = createOAuthLoopbackLoginService();
53+
const handle = await service.start("xai-oauth");
54+
try {
55+
expect(redirectOf(handle.authorizeUrl)).toBe(XAI_REDIRECT_URI);
56+
expect(new URL(XAI_REDIRECT_URI).hostname).toBe("127.0.0.1");
57+
expect(new URL(XAI_REDIRECT_URI).port).toBe("1456");
58+
} finally {
59+
handle.cancel();
60+
}
61+
});
62+
63+
test("a bound pinned port surfaces OAuthCallbackPortInUseError, never a fallback port", async () => {
64+
// The xai pin shares this machine's port space, so holding 1456 forces
65+
// the bind failure path for whichever login reaches it.
66+
const held = await occupy(1456);
67+
if (!held) return; // something else already proves the port is busy
68+
const service = createOAuthLoopbackLoginService();
69+
// The bind happens while the handle is being staged, so `start` itself
70+
// is what rejects.
71+
const outcome = await service.start("xai-oauth").then(
72+
() => "completed",
73+
(cause: unknown) => cause,
74+
);
75+
expect(outcome).toBeInstanceOf(OAuthCallbackPortInUseError);
76+
expect((outcome as OAuthCallbackPortInUseError).port).toBe(1456);
77+
});
78+
79+
test("a cancelled login frees the pinned port for the next one", async () => {
80+
const service = createOAuthLoopbackLoginService();
81+
const first = await service.start("codex");
82+
first.cancel();
83+
// If cancel did not close 1455's listener, this rebind would fail with
84+
// the typed port-in-use error instead of staging a fresh login.
85+
const second = await service.start("codex");
86+
expect(redirectOf(second.authorizeUrl)).toBe(CODEX_REDIRECT_URI);
87+
second.cancel();
88+
});
89+
});

0 commit comments

Comments
 (0)