Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 4 additions & 11 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,10 @@ Run the relevant checks locally when possible:
```bash
bun run lint:frontend
bun run lint:backend
bun run build
bun run test:coverage
```

Backend changes use the same checks from `backend/`:

```bash
cd backend
bun run lint:backend
bun run build
bun run test:coverage
bun run build:frontend
bun run build:backend
bun run test:frontend:coverage
bun run test:backend:coverage
```

Run focused tests while iterating, then run the applicable coverage suite before
Expand Down
8 changes: 4 additions & 4 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
<!-- Check everything you ran. Leave unchecked with a short reason if not applicable. -->

- [ ] Frontend lint: `bun run lint:frontend`
- [ ] Frontend build: `bun run build`
- [ ] Frontend tests/coverage: `bun run test:coverage`
- [ ] Frontend build: `bun run build:frontend`
- [ ] Frontend tests/coverage: `bun run test:frontend:coverage`
- [ ] Backend lint: `bun run lint:backend`
- [ ] Backend build: `bun run build` from `backend/`
- [ ] Backend tests/coverage: `bun run test:coverage` from `backend/`
- [ ] Backend build: `bun run build:backend`
- [ ] Backend tests/coverage: `bun run test:backend:coverage`
- [ ] Focused regression tests: <!-- command(s) -->
- [ ] Manual UI/API smoke check, if relevant

Expand Down
10 changes: 4 additions & 6 deletions .github/workflows/dashboard-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ jobs:
run: bun run lint:frontend

- name: Build frontend
run: bun run build
run: bun run build:frontend

- name: Test frontend coverage
run: bun run test:coverage
run: bun run test:frontend:coverage

- name: Upload frontend coverage artifact
uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -92,12 +92,10 @@ jobs:
run: bun run lint:backend

- name: Build backend
run: bun run build
working-directory: backend
run: bun run build:backend

- name: Test backend coverage
run: bun run test:coverage
working-directory: backend
run: bun run test:backend:coverage

- name: Prefix backend LCOV paths
if: always()
Expand Down
39 changes: 20 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,31 @@ bun install
Install backend dependencies separately:

```bash
cd backend
bun install
bun --cwd backend install
```

Run the frontend dev server:
Run the complete local dev stack:

```bash
bun run dev
```

Run the backend dev server from `backend/`:
For WebAuthn and access from another Tailscale device, use the HTTPS route:

```bash
bun run dev
bun run dev:remote
```

The backend scripts use Doppler (`rajohan` / `prd`) for runtime secrets. Do not commit `.environment` files, tokens, database dumps, or generated runtime state.
Both commands start frontend and backend hot reload, React Compiler, an isolated
Dashboard database/workspace snapshot, and a dev-only scheduler/worker. Dev
connects to the live OpenClaw Gateway, so chat and session changes can affect
production data. Production host, backup, config, cron, destructive session,
and PR actions remain blocked.

Only the Gateway token and production auth timing values are selected from
Doppler (`rajohan` / `prd`) at runtime. No secret values are stored in scripts
or tracked files. See [Local development](docs/development/local-dev.md) for
state paths, reset commands, and the trusted PR-dev flow.

## Verification commands

Expand All @@ -78,18 +86,10 @@ From the repo root:
bun run lint:frontend
bun run lint:backend
bun run build
bun run test
bun run test:coverage
bun run format:check
```

From `backend/`:

```bash
bun run lint:backend
bun run build
bun run test
bun run test:coverage
bun run test:frontend
bun run test:backend
bun run test:frontend:coverage
bun run test:backend:coverage
bun run format:check
```

Expand Down Expand Up @@ -119,7 +119,8 @@ CI and local verification.
restrictive storage modes, deploy/maintenance snapshots, and automated
restore checks.
- Frontend builds and the local frontend dev server use Bun's HTML bundler with Babel React Compiler and Bun Tailwind plugins.
- Dev server listens on all addresses so the dashboard can be reached over Tailscale when needed.
- Dev servers bind to loopback. `bun run dev:remote` publishes the frontend
through an explicit Tailscale Serve HTTPS route.
- Auth is enforced by the backend request policy for every API route except
`GET|HEAD /api/health/live`, `GET|HEAD /api/health/ready`,
`GET|HEAD /api/auth/bootstrap`,
Expand Down
16 changes: 7 additions & 9 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,18 @@
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"build": "bun node_modules/@typescript/native/bin/tsc --noEmit && bun scripts/build.ts",
"build:backend": "bun node_modules/@typescript/native/bin/tsc --noEmit && bun scripts/build.ts",
"db:preflight": "bun dist/databasePreflight.js",
"deploy:prepare": "bun run build && bun run db:preflight",
"deploy:prepare:backend": "bun run build:backend && bun run db:preflight",
"auth:reset-password": "MIRA_DASHBOARD_DB_PATH=${MIRA_DASHBOARD_DB_PATH:-/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db} NODE_ENV=production doppler run --config prd --project rajohan --preserve-env=MIRA_DASHBOARD_DB_PATH -- bun dist/resetDashboardPassword.js",
"start": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/serverStart.js",
"start:backend": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/serverStart.js",
"start:worker": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/workerStart.js",
"dev": "MIRA_DASHBOARD_DISABLE_SCHEDULER=1 doppler run --config prd --project rajohan -- bun --watch src/serverStart.ts",
"dev:worker": "doppler run --config prd --project rajohan -- bun --watch src/workerStart.ts",
"lint:backend": "eslint .",
"lint:backend:fix": "eslint . --fix",
"format": "prettier --write '**/*.{ts,js}'",
"format:check": "prettier --check '**/*.{ts,js}'",
"test": "bun test",
"test:coverage": "bun ../scripts/runCoverage.ts 85 src/"
"format:backend": "prettier --write '**/*.{ts,js}'",
"format:backend:check": "prettier --check '**/*.{ts,js}'",
"test:backend": "bun test",
"test:backend:coverage": "bun ../scripts/runCoverage.ts 85 src/"
},
"dependencies": {
"@simplewebauthn/server": "13.3.2",
Expand Down
228 changes: 228 additions & 0 deletions backend/src/development/developmentOpenClaw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import {
chmodSync,
cpSync,
lstatSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";

const MAX_OPENCLAW_CONFIG_BYTES = 2 * 1024 * 1024;
const OMITTED_WORKSPACE_DIRECTORIES = new Set([
".aws",
".azure",
".credentials",
".git",
".gnupg",
".secrets",
".ssh",
"credentials",
"secrets",
]);
const SAFE_ENVIRONMENT_TEMPLATE_SUFFIXES = new Set(["example", "sample", "template"]);
const SENSITIVE_WORKSPACE_FILE_NAMES = new Set([
".env",
"credentials.json",
"id_ed25519",
"id_rsa",
"private.key",
"secrets.json",
"secrets.yaml",
"secrets.yml",
]);
const SENSITIVE_AGENT_CONFIG_KEY =
/(?:^|[._-])(?:api[._-]?key|credential|credentials|password|secret|secrets|token)(?:$|[._-])/iu;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export type DevelopmentWorkspaceState = "copied" | "empty" | "reused";

export interface DevelopmentOpenClawSnapshotConfig {
configSource?: string;
openClawHome: string;
workspaceSource?: string;
}

function isRealDirectory(directoryPath: string): boolean {
try {
const stat = lstatSync(directoryPath);
return stat.isDirectory() && !stat.isSymbolicLink();
} catch {
return false;
}
}

function isRealRegularFile(filePath: string): boolean {
try {
const stat = lstatSync(filePath);
return stat.isFile() && !stat.isSymbolicLink();
} catch {
return false;
}
}

function ensurePrivateDirectory(directoryPath: string): void {
mkdirSync(directoryPath, { mode: 0o700, recursive: true });
if (!isRealDirectory(directoryPath)) {
throw new Error(`Development path must be a real directory: ${directoryPath}`);
}
chmodSync(directoryPath, 0o700);
}

function writePrivateJson(filePath: string, value: unknown): void {
const stagingPath = `${filePath}.partial-${Bun.randomUUIDv7()}`;
try {
writeFileSync(stagingPath, `${JSON.stringify(value, undefined, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
renameSync(stagingPath, filePath);
} catch (error) {
rmSync(stagingPath, { force: true });
throw error;
}
}

function defaultAgentsConfig(openClawHome: string) {
return {
defaults: {
model: { primary: "unknown" },
models: {},
workspace: path.join(openClawHome, "workspace"),
},
list: [{ default: true, id: "main" }],
};
}

function sanitizedAgentConfigValue(value: unknown, openClawHome: string): unknown {
if (Array.isArray(value)) {
return value.map((item) => sanitizedAgentConfigValue(item, openClawHome));
}
if (!value || typeof value !== "object") {
return value;
}
const sanitized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.replaceAll(/([a-z\d])([A-Z])/gu, "$1_$2");
if (SENSITIVE_AGENT_CONFIG_KEY.test(normalizedKey)) {
continue;
}
sanitized[key] =
key === "workspace"
? path.join(openClawHome, "workspace")
: sanitizedAgentConfigValue(child, openClawHome);
}
return sanitized;
}

function snapshotAgentsConfig(config: DevelopmentOpenClawSnapshotConfig): unknown {
if (!config.configSource) {
return defaultAgentsConfig(config.openClawHome);
}
if (!isRealRegularFile(config.configSource)) {
throw new Error(
`MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE must be a real regular file: ${config.configSource}`
);
}
const stat = lstatSync(config.configSource);
if (stat.size > MAX_OPENCLAW_CONFIG_BYTES) {
throw new Error("Development OpenClaw config source is too large");
}
const parsed = Bun.JSON5.parse(readFileSync(config.configSource, "utf8")) as {
Comment thread
mira-2026 marked this conversation as resolved.
Fixed
agents?: unknown;
};
if (!parsed.agents || typeof parsed.agents !== "object") {
return defaultAgentsConfig(config.openClawHome);
}
return sanitizedAgentConfigValue(parsed.agents, config.openClawHome);
}

function isEnvironmentTemplate(fileName: string): boolean {
if (!fileName.startsWith(".env.")) return false;
const suffix = fileName.slice(".env.".length).toLowerCase();
return SAFE_ENVIRONMENT_TEMPLATE_SUFFIXES.has(suffix);
}

function shouldCopyWorkspacePath(sourceRoot: string, candidate: string): boolean {
const relativePath = path.relative(sourceRoot, candidate);
if (!relativePath) return true;
const segments = relativePath.split(path.sep);
if (
segments.some((segment) =>
OMITTED_WORKSPACE_DIRECTORIES.has(segment.toLowerCase())
)
) {
return false;
}
const fileName = segments.at(-1)?.toLowerCase() || "";
return !(
SENSITIVE_WORKSPACE_FILE_NAMES.has(fileName) ||
(fileName.startsWith(".env.") && !isEnvironmentTemplate(fileName)) ||
fileName.endsWith(".token") ||
fileName.endsWith(".secret")
Comment thread
mira-2026 marked this conversation as resolved.
);
}

function copyWorkspaceSnapshot(sourcePath: string, targetPath: string): void {
if (!isRealDirectory(sourcePath)) {
throw new Error(
`MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE must be a real directory: ${sourcePath}`
);
}
if (path.resolve(sourcePath) === path.resolve(targetPath)) {
throw new Error("Development workspace source and target must be distinct");
}
const stagingPath = `${targetPath}.partial-${Bun.randomUUIDv7()}`;
try {
cpSync(sourcePath, stagingPath, {
errorOnExist: true,
filter(source) {
const stat = lstatSync(source);
if (stat.isSymbolicLink()) {
throw new Error(
`Development workspace source contains a symlink: ${source}`
);
}
return shouldCopyWorkspacePath(sourcePath, source);
},
force: false,
preserveTimestamps: true,
recursive: true,
});
renameSync(stagingPath, targetPath);
} catch (error) {
rmSync(stagingPath, { force: true, recursive: true });
throw error;
}
}

/** Creates a writable workspace snapshot and a secret-free agent config for dev. */
export function prepareDevelopmentOpenClawSnapshot(
config: DevelopmentOpenClawSnapshotConfig
): DevelopmentWorkspaceState {
ensurePrivateDirectory(config.openClawHome);
ensurePrivateDirectory(path.join(config.openClawHome, "agents"));
const targetWorkspace = path.join(config.openClawHome, "workspace");
let workspaceState: DevelopmentWorkspaceState;
if (isRealDirectory(targetWorkspace)) {
workspaceState = "reused";
} else if (config.workspaceSource) {
copyWorkspaceSnapshot(config.workspaceSource, targetWorkspace);
workspaceState = "copied";
} else {
ensurePrivateDirectory(targetWorkspace);
workspaceState = "empty";
}
chmodSync(targetWorkspace, 0o700);

const visibleConfigPath = path.join(config.openClawHome, "openclaw.json");
if (isRealRegularFile(visibleConfigPath)) {
chmodSync(visibleConfigPath, 0o600);
} else {
writePrivateJson(visibleConfigPath, {
agents: snapshotAgentsConfig(config),
});
}
return workspaceState;
}
Loading