Support Nginx-authenticated OpenClaw and fix workflow retry start context

This commit is contained in:
2026-02-27 11:42:50 -05:00
parent 7c3836068f
commit 73d9ccafd6
4 changed files with 114 additions and 12 deletions

View File

@@ -88,6 +88,12 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
OPENCLAW_BASE_URL=http://localhost:4000
OPENCLAW_API_KEY=your_key
OPENCLAW_MODEL=zeroclaw
OPENCLAW_AUTH_MODE=bearer
# for OPENCLAW_AUTH_MODE=basic
# OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user
# OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password
# optional: forward API key in a custom header when using basic/none auth
# OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
WORKFLOW_TARGET_WORLD=local
@@ -95,7 +101,19 @@ WORKFLOW_LOCAL_DATA_DIR=.workflow-data
WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100
```
If OpenClaw is unset, the app uses local fallback analysis so task workflows still run.
If OpenClaw is unset or invalidly configured, the app uses local fallback analysis so task workflows still run.
For OpenClaw behind Nginx Basic Auth, use:
```env
OPENCLAW_BASE_URL=https://your-nginx-host
OPENCLAW_AUTH_MODE=basic
OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user
OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password
# optional if upstream still needs an API key in a non-Authorization header
OPENCLAW_API_KEY=your_key
OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key
```
## API surface

View File

@@ -28,7 +28,7 @@ export async function runTaskWorkflow(taskId: string) {
if (nextState.shouldRetry) {
await sleep('1200ms');
await start(runTaskWorkflow, [task.id]);
await restartTaskWorkflowStep(task.id);
}
}
}
@@ -52,3 +52,8 @@ async function markTaskFailureStep(taskId: string, reason: string) {
'use step';
return await markTaskFailure(taskId, reason);
}
async function restartTaskWorkflowStep(taskId: string) {
'use step';
await start(runTaskWorkflow, [taskId]);
}

View File

@@ -21,6 +21,10 @@ services:
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
OPENCLAW_AUTH_MODE: ${OPENCLAW_AUTH_MODE:-bearer}
OPENCLAW_BASIC_AUTH_USERNAME: ${OPENCLAW_BASIC_AUTH_USERNAME:-}
OPENCLAW_BASIC_AUTH_PASSWORD: ${OPENCLAW_BASIC_AUTH_PASSWORD:-}
OPENCLAW_API_KEY_HEADER: ${OPENCLAW_API_KEY_HEADER:-}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>}
WORKFLOW_TARGET_WORLD: local
WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data}

View File

@@ -6,6 +6,18 @@ type ChatCompletionResponse = {
}>;
};
type OpenClawAuthMode = 'bearer' | 'basic' | 'none';
type OpenClawConfig = {
baseUrl?: string;
apiKey?: string;
model: string;
authMode: OpenClawAuthMode;
basicAuthUsername?: string;
basicAuthPassword?: string;
apiKeyHeader?: string;
};
function envValue(name: string) {
const value = process.env[name];
if (!value) {
@@ -17,12 +29,73 @@ function envValue(name: string) {
}
const DEFAULT_MODEL = 'zeroclaw';
const DEFAULT_AUTH_MODE: OpenClawAuthMode = 'bearer';
function parseAuthMode(value: string | undefined): OpenClawAuthMode {
const normalized = value?.trim().toLowerCase();
if (normalized === 'basic' || normalized === 'none') {
return normalized;
}
return DEFAULT_AUTH_MODE;
}
function hasSupportedProtocol(url: string) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function buildCompletionsUrl(baseUrl: string) {
if (!hasSupportedProtocol(baseUrl)) {
return undefined;
}
const withSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL('v1/chat/completions', withSlash).toString();
}
function hasRequiredAuth(config: OpenClawConfig) {
if (config.authMode === 'none') {
return true;
}
if (config.authMode === 'basic') {
return Boolean(config.basicAuthUsername && config.basicAuthPassword);
}
return Boolean(config.apiKey);
}
function buildAuthHeaders(config: OpenClawConfig) {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (config.authMode === 'basic' && config.basicAuthUsername && config.basicAuthPassword) {
const credentials = Buffer
.from(`${config.basicAuthUsername}:${config.basicAuthPassword}`, 'utf8')
.toString('base64');
headers.Authorization = `Basic ${credentials}`;
} else if (config.authMode === 'bearer' && config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
if (config.apiKey && config.apiKeyHeader) {
headers[config.apiKeyHeader] = config.apiKey;
}
return headers;
}
function fallbackResponse(prompt: string) {
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
return [
'OpenClaw fallback mode is active (missing OPENCLAW_BASE_URL or OPENCLAW_API_KEY).',
'OpenClaw fallback mode is active (configuration is missing or invalid).',
'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.',
'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.',
`Context digest: ${clipped}`
@@ -33,19 +106,24 @@ export function getOpenClawConfig() {
return {
baseUrl: envValue('OPENCLAW_BASE_URL'),
apiKey: envValue('OPENCLAW_API_KEY'),
model: envValue('OPENCLAW_MODEL') ?? DEFAULT_MODEL
};
model: envValue('OPENCLAW_MODEL') ?? DEFAULT_MODEL,
authMode: parseAuthMode(envValue('OPENCLAW_AUTH_MODE')),
basicAuthUsername: envValue('OPENCLAW_BASIC_AUTH_USERNAME'),
basicAuthPassword: envValue('OPENCLAW_BASIC_AUTH_PASSWORD'),
apiKeyHeader: envValue('OPENCLAW_API_KEY_HEADER')
} satisfies OpenClawConfig;
}
export function isOpenClawConfigured() {
const config = getOpenClawConfig();
return Boolean(config.baseUrl && config.apiKey);
return Boolean(config.baseUrl && hasSupportedProtocol(config.baseUrl) && hasRequiredAuth(config));
}
export async function runOpenClawAnalysis(prompt: string, systemPrompt?: string) {
const config = getOpenClawConfig();
const endpoint = config.baseUrl ? buildCompletionsUrl(config.baseUrl) : undefined;
if (!config.baseUrl || !config.apiKey) {
if (!endpoint || !hasRequiredAuth(config)) {
return {
provider: 'local-fallback',
model: config.model,
@@ -53,12 +131,9 @@ export async function runOpenClawAnalysis(prompt: string, systemPrompt?: string)
};
}
const response = await fetch(`${config.baseUrl}/v1/chat/completions`, {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`
},
headers: buildAuthHeaders(config),
body: JSON.stringify({
model: config.model,
temperature: 0.2,