mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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" }] } };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user