Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions apps/server/src/binMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ export async function runCliCommand() {
// Exit 2 identifies document rejections; OS and process failures remain retryable.
process.exitCode = 2;
}
} else if (command === "__managed-chatgpt-token") {
const { runManagedChatGptTokenCli } = await import("./cli/managedChatGptToken.ts");
await runManagedChatGptTokenCli(process.argv.slice(3));
} else if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts");
await runAcpMcpCliFastPath(command, process.argv.slice(3));
Expand Down
48 changes: 0 additions & 48 deletions apps/server/src/cli/managedChatGptToken.ts

This file was deleted.

187 changes: 18 additions & 169 deletions apps/server/src/provider/CodexChatGptAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Local mock OAuth server validates the browser callback boundary.
import * as NodeNet from "node:net";
import * as Context from "effect/Context";
import * as Scope from "effect/Scope";
import * as ServerConfig from "../config.ts";
import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts";
import * as NodeHttp from "node:http";
import * as NodeCrypto from "node:crypto";
import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";
Expand All @@ -18,7 +13,7 @@ import * as TestClock from "effect/testing/TestClock";
import * as Fiber from "effect/Fiber";
import * as Deferred from "effect/Deferred";
import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts";
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
import { FetchHttpClient } from "effect/http";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
Expand Down Expand Up @@ -1769,171 +1764,25 @@ it.effect("a running session renews its bound profile but refuses a replacement
),
);

const makeRenewalBroker = Effect.gen(function* () {
const oauthEntered = yield* Deferred.make<void>();
const oauthRelease = yield* Deferred.make<void>();
let pauseOAuth = false;
const refreshTokens: string[] = [];
const credentials = {
clientId: "oaiapp_broker",
subject: "broker-user",
idToken: "broker-sign-in",
email: null,
accessToken: "expired-access",
refreshToken: "single-use-refresh",
expiresAt: 0,
earliestRefreshAt: null,
scopes: ["chatgpt.tokens.use.direct"],
};
const environmentId = EnvironmentId.make("00000000-0000-4000-8000-000000000001");
const http = HttpClient.make((request) =>
Effect.gen(function* () {
if (request.url.endsWith("openid-configuration")) {
return HttpClientResponse.fromWeb(
request,
Response.json({
issuer: "https://auth.openai.com",
authorization_endpoint: "https://auth.openai.com/authorize",
token_endpoint: "https://auth.openai.com/token",
jwks_uri: "https://auth.openai.com/jwks",
}),
);
}
assert.equal(request.url, "https://auth.openai.com/token");
assert.equal(request.body._tag, "Uint8Array");
if (request.body._tag !== "Uint8Array") return yield* Effect.die("Missing refresh body");
const body = new URLSearchParams(new TextDecoder().decode(request.body.body));
refreshTokens.push(body.get("refresh_token")!);
yield* Deferred.succeed(oauthEntered, undefined);
if (pauseOAuth) yield* Deferred.await(oauthRelease);
return HttpClientResponse.fromWeb(
request,
Response.json({
access_token: "renewed-access",
refresh_token: "rotated-refresh",
expires_in: 3600,
token_type: "Bearer",
scope: "chatgpt.tokens.use.direct",
}),
);
}),
);
return yield* Effect.gen(function* () {
const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
yield* store.set(new TextEncoder().encode(JSON.stringify(credentials)));
const auth = yield* makeCodexChatGptAuth({ instanceId });
const profileKey = yield* auth.profileKey(credentials);
const scope = yield* Scope.make();
yield* Effect.addFinalizer((exit) => Scope.close(scope, exit));
const context = yield* Layer.buildWithScope(CodexManagedTokenBroker.layer, scope);
const broker = Context.get(context, CodexManagedTokenBroker.CodexManagedTokenBroker);
const endpoint = yield* broker.endpoint;
yield* Effect.addFinalizer(() => Deferred.succeed(oauthRelease, undefined));
return {
auth,
store,
broker,
endpoint,
refreshTokens,
request: { version: 1, instanceId, environmentId, profileKey },
oauthEntered,
oauthRelease,
pause: () => {
pauseOAuth = true;
},
};
}).pipe(
Effect.provide(ServerSecretStore.layer),
Effect.provideService(HttpClient.HttpClient, http),
Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, {
getEnvironmentId: Effect.succeed(environmentId),
}),
);
});

