Skip to content
Merged
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
10 changes: 7 additions & 3 deletions dashboard/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,15 @@ export async function fetchProxyHealth(): Promise<ProxyHealthResponse> {
}

export async function fetchDynamicModels(baseUrl: string, apiKey?: string): Promise<string[]> {
let url = `${API_BASE}/models/discover?baseUrl=${encodeURIComponent(baseUrl)}`;
const body: { baseUrl: string; apiKey?: string } = { baseUrl };
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
body.apiKey = apiKey;
}
const response = await fetch(url, { headers: getAuthHeaders() });
const response = await fetch(`${API_BASE}/models/discover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(body),
});
const data = await handleResponse<{ models: string[] }>(response);
return data.models;
}
Expand Down
19 changes: 13 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions proxy/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions proxy/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ export function registerProxyRoutes(
keyId = keySelection.keyId;
}

const FORWARDED_HEADERS = ['content-type', 'accept', 'user-agent'];
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
for (const name of FORWARDED_HEADERS) {
const value = req.headers[name];
if (typeof value === 'string') {
headers[key] = value;
headers[name] = value;
} else if (Array.isArray(value)) {
headers[key] = value[0];
headers[name] = value[0];
}
}

Expand Down
63 changes: 22 additions & 41 deletions src/bots/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { mkdirSync, writeFileSync, chmodSync, chownSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { validateHostname } from '../secrets/manager.js';

/**
* Try to change file ownership, but gracefully skip if not permitted.
Expand All @@ -15,14 +16,19 @@ function tryChown(path: string, uid: number, gid: number): void {
try {
chownSync(path, uid, gid);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
// Not running as root - skip chown (acceptable in dev/CI)
return;
}
if ((err as NodeJS.ErrnoException).code === 'EPERM') return;
throw err;
}
}

const OPENCLAW_UID = 1000;
const OPENCLAW_GID = 1000;

function setOwnership(path: string, mode: number): void {
chmodSync(path, mode);
tryChown(path, OPENCLAW_UID, OPENCLAW_GID);
}

export interface BotPersona {
name: string;
identity: string;
Expand Down Expand Up @@ -194,51 +200,25 @@ function generateIdentityMd(persona: BotPersona): string {
export function createBotWorkspace(dataDir: string, config: BotWorkspaceConfig): void {
const botDir = join(dataDir, 'bots', config.botHostname);
const workspaceDir = join(botDir, 'workspace');
const agentDir = join(botDir, 'agents', 'main', 'agent');
const sessionsDir = join(botDir, 'agents', 'main', 'sessions');
const sandboxDir = join(botDir, 'sandbox');

// Create directories with permissions for bot container (runs as uid 1000)
mkdirSync(botDir, { recursive: true, mode: 0o777 });
mkdirSync(workspaceDir, { recursive: true, mode: 0o777 });
// Ensure parent dir has correct permissions (recursive: true doesn't set mode on existing dirs)
chmodSync(botDir, 0o777);
chmodSync(workspaceDir, 0o777);
for (const dir of [botDir, workspaceDir, agentDir, sessionsDir, sandboxDir]) {
mkdirSync(dir, { recursive: true, mode: 0o755 });
setOwnership(dir, 0o755);
}

// Write openclaw.json at root of bot directory (OPENCLAW_STATE_DIR)
const openclawConfig = generateOpenclawConfig(config);
const configPath = join(botDir, 'openclaw.json');
writeFileSync(configPath, JSON.stringify(openclawConfig, null, 2));
chmodSync(configPath, 0o666);
writeFileSync(configPath, JSON.stringify(generateOpenclawConfig(config), null, 2));
setOwnership(configPath, 0o644);

// Write only persona files — OpenClaw's ensureAgentWorkspace() will create
// AGENTS.md, BOOTSTRAP.md, TOOLS.md, HEARTBEAT.md from its own templates
// (using writeFileIfMissing / wx flag, so our files won't be overwritten).
const soulPath = join(workspaceDir, 'SOUL.md');
const identityPath = join(workspaceDir, 'IDENTITY.md');
writeFileSync(soulPath, generateSoulMd(config.persona));
writeFileSync(identityPath, generateIdentityMd(config.persona));
chmodSync(soulPath, 0o666);
chmodSync(identityPath, 0o666);

// OpenClaw runs as uid 1000 (node user), so we need to set ownership
const OPENCLAW_UID = 1000;
const OPENCLAW_GID = 1000;

const agentDir = join(botDir, 'agents', 'main', 'agent');
mkdirSync(agentDir, { recursive: true, mode: 0o777 });
chmodSync(agentDir, 0o777);
tryChown(agentDir, OPENCLAW_UID, OPENCLAW_GID);

// Pre-create sessions directory for OpenClaw runtime use
const sessionsDir = join(botDir, 'agents', 'main', 'sessions');
mkdirSync(sessionsDir, { recursive: true, mode: 0o777 });
chmodSync(sessionsDir, 0o777);
tryChown(sessionsDir, OPENCLAW_UID, OPENCLAW_GID);

// Pre-create sandbox directory for OpenClaw code execution
// OpenClaw hardcodes /app/workspace for sandbox operations
const sandboxDir = join(botDir, 'sandbox');
mkdirSync(sandboxDir, { recursive: true, mode: 0o777 });
chmodSync(sandboxDir, 0o777);
tryChown(sandboxDir, OPENCLAW_UID, OPENCLAW_GID);
setOwnership(soulPath, 0o644);
setOwnership(identityPath, 0o644);
}

/**
Expand All @@ -260,6 +240,7 @@ export function getBotWorkspacePath(dataDir: string, hostname: string): string {
* @param hostname - Bot hostname
*/
export function deleteBotWorkspace(dataDir: string, hostname: string): void {
validateHostname(hostname);
const botDir = join(dataDir, 'bots', hostname);
rmSync(botDir, { recursive: true, force: true });
}
11 changes: 9 additions & 2 deletions src/secrets/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';

/** Hostname regex for validation - prevents directory traversal attacks */
const HOSTNAME_REGEX = /^[a-z0-9-]{1,64}$/;
const SECRET_NAME_REGEX = /^[A-Z0-9_]{1,64}$/;

/**
* Returns the root directory for secrets storage.
Expand Down Expand Up @@ -64,7 +64,10 @@ export function createBotSecretsDir(hostname: string): string {
export function writeSecret(hostname: string, name: string, value: string): void {
validateHostname(hostname);

// Ensure bot directory exists
if (!SECRET_NAME_REGEX.test(name)) {
throw new Error(`Invalid secret name: ${name}`);
}

const botDir = createBotSecretsDir(hostname);
const filePath = join(botDir, name);

Expand All @@ -82,6 +85,10 @@ export function writeSecret(hostname: string, name: string, value: string): void
export function readSecret(hostname: string, name: string): string | undefined {
validateHostname(hostname);

if (!SECRET_NAME_REGEX.test(name)) {
throw new Error(`Invalid secret name: ${name}`);
}

const secretsRoot = getSecretsRoot();
const filePath = join(secretsRoot, hostname, name);

Expand Down
Loading