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
23 changes: 22 additions & 1 deletion scripts/hermes-dashboard-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,26 @@ const WS_HANDSHAKE_TIMEOUT_MS = envMs("HERMES_DASH_WS_TIMEOUT_MS", 15_000);
// the upgrade handshake complete and stand its timeout down.
const HEADER_TERMINATOR = "\r\n\r\n";

// Prepare a string that came off the wire for a log line. Two rules, both about
// the shape of the record rather than its content: one value stays one line, and
// the record's size does not follow its input's. The same rules as
// src/lib/log-safe.ts, restated here because this script is CommonJS and runs as
// its own process, so it cannot import the TypeScript module.
//
// \p{Cc} is the Unicode "control" category: the C0 range, DEL, and C1. Replaced
// rather than stripped, so two values differing only in control characters do
// not collapse into the same line. U+FFFD is the conventional stand-in.
const LOG_CONTROL_CHARACTERS = /\p{Cc}/gu;
const LOG_FIELD_MAX_LENGTH = 200;
function logSafe(value, maxLength = LOG_FIELD_MAX_LENGTH) {
const s = String(value);
if (s.length <= maxLength) return s.replace(LOG_CONTROL_CHARACTERS, "�");
// Cut first, then sanitise the head only: every character the pattern matches
// is one UTF-16 code unit replaced by one, so no match can straddle the cut.
const head = s.slice(0, maxLength).replace(LOG_CONTROL_CHARACTERS, "�");
return `${head}...[+${s.length - maxLength} chars]`;
}

