diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh index 604cd1d0e..60cd771e9 100755 --- a/scripts/docker/install-sh-e2e/run.sh +++ b/scripts/docker/install-sh-e2e/run.sh @@ -5,6 +5,9 @@ INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}" MODELS_MODE="${CLAWDBOT_E2E_MODELS:-both}" # both|openai|anthropic E2E_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_E2E_PREVIOUS:-}" SKIP_PREVIOUS="${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}" +OPENAI_API_KEY="${OPENAI_API_KEY:-}" +ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}" if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then echo "ERROR: CLAWDBOT_E2E_MODELS must be one of: both|openai|anthropic" >&2 @@ -12,15 +15,19 @@ if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" ! fi if [[ "$MODELS_MODE" == "both" ]]; then - if [[ -z "${OPENAI_API_KEY:-}" || -z "${ANTHROPIC_API_KEY:-}" ]]; then - echo "ERROR: CLAWDBOT_E2E_MODELS=both requires OPENAI_API_KEY and ANTHROPIC_API_KEY." >&2 + if [[ -z "$OPENAI_API_KEY" ]]; then + echo "ERROR: CLAWDBOT_E2E_MODELS=both requires OPENAI_API_KEY." >&2 exit 2 fi -elif [[ "$MODELS_MODE" == "openai" && -z "${OPENAI_API_KEY:-}" ]]; then + if [[ -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then + echo "ERROR: CLAWDBOT_E2E_MODELS=both requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2 + exit 2 + fi +elif [[ "$MODELS_MODE" == "openai" && -z "$OPENAI_API_KEY" ]]; then echo "ERROR: CLAWDBOT_E2E_MODELS=openai requires OPENAI_API_KEY." >&2 exit 2 -elif [[ "$MODELS_MODE" == "anthropic" && -z "${ANTHROPIC_API_KEY:-}" ]]; then - echo "ERROR: CLAWDBOT_E2E_MODELS=anthropic requires ANTHROPIC_API_KEY." >&2 +elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then + echo "ERROR: CLAWDBOT_E2E_MODELS=anthropic requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2 exit 2 fi @@ -328,6 +335,18 @@ run_profile() { --gateway-auth token \ --workspace "$workspace" \ --skip-health + elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then + clawdbot --profile "$profile" onboard \ + --non-interactive \ + --flow quickstart \ + --auth-choice token \ + --token-provider anthropic \ + --token "$ANTHROPIC_API_TOKEN" \ + --gateway-port "$port" \ + --gateway-bind loopback \ + --gateway-auth token \ + --workspace "$workspace" \ + --skip-health else clawdbot --profile "$profile" onboard \ --non-interactive \ diff --git a/scripts/test-install-sh-e2e-docker.sh b/scripts/test-install-sh-e2e-docker.sh index e76de2518..10b837a64 100755 --- a/scripts/test-install-sh-e2e-docker.sh +++ b/scripts/test-install-sh-e2e-docker.sh @@ -7,6 +7,7 @@ INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}" OPENAI_API_KEY="${OPENAI_API_KEY:-}" ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}" CLAWDBOT_E2E_MODELS="${CLAWDBOT_E2E_MODELS:-}" echo "==> Build image: $IMAGE_NAME" @@ -23,4 +24,5 @@ docker run --rm -t \ -e CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ + -e ANTHROPIC_API_TOKEN="$ANTHROPIC_API_TOKEN" \ "$IMAGE_NAME" diff --git a/src/commands/onboard-non-interactive.token.test.ts b/src/commands/onboard-non-interactive.token.test.ts new file mode 100644 index 000000000..71de29a0a --- /dev/null +++ b/src/commands/onboard-non-interactive.token.test.ts @@ -0,0 +1,102 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +describe("onboard (non-interactive): token auth", () => { + it("writes token profile config and stores the token", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.CLAWDBOT_STATE_DIR, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + password: process.env.CLAWDBOT_GATEWAY_PASSWORD, + }; + + process.env.CLAWDBOT_SKIP_CHANNELS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + + const tempHome = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-onboard-token-"), + ); + process.env.HOME = tempHome; + delete process.env.CLAWDBOT_STATE_DIR; + delete process.env.CLAWDBOT_CONFIG_PATH; + + const token = `sk-ant-oat01-${"a".repeat(80)}`; + + const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + try { + const { runNonInteractiveOnboarding } = await import( + "./onboard-non-interactive.js" + ); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + authChoice: "token", + tokenProvider: "anthropic", + token, + tokenProfileId: "anthropic:default", + skipHealth: true, + skipChannels: true, + json: true, + }, + runtime, + ); + + const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); + const cfg = JSON.parse( + await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"), + ) as { + auth?: { + profiles?: Record; + }; + }; + + expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe( + "anthropic", + ); + expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token"); + + const { ensureAuthProfileStore } = await import( + "../agents/auth-profiles.js" + ); + const store = ensureAuthProfileStore(); + const profile = store.profiles["anthropic:default"]; + expect(profile?.type).toBe("token"); + if (profile?.type === "token") { + expect(profile.provider).toBe("anthropic"); + expect(profile.token).toBe(token); + } + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.CLAWDBOT_STATE_DIR = prev.stateDir; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password; + } + }, 60_000); +}); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index ebfd6b958..9165b50a2 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -5,8 +5,11 @@ import { ensureAuthProfileStore, resolveApiKeyForProfile, resolveAuthProfileOrder, + upsertAuthProfile, } from "../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { normalizeProviderId } from "../agents/model-selection.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, @@ -28,6 +31,10 @@ import { upsertSharedEnvVar } from "../infra/env-file.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; +import { + buildTokenProfileId, + validateAnthropicSetupToken, +} from "./auth-token.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, @@ -206,7 +213,65 @@ export async function runNonInteractiveOnboarding( }; const authChoice: AuthChoice = opts.authChoice ?? "skip"; - if (authChoice === "apiKey") { + if (authChoice === "token") { + const providerRaw = opts.tokenProvider?.trim(); + if (!providerRaw) { + runtime.error("Missing --token-provider for --auth-choice token."); + runtime.exit(1); + return; + } + const provider = normalizeProviderId(providerRaw); + if (provider !== "anthropic") { + runtime.error( + "Only --token-provider anthropic is supported for --auth-choice token.", + ); + runtime.exit(1); + return; + } + const tokenRaw = opts.token?.trim(); + if (!tokenRaw) { + runtime.error("Missing --token for --auth-choice token."); + runtime.exit(1); + return; + } + const tokenError = validateAnthropicSetupToken(tokenRaw); + if (tokenError) { + runtime.error(tokenError); + runtime.exit(1); + return; + } + + let expires: number | undefined; + const expiresInRaw = opts.tokenExpiresIn?.trim(); + if (expiresInRaw) { + try { + expires = + Date.now() + parseDurationMs(expiresInRaw, { defaultUnit: "d" }); + } catch (err) { + runtime.error(`Invalid --token-expires-in: ${String(err)}`); + runtime.exit(1); + return; + } + } + + const profileId = + opts.tokenProfileId?.trim() || + buildTokenProfileId({ provider, name: "" }); + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider, + token: tokenRaw.trim(), + ...(expires ? { expires } : {}), + }, + }); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider, + mode: "token", + }); + } else if (authChoice === "apiKey") { const resolved = await resolveNonInteractiveApiKey({ provider: "anthropic", cfg: baseConfig, @@ -417,18 +482,12 @@ export async function runNonInteractiveOnboarding( }); nextConfig = applyOpencodeZenConfig(nextConfig); } else if ( - authChoice === "token" || authChoice === "oauth" || authChoice === "chutes" || authChoice === "openai-codex" || authChoice === "antigravity" ) { - const label = - authChoice === "antigravity" - ? "Antigravity" - : authChoice === "token" - ? "Token" - : "OAuth"; + const label = authChoice === "antigravity" ? "Antigravity" : "OAuth"; runtime.error(`${label} requires interactive mode.`); runtime.exit(1); return;