fix(codex): add API key paste auth (#85533)

* fix codex api key auth paste

* changelog for codex api key auth

* support piped codex api key auth

* fix codex auth prompt validator type

* normalize pasted codex auth secrets

* honor codex auth profile type at runtime
This commit is contained in:
Josh Avant
2026-05-22 17:53:05 -07:00
committed by GitHub
parent 743caedb05
commit f2365053d3
8 changed files with 424 additions and 2 deletions

View File

@@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: convert pasted `data:image/...;base64,...` clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.
- Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.
- OpenAI Codex: preserve image input support for sparse `openai-codex/gpt-5.5` catalog rows. (#85095) Thanks @sercada.
- CLI/models: add a piped or pasted API-key path for OpenAI Codex auth and warn when API keys are pasted into token-mode auth. (#85533) Thanks @joshavant.
- Plugins/discovery: strip `-plugin` package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.
- Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.
- Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives `language-*` code classes. (#85209) Thanks @leno23.

View File

@@ -169,6 +169,7 @@ openclaw models fallbacks list
openclaw models auth add
openclaw models auth list [--provider <id>] [--json]
openclaw models auth login --provider <id>
openclaw models auth paste-api-key --provider <id>
openclaw models auth setup-token --provider <id>
openclaw models auth paste-token
```
@@ -185,7 +186,7 @@ filter to one provider, such as `openai-codex`, and `--json` for scripting.
`openclaw plugins list` to see which providers are installed.
Use `openclaw models auth --agent <id> <subcommand>` to write auth results to a
specific configured agent store. The parent `--agent` flag is honored by
`add`, `list`, `login`, `setup-token`, `paste-token`, and
`add`, `list`, `login`, `paste-api-key`, `setup-token`, `paste-token`, and
`login-github-copilot`.
For OpenAI models, `--provider openai` defaults to ChatGPT/Codex account login.
@@ -198,11 +199,16 @@ Examples:
```bash
openclaw models auth login --provider openai --set-default
openclaw models auth login --provider openai --method api-key
openclaw models auth paste-api-key --provider openai-codex
openclaw models auth list --provider openai
```
Notes:
- `paste-api-key` accepts API keys generated elsewhere, prompts for the key
value, and writes it to the default profile id `<provider>:manual` unless you
pass `--profile-id`. In automation, pipe the key on stdin, for example
`printf "%s\n" "$OPENAI_API_KEY" | openclaw models auth paste-api-key --provider openai-codex`.
- `setup-token` and `paste-token` remain generic token commands for providers
that expose token auth methods.
- `setup-token` requires an interactive TTY and runs the provider's token-auth
@@ -214,6 +220,9 @@ Notes:
`--profile-id`.
- `paste-token --expires-in <duration>` stores an absolute token expiry from a
relative duration such as `365d` or `12h`.
- For `openai-codex`, OpenAI API keys and ChatGPT/OAuth token material are
different auth shapes. Use `paste-api-key` for `sk-...` OpenAI API keys and
`paste-token` only for token auth material.
- Anthropic note: Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so OpenClaw treats Claude CLI reuse and `claude -p` usage as sanctioned for this integration unless Anthropic publishes a new policy.
- Anthropic `setup-token` / `paste-token` remain available as a supported OpenClaw token path, but OpenClaw now prefers Claude CLI reuse and `claude -p` when available.

View File

@@ -1123,6 +1123,71 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("passes OpenAI Codex token profiles through to app-server token login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "token",
provider: "openai-codex",
token: "sk-openai-codex-api-key-value",
},
});
await expect(
applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toBeUndefined();
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "sk-openai-codex-api-key-value",
chatgptAccountId: "openai-codex:work",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("passes OpenAI Codex API-key profiles through to app-server API-key login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "apiKey" }));
const tokenLikeKey = "eyJhbGciOiJub25l.eyJzdWIiOiJjb2RleCJ9.signature123456";
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "api_key",
provider: "openai-codex",
key: tokenLikeKey,
},
});
await expect(
applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toBeUndefined();
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "apiKey",
apiKey: tokenLikeKey,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("accepts a legacy Codex auth-provider alias for app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

View File

@@ -414,6 +414,9 @@ async function resolveLoginParamsForCredential(
credential: AuthProfileCredential,
params: { agentDir: string; forceOAuthRefresh: boolean; config?: AuthProfileOrderConfig },
): Promise<CodexLoginAccountParams | undefined> {
// Runtime honors the persisted auth profile type. Shape-based remediation
// belongs at credential entry time so request handling does not preemptively
// reject opaque provider credentials.
if (credential.type === "api_key") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),

View File

@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
modelsAuthAddCommand: vi.fn().mockResolvedValue(undefined),
modelsAuthListCommand: vi.fn().mockResolvedValue(undefined),
modelsAuthLoginCommand: vi.fn().mockResolvedValue(undefined),
modelsAuthPasteApiKeyCommand: vi.fn().mockResolvedValue(undefined),
modelsAuthPasteTokenCommand: vi.fn().mockResolvedValue(undefined),
modelsAuthSetupTokenCommand: vi.fn().mockResolvedValue(undefined),
}));
@@ -19,6 +20,7 @@ const {
modelsAuthAddCommand,
modelsAuthListCommand,
modelsAuthLoginCommand,
modelsAuthPasteApiKeyCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
modelsSetCommand,
@@ -35,6 +37,7 @@ vi.mock("../commands/models/list.status-command.js", () => ({
vi.mock("../commands/models/auth.js", () => ({
modelsAuthAddCommand: mocks.modelsAuthAddCommand,
modelsAuthLoginCommand: mocks.modelsAuthLoginCommand,
modelsAuthPasteApiKeyCommand: mocks.modelsAuthPasteApiKeyCommand,
modelsAuthPasteTokenCommand: mocks.modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand: mocks.modelsAuthSetupTokenCommand,
}));
@@ -78,6 +81,7 @@ describe("models cli", () => {
modelsAuthAddCommand.mockClear();
modelsAuthListCommand.mockClear();
modelsAuthLoginCommand.mockClear();
modelsAuthPasteApiKeyCommand.mockClear();
modelsAuthPasteTokenCommand.mockClear();
modelsAuthSetupTokenCommand.mockClear();
modelsSetCommand.mockClear();
@@ -180,6 +184,12 @@ describe("models cli", () => {
command: modelsAuthPasteTokenCommand,
expected: { agent: "poe", provider: "anthropic" },
},
{
label: "paste-api-key",
args: ["models", "auth", "--agent", "poe", "paste-api-key", "--provider", "openai-codex"],
command: modelsAuthPasteApiKeyCommand,
expected: { agent: "poe", provider: "openai-codex" },
},
{
label: "login-github-copilot",
args: ["models", "auth", "--agent", "poe", "login-github-copilot", "--yes"],

View File

@@ -400,6 +400,26 @@ export function registerModelsCli(program: Command) {
});
});
auth
.command("paste-api-key")
.description("Paste an API key into auth-profiles.json and update config")
.requiredOption("--provider <name>", "Provider id (e.g. openai-codex)")
.option("--profile-id <id>", "Auth profile id (default: <provider>:manual)")
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command);
const { modelsAuthPasteApiKeyCommand } = await import("../commands/models/auth.js");
await modelsAuthPasteApiKeyCommand(
{
provider: opts.provider as string | undefined,
profileId: opts.profileId as string | undefined,
agent,
},
defaultRuntime,
);
});
});
auth
.command("login-github-copilot")
.description("Login to GitHub Copilot via GitHub device flow (TTY required)")

View File

@@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({
clackCancel: vi.fn(),
clackConfirm: vi.fn(),
clackIsCancel: vi.fn((value: unknown) => value === Symbol.for("clack:cancel")),
clackPassword: vi.fn(),
clackSelect: vi.fn(),
clackText: vi.fn(),
resolveDefaultAgentId: vi.fn(),
@@ -108,6 +109,7 @@ vi.mock("@clack/prompts", () => ({
cancel: mocks.clackCancel,
confirm: mocks.clackConfirm,
isCancel: mocks.clackIsCancel,
password: mocks.clackPassword,
select: mocks.clackSelect,
text: mocks.clackText,
}));
@@ -256,6 +258,7 @@ vi.mock("../../plugins/provider-auth-choice-helpers.js", async (importOriginal)
const {
modelsAuthAddCommand,
modelsAuthLoginCommand,
modelsAuthPasteApiKeyCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
} = await import("./auth.js");
@@ -286,6 +289,34 @@ function withInteractiveStdin() {
};
}
function withPipedStdin(input: string) {
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const restoreInteractive = withInteractiveStdin();
const previousAsyncIteratorDescriptor = Object.getOwnPropertyDescriptor(
stdin,
Symbol.asyncIterator,
);
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => false,
});
Object.defineProperty(stdin, Symbol.asyncIterator, {
configurable: true,
value: async function* () {
yield input;
},
});
return () => {
if (previousAsyncIteratorDescriptor) {
Object.defineProperty(stdin, Symbol.asyncIterator, previousAsyncIteratorDescriptor);
} else {
Reflect.deleteProperty(stdin, Symbol.asyncIterator);
}
restoreInteractive();
};
}
function createProvider(params: {
id: string;
label?: string;
@@ -322,6 +353,7 @@ describe("modelsAuthLoginCommand", () => {
mocks.clackIsCancel.mockImplementation(
(value: unknown) => value === Symbol.for("clack:cancel"),
);
mocks.clackPassword.mockReset();
mocks.clackSelect.mockReset();
mocks.clackText.mockReset();
mocks.upsertAuthProfileWithLock.mockReset();
@@ -1181,6 +1213,157 @@ describe("modelsAuthLoginCommand", () => {
});
});
it("rejects OpenAI API keys pasted as OpenAI Codex token material", async () => {
const runtime = createRuntime();
const validateMessages: string[] = [];
mocks.clackText.mockImplementation(
async (params: { validate?: (value: string) => string | undefined }) => {
const message = params.validate?.("sk-openai-codex-api-key-value");
if (message) {
validateMessages.push(message);
throw new Error(message);
}
return "sk-openai-codex-api-key-value";
},
);
await expect(
modelsAuthPasteTokenCommand({ provider: "openai-codex" }, runtime),
).rejects.toThrow("paste-api-key --provider openai-codex");
expect(validateMessages).toEqual([
"That looks like an OpenAI API key. Use openclaw models auth paste-api-key --provider openai-codex for API-key auth.",
]);
expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled();
expect(mocks.updateConfig).not.toHaveBeenCalled();
});
it("rejects piped OpenAI API keys as OpenAI Codex token material", async () => {
const runtime = createRuntime();
restoreStdin?.();
restoreStdin = withPipedStdin("sk-openai-codex-api-key-value\n");
await expect(
modelsAuthPasteTokenCommand({ provider: "openai-codex" }, runtime),
).rejects.toThrow("paste-api-key --provider openai-codex");
expect(mocks.clackText).not.toHaveBeenCalled();
expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled();
expect(mocks.updateConfig).not.toHaveBeenCalled();
});
it("rejects line-wrapped piped OpenAI API keys as OpenAI Codex token material", async () => {
const runtime = createRuntime();
restoreStdin?.();
restoreStdin = withPipedStdin("sk-openai-\ncodex-api-key-value\n");
await expect(
modelsAuthPasteTokenCommand({ provider: "openai-codex" }, runtime),
).rejects.toThrow("paste-api-key --provider openai-codex");
expect(mocks.clackText).not.toHaveBeenCalled();
expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled();
expect(mocks.updateConfig).not.toHaveBeenCalled();
});
it("writes pasted API keys to the requested agent store", async () => {
const runtime = createRuntime();
useCoderAgentConfig();
mocks.clackPassword.mockResolvedValue("sk-openai-codex-api-key-value");
await modelsAuthPasteApiKeyCommand({ provider: "openai-codex", agent: "coder" }, runtime);
expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled();
expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({
profileId: "openai-codex:manual",
credential: {
type: "api_key",
provider: "openai-codex",
key: "sk-openai-codex-api-key-value",
},
agentDir: "/tmp/openclaw/agents/coder",
});
expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:manual"]).toEqual({
provider: "openai-codex",
mode: "api_key",
});
expect(runtime.log).toHaveBeenCalledWith(
"Auth profile: openai-codex:manual (openai-codex/api_key)",
);
});
it("writes piped OpenAI Codex API keys to API-key profiles", async () => {
const runtime = createRuntime();
restoreStdin?.();
restoreStdin = withPipedStdin("sk-openai-codex-api-key-value\n");
await modelsAuthPasteApiKeyCommand({ provider: "openai-codex" }, runtime);
expect(mocks.clackPassword).not.toHaveBeenCalled();
expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({
profileId: "openai-codex:manual",
credential: {
type: "api_key",
provider: "openai-codex",
key: "sk-openai-codex-api-key-value",
},
agentDir: "/tmp/openclaw/agents/main",
});
expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:manual"]).toEqual({
provider: "openai-codex",
mode: "api_key",
});
});
it("normalizes line-wrapped piped OpenAI Codex API keys before storing", async () => {
const runtime = createRuntime();
restoreStdin?.();
restoreStdin = withPipedStdin("sk-openai-\ncodex-api-key-value\n");
await modelsAuthPasteApiKeyCommand({ provider: "openai-codex" }, runtime);
expect(mocks.clackPassword).not.toHaveBeenCalled();
expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({
profileId: "openai-codex:manual",
credential: {
type: "api_key",
provider: "openai-codex",
key: "sk-openai-codex-api-key-value",
},
agentDir: "/tmp/openclaw/agents/main",
});
expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:manual"]).toEqual({
provider: "openai-codex",
mode: "api_key",
});
});
it("rejects token material pasted into the OpenAI Codex API-key command", async () => {
const runtime = createRuntime();
const jwtLikeToken = ["eyJhbGciOiJub25l", "eyJzdWIiOiJjb2RleCJ9", "signature123456"].join(".");
const validateMessages: string[] = [];
mocks.clackPassword.mockImplementation(
async (params: { validate?: (value: string) => string | undefined }) => {
const message = params.validate?.(jwtLikeToken);
if (message) {
validateMessages.push(message);
throw new Error(message);
}
return jwtLikeToken;
},
);
await expect(
modelsAuthPasteApiKeyCommand({ provider: "openai-codex" }, runtime),
).rejects.toThrow("paste-token --provider openai-codex");
expect(validateMessages).toEqual([
"That looks like token or OAuth material, not an OpenAI API key. Use openclaw models auth paste-token --provider openai-codex for token auth material.",
]);
expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled();
expect(mocks.updateConfig).not.toHaveBeenCalled();
});
it("rejects an unknown agent before prompting for pasted tokens", async () => {
const runtime = createRuntime();
currentConfig = { agents: { list: [{ id: "main" }] } };

View File

@@ -2,6 +2,7 @@ import {
cancel,
confirm as clackConfirm,
isCancel,
password as clackPassword,
select as clackSelect,
text as clackText,
} from "@clack/prompts";
@@ -51,6 +52,7 @@ import {
normalizeStringifiedOptionalString,
} from "../../shared/string-coerce.js";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { validateAnthropicSetupToken } from "../auth-token.js";
import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runtime-plugin-install.js";
@@ -81,6 +83,13 @@ const text = async (params: Parameters<typeof clackText>[0]) =>
message: stylePromptMessage(params.message),
}),
);
const password = async (params: Parameters<typeof clackPassword>[0]) =>
guardCancel(
await clackPassword({
...params,
message: stylePromptMessage(params.message),
}),
);
const select = async <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
guardCancel(
await clackSelect({
@@ -92,10 +101,76 @@ const select = async <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
}),
);
async function readPipedStdin(): Promise<string> {
process.stdin.setEncoding("utf8");
let input = "";
for await (const chunk of process.stdin) {
input += String(chunk);
}
return input;
}
async function readPastedSecret(params: {
message: string;
masked: boolean;
validate?: (value: string | undefined) => string | undefined;
}): Promise<string> {
const promptParams = { message: params.message, validate: params.validate };
const input = process.stdin.isTTY
? await (params.masked ? password(promptParams) : text(promptParams))
: await readPipedStdin();
const normalized = normalizeSecretInput(input);
const validationMessage = params.validate?.(normalized);
if (validationMessage) {
throw new Error(validationMessage);
}
return normalized;
}
function resolveDefaultTokenProfileId(provider: string): string {
return `${normalizeProviderId(provider)}:manual`;
}
function isOpenAICodexProvider(provider: string): boolean {
return normalizeProviderId(provider) === "openai-codex";
}
function stripBearerPrefix(value: string): string {
return value
.trim()
.replace(/^Bearer\s+/i, "")
.trim();
}
function looksLikeOpenAIApiKey(value: string): boolean {
return /^sk-[A-Za-z0-9_-]{8,}$/.test(value.trim());
}
function looksLikeJwtToken(value: string): boolean {
const token = stripBearerPrefix(value);
const parts = token.split(".");
return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]{8,}$/.test(part));
}
function looksLikeStructuredCredential(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
function validateOpenAICodexApiKeyInput(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) {
return "Required";
}
if (looksLikeOpenAIApiKey(trimmed)) {
return undefined;
}
if (looksLikeJwtToken(trimmed) || looksLikeStructuredCredential(trimmed)) {
return `That looks like token or OAuth material, not an OpenAI API key. Use ${formatCliCommand("openclaw models auth paste-token --provider openai-codex")} for token auth material.`;
}
return "That does not look like an OpenAI API key.";
}
type ResolvedModelsAuthContext = {
config: OpenClawConfig;
agentDir: string;
@@ -502,8 +577,9 @@ export async function modelsAuthPasteTokenCommand(
const profileId =
normalizeOptionalString(opts.profileId) || resolveDefaultTokenProfileId(provider);
const tokenInput = await text({
const tokenInput = await readPastedSecret({
message: `Paste token for ${provider}`,
masked: false,
validate: (value) => {
const trimmed = value?.trim();
if (!trimmed) {
@@ -512,6 +588,9 @@ export async function modelsAuthPasteTokenCommand(
if (provider === "anthropic") {
return validateAnthropicSetupToken(trimmed.replaceAll(/\s+/g, ""));
}
if (isOpenAICodexProvider(provider) && looksLikeOpenAIApiKey(trimmed)) {
return `That looks like an OpenAI API key. Use ${formatCliCommand("openclaw models auth paste-api-key --provider openai-codex")} for API-key auth.`;
}
return undefined;
},
});
@@ -549,6 +628,58 @@ export async function modelsAuthPasteTokenCommand(
}
}
export async function modelsAuthPasteApiKeyCommand(
opts: {
provider?: string;
profileId?: string;
agent?: string;
},
runtime: RuntimeEnv,
) {
const agentDir = await resolveModelsAuthAgentDir(opts.agent);
const rawProvider = normalizeOptionalString(opts.provider);
if (!rawProvider) {
throw new Error(
`Missing --provider. Run ${formatCliCommand("openclaw models status")} or ${formatCliCommand("openclaw plugins list")} to choose a provider.`,
);
}
const provider = normalizeProviderId(rawProvider);
const profileId =
normalizeOptionalString(opts.profileId) || resolveDefaultTokenProfileId(provider);
const key = await readPastedSecret({
message: `Paste API key for ${provider}`,
masked: true,
validate: (value) => {
const trimmed = value?.trim();
if (!trimmed) {
return "Required";
}
if (isOpenAICodexProvider(provider)) {
return validateOpenAICodexApiKeyInput(trimmed);
}
return undefined;
},
});
await upsertAuthProfileWithLockOrThrow({
profileId,
credential: {
type: "api_key",
provider,
key,
},
agentDir,
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, { profileId, provider, mode: "api_key" }),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/api_key)`);
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {