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
121 changes: 121 additions & 0 deletions apps/web/app/api/settings/auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import {
upsertAuthProfile,
readConfig,
writeConfig,
applyAuthProfileConfig,
} from "@/lib/settings";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

/**
* Mapping from auth-method value (frontend) → { profileId, credentialProvider }
* Must exactly match what the CLI's onboard-auth.credentials.ts does for each
* provider. The gateway looks up credentials by profileId, so if they don't
* match, the key won't be found.
*/
const AUTH_METHOD_MAP: Record<string, { profileId: string; provider: string }> = {
// OpenAI
"openai-api-key": { profileId: "openai:default", provider: "openai" },
// Anthropic
"apiKey": { profileId: "anthropic:default", provider: "anthropic" },
// Google
"gemini-api-key": { profileId: "google:default", provider: "google" },
// xAI
"xai-api-key": { profileId: "xai:default", provider: "xai" },
// OpenRouter
"openrouter-api-key": { profileId: "openrouter:default", provider: "openrouter" },
// Vercel AI Gateway
"ai-gateway-api-key": { profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway" },
// Moonshot
"moonshot-api-key": { profileId: "moonshot:default", provider: "moonshot" },
"moonshot-api-key-cn": { profileId: "moonshot:default", provider: "moonshot" },
// Kimi Coding
"kimi-code-api-key": { profileId: "kimi-coding:default", provider: "kimi-coding" },
// Together
"together-api-key": { profileId: "together:default", provider: "together" },
// Hugging Face
"huggingface-api-key": { profileId: "huggingface:default", provider: "huggingface" },
// Venice
"venice-api-key": { profileId: "venice:default", provider: "venice" },
// LiteLLM
"litellm-api-key": { profileId: "litellm:default", provider: "litellm" },
// Synthetic
"synthetic-api-key": { profileId: "synthetic:default", provider: "synthetic" },
// Custom
"custom-api-key": { profileId: "custom:default", provider: "custom" },
};

/**
* POST /api/settings/auth
*
* Body: { provider: string, authMethod: string, apiKey: string }
*
* 1. Writes the API key to credentials.json (AuthProfileStore) – same as CLI.
* 2. Links the auth profile in openclaw.json – same as CLI.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { authMethod, apiKey } = body as {
provider?: string;
authMethod?: string;
apiKey?: string;
};

if (!authMethod) {
return NextResponse.json(
{ error: "authMethod is required" },
{ status: 400 },
);
}

if (!apiKey || apiKey.trim().length === 0) {
return NextResponse.json(
{ error: "apiKey is required" },
{ status: 400 },
);
}

const mapping = AUTH_METHOD_MAP[authMethod];
if (!mapping) {
return NextResponse.json(
{ error: `Unknown auth method: ${authMethod}` },
{ status: 400 },
);
}

const trimmedKey = apiKey.trim();

// Step 1: Write credential to credentials.json (AuthProfileStore)
upsertAuthProfile({
profileId: mapping.profileId,
credential: {
type: "api_key",
provider: mapping.provider,
key: trimmedKey,
},
});

// Step 2: Link the auth profile in openclaw.json
const config = readConfig();
const updatedConfig = applyAuthProfileConfig(config, {
profileId: mapping.profileId,
provider: mapping.provider,
mode: "api_key",
});
writeConfig(updatedConfig);

return NextResponse.json({
ok: true,
profileId: mapping.profileId,
provider: mapping.provider,
});
} catch (err) {
return NextResponse.json(
{ error: `Failed to save auth config: ${String(err)}` },
{ status: 500 },
);
}
}
98 changes: 98 additions & 0 deletions apps/web/app/api/settings/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { NextResponse } from "next/server";
import { readAuthProfileStore } from "@/lib/settings";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

/** Resolve the openclaw.json config path. */
function resolveConfigPath(): string {
const envPath = process.env.OPENCLAW_CONFIG;
if (envPath) { return envPath; }
return join(homedir(), ".openclaw", "openclaw.json");
}

/** Mask an API key for safe display: show first 4 + last 4 chars. */
function maskKey(key: string | undefined): string | undefined {
if (!key) { return undefined; }
const trimmed = key.trim();
if (trimmed.length <= 8) { return trimmed.length > 0 ? `${trimmed.slice(0, 2)}…` : undefined; }
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
}

/** Deep-walk an object and mask any key named "apiKey", "token", "key", or "password". */
function redactSensitiveFields(obj: unknown): unknown {
if (obj === null || obj === undefined) { return obj; }
if (typeof obj !== "object") { return obj; }

if (Array.isArray(obj)) {
return obj.map(redactSensitiveFields);
}

const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (
(key === "apiKey" || key === "token" || key === "key" || key === "password" || key === "accessToken" || key === "refreshToken") &&
typeof value === "string"
) {
result[key] = maskKey(value);
} else if (typeof value === "object" && value !== null) {
result[key] = redactSensitiveFields(value);
} else {
result[key] = value;
}
}
return result;
}

