Skip to content
6 changes: 5 additions & 1 deletion apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* terminal-fallback agents run `t3 acp-mcp-call` per tool call, so their
* startup sits on first-message latency. Both dispatch here before the full
* CLI module graph (seconds of evaluation) loads; everything else defers to
* the real CLI in ./binCli.ts.
* the real CLI in ./binCli.ts. Managed Codex re-runs `t3 codex-managed-token`
* on a short interval to renew its bearer, so it dispatches here as well.
*/
import { isEntrypoint } from "./entrypoint.ts";

Expand All @@ -20,6 +21,9 @@ if (
if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts");
await runAcpMcpCliFastPath(command, process.argv.slice(3));
} else if (command === "codex-managed-token") {
const { runCodexManagedTokenCommand } = await import("./provider/codexManagedTokenCommand.ts");
await runCodexManagedTokenCommand();
} else {
const { runCli } = await import("./binCli.ts");
runCli();
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as CliError from "effect/cli/CliError";
import * as NetService from "@t3tools/shared/Net";
import packageJson from "../package.json" with { type: "json" };
import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
import { codexManagedTokenCommand } from "./cli/codexManagedToken.ts";
import { authCommand } from "./cli/auth.ts";
import { appCommand } from "./cli/app.ts";
import { browserCommand } from "./cli/browser.ts";
Expand Down Expand Up @@ -67,6 +68,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
),
acpMcpBridgeCommand,
acpMcpCallCommand,
codexManagedTokenCommand,
startCommand,
serveCommand,
appCommand,
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/cli/codexManagedToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Effect from "effect/Effect";
import { Command } from "effect/cli";

import {
codexManagedTokenCommandName,
runCodexManagedTokenCommand,
} from "../provider/codexManagedTokenCommand.ts";

/**
* `t3 codex-managed-token` — internal credential command managed Codex runs to
* renew its bearer from the provider's loopback bridge.
*
* Real invocations dispatch through the bin.ts fast path before the CLI graph
* loads; this definition keeps the command wired for anything that drives the
* full CLI programmatically.
*/
export const codexManagedTokenCommand = Command.make(codexManagedTokenCommandName).pipe(
Command.withDescription("Print the current managed Codex credential for Codex."),
Command.unlisted,
Command.withHandler(() => Effect.promise(() => runCodexManagedTokenCommand())),
);
89 changes: 89 additions & 0 deletions apps/server/src/provider/CodexChatGptAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as Fiber from "effect/Fiber";
import * as Deferred from "effect/Deferred";
import * as ScopedCache from "effect/ScopedCache";
import * as TestClock from "effect/testing/TestClock";
import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts";
import { FetchHttpClient } from "effect/http";
Expand All @@ -20,6 +21,7 @@ import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
import { makeCodexManagedTokenSource } from "./CodexManagedTokenSource.ts";

const assertSameCallback = (actual: string | null, expected: string | null) => {
const left = new URL(actual!);
Expand Down Expand Up @@ -221,10 +223,16 @@ const makeHarnessFor = Effect.fnUntraced(function* (
}),
flush: Effect.void,
});
const tokenSources = yield* ScopedCache.make({
capacity: 1,
lookup: (clientId: string): ReturnType<typeof makeCodexManagedTokenSource> =>
makeCodexManagedTokenSource(instanceId, clientId, auth.access),
});
const auth = yield* makeCodexChatGptAuth({
instanceId,
discoveryUrl: `${origin}/discovery`,
resource: `${origin}/v1`,
onCredentialsReplaced: ScopedCache.invalidateAll(tokenSources),
}).pipe(
Effect.provideService(AnalyticsService.AnalyticsService, analytics),
Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
Expand Down Expand Up @@ -278,6 +286,10 @@ const makeHarnessFor = Effect.fnUntraced(function* (
});
return {
auth,
tokenSource: Effect.gen(function* () {
const credentials = Option.getOrThrow(yield* auth.read);
return yield* ScopedCache.get(tokenSources, credentials.clientId);
}),
analyticsEvents,
secrets,
environmentId,
Expand Down Expand Up @@ -386,6 +398,16 @@ const makeHarnessFor = Effect.fnUntraced(function* (
};
});
const makeHarness = makeHarnessFor(instanceId);
const readManagedToken = (source: { url: string; secret: string }, clientId: string) =>
Effect.promise(async () => {
const response = await fetch(source.url, {
headers: {
authorization: `Bearer ${source.secret}`,
"x-t3-codex-account": clientId,
},
});
return { status: response.status, token: await response.text() };
});
const provision = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.scoped,
Expand Down Expand Up @@ -1359,6 +1381,68 @@ it.effect(
),
);