const brokerClient = (endpoint: string, request: unknown) =>
Effect.acquireRelease(
Effect.callback<{ socket: NodeNet.Socket; response: Promise<string> }, Error>((resume) => {
const socket = NodeNet.createConnection(endpoint);
let text = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
text += chunk;
});
const response = new Promise<string>((resolve) => socket.once("close", () => resolve(text)));
socket.on("error", () => {});
socket.once("error", (error) => resume(Effect.fail(error)));
socket.once("connect", () => {
socket.write(JSON.stringify(request) + "\n");
resume(Effect.succeed({ socket, response }));
});
return Effect.sync(() => {
if (socket.connecting) socket.destroy();
});
}),
({ socket }) => Effect.sync(() => socket.destroy()),
);
const provideBroker = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.scoped,
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-renewal-test-" }).pipe(
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer)),
),
),
);

// regression: #16132 a renewal must outlive the Codex helper that asked for it, because the
// refresh token it spends is single-use.
it.effect("server-owned renewal persists rotated credentials after the helper disconnects", () =>
provideBroker(
Effect.gen(function* () {
const h = yield* makeRenewalBroker;
h.pause();
const client = yield* brokerClient(h.endpoint, h.request);
yield* Deferred.await(h.oauthEntered);
client.socket.destroy();
yield* Effect.promise(() => client.response);
assert.equal(Option.getOrThrow(yield* h.auth.read).refreshToken, "single-use-refresh");
yield* Deferred.succeed(h.oauthRelease, undefined);
yield* h.broker.awaitIdle;
const persisted = Option.getOrThrow(yield* h.auth.read);
assert.equal(persisted.refreshToken, "rotated-refresh");
assert.equal((yield* h.auth.access).accessToken, "renewed-access");
assert.deepEqual(h.refreshTokens, ["single-use-refresh"]);
}),
),
);

it.live("the private broker refuses another environment, instance, or sign-in profile", () =>
provideBroker(
// regression: #29927 the API can reject a token before its locally stated expiry.
it.effect("renews a locally unexpired rejected token once across bound sessions", () =>
provision(
Effect.gen(function* () {
const h = yield* makeRenewalBroker;
yield* Effect.forEach(
[
{
...h.request,
environmentId: EnvironmentId.make("00000000-0000-4000-8000-000000000002"),
},
{ ...h.request, instanceId: ProviderInstanceId.make("another-instance") },
{ ...h.request, profileKey: "0".repeat(64) },
],
(request) =>
Effect.scoped(
Effect.gen(function* () {
const client = yield* brokerClient(h.endpoint, request);
assert.equal(yield* Effect.promise(() => client.response), "");
}),
),
const h = yield* makeHarness;
yield* h.signIn;
yield* h.phase("succeeded");
const saved = Option.getOrThrow(yield* h.auth.read);
const profileKey = yield* h.auth.profileKey(saved);
const first = yield* h.bindProfile(profileKey);
const second = yield* h.bindProfile(profileKey);
const renewed = yield* Effect.all(
[first.renew(saved.accessToken), second.renew(saved.accessToken)],
{ concurrency: "unbounded" },
);
yield* h.broker.awaitIdle;
assert.deepEqual(h.refreshTokens, []);
const client = yield* brokerClient(h.endpoint, h.request);
assert.deepEqual(JSON.parse(yield* Effect.promise(() => client.response)), {
token: "renewed-access",
});
yield* h.broker.awaitIdle;
assert.deepEqual(h.refreshTokens, ["single-use-refresh"]);
assert.equal(h.refreshes(), 1);
assert.equal(renewed[0].accessToken, "access-1");
assert.deepEqual(renewed[0], renewed[1]);
assert.equal(yield* h.auth.profileKey(renewed[0]), profileKey);
}),
),
);
Loading
Loading