Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
queries: +security-extended,security-and-quality
config-file: .github/codeql/codeql-config.yml

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: /language:javascript-typescript
16 changes: 8 additions & 8 deletions .github/workflows/dashboard-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
CODECOV_TOKEN_PRESENT: ${{ secrets.CODECOV_TOKEN != '' }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version

Expand All @@ -47,7 +47,7 @@ jobs:
run: bun run test:frontend:coverage

- name: Upload frontend coverage artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: frontend-coverage-lcov
Expand All @@ -56,7 +56,7 @@ jobs:
retention-days: 14

- name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ env.CODECOV_TOKEN_PRESENT == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
Expand All @@ -75,12 +75,12 @@ jobs:
CODECOV_TOKEN_PRESENT: ${{ secrets.CODECOV_TOKEN != '' }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version

Expand All @@ -98,7 +98,7 @@ jobs:
run: test ! -f backend/coverage/lcov.info || perl -0pi -e 's{^SF:src/}{SF:backend/src/}mg' backend/coverage/lcov.info

- name: Upload backend coverage artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: backend-coverage-lcov
Expand All @@ -107,7 +107,7 @@ jobs:
retention-days: 14

- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ env.CODECOV_TOKEN_PRESENT == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
Expand Down
38 changes: 30 additions & 8 deletions backend/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ type DatabaseSync = Database;

const SQLITE_BUSY_TIMEOUT_MS = 5000;
const SQLITE_JOURNAL_MODE_RETRY_DELAY_MS = 25;
const TEST_FILE_ARGUMENT_PATTERN = /(?:^|[\\/])[^\\/]+\.test\.[cm]?[jt]sx?$/u;

/**
* Detects a Dashboard test process independently of mutable application mode.
* A test may exercise production policy by changing NODE_ENV, but that must
* never disable the database isolation boundary for the surrounding process.
* @returns Whether the current process is executing a Dashboard test file.
*/
export function isDashboardTestProcess(
environment: NodeJS.ProcessEnv = process.env,
arguments_: readonly string[] = process.argv
): boolean {
return (
environment.NODE_ENV === "test" ||
arguments_.some((argument) => TEST_FILE_ARGUMENT_PATTERN.test(argument))
);
}

/**
* Converts optional values to SQLite NULL-compatible bindings.
Expand All @@ -31,11 +48,16 @@ function resolveDatabasePath(): {
configuredDatabasePath: string | undefined;
databasePath: string;
} {
const projectPaths = resolveDashboardProjectPathsForRuntime();
const configuredDatabasePath = resolveDashboardRuntimePath(
projectPaths?.productionDatabasePath,
process.env.MIRA_DASHBOARD_DB_PATH
);
const isTestProcess = isDashboardTestProcess();
const projectPaths = isTestProcess
? undefined
: resolveDashboardProjectPathsForRuntime();
const configuredDatabasePath = isTestProcess
? process.env.MIRA_DASHBOARD_DB_PATH?.trim() || undefined
: resolveDashboardRuntimePath(
projectPaths?.productionDatabasePath,
process.env.MIRA_DASHBOARD_DB_PATH
);
return {
configuredDatabasePath,
databasePath: configuredDatabasePath
Expand Down Expand Up @@ -74,7 +96,7 @@ function assertTestDatabasePath(
databasePath: string,
configuredDatabasePath: string | undefined
): void {
if (process.env.NODE_ENV !== "test") {
if (!isDashboardTestProcess()) {
return;
}
const configuredTemporaryRoot = path.resolve(os.tmpdir());
Expand Down Expand Up @@ -259,7 +281,7 @@ function instrumentStatement<T extends object>(statement: T): T {
}

function currentDatabase(): DatabaseSync {
if (process.env.NODE_ENV !== "test" && activeDatabaseState.database !== undefined) {
if (!isDashboardTestProcess() && activeDatabaseState.database !== undefined) {
return activeDatabaseState.database;
}
const { databasePath } = resolveDatabasePath();
Expand All @@ -283,7 +305,7 @@ function closeActiveDatabase(): void {
}

export function closeDatabaseForTests(): void {
if (process.env.NODE_ENV !== "test") {
if (!isDashboardTestProcess()) {
throw new Error("closeDatabaseForTests can only be used in test");
}
closeActiveDatabase();
Expand Down
2 changes: 1 addition & 1 deletion backend/src/development/developmentOpenClaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const SENSITIVE_WORKSPACE_FILE_SUFFIXES = [
const SENSITIVE_WORKSPACE_PATH_SEGMENT =
/(?:^|[._-])(?:api[._-]?keys?|credentials?|passwords?|private[._-]?keys?|secrets?|service[._-]?accounts?|tokens?)(?:$|[._-]|\d)/iu;
const SENSITIVE_AGENT_CONFIG_KEY =
/(?:^|[._-])(?:api[._-]?keys?|credentials?|passwords?|secrets?|tokens?)(?:$|[._-]|\d)/iu;
/(?:^|[._-])(?:access[._-]?keys?|api[._-]?keys?|authorization|cookies?|credentials?|keys?|passphrases?|passwords?|private[._-]?keys?|raw|seeds?|secrets?|tokens?|webhook[._-]?urls?)(?:$|[._-]|\d)/iu;

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

Expand Down
135 changes: 111 additions & 24 deletions backend/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ChatRuntimeMetrics, GatewayMetrics } from "../../contracts/metrics
import type { Session } from "../../contracts/sessions.ts";
import type { DashboardSettingsResponse } from "../../contracts/settings.ts";
import {
MAX_DASHBOARD_SOCKET_REQUEST_TIMEOUT_MS,
parseDashboardSocketRequest,
readSessionsResponseContainer,
} from "../../contracts/socket.ts";
Expand All @@ -29,6 +30,7 @@ import {
} from "./lib/openclawGatewayClient.ts";
import { createStructuredLogger } from "./lib/structuredLogger.ts";
import {
boundedTimestamp,
nonEmptyEnvironmentFallback,
stringFallback,
unknownArray,
Expand All @@ -43,6 +45,8 @@ import {
} from "./services/logStreams.ts";

const logger = createStructuredLogger("gateway");
const DEFAULT_FORWARDED_GATEWAY_REQUEST_TIMEOUT_MS = 30_000;
const SESSION_COMPACT_REQUEST_TIMEOUT_MS = 15 * 60_000;

function validateOpenClawRoot(rootPath: string, environmentName: string): string {
const resolved = Path.resolve(rootPath);
Expand Down Expand Up @@ -746,6 +750,94 @@ function isCurrentGatewayClient(expectedClient: OpenClawGatewayClientInstance):
return gatewayState.client === expectedClient;
}

function gatewayString(record: Record<string, unknown>, key: string): string | undefined {
return typeof record[key] === "string" ? record[key] : undefined;
}

function gatewayFiniteNumber(
record: Record<string, unknown>,
key: string
): number | undefined {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

function gatewayBoolean(
record: Record<string, unknown>,
key: string
): boolean | undefined {
return typeof record[key] === "boolean" ? record[key] : undefined;
}

function gatewaySessionFromRecord(record: Record<string, unknown>): GatewaySession {
const thinkingLevels = Array.isArray(record.thinkingLevels)
? record.thinkingLevels.slice(0, 100).flatMap((value) => {
const level = asRecord(value);
const id = level ? gatewayString(level, "id")?.trim() : undefined;
const label = level ? gatewayString(level, "label")?.trim() : undefined;
return id && label ? [{ id, label }] : [];
})
: undefined;
const thinkingOptions = Array.isArray(record.thinkingOptions)
? record.thinkingOptions
.slice(0, 100)
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
: undefined;
const fastMode =
typeof record.fastMode === "boolean" || record.fastMode === "auto"
? record.fastMode
: undefined;
const effectiveFastMode =
typeof record.effectiveFastMode === "boolean" ||
record.effectiveFastMode === "auto"
? record.effectiveFastMode
: undefined;
const endedAt =
typeof record.endedAt === "string" ||
(typeof record.endedAt === "number" && Number.isFinite(record.endedAt))
? record.endedAt
: undefined;
const startedAt =
typeof record.startedAt === "string" ||
(typeof record.startedAt === "number" && Number.isFinite(record.startedAt))
? record.startedAt
: undefined;
return {
activeRunId: gatewayString(record, "activeRunId"),
channel: gatewayString(record, "channel"),
contextTokens: gatewayFiniteNumber(record, "contextTokens"),
currentRunId: gatewayString(record, "currentRunId"),
displayName: gatewayString(record, "displayName"),
effectiveFastMode,
elevatedLevel: gatewayString(record, "elevatedLevel"),
endedAt,
fastMode,
hasActiveRun: gatewayBoolean(record, "hasActiveRun"),
isRunning: gatewayBoolean(record, "isRunning"),
key: gatewayString(record, "key"),
kind: gatewayString(record, "kind"),
label: gatewayString(record, "label"),
model: gatewayString(record, "model"),
modelProvider: gatewayString(record, "modelProvider"),
reasoningLevel: gatewayString(record, "reasoningLevel"),
runId: gatewayString(record, "runId"),
running: gatewayBoolean(record, "running"),
sessionId: gatewayString(record, "sessionId"),
startedAt,
status: gatewayString(record, "status"),
thinkingDefault: gatewayString(record, "thinkingDefault"),
thinkingLevel: gatewayString(record, "thinkingLevel"),
thinkingLevels,
thinkingOptions,
totalTokens: gatewayFiniteNumber(record, "totalTokens"),
totalTokensFresh: gatewayBoolean(record, "totalTokensFresh"),
updatedAt: boundedTimestamp(record.updatedAt),
verboseLevel: gatewayString(record, "verboseLevel"),
};
}

/**
* Normalizes one raw Gateway sessions.list response for Dashboard consumers.
* @param response Raw Gateway response.
Expand All @@ -754,7 +846,10 @@ function isCurrentGatewayClient(expectedClient: OpenClawGatewayClientInstance):
export function normalizeGatewaySessionList(response: unknown): Session[] {
const container = readSessionsResponseContainer(response);
const sessions = container?.sessions ?? [];
const defaults = asRecord(container?.defaults) as GatewaySession | undefined;
const defaultsRecord = asRecord(container?.defaults);
const defaults = defaultsRecord
? gatewaySessionFromRecord(defaultsRecord)
: undefined;
return sessions
.map((entry) => asRecord(entry))
.filter(
Expand All @@ -763,25 +858,13 @@ export function normalizeGatewaySessionList(response: unknown): Session[] {
(entry.sessionId === undefined || typeof entry.sessionId === "string") &&
(entry.key === undefined || typeof entry.key === "string") &&
(entry.updatedAt === undefined ||
(typeof entry.updatedAt === "number" &&
Number.isFinite(entry.updatedAt)) ||
(typeof entry.updatedAt === "string" &&
!Number.isNaN(Date.parse(entry.updatedAt)))) &&
boundedTimestamp(entry.updatedAt) !== undefined) &&
(stringFallback(entry.sessionId).trim() ||
stringFallback(entry.key).trim()) !== ""
)
.map((entry) => {
const session = entry as GatewaySession & {
activeRunId?: string | null | undefined;
currentRunId?: string | null | undefined;
endedAt?: string | number | null | undefined;
runId?: string | null | undefined;
startedAt?: string | number | null | undefined;
};
const updatedAt =
typeof entry.updatedAt === "string"
? Date.parse(entry.updatedAt)
: entry.updatedAt;
const session = gatewaySessionFromRecord(entry);
const updatedAt = boundedTimestamp(entry.updatedAt);
const shouldApplyDefaults =
(!session.model || session.model === defaults?.model) &&
(!session.modelProvider ||
Expand Down Expand Up @@ -820,13 +903,12 @@ export function normalizeGatewaySessionList(response: unknown): Session[] {
session.effectiveFastMode ??
matchingDefaults?.effectiveFastMode ??
matchingDefaults?.fastMode,
activeRunId:
session.activeRunId === null ? undefined : session.activeRunId,
activeRunId: entry.activeRunId === null ? undefined : session.activeRunId,
currentRunId:
session.currentRunId === null ? undefined : session.currentRunId,
endedAt: session.endedAt === null ? undefined : session.endedAt,
runId: session.runId === null ? undefined : session.runId,
startedAt: session.startedAt === null ? undefined : session.startedAt,
entry.currentRunId === null ? undefined : session.currentRunId,
endedAt: entry.endedAt === null ? undefined : session.endedAt,
runId: entry.runId === null ? undefined : session.runId,
startedAt: entry.startedAt === null ? undefined : session.startedAt,
updatedAt:
typeof updatedAt === "number" && Number.isFinite(updatedAt)
? updatedAt
Expand Down Expand Up @@ -1209,8 +1291,13 @@ async function forwardRequest(
}
const activeGateway = gatewayState.client;
const requestOptions = {
timeoutMs,
shouldWaitIndefinitely: method === "sessions.compact",
timeoutMs:
method === "sessions.compact"
? SESSION_COMPACT_REQUEST_TIMEOUT_MS
: Math.min(
timeoutMs ?? DEFAULT_FORWARDED_GATEWAY_REQUEST_TIMEOUT_MS,
MAX_DASHBOARD_SOCKET_REQUEST_TIMEOUT_MS
),
};

if (clientWs && clientId) {
Expand Down
18 changes: 18 additions & 0 deletions backend/src/gatewayToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getPersistedGatewayToken } from "./auth.ts";

/**
* Resolves the Gateway token consistently for every Dashboard integration.
* Runtime environment configuration takes precedence over the encrypted DB
* fallback, matching the production server startup contract.
* @returns Configured Gateway token, or undefined when none is available.
*/
export function resolveGatewayToken(
environment: NodeJS.ProcessEnv = process.env,
persistedToken?: () => string | undefined
): string | undefined {
return (
environment.OPENCLAW_GATEWAY_TOKEN?.trim() ||
(persistedToken ?? getPersistedGatewayToken)()?.trim() ||
undefined
);
}
2 changes: 0 additions & 2 deletions backend/src/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ export function evaluateReadiness(signals: ReadinessSignals): DashboardReadiness
database: signals.database,
frontend: { ready: signals.frontendReady },
release: {
backendCommit: signals.release.backendCommit,
frontendCommit: signals.release.frontendCommit,
...(signals.release.issue && { issue: signals.release.issue }),
...(signals.release.manifestFormatVersion !== undefined && {
manifestFormatVersion: signals.release.manifestFormatVersion,
Expand Down
Loading