/**
* Summarize auth profiles for UI display.
* Returns a list of { profileId, provider, type, hasKey } objects.
*/
function summarizeAuthProfiles(): Array<{
profileId: string;
provider: string;
type: string;
hasKey: boolean;
}> {
const store = readAuthProfileStore();
return Object.entries(store.profiles).map(([profileId, profile]) => ({
profileId,
provider: profile.provider,
type: profile.type,
hasKey: Boolean(profile.key || profile.token || profile.accessToken),
}));
}

export async function GET() {
const configPath = resolveConfigPath();

if (!existsSync(configPath)) {
return NextResponse.json({
exists: false,
config: {},
configPath,
authProfiles: summarizeAuthProfiles(),
});
}

try {
const raw = readFileSync(configPath, "utf-8");
const config = JSON.parse(raw);
const redacted = redactSensitiveFields(config);

return NextResponse.json({
exists: true,
config: redacted,
configPath,
authProfiles: summarizeAuthProfiles(),
});
} catch (err) {
return NextResponse.json(
{ exists: true, error: `Failed to parse config: ${String(err)}`, configPath },
{ status: 500 },
);
}
}
72 changes: 72 additions & 0 deletions apps/web/app/api/settings/copilot/poll/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { upsertAuthProfile, applyAuthProfileConfig, readConfig, writeConfig } from "@/lib/settings";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

const CLIENT_ID = "Iv1.b507a3d255788057";

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { device_code } = body as { device_code?: string };

if (!device_code) {
return NextResponse.json({ error: "device_code is required" }, { status: 400 });
}

const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});

if (!res.ok) {
const error = await res.text();
return NextResponse.json({ error: `Polling failed: ${error}` }, { status: 500 });
}

const data = await res.json();

if (data.error) {
// data.error: authorization_pending, slow_down, expired_token, access_denied
return NextResponse.json(data);
}

if (data.access_token) {
const profileId = "github-copilot:default";

// Store in credentials.json
upsertAuthProfile({
profileId,
credential: {
type: "oauth", // CLI marks it as oauth
provider: "github-copilot",
accessToken: data.access_token,
},
});

// Update openclaw.json
const config = readConfig();
const updatedConfig = applyAuthProfileConfig(config, {
profileId,
provider: "github-copilot",
mode: "oauth",
});
writeConfig(updatedConfig);

return NextResponse.json({ ok: true, profileId });
}

return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: `Polling failed: ${String(err)}` }, { status: 500 });
}
}
33 changes: 33 additions & 0 deletions apps/web/app/api/settings/copilot/start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

const CLIENT_ID = "Iv1.b507a3d255788057"; // GitHub Copilot CLI Client ID

export async function POST() {
try {
const res = await fetch("https://github.com/login/device/code", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
});

if (!res.ok) {
const error = await res.text();
return NextResponse.json({ error: `Failed to start device flow: ${error}` }, { status: 500 });
}

const data = await res.json();
// data: { device_code, user_code, verification_uri, expires_in, interval }
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: `Failed to start Copilot flow: ${String(err)}` }, { status: 500 });
}
}
Loading