it.effect.each(["cancel", "expired code", "wrong subject", "invalid nonce"])(
"preserves the live credential bridge after reconnect fails: %s",
(failure) =>
provision(
Effect.gen(function* () {
const h = yield* makeHarness;
yield* h.signIn;
yield* h.phase("succeeded");
const before = Option.getOrThrow(yield* h.auth.read);
const source = yield* h.tokenSource;
assert.deepEqual(yield* readManagedToken(source, before.clientId), {
status: 200,
token: before.accessToken,
});
if (failure === "cancel") {
const { waiting } = yield* h.startRemote();
yield* h.auth.controller.cancel("owner", waiting.flowId!);
yield* h.phase("cancelled");
} else {
if (failure === "expired code") h.setCodeError("invalid_grant");
if (failure === "wrong subject") h.setIdentity("another-user", "another@example.test");
if (failure === "invalid nonce") h.setInvalidNonce();
yield* h.signIn;
yield* h.phase("failed");
}
assert.deepEqual(Option.getOrThrow(yield* h.auth.read), before);
assert.deepEqual(yield* readManagedToken(source, before.clientId), {
status: 200,
token: before.accessToken,
});
}),
),
);

it.effect(
"successful same-account reconnect retires the old bridge and issues a fresh capability",
() =>
provision(
Effect.gen(function* () {
const h = yield* makeHarness;
yield* h.signIn;
yield* h.phase("succeeded");
const source = yield* h.tokenSource;
const { waiting, callbackUrl } = yield* h.startRemote();
// Token reads wait for the auth lock while sign-in is pending, but the
// listener itself must remain available until credentials are saved.
const pending = yield* Effect.promise(() => fetch(source.url));
assert.strictEqual(pending.status, 401);
yield* h.auth.controller.complete("owner", { flowId: waiting.flowId!, callbackUrl });
yield* h.phase("succeeded");
const old = yield* Effect.tryPromise(() => fetch(source.url)).pipe(Effect.result);
assert.strictEqual(old._tag, "Failure");
const replacement = yield* h.tokenSource;
assert.notStrictEqual(replacement.secret, source.secret);
assert.deepEqual(yield* readManagedToken(replacement, "oaiapp_test"), {
status: 200,
token: Option.getOrThrow(yield* h.auth.read).accessToken,
});
}),
),
);

it.effect("declined sharing on an older profile does not replace the active account", () =>
provision(
Effect.gen(function* () {
Expand All @@ -1370,6 +1454,7 @@ it.effect("declined sharing on an older profile does not replace the active acco
yield* h.changeAccount;
yield* h.phase("succeeded");
const before = h.storedRecords();
const source = yield* h.tokenSource;
h.setIdentity("user-test", "hidden@example.test");
h.setCallbackClientId("oaiapp_test");
h.declineSharing();
Expand All @@ -1382,6 +1467,10 @@ it.effect("declined sharing on an older profile does not replace the active acco
Option.getOrThrow(yield* h.auth.read),
before.find((record) => record.clientId === "oaiapp_other_account"),
);
assert.deepEqual(yield* readManagedToken(source, "oaiapp_other_account"), {
status: 200,
token: Option.getOrThrow(yield* h.auth.read).accessToken,
});
}),
),
);
Expand Down
16 changes: 14 additions & 2 deletions apps/server/src/provider/CodexChatGptAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
readonly defaultReturnUrl?: string;
readonly reconnectProfile?: ChatGptReconnectProfile | null;
readonly telemetryFlow?: "direct" | "primary_handoff";
/** Retire issued runtime credentials on sign-in, logout or credential replacement, not renewal. */
readonly onCredentialsReplaced?: Effect.Effect<void>;
}) {
const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService);
const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService);
Expand Down Expand Up @@ -664,6 +666,12 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
const scopes = tokens.scope.split(/\s+/).filter(Boolean);
yield* withSessionLock(
Effect.gen(function* () {
const active = yield* read;
const activate = scopes.includes(REQUIRED_SCOPE) || Option.isNone(active);
// Failed sign-in leaves the running connection intact. A declined
// grant for another profile also leaves the active credentials alone.
if (activate || (Option.isSome(active) && active.value.clientId === clientId))
yield* options.onCredentialsReplaced ?? Effect.void;
yield* saveRegistration({
clientId,
redirectUri,
Expand All @@ -682,7 +690,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
scopes,
...identity,
},
scopes.includes(REQUIRED_SCOPE) || Option.isNone(yield* read),
activate,
);
}),
);
Expand Down Expand Up @@ -786,6 +794,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
),
);
const logout = Effect.gen(function* () {
yield* options.onCredentialsReplaced ?? Effect.void;
const credentials = Option.getOrUndefined(yield* read);
let confirmed = !credentials?.refreshToken;
if (credentials?.refreshToken) {
Expand Down Expand Up @@ -967,6 +976,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
"import",
"The transferred identity conflicts with the saved ChatGPT connection.",
);
yield* options.onCredentialsReplaced ?? Effect.void;
yield* saveRegistration(registration);
yield* save(profile.credentials, true);
});
Expand All @@ -985,6 +995,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
read,
access,
exportProfile,
revoke: lock.withPermit(withSessionLock(remove)),
revoke: lock.withPermit(
withSessionLock((options.onCredentialsReplaced ?? Effect.void).pipe(Effect.andThen(remove))),
),
};
});
Loading
Loading