// Rewrite the origin part of a Referer to the upstream authority, keeping path.
function rewriteReferer(value) {
return typeof value === "string" ? value.replace(/^https?:\/\/[^/]+/i, UPSTREAM_ORIGIN) : value;
Expand Down Expand Up @@ -389,7 +409,8 @@ function hermesLogin() {
up.on("end", () => {
const setCookies = up.headers["set-cookie"];
if (up.statusCode !== 200 || !Array.isArray(setCookies) || setCookies.length === 0) {
console.error(`[hermes-dashboard-proxy] login failed: HTTP ${up.statusCode} ${Buffer.concat(chunks).toString().slice(0, 120)}`);
// The body is the upstream's, so bound it before logging it.
console.error(`[hermes-dashboard-proxy] login failed: HTTP ${up.statusCode} ${logSafe(Buffer.concat(chunks).toString(), 120)}`);
settle(null);
return;
}
Expand Down
25 changes: 17 additions & 8 deletions src/app/setup-api/ai-models/configure/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
import { resolveEntitledCodexModel } from "@/lib/codex-model-probe";
import { isValidModelId, isCatalogProvider, GOOGLE_MODELS, ANTHROPIC_MODELS, extractProviderModelId } from "@/lib/provider-models";
import { refreshInBackground as refreshCatalogInBackground } from "@/app/setup-api/ai-models/catalog/route";
// The model name on this route arrives in the request body. For a local
// provider it is the whole of `apiKey`, which nothing further constrains, and
// it reaches the lines below both directly and inside a subprocess error that
// quotes the command it ran. Bound every such field before logging it — see
// src/lib/log-safe.ts.
import { logSafe } from "@/lib/log-safe";

const OPENCLAW_BIN = findOpenclawBin();
const OPENCLAW_HOME_DIR =
Expand Down Expand Up @@ -378,7 +384,7 @@

if (fallbackCandidates.length > 0) {
await setFallbackModels([fallbackCandidates[0]]);
console.log(`[AI Config] Configured local fallback model: ${fallbackCandidates[0]}`);
console.log(`[AI Config] Configured local fallback model: ${logSafe(fallbackCandidates[0])}`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
return;
}

Expand Down Expand Up @@ -937,7 +943,7 @@
config.defaultModel,
]);
if (shouldPromoteLocalToPrimary) {
console.log(`[AI Config] Promoted local model to active primary: ${config.defaultModel}`);
console.log(`[AI Config] Promoted local model to active primary: ${logSafe(config.defaultModel)}`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
}
}
// Reserve sized to the active model's context window. Local models run on
Expand Down Expand Up @@ -1099,7 +1105,7 @@
// Non-fatal: Ollama will still work, just use more memory
console.warn("[AI Config] Failed to optimize Ollama service:", err instanceof Error ? err.message : err);
}
console.log(`[AI Config] Set ollama provider in openclaw.json: ${modelName} (context=${OLLAMA_CONTEXT_WINDOW}, mode=replace)`);
console.log(`[AI Config] Set ollama provider in openclaw.json: ${logSafe(modelName)} (context=${OLLAMA_CONTEXT_WINDOW}, mode=replace)`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
} else if (isLlamaCpp) {
const modelName = config.defaultModel.replace(/^llamacpp\//, "");
const providerDef = JSON.stringify({
Expand All @@ -1123,7 +1129,7 @@
"config", "set", "models.mode", isLocalScope ? "merge" : "replace",
]);
await ensureFallbackModel(shouldPromoteLocalToPrimary ? config.defaultModel : (isLocalScope ? null : config.defaultModel), config.defaultModel);
console.log(`[AI Config] Set llama.cpp provider in openclaw.json: ${modelName} (context=${llamaCppContextWindow}, mode=replace)`);
console.log(`[AI Config] Set llama.cpp provider in openclaw.json: ${logSafe(modelName)} (context=${llamaCppContextWindow}, mode=replace)`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
} else if (isOpenRouter) {
// OpenRouter has no native OpenClaw adapter, so without this explicit
// provider entry the chat turn silently returns usage 0/0/0.
Expand All @@ -1134,7 +1140,7 @@
defaultModel: config.defaultModel,
curatedModels: OPENROUTER_CURATED_MODELS,
});
console.log(`[AI Config] Set openrouter provider (openai-compat): ${config.defaultModel}`);
console.log(`[AI Config] Set openrouter provider (openai-compat): ${logSafe(config.defaultModel)}`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
} else if (isGoogle) {
// Native google plugin registers Gemini models but its 2026.6.8 auth
// fails at call time (runs fall back with reason=auth). Route through
Expand All @@ -1146,7 +1152,7 @@
defaultModel: config.defaultModel,
curatedModels: GOOGLE_MODELS,
});
console.log(`[AI Config] Set google provider (openai-compat): ${config.defaultModel}`);
console.log(`[AI Config] Set google provider (openai-compat): ${logSafe(config.defaultModel)}`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
} else if (isAnthropic) {
// Native anthropic plugin reads a per-agent sqlite auth store that
// ClawBox's file auth profile doesn't populate, so it fails with
Expand All @@ -1159,7 +1165,7 @@
defaultModel: config.defaultModel,
curatedModels: ANTHROPIC_MODELS,
});
console.log(`[AI Config] Set anthropic provider (openai-compat): ${config.defaultModel}`);
console.log(`[AI Config] Set anthropic provider (openai-compat): ${logSafe(config.defaultModel)}`);
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
} else {
// Switching away from Ollama/ClawBox AI — reset models.mode so cloud providers
// auto-detect their model catalog normally.
Expand Down Expand Up @@ -1267,7 +1273,10 @@
// Never surface the raw error: it can carry CLI internals and filesystem
// paths. Log it server-side for diagnosis and return a generic, actionable
// message (mirrors the sanitized gateway-restart branch above).
console.error("[configure] Failed to configure AI model:", err instanceof Error ? err.message : err);
console.error(
"[configure] Failed to configure AI model:",
err instanceof Error ? logSafe(err.message) : err,
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
);
// Classify so the message matches the cause. A local on-device model has no
// credentials, and an edition without the openclaw binary is not something
// the user can fix by re-checking a key — "check your credentials" is wrong
Expand Down
15 changes: 11 additions & 4 deletions src/app/setup-api/apps/install/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "path";
import { DATA_DIR, getAll as configGetAll, setMany as configSetMany } from "@/lib/config-store";
import { getSkillsDir, findOpenclawBin } from "@/lib/openclaw-config";
import { CATEGORY_COLORS, DEFAULT_CATEGORY_COLOR, type InstalledMeta } from "@/lib/store-categories";
import { boundPreferenceText, sanitizePreferenceWrites } from "@/lib/preference-schema";

const STORE_SEARCH_API = "https://openclawhardware.dev/api/store/apps";
const STORE_ICONS_BASE = "https://openclawhardware.dev/store/icons";
Expand Down Expand Up @@ -39,8 +40,9 @@ async function lookupStoreMeta(appId: string): Promise<InstalledMeta> {
// in the POST handler failed. Matches what AppStore.tsx's apiToStoreApp
// stores for UI-initiated installs, so both paths produce identical meta.
const remoteIconUrl = `${STORE_ICONS_BASE}/${appId}.png`;
// The name ends up in a stored preference, so bound it to what one may hold.
const fallback: InstalledMeta = {
name: titleCaseFromSlug(appId),
name: boundPreferenceText(titleCaseFromSlug(appId), appId),
color: DEFAULT_CATEGORY_COLOR,
iconUrl: remoteIconUrl,
};
Expand All @@ -61,7 +63,7 @@ async function lookupStoreMeta(appId: string): Promise<InstalledMeta> {
? CATEGORY_COLORS[category]
: DEFAULT_CATEGORY_COLOR;
return {
name: match.name ?? fallback.name,
name: boundPreferenceText(match.name, fallback.name),
color,
iconUrl: remoteIconUrl,
};
Expand Down Expand Up @@ -169,8 +171,13 @@ async function syncInstalledPreferences(appId: string): Promise<string | undefin
if (!alreadyListed) {
nextUpdates["pref:installed_apps"] = [...list, appId];
}
if (Object.keys(nextUpdates).length > 0) {
await configSetMany(nextUpdates);
// This writes to the config store directly rather than through
// POST /setup-api/preferences, so the preference rules are applied here.
// The check covers the entries carried over from the read above as well as
// the one being added. See src/lib/preference-schema.ts.
const checkedUpdates = sanitizePreferenceWrites(nextUpdates);
if (Object.keys(checkedUpdates).length > 0) {
await configSetMany(checkedUpdates);
}
return undefined;
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions src/app/setup-api/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export async function POST(request: NextRequest) {
const { projectId, name, color, description, template } = body;
if (!projectId || !validateProjectId(projectId)) return err("Invalid project ID");
if (!name) return err("Project name required");
// Shape and length are checked inside initProject too, before it creates
// anything, so the MCP door gets the same rules; a ValidationError from
// there is answered as a 400 below.
const meta = await initProject(projectId, name, { color, description, template });
return ok({ success: true, project: meta });
}
Expand Down
18 changes: 17 additions & 1 deletion src/app/setup-api/preferences/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
return ALLOWED_PREFIXES.some((p) => key.startsWith(p));
}

// Most keys one read may name. Every caller in the app asks for a single key
// (see SettingsApp, i18n, mascot-client); the whole set is fetched with `all=1`
// instead. Bound it so the size of a response follows the store rather than the
// request.
const MAX_KEYS_PER_READ = 32;

// GET /setup-api/preferences?keys=wp_opacity,wp_bg_color
// GET /setup-api/preferences?all=1 (returns all pref:* keys)
//
Expand Down Expand Up @@ -46,9 +52,19 @@
return NextResponse.json({ error: "keys or all param required" }, { status: 400 });
}
const keys = keysParam.split(",").filter(isAllowed);
if (keys.length > MAX_KEYS_PER_READ) {
return NextResponse.json(
{ error: `at most ${MAX_KEYS_PER_READ} keys per request` },
{ status: 400 },
);
}
// One read of the store rather than one per key: config.get() re-reads and
// re-parses the whole file synchronously on every call, so the work of a
// request would otherwise follow the length of its `keys` parameter.
const allConfig = await config.getAll();
Comment on lines 54 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Count supplied keys before whitelist filtering.

Line 54 removes unallowed keys before the limit check. A request with more than 32 unallowed keys bypasses the cap and still calls config.getAll(). Count the raw split values before filter(isAllowed). Add a test that uses more than 32 unallowed keys and asserts no store read.

Proposed fix
-  const keys = keysParam.split(",").filter(isAllowed);
-  if (keys.length > MAX_KEYS_PER_READ) {
+  const requestedKeys = keysParam.split(",");
+  if (requestedKeys.length > MAX_KEYS_PER_READ) {
     return NextResponse.json(
       { error: `at most ${MAX_KEYS_PER_READ} keys per request` },
       { status: 400 },
     );
   }
+  const keys = requestedKeys.filter(isAllowed);

As per path instructions, src/app/**/*.ts* requires review for resource constraints on embedded hardware.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const keys = keysParam.split(",").filter(isAllowed);
if (keys.length > MAX_KEYS_PER_READ) {
return NextResponse.json(
{ error: `at most ${MAX_KEYS_PER_READ} keys per request` },
{ status: 400 },
);
}
// One read of the store rather than one per key: config.get() re-reads and
// re-parses the whole file synchronously on every call, so the work of a
// request would otherwise follow the length of its `keys` parameter.
const allConfig = await config.getAll();
const requestedKeys = keysParam.split(",");
if (requestedKeys.length > MAX_KEYS_PER_READ) {
return NextResponse.json(
{ error: `at most ${MAX_KEYS_PER_READ} keys per request` },
{ status: 400 },
);
}
const keys = requestedKeys.filter(isAllowed);
// One read of the store rather than one per key: config.get() re-reads and
// re-parses the whole file synchronously on every call, so the work of a
// request would otherwise follow the length of its `keys` parameter.
const allConfig = await config.getAll();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/setup-api/preferences/route.ts` around lines 54 - 64, Update the
key-count validation in the preferences route before filtering with isAllowed:
count the raw values from keysParam.split(",") and reject requests exceeding
MAX_KEYS_PER_READ, while retaining filtering for allowed keys afterward. Add
coverage proving a request containing more than 32 unallowed keys returns the
limit error without invoking config.getAll().

Source: Path instructions

const result: Record<string, unknown> = Object.create(null);
for (const key of keys) {
result[key] = await config.get(`pref:${key}`);
result[key] = allConfig[`pref:${key}`];
Comment thread
KrasimirKralev marked this conversation as resolved.
Dismissed
}
return NextResponse.json(sanitizePreferences(result));
}
Expand Down
37 changes: 31 additions & 6 deletions src/lib/code-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ export function validateProjectId(id: string): boolean {
return APP_ID_RE.test(id);
}

/** Longest project name the desktop label and the starter templates carry. */
export const MAX_PROJECT_NAME_LENGTH = 60;

/**
* The name a project may be created with, or a ValidationError.
*
* Checked here rather than at each caller because initProject writes the
* directory and project.json before the name reaches the templates: a name the
* templates cannot render has to be refused while nothing has been created yet,
* so a rejected request leaves no project behind for the next attempt to
* collide with. The MCP door declares the same limit (`zText(60)` in
* mcp/tools/desktop.ts); this is where it is enforced.
*/
export function validateProjectName(name: unknown): string {
if (typeof name !== "string") throw new ValidationError("Project name must be a string");
const trimmed = name.trim();
if (!trimmed) throw new ValidationError("Project name required");
if (trimmed.length > MAX_PROJECT_NAME_LENGTH) {
throw new ValidationError(`Project name must be at most ${MAX_PROJECT_NAME_LENGTH} characters`);
}
return trimmed;
}

/** Resolve a file path inside a project directory, preventing traversal. */
function safePath(projectId: string, filePath: string): string {
if (!validateProjectId(projectId)) throw new ValidationError("Invalid project ID");
Expand Down Expand Up @@ -119,6 +142,8 @@ export async function initProject(
opts?: { color?: string; description?: string; template?: "blank" | "app" }
): Promise<ProjectMeta> {
if (!validateProjectId(projectId)) throw new ValidationError("Invalid project ID");
// Before anything is created on disk — see validateProjectName.
const projectName = validateProjectName(name);

const dir = projectDir(projectId);
const exists = await fs.stat(dir).catch(() => null);
Expand All @@ -129,7 +154,7 @@ export async function initProject(
const now = new Date().toISOString();
const meta: ProjectMeta = {
projectId,
name,
name: projectName,
color: opts?.color || "#f97316",
description: opts?.description || "",
created: now,
Expand All @@ -147,14 +172,14 @@ export async function initProject(
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(name)}</title>
<title>${escapeHtml(projectName)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<h1>${escapeHtml(name)}</h1>
<h1>${escapeHtml(projectName)}</h1>
</body>
</html>`,
"utf-8"
Expand All @@ -168,7 +193,7 @@ export async function initProject(
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(name)}</title>
<title>${escapeHtml(projectName)}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
Expand Down Expand Up @@ -207,8 +232,8 @@ h1 {
// template-literal metacharacters (` $ \) and newlines untouched, so a name
// like "`;fetch('/setup-api/...')`" would break out of the literal and run
// as code when the built app loads on the ClawBox origin (stored XSS).
const commentName = name.replace(/[\r\n]+/g, " ");
const innerName = jsTemplateEscape(escapeHtml(name));
const commentName = projectName.replace(/[\r\n]+/g, " ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

XSS (CWE-79): Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Reachability: External

Reachability path
● Entry
  src/app/setup-api/code/route.ts:58
  initProject
│
▼
● Sink
  src/lib/code-projects.ts

Sanitize all JavaScript line terminators before generating commentName.

The request-derived name reaches the generated // comment. The current replacement omits U+2028 and U+2029, which can terminate the comment and enable stored XSS when the webapp loads. Replace them or remove the untrusted name from the comment. Add regression tests for both characters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/code-projects.ts` at line 235, Update the commentName sanitization
near projectName so it replaces all JavaScript line terminators, including
U+2028 and U+2029, before embedding the untrusted name in the generated comment.
Add regression coverage for both characters and preserve existing CR/LF
sanitization.

const innerName = jsTemplateEscape(escapeHtml(projectName));
await fs.writeFile(
path.join(dir, "app.js"),
`// ${commentName} — ClawBox Web App
Expand Down
13 changes: 13 additions & 0 deletions src/lib/hermes-dashboard-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ const LOGIN_RETRY_COOLDOWN_MS = 10_000;
// promise to every later caller until the server is restarted. Every request
// this module issues is therefore bounded.
const REQUEST_TIMEOUT_MS = 8_000;
// Every request below sets this. Node's fetch defaults to "follow", which makes
// a redirect invisible to the caller — the response that comes back is the one
// from wherever Location pointed, not from the path we asked for, and a
// redirected request can carry its body and headers there. Resolving redirects
// manually keeps each call's answer the answer to the call it made: a 3xx from
// the dashboard means the request did not reach the API, which is what the
// callers below already treat as "not signed in". Same rule and same reason as
// mcp/lib/api.ts.
const REDIRECT_POLICY = "manual" as const;
Comment on lines +33 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/lib/hermes-dashboard-auth.ts --items all
rg -n -C 4 '\bDASH_ORIGIN\b|dashboardFetch\s*\(' src/lib/hermes-dashboard-auth.ts .

Repository: ID-Robots/clawbox

Length of output: 8090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- hermes-dashboard-auth.ts ---'
sed -n '1,110p' src/lib/hermes-dashboard-auth.ts

printf '%s\n' '--- HERMES_DASH_HOST / HERMES_PORT references ---'
rg -n -C 3 'HERMES_DASH_HOST|HERMES_PORT|HERMES_DASH_USERNAME' . \
  -g '!node_modules' -g '!dist' -g '!build'

Repository: ID-Robots/clawbox

Length of output: 17318


Sensitive Data Exposure (CWE-319): Cleartext Transmission of Sensitive Information

Reachability: Internal

Reachability path
● Entry
  src/app/setup-api/apps/install/route.ts:112
  attempt
│
▼
● Sink
  src/lib/hermes-dashboard-auth.ts

Restrict HERMES_DASH_HOST to a local-only endpoint or use HTTPS.

DASH_ORIGIN always uses http://, while HERMES_DASH_HOST can override the loopback default. A non-loopback value sends the dashboard password and session cookie without transport encryption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/hermes-dashboard-auth.ts` around lines 33 - 41, Update the dashboard
origin configuration around DASH_ORIGIN and HERMES_DASH_HOST to permit only
loopback hosts over HTTP, or require HTTPS for non-loopback hosts. Reject or
safely handle insecure remote values before requests send dashboard credentials
or session cookies, while preserving the existing local default.


async function readPassword(): Promise<string> {
try {
Expand All @@ -46,10 +55,13 @@ async function login(): Promise<string | null> {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ provider: "basic", username: USERNAME, password: pw, next: "/" }),
redirect: REDIRECT_POLICY,
// The caller's `init.signal` only covers `attempt()` below — login runs
// before it and would otherwise be unbounded.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
// A redirect here is not a login: the cookies are read off THIS response, so
// treat anything that is not a 2xx as "no session".
if (!res.ok) return null;
const setCookies = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : [];
const cookie = setCookies.map((c) => c.split(";", 1)[0]).filter(Boolean).join("; ");
Expand All @@ -69,6 +81,7 @@ export async function dashboardFetch(apiPath: string, init?: RequestInit): Promi
const attempt = () =>
fetch(`${DASH_ORIGIN}${apiPath}`, {
...init,
redirect: REDIRECT_POLICY,
// Callers that don't bring their own deadline still get one — no request
// from this module may be able to hang indefinitely.
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
Expand Down
Loading
Loading