Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ if (
})
) {
const command = process.argv[2];
if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
if (command === "__managed-chatgpt-token") {
const { runCodexManagedTokenCommand } = await import("./provider/codexManagedTokenCommand.ts");
await runCodexManagedTokenCommand(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));
} else {
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/CodexChatGptAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,25 @@ it.effect("primary completes OAuth and destination imports and owns the refresh
),
);

it.effect("profile import stops credential users before taking the auth access lock", () =>
provision(
Effect.gen(function* () {
const h = yield* makeHarness;
yield* h.signIn;
yield* h.phase("succeeded");
const profile = yield* h.auth.exportProfile;
const destination = yield* h.destination;
yield* destination.auth.controller.importProfile!(profile, Effect.void);

const imported = yield* destination.auth.controller.importProfile!(
profile,
destination.auth.access.pipe(Effect.asVoid),
);
assert.strictEqual(imported.phase, "succeeded");
}),
),
);

it.effect("transferred profiles reject mismatched identities without overwriting credentials", () =>
provision(
Effect.gen(function* () {
Expand Down
174 changes: 103 additions & 71 deletions apps/server/src/provider/CodexChatGptAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ const Record = Schema.Struct({
issuer: Schema.optionalKey(Schema.String),
});
export type CodexChatGptCredentials = typeof Record.Type;

export const codexChatGptProfileKey = (record: CodexChatGptCredentials) =>
NodeCrypto.createHash("sha256")
.update(
JSON.stringify([
record.clientId,
record.subject,
record.idToken ?? null,
record.issuer ?? null,
]),
)
.digest("hex");

const Sessions = Schema.Struct({
activeClientId: Schema.NullOr(Schema.String),
sessions: Schema.Array(Record),
Expand Down Expand Up @@ -713,73 +726,86 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
),
);
});
const access = lock.withPermit(
withSessionLock(
Effect.uninterruptible(
Effect.gen(function* () {
const saved = yield* read;
if (Option.isNone(saved))
return yield* failure("access", "Sign in with ChatGPT to use managed Codex.");
let record = saved.value;
if (!record.scopes.includes(REQUIRED_SCOPE))
return yield* failure(
"sharing",
"Token sharing is disabled. Sign in with ChatGPT and enable token sharing.",
);
const now = yield* Clock.currentTimeMillis;
if (record.expiresAt - now > 60_000) return record;
if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) {
if (record.expiresAt > now) return record;
return yield* failure(
"refresh",
"ChatGPT cannot renew this connection yet. Try again shortly.",
);
}
if (!record.refreshToken) {
yield* remove;
return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again.");
}
const tokens = yield* exchange(
new URLSearchParams({
grant_type: "refresh_token",
client_id: record.clientId,
refresh_token: record.refreshToken,
resource,
}),
).pipe(
Effect.interruptible,
Effect.timeout("20 seconds"),
Effect.mapError((error) =>
error._tag === "ProviderSetupError" && error.operation === "client"
? error
: failure(
"refresh",
"Could not renew the ChatGPT connection. Retry, or sign in again.",
),
),
);
if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") {
return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again.");
}
record = {
...record,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000,
earliestRefreshAt: earliest(tokens.earliest_refresh_at),
scopes: tokens.scope.split(/\s+/).filter(Boolean),
};
yield* save(record);
if (!record.scopes.includes(REQUIRED_SCOPE))
return yield* failure(
"sharing",
"ChatGPT token sharing is no longer enabled. Sign in again.",
const accessForProfile = (expectedProfileKey?: string) =>
lock.withPermit(
withSessionLock(
Effect.uninterruptible(
Effect.gen(function* () {
const saved = yield* read;
if (Option.isNone(saved))
return yield* failure("access", "Sign in with ChatGPT to use managed Codex.");
let record = saved.value;
if (
expectedProfileKey !== undefined &&
codexChatGptProfileKey(record) !== expectedProfileKey
)
return yield* failure(
"access",
"The ChatGPT connection changed. Start a new session.",
);
if (!record.scopes.includes(REQUIRED_SCOPE))
return yield* failure(
"sharing",
"Token sharing is disabled. Sign in with ChatGPT and enable token sharing.",
);
const now = yield* Clock.currentTimeMillis;
if (record.expiresAt - now > 60_000) return record;
if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) {
if (record.expiresAt > now) return record;
return yield* failure(
"refresh",
"ChatGPT cannot renew this connection yet. Try again shortly.",
);
}
if (!record.refreshToken) {
yield* remove;
return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again.");
}
const tokens = yield* exchange(
new URLSearchParams({
grant_type: "refresh_token",
client_id: record.clientId,
refresh_token: record.refreshToken,
resource,
}),
).pipe(
Effect.interruptible,
Effect.timeout("20 seconds"),
Effect.mapError((error) =>
error._tag === "ProviderSetupError" && error.operation === "client"
? error
: failure(
"refresh",
"Could not renew the ChatGPT connection. Retry, or sign in again.",
),
),
);
return record;
}),
if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") {
return yield* failure(
"refresh",
"ChatGPT returned an invalid renewal. Sign in again.",
);
}
record = {
...record,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000,
earliestRefreshAt: earliest(tokens.earliest_refresh_at),
scopes: tokens.scope.split(/\s+/).filter(Boolean),
};
yield* save(record);
if (!record.scopes.includes(REQUIRED_SCOPE))
return yield* failure(
"sharing",
"ChatGPT token sharing is no longer enabled. Sign in again.",
);
return record;
}),
),
),
),
);
);
const access = accessForProfile();
const logout = Effect.gen(function* () {
const credentials = Option.getOrUndefined(yield* read);
let confirmed = !credentials?.refreshToken;
Expand Down Expand Up @@ -964,12 +990,17 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
yield* saveRegistration(registration);
yield* save(profile.credentials, true);
});
return lock.withPermit(
track(
"transfer",
{ flow: "primary_handoff" },
validate.pipe(
Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
// Stop session scopes before taking the auth/session locks used by access.
// A closing managed Codex scope can be draining an admitted token refresh.
return track(
Comment thread
nkcbuilds marked this conversation as resolved.
"transfer",
{ flow: "primary_handoff" },
validate.pipe(
Effect.andThen(
controller.adoptCredentials!(
lock.withPermit(withSessionLock(saveImported)),
stopSessions,
),
),
),
);
Expand All @@ -978,6 +1009,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
controller: { ...controller, reconnectProfile, importProfile },
read,
access,
accessForProfile,
exportProfile,
revoke: lock.withPermit(withSessionLock(remove)),
};
Expand Down
Loading
Loading