feat: improve auth setup flows

This commit is contained in:
Peter Steinberger
2026-01-09 09:59:58 +01:00
parent 3db52c972d
commit 827e68eadd
11 changed files with 310 additions and 226 deletions

View File

@@ -55,6 +55,8 @@
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Daemon runtime: remove Bun from selection options.
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.

View File

@@ -169,8 +169,9 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--gemini-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`

View File

@@ -71,11 +71,12 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
2) **Model/Auth**
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **API key**: stores the key for you.
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
- **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is autowritten for the LM Studio endpoint.
- **Skip**: no auth configured yet.
- Wizard runs a model check and warns if the configured model is unknown or missing auth.

View File

@@ -239,9 +239,10 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: oauth|claude-cli|token|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--openai-api-key <key>", "OpenAI API key")
.option("--gemini-api-key <key>", "Gemini API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
@@ -270,6 +271,7 @@ export function buildProgram() {
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
@@ -278,6 +280,7 @@ export function buildProgram() {
| "skip"
| undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined,
openaiApiKey: opts.openaiApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,
gatewayPort:
typeof opts.gatewayPort === "string"

View File

@@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: {
value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)",
});
options.push({ value: "openai-api-key", label: "OpenAI API key" });
options.push({
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",

View File

@@ -18,8 +18,8 @@ import {
} from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
@@ -210,38 +210,10 @@ export async function applyAuthChoice(params: {
mode: "token",
});
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
const profileNameRaw = await params.prompter.text({
message: "Token name (blank = default)",
placeholder: "default",
});
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const existing = store.profiles[profileId];
if (existing?.type === "token") {
const useExisting = await params.prompter.confirm({
message: `Use existing token "${profileId}"?`,
initialValue: true,
});
if (useExisting) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider,
mode: "token",
});
return { config: nextConfig, agentModelOverride };
}
}
await params.prompter.note(
[
"Run `claude setup-token` in your terminal.",
@@ -256,46 +228,67 @@ export async function applyAuthChoice(params: {
});
const token = String(tokenRaw).trim();
const wantsExpiry = await params.prompter.confirm({
message: "Does this token expire?",
initialValue: false,
const profileNameRaw = await params.prompter.text({
message: "Token name (blank = default)",
placeholder: "default",
});
const namedProfileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
const expiresInRaw = wantsExpiry
? await params.prompter.text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
})
: "";
const expiresIn = String(expiresInRaw).trim();
const expires = expiresIn
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
profileId,
profileId: namedProfileId,
agentDir: params.agentDir,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
profileId: namedProfileId,
provider,
mode: "token",
});
} else if (params.authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
});
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
await params.prompter.note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
return { config: nextConfig, agentModelOverride };
}
}
const key = await params.prompter.text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
await params.prompter.note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
await params.prompter.note(

View File

@@ -21,7 +21,6 @@ import {
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { createCliProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -65,6 +64,8 @@ import {
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
@@ -351,6 +352,7 @@ async function promptAuthConfig(
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
@@ -398,14 +400,6 @@ async function promptAuthConfig(
mode: "token",
});
} else if (authChoice === "token" || authChoice === "oauth") {
const profileNameRaw = guardCancel(
await text({
message: "Token name (blank = default)",
placeholder: "default",
}),
runtime,
);
const provider = guardCancel(
await select({
message: "Token provider",
@@ -419,32 +413,6 @@ async function promptAuthConfig(
runtime,
) as "anthropic";
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
const existing = store.profiles[profileId];
if (existing?.type === "token") {
const useExisting = guardCancel(
await confirm({
message: `Use existing token "${profileId}"?`,
initialValue: true,
}),
runtime,
);
if (useExisting) {
next = applyAuthProfileConfig(next, {
profileId,
provider,
mode: "token",
});
return next;
}
}
note(
[
"Run `claude setup-token` in your terminal.",
@@ -462,34 +430,17 @@ async function promptAuthConfig(
);
const token = String(tokenRaw).trim();
const wantsExpiry = guardCancel(
await confirm({
message: "Does this token expire?",
initialValue: false,
const profileNameRaw = guardCancel(
await text({
message: "Token name (blank = default)",
placeholder: "default",
}),
runtime,
);
const expiresInRaw = wantsExpiry
? guardCancel(
await text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
}),
runtime,
)
: "";
const expiresIn = String(expiresInRaw).trim();
const expires = expiresIn
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
: undefined;
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
upsertAuthProfile({
profileId,
@@ -497,11 +448,52 @@ async function promptAuthConfig(
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
} else if (authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = guardCancel(
await confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
}),
runtime,
);
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
}
}
const key = guardCancel(
await text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
@@ -703,13 +695,24 @@ async function promptAuthConfig(
next = applyMinimaxConfig(next);
}
const currentModel =
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "apiKey";
const modelInitialValue =
preferAnthropic && !currentModel.startsWith("anthropic/")
? "anthropic/claude-opus-4-5"
: currentModel;
const modelInput = guardCancel(
await text({
message: "Default model (blank to keep)",
initialValue:
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? ""),
initialValue: modelInitialValue,
}),
runtime,
);
@@ -1078,58 +1081,65 @@ export async function runConfigureWizard(
runtime.error(controlUiAssets.message);
}
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token:
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
password:
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD,
});
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
note(
(() => {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
return [
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
"Docs: https://docs.clawd.bot/web/control-ui",
].join("\n");
})(),
[
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.clawd.bot/web/control-ui",
].join("\n"),
"Control UI",
);
const browserSupport = await detectBrowserOpenSupport();
if (!browserSupport.ok) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
} else {
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const opened = await openUrl(links.httpUrl);
if (!opened) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
if (gatewayProbe.ok) {
if (!browserSupport.ok) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
} else {
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const opened = await openUrl(links.httpUrl);
if (!opened) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
}
}
}
}

View File

@@ -4,6 +4,7 @@ import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import {
type ClawdbotConfig,
CONFIG_PATH_CLAWDBOT,
@@ -16,6 +17,7 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -135,6 +137,19 @@ export async function runNonInteractiveOnboarding(
mode: "api_key",
});
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
} else if (authChoice === "openai-api-key") {
const key = opts.openaiApiKey?.trim() || resolveEnvApiKey("openai")?.apiKey;
if (!key) {
runtime.error("Missing --openai-api-key (or OPENAI_API_KEY in env).");
runtime.exit(1);
return;
}
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: key,
});
process.env.OPENAI_API_KEY = key;
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,

View File

@@ -7,6 +7,7 @@ export type AuthChoice =
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "apiKey"
@@ -26,6 +27,7 @@ export type OnboardOptions = {
nonInteractive?: boolean;
authChoice?: AuthChoice;
anthropicApiKey?: string;
openaiApiKey?: string;
geminiApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;

55
src/infra/env-file.ts Normal file
View File

@@ -0,0 +1,55 @@
import fs from "node:fs";
import path from "node:path";
import { resolveConfigDir } from "../utils.js";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function upsertSharedEnvVar(params: {
key: string;
value: string;
env?: NodeJS.ProcessEnv;
}): { path: string; updated: boolean; created: boolean } {
const env = params.env ?? process.env;
const dir = resolveConfigDir(env);
const filepath = path.join(dir, ".env");
const key = params.key.trim();
const value = params.value;
let raw = "";
if (fs.existsSync(filepath)) {
raw = fs.readFileSync(filepath, "utf8");
}
const lines = raw.length ? raw.split(/\r?\n/) : [];
const matcher = new RegExp(`^(\\s*(?:export\\s+)?)${escapeRegExp(key)}\\s*=`);
let updated = false;
let replaced = false;
const nextLines = lines.map((line) => {
const match = line.match(matcher);
if (!match) return line;
replaced = true;
const prefix = match[1] ?? "";
const next = `${prefix}${key}=${value}`;
if (next !== line) updated = true;
return next;
});
if (!replaced) {
nextLines.push(`${key}=${value}`);
updated = true;
}
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
const output = `${nextLines.join("\n")}\n`;
fs.writeFileSync(filepath, output, "utf8");
fs.chmodSync(filepath, 0o600);
return { path: filepath, updated, created: !raw };
}

View File

@@ -548,65 +548,66 @@ export async function runOnboardingWizard(
"Optional apps",
);
const links = resolveControlUiLinks({
bind,
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
});
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
const authedUrl = `${links.httpUrl}${tokenParam}`;
const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token: authMode === "token" ? gatewayToken : undefined,
password: authMode === "password" ? baseConfig.gateway?.auth?.password : "",
});
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
await prompter.note(
(() => {
const links = resolveControlUiLinks({
bind,
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
});
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
const authedUrl = `${links.httpUrl}${tokenParam}`;
return [
`Web UI: ${links.httpUrl}`,
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`,
"Docs: https://docs.clawd.bot/web/control-ui",
]
.filter(Boolean)
.join("\n");
})(),
[
`Web UI: ${links.httpUrl}`,
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.clawd.bot/web/control-ui",
]
.filter(Boolean)
.join("\n"),
"Control UI",
);
const browserSupport = await detectBrowserOpenSupport();
if (!browserSupport.ok) {
await prompter.note(
formatControlUiSshHint({
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
token: authMode === "token" ? gatewayToken : undefined,
}),
"Open Control UI",
);
} else {
const wantsOpen = await prompter.confirm({
message: "Open Control UI now?",
initialValue: true,
});
if (wantsOpen) {
const links = resolveControlUiLinks({
bind,
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
if (gatewayProbe.ok) {
if (!browserSupport.ok) {
await prompter.note(
formatControlUiSshHint({
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
token: authMode === "token" ? gatewayToken : undefined,
}),
"Open Control UI",
);
} else {
const wantsOpen = await prompter.confirm({
message: "Open Control UI now?",
initialValue: true,
});
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
const opened = await openUrl(`${links.httpUrl}${tokenParam}`);
if (!opened) {
await prompter.note(
formatControlUiSshHint({
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
token: authMode === "token" ? gatewayToken : undefined,
}),
"Open Control UI",
);
if (wantsOpen) {
const opened = await openUrl(`${links.httpUrl}${tokenParam}`);
if (!opened) {
await prompter.note(
formatControlUiSshHint({
port,
basePath: baseConfig.gateway?.controlUi?.basePath,
token: authMode === "token" ? gatewayToken : undefined,
}),
"Open Control UI",
);
}
}
}
}