From 94974016cf7e4916084cd9ecfac36c4454078f47 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 00:35:13 -0700 Subject: [PATCH 01/14] Thread the oauth.login frame pair through the sidecar ws channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the OAuthLoginStartFrame (hub to sidecar) and OAuthLoginResultFrame (sidecar to hub) arktype frames with started/completed/error outcome arms to @intx/types, and wire them through both ends of the sidecar websocket channel. The hub-side router (hub-sessions) gains requestOAuthLogin: it picks the first connected sidecar that passes a new oauthLogin.isLocalSidecar locality gate, correlates the login by requestId with a whole-login timeout and a disconnect sweep, and resolves a typed gate outcome when no connected sidecar is local — the hub never hosts a loopback listener or falls back. The sidecar-side link (hub-agent) routes oauth.login.start to a new OAuthLoginExecutor and answers every path with oauth.login.result frames, fail-closed when the host wires no executor. Ledger rows updated in the same commit. --- VENDORED.md | 6 +- scripts/checks/kill-dates.txt | 6 +- vendor/intx/hub-agent/src/ws/hub-link.ts | 81 ++++++++ .../hub-sessions/src/ws/sidecar-handler.ts | 185 ++++++++++++++++++ .../types/src/sidecar-oauth-login.test.ts | 106 ++++++++++ vendor/intx/types/src/sidecar.ts | 60 +++++- 6 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 vendor/intx/types/src/sidecar-oauth-login.test.ts diff --git a/VENDORED.md b/VENDORED.md index f39919818..e0dc29c03 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; 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 and 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); 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` frame pair (`OAuthLoginStartFrame`/`OAuthLoginResultFrame` 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/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index fdfaa36a3..a3119f11c 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 | c9ed617b9b7260dd641d6928303bfcd5ef51ffe078834c8c4b2912fbeb73d4af 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 | e34705b6b15c70684b0c4e6bc2773fefaa6fb8874b04f8feaaee11f3f640c292 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 | 5e91efa7dd0d2271920d2acecda3a74ce0845a2ceee7ef16dda00aec98735c07 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/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index 562770d75..e24e74a0b 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,26 @@ 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") => { + authorizeUrl: string; + completed: Promise; +}; + +/** 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 +603,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 +730,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { credentialsInboundRouter, applyWorkflowRunPack, workflowProbeExecutor = defaultWorkflowProbeExecutor, + oauthLoginExecutor = defaultOAuthLoginExecutor, getWorkflowAddresses = () => [], onWorkflowAddressesRoutable, onWorkflowAddressesUnroutable, @@ -1315,6 +1348,51 @@ 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: ReturnType; + try { + handle = 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; + } + 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), + }, + }); + } + } + async function pushWorkflowRunPack(opts: { agentAddress: string; repoId: RepoId; @@ -1517,6 +1595,9 @@ 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 "repo.pack.ack": handlePackAck(frame); break; diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 9ff02c791..c9288ca14 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,22 @@ 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; + /** 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 +659,25 @@ 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; + 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 +1028,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 +1203,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 +1733,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 +2849,101 @@ 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); + settleOAuthLoginError( + requestId, + entry, + outcome.status === "error" + ? outcome.message + : "the login ended before it started", + ); + } + + 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 && 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, + requestSettled: false, + resolveRequest, + resolveFinal: resolveCompleted, + completed, + timer: setTimeout(() => { + pendingOAuthLogins.delete(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 +3181,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..580350415 --- /dev/null +++ b/vendor/intx/types/src/sidecar-oauth-login.test.ts @@ -0,0 +1,106 @@ +// 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, + 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); + }); +}); + +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..e22f5396e 100644 --- a/vendor/intx/types/src/sidecar.ts +++ b/vendor/intx/types/src/sidecar.ts @@ -941,6 +941,60 @@ 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; + // --------------------------------------------------------------------------- // Discriminated frame unions // --------------------------------------------------------------------------- @@ -963,7 +1017,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 +1036,8 @@ export const HubFrame = MailInboundFrame.or(AgentDeployFrame) .or(RunGrantsFrame) .or(SignalCorrelationRegisterAckFrame) .or(DrainDeliverFrame) - .or(WorkflowProbeRequestFrame); + .or(WorkflowProbeRequestFrame) + .or(OAuthLoginStartFrame); export type HubFrame = typeof HubFrame.infer; /** Any frame on the wire, regardless of direction. */ From 97b6f4ebcc2661840858444ab6f7ce1b2ca65fec Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 00:51:05 -0700 Subject: [PATCH 02/14] Host the pinned-port loopback login in the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/sidecar gains createOAuthLoopbackLoginService, which stages the PKCE login for codex (localhost:1455) and xai-oauth (127.0.0.1:1456) through @corbits/oauth-core's startOAuthLogin — pinned ports asserted up front from the providers' registered redirect URIs, a busy port surfacing as the typed OAuthCallbackPortInUseError, the browser never opened sidecar-side (the hub ships the authorize URL to the web UI), and the PKCE verifier never leaving the process. The service wires in as the hub link's OAuthLoginExecutor, so oauth.login.start frames run on this machine and only finished tokens cross the channel. --- VENDORED.md | 2 +- apps/sidecar/package.json | 1 + apps/sidecar/src/index.ts | 6 + apps/sidecar/src/oauth-login.test.ts | 78 +++++++++ apps/sidecar/src/oauth-login.ts | 152 ++++++++++++++++++ bun.lock | 13 +- scripts/checks/kill-dates.txt | 2 +- .../hub-agent/src/sidecar-orchestrator.ts | 10 ++ vendor/intx/hub-agent/src/ws/hub-link.ts | 10 +- 9 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 apps/sidecar/src/oauth-login.test.ts create mode 100644 apps/sidecar/src/oauth-login.ts diff --git a/VENDORED.md b/VENDORED.md index e0dc29c03..5869b21ba 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -28,7 +28,7 @@ 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`; 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; 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; 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); 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 and 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); 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` | 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..1bf76b0f6 --- /dev/null +++ b/apps/sidecar/src/oauth-login.test.ts @@ -0,0 +1,78 @@ +// 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); + }); +}); 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..faa7d76d5 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,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:*", @@ -3623,12 +3624,10 @@ "@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/connections/@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=="], "@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=="], - "@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=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3651,10 +3650,6 @@ "@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/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=="], - "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=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3709,8 +3704,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 +3750,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/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index a3119f11c..2c9360a94 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -17,7 +17,7 @@ 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 | c9ed617b9b7260dd641d6928303bfcd5ef51ffe078834c8c4b2912fbeb73d4af +vendor/intx/hub-agent | sawyer | 2026-11-03 | baacb5bfc3606a472c0d10f615c0b80aa2cfcbf57dc788aa4dc6f17e81eed6db 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 | e34705b6b15c70684b0c4e6bc2773fefaa6fb8874b04f8feaaee11f3f640c292 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.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index e24e74a0b..8dc8a2691 100644 --- a/vendor/intx/hub-agent/src/ws/hub-link.ts +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -496,10 +496,12 @@ const defaultWorkflowProbeExecutor: WorkflowProbeExecutor = { * 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") => { +export type OAuthLoginExecutor = ( + connectorId: "codex" | "xai-oauth", +) => Promise<{ authorizeUrl: string; completed: Promise; -}; +}>; /** Fail-closed placeholder: an `oauth.login.start` still gets an error * reply instead of hanging the hub's request. */ @@ -1355,9 +1357,9 @@ export function createHubLink(config: HubLinkConfig): HubLink { // `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: ReturnType; + let handle: Awaited>; try { - handle = oauthLoginExecutor(frame.connectorId); + handle = await oauthLoginExecutor(frame.connectorId); } catch (err) { send({ type: "oauth.login.result", From 5c42fa0c50a08117fead62973ab7a1fbeda6197a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 01:11:33 -0700 Subject: [PATCH 03/14] Receive sidecar loopback logins in the hub and persist the credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /connections/:connectorId/loopback under the tenant prefix (packages/connections' createOAuthLoopbackRoutes). It starts the login through the sidecar router's gated requestOAuthLogin — no local sidecar resolves the typed 409 gate outcome, never a hub-hosted listener — returns the staged authorize URL for the web UI, and persists the terminal tokens through the shared connect pipeline (secret, refreshSecret, expiresAt, and the id_token-derived accountId metadata) once the oauth.login.result frame lands, detached and reported on failure. The hub's locality gate admits only sidecars whose allocation was provisioned by the process backend: a docker/e2b sidecar's loopback opens inside the container or sandbox, never on the browsing machine. Serving-time refresh now dispatches codex/xai-oauth credentials to the provider packages' own refresh grants (carrying chatgpt-account-id forward), and the deferred connector-registry items land: the codex/xai token exchanges match the providers' 15s timeouts and the codex exchange retains the id_token's account id. --- apps/hub/package.json | 2 + apps/hub/src/credential-material-refresh.ts | 53 +++++ apps/hub/src/index.ts | 32 +++ bun.lock | 19 +- packages/connections/package.json | 1 + packages/connections/src/descriptor.ts | 4 + packages/connections/src/index.ts | 5 + .../src/oauth-loopback-routes.test.ts | 194 ++++++++++++++++++ .../connections/src/oauth-loopback-routes.ts | 187 +++++++++++++++++ packages/connections/src/oauth-routes.ts | 6 + scripts/checks/kill-dates.txt | 2 +- templates/connectors.ts | 42 +++- vendor/intx/hub-sessions/src/index.ts | 3 + vendor/intx/hub-sessions/src/ws/index.ts | 3 + .../hub-sessions/src/ws/sidecar-handler.ts | 6 +- 15 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 packages/connections/src/oauth-loopback-routes.test.ts create mode 100644 packages/connections/src/oauth-loopback-routes.ts 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/bun.lock b/bun.lock index faa7d76d5..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:*", @@ -615,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:", @@ -3624,10 +3627,18 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/connections/@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=="], + "@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/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=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3650,6 +3661,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + "@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/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=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], 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..dfec14f08 --- /dev/null +++ b/packages/connections/src/oauth-loopback-routes.test.ts @@ -0,0 +1,194 @@ +// 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; + 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, + 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"); + expect(persisted[0]?.metadata?.accountId).toBe("acc_42"); + expect(persisted[0]?.metadata?.expiresAt).toBe( + new Date(1750000000000).toISOString(), + ); + }); + + 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..c96a61e78 --- /dev/null +++ b/packages/connections/src/oauth-loopback-routes.ts @@ -0,0 +1,187 @@ +// 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. +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; + const credentialMetadata: Record = { + ...(tokens.expiresAt !== undefined + ? { expiresAt: new Date(tokens.expiresAt).toISOString() } + : {}), + ...(tokens.accountId !== undefined + ? { accountId: tokens.accountId } + : {}), + ...(tokens.idToken !== undefined ? { idToken: tokens.idToken } : {}), + }; + await persistConnectorCredential({ + api, + cookies, + tenantId: tenant.id, + descriptor, + secret: tokens.access, + credentialMetadata, + ...(tokens.refresh !== undefined + ? { refreshSecret: tokens.refresh } + : {}), + ...(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.ts b/packages/connections/src/oauth-routes.ts index c804a2113..e2d8872b1 100644 --- a/packages/connections/src/oauth-routes.ts +++ b/packages/connections/src/oauth-routes.ts @@ -594,6 +594,12 @@ export function createOAuthConnectRoutes( 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/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 2c9360a94..47b72ee9c 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -20,7 +20,7 @@ vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746 vendor/intx/hub-agent | sawyer | 2026-11-03 | baacb5bfc3606a472c0d10f615c0b80aa2cfcbf57dc788aa4dc6f17e81eed6db 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 | e34705b6b15c70684b0c4e6bc2773fefaa6fb8874b04f8feaaee11f3f640c292 +vendor/intx/hub-sessions | sawyer | 2026-11-03 | f5f2bd519b16aa6de92f7f6aea81c1e2037e75f71f45a94715ea16cf7bae954f 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 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-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.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index c9288ca14..a9b2997c8 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -457,7 +457,9 @@ export type SidecarRouterConfig = { * listener itself. */ oauthLogin?: { - isLocalSidecar: (identity: SidecarAuthIdentity) => boolean; + isLocalSidecar: ( + identity: SidecarAuthIdentity, + ) => boolean | Promise; /** Whole-login timeout (bind → authorize → callback → exchange). * Defaults to 5 minutes — a human completes the consent page. */ timeoutMs?: number; @@ -2898,7 +2900,7 @@ export function createSidecarRouter( const gate = config.oauthLogin; let target: { ws: WsHandle; conn: SidecarConnection } | undefined; for (const [ws, conn] of connections) { - if (gate !== undefined && gate.isLocalSidecar(conn.identity)) { + if (gate !== undefined && (await gate.isLocalSidecar(conn.identity))) { target = { ws, conn }; break; } From 98a95e90cd62394bcf36533c86503adfa56fd793 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 01:38:03 -0700 Subject: [PATCH 04/14] Persist loopback token expiry on the credential column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serving-time refresh keys on the credential row's expiresAt column, but the loopback receive path filed the expiry only inside metadata — so after the access token lapsed, codex/xai-oauth credentials never refreshed and never got marked re-auth-required. The route now threads the terminal tokens' expiry through persistConnectorCredential -> ensureCredential as a first-class field so the column is written. The raw id_token also stays out of the (unencrypted) credential metadata; only the derived accountId label is kept. --- .../connections/src/oauth-loopback-routes.test.ts | 11 +++++++---- packages/connections/src/oauth-loopback-routes.ts | 12 ++++++++---- packages/connections/src/persist-credential.ts | 9 ++++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/connections/src/oauth-loopback-routes.test.ts b/packages/connections/src/oauth-loopback-routes.test.ts index dfec14f08..198b3f1fe 100644 --- a/packages/connections/src/oauth-loopback-routes.test.ts +++ b/packages/connections/src/oauth-loopback-routes.test.ts @@ -37,6 +37,7 @@ function makeDeps(overrides?: { tenantId: string; connectorId: string; secret: string; + expiresAt: string | undefined; metadata: Record | undefined; refreshSecret: string | undefined; }[] = []; @@ -57,6 +58,7 @@ function makeDeps(overrides?: { tenantId: args.tenantId, connectorId: args.name, secret: args.secret, + expiresAt: args.expiresAt, metadata: args.metadata, refreshSecret: args.refreshSecret, }); @@ -178,10 +180,11 @@ describe("createOAuthLoopbackRoutes", () => { expect(persisted).toHaveLength(1); expect(persisted[0]?.secret).toBe("at"); expect(persisted[0]?.refreshSecret).toBe("rt"); - expect(persisted[0]?.metadata?.accountId).toBe("acc_42"); - expect(persisted[0]?.metadata?.expiresAt).toBe( - new Date(1750000000000).toISOString(), - ); + // 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 () => { diff --git a/packages/connections/src/oauth-loopback-routes.ts b/packages/connections/src/oauth-loopback-routes.ts index c96a61e78..d1b500517 100644 --- a/packages/connections/src/oauth-loopback-routes.ts +++ b/packages/connections/src/oauth-loopback-routes.ts @@ -126,14 +126,15 @@ export function createOAuthLoopbackRoutes( 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.expiresAt !== undefined - ? { expiresAt: new Date(tokens.expiresAt).toISOString() } - : {}), ...(tokens.accountId !== undefined ? { accountId: tokens.accountId } : {}), - ...(tokens.idToken !== undefined ? { idToken: tokens.idToken } : {}), }; await persistConnectorCredential({ api, @@ -145,6 +146,9 @@ export function createOAuthLoopbackRoutes( ...(tokens.refresh !== undefined ? { refreshSecret: tokens.refresh } : {}), + ...(tokens.expiresAt !== undefined + ? { expiresAt: new Date(tokens.expiresAt).toISOString() } + : {}), ...(deps.ensureProviderFn !== undefined ? { ensureProviderFn: deps.ensureProviderFn } : {}), diff --git a/packages/connections/src/persist-credential.ts b/packages/connections/src/persist-credential.ts index 6eca122dc..8e37a11d3 100644 --- a/packages/connections/src/persist-credential.ts +++ b/packages/connections/src/persist-credential.ts @@ -73,8 +73,12 @@ export type PersistConnectorCredentialArgs = PersistConnectorCredentialFns & { 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 +143,9 @@ export async function persistConnectorCredential( ...(args.refreshSecret !== undefined ? { refreshSecret: args.refreshSecret } : {}), + ...(args.expiresAt !== undefined + ? { expiresAt: args.expiresAt } + : {}), } : { tenantId: args.tenantId, From abca698c6837014074553363b08ea12f34d964e7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 01:50:40 -0700 Subject: [PATCH 05/14] Cancel abandoned sidecar logins so retries can rebind the ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A login the hub gives up on left the sidecar's pinned-port callback listener bound, so every retry of that connector failed to bind until the stale tab completed or the sidecar restarted. The hub now sends a new oauth.login.cancel frame when the whole-login timeout fires, and the sidecar link honors it — and also cancels all staged logins when the ws connection drops, since the hub will never see a terminal frame for those — via the executor's cancel handle, which closes the staged login's callback server. A post-timeout retry gets a fresh start with a new requestId (regression-tested). Vendored ledger rows and hashes updated in the same commit. --- VENDORED.md | 6 +- apps/sidecar/src/oauth-login.test.ts | 11 +++ scripts/checks/kill-dates.txt | 6 +- vendor/intx/hub-agent/src/ws/hub-link.ts | 33 +++++++ .../ws/sidecar-handler-oauth-login.test.ts | 86 +++++++++++++++++++ .../hub-sessions/src/ws/sidecar-handler.ts | 7 ++ .../types/src/sidecar-oauth-login.test.ts | 10 +++ vendor/intx/types/src/sidecar.ts | 13 ++- 8 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts diff --git a/VENDORED.md b/VENDORED.md index 5869b21ba..08e688196 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`; 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; 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; 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); 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 and 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); 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); 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; another (CL-7508): the `oauth.login.start` / `oauth.login.result` frame pair (`OAuthLoginStartFrame`/`OAuthLoginResultFrame` 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/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/sidecar/src/oauth-login.test.ts b/apps/sidecar/src/oauth-login.test.ts index 1bf76b0f6..63f282c46 100644 --- a/apps/sidecar/src/oauth-login.test.ts +++ b/apps/sidecar/src/oauth-login.test.ts @@ -75,4 +75,15 @@ describe("oauth loopback login service", () => { 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/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 47b72ee9c..271e80365 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 | baacb5bfc3606a472c0d10f615c0b80aa2cfcbf57dc788aa4dc6f17e81eed6db +vendor/intx/hub-agent | sawyer | 2026-11-03 | 0d90c7b5db834a1161782502c43699245f04e6367932df4a9f8e77182839a9ff 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 | f5f2bd519b16aa6de92f7f6aea81c1e2037e75f71f45a94715ea16cf7bae954f +vendor/intx/hub-sessions | sawyer | 2026-11-03 | 73bcdaa2e3ae8ab040d7ce17f64c6174286b6737f31fbd7f1bb9ae368142e60b 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 | 5e91efa7dd0d2271920d2acecda3a74ce0845a2ceee7ef16dda00aec98735c07 +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/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index 8dc8a2691..bd53dba91 100644 --- a/vendor/intx/hub-agent/src/ws/hub-link.ts +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -501,6 +501,11 @@ export type OAuthLoginExecutor = ( ) => 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 @@ -760,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 @@ -1371,6 +1381,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { }); return; } + activeOAuthLogins.set(frame.requestId, handle); send({ type: "oauth.login.result", requestId: frame.requestId, @@ -1392,9 +1403,24 @@ export function createHubLink(config: HubLinkConfig): HubLink { 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; @@ -1600,6 +1626,9 @@ export function createHubLink(config: HubLinkConfig): HubLink { 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; @@ -1723,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/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..d0900c78e --- /dev/null +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts @@ -0,0 +1,86 @@ +// 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); + }); +}); diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index a9b2997c8..9f166b266 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -672,6 +672,7 @@ export function createSidecarRouter( // login that failed to start. type PendingOAuthLogin = { ws: WsHandle; + conn: SidecarConnection; requestSettled: boolean; resolveRequest(outcome: OAuthLoginRequestOutcome): void; resolveFinal(outcome: OAuthLoginFinalOutcome): void; @@ -2924,12 +2925,18 @@ export function createSidecarRouter( }); 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, diff --git a/vendor/intx/types/src/sidecar-oauth-login.test.ts b/vendor/intx/types/src/sidecar-oauth-login.test.ts index 580350415..1027363fc 100644 --- a/vendor/intx/types/src/sidecar-oauth-login.test.ts +++ b/vendor/intx/types/src/sidecar-oauth-login.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "bun:test"; import { type } from "arktype"; import { HubFrame, + OAuthLoginCancelFrame, OAuthLoginResultFrame, OAuthLoginStartFrame, SidecarFrame, @@ -43,6 +44,15 @@ describe("oauth.login.start frame", () => { 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", () => { diff --git a/vendor/intx/types/src/sidecar.ts b/vendor/intx/types/src/sidecar.ts index e22f5396e..d961a5dae 100644 --- a/vendor/intx/types/src/sidecar.ts +++ b/vendor/intx/types/src/sidecar.ts @@ -995,6 +995,16 @@ export const OAuthLoginResultFrame = type({ }); 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 // --------------------------------------------------------------------------- @@ -1037,7 +1047,8 @@ export const HubFrame = MailInboundFrame.or(AgentDeployFrame) .or(SignalCorrelationRegisterAckFrame) .or(DrainDeliverFrame) .or(WorkflowProbeRequestFrame) - .or(OAuthLoginStartFrame); + .or(OAuthLoginStartFrame) + .or(OAuthLoginCancelFrame); export type HubFrame = typeof HubFrame.infer; /** Any frame on the wire, regardless of direction. */ From 3f3bb911f19051ec4b3a3c74eb92771c7b822598 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 02:21:52 -0700 Subject: [PATCH 06/14] Pin the hub-agent oauth.login cancel dispatch with a colocated test Exercise hub-link's login tracking, cancel frames, and cancel-on-disconnect over a real local ws server, and give the vendored package its first bun test script. Ledger row and tree hash updated to record the delta. --- VENDORED.md | 2 +- scripts/checks/kill-dates.txt | 2 +- vendor/intx/hub-agent/package.json | 3 +- .../src/ws/hub-link-oauth-login.test.ts | 176 ++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts diff --git a/VENDORED.md b/VENDORED.md index 08e688196..775928e55 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -28,7 +28,7 @@ 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`; 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; 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); 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); 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` | diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 271e80365..0f6810b28 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -17,7 +17,7 @@ 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 | 0d90c7b5db834a1161782502c43699245f04e6367932df4a9f8e77182839a9ff +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 | 73bcdaa2e3ae8ab040d7ce17f64c6174286b6737f31fbd7f1bb9ae368142e60b 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/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(); + }); +}); From e54863f9cfa27dec06d28a10a779a79116a164cf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 02:21:56 -0700 Subject: [PATCH 07/14] Fix the credentialMetadata doc comment on expiry --- VENDORED.md | 2 +- packages/connections/src/persist-credential.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index 775928e55..906d525f1 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -28,7 +28,7 @@ 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`; 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-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); 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); 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` | diff --git a/packages/connections/src/persist-credential.ts b/packages/connections/src/persist-credential.ts index 8e37a11d3..455669cb7 100644 --- a/packages/connections/src/persist-credential.ts +++ b/packages/connections/src/persist-credential.ts @@ -66,10 +66,11 @@ 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 From 84858422c47d6a404fe19118e0d1f66ebd83db31 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 08:30:13 -0700 Subject: [PATCH 08/14] Resolve a completed sidecar login instead of discarding its tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settleOAuthLogin only branched on the started arm, so both terminal arms funneled into settleOAuthLoginError and a successful oauth.login.result resolved as an error — the tokens were dropped. Branch on the outcome arm: completed resolves the pending login with its tokens, error settles as an error. Pinned with colocated router tests for the completed arm and for an error before started. --- VENDORED.md | 2 +- scripts/checks/kill-dates.txt | 2 +- .../ws/sidecar-handler-oauth-login.test.ts | 56 +++++++++++++++++++ .../hub-sessions/src/ws/sidecar-handler.ts | 12 ++-- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index 906d525f1..df80da24f 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -30,7 +30,7 @@ never a convenience. | `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`; 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); 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); 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` | diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 0f6810b28..8ab7a75c0 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -20,7 +20,7 @@ vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746 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 | 73bcdaa2e3ae8ab040d7ce17f64c6174286b6737f31fbd7f1bb9ae368142e60b +vendor/intx/hub-sessions | sawyer | 2026-11-03 | 40c3a2153796f64a6592813d87506b37650cd675500e73cecbf0db1cbc87a06b 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 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 index d0900c78e..bb918087e 100644 --- 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 @@ -83,4 +83,60 @@ describe("requestOAuthLogin cancellation", () => { 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" }); + }); }); diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 9f166b266..cb56a50b1 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -2886,13 +2886,11 @@ export function createSidecarRouter( } clearTimeout(entry.timer); pendingOAuthLogins.delete(requestId); - settleOAuthLoginError( - requestId, - entry, - outcome.status === "error" - ? outcome.message - : "the login ended before it started", - ); + if (outcome.status === "completed") { + entry.resolveFinal({ status: "completed", tokens: outcome.tokens }); + return; + } + settleOAuthLoginError(requestId, entry, outcome.message); } async function requestOAuthLogin(args: { From 95e8841bef6f4248ce9d993e1c7e640389b9afca Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 08:30:21 -0700 Subject: [PATCH 09/14] Persist redirect-flow OAuth expiry on the credential column The legacy web-redirect path only folded the exchange's expiry into credentialMetadata, so OpenRouter/Hugging Face oauth_token rows kept a NULL expiresAt column and serving-time refresh skipped them. Lift the expiry onto the first-class expiresAt arg as well, keeping the metadata fold since its presence is what types the row oauth_token. Forwarded through the tenant connect wiring to persistConnectorCredential. --- packages/connections/src/oauth-routes.test.ts | 48 +++++++++++++++++++ packages/connections/src/oauth-routes.ts | 10 +++- .../connections/src/oauth-tenant-connect.ts | 1 + 3 files changed, 58 insertions(+), 1 deletion(-) 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 e2d8872b1..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,15 @@ 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, 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 } : {}), From 76c17dea9fbe2aee71c60b21d2ac2f405b56dccc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 08:30:30 -0700 Subject: [PATCH 10/14] Note the loopback login route awaits its S5 onboarding caller --- packages/connections/src/oauth-loopback-routes.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/connections/src/oauth-loopback-routes.ts b/packages/connections/src/oauth-loopback-routes.ts index d1b500517..539d1fe2b 100644 --- a/packages/connections/src/oauth-loopback-routes.ts +++ b/packages/connections/src/oauth-loopback-routes.ts @@ -15,6 +15,9 @@ // 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"; From f92bd2df59d367d2ccf72a0c7f3d3409a0a66664 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 09:02:34 -0700 Subject: [PATCH 11/14] onboarding: persist an OAuth exchange's expiresAt on the first-login connect path The shared oauth-routes callback sets the first-class expiresAt field alongside the metadata fold, but onboarding's own connectCredential wrapper dropped it, so a first-login Hugging Face connect stored a row whose expires_at column stayed NULL and serving-time refresh never saw an expiry. Thread expiresAt from the wrapper through testAndPersistCredential into persistConnectorCredential, which already writes the column on create and rotate. A pasted key still omits the key entirely. --- .../onboarding/src/complete-credential.ts | 16 ++++- packages/onboarding/src/routes.ts | 16 ++--- .../test/complete-credential.test.ts | 72 +++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 5c562d78f..9aafaa3d0 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -168,12 +168,18 @@ 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 is NOT metadata: it is the + * first-class `expiresAt` field below, which writes the credential + * row's own column that serving-time refresh keys on. 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. Never folded into `credentialMetadata`. */ + expiresAt?: string; /** The configurable-base-URL seam `ollama` uses (see `modelSourceFor`); * ignored for every other provider. */ baseURLOverride?: string; @@ -194,6 +200,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 +470,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 = () => ({ From 859f5b45fd2bbb0e65cf0a55a1e00b5b0138bdd4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 09:02:42 -0700 Subject: [PATCH 12/14] hub-sessions: settle the oauth login request promise when a terminal outcome precedes started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sidecar that answered oauth.login.result with a completed outcome without ever sending started left the caller's request promise unsettled — the HTTP caller hung until the whole-login timeout even though the login had already finished. Settle the request as an error in that ordering (the caller never saw an authorize URL) while the tokens still reach the final consumer; pinned by a no-hang test. Recorded as the CL-7508 delta in VENDORED-FROM with the tree hash updated. --- scripts/checks/kill-dates.txt | 2 +- vendor/intx/hub-sessions/VENDORED-FROM | 2 +- .../ws/sidecar-handler-oauth-login.test.ts | 31 +++++++++++++++++++ .../hub-sessions/src/ws/sidecar-handler.ts | 10 ++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 8ab7a75c0..3d3ab3585 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -20,7 +20,7 @@ vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746 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 | 40c3a2153796f64a6592813d87506b37650cd675500e73cecbf0db1cbc87a06b +vendor/intx/hub-sessions | sawyer | 2026-11-03 | a5d9414e881f44a4e8c1a79a6a60f4eb1fdb88160ce68e896c671b4bbd7bfc90 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 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/ws/sidecar-handler-oauth-login.test.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts index bb918087e..88d7e3759 100644 --- 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 @@ -139,4 +139,35 @@ describe("requestOAuthLogin cancellation", () => { 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 cb56a50b1..8a7f8a86e 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -2887,6 +2887,16 @@ export function createSidecarRouter( 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. The + // tokens are still delivered to the final consumer. + entry.requestSettled = true; + entry.resolveRequest({ + status: "error", + message: "the login completed before it started", + }); + } entry.resolveFinal({ status: "completed", tokens: outcome.tokens }); return; } From 884ac75c13cdb8e2895d77d9b4580d09f4e95602 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 09:18:32 -0700 Subject: [PATCH 13/14] onboarding: pin the wrapper's expiresAt hop and correct its doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the happy-path callback test now records and asserts the first-class expiresAt field through onboarding's connectCredential wrapper — the exact hop that dropped the exchange's expiry — and the expiresAt docs no longer claim the metadata fold never carries it (it does, to type the row oauth_token); the field is the ride that lands the column, not the fold's rival. --- packages/onboarding/src/complete-credential.ts | 12 ++++++++---- .../test/huggingface-connect-routes.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 9aafaa3d0..4f07944cd 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -170,15 +170,19 @@ export type TestAndPersistCredentialArgs = CommonArgs & /** * 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 below, which writes the credential - * row's own column that serving-time refresh keys on. Absent for a + * 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. Never folded into `credentialMetadata`. */ + * 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. */ 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" }, }, ]); From ab7f8961b2078afe54777cfa036ec4a793f0d014 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 09:18:39 -0700 Subject: [PATCH 14/14] hub-sessions: correct the settle-path comment about dropped tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: a completed-before-started outcome resolves a completed promise nothing consumes (the loopback route only receives it through the started arm), so the exchanged tokens are dropped and the login must run again — the comment claimed delivery; the tree hash is updated to match. --- scripts/checks/kill-dates.txt | 2 +- vendor/intx/hub-sessions/src/ws/sidecar-handler.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 3d3ab3585..558537e3e 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -20,7 +20,7 @@ vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746 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 | a5d9414e881f44a4e8c1a79a6a60f4eb1fdb88160ce68e896c671b4bbd7bfc90 +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 diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 8a7f8a86e..5c5f42479 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -2889,8 +2889,10 @@ export function createSidecarRouter( if (outcome.status === "completed") { if (!entry.requestSettled) { // A terminal frame arrived before the started acknowledgement — - // settle the request promise so the caller never hangs. The - // tokens are still delivered to the final consumer. + // 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",