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
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