diff --git a/VENDORED.md b/VENDORED.md index f39919818..df80da24f 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -28,14 +28,14 @@ never a convenience. | `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; one local delta (CL-7190): `ToolBundle.beforeToolExtension` (`tool.ts`) and its composition into `ResolvedTools.beforeToolExtensions` (`agent.ts`), so a tool package can contribute its own suspend-capable extension without the reactor or director special-casing it by name; retired by the next `@intx/agent` publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0089`/`0090` behind upstream's `0088`, plus `0091` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections, plus `0092` adding `workflow_definition.schedule_claimed_minute` for the hub's native ScheduleTrigger poller (CL-4455); the pin also carries upstream's unpublished per-principal signing-key series (`principal_key` migration renumbered to `0093`, the principal/principal-key stores, and the backfill), which npm 0.3.0 lacks; and a CL-7505 serving-time refresh delta (`model-source-resolution.ts`: `buildSource`/`resolveModelSources`/`resolveInstanceModelSources` accept an optional `servingRefresh` hook, exposing `buildSource`, with a new `credential_needs_reauth` skip so an unrefreshable `oauth_token` credential is never served); one more local delta (CL-7510): `"openai-responses"` added to `schema/catalog.ts`'s `modelProvider.plugin` enum, in lockstep with the `@intx/types` delta, and an adapter registry-key dispatch delta in `model-source-resolution.ts` (`buildSource` rewrites both the launched `InferenceSource.provider` and the served credential material's `providerKey` to the provider's own registry key — `ollama`, `codex`, `xai-oauth` — while the catalog `plugin` column keeps the wire-format id, so the sidecar manifest's custom factories actually resolve); retired when upstream absorbs the deltas or publishes the signing-key series | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; one local delta (CL-6396): `SessionEventSink` / `publishWorkflowInferenceEvent` stamp optional `childRunId` on `agent.event`; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; one local delta (CL-6396): `SessionEventSink` / `publishWorkflowInferenceEvent` stamp optional `childRunId` on `agent.event`; another (CL-7508): the `OAuthLoginExecutor` link option (`ws/hub-link.ts`) routing inbound `oauth.login.start` frames to the sidecar host's loopback login executor and answering every path with `oauth.login.result` frames (`started`, then one terminal arm), with a fail-closed placeholder executor so an unwired host still errors instead of hanging the hub, threaded through `sidecar-orchestrator.ts`'s config, plus cancellation: `oauth.login.cancel` frames and link disconnects close the staged login's pinned-port listener via the executor's `cancel` handle; the pin also carries the package's first colocated test (`ws/hub-link-oauth-login.test.ts` with a `bun test` script) pinning the cancel dispatch against a local ws server; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345), the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer), or the upstream per-principal signing-key seam this pin re-vendors (`principalKeyStore` threaded through the tenant/invite/run/workflow routes, the grant materializer, and `createApp`); and a CL-7505 serving-time refresh delta (`run-source-resolution.ts`: `resolveDefinitionSources` accepts and threads an optional `servingRefresh` hook); retired when upstream absorbs the deltas or publishes the signing-key series | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478), the sealed-run terminal-status backfill (CL-6595), the CL-7190 `registerSignalCorrelation` approval-only guard (fails loud on a future `SignalKind` this RPC has no persistence for, rather than silently mis-persisting one), or the CL-6396 `childRunId` forward on `agent.event`; and a CL-7505 serving-time refresh delta (`credential-push.ts`: `pushInstanceSourceUpdate`/`pushSourceUpdates`/`pushSourceUpdatesSubtree` accept and thread an optional `servingRefresh` hook); retired when upstream absorbs the deltas | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478), the sealed-run terminal-status backfill (CL-6595), the CL-7190 `registerSignalCorrelation` approval-only guard (fails loud on a future `SignalKind` this RPC has no persistence for, rather than silently mis-persisting one), or the CL-6396 `childRunId` forward on `agent.event`; and a CL-7505 serving-time refresh delta (`credential-push.ts`: `pushInstanceSourceUpdate`/`pushSourceUpdates`/`pushSourceUpdatesSubtree` accept and thread an optional `servingRefresh` hook); and a CL-7508 loopback-login delta (`ws/sidecar-handler.ts`: the `oauth.login.result` frame classified as a bypass/terminal correlated response, the `pendingOAuthLogins` request map with a whole-login timeout that also sends `oauth.login.cancel` so the sidecar tears its pinned-port listener down, plus a disconnect sweep, the `requestOAuthLogin` router method gated behind a new `oauthLogin.isLocalSidecar` locality policy, and the typed gate outcome when no connected sidecar is local; review fix: the terminal `completed` arm resolves the pending login with its tokens instead of falling into the error arm); retired when upstream absorbs the deltas | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); local deltas: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); and CL-7190's `message_response` resume branch in `reactor.ts`'s `resumePendingOperation`/`timeoutMessageFor`, plus its `reactor.test.ts`/`testing/fakes.ts` regression harness (this package previously had zero tests); retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); one local delta (CL-7190): `"message_response"` added to `signals.ts`'s `signalKinds`, alongside `signalKindToGateType`; another (CL-6396): optional `childRunId` on `AgentEventFrame`; the pin also carries the unpublished `signer-identity` contract and `principalKeyAad` domain separator from the signing-key series; one more local delta (CL-7510): `"openai-responses"` added to `catalog.ts`'s `modelProviderPlugins` so the workbench's loopback-OAuth providers (Codex, xai-oauth) can create model-provider rows; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); one local delta (CL-7190): `"message_response"` added to `signals.ts`'s `signalKinds`, alongside `signalKindToGateType`; another (CL-6396): optional `childRunId` on `AgentEventFrame`; the pin also carries the unpublished `signer-identity` contract and `principalKeyAad` domain separator from the signing-key series; one more local delta (CL-7510): `"openai-responses"` added to `catalog.ts`'s `modelProviderPlugins` so the workbench's loopback-OAuth providers (Codex, xai-oauth) can create model-provider rows; another (CL-7508): the `oauth.login.start` / `oauth.login.result` / `oauth.login.cancel` frame family (`OAuthLoginStartFrame`/`OAuthLoginResultFrame`/`OAuthLoginCancelFrame` plus the `OAuthLoginOutcome`/`OAuthLoginTokens` arms, in `sidecar.ts`) threaded through the sidecar ws channel for sidecar-hosted loopback logins, with a parse/reject test (`sidecar-oauth-login.test.ts`); retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/hub-common` | `@intx/hub-common` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 predates `generateId`'s `pky_` principal-key prefix from the signing-key series, which the re-vendored `db`/`hub-api` import; no local delta; retired by the next `@intx/hub-common` publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | No local delta: npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-11-03 | `check:killdates` | | `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-11-03 | `check:killdates` | diff --git a/apps/hub/package.json b/apps/hub/package.json index c79470db7..fb80e088f 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -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:*", diff --git a/apps/hub/src/credential-material-refresh.ts b/apps/hub/src/credential-material-refresh.ts index 3a960b6e8..f311626b6 100644 --- a/apps/hub/src/credential-material-refresh.ts +++ b/apps/hub/src/credential-material-refresh.ts @@ -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, @@ -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 = { @@ -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 ?? diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 00bdb34ca..b93db1244 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -293,6 +293,7 @@ import { createMcpOAuthRoutes, createMcpServerRoutes, createOAuthConnectRoutes, + createOAuthLoopbackRoutes, createTenantConnectCredential, createWorkflowConnectionRoutes, DEFAULT_RETURN_PATH_ALLOWLIST, @@ -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`, @@ -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 diff --git a/apps/sidecar/package.json b/apps/sidecar/package.json index 5ce0a3b53..b827f2107 100644 --- a/apps/sidecar/package.json +++ b/apps/sidecar/package.json @@ -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:*", diff --git a/apps/sidecar/src/index.ts b/apps/sidecar/src/index.ts index e6b60ff31..07efc9c34 100644 --- a/apps/sidecar/src/index.ts +++ b/apps/sidecar/src/index.ts @@ -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 { @@ -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, @@ -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. diff --git a/apps/sidecar/src/oauth-login.test.ts b/apps/sidecar/src/oauth-login.test.ts new file mode 100644 index 000000000..63f282c46 --- /dev/null +++ b/apps/sidecar/src/oauth-login.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/sidecar/src/oauth-login.ts b/apps/sidecar/src/oauth-login.ts new file mode 100644 index 000000000..ec7a8fc06 --- /dev/null +++ b/apps/sidecar/src/oauth-login.ts @@ -0,0 +1,152 @@ +// Sidecar-hosted loopback OAuth login for the pinned-port providers +// (CL-7508). The authorization servers for Codex ("Login with ChatGPT") and +// xai-oauth (Grok CLI) only accept the fixed loopback redirect URIs their +// own CLIs register — `http://localhost:1455/auth/callback` and +// `http://127.0.0.1:1456/callback` — so the callback listener must bind +// exactly those ports on the machine the user is browsing from. That +// machine is the sidecar's host, which is why the login runs here, not in +// the hub: the hub threads an `oauth.login.start` frame over the ws +// channel, this service stages the login (PKCE pair, callback server, +// code exchange) sidecar-side, and only the finished tokens cross the +// wire — the PKCE verifier never leaves this process, and the browser is +// never opened sidecar-side (the hub ships the authorize URL to the web +// UI for navigation). +import { + buildAuthorizeUrl, + startCallbackServer, + startOAuthLogin, + type BaseTokens, + type CallbackServerConfig, + type FetchLike, + type OAuthClientConfig, +} from "@corbits/oauth-core"; +import { + codexOAuthConfig, + exchangeCodexCode, + CODEX_REDIRECT_URI, +} from "@corbits/codex-provider"; +import { + exchangeXaiCode, + xaiOAuthConfig, + XAI_REDIRECT_URI, +} from "@corbits/xai-provider"; + +/** The sidecar-hosted login outcome: the shape `@intx/types`'s + * `OAuthLoginTokens` wire arm carries. */ +export type StagedLoginTokens = BaseTokens & { + /** The issuer's id_token, when it issues one (xai-oauth). */ + idToken?: string; + /** id_token-derived account label (codex: `chatgpt_account_id`). */ + accountId?: string; +}; + +export type OAuthLoopbackLoginService = { + start: (connectorId: "codex" | "xai-oauth") => Promise<{ + authorizeUrl: string; + completed: Promise; + /** Closes the callback server and abandons the staged login. */ + cancel: () => void; + }>; +}; + +// The pins this service exists to enforce. A redirect URI whose port is not +// one of these is a manifest drift, not a cue to rebind dynamically. +const CODEX_PINNED_PORT = 1455; +const XAI_PINNED_PORT = 1456; + +function isLoopbackHost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "::1"; +} + +/** Builds the callback-server config from a provider's registered redirect + * URI, asserting the loopback + pinned-port contract up front: a bind + * failure later is genuinely "port already in use", never "we drifted onto + * some other port". */ +function callbackConfig( + redirectUri: string, + pinnedPort: number, +): CallbackServerConfig { + const url = new URL(redirectUri); + if (url.protocol !== "http:" || !isLoopbackHost(url.hostname)) { + throw new Error( + `${redirectUri} is not a loopback http redirect; refusing to host a login for it`, + ); + } + const port = Number(url.port); + if (port !== pinnedPort) { + throw new Error( + `${redirectUri} is not the pinned port ${String(pinnedPort)}; the authorization server only accepts the registered redirect`, + ); + } + return { + port, + host: url.hostname, + path: url.pathname, + doneHtml: + "Connected

Login complete — you can close this window.

", + failedHtml: (reason) => + `Login failed

Login failed: ${reason}

`, + }; +} + +export function createOAuthLoopbackLoginService(deps?: { + /** Epoch-ms clock; defaults to `Date.now`. Injectable for tests. */ + now?: () => number; + /** HTTP client for the token exchange; defaults to `fetch`. */ + fetchImpl?: FetchLike; +}): OAuthLoopbackLoginService { + const now = deps?.now ?? Date.now; + const fetchImpl = deps?.fetchImpl ?? fetch; + + // Browser-open suppression is deliberate: the sidecar runs on the user's + // machine but the hub owns navigation — it ships `authorizeUrl` to the web + // UI, and a sidecar-side `open` would race it with a second consent page. + const suppressedOpen = () => undefined; + + const connectors = { + codex: { + callback: callbackConfig(CODEX_REDIRECT_URI, CODEX_PINNED_PORT), + oauthConfig: codexOAuthConfig satisfies OAuthClientConfig, + exchange: (code: string, verifier: string, at: number) => + exchangeCodexCode(code, verifier, at, fetchImpl), + }, + "xai-oauth": { + callback: callbackConfig(XAI_REDIRECT_URI, XAI_PINNED_PORT), + oauthConfig: xaiOAuthConfig, + exchange: (code: string, verifier: string, at: number) => + exchangeXaiCode(code, verifier, at, fetchImpl), + }, + } as const; + + return { + async start(connectorId) { + const connector = connectors[connectorId]; + const handle = await startOAuthLogin( + { profile: connectorId, signal: new AbortController().signal, now }, + { + startCallbackServer: (state) => + startCallbackServer(state, connector.callback), + buildAuthorizeUrl: (pkce, state) => + buildAuthorizeUrl(connector.oauthConfig, pkce, state), + exchangeCode: (code, verifier, at) => + connector.exchange(code, verifier, at), + // Persistence is the hub's job: the connect pipeline stores the + // credential row, so the staged `commit` here is a no-op that + // exists only to satisfy the oauth-core login contract. + saveProfile: async () => undefined, + // A no-op keeps navigation authority with the hub: the web UI + // opens the authorize URL the hub ships it, and a sidecar-side + // opener would race it with a second consent page. + openInBrowser: suppressedOpen, + }, + ); + return { + authorizeUrl: handle.authorizeUrl, + completed: handle.completed.then((staged) => staged.profile.tokens), + cancel: () => { + handle.cancel(); + }, + }; + }, + }; +} diff --git a/bun.lock b/bun.lock index 6fa678069..7221ca6ad 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@corbits/bench": "workspace:*", "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", + "@corbits/codex-provider": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/credential-providers": "workspace:*", @@ -77,6 +78,7 @@ "@corbits/url-path": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workflows": "workspace:*", + "@corbits/xai-provider": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", @@ -115,6 +117,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:*", @@ -614,6 +617,7 @@ "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", + "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "arktype": "catalog:", @@ -3623,11 +3627,17 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3651,9 +3661,11 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -3709,8 +3721,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3757,8 +3767,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/packages/connections/package.json b/packages/connections/package.json index c2d351fb4..c56bf39f4 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -42,6 +42,7 @@ "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", + "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "arktype": "catalog:", diff --git a/packages/connections/src/descriptor.ts b/packages/connections/src/descriptor.ts index e90407e3d..23642e836 100644 --- a/packages/connections/src/descriptor.ts +++ b/packages/connections/src/descriptor.ts @@ -33,6 +33,10 @@ export type OAuthExchangeResult = * expiring access token (Google) — stored as the credential row's * `refreshSecret`, never in metadata. */ readonly refreshToken?: string; + /** An id_token-derived account label (codex: `chatgpt_account_id`, + * CL-7508) — folded into the credential's `metadata`, never stored + * as a secret. */ + readonly accountId?: string; } | { readonly ok: false; readonly message: string }; diff --git a/packages/connections/src/index.ts b/packages/connections/src/index.ts index 0a1270a40..725ebf541 100644 --- a/packages/connections/src/index.ts +++ b/packages/connections/src/index.ts @@ -95,6 +95,11 @@ export { createTenantConnectCredential, type CreateTenantConnectCredentialDeps, } from "./oauth-tenant-connect"; +export { + createOAuthLoopbackRoutes, + isLoopbackConnectorId, + type CreateOAuthLoopbackRoutesDeps, +} from "./oauth-loopback-routes"; export { isInferenceProvider, persistConnectorCredential, diff --git a/packages/connections/src/oauth-loopback-routes.test.ts b/packages/connections/src/oauth-loopback-routes.test.ts new file mode 100644 index 000000000..198b3f1fe --- /dev/null +++ b/packages/connections/src/oauth-loopback-routes.test.ts @@ -0,0 +1,197 @@ +// CL-7508: the loopback connect route returns the authorize URL the +// sidecar's login staged, resolves the typed gate outcome when no local +// sidecar passed the gate, and persists the terminal tokens through the +// shared connect pipeline (including the id_token-derived metadata). +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import type { ConnectorDescriptor } from "./descriptor"; +import { + createOAuthLoopbackRoutes, + type CreateOAuthLoopbackRoutesDeps, +} from "./oauth-loopback-routes"; + +const loopbackDescriptor: ConnectorDescriptor = { + id: "codex", + displayName: "Codex", + authKind: "oauth-loopback", + docsUrl: "https://example.com/docs", + feedsTools: [], + credentialPlugin: "http", + oauth: { + authorizeUrl: "https://auth.openai.com/oauth/authorize", + usesPKCE: true, + echoesState: true, + deploysDefaultWorkflows: true, + buildAuthorizeUrl: ({ state }) => + new URL(`https://auth.openai.com/oauth/authorize?state=${state}`), + exchange: async () => ({ ok: false, message: "unused in this test" }), + }, +}; + +function makeDeps(overrides?: { + requestOAuthLogin?: CreateOAuthLoopbackRoutesDeps["requestOAuthLogin"]; + persistConnectorCredentialFn?: CreateOAuthLoopbackRoutesDeps["seedCatalogFn"]; +}) { + const persisted: { + tenantId: string; + connectorId: string; + secret: string; + expiresAt: string | undefined; + metadata: Record | undefined; + refreshSecret: string | undefined; + }[] = []; + const deps: CreateOAuthLoopbackRoutesDeps = { + hubUrl: "http://hub.test", + log: () => undefined, + registry: { codex: loopbackDescriptor }, + requestOAuthLogin: + overrides?.requestOAuthLogin ?? + (() => + Promise.resolve({ + status: "gate", + message: "requires a local sidecar", + })), + ensureProviderFn: async () => "prov_1", + ensureCredentialFn: async (_api, _cookies, args) => { + persisted.push({ + tenantId: args.tenantId, + connectorId: args.name, + secret: args.secret, + expiresAt: args.expiresAt, + metadata: args.metadata, + refreshSecret: args.refreshSecret, + }); + return "cred_1"; + }, + seedCatalogFn: async () => ({ + completionCapable: false, + modelsSeeded: 0, + hasCompletionCapableModel: false, + }), + }; + return { deps, persisted }; +} + +function tenantEnvApp(deps: CreateOAuthLoopbackRoutesDeps) { + // Mirror the hub's tenant middleware: the factory reads + // `c.get("tenant")`/`c.get("principal")` from the resolved env. + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", TENANT); + c.set("principal", PRINCIPAL); + await next(); + }); + app.route("/", createOAuthLoopbackRoutes(deps)); + return app; +} + +function makeRequest(): Request { + return new Request("http://hub.test/codex/loopback", { + method: "POST", + headers: { cookie: "session=cookie-value" }, + }); +} + +// Minimal TenantEnv stand-ins: the route factory only reads +// `c.get("tenant")`/`c.get("principal")` from the typed env, and the +// wrapper middleware supplies them. +const TENANT = { + id: "tenant_1", + name: "Bench", + slug: "bench", + domain: "bench.example", + parentId: null, + config: null, + createdAt: new Date(), + updatedAt: new Date(), +}; +const PRINCIPAL = { + id: "prn_1", + tenantId: TENANT.id, + kind: "user" as const, + refId: "prn_1", + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("createOAuthLoopbackRoutes", () => { + test("no local sidecar resolves the typed gate outcome", async () => { + const { deps } = makeDeps(); + const response = await tenantEnvApp(deps).request(makeRequest()); + expect(response.status).toBe(409); + const body = (await response.json()) as { ok: boolean; reason: string }; + expect(body.ok).toBe(false); + expect(body.reason).toBe("gate"); + }); + + test("a started login returns its authorize URL and persists the terminal tokens", async () => { + let resolveCompleted: (final: { + status: "completed"; + tokens: { + access: string; + refresh: string; + expiresAt: number; + accountId: string; + }; + }) => void = () => undefined; + const completed = new Promise<{ + status: "completed" | "error"; + tokens?: { + access: string; + refresh: string; + expiresAt: number; + accountId: string; + }; + message?: string; + }>((resolve) => { + resolveCompleted = resolve as typeof resolveCompleted; + }); + const { deps, persisted } = makeDeps({ + requestOAuthLogin: () => + Promise.resolve({ + status: "started", + requestId: "req_1", + authorizeUrl: "https://auth.openai.com/oauth/authorize?x=1", + completed: completed as never, + }), + }); + const app = tenantEnvApp(deps); + const response = await app.request(makeRequest()); + expect(response.status).toBe(200); + const body = (await response.json()) as { + ok: boolean; + authorizeUrl: string; + }; + expect(body.ok).toBe(true); + expect(body.authorizeUrl).toContain("auth.openai.com"); + + resolveCompleted({ + status: "completed", + tokens: { + access: "at", + refresh: "rt", + expiresAt: 1750000000000, + accountId: "acc_42", + }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.secret).toBe("at"); + expect(persisted[0]?.refreshSecret).toBe("rt"); + // The expiry is a first-class credential COLUMN (serving-time refresh + // keys on it), not free-form metadata. + expect(persisted[0]?.expiresAt).toBe(new Date(1750000000000).toISOString()); + // The derived account label persists; the raw id_token never does. + expect(persisted[0]?.metadata).toEqual({ accountId: "acc_42" }); + }); + + test("a non-loopback connector is refused", async () => { + const { deps } = makeDeps(); + const response = await tenantEnvApp(deps).request( + new Request("http://hub.test/github/loopback", { method: "POST" }), + ); + expect(response.status).toBe(404); + }); +}); diff --git a/packages/connections/src/oauth-loopback-routes.ts b/packages/connections/src/oauth-loopback-routes.ts new file mode 100644 index 000000000..539d1fe2b --- /dev/null +++ b/packages/connections/src/oauth-loopback-routes.ts @@ -0,0 +1,194 @@ +// The loopback-OAuth half of the Connections surface (CL-7508). Where +// `./oauth-routes.ts` drives the flows whose browser returns to the hub, +// the `oauth-loopback` connectors (codex, xai-oauth) cannot: their +// authorization servers only accept the fixed loopback redirect URIs the +// providers' own CLIs register, so the callback listener lives on the +// sidecar's machine and is reached through the sidecar ws channel +// (`oauth.login.start` / `oauth.login.result`). +// +// One POST per connector: gate through the router's locality policy (a +// remote sidecar's loopback opens on the wrong machine — no local sidecar +// resolves the typed gate outcome, never a hub-hosted fallback), return +// the authorize URL for the web UI to navigate, and persist the finished +// tokens through the one shared connect sequence +// (`./persist-credential.ts`) when the sidecar's terminal result frame +// lands. The terminal frame arrives after this response, so the persist +// runs detached: failures are reported, never silently dropped, and a +// caller that needs the outcome re-checks the connection list. +// +// No caller ships yet: the S5 onboarding UI (apps/web) wires these POSTs; +// tracked on CL-7511. +import { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import { createHubAPI } from "@corbits/hub-api-client"; +import { reportError } from "@corbits/error-sink"; +import type { OAuthLoginRequestOutcome } from "@intx/hub-sessions"; +import type { ConnectorDescriptor } from "./descriptor"; +import { + persistConnectorCredential, + type PersistConnectorCredentialFns, +} from "./persist-credential"; + +export type CreateOAuthLoopbackRoutesDeps = PersistConnectorCredentialFns & { + readonly hubUrl: string; + readonly log: (line: string) => void; + /** The connector set this build ships — this package carries none of + * its own (CL-7384), so a caller always supplies one. */ + readonly registry: Readonly>; + /** The sidecar router's gated login starter; the hub wiring supplies + * it bound to its locality policy. */ + readonly requestOAuthLogin: (args: { + connectorId: "codex" | "xai-oauth"; + }) => Promise; + /** Cleared on a successful connect, same store the other connect + * surfaces share (CL-6092). */ + readonly providerHealth?: { + clear(tenantId: string, connectorId: string): void; + }; + /** Fired once the terminal result persisted, mirroring the other + * connect surfaces' `onConnected`. */ + readonly onConnected?: (info: { + tenantId: string; + connectorId: string; + displayName: string; + }) => Promise | void; +}; + +export function isLoopbackConnectorId( + connectorId: string, +): connectorId is "codex" | "xai-oauth" { + return connectorId === "codex" || connectorId === "xai-oauth"; +} + +export function createOAuthLoopbackRoutes( + deps: CreateOAuthLoopbackRoutesDeps, +): Hono { + const app = new Hono(); + const api = createHubAPI(deps.hubUrl); + + app.post("/:connectorId/loopback", async (c) => { + const connectorId = c.req.param("connectorId"); + const descriptor = deps.registry[connectorId]; + if ( + descriptor === undefined || + descriptor.authKind !== "oauth-loopback" || + descriptor.oauth === undefined || + !isLoopbackConnectorId(connectorId) + ) { + return c.json( + { + ok: false, + reason: "unsupported", + message: `${connectorId} is not a loopback-OAuth connector`, + }, + 404, + ); + } + const tenant = c.get("tenant"); + const cookies = + c.req + .header("cookie") + ?.split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => cookie.length > 0) ?? []; + + let outcome: OAuthLoginRequestOutcome; + try { + outcome = await deps.requestOAuthLogin({ connectorId }); + } catch (cause) { + // report-error-ignore: a failed login start is surfaced to the caller + // through this route's own typed error body, so a report-error sink + // entry would double-report the same failure. + const message = cause instanceof Error ? cause.message : String(cause); + deps.log( + `loopback login for ${connectorId} on tenant ${tenant.id} failed to start: ${message}`, + ); + return c.json({ ok: false, reason: "error", message }, 502); + } + if (outcome.status === "gate") { + return c.json( + { ok: false, reason: "gate", message: outcome.message }, + 409, + ); + } + if (outcome.status === "error") { + return c.json( + { ok: false, reason: "error", message: outcome.message }, + 502, + ); + } + + // The terminal frame lands after this response: persist detached, but + // never silently. + void outcome.completed + .then(async (final) => { + if (final.status === "error") { + deps.log( + `loopback login ${outcome.requestId} for ${connectorId} failed: ${final.message}`, + ); + return; + } + const tokens = final.tokens; + // The derived account label is the only id_token-derived material + // that persists: it is a public label, while the raw id_token is a + // bearer-adjacent secret and metadata is stored unencrypted — so the + // id_token never goes there. The expiry is a first-class COLUMN + // (serving-time refresh keys on it), never metadata. + const credentialMetadata: Record = { + ...(tokens.accountId !== undefined + ? { accountId: tokens.accountId } + : {}), + }; + await persistConnectorCredential({ + api, + cookies, + tenantId: tenant.id, + descriptor, + secret: tokens.access, + credentialMetadata, + ...(tokens.refresh !== undefined + ? { refreshSecret: tokens.refresh } + : {}), + ...(tokens.expiresAt !== undefined + ? { expiresAt: new Date(tokens.expiresAt).toISOString() } + : {}), + ...(deps.ensureProviderFn !== undefined + ? { ensureProviderFn: deps.ensureProviderFn } + : {}), + ...(deps.ensureCredentialFn !== undefined + ? { ensureCredentialFn: deps.ensureCredentialFn } + : {}), + ...(deps.seedCatalogFn !== undefined + ? { seedCatalogFn: deps.seedCatalogFn } + : {}), + log: deps.log, + }); + deps.providerHealth?.clear(tenant.id, connectorId); + await deps.onConnected?.({ + tenantId: tenant.id, + connectorId, + displayName: descriptor.displayName, + }); + deps.log(`connected ${connectorId} for tenant ${tenant.id}`); + }) + .catch((cause: unknown) => { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log( + `loopback login ${outcome.requestId} for ${connectorId} failed to persist: ${message}`, + ); + reportError(cause, { + operation: "persist_loopback_oauth_connection", + tenantId: tenant.id, + extra: { connectorId }, + }); + }); + + return c.json({ + ok: true, + requestId: outcome.requestId, + authorizeUrl: outcome.authorizeUrl, + }); + }); + + return app; +} diff --git a/packages/connections/src/oauth-routes.test.ts b/packages/connections/src/oauth-routes.test.ts index 24284ceb5..7d6660419 100644 --- a/packages/connections/src/oauth-routes.test.ts +++ b/packages/connections/src/oauth-routes.test.ts @@ -418,6 +418,54 @@ describe("GET /:connectorId/callback", () => { ]); }); + test("a redirect-flow exchange's expiry lands on the first-class expiresAt field", async () => { + const stored: { + expiresAt?: string | undefined; + credentialMetadata?: Record | undefined; + }[] = []; + const app = connectRoutes( + { + connectCredential: async (args) => { + stored.push({ + expiresAt: args.expiresAt, + credentialMetadata: args.credentialMetadata, + }); + return { + kind: "connected", + tenantId: "ten_1", + tenantSlug: "widget-tenant", + principalId: "prn_1", + tenantDomain: "widget-tenant.bench.local", + }; + }, + }, + { + widget: fakeDescriptor({ + exchange: async ({ code }) => ({ + ok: true, + apiKey: `key-for-${code}`, + refreshToken: "rt_1", + expiresAt: "2025-09-09T00:00:00.000Z", + }), + }), + }, + ); + const { response: started } = await startConnect(app); + const response = await app.request( + "/api/connections/oauth/widget/callback?code=abc123", + { headers: { cookie: allCookies(started) } }, + ); + expect(response.status).toBe(302); + // The column is what serving-time refresh keys on; the metadata + // presence stays, since it is what types the row `oauth_token`. + expect(stored).toEqual([ + { + expiresAt: "2025-09-09T00:00:00.000Z", + credentialMetadata: { expiresAt: "2025-09-09T00:00:00.000Z" }, + }, + ]); + }); + test("falls back to the default return path when none was requested", async () => { const app = connectRoutes(); const { response: started } = await startConnect(app); diff --git a/packages/connections/src/oauth-routes.ts b/packages/connections/src/oauth-routes.ts index c804a2113..8668518ee 100644 --- a/packages/connections/src/oauth-routes.ts +++ b/packages/connections/src/oauth-routes.ts @@ -182,6 +182,9 @@ export type CreateOAuthConnectRoutesDeps = { cookies: string[]; apiKey: string; credentialMetadata?: Record; + /** ISO instant the secret expires — the credential row's own + * `expiresAt` column, which serving-time refresh keys on. */ + expiresAt?: string; refreshToken?: string; }) => Promise; /** Best-effort duplicate-callback recovery — see this module's header. @@ -590,10 +593,21 @@ export function createOAuthConnectRoutes( cookies, apiKey: exchanged.apiKey, }; - if (exchanged.expiresAt !== undefined) + if (exchanged.expiresAt !== undefined) { + connectCredentialArgs.expiresAt = exchanged.expiresAt; + // The metadata fold stays: its presence is what types the row + // `oauth_token`, while the `expiresAt` field above lands the + // column serving-time refresh keys on. connectCredentialArgs.credentialMetadata = { expiresAt: exchanged.expiresAt, }; + } + if (exchanged.accountId !== undefined) { + connectCredentialArgs.credentialMetadata = { + ...connectCredentialArgs.credentialMetadata, + accountId: exchanged.accountId, + }; + } if (exchanged.refreshToken !== undefined) connectCredentialArgs.refreshToken = exchanged.refreshToken; const result = await deps.connectCredential(connectCredentialArgs); diff --git a/packages/connections/src/oauth-tenant-connect.ts b/packages/connections/src/oauth-tenant-connect.ts index 7c8d69ed5..3e51b9831 100644 --- a/packages/connections/src/oauth-tenant-connect.ts +++ b/packages/connections/src/oauth-tenant-connect.ts @@ -73,6 +73,7 @@ export function createTenantConnectCredential( ...(args.credentialMetadata !== undefined ? { credentialMetadata: args.credentialMetadata } : {}), + ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), ...(args.refreshToken !== undefined ? { refreshSecret: args.refreshToken } : {}), diff --git a/packages/connections/src/persist-credential.ts b/packages/connections/src/persist-credential.ts index 6eca122dc..455669cb7 100644 --- a/packages/connections/src/persist-credential.ts +++ b/packages/connections/src/persist-credential.ts @@ -66,15 +66,20 @@ export type PersistConnectorCredentialArgs = PersistConnectorCredentialFns & { * `baseURLOverride`. Always already proven by the caller, so the row * is stored `verified: true`. */ readonly secret: string; - /** Free-form data stored on the credential's `metadata` field — the - * extension point an expiring OAuth token's expiry lives in. Its - * presence is also what types the row `oauth_token` instead of - * `api_key`. */ + /** Free-form data stored on the credential's `metadata` field — + * provider labels (an OAuth account id, an MCP client registration). + * An expiring OAuth token's expiry is NOT metadata: it is the + * first-class `expiresAt` field, which writes the credential row's own + * column that serving-time refresh keys on. */ readonly credentialMetadata?: Record; /** A provider-issued refresh token — stored on the credential row's * own `refreshSecret` field (a secret, never metadata). Only ever set - * alongside `credentialMetadata`'s `expiresAt`. */ + * alongside `expiresAt`. */ readonly refreshSecret?: string; + /** ISO instant the access token expires — stored on the credential + * row's own `expiresAt` COLUMN, which serving-time refresh keys on + * (CL-7508). Never folded into `credentialMetadata`. */ + readonly expiresAt?: string; /** The instance origin a url-kind connector actually points at — * stored as the provider row's `apiBaseUrl` and threaded into * `seedCatalog`'s own base-URL seam. */ @@ -139,6 +144,9 @@ export async function persistConnectorCredential( ...(args.refreshSecret !== undefined ? { refreshSecret: args.refreshSecret } : {}), + ...(args.expiresAt !== undefined + ? { expiresAt: args.expiresAt } + : {}), } : { tenantId: args.tenantId, diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 5c562d78f..4f07944cd 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -168,12 +168,22 @@ export type TestAndPersistCredentialArgs = CommonArgs & provider: SupportedCredentialProvider; apiKey: string; /** - * Free-form data stored on the credential's `metadata` field — the - * extension point an OAuth connect flow's token expiry lives in (see - * `huggingface-connect.ts`'s `exchangeCodeForToken`). Absent for a + * Free-form data stored on the credential's `metadata` field — + * provider labels (an OAuth account id, an MCP client registration). + * An expiring OAuth token's expiry lives on the first-class + * `expiresAt` field below, which writes the credential row's own + * column that serving-time refresh keys on; a metadata fold may + * also carry it (that fold is what types the row `oauth_token`), + * but it must not be the expiry's only ride. Absent for a * pasted key or a durable-key connect flow (OpenRouter). */ credentialMetadata?: Record; + /** ISO instant an OAuth exchange's access token expires — stored on + * the credential row's own `expiresAt` COLUMN, which serving-time + * refresh keys on. The metadata fold that types the row may also + * carry the expiry, but this field is the ride that lands the + * column. */ + expiresAt?: string; /** The configurable-base-URL seam `ollama` uses (see `modelSourceFor`); * ignored for every other provider. */ baseURLOverride?: string; @@ -194,6 +204,9 @@ export type CompleteCredentialArgs = CommonArgs & provider: SupportedCredentialProvider; apiKey: string; credentialMetadata?: Record; + /** ISO instant an OAuth exchange's access token expires — see + * `TestAndPersistCredentialArgs`. */ + expiresAt?: string; baseURLOverride?: string; seedTenantFn?: (args: SeedTenantArgs) => ReturnType; }; @@ -461,6 +474,7 @@ export async function testAndPersistCredential( descriptor, secret: args.apiKey, log: args.log, + ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), ...(args.credentialMetadata !== undefined ? { credentialMetadata: args.credentialMetadata } : {}), diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index eec070c72..500b86da2 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -633,6 +633,7 @@ export function createOnboardingRoutes( userEmail: string; cookies: string[]; apiKey: string; + expiresAt?: string; credentialMetadata?: Record; }): Promise { const provider = onboardingOAuthProvider(args.connectorId); @@ -659,14 +660,13 @@ export function createOnboardingRoutes( pushWorkflow: deps.pushWorkflow, log: deps.log, }; - return impl( - args.credentialMetadata !== undefined - ? { - ...connectCredentialArgs, - credentialMetadata: args.credentialMetadata, - } - : connectCredentialArgs, - ); + return impl({ + ...connectCredentialArgs, + ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), + ...(args.credentialMetadata !== undefined + ? { credentialMetadata: args.credentialMetadata } + : {}), + }); } async function recentlyConnected(args: { diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 02b04bee7..0332b57db 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -599,6 +599,78 @@ describe("completeCredentialSetup", () => { ]); }); + test("an OAuth exchange's expiresAt reaches the credential row's column, never just metadata", async () => { + // The onboarding mount's share of CL-7508: the exchanged expiry is + // the first-class field persistConnectorCredential writes to the + // `expires_at` COLUMN serving-time refresh keys on — the metadata + // fold only types the row `oauth_token` and must not be the expiry's + // only ride. + const ensureCredentialArgs: Record[] = []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse(); + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return tenantResponse(); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + const baseArgs = { + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + provider: "huggingface" as const, + apiKey: "hf_oauth_minted", + pushWorkflow: noopPush, + log: collector().log, + seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), + seedTenantFn: async () => {}, + }; + + await completeCredentialSetup({ + ...baseArgs, + credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, + expiresAt: "2026-08-13T20:00:00.000Z", + ...stubPersistFns, + ensureCredentialFn: async ( + _api: unknown, + _cookies: string[], + args: { providerId: string }, + ) => { + ensureCredentialArgs.push(args as unknown as Record); + return `cred_${args.providerId}`; + }, + }); + expect(ensureCredentialArgs).toEqual([ + expect.objectContaining({ + type: "oauth_token", + expiresAt: "2026-08-13T20:00:00.000Z", + metadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, + }), + ]); + + // A pasted key (no exchange, no expiry) must omit the column key + // entirely — under exactOptionalPropertyTypes an explicit undefined + // is a different request shape than an absent field. + ensureCredentialArgs.length = 0; + await completeCredentialSetup({ + ...baseArgs, + apiKey: "hf_pasted_key", + ...stubPersistFns, + ensureCredentialFn: async ( + _api: unknown, + _cookies: string[], + args: { providerId: string }, + ) => { + ensureCredentialArgs.push(args as unknown as Record); + return `cred_${args.providerId}`; + }, + }); + expect(ensureCredentialArgs[0]).not.toHaveProperty("expiresAt"); + }); + test("a reconnect against an expired Hugging Face credential rotates it and still reports seeded", async () => { const TIMESTAMP = "2026-01-01T00:00:00.000Z"; const staleCredentialRow = () => ({ diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts index f78971cc5..929a7e4f9 100644 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ b/packages/onboarding/test/huggingface-connect-routes.test.ts @@ -309,6 +309,7 @@ describe("GET /oauth/huggingface/callback", () => { provider: string; apiKey: string; userId: string; + expiresAt?: string; credentialMetadata?: Record; }[] = []; const pendingSeedStore: PendingSeedStore = createInMemoryPendingSeedStore( @@ -330,6 +331,9 @@ describe("GET /oauth/huggingface/callback", () => { provider: args.provider, apiKey: args.apiKey, userId: args.userId, + ...(args.expiresAt !== undefined + ? { expiresAt: args.expiresAt } + : {}), }; connections.push( args.credentialMetadata !== undefined @@ -383,6 +387,10 @@ describe("GET /oauth/huggingface/callback", () => { provider: "huggingface", apiKey: "hf_oauth_minted", userId: "user_1", + // The first-class field through the onboarding wrapper — the + // hop that used to drop the exchange's expiry, leaving the + // row's expires_at column NULL (CL-7508). + expiresAt: "2026-08-13T20:00:00.000Z", credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, }, ]); diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index fdfaa36a3..558537e3e 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -17,15 +17,15 @@ apps/sidecar | sawyer | 2026-11-03 vendor/intx/agent | sawyer | 2026-11-03 | 9dd132fda8d7aef9d70395de2d289abeb0361416b18a5079f219372df69d81a0 vendor/intx/db | sawyer | 2026-11-03 | 2d0f5ddde3a8f9313b7d274fcc9a62d743638cb3ea4078ad4176390bde859136 vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746f31e791fb92cdbc2aaf2c5080dbf -vendor/intx/hub-agent | sawyer | 2026-11-03 | 0a24a0cf85cc0483dbeee815d89847440e2fbd1242fdfd992941379a9acc2a4a +vendor/intx/hub-agent | sawyer | 2026-11-03 | b0f3b0bd760b1a397e00ff47cce5b9701273bdb04e294748afe650c4d142aa5a vendor/intx/hub-api | sawyer | 2026-11-03 | 90aa5cddef828d77b62adb63186dcf82b5465926cafea1ab6b64bca263031b57 vendor/intx/hub-common | sawyer | 2026-11-03 | 2fe74684593083f422023b2d6b337e414b5b319c6bfe02c7fa157130d7162e41 -vendor/intx/hub-sessions | sawyer | 2026-11-03 | 40f873fb8059a4e9488411f2eac4428b822692dc2d969dc14c09b3609ca1c79e +vendor/intx/hub-sessions | sawyer | 2026-11-03 | 80b0d49a6bce40cc22941e5f79b6e7d50308418661ea17a6e88e22244bdabf8f vendor/intx/inference | sawyer | 2026-11-03 | 81e1b31947cd8e6cac4e336d21d8a87ba063b638e0ac4ada1b0f375ec6861bf5 vendor/intx/mail-memory | sawyer | 2026-11-03 | e281498352adbc2face631744ce9a02cb838c60a6c318961022904f0fd05d192 vendor/intx/mailbox | sawyer | 2026-11-03 | 926bfef1b60a4ba7e600252e9ad6e37c17d387a200f79c2cbc0b3ceb612000ea vendor/intx/mime | sawyer | 2026-11-03 | ddf7e626172350c6a447890affc6c746b7938e04ee53af8f1d9c8cffd97d1954 -vendor/intx/types | sawyer | 2026-11-03 | 5d9738e8b9e6a9193a759272350edb60a1cff09c430763a77d265855117b82c2 +vendor/intx/types | sawyer | 2026-11-03 | f1c246c159318778082bb50de90477c46f54a38eef1f6d584ce9f16cd510c9da vendor/intx/workflow | sawyer | 2026-11-03 | c8f3a7a40d4ef429b58ba913c2faa27a5e7e98baf433dc57f864e12c907eb47a vendor/intx/workflow-deploy | sawyer | 2026-11-03 | 72a4f931ba5c404a091dc46a7ed60f5b0530a63535f7cacacfe31f95c397e473 vendor/intx/workflow-host | sawyer | 2026-11-03 | b9964010920cef377ba4be777c179d414c430c1709e665bbf8c5e4a413e249e9 diff --git a/templates/connectors.ts b/templates/connectors.ts index 15b95ac09..b9c5ed59b 100644 --- a/templates/connectors.ts +++ b/templates/connectors.ts @@ -35,6 +35,38 @@ import { GMAIL_SCOPE, GOOGLE_AUTHORIZE_URL, } from "@corbits/connections/gmail-connect"; + +// Decodes the ChatGPT account id out of a Codex id_token (a JWT payload +// claim), mirroring `@corbits/codex-provider`'s `accountIdFromIdToken` +// without pulling that package (and its oauth-core dependency graph) into +// this browser-shipped registry. Undefined on any malformed input — the +// account id is a label, never a gate. +function chatgptAccountIdFromIdToken(idToken: string): string | undefined { + const payload = idToken.split(".")[1]; + if (payload === undefined) return undefined; + let claims: Record; + try { + const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); + claims = JSON.parse( + atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")), + ) as Record; + } catch { + return undefined; + } + if (typeof claims.chatgpt_account_id === "string") { + return claims.chatgpt_account_id; + } + const auth: unknown = claims["https://api.openai.com/auth"]; + if ( + typeof auth === "object" && + auth !== null && + "chatgpt_account_id" in auth + ) { + const nested = auth.chatgpt_account_id; + if (typeof nested === "string") return nested; + } + return undefined; +} // simple-icons ships one brand per named export, tree-shaken by any bundler // that respects its `sideEffects: false` — importing only the brands this // registry actually has a listing for pulls in only those icons' data, not @@ -321,7 +353,7 @@ function inferenceProviderDescriptors(): Record { const response = await fetch(CODEX_TOKEN_URL, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(15_000), body: new URLSearchParams({ grant_type: "authorization_code", code, @@ -340,10 +372,15 @@ function inferenceProviderDescriptors(): Record { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; + id_token?: unknown; }; if (typeof tokens.access_token !== "string") { return { ok: false, message: "Codex returned no access token" }; } + const accountId = + typeof tokens.id_token === "string" + ? chatgptAccountIdFromIdToken(tokens.id_token) + : undefined; return { ok: true, apiKey: tokens.access_token, @@ -357,6 +394,7 @@ function inferenceProviderDescriptors(): Record { ).toISOString(), } : {}), + ...(accountId !== undefined ? { accountId } : {}), }; } catch (cause) { return { @@ -398,7 +436,7 @@ function inferenceProviderDescriptors(): Record { const response = await fetch(XAI_TOKEN_URL, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(15_000), body: new URLSearchParams({ grant_type: "authorization_code", code, diff --git a/vendor/intx/hub-agent/package.json b/vendor/intx/hub-agent/package.json index c4b05c1e2..786cf74ac 100644 --- a/vendor/intx/hub-agent/package.json +++ b/vendor/intx/hub-agent/package.json @@ -15,7 +15,8 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { "@intx/harness": "workspace:*", diff --git a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts index dc90ac9a0..afce82b0a 100644 --- a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts +++ b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts @@ -35,6 +35,7 @@ import { type CredentialsInboundRouter, type WorkflowRunPackApplier, type WorkflowProbeExecutor, + type OAuthLoginExecutor, type ReconnectScheduler, } from "./ws/hub-link"; @@ -169,6 +170,13 @@ export type SidecarOrchestratorConfig = { * placeholder so a probe is answered with an error rather than dropped. */ workflowProbeExecutor?: WorkflowProbeExecutor; + /** + * Optional loopback-login executor (CL-7508). Forwarded unchanged to + * `createHubLink`, where it answers every inbound `oauth.login.start`; + * omitted, the link answers those requests with an error rather than + * hanging the hub. + */ + oauthLoginExecutor?: OAuthLoginExecutor; /** * Returns the workflow-substrate deployment addresses this sidecar * currently hosts. Forwarded to the hub link, which announces them on @@ -230,6 +238,7 @@ export function createSidecarOrchestrator( credentialsInboundRouter, applyWorkflowRunPack, workflowProbeExecutor, + oauthLoginExecutor, getWorkflowAddresses, onWorkflowAddressesRoutable, onWorkflowAddressesUnroutable, @@ -323,6 +332,7 @@ export function createSidecarOrchestrator( ? { credentialsInboundRouter } : {}), ...(workflowProbeExecutor !== undefined ? { workflowProbeExecutor } : {}), + ...(oauthLoginExecutor !== undefined ? { oauthLoginExecutor } : {}), ...(getWorkflowAddresses !== undefined ? { getWorkflowAddresses } : {}), ...(onWorkflowAddressesRoutable !== undefined ? { onWorkflowAddressesRoutable } diff --git a/vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts b/vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts new file mode 100644 index 000000000..14211912e --- /dev/null +++ b/vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts @@ -0,0 +1,176 @@ +// CL-7508 review F2b: the hub link must deliver an inbound +// `oauth.login.cancel` frame to the executor's cancel handle, and a +// subsequent `oauth.login.start` must re-invoke the executor — the +// frame-to-cancel half of the abandoned-login fix. This package previously +// had no colocated tests; this one runs the real link against a local +// Bun WebSocket server so no test infrastructure outside the package is +// needed. +import { afterAll, describe, expect, test } from "bun:test"; +import type { HubTransport } from "@intx/mail-memory"; +import type { SessionManager } from "../session-manager"; +import type { AgentKeyStore } from "../agent-key-store"; +import { createHubLink, type OAuthLoginExecutor } from "./hub-link"; + +function stubTransport(): HubTransport { + return { + register() { + return undefined; + }, + unregister() { + return undefined; + }, + getTransportFor() { + throw new Error("not used in this test"); + }, + setRemoteSendHandler() { + return undefined; + }, + addMessageSentHandler() { + return undefined; + }, + deliver() { + return undefined; + }, + }; +} + +const stubKeyStore: AgentKeyStore = { + loadOrGenerateKey: async () => { + throw new Error("not used in this test"); + }, + recordHubKey() { + return undefined; + }, + verifyDeployCommit: async () => false, + forgetAgent() { + return undefined; + }, +}; + +type ServerSocket = { send: (data: string) => void }; + +describe("hub-link oauth.login.cancel", () => { + let server: ReturnType; + + afterAll(() => { + server?.stop(true); + }); + + test("an inbound cancel frame reaches the executor and a later start re-invokes it", async () => { + const cancelled: string[] = []; + const started: string[] = []; + // The login the driver is currently asking for; the executor reads it + // because the wire frame alone does not carry which login the test is + // staging beyond its requestId (it does — this mirrors it for asserts). + let currentRequestId = ""; + + let serverSocket: ServerSocket | undefined; + const sockets = (): ServerSocket[] => + serverSocket === undefined ? [] : [serverSocket]; + server = Bun.serve({ + port: 0, + fetch(request, upgradeServer) { + if (upgradeServer.upgrade(request, { data: undefined })) { + return undefined; + } + return new Response("upgrade required", { status: 426 }); + }, + websocket: { + open(ws) { + serverSocket = ws as unknown as ServerSocket; + }, + message(ws, data) { + const frame = JSON.parse(String(data)) as { + type?: string; + requestId?: string; + }; + if (frame.type !== "oauth.login.start") return; + const requestId = frame.requestId ?? ""; + ws.send( + JSON.stringify({ + type: "oauth.login.result", + requestId, + outcome: { + status: "started", + authorizeUrl: "https://auth.example/authorize", + }, + }), + ); + }, + }, + }); + + const executor: OAuthLoginExecutor = (connectorId) => { + expect(connectorId).toBe("codex"); + const requestId = currentRequestId; + started.push(requestId); + return Promise.resolve({ + authorizeUrl: `https://auth.example/${requestId}`, + completed: new Promise(() => undefined), + cancel: () => { + cancelled.push(requestId); + }, + }); + }; + + const link = createHubLink({ + hubURL: `ws://localhost:${String(server.port)}`, + sidecarId: "sc-test", + token: "token", + transport: stubTransport(), + sessions: { + initRepo: async () => undefined, + } as unknown as SessionManager, + keyStore: stubKeyStore, + deployRouter: { + deploy: async () => ({ publicKey: "a".repeat(64) }), + }, + oauthLoginExecutor: executor, + pingIntervalMs: 60_000, + reconnectDelayMs: 60_000, + }); + link.connect(); + + // Give the socket time to open and the register handshake to land. + await new Promise((resolve) => setTimeout(resolve, 50)); + + async function driveLogin(requestId: string): Promise { + currentRequestId = requestId; + for (const socket of sockets()) { + socket.send( + JSON.stringify({ + type: "oauth.login.start", + requestId, + connectorId: "codex", + }), + ); + } + for (let i = 0; i < 100 && !startedRequests().includes(requestId); i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + + function startedRequests(): string[] { + return [...started]; + } + + await driveLogin("req_1"); + expect(started).toEqual(["req_1"]); + expect(cancelled).toEqual([]); + + for (const socket of sockets()) { + socket.send( + JSON.stringify({ type: "oauth.login.cancel", requestId: "req_1" }), + ); + } + for (let i = 0; i < 100 && cancelled.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(cancelled).toEqual(["req_1"]); + + await driveLogin("req_2"); + expect(started).toEqual(["req_1", "req_2"]); + + link.close(); + }); +}); diff --git a/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index 562770d75..bd53dba91 100644 --- a/vendor/intx/hub-agent/src/ws/hub-link.ts +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -34,6 +34,8 @@ import { type SyncRequestFrame, type WorkflowProbeRequestFrame, type WorkflowProbeResultFrame, + type OAuthLoginResultFrame, + type OAuthLoginTokens, } from "@intx/types/sidecar"; import type { SignalKind } from "@intx/types"; import { createPackReceiver, createPackSender } from "@intx/pack-transport"; @@ -485,6 +487,33 @@ const defaultWorkflowProbeExecutor: WorkflowProbeExecutor = { }, }; +/** + * Executor for sidecar-hosted loopback OAuth logins (CL-7508). Runs the + * connector's PKCE login on this machine: binds the pinned loopback callback + * server, returns the authorize URL for the hub to ship to the web UI, and + * completes with the staged tokens once the user finishes consent. The PKCE + * verifier stays inside the executor — only the tokens cross the wire. A + * rejection (including a pinned port already in use) rides back as the + * error arm of the `oauth.login.result` reply. + */ +export type OAuthLoginExecutor = ( + connectorId: "codex" | "xai-oauth", +) => Promise<{ + authorizeUrl: string; + completed: Promise; + /** Closes the staged login's pinned-port callback listener. Called when + * the hub cancels the login (`oauth.login.cancel`) or the link + * disconnects, so an abandoned bind cannot wedge every retry of the + * connector until the sidecar restarts. */ + cancel?: () => void; +}>; + +/** Fail-closed placeholder: an `oauth.login.start` still gets an error + * reply instead of hanging the hub's request. */ +const defaultOAuthLoginExecutor: OAuthLoginExecutor = () => { + throw new Error("oauth login execution is not implemented on this sidecar"); +}; + export type HubLinkConfig = { hubURL: string; sidecarId: string; @@ -581,6 +610,16 @@ export type HubLinkConfig = { * request/response frame with no reply hangs the hub's probe. */ workflowProbeExecutor?: WorkflowProbeExecutor; + /** + * Optional loopback-login executor (CL-7508). When present, the link routes + * every inbound `oauth.login.start` frame through it and answers with + * `oauth.login.result` frames: a `started` arm as soon as the executor + * returns the authorize URL, then exactly one terminal arm (`completed` + * with the staged tokens, or `error`). Absent, the link wires a + * placeholder that always errors — required because a request frame with + * no reply hangs the hub's login request. + */ + oauthLoginExecutor?: OAuthLoginExecutor; /** * Returns the workflow-substrate deployment addresses this sidecar * currently hosts a live supervisor for. Called on every (re)connect to @@ -698,6 +737,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { credentialsInboundRouter, applyWorkflowRunPack, workflowProbeExecutor = defaultWorkflowProbeExecutor, + oauthLoginExecutor = defaultOAuthLoginExecutor, getWorkflowAddresses = () => [], onWorkflowAddressesRoutable, onWorkflowAddressesUnroutable, @@ -725,6 +765,11 @@ export function createHubLink(config: HubLinkConfig): HubLink { let handshakePending = true; const packReceiver = createPackReceiver(); + // Staged loopback logins by requestId (see handleOAuthLoginStart). + const activeOAuthLogins = new Map< + string, + Awaited> + >(); // One sender owns the agent-state push path (`handleSyncRequest`, // `handleAgentUndeploy`) and the workflow-run push path // (`pushWorkflowRunPack`). transferIds for the two flows live in @@ -1315,6 +1360,67 @@ export function createHubLink(config: HubLinkConfig): HubLink { } } + async function handleOAuthLoginStart( + frame: Extract, + ): Promise { + // `oauth.login.start` is request/response: every path answers + // `oauth.login.result` — `started` once the executor returns the + // authorize URL, then exactly one terminal arm — never a log-and-drop, + // or the hub's login request hangs. + let handle: Awaited>; + try { + handle = await oauthLoginExecutor(frame.connectorId); + } catch (err) { + send({ + type: "oauth.login.result", + requestId: frame.requestId, + outcome: { + status: "error", + message: err instanceof Error ? err.message : String(err), + }, + }); + return; + } + activeOAuthLogins.set(frame.requestId, handle); + send({ + type: "oauth.login.result", + requestId: frame.requestId, + outcome: { status: "started", authorizeUrl: handle.authorizeUrl }, + }); + try { + const tokens = await handle.completed; + send({ + type: "oauth.login.result", + requestId: frame.requestId, + outcome: { status: "completed", tokens }, + }); + } catch (err) { + send({ + type: "oauth.login.result", + requestId: frame.requestId, + outcome: { + status: "error", + message: err instanceof Error ? err.message : String(err), + }, + }); + } finally { + activeOAuthLogins.delete(frame.requestId); + } + } + + /** In-flight staged logins by requestId, so a hub `oauth.login.cancel` — + * or a link disconnect, where the hub will never see the terminal frame — + * closes the staged login's pinned-port listener. */ + function cancelOAuthLogin(requestId: string): void { + activeOAuthLogins.get(requestId)?.cancel?.(); + activeOAuthLogins.delete(requestId); + } + + function cancelAllOAuthLogins(): void { + for (const handle of activeOAuthLogins.values()) handle.cancel?.(); + activeOAuthLogins.clear(); + } + async function pushWorkflowRunPack(opts: { agentAddress: string; repoId: RepoId; @@ -1517,6 +1623,12 @@ export function createHubLink(config: HubLinkConfig): HubLink { case "workflow.probe.request": await handleWorkflowProbeRequest(frame); break; + case "oauth.login.start": + void handleOAuthLoginStart(frame).catch(() => undefined); + break; + case "oauth.login.cancel": + cancelOAuthLogin(frame.requestId); + break; case "repo.pack.ack": handlePackAck(frame); break; @@ -1640,6 +1752,10 @@ export function createHubLink(config: HubLinkConfig): HubLink { // belongs to the reconnect re-emit, and a lingering watchdog would only // fire onto a closed socket. registerAcker.cancelAll(); + // The hub will never see a terminal frame for logins staged on this + // dead connection, and the sidecar-side listener outlives it — close + // those listeners so a retry can rebind the pinned ports. + cancelAllOAuthLogins(); // The hub dropped every route this link held. Block workflow-run pushes // for the deployments it hosts until the authenticated reconnect // re-routes them, so the coalescing pusher does not re-ship onto the diff --git a/vendor/intx/hub-sessions/VENDORED-FROM b/vendor/intx/hub-sessions/VENDORED-FROM index ca1504372..57917ba1c 100644 --- a/vendor/intx/hub-sessions/VENDORED-FROM +++ b/vendor/intx/hub-sessions/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-sessions) Commit: 692c31068cbb636ebb19c6d179ec6fe126a2d4e6 (origin/main, 2026-09-03) License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. Upstream test files are not vendored (they import the unvendored @intx/inference-testing and @intx/test-harness test packages); only the tests covering a workbench delta are kept. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack gates the anchor lookup on the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address, no liveness requirement) instead of upstream's live-status filter, so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail arriving in its teardown window; upstream's isNotNull(definitionId) tightening is kept. CL-6361 widens the same lookup to peel a per-step pack source address back to its base run's anchor via anchorAddressForPackSource; CL-6379 classifies an accepted pack's newly-terminal runs through decideTerminalRunFlip so a section occurrence's repo-local child run (turn__) is skipped quietly. CL-6324: workflow-probe-gate.ts's PersistFrozenApprovalFn carries the inert projection and createDbFrozenApprovalWriter stamps workflow_definition_version.wire_projection in the same transaction as approved_wire_hash. CL-6478: sanitize-tool-name.ts + event-collector.ts's tool_call case persist only a tool-call name encodeToolName can re-invert (anything else collapses to MALFORMED_TOOL_NAME), so one bad name fails its turn instead of wedging the room; @intx/inference is a dependency for this. CL-6595: workflow-run-kind.ts's validatePush also reports a run sealed from birth (combined events.jsonl with no per-event blobs) as newly terminal, and the new readCommittedWorkflowRunTerminalStatus export backs a same-push markTerminal backfill in hub-session-lookups.ts; both classify through upstream's classifyTerminalEvent. CL-7190: registerSignalCorrelation (hub-session-lookups.ts) now throws for any signal kind other than "approval", failing loud instead of silently mis-persisting a future SignalKind it has no co-write for. CL-6396: sidecar-events.ts's agent.event map and sidecar-handler.ts's agent.event dispatch forward an optional childRunId from the sidecar frame so the hub can close the matching occurrence row. RETIRED AT THIS PIN: the CL-6324 session-service.ts deltas (deployAdoptedCodeSourcedWorkflow / deployAdoptedWorkflowFromSource / AdoptingWorkflowDeployer, the sourceRef threading, and CL-7191's UserMessageParams.correlationId) hung off upstream's deployWorkflowFromSource front, which this pin deletes in favour of the catalog-offering + provisioner-selection deploy path. session-service.ts is vendored pristine and carries no co-located test. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. Upstream test files are not vendored (they import the unvendored @intx/inference-testing and @intx/test-harness test packages); only the tests covering a workbench delta are kept. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack gates the anchor lookup on the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address, no liveness requirement) instead of upstream's live-status filter, so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail arriving in its teardown window; upstream's isNotNull(definitionId) tightening is kept. CL-6361 widens the same lookup to peel a per-step pack source address back to its base run's anchor via anchorAddressForPackSource; CL-6379 classifies an accepted pack's newly-terminal runs through decideTerminalRunFlip so a section occurrence's repo-local child run (turn__) is skipped quietly. CL-6324: workflow-probe-gate.ts's PersistFrozenApprovalFn carries the inert projection and createDbFrozenApprovalWriter stamps workflow_definition_version.wire_projection in the same transaction as approved_wire_hash. CL-6478: sanitize-tool-name.ts + event-collector.ts's tool_call case persist only a tool-call name encodeToolName can re-invert (anything else collapses to MALFORMED_TOOL_NAME), so one bad name fails its turn instead of wedging the room; @intx/inference is a dependency for this. CL-6595: workflow-run-kind.ts's validatePush also reports a run sealed from birth (combined events.jsonl with no per-event blobs) as newly terminal, and the new readCommittedWorkflowRunTerminalStatus export backs a same-push markTerminal backfill in hub-session-lookups.ts; both classify through upstream's classifyTerminalEvent. CL-7190: registerSignalCorrelation (hub-session-lookups.ts) now throws for any signal kind other than "approval", failing loud instead of silently mis-persisting a future SignalKind it has no co-write for. CL-6396: sidecar-events.ts's agent.event map and sidecar-handler.ts's agent.event dispatch forward an optional childRunId from the sidecar frame so the hub can close the matching occurrence row. CL-7508: sidecar-handler.ts's oauth.login result handling settles the caller's request promise as an error when a terminal outcome arrives before the started acknowledgement — previously a completed-before-started frame left the HTTP caller hanging until the whole-login timeout; the no-hang pin lives in sidecar-handler-oauth-login.test.ts (only tests covering a workbench delta are vendored). RETIRED AT THIS PIN: the CL-6324 session-service.ts deltas (deployAdoptedCodeSourcedWorkflow / deployAdoptedWorkflowFromSource / AdoptingWorkflowDeployer, the sourceRef threading, and CL-7191's UserMessageParams.correlationId) hung off upstream's deployWorkflowFromSource front, which this pin deletes in favour of the catalog-offering + provisioner-selection deploy path. session-service.ts is vendored pristine and carries no co-located test. diff --git a/vendor/intx/hub-sessions/src/index.ts b/vendor/intx/hub-sessions/src/index.ts index 8cfdd88d7..f1037242d 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -37,6 +37,9 @@ export { type SidecarRouterConfig, type SidecarAuthIdentity, type SidecarAuthenticator, + type OAuthLoginRequestOutcome, + type OAuthLoginFinalOutcome, + type OAuthLoginGateOutcome, type AllocatedSidecarTarget, type SidecarAllocationRouter, createSidecarCredentialResolver, diff --git a/vendor/intx/hub-sessions/src/ws/index.ts b/vendor/intx/hub-sessions/src/ws/index.ts index b8ce20a0a..30467c34a 100644 --- a/vendor/intx/hub-sessions/src/ws/index.ts +++ b/vendor/intx/hub-sessions/src/ws/index.ts @@ -5,6 +5,9 @@ export { type SidecarConnection, type SidecarAuthIdentity, type SidecarAuthenticator, + type OAuthLoginRequestOutcome, + type OAuthLoginFinalOutcome, + type OAuthLoginGateOutcome, type AllocatedSidecarTarget, type SidecarAllocationRouter, type SendPackOptions, diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts new file mode 100644 index 000000000..88d7e3759 --- /dev/null +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts @@ -0,0 +1,173 @@ +// CL-7508 review S1: a login the hub gives up on (whole-login timeout) must +// cancel the sidecar's staged listener — an abandoned pinned-port bind would +// make every retry of the connector fail to bind — and a retry must get a +// fresh, working start. +import { describe, expect, test } from "bun:test"; +import { + connectAllocated, + createAllocatedRouter, + parsedFrames, + tick, +} from "./sidecar-handler.test-helpers"; + +function sentOAuthFrames( + ws: { sent: string[] }, + type: "oauth.login.start" | "oauth.login.cancel", +): { requestId: string }[] { + return parsedFrames(ws).flatMap((frame) => { + if ( + typeof frame === "object" && + frame !== null && + "type" in frame && + frame.type === type && + "requestId" in frame && + typeof frame.requestId === "string" + ) { + return [{ requestId: frame.requestId }]; + } + return []; + }); +} + +describe("requestOAuthLogin cancellation", () => { + test("a timed-out login sends oauth.login.cancel and a retry starts fresh", async () => { + const router = createAllocatedRouter({ + oauthLogin: { isLocalSidecar: () => true, timeoutMs: 50 }, + }); + const ws = await connectAllocated(router); + + const firstPending = router.requestOAuthLogin({ connectorId: "codex" }); + await tick(); + const [firstStart] = sentOAuthFrames(ws, "oauth.login.start"); + const firstRequestId = firstStart?.requestId; + if (firstRequestId === undefined) throw new Error("no start frame sent"); + // Simulate the sidecar staging the login: the started arm resolves the + // request promise and hands back the terminal promise. + router.handleMessage( + ws, + JSON.stringify({ + type: "oauth.login.result", + requestId: firstStart?.requestId, + outcome: { status: "started", authorizeUrl: "https://auth.example/a" }, + }), + ); + const first = await firstPending; + if (first.status !== "started") throw new Error("expected a started login"); + + // The sidecar never sends a terminal frame, so the whole-login timeout + // fires: the request fails and the sidecar is told to tear its + // listener down. + const final = await first.completed; + if (final.status !== "error") throw new Error("expected a timeout error"); + expect(final.message).toContain("timed out"); + expect(sentOAuthFrames(ws, "oauth.login.cancel")).toEqual([ + { requestId: firstRequestId }, + ]); + + // The retry for the same connector gets a fresh start with a new + // requestId — no stale pending entry, no wedged gate. + const secondPending = router.requestOAuthLogin({ connectorId: "codex" }); + await tick(); + const [secondStart] = sentOAuthFrames(ws, "oauth.login.start").slice(-1); + expect(secondStart?.requestId).not.toBe(firstRequestId); + router.handleMessage( + ws, + JSON.stringify({ + type: "oauth.login.result", + requestId: secondStart?.requestId, + outcome: { status: "started", authorizeUrl: "https://auth.example/b" }, + }), + ); + const second = await secondPending; + if (second.status !== "started") + throw new Error("expected a started retry"); + expect(sentOAuthFrames(ws, "oauth.login.start")).toHaveLength(2); + }); + + test("a completed outcome resolves the pending login with its tokens", async () => { + const router = createAllocatedRouter({ + oauthLogin: { isLocalSidecar: () => true, timeoutMs: 5000 }, + }); + const ws = await connectAllocated(router); + + const pending = router.requestOAuthLogin({ connectorId: "codex" }); + await tick(); + const [start] = sentOAuthFrames(ws, "oauth.login.start"); + if (start === undefined) throw new Error("no start frame sent"); + router.handleMessage( + ws, + JSON.stringify({ + type: "oauth.login.result", + requestId: start.requestId, + outcome: { status: "started", authorizeUrl: "https://auth.example/a" }, + }), + ); + const started = await pending; + if (started.status !== "started") throw new Error("expected a started login"); + + const tokens = { access: "at_1", refresh: "rt_1", expiresAt: 1750000000000 }; + router.handleMessage( + ws, + JSON.stringify({ + type: "oauth.login.result", + requestId: start.requestId, + outcome: { status: "completed", tokens }, + }), + ); + const final = await started.completed; + expect(final).toEqual({ status: "completed", tokens }); + }); + + test("an error outcome before started settles the request as an error", async () => { + const router = createAllocatedRouter({ + oauthLogin: { isLocalSidecar: () => true, timeoutMs: 5000 }, + }); + const ws = await connectAllocated(router); + + const pending = router.requestOAuthLogin({ connectorId: "codex" }); + await tick(); + const [start] = sentOAuthFrames(ws, "oauth.login.start"); + if (start === undefined) throw new Error("no start frame sent"); + router.handleMessage( + ws, + JSON.stringify({ + type: "oauth.login.result", + requestId: start.requestId, + outcome: { status: "error", message: "port 1455 busy" }, + }), + ); + const outcome = await pending; + expect(outcome).toEqual({ status: "error", message: "port 1455 busy" }); + }); + + test("a completed outcome before started settles the request — no hang", async () => { + const router = createAllocatedRouter({ + oauthLogin: { isLocalSidecar: () => true, timeoutMs: 5000 }, + }); + const ws = await connectAllocated(router); + + const pending = router.requestOAuthLogin({ connectorId: "codex" }); + await tick(); + const [start] = sentOAuthFrames(ws, "oauth.login.start"); + if (start === undefined) throw new Error("no start frame sent"); + // A misbehaving peer skips the started acknowledgement and answers + // with the terminal arm directly: the request promise must settle + // (as an error — the caller never saw an authorize URL) instead of + // hanging until the whole-login timeout. + router.handleMessage( + ws, + JSON.stringify({ + type: "oauth.login.result", + requestId: start.requestId, + outcome: { + status: "completed", + tokens: { access: "at_1", refresh: "rt_1", expiresAt: 1750000000000 }, + }, + }), + ); + const outcome = await pending; + if (outcome.status !== "error") + throw new Error("expected a request-level error"); + expect(outcome.message).toContain("before it started"); + }); +}); diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 9ff02c791..5c5f42479 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -30,6 +30,8 @@ import { type CredentialDelivery, type WorkflowSourceAssetMount, type WorkflowProjectionDefinition, + type OAuthLoginTokens, + type OAuthLoginResultFrame, } from "@intx/types/sidecar"; import type { ConnectorThreadState, @@ -183,6 +185,29 @@ export type WorkflowProbeResult = { wireHash: string; }; +/** The terminal arms of a sidecar-hosted loopback login. */ +export type OAuthLoginFinalOutcome = + | { status: "completed"; tokens: OAuthLoginTokens } + | { status: "error"; message: string }; + +/** The typed gate outcome: no sidecar passed the locality gate. The caller + * must surface "connect requires a local sidecar" — the hub never hosts a + * loopback listener for these flows and never falls back. */ +export type OAuthLoginGateOutcome = { status: "gate"; message: string }; + +export type OAuthLoginRequestOutcome = + | OAuthLoginGateOutcome + | { status: "error"; message: string } + | { + status: "started"; + requestId: string; + authorizeUrl: string; + completed: Promise; + }; + +/** Default whole-login timeout: a human completes the consent page. */ +export const DEFAULT_OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000; + export type SidecarRouter = { handleOpen(ws: WsHandle): void; handleMessage(ws: WsHandle, data: string): void; @@ -281,6 +306,21 @@ export type SidecarRouter = { getConnectedSidecars(): string[]; getRoutableAddresses(): string[]; + /** + * Start a sidecar-hosted loopback OAuth login (CL-7508) on the first + * connected sidecar that passes the router's locality gate. Resolves the + * typed gate outcome when no connected sidecar is local — never a + * hub-hosted fallback. On a gated-through sidecar the returned promise + * resolves once the sidecar reports `started` (callback server bound, + * authorize URL ready for the web UI to navigate); `completed` settles + * with the terminal outcome (tokens or a message — including bind + * failures like a pinned port already in use, which arrive as the + * error arm) or with the whole-login timeout. + */ + requestOAuthLogin(args: { + connectorId: "codex" | "xai-oauth"; + }): Promise; + /** Typed event emitter for the receiver-dispatch surface. See * `sidecar-events.ts` for the event map and emission semantics. */ events: SidecarEventEmitter; @@ -406,6 +446,24 @@ export type SidecarAuthenticator = (claim: { export type SidecarRouterConfig = { requestTimeoutMs?: number; + /** + * Sidecar-hosted OAuth loopback login policy (CL-7508). The locality gate + * decides whether a connected sidecar may host a loopback login: a remote + * sidecar's `localhost:1455`/`127.0.0.1:1456` callback opens on the wrong + * machine, so the hub host supplies the predicate that separates a local + * sidecar (same host as the user's browser) from a provisioned remote one. + * Absent means no sidecar is ever local and every login request resolves + * the typed gate outcome — the hub never falls back to hosting the + * listener itself. + */ + oauthLogin?: { + isLocalSidecar: ( + identity: SidecarAuthIdentity, + ) => boolean | Promise; + /** Whole-login timeout (bind → authorize → callback → exchange). + * Defaults to 5 minutes — a human completes the consent page. */ + timeoutMs?: number; + }; /** Hex-encoded 32-byte Ed25519 public key for signing deploy commits. * Included in agent.deploy frames so sidecars can verify pack signatures. */ hubPublicKey?: string; @@ -603,6 +661,26 @@ export function createSidecarRouter( }; const pendingProbes = new Map(); + // Sidecar-hosted loopback logins (CL-7508). Keyed on requestId alone — the + // login is tenant-level, not address-scoped — so, like probes, the + // connection close sweep is the only disconnect cleanup. A login settles in + // two phases: the `started` arm resolves the request promise (the hub + // returns the authorize URL to the web UI), and exactly one terminal arm + // settles `completed`. An error arm that arrives before any `started` + // (e.g. the pinned callback port was already bound) resolves the request + // promise as an error and tears the entry down — the hub never waits on a + // login that failed to start. + type PendingOAuthLogin = { + ws: WsHandle; + conn: SidecarConnection; + requestSettled: boolean; + resolveRequest(outcome: OAuthLoginRequestOutcome): void; + resolveFinal(outcome: OAuthLoginFinalOutcome): void; + completed: Promise; + timer: ReturnType; + }; + const pendingOAuthLogins = new Map(); + // Receives agent-state packs pushed from sidecars. The wire frames // (`repo.pack.push` / `repo.pack.done`) are shared with the // workflow-run flow; dispatch on `repoId.kind` picks which receiver @@ -953,6 +1031,7 @@ export function createSidecarRouter( case "repo.pack.reject": case "workflow.probe.result": case "workflow.probe.error": + case "oauth.login.result": return true; case "register": case "reconnect": @@ -1127,6 +1206,9 @@ export function createSidecarRouter( case "workflow.probe.error": rejectProbe(ws, frame.requestId, frame.error); return; + case "oauth.login.result": + settleOAuthLogin(frame.requestId, frame.outcome); + return; default: return assertNever(frame); } @@ -1654,6 +1736,16 @@ export function createSidecarRouter( probe.reject(`Sidecar ${conn.sidecarId} disconnected`); } + // Fail any in-flight loopback logins on this sidecar the same way: the + // login dies with its callback server, so a disconnect is terminal even + // though the browser may still be sitting on the consent page. + for (const [requestId, login] of pendingOAuthLogins) { + if (login.ws !== ws) continue; + clearTimeout(login.timer); + pendingOAuthLogins.delete(requestId); + settleOAuthLoginError(requestId, login, "the sidecar disconnected"); + } + // Cancel any in-flight inbound pack transfers from this sidecar // across both receivers. The two receivers track their own in- // flight transferIds, so a pending workflow-run transfer for an @@ -2760,6 +2852,117 @@ export function createSidecarRouter( return sendProbeOnConnection(ws, conn, args); } + function settleOAuthLoginError( + requestId: string, + entry: PendingOAuthLogin, + message: string, + ): void { + entry.resolveFinal({ status: "error", message }); + if (!entry.requestSettled) { + entry.requestSettled = true; + entry.resolveRequest({ status: "error", message }); + } + } + + function settleOAuthLogin( + requestId: string, + outcome: OAuthLoginResultFrame["outcome"], + ): void { + const entry = pendingOAuthLogins.get(requestId); + if (entry === undefined) { + logger.debug`Received oauth.login.result for uncorrelated ${requestId}`; + return; + } + if (outcome.status === "started") { + if (entry.requestSettled) return; + entry.requestSettled = true; + entry.resolveRequest({ + status: "started", + requestId, + authorizeUrl: outcome.authorizeUrl, + completed: entry.completed, + }); + return; + } + clearTimeout(entry.timer); + pendingOAuthLogins.delete(requestId); + if (outcome.status === "completed") { + if (!entry.requestSettled) { + // A terminal frame arrived before the started acknowledgement — + // settle the request promise so the caller never hangs. This + // ordering drops the exchanged tokens: nothing consumes the + // completed promise without the started arm, so the person has + // to run the login again. + entry.requestSettled = true; + entry.resolveRequest({ + status: "error", + message: "the login completed before it started", + }); + } + entry.resolveFinal({ status: "completed", tokens: outcome.tokens }); + return; + } + settleOAuthLoginError(requestId, entry, outcome.message); + } + + async function requestOAuthLogin(args: { + connectorId: "codex" | "xai-oauth"; + }): Promise { + const gate = config.oauthLogin; + let target: { ws: WsHandle; conn: SidecarConnection } | undefined; + for (const [ws, conn] of connections) { + if (gate !== undefined && (await gate.isLocalSidecar(conn.identity))) { + target = { ws, conn }; + break; + } + } + if (target === undefined || gate === undefined) { + return { + status: "gate", + message: + "Connecting this provider requires a local sidecar — its login opens a pinned loopback port (codex: localhost:1455, xai-oauth: 127.0.0.1:1456) on the machine you are browsing from, and no connected sidecar is local to this hub.", + }; + } + const targetWs = target.ws; + const targetConn = target.conn; + + const timeoutMs = gate.timeoutMs ?? DEFAULT_OAUTH_LOGIN_TIMEOUT_MS; + const requestId = nextRequestId(); + return new Promise((resolveRequest) => { + let resolveCompleted!: (outcome: OAuthLoginFinalOutcome) => void; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + const entry: PendingOAuthLogin = { + ws: targetWs, + conn: targetConn, + requestSettled: false, + resolveRequest, + resolveFinal: resolveCompleted, + completed, + timer: setTimeout(() => { + pendingOAuthLogins.delete(requestId); + // Tell the sidecar to tear its callback listener down: the hub + // gave up, and an abandoned pinned-port bind would make every + // retry of this connector fail to bind until the stale login + // completes or the sidecar restarts. + targetConn.send({ type: "oauth.login.cancel", requestId }); + settleOAuthLoginError( + requestId, + entry, + `the login timed out after ${String(timeoutMs)}ms`, + ); + }, timeoutMs), + }; + pendingOAuthLogins.set(requestId, entry); + targetConn.send({ + type: "oauth.login.start", + requestId, + connectorId: args.connectorId, + }); + }); + } + function disconnectAllocation(target: AllocatedSidecarTarget): void { const current = allocatedConnections.get(target.allocationId); if ( @@ -2997,6 +3200,7 @@ export function createSidecarRouter( routeMail, sendRunGrants, sendProbeToAllocation, + requestOAuthLogin, disconnectAllocation, sendAgentUndeploy, sendSourcesUpdate, diff --git a/vendor/intx/types/src/sidecar-oauth-login.test.ts b/vendor/intx/types/src/sidecar-oauth-login.test.ts new file mode 100644 index 000000000..1027363fc --- /dev/null +++ b/vendor/intx/types/src/sidecar-oauth-login.test.ts @@ -0,0 +1,116 @@ +// CL-7508 local delta: parse/reject tests for the oauth.login.start / +// oauth.login.result frame pair the sidecar ws channel threads (see the +// `vendor/intx/types` ledger row). +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; +import { + HubFrame, + OAuthLoginCancelFrame, + OAuthLoginResultFrame, + OAuthLoginStartFrame, + SidecarFrame, +} from "./sidecar"; + +describe("oauth.login.start frame", () => { + test("parses a well-formed hub request", () => { + const frame = { + type: "oauth.login.start", + requestId: "req_1", + connectorId: "codex", + }; + expect(OAuthLoginStartFrame(frame)).not.toBeInstanceOf(type.errors); + expect(HubFrame(frame)).not.toBeInstanceOf(type.errors); + }); + + test("accepts every loopback connector id and nothing else", () => { + expect( + OAuthLoginStartFrame({ + type: "oauth.login.start", + requestId: "r", + connectorId: "xai-oauth", + }), + ).not.toBeInstanceOf(type.errors); + expect( + OAuthLoginStartFrame({ + type: "oauth.login.start", + requestId: "r", + connectorId: "github", + }), + ).toBeInstanceOf(type.errors); + }); + + test("rejects a missing requestId", () => { + expect( + OAuthLoginStartFrame({ type: "oauth.login.start", connectorId: "codex" }), + ).toBeInstanceOf(type.errors); + }); + + test("parses the cancel frame the hub sends on timeout", () => { + const frame = { type: "oauth.login.cancel", requestId: "req_1" }; + expect(OAuthLoginCancelFrame(frame)).not.toBeInstanceOf(type.errors); + expect(HubFrame(frame)).not.toBeInstanceOf(type.errors); + expect( + OAuthLoginCancelFrame({ type: "oauth.login.cancel" }), + ).toBeInstanceOf(type.errors); + }); +}); + +describe("oauth.login.result frame", () => { + test("parses the started arm with its authorize URL", () => { + const frame = { + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "started", authorizeUrl: "https://auth.example/authorize?x=1" }, + }; + expect(OAuthLoginResultFrame(frame)).not.toBeInstanceOf(type.errors); + expect(SidecarFrame(frame)).not.toBeInstanceOf(type.errors); + }); + + test("parses the completed arm with tokens", () => { + const frame = { + type: "oauth.login.result", + requestId: "req_1", + outcome: { + status: "completed", + tokens: { + access: "at", + refresh: "rt", + expiresAt: 123, + idToken: "idt", + accountId: "acc", + }, + }, + }; + expect(OAuthLoginResultFrame(frame)).not.toBeInstanceOf(type.errors); + }); + + test("parses the error arm", () => { + expect( + OAuthLoginResultFrame({ + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "error", message: "port in use" }, + }), + ).not.toBeInstanceOf(type.errors); + }); + + test("rejects a completed arm without tokens", () => { + expect( + OAuthLoginResultFrame({ + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "completed" }, + }), + ).toBeInstanceOf(type.errors); + }); + + test("rejects an unknown outcome status", () => { + expect( + OAuthLoginResultFrame({ + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "pending" }, + }), + ).toBeInstanceOf(type.errors); + }); +}); diff --git a/vendor/intx/types/src/sidecar.ts b/vendor/intx/types/src/sidecar.ts index 7a14e70b6..d961a5dae 100644 --- a/vendor/intx/types/src/sidecar.ts +++ b/vendor/intx/types/src/sidecar.ts @@ -941,6 +941,70 @@ export const WorkflowProbeErrorFrame = type({ }); export type WorkflowProbeErrorFrame = typeof WorkflowProbeErrorFrame.infer; +// --------------------------------------------------------------------------- +// Sidecar-hosted OAuth loopback login (CL-7508) +// --------------------------------------------------------------------------- + +/** Tokens a sidecar-hosted loopback login staged. The PKCE verifier never + * crosses the wire — it lives only in the sidecar's login service and dies + * with the callback server. */ +export const OAuthLoginTokens = type({ + access: "string", + "refresh?": "string", + /** Epoch ms the access token expires; absent when the issuer stated no + * lifetime (stored non-due, never a short artificial timer). */ + "expiresAt?": "number", + /** The issuer's id_token when it issues one (xai-oauth retains it). */ + "idToken?": "string", + /** id_token-derived account label (codex: `chatgpt_account_id`), the + * value `accountIdFromIdToken` decodes; threaded into credential + * metadata hub-side. */ + "accountId?": "string", +}); +export type OAuthLoginTokens = typeof OAuthLoginTokens.infer; + +/** Outcome arms a `oauth.login.result` frame may carry. A login sends + * `started` once its callback server is bound and the authorize URL is + * ready for the web UI to navigate; exactly one terminal arm (`completed` + * or `error`) follows. */ +export const OAuthLoginOutcome = type({ + status: "'started'", + authorizeUrl: "string", +}) + .or({ status: "'completed'", tokens: OAuthLoginTokens }) + .or({ status: "'error'", message: "string" }); +export type OAuthLoginOutcome = typeof OAuthLoginOutcome.infer; + +/** Hub → sidecar: run the named connector's loopback PKCE login on the + * machine this sidecar runs on. The connector must pin a fixed loopback + * redirect (`codex` → localhost:1455, `xai-oauth` → 127.0.0.1:1456); the + * hub never hosts a listener for these flows. */ +export const OAuthLoginStartFrame = type({ + type: "'oauth.login.start'", + requestId: "string", + connectorId: "'codex' | 'xai-oauth'", +}); +export type OAuthLoginStartFrame = typeof OAuthLoginStartFrame.infer; + +/** Sidecar → hub: the staged progress / terminal outcome of the login the + * hub requested with the same `requestId`. */ +export const OAuthLoginResultFrame = type({ + type: "'oauth.login.result'", + requestId: "string", + outcome: OAuthLoginOutcome, +}); +export type OAuthLoginResultFrame = typeof OAuthLoginResultFrame.infer; + +/** Hub → sidecar: tear the staged login with this `requestId` down and + * close its pinned-port callback listener. The hub sends it when it gives + * up on a login (whole-login timeout) so an abandoned bind does not wedge + * every retry of the connector until the sidecar restarts. */ +export const OAuthLoginCancelFrame = type({ + type: "'oauth.login.cancel'", + requestId: "string", +}); +export type OAuthLoginCancelFrame = typeof OAuthLoginCancelFrame.infer; + // --------------------------------------------------------------------------- // Discriminated frame unions // --------------------------------------------------------------------------- @@ -963,7 +1027,8 @@ export const SidecarFrame = RegisterFrame.or(ReconnectFrame) .or(PackRejectFrame) .or(MailInboundAckFrame) .or(WorkflowProbeResultFrame) - .or(WorkflowProbeErrorFrame); + .or(WorkflowProbeErrorFrame) + .or(OAuthLoginResultFrame); export type SidecarFrame = typeof SidecarFrame.infer; /** All frame types the hub sends to the sidecar. */ @@ -981,7 +1046,9 @@ export const HubFrame = MailInboundFrame.or(AgentDeployFrame) .or(RunGrantsFrame) .or(SignalCorrelationRegisterAckFrame) .or(DrainDeliverFrame) - .or(WorkflowProbeRequestFrame); + .or(WorkflowProbeRequestFrame) + .or(OAuthLoginStartFrame) + .or(OAuthLoginCancelFrame); export type HubFrame = typeof HubFrame.infer; /** Any frame on the wire, regardless of direction. */