Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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
25 changes: 24 additions & 1 deletion 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 All @@ -1770,11 +1789,15 @@ it.effect("transferred profiles reject mismatched identities without overwriting
{ ...profile.credentials, scopes: ["openid"] },
{ ...profile.credentials, expiresAt: 0 },
]) {
let stopped = false;
const result = yield* destination.auth.controller.importProfile!(
{ ...profile, credentials },
Effect.void,
Effect.sync(() => {
stopped = true;
}),
).pipe(Effect.result);
assert.strictEqual(result._tag, "Failure");
assert.isFalse(stopped);
assert.deepEqual(Option.getOrThrow(yield* destination.auth.read), original);
}
}),
Expand Down
173 changes: 101 additions & 72 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,20 +990,23 @@ 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" },
controller.adoptCredentials!(
lock.withPermit(withSessionLock(saveImported)),
stopSessions,
lock.withPermit(validate),
),
);
};
return {
controller: { ...controller, reconnectProfile, importProfile },
read,
access,
accessForProfile,
exportProfile,
revoke: lock.withPermit(withSessionLock(remove)),
};
Expand Down
Loading
Loading