Skip to content

Commit 8df9751

Browse files
committed
feat: Add explicit provider support, fix Cloudflare Access auth
## Changes - Add AI_GATEWAY_PROVIDER for explicit OpenAI/Anthropic selection - Add AI_GATEWAY_MODEL for custom model names - Add AI_GATEWAY_API_FORMAT for openai-completions/openai-responses API type - Fix Cloudflare Access domain normalization (add .cloudflareaccess.com suffix) - Add normalizeTeamDomain() helper shared between jwt.ts and middleware.ts - Fix OpenAI provider config to include apiKey (required for custom baseUrl) - Add comprehensive tests for new env.ts logic ## Security - Redact API keys in debug endpoints and container logs - No sensitive data exposed in /debug/env or container config ## Breaking Changes - None (backward compatible with existing configs) Tested with custom OpenAI-compatible API gateway
1 parent 182a8fa commit 8df9751

8 files changed

Lines changed: 62 additions & 35 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ The `AI_GATEWAY_*` variables take precedence over `ANTHROPIC_*` if both are set.
363363
| `AI_GATEWAY_BASE_URL` | Yes* | AI Gateway endpoint URL (required when using `AI_GATEWAY_API_KEY`) |
364364
| `AI_GATEWAY_PROVIDER` | No | Explicit provider type: `openai` or `anthropic` (auto-detected from URL if not set) |
365365
| `AI_GATEWAY_MODEL` | No | Custom model name (e.g., `gpt-5-mini`) |
366+
| `AI_GATEWAY_API_FORMAT` | No | OpenAI API format: `openai-completions` (default) or `openai-responses` |
366367
| `ANTHROPIC_API_KEY` | Yes* | Direct Anthropic API key (fallback if AI Gateway not configured) |
367368
| `ANTHROPIC_BASE_URL` | No | Direct Anthropic API base URL (fallback) |
368369
| `OPENAI_API_KEY` | No | OpenAI API key (alternative provider) |

src/auth/jwt.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { jwtVerify, createRemoteJWKSet, type JWTPayload as JoseJWTPayload } from 'jose';
22
import type { JWTPayload } from '../types';
33

4+
/**
5+
* Normalize a Cloudflare Access team domain.
6+
* Handles cases where user provides just the team name or the full domain.
7+
*
8+
* @param teamDomain - Team domain (e.g., 'myteam' or 'myteam.cloudflareaccess.com')
9+
* @returns Normalized domain (e.g., 'myteam.cloudflareaccess.com')
10+
*/
11+
export function normalizeTeamDomain(teamDomain: string): string {
12+
// Remove trailing slashes
13+
let domain = teamDomain.replace(/\/+$/, '');
14+
// Add .cloudflareaccess.com if not present
15+
if (!domain.endsWith('.cloudflareaccess.com')) {
16+
domain = `${domain}.cloudflareaccess.com`;
17+
}
18+
return domain;
19+
}
20+
421
/**
522
* Verify a Cloudflare Access JWT token using the jose library.
623
*
@@ -18,10 +35,13 @@ export async function verifyAccessJWT(
1835
teamDomain: string,
1936
expectedAud: string
2037
): Promise<JWTPayload> {
38+
// Normalize team domain
39+
const normalizedDomain = normalizeTeamDomain(teamDomain);
40+
2141
// Ensure teamDomain has https:// prefix for issuer check
22-
const issuer = teamDomain.startsWith('https://')
23-
? teamDomain
24-
: `https://${teamDomain}`;
42+
const issuer = normalizedDomain.startsWith('https://')
43+
? normalizedDomain
44+
: `https://${normalizedDomain}`;
2545

2646
// Create JWKS from the team domain
2747
const JWKS = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));

src/auth/middleware.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,18 @@ describe('extractJWT', () => {
7070

7171
it('extracts JWT from CF_Authorization cookie with other cookies', () => {
7272
const jwt = 'cookie.payload.signature';
73-
const c = createMockContext({
74-
cookies: `other=value; CF_Authorization=${jwt}; another=test`
73+
const c = createMockContext({
74+
cookies: `other=value; CF_Authorization=${jwt}; another=test`
7575
});
7676
expect(extractJWT(c)).toBe(jwt);
7777
});
7878

7979
it('prefers header over cookie', () => {
8080
const headerJwt = 'header.jwt.token';
8181
const cookieJwt = 'cookie.jwt.token';
82-
const c = createMockContext({
82+
const c = createMockContext({
8383
jwtHeader: headerJwt,
84-
cookies: `CF_Authorization=${cookieJwt}`
84+
cookies: `CF_Authorization=${cookieJwt}`
8585
});
8686
expect(extractJWT(c)).toBe(headerJwt);
8787
});
@@ -187,8 +187,8 @@ describe('createAccessMiddleware', () => {
187187
});
188188

189189
it('returns 401 JSON error when JWT is missing', async () => {
190-
const { c, jsonMock } = createFullMockContext({
191-
env: { CF_ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', CF_ACCESS_AUD: 'aud123' }
190+
const { c, jsonMock } = createFullMockContext({
191+
env: { CF_ACCESS_TEAM_DOMAIN: 'team', CF_ACCESS_AUD: 'aud123' }
192192
});
193193
const middleware = createAccessMiddleware({ type: 'json' });
194194
const next = vi.fn();
@@ -203,8 +203,8 @@ describe('createAccessMiddleware', () => {
203203
});
204204

205205
it('returns 401 HTML error when JWT is missing', async () => {
206-
const { c, htmlMock } = createFullMockContext({
207-
env: { CF_ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', CF_ACCESS_AUD: 'aud123' }
206+
const { c, htmlMock } = createFullMockContext({
207+
env: { CF_ACCESS_TEAM_DOMAIN: 'team', CF_ACCESS_AUD: 'aud123' }
208208
});
209209
const middleware = createAccessMiddleware({ type: 'html' });
210210
const next = vi.fn();
@@ -219,8 +219,8 @@ describe('createAccessMiddleware', () => {
219219
});
220220

221221
it('redirects when JWT is missing and redirectOnMissing is true', async () => {
222-
const { c, redirectMock } = createFullMockContext({
223-
env: { CF_ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', CF_ACCESS_AUD: 'aud123' }
222+
const { c, redirectMock } = createFullMockContext({
223+
env: { CF_ACCESS_TEAM_DOMAIN: 'team', CF_ACCESS_AUD: 'aud123' }
224224
});
225225
const middleware = createAccessMiddleware({ type: 'html', redirectOnMissing: true });
226226
const next = vi.fn();

src/auth/middleware.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Context, Next } from 'hono';
22
import type { AppEnv, MoltbotEnv } from '../types';
3-
import { verifyAccessJWT } from './jwt';
3+
import { verifyAccessJWT, normalizeTeamDomain } from './jwt';
44

55
/**
66
* Options for creating an access middleware
@@ -34,7 +34,7 @@ export function extractJWT(c: Context<AppEnv>): string | null {
3434

3535
/**
3636
* Create a Cloudflare Access authentication middleware
37-
*
37+
*
3838
* @param options - Middleware options
3939
* @returns Hono middleware function
4040
*/
@@ -75,9 +75,9 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) {
7575

7676
if (!jwt) {
7777
if (type === 'html' && redirectOnMissing) {
78-
return c.redirect(`https://${teamDomain}`, 302);
78+
return c.redirect(`https://${normalizeTeamDomain(teamDomain)}`, 302);
7979
}
80-
80+
8181
if (type === 'json') {
8282
return c.json({
8383
error: 'Unauthorized',
@@ -89,7 +89,7 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) {
8989
<body>
9090
<h1>Unauthorized</h1>
9191
<p>Missing Cloudflare Access token.</p>
92-
<a href="https://${teamDomain}">Login</a>
92+
<a href="https://${normalizeTeamDomain(teamDomain)}">Login</a>
9393
</body>
9494
</html>
9595
`, 401);
@@ -103,7 +103,7 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) {
103103
await next();
104104
} catch (err) {
105105
console.error('Access JWT verification failed:', err);
106-
106+
107107
if (type === 'json') {
108108
return c.json({
109109
error: 'Unauthorized',
@@ -115,7 +115,7 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) {
115115
<body>
116116
<h1>Unauthorized</h1>
117117
<p>Your Cloudflare Access session is invalid or expired.</p>
118-
<a href="https://${teamDomain}">Login again</a>
118+
<a href="https://${normalizeTeamDomain(teamDomain)}">Login again</a>
119119
</body>
120120
</html>
121121
`, 401);

src/gateway/env.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('buildEnvVars', () => {
101101
SLACK_APP_TOKEN: 'slack-app',
102102
});
103103
const result = buildEnvVars(env);
104-
104+
105105
expect(result.TELEGRAM_BOT_TOKEN).toBe('tg-token');
106106
expect(result.TELEGRAM_DM_POLICY).toBe('pairing');
107107
expect(result.DISCORD_BOT_TOKEN).toBe('discord-token');
@@ -116,7 +116,7 @@ describe('buildEnvVars', () => {
116116
CLAWDBOT_BIND_MODE: 'lan',
117117
});
118118
const result = buildEnvVars(env);
119-
119+
120120
expect(result.CLAWDBOT_DEV_MODE).toBe('true');
121121
expect(result.CLAWDBOT_BIND_MODE).toBe('lan');
122122
});
@@ -128,7 +128,7 @@ describe('buildEnvVars', () => {
128128
TELEGRAM_BOT_TOKEN: 'tg',
129129
});
130130
const result = buildEnvVars(env);
131-
131+
132132
expect(result).toEqual({
133133
ANTHROPIC_API_KEY: 'sk-key',
134134
CLAWDBOT_GATEWAY_TOKEN: 'token',

src/gateway/env.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function isValidGatewayUrl(url: string): boolean {
1919

2020
/**
2121
* Build environment variables to pass to the Moltbot container process
22-
*
22+
*
2323
* @param env - Worker environment bindings
2424
* @returns Environment variables record
2525
*/
@@ -74,9 +74,10 @@ export function buildEnvVars(env: MoltbotEnv): Record<string, string> {
7474
} else if (env.ANTHROPIC_BASE_URL) {
7575
envVars.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL;
7676
}
77-
// Pass explicit provider type and model overrides to container
77+
// Pass explicit provider type, model, and API format overrides to container
7878
if (env.AI_GATEWAY_PROVIDER) envVars.AI_GATEWAY_PROVIDER = env.AI_GATEWAY_PROVIDER;
7979
if (env.AI_GATEWAY_MODEL) envVars.AI_GATEWAY_MODEL = env.AI_GATEWAY_MODEL;
80+
if (env.AI_GATEWAY_API_FORMAT) envVars.AI_GATEWAY_API_FORMAT = env.AI_GATEWAY_API_FORMAT;
8081
// Map MOLTBOT_GATEWAY_TOKEN to CLAWDBOT_GATEWAY_TOKEN (container expects this name)
8182
if (env.MOLTBOT_GATEWAY_TOKEN) envVars.CLAWDBOT_GATEWAY_TOKEN = env.MOLTBOT_GATEWAY_TOKEN;
8283
if (env.DEV_MODE) envVars.CLAWDBOT_DEV_MODE = env.DEV_MODE; // Pass DEV_MODE as CLAWDBOT_DEV_MODE to container

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface MoltbotEnv {
1212
AI_GATEWAY_BASE_URL?: string; // AI Gateway URL (e.g., https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/anthropic)
1313
AI_GATEWAY_PROVIDER?: 'openai' | 'anthropic'; // Explicit provider type override
1414
AI_GATEWAY_MODEL?: string; // Custom model name override
15+
AI_GATEWAY_API_FORMAT?: 'openai-completions' | 'openai-responses'; // OpenAI API format (default: openai-completions)
1516
// Legacy direct provider configuration (fallback)
1617
ANTHROPIC_API_KEY?: string;
1718
ANTHROPIC_BASE_URL?: string;

start-moltbot.sh

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,30 +39,30 @@ mkdir -p "$CONFIG_DIR"
3939
should_restore_from_r2() {
4040
local R2_SYNC_FILE="$BACKUP_DIR/.last-sync"
4141
local LOCAL_SYNC_FILE="$CONFIG_DIR/.last-sync"
42-
42+
4343
# If no R2 sync timestamp, don't restore
4444
if [ ! -f "$R2_SYNC_FILE" ]; then
4545
echo "No R2 sync timestamp found, skipping restore"
4646
return 1
4747
fi
48-
48+
4949
# If no local sync timestamp, restore from R2
5050
if [ ! -f "$LOCAL_SYNC_FILE" ]; then
5151
echo "No local sync timestamp, will restore from R2"
5252
return 0
5353
fi
54-
54+
5555
# Compare timestamps
5656
R2_TIME=$(cat "$R2_SYNC_FILE" 2>/dev/null)
5757
LOCAL_TIME=$(cat "$LOCAL_SYNC_FILE" 2>/dev/null)
58-
58+
5959
echo "R2 last sync: $R2_TIME"
6060
echo "Local last sync: $LOCAL_TIME"
61-
61+
6262
# Convert to epoch seconds for comparison
6363
R2_EPOCH=$(date -d "$R2_TIME" +%s 2>/dev/null || echo "0")
6464
LOCAL_EPOCH=$(date -d "$LOCAL_TIME" +%s 2>/dev/null || echo "0")
65-
65+
6666
if [ "$R2_EPOCH" -gt "$LOCAL_EPOCH" ]; then
6767
echo "R2 backup is newer, will restore"
6868
return 0
@@ -223,23 +223,27 @@ if (isOpenAI) {
223223
console.log('Configuring OpenAI provider with base URL:', baseUrl);
224224
config.models = config.models || {};
225225
config.models.providers = config.models.providers || {};
226-
226+
227227
// Use custom model if specified, otherwise use defaults
228228
const defaultModels = [
229229
{ id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200000 },
230230
{ id: 'gpt-5', name: 'GPT-5', contextWindow: 200000 },
231231
{ id: 'gpt-4.5-preview', name: 'GPT-4.5 Preview', contextWindow: 128000 },
232232
];
233-
const models = customModel
233+
const models = customModel
234234
? [{ id: customModel, name: customModel, contextWindow: 200000 }, ...defaultModels]
235235
: defaultModels;
236236
const primaryModel = customModel || 'gpt-5.2';
237-
237+
238238
config.models.providers.openai = {
239239
baseUrl: baseUrl,
240-
api: 'openai-responses',
240+
api: process.env.AI_GATEWAY_API_FORMAT || 'openai-completions',
241241
models: models
242242
};
243+
// Include API key in provider config if set (required when using custom baseUrl)
244+
if (process.env.OPENAI_API_KEY) {
245+
config.models.providers.openai.apiKey = process.env.OPENAI_API_KEY;
246+
}
243247
// Add models to the allowlist so they appear in /models
244248
config.agents.defaults.models = config.agents.defaults.models || {};
245249
if (customModel) {

0 commit comments

Comments
 (0)