From fb1803401104c1631fd2b2012106c2e7dbc94601 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:47:07 -0500 Subject: [PATCH 001/293] test: add macmini test profile --- package.json | 2 +- scripts/test-parallel.mjs | 189 +++++++++++++++++++++++++++++--------- 2 files changed, 146 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index e70c7dc3061..72ab6fb3b9a 100644 --- a/package.json +++ b/package.json @@ -655,7 +655,7 @@ "test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh", "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", - "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", + "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=macmini node scripts/test-parallel.mjs", "test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh", "test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh", "test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 8c63e61aeb4..1a128cf70dd 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -55,11 +55,13 @@ const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === const rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase(); const testProfile = rawTestProfile === "low" || + rawTestProfile === "macmini" || rawTestProfile === "max" || rawTestProfile === "normal" || rawTestProfile === "serial" ? rawTestProfile : "normal"; +const isMacMiniProfile = testProfile === "macmini"; // Even on low-memory hosts, keep the isolated lane split so files like // git-commit.test.ts still get the worker/process isolation they require. const shouldSplitUnitRuns = testProfile !== "serial"; @@ -162,6 +164,17 @@ const parsePassthroughArgs = (args) => { }; const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } = parsePassthroughArgs(passthroughArgs); +const passthroughMetadataFlags = new Set(["-h", "--help", "--listTags", "--clearCache"]); +const passthroughMetadataOnly = + passthroughArgs.length > 0 && + passthroughFileFilters.length === 0 && + passthroughOptionArgs.every((arg) => { + if (!arg.startsWith("-")) { + return false; + } + const [flag] = arg.split("=", 1); + return passthroughMetadataFlags.has(flag); + }); const countExplicitEntryFilters = (entryArgs) => { const { fileFilters } = parsePassthroughArgs(entryArgs.slice(2)); return fileFilters.length > 0 ? fileFilters.length : null; @@ -242,9 +255,25 @@ const allKnownUnitFiles = allKnownTestFiles.filter((file) => { return isUnitConfigTestFile(file); }); const defaultHeavyUnitFileLimit = - testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60; + testProfile === "serial" + ? 0 + : isMacMiniProfile + ? 90 + : testProfile === "low" + ? 20 + : highMemLocalHost + ? 80 + : 60; const defaultHeavyUnitLaneCount = - testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4; + testProfile === "serial" + ? 0 + : isMacMiniProfile + ? 6 + : testProfile === "low" + ? 2 + : highMemLocalHost + ? 5 + : 4; const heavyUnitFileLimit = parseEnvNumber( "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", defaultHeavyUnitFileLimit, @@ -538,12 +567,16 @@ const targetedEntries = (() => { // Node 25 local runs still show cross-process worker shutdown contention even // after moving the known heavy files into singleton lanes. const topLevelParallelEnabled = - testProfile !== "low" && testProfile !== "serial" && !(!isCI && nodeMajor >= 25); + testProfile !== "low" && + testProfile !== "serial" && + !(!isCI && nodeMajor >= 25) && + !isMacMiniProfile; const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; const parallelGatewayEnabled = - process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost); + !isMacMiniProfile && + (process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost)); // Keep gateway serial by default except when explicitly requested or on high-memory local hosts. const keepGatewaySerial = isWindowsCi || @@ -570,45 +603,52 @@ const defaultWorkerBudget = extensions: 4, gateway: 1, } - : testProfile === "serial" + : isMacMiniProfile ? { - unit: 1, + unit: 3, unitIsolated: 1, extensions: 1, gateway: 1, } - : testProfile === "max" + : testProfile === "serial" ? { - unit: localWorkers, - unitIsolated: Math.min(4, localWorkers), - extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), - gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), + unit: 1, + unitIsolated: 1, + extensions: 1, + gateway: 1, } - : highMemLocalHost + : testProfile === "max" ? { - // After peeling measured hotspots into dedicated lanes, the shared - // unit-fast lane shuts down more reliably with a slightly smaller - // worker fan-out than the old "max it out" local default. - unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))), - unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), - extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), + unit: localWorkers, + unitIsolated: Math.min(4, localWorkers), + extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), + gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), } - : lowMemLocalHost + : highMemLocalHost ? { - // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. - unit: 2, - unitIsolated: 1, - extensions: 4, - gateway: 1, - } - : { - // 64-95 GiB local hosts: conservative split with some parallel headroom. - unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), - unitIsolated: 1, + // After peeling measured hotspots into dedicated lanes, the shared + // unit-fast lane shuts down more reliably with a slightly smaller + // worker fan-out than the old "max it out" local default. + unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))), + unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: 1, - }; + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), + } + : lowMemLocalHost + ? { + // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. + unit: 2, + unitIsolated: 1, + extensions: 4, + gateway: 1, + } + : { + // 64-95 GiB local hosts: conservative split with some parallel headroom. + unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), + unitIsolated: 1, + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: 1, + }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. // In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts. @@ -766,21 +806,52 @@ const run = async (entry, extraArgs = []) => { return 0; }; +const runEntriesWithLimit = async (entries, extraArgs = [], concurrency = 1) => { + if (entries.length === 0) { + return undefined; + } + + const normalizedConcurrency = Math.max(1, Math.floor(concurrency)); + if (normalizedConcurrency <= 1) { + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + const code = await run(entry, extraArgs); + if (code !== 0) { + return code; + } + } + + return undefined; + } + + let nextIndex = 0; + let firstFailure; + const worker = async () => { + while (firstFailure === undefined) { + const entryIndex = nextIndex; + nextIndex += 1; + if (entryIndex >= entries.length) { + return; + } + const code = await run(entries[entryIndex], extraArgs); + if (code !== 0 && firstFailure === undefined) { + firstFailure = code; + } + } + }; + + const workerCount = Math.min(normalizedConcurrency, entries.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return firstFailure; +}; + const runEntries = async (entries, extraArgs = []) => { if (topLevelParallelEnabled) { const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs))); return codes.find((code) => code !== 0); } - for (const entry of entries) { - // eslint-disable-next-line no-await-in-loop - const code = await run(entry, extraArgs); - if (code !== 0) { - return code; - } - } - - return undefined; + return runEntriesWithLimit(entries, extraArgs); }; const shutdown = (signal) => { @@ -800,6 +871,17 @@ if (process.env.OPENCLAW_TEST_LIST_LANES === "1") { process.exit(0); } +if (passthroughMetadataOnly) { + const exitCode = await runOnce( + { + name: "vitest-meta", + args: ["vitest", "run"], + }, + passthroughOptionArgs, + ); + process.exit(exitCode); +} + if (targetedEntries.length > 0) { if (passthroughRequiresSingleRun && targetedEntries.length > 1) { console.error( @@ -834,9 +916,28 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) { process.exit(2); } -const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs); -if (failedParallel !== undefined) { - process.exit(failedParallel); +if (isMacMiniProfile && targetedEntries.length === 0) { + const unitFastEntry = parallelRuns.find((entry) => entry.name === "unit-fast"); + if (unitFastEntry) { + const unitFastCode = await run(unitFastEntry, passthroughOptionArgs); + if (unitFastCode !== 0) { + process.exit(unitFastCode); + } + } + const deferredEntries = parallelRuns.filter((entry) => entry.name !== "unit-fast"); + const failedMacMiniParallel = await runEntriesWithLimit( + deferredEntries, + passthroughOptionArgs, + 3, + ); + if (failedMacMiniParallel !== undefined) { + process.exit(failedMacMiniParallel); + } +} else { + const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs); + if (failedParallel !== undefined) { + process.exit(failedParallel); + } } for (const entry of serialRuns) { From e1b5ffadca14766254c8e32a7140a19c8441d1e2 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:28:56 -0500 Subject: [PATCH 002/293] docs: clarify scoped-test validation policy --- AGENTS.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 488bc0678fd..8b659b985b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,10 +70,33 @@ - Format check: `pnpm format` (oxfmt --check) - Format fix: `pnpm format:fix` (oxfmt --write) - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` -- Hard gate: before any commit, `pnpm check` MUST be run and MUST pass for the change being committed. -- Hard gate: before any push to `main`, `pnpm check` MUST be run and MUST pass, and `pnpm test` MUST be run and MUST pass. +- Default landing bar: before any commit, run `pnpm check` and prefer a passing result for the change being committed. +- For narrowly scoped changes, run narrowly scoped tests that directly validate the touched behavior; this is required proof for the change before commit and push decisions. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available. +- Default landing bar: before any push to `main`, run `pnpm check` and `pnpm test` and prefer a green result. +- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default. - Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`. -- Hard gate: do not commit or push with failing format, lint, type, build, or required test checks. +- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface. + +## Judgment / Exception Handling + +- Use judgment for narrowly scoped changes when unrelated failures already exist on latest `origin/main`. +- Before using that judgment, explicitly separate: + - failures caused by the change + - failures reproducible on current `origin/main` + - failures that are clearly unrelated to the touched surface +- Scoped exceptions are allowed only when all of the following are true: + - the diff is narrowly scoped and low blast radius + - the failing checks touch unrelated surfaces + - the failures are reproducible on current `origin/main` or are otherwise clearly pre-existing + - you explicitly explain that conclusion to Tak +- Even when using a scoped exception, narrowly scoped tests are still required as direct proof of the change unless no meaningful scoped test exists. +- Do not claim full gate compliance when using a scoped exception. State which checks are failing and why they appear unrelated. +- When using judgment because full-suite failures are unrelated or already failing on latest `origin/main`, report both: + - which scoped tests you ran as direct proof of the change + - which full-suite failures you are setting aside and why they appear unrelated +- If the branch contains only the intended scoped change and the remaining failures are demonstrably unrelated or already failing on latest `origin/main`, report that clearly and ask for a push/waiver decision instead of silently broadening scope into unrelated fixes. +- If Tak explicitly authorizes landing despite unrelated failing gates, treat that as an informed override. Do not keep repairing unrelated areas unless Tak explicitly asks for that broader work. +- Do not use judgment as a blanket bypass. If the change could plausibly affect the failing area, treat the failure as in-scope until proven otherwise. Do not use “scoped tests passed” as permission to ignore plausibly related failures. ## Coding Style & Naming Conventions From 5a41229a6d51e745023e99288596a4e546d6f5cf Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:34:04 -0500 Subject: [PATCH 003/293] docs: simplify AGENTS validation policy --- AGENTS.md | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b659b985b0..538670892f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,27 +76,8 @@ - Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default. - Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`. - Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface. - -## Judgment / Exception Handling - -- Use judgment for narrowly scoped changes when unrelated failures already exist on latest `origin/main`. -- Before using that judgment, explicitly separate: - - failures caused by the change - - failures reproducible on current `origin/main` - - failures that are clearly unrelated to the touched surface -- Scoped exceptions are allowed only when all of the following are true: - - the diff is narrowly scoped and low blast radius - - the failing checks touch unrelated surfaces - - the failures are reproducible on current `origin/main` or are otherwise clearly pre-existing - - you explicitly explain that conclusion to Tak -- Even when using a scoped exception, narrowly scoped tests are still required as direct proof of the change unless no meaningful scoped test exists. -- Do not claim full gate compliance when using a scoped exception. State which checks are failing and why they appear unrelated. -- When using judgment because full-suite failures are unrelated or already failing on latest `origin/main`, report both: - - which scoped tests you ran as direct proof of the change - - which full-suite failures you are setting aside and why they appear unrelated -- If the branch contains only the intended scoped change and the remaining failures are demonstrably unrelated or already failing on latest `origin/main`, report that clearly and ask for a push/waiver decision instead of silently broadening scope into unrelated fixes. -- If Tak explicitly authorizes landing despite unrelated failing gates, treat that as an informed override. Do not keep repairing unrelated areas unless Tak explicitly asks for that broader work. -- Do not use judgment as a blanket bypass. If the change could plausibly affect the failing area, treat the failure as in-scope until proven otherwise. Do not use “scoped tests passed” as permission to ignore plausibly related failures. +- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures. +- Do not use scoped tests as permission to ignore plausibly related failures. ## Coding Style & Naming Conventions From ff6541f69d2e6cd88424953b13a43a20fa7aefb9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 11:39:59 -0400 Subject: [PATCH 004/293] Matrix: fix Jiti runtime API boundary --- extensions/matrix/runtime-api.ts | 16 +- extensions/matrix/src/channel.ts | 16 +- .../src/matrix/thread-bindings-shared.ts | 225 +++++++++++++++++ .../matrix/src/matrix/thread-bindings.ts | 238 +++--------------- extensions/matrix/src/runtime-api.ts | 1 + extensions/matrix/thread-bindings-runtime.ts | 4 + src/plugin-sdk/matrix.ts | 2 +- src/plugins/runtime/types-channel.ts | 4 +- 8 files changed, 273 insertions(+), 233 deletions(-) create mode 100644 extensions/matrix/src/matrix/thread-bindings-shared.ts create mode 100644 extensions/matrix/thread-bindings-runtime.ts diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 52df80f9843..bc8163c9969 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -1,14 +1,4 @@ -export * from "openclaw/plugin-sdk/matrix"; +// Keep the external runtime API light so Jiti callers can resolve Matrix config +// helpers without traversing the full plugin-sdk/runtime graph. export * from "./src/auth-precedence.js"; -export { - findMatrixAccountEntry, - hashMatrixAccessToken, - listMatrixEnvAccountIds, - resolveConfiguredMatrixAccountIds, - resolveMatrixChannelConfig, - resolveMatrixCredentialsFilename, - resolveMatrixEnvAccountToken, - resolveMatrixHomeserverKey, - resolveMatrixLegacyFlatStoreRoot, - sanitizeMatrixPathSegment, -} from "./helper-api.js"; +export * from "./helper-api.js"; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index cfc4ccdddf1..34b6b9610e3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -17,14 +17,6 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { - buildChannelConfigSchema, - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, - DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, - type ChannelPlugin, -} from "../runtime-api.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { @@ -44,6 +36,14 @@ import { resolveMatrixDirectUserId, resolveMatrixTargetIdentity, } from "./matrix/target-ids.js"; +import { + buildChannelConfigSchema, + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + type ChannelPlugin, +} from "./runtime-api.js"; import { getMatrixRuntime } from "./runtime.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; import { matrixSetupAdapter } from "./setup-core.js"; diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts new file mode 100644 index 00000000000..f8c9c2b9e3f --- /dev/null +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -0,0 +1,225 @@ +import type { + BindingTargetKind, + SessionBindingRecord, +} from "openclaw/plugin-sdk/conversation-runtime"; + +export type MatrixThreadBindingTargetKind = "subagent" | "acp"; + +export type MatrixThreadBindingRecord = { + accountId: string; + conversationId: string; + parentConversationId?: string; + targetKind: MatrixThreadBindingTargetKind; + targetSessionKey: string; + agentId?: string; + label?: string; + boundBy?: string; + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +export type MatrixThreadBindingManager = { + accountId: string; + getIdleTimeoutMs: () => number; + getMaxAgeMs: () => number; + getByConversation: (params: { + conversationId: string; + parentConversationId?: string; + }) => MatrixThreadBindingRecord | undefined; + listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; + listBindings: () => MatrixThreadBindingRecord[]; + touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; + setIdleTimeoutBySessionKey: (params: { + targetSessionKey: string; + idleTimeoutMs: number; + }) => MatrixThreadBindingRecord[]; + setMaxAgeBySessionKey: (params: { + targetSessionKey: string; + maxAgeMs: number; + }) => MatrixThreadBindingRecord[]; + stop: () => void; +}; + +export type MatrixThreadBindingManagerCacheEntry = { + filePath: string; + manager: MatrixThreadBindingManager; +}; + +const MANAGERS_BY_ACCOUNT_ID = new Map(); +const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); + +export function resolveBindingKey(params: { + accountId: string; + conversationId: string; + parentConversationId?: string; +}): string { + return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; +} + +function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { + return raw === "subagent" ? "subagent" : "session"; +} + +export function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { + return raw === "subagent" ? "subagent" : "acp"; +} + +export function resolveEffectiveBindingExpiry(params: { + record: MatrixThreadBindingRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +} + +export function toSessionBindingRecord( + record: MatrixThreadBindingRecord, + defaults: { idleTimeoutMs: number; maxAgeMs: number }, +): SessionBindingRecord { + const lifecycle = resolveEffectiveBindingExpiry({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + }); + const idleTimeoutMs = + typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; + const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; + return { + bindingId: resolveBindingKey(record), + targetSessionKey: record.targetSessionKey, + targetKind: toSessionBindingTargetKind(record.targetKind), + conversation: { + channel: "matrix", + accountId: record.accountId, + conversationId: record.conversationId, + parentConversationId: record.parentConversationId, + }, + status: "active", + boundAt: record.boundAt, + expiresAt: lifecycle.expiresAt, + metadata: { + agentId: record.agentId, + label: record.label, + boundBy: record.boundBy, + lastActivityAt: record.lastActivityAt, + idleTimeoutMs, + maxAgeMs, + }, + }; +} + +export function setBindingRecord(record: MatrixThreadBindingRecord): void { + BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); +} + +export function removeBindingRecord( + record: MatrixThreadBindingRecord, +): MatrixThreadBindingRecord | null { + const key = resolveBindingKey(record); + const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; + if (removed) { + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + } + return removed; +} + +export function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { + return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (entry) => entry.accountId === accountId, + ); +} + +export function getMatrixThreadBindingManagerEntry( + accountId: string, +): MatrixThreadBindingManagerCacheEntry | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId) ?? null; +} + +export function setMatrixThreadBindingManagerEntry( + accountId: string, + entry: MatrixThreadBindingManagerCacheEntry, +): void { + MANAGERS_BY_ACCOUNT_ID.set(accountId, entry); +} + +export function deleteMatrixThreadBindingManagerEntry(accountId: string): void { + MANAGERS_BY_ACCOUNT_ID.delete(accountId); +} + +export function getMatrixThreadBindingManager( + accountId: string, +): MatrixThreadBindingManager | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; +} + +export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { + accountId: string; + targetSessionKey: string; + idleTimeoutMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; + if (!manager) { + return []; + } + return manager.setIdleTimeoutBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function setMatrixThreadBindingMaxAgeBySessionKey(params: { + accountId: string; + targetSessionKey: string; + maxAgeMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; + if (!manager) { + return []; + } + return manager.setMaxAgeBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function resetMatrixThreadBindingsForTests(): void { + for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { + manager.stop(); + } + MANAGERS_BY_ACCOUNT_ID.clear(); + BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); +} diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index 6cf8029f9e9..edbbde5d000 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -6,70 +6,39 @@ import { resolveThreadBindingFarewellText, unregisterSessionBindingAdapter, writeJsonFileAtomically, - type BindingTargetKind, - type SessionBindingRecord, } from "../runtime-api.js"; import { resolveMatrixStoragePaths } from "./client/storage.js"; import type { MatrixAuth } from "./client/types.js"; import type { MatrixClient } from "./sdk.js"; import { sendMessageMatrix } from "./send.js"; +import { + deleteMatrixThreadBindingManagerEntry, + getMatrixThreadBindingManager, + getMatrixThreadBindingManagerEntry, + listBindingsForAccount, + removeBindingRecord, + resetMatrixThreadBindingsForTests, + resolveBindingKey, + resolveEffectiveBindingExpiry, + setBindingRecord, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingManagerEntry, + setMatrixThreadBindingMaxAgeBySessionKey, + toMatrixBindingTargetKind, + toSessionBindingRecord, + type MatrixThreadBindingManager, + type MatrixThreadBindingRecord, +} from "./thread-bindings-shared.js"; const STORE_VERSION = 1; const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000; const TOUCH_PERSIST_DELAY_MS = 30_000; -type MatrixThreadBindingTargetKind = "subagent" | "acp"; - -type MatrixThreadBindingRecord = { - accountId: string; - conversationId: string; - parentConversationId?: string; - targetKind: MatrixThreadBindingTargetKind; - targetSessionKey: string; - agentId?: string; - label?: string; - boundBy?: string; - boundAt: number; - lastActivityAt: number; - idleTimeoutMs?: number; - maxAgeMs?: number; -}; - type StoredMatrixThreadBindingState = { version: number; bindings: MatrixThreadBindingRecord[]; }; -export type MatrixThreadBindingManager = { - accountId: string; - getIdleTimeoutMs: () => number; - getMaxAgeMs: () => number; - getByConversation: (params: { - conversationId: string; - parentConversationId?: string; - }) => MatrixThreadBindingRecord | undefined; - listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; - listBindings: () => MatrixThreadBindingRecord[]; - touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; - setIdleTimeoutBySessionKey: (params: { - targetSessionKey: string; - idleTimeoutMs: number; - }) => MatrixThreadBindingRecord[]; - setMaxAgeBySessionKey: (params: { - targetSessionKey: string; - maxAgeMs: number; - }) => MatrixThreadBindingRecord[]; - stop: () => void; -}; - -type MatrixThreadBindingManagerCacheEntry = { - filePath: string; - manager: MatrixThreadBindingManager; -}; - -const MANAGERS_BY_ACCOUNT_ID = new Map(); -const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); - function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return fallback; @@ -86,94 +55,6 @@ function normalizeConversationId(raw: unknown): string | undefined { return trimmed || undefined; } -function resolveBindingKey(params: { - accountId: string; - conversationId: string; - parentConversationId?: string; -}): string { - return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; -} - -function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { - return raw === "subagent" ? "subagent" : "session"; -} - -function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { - return raw === "subagent" ? "subagent" : "acp"; -} - -function resolveEffectiveBindingExpiry(params: { - record: MatrixThreadBindingRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): { - expiresAt?: number; - reason?: "idle-expired" | "max-age-expired"; -} { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; -} - -function toSessionBindingRecord( - record: MatrixThreadBindingRecord, - defaults: { idleTimeoutMs: number; maxAgeMs: number }, -): SessionBindingRecord { - const lifecycle = resolveEffectiveBindingExpiry({ - record, - defaultIdleTimeoutMs: defaults.idleTimeoutMs, - defaultMaxAgeMs: defaults.maxAgeMs, - }); - const idleTimeoutMs = - typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; - const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; - return { - bindingId: resolveBindingKey(record), - targetSessionKey: record.targetSessionKey, - targetKind: toSessionBindingTargetKind(record.targetKind), - conversation: { - channel: "matrix", - accountId: record.accountId, - conversationId: record.conversationId, - parentConversationId: record.parentConversationId, - }, - status: "active", - boundAt: record.boundAt, - expiresAt: lifecycle.expiresAt, - metadata: { - agentId: record.agentId, - label: record.label, - boundBy: record.boundBy, - lastActivityAt: record.lastActivityAt, - idleTimeoutMs, - maxAgeMs, - }, - }; -} - function resolveBindingsPath(params: { auth: MatrixAuth; accountId: string; @@ -256,25 +137,6 @@ async function persistBindingsSnapshot( await writeJsonFileAtomically(filePath, toStoredBindingsState(bindings)); } -function setBindingRecord(record: MatrixThreadBindingRecord): void { - BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); -} - -function removeBindingRecord(record: MatrixThreadBindingRecord): MatrixThreadBindingRecord | null { - const key = resolveBindingKey(record); - const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; - if (removed) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); - } - return removed; -} - -function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { - return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( - (entry) => entry.accountId === accountId, - ); -} - function buildMatrixBindingIntroText(params: { metadata?: Record; targetSessionKey: string; @@ -365,7 +227,7 @@ export async function createMatrixThreadBindingManager(params: { env: params.env, stateDir: params.stateDir, }); - const existingEntry = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + const existingEntry = getMatrixThreadBindingManagerEntry(params.accountId); if (existingEntry) { if (existingEntry.filePath === filePath) { return existingEntry.manager; @@ -506,11 +368,11 @@ export async function createMatrixThreadBindingManager(params: { channel: "matrix", accountId: params.accountId, }); - if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager === manager) { - MANAGERS_BY_ACCOUNT_ID.delete(params.accountId); + if (getMatrixThreadBindingManagerEntry(params.accountId)?.manager === manager) { + deleteMatrixThreadBindingManagerEntry(params.accountId); } for (const record of listBindingsForAccount(params.accountId)) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(resolveBindingKey(record)); + removeBindingRecord(record); } }, }; @@ -705,57 +567,15 @@ export async function createMatrixThreadBindingManager(params: { sweepTimer.unref?.(); } - MANAGERS_BY_ACCOUNT_ID.set(params.accountId, { + setMatrixThreadBindingManagerEntry(params.accountId, { filePath, manager, }); return manager; } - -export function getMatrixThreadBindingManager( - accountId: string, -): MatrixThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; -} - -export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { - accountId: string; - targetSessionKey: string; - idleTimeoutMs: number; -}): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; - if (!manager) { - return []; - } - return manager.setIdleTimeoutBySessionKey(params).map((record) => - toSessionBindingRecord(record, { - idleTimeoutMs: manager.getIdleTimeoutMs(), - maxAgeMs: manager.getMaxAgeMs(), - }), - ); -} - -export function setMatrixThreadBindingMaxAgeBySessionKey(params: { - accountId: string; - targetSessionKey: string; - maxAgeMs: number; -}): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; - if (!manager) { - return []; - } - return manager.setMaxAgeBySessionKey(params).map((record) => - toSessionBindingRecord(record, { - idleTimeoutMs: manager.getIdleTimeoutMs(), - maxAgeMs: manager.getMaxAgeMs(), - }), - ); -} - -export function resetMatrixThreadBindingsForTests(): void { - for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { - manager.stop(); - } - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); -} +export { + getMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +}; diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index ece735819df..3c447f50e2f 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1 +1,2 @@ +export * from "openclaw/plugin-sdk/matrix"; export * from "../runtime-api.js"; diff --git a/extensions/matrix/thread-bindings-runtime.ts b/extensions/matrix/thread-bindings-runtime.ts new file mode 100644 index 00000000000..b0e8ff49628 --- /dev/null +++ b/extensions/matrix/thread-bindings-runtime.ts @@ -0,0 +1,4 @@ +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "./src/matrix/thread-bindings-shared.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index a85e8997389..660fe7183fb 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -85,7 +85,7 @@ export { export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey, -} from "../../extensions/matrix/src/matrix/thread-bindings.js"; +} from "../../extensions/matrix/thread-bindings-runtime.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 0a7eab63727..1a44e0e45f1 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -195,8 +195,8 @@ export type PluginRuntimeChannel = { }; matrix: { threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingMaxAgeBySessionKey; }; }; signal: { From 9d772d6eab528b48235a036ad2585348c4860902 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:16:34 -0700 Subject: [PATCH 005/293] fix(ci): normalize bundle mcp paths and skip explicit channel scans --- src/infra/outbound/channel-selection.test.ts | 17 ++++++++++ src/infra/outbound/channel-selection.ts | 8 ++--- src/plugins/bundle-mcp.ts | 33 ++++++++++++++------ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index fdb4ecd4b6f..9e6a1fa74d6 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -143,6 +143,23 @@ describe("resolveMessageChannelSelection", () => { }); }); + it("does not probe configured channels when an explicit channel is available", async () => { + const isConfigured = vi.fn(async () => true); + mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "slack", isConfigured })]); + + const selection = await resolveMessageChannelSelection({ + cfg: {} as never, + channel: "slack", + }); + + expect(selection).toEqual({ + channel: "slack", + configured: [], + source: "explicit", + }); + expect(isConfigured).not.toHaveBeenCalled(); + }); + it("falls back to tool context channel when explicit channel is unknown", async () => { const selection = await resolveMessageChannelSelection({ cfg: {} as never, diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 0e87a8e4950..f9c6f558769 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,6 @@ -import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { listChannelPlugins } from "../../channels/plugins/index.js"; import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, @@ -165,7 +165,7 @@ export async function resolveMessageChannelSelection(params: { if (fallback) { return { channel: fallback, - configured: await listConfiguredMessageChannels(params.cfg), + configured: [], source: "tool-context-fallback", }; } @@ -176,7 +176,7 @@ export async function resolveMessageChannelSelection(params: { } return { channel: availableExplicit, - configured: await listConfiguredMessageChannels(params.cfg), + configured: [], source: "explicit", }; } @@ -188,7 +188,7 @@ export async function resolveMessageChannelSelection(params: { if (fallback) { return { channel: fallback, - configured: await listConfiguredMessageChannels(params.cfg), + configured: [], source: "tool-context-fallback", }; } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index b0960c17a93..620eb4a0a1f 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; +import type { PluginBundleFormat } from "./types.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; @@ -13,7 +14,7 @@ import { } from "./bundle-manifest.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import type { PluginBundleFormat } from "./types.js"; +import { safeRealpathSync } from "./path-safety.js"; export type BundleMcpServerConfig = Record; @@ -121,6 +122,14 @@ function expandBundleRootPlaceholders(value: string, rootDir: string): string { return value.split(CLAUDE_PLUGIN_ROOT_PLACEHOLDER).join(rootDir); } +function canonicalizeBundlePath(targetPath: string): string { + return path.normalize(safeRealpathSync(targetPath) ?? path.resolve(targetPath)); +} + +function normalizeExpandedAbsolutePath(value: string): string { + return path.isAbsolute(value) ? path.normalize(value) : value; +} + function absolutizeBundleMcpServer(params: { rootDir: string; baseDir: string; @@ -137,7 +146,7 @@ function absolutizeBundleMcpServer(params: { const expanded = expandBundleRootPlaceholders(command, params.rootDir); next.command = isExplicitRelativePath(expanded) ? path.resolve(params.baseDir, expanded) - : expanded; + : normalizeExpandedAbsolutePath(expanded); } const cwd = next.cwd; @@ -150,7 +159,7 @@ function absolutizeBundleMcpServer(params: { if (typeof workingDirectory === "string") { const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir); next.workingDirectory = path.isAbsolute(expanded) - ? expanded + ? path.normalize(expanded) : path.resolve(params.baseDir, expanded); } @@ -161,7 +170,7 @@ function absolutizeBundleMcpServer(params: { } const expanded = expandBundleRootPlaceholders(entry, params.rootDir); if (!isExplicitRelativePath(expanded)) { - return expanded; + return normalizeExpandedAbsolutePath(expanded); } return path.resolve(params.baseDir, expanded); }); @@ -171,7 +180,9 @@ function absolutizeBundleMcpServer(params: { next.env = Object.fromEntries( Object.entries(next.env).map(([key, value]) => [ key, - typeof value === "string" ? expandBundleRootPlaceholders(value, params.rootDir) : value, + typeof value === "string" + ? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, params.rootDir)) + : value, ]), ); } @@ -183,10 +194,11 @@ function loadBundleFileBackedMcpConfig(params: { rootDir: string; relativePath: string; }): BundleMcpConfig { - const absolutePath = path.resolve(params.rootDir, params.relativePath); + const rootDir = canonicalizeBundlePath(params.rootDir); + const absolutePath = path.resolve(rootDir, params.relativePath); const opened = openBoundaryFileSync({ absolutePath, - rootPath: params.rootDir, + rootPath: rootDir, boundaryLabel: "plugin root", rejectHardlinks: true, }); @@ -200,12 +212,12 @@ function loadBundleFileBackedMcpConfig(params: { } const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; const servers = extractMcpServerMap(raw); - const baseDir = path.dirname(absolutePath); + const baseDir = canonicalizeBundlePath(path.dirname(absolutePath)); return { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ rootDir: params.rootDir, baseDir, server }), + absolutizeBundleMcpServer({ rootDir, baseDir, server }), ]), ), }; @@ -221,12 +233,13 @@ function loadBundleInlineMcpConfig(params: { if (!isRecord(params.raw.mcpServers)) { return { mcpServers: {} }; } + const baseDir = canonicalizeBundlePath(params.baseDir); const servers = extractMcpServerMap(params.raw.mcpServers); return { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ rootDir: params.baseDir, baseDir: params.baseDir, server }), + absolutizeBundleMcpServer({ rootDir: baseDir, baseDir, server }), ]), ), }; From 7a57082466bb1d9550cf55cb5e6abb94301529eb Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 21:28:48 +0530 Subject: [PATCH 006/293] fix(provider): onboard azure custom endpoints via responses --- CHANGELOG.md | 2 +- src/commands/onboard-custom.test.ts | 200 ++++++++++++++++++++++++++-- src/commands/onboard-custom.ts | 126 +++++++++++++++--- 3 files changed, 300 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a26a8e80b25..12cd1cb3095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,6 @@ Docs: https://docs.openclaw.ai - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. - Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. -- Contracts/Matrix: validate Matrix session binding coverage through the real manager, expose the manager on the Matrix runtime API, and let tests pass an explicit state directory for isolated contract setup. (#50369) thanks @ChroniCat. ### Fixes @@ -93,6 +92,7 @@ Docs: https://docs.openclaw.ai - Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28. - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman. - Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898. +- Onboarding/custom providers: store Azure OpenAI and Azure AI Foundry custom endpoints with the Responses API config shape, normalized `/openai/v1` base URLs, and Azure-safe defaults so TUI and agent runs work after setup. (#49543) Thanks @kunalk16. - Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman. - Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire. diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index cf86da64211..ef97b3e4f83 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -188,7 +188,7 @@ describe("promptCustomApiConfig", () => { expect(JSON.parse(firstCall?.body ?? "{}")).toMatchObject({ max_tokens: 1 }); }); - it("uses azure-specific headers and body for openai verification probes", async () => { + it("uses azure responses-specific headers and body for openai verification probes", async () => { const prompter = createTestPrompter({ text: [ "https://my-resource.openai.azure.com", @@ -213,18 +213,16 @@ describe("promptCustomApiConfig", () => { } const parsedBody = JSON.parse(firstInit?.body ?? "{}"); - expect(firstUrl).toContain("/openai/deployments/gpt-4.1/chat/completions"); - expect(firstUrl).toContain("api-version=2024-10-21"); + expect(firstUrl).toBe("https://my-resource.openai.azure.com/openai/v1/responses"); expect(firstInit?.headers?.["api-key"]).toBe("azure-test-key"); expect(firstInit?.headers?.Authorization).toBeUndefined(); expect(firstInit?.body).toBeDefined(); - expect(parsedBody).toMatchObject({ - messages: [{ role: "user", content: "Hi" }], - max_completion_tokens: 5, + expect(parsedBody).toEqual({ + model: "gpt-4.1", + input: "Hi", + max_output_tokens: 1, stream: false, }); - expect(parsedBody).not.toHaveProperty("model"); - expect(parsedBody).not.toHaveProperty("max_tokens"); }); it("uses expanded max_tokens for anthropic verification probes", async () => { @@ -432,6 +430,192 @@ describe("applyCustomApiConfig", () => { ])("rejects $name", ({ params, expectedMessage }) => { expect(() => applyCustomApiConfig(params)).toThrow(expectedMessage); }); + + it("produces azure-specific config for Azure OpenAI URLs with reasoning model", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://user123-resource.openai.azure.com", + modelId: "o4-mini", + compatibility: "openai", + apiKey: "abcd1234", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://user123-resource.openai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "abcd1234" }); + + const model = provider?.models?.find((m) => m.id === "o4-mini"); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.reasoning).toBe(true); + expect(model?.compat).toEqual({ supportsStore: false }); + + const modelRef = `${providerId}/${result.modelId}`; + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("medium"); + }); + + it("produces azure-specific config for Azure AI Foundry URLs", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://my-resource.services.ai.azure.com", + modelId: "gpt-4.1", + compatibility: "openai", + apiKey: "key123", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://my-resource.services.ai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "key123" }); + + const model = provider?.models?.find((m) => m.id === "gpt-4.1"); + expect(model?.reasoning).toBe(false); + expect(model?.input).toEqual(["text"]); + expect(model?.compat).toEqual({ supportsStore: false }); + + const modelRef = `${providerId}/gpt-4.1`; + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBeUndefined(); + }); + + it("strips pre-existing deployment path from Azure URL in stored config", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", + modelId: "gpt-4", + compatibility: "openai", + apiKey: "key456", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); + }); + + it("re-onboard updates existing Azure provider instead of creating a duplicate", () => { + const oldProviderId = "custom-my-resource-openai-azure-com"; + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + [oldProviderId]: { + baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", + api: "openai-completions", + models: [ + { + id: "gpt-4", + name: "gpt-4", + contextWindow: 1, + maxTokens: 1, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }, + baseUrl: "https://my-resource.openai.azure.com", + modelId: "gpt-4", + compatibility: "openai", + apiKey: "key789", + }); + + expect(result.providerId).toBe(oldProviderId); + expect(result.providerIdRenamedFrom).toBeUndefined(); + const provider = result.config.models?.providers?.[oldProviderId]; + expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "key789" }); + }); + + it("does not add azure fields for non-azure URLs", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "key123", + providerId: "custom", + }); + const provider = result.config.models?.providers?.custom; + + expect(provider?.api).toBe("openai-completions"); + expect(provider?.authHeader).toBeUndefined(); + expect(provider?.headers).toBeUndefined(); + expect(provider?.models?.[0]?.reasoning).toBe(false); + expect(provider?.models?.[0]?.input).toEqual(["text"]); + expect(provider?.models?.[0]?.compat).toBeUndefined(); + expect( + result.config.agents?.defaults?.models?.["custom/foo-large"]?.params?.thinking, + ).toBeUndefined(); + }); + + it("re-onboard preserves user-customized fields for non-azure models", () => { + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + custom: { + baseUrl: "https://llm.example.com/v1", + api: "openai-completions", + models: [ + { + id: "foo-large", + name: "My Custom Model", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 16384, + }, + ], + }, + }, + }, + } as OpenClawConfig, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "key", + providerId: "custom", + }); + const model = result.config.models?.providers?.custom?.models?.find( + (m) => m.id === "foo-large", + ); + expect(model?.name).toBe("My Custom Model"); + expect(model?.reasoning).toBe(true); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.cost).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }); + expect(model?.maxTokens).toBe(16384); + expect(model?.contextWindow).toBe(131072); + }); + + it("preserves existing per-model thinking when already set for azure reasoning model", () => { + const providerId = "custom-my-resource-openai-azure-com"; + const modelRef = `${providerId}/o3-mini`; + const result = applyCustomApiConfig({ + config: { + agents: { + defaults: { + models: { + [modelRef]: { params: { thinking: "high" } }, + }, + }, + }, + } as OpenClawConfig, + baseUrl: "https://my-resource.openai.azure.com", + modelId: "o3-mini", + compatibility: "openai", + apiKey: "key", + }); + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("high"); + }); }); describe("parseNonInteractiveCustomApiFlags", () => { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 9de8e3f85cf..bf4fc1edeea 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -19,6 +19,9 @@ import type { SecretInputMode } from "./onboard-types.js"; const DEFAULT_CONTEXT_WINDOW = CONTEXT_WINDOW_HARD_MIN_TOKENS; const DEFAULT_MAX_TOKENS = 4096; +// Azure OpenAI uses the Responses API which supports larger defaults +const AZURE_DEFAULT_CONTEXT_WINDOW = 400_000; +const AZURE_DEFAULT_MAX_TOKENS = 16_384; const VERIFY_TIMEOUT_MS = 30_000; function normalizeContextWindowForCustomModel(value: unknown): number { @@ -61,6 +64,32 @@ function transformAzureUrl(baseUrl: string, modelId: string): string { return `${normalizedUrl}/openai/deployments/${modelId}`; } +/** + * Transforms an Azure URL into the base URL stored in config. + * + * Example: + * https://my-resource.openai.azure.com + * => https://my-resource.openai.azure.com/openai/v1 + */ +function transformAzureConfigUrl(baseUrl: string): string { + const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + if (normalizedUrl.endsWith("/openai/v1")) { + return normalizedUrl; + } + // Strip a full deployment path back to the base origin + const deploymentIdx = normalizedUrl.indexOf("/openai/deployments/"); + const base = deploymentIdx !== -1 ? normalizedUrl.slice(0, deploymentIdx) : normalizedUrl; + return `${base}/openai/v1`; +} + +function hasSameHost(a: string, b: string): boolean { + try { + return new URL(a).hostname.toLowerCase() === new URL(b).hostname.toLowerCase(); + } catch { + return false; + } +} + export type CustomApiCompatibility = "openai" | "anthropic"; type CustomApiCompatibilityChoice = CustomApiCompatibility | "unknown"; export type CustomApiResult = { @@ -174,7 +203,11 @@ function resolveUniqueEndpointId(params: { }) { const normalized = normalizeEndpointId(params.requestedId) || "custom"; const existing = params.providers[normalized]; - if (!existing?.baseUrl || existing.baseUrl === params.baseUrl) { + if ( + !existing?.baseUrl || + existing.baseUrl === params.baseUrl || + (isAzureUrl(params.baseUrl) && hasSameHost(existing.baseUrl, params.baseUrl)) + ) { return { providerId: normalized, renamed: false }; } let suffix = 2; @@ -320,26 +353,31 @@ async function requestOpenAiVerification(params: { apiKey: string; modelId: string; }): Promise { - const endpoint = resolveVerificationEndpoint({ - baseUrl: params.baseUrl, - modelId: params.modelId, - endpointPath: "chat/completions", - }); const isBaseUrlAzureUrl = isAzureUrl(params.baseUrl); const headers = isBaseUrlAzureUrl ? buildAzureOpenAiHeaders(params.apiKey) : buildOpenAiHeaders(params.apiKey); if (isBaseUrlAzureUrl) { + const endpoint = new URL( + "responses", + transformAzureConfigUrl(params.baseUrl).replace(/\/?$/, "/"), + ).href; return await requestVerification({ endpoint, headers, body: { - messages: [{ role: "user", content: "Hi" }], - max_completion_tokens: 5, + model: params.modelId, + input: "Hi", + max_output_tokens: 1, stream: false, }, }); } else { + const endpoint = resolveVerificationEndpoint({ + baseUrl: params.baseUrl, + modelId: params.modelId, + endpointPath: "chat/completions", + }); return await requestVerification({ endpoint, headers, @@ -572,8 +610,9 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom throw new CustomApiError("invalid_model_id", "Custom provider model ID is required."); } + const isAzure = isAzureUrl(baseUrl); // Transform Azure URLs to include the deployment path for API calls - const resolvedBaseUrl = isAzureUrl(baseUrl) ? transformAzureUrl(baseUrl, modelId) : baseUrl; + const resolvedBaseUrl = isAzure ? transformAzureConfigUrl(baseUrl) : baseUrl; const providerIdResult = resolveCustomProviderId({ config: params.config, @@ -597,21 +636,39 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom const existingProvider = providers[providerId]; const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; const hasModel = existingModels.some((model) => model.id === modelId); - const nextModel = { - id: modelId, - name: `${modelId} (Custom Provider)`, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - input: ["text"] as ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }; + const isLikelyReasoningModel = isAzure && /\b(o[134]|gpt-([5-9]|\d{2,}))\b/i.test(modelId); + const nextModel = isAzure + ? { + id: modelId, + name: `${modelId} (Custom Provider)`, + contextWindow: AZURE_DEFAULT_CONTEXT_WINDOW, + maxTokens: AZURE_DEFAULT_MAX_TOKENS, + input: isLikelyReasoningModel + ? (["text", "image"] as Array<"text" | "image">) + : (["text"] as ["text"]), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: isLikelyReasoningModel, + compat: { supportsStore: false }, + } + : { + id: modelId, + name: `${modelId} (Custom Provider)`, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + input: ["text"] as ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }; const mergedModels = hasModel ? existingModels.map((model) => model.id === modelId ? { ...model, + ...(isAzure ? nextModel : {}), + name: model.name ?? nextModel.name, + cost: model.cost ?? nextModel.cost, contextWindow: normalizeContextWindowForCustomModel(model.contextWindow), + maxTokens: model.maxTokens ?? nextModel.maxTokens, } : model, ) @@ -621,6 +678,11 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom normalizeOptionalProviderApiKey(params.apiKey) ?? normalizeOptionalProviderApiKey(existingApiKey); + const providerApi = isAzure + ? ("openai-responses" as const) + : resolveProviderApi(params.compatibility); + const azureHeaders = isAzure && normalizedApiKey ? { "api-key": normalizedApiKey } : undefined; + let config: OpenClawConfig = { ...params.config, models: { @@ -631,8 +693,10 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom [providerId]: { ...existingProviderRest, baseUrl: resolvedBaseUrl, - api: resolveProviderApi(params.compatibility), + api: providerApi, ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + ...(isAzure ? { authHeader: false } : {}), + ...(azureHeaders ? { headers: azureHeaders } : {}), models: mergedModels.length > 0 ? mergedModels : [nextModel], }, }, @@ -640,6 +704,30 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom }; config = applyPrimaryModel(config, modelRef); + if (isAzure && isLikelyReasoningModel) { + const existingPerModelThinking = config.agents?.defaults?.models?.[modelRef]?.params?.thinking; + if (!existingPerModelThinking) { + config = { + ...config, + agents: { + ...config.agents, + defaults: { + ...config.agents?.defaults, + models: { + ...config.agents?.defaults?.models, + [modelRef]: { + ...config.agents?.defaults?.models?.[modelRef], + params: { + ...config.agents?.defaults?.models?.[modelRef]?.params, + thinking: "medium", + }, + }, + }, + }, + }, + }; + } + } if (alias) { config = { ...config, From 5b1836d700410461a43e9ec0ae4183963286fc7e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 21:42:06 +0530 Subject: [PATCH 007/293] fix(onboard): raise azure probe output floor --- src/commands/onboard-custom.test.ts | 2 +- src/commands/onboard-custom.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index ef97b3e4f83..a8a6adc52f6 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -220,7 +220,7 @@ describe("promptCustomApiConfig", () => { expect(parsedBody).toEqual({ model: "gpt-4.1", input: "Hi", - max_output_tokens: 1, + max_output_tokens: 16, stream: false, }); }); diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index bf4fc1edeea..a24a113cbb7 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -368,7 +368,7 @@ async function requestOpenAiVerification(params: { body: { model: params.modelId, input: "Hi", - max_output_tokens: 1, + max_output_tokens: 16, stream: false, }, }); From 91104ac74057bc75ce58dfb55ff01e877ec73a0a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 22:04:33 +0530 Subject: [PATCH 008/293] fix(onboard): respect services.ai custom provider compatibility --- CHANGELOG.md | 1 + src/commands/onboard-custom.test.ts | 42 +++++++++++++++++++++++++++-- src/commands/onboard-custom.ts | 30 +++++++++++++-------- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd1cb3095..b2c66c05ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,7 @@ Docs: https://docs.openclaw.ai - Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant. - WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67. - Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo. +- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. ### Breaking diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index a8a6adc52f6..7917d45ca8f 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -225,6 +225,44 @@ describe("promptCustomApiConfig", () => { }); }); + it("uses Azure Foundry chat-completions probes for services.ai URLs", async () => { + const prompter = createTestPrompter({ + text: [ + "https://my-resource.services.ai.azure.com", + "azure-test-key", + "deepseek-v3-0324", + "custom", + "alias", + ], + select: ["plaintext", "openai"], + }); + const fetchMock = stubFetchSequence([{ ok: true }]); + + await runPromptCustomApi(prompter); + + const firstCall = fetchMock.mock.calls[0]; + const firstUrl = firstCall?.[0]; + const firstInit = firstCall?.[1] as + | { body?: string; headers?: Record } + | undefined; + if (typeof firstUrl !== "string") { + throw new Error("Expected first verification call URL"); + } + const parsedBody = JSON.parse(firstInit?.body ?? "{}"); + + expect(firstUrl).toBe( + "https://my-resource.services.ai.azure.com/openai/deployments/deepseek-v3-0324/chat/completions?api-version=2024-10-21", + ); + expect(firstInit?.headers?.["api-key"]).toBe("azure-test-key"); + expect(firstInit?.headers?.Authorization).toBeUndefined(); + expect(parsedBody).toEqual({ + model: "deepseek-v3-0324", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 1, + stream: false, + }); + }); + it("uses expanded max_tokens for anthropic verification probes", async () => { const prompter = createTestPrompter({ text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], @@ -456,7 +494,7 @@ describe("applyCustomApiConfig", () => { expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("medium"); }); - it("produces azure-specific config for Azure AI Foundry URLs", () => { + it("keeps selected compatibility for Azure AI Foundry URLs", () => { const result = applyCustomApiConfig({ config: {}, baseUrl: "https://my-resource.services.ai.azure.com", @@ -468,7 +506,7 @@ describe("applyCustomApiConfig", () => { const provider = result.config.models?.providers?.[providerId]; expect(provider?.baseUrl).toBe("https://my-resource.services.ai.azure.com/openai/v1"); - expect(provider?.api).toBe("openai-responses"); + expect(provider?.api).toBe("openai-completions"); expect(provider?.authHeader).toBe(false); expect(provider?.headers).toEqual({ "api-key": "key123" }); diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index a24a113cbb7..5afab742448 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -29,22 +29,30 @@ function normalizeContextWindowForCustomModel(value: unknown): number { return parsed >= CONTEXT_WINDOW_HARD_MIN_TOKENS ? parsed : CONTEXT_WINDOW_HARD_MIN_TOKENS; } -/** - * Detects if a URL is from Azure AI Foundry or Azure OpenAI. - * Matches both: - * - https://*.services.ai.azure.com (Azure AI Foundry) - * - https://*.openai.azure.com (classic Azure OpenAI) - */ -function isAzureUrl(baseUrl: string): boolean { +function isAzureFoundryUrl(baseUrl: string): boolean { try { const url = new URL(baseUrl); const host = url.hostname.toLowerCase(); - return host.endsWith(".services.ai.azure.com") || host.endsWith(".openai.azure.com"); + return host.endsWith(".services.ai.azure.com"); } catch { return false; } } +function isAzureOpenAiUrl(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + const host = url.hostname.toLowerCase(); + return host.endsWith(".openai.azure.com"); + } catch { + return false; + } +} + +function isAzureUrl(baseUrl: string): boolean { + return isAzureFoundryUrl(baseUrl) || isAzureOpenAiUrl(baseUrl); +} + /** * Transforms an Azure AI Foundry/OpenAI URL to include the deployment path. * Azure requires: https://host/openai/deployments//chat/completions?api-version=2024-xx-xx-preview @@ -357,7 +365,7 @@ async function requestOpenAiVerification(params: { const headers = isBaseUrlAzureUrl ? buildAzureOpenAiHeaders(params.apiKey) : buildOpenAiHeaders(params.apiKey); - if (isBaseUrlAzureUrl) { + if (isAzureOpenAiUrl(params.baseUrl)) { const endpoint = new URL( "responses", transformAzureConfigUrl(params.baseUrl).replace(/\/?$/, "/"), @@ -611,7 +619,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom } const isAzure = isAzureUrl(baseUrl); - // Transform Azure URLs to include the deployment path for API calls + const isAzureOpenAi = isAzureOpenAiUrl(baseUrl); const resolvedBaseUrl = isAzure ? transformAzureConfigUrl(baseUrl) : baseUrl; const providerIdResult = resolveCustomProviderId({ @@ -678,7 +686,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom normalizeOptionalProviderApiKey(params.apiKey) ?? normalizeOptionalProviderApiKey(existingApiKey); - const providerApi = isAzure + const providerApi = isAzureOpenAi ? ("openai-responses" as const) : resolveProviderApi(params.compatibility); const azureHeaders = isAzure && normalizedApiKey ? { "api-key": normalizedApiKey } : undefined; From f1e4f8e8d2784d4455f64fe552878bb84067d790 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 22:06:10 +0530 Subject: [PATCH 009/293] fix: add changelog attribution for Azure Foundry custom providers (#50535) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c66c05ac5..50f4c317fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,7 +163,7 @@ Docs: https://docs.openclaw.ai - Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant. - WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67. - Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo. -- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. +- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus. ### Breaking From dcbcecfb85e722156e4a9c698ded3972c0da9689 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:38:30 -0700 Subject: [PATCH 010/293] fix(ci): resolve Claude marketplace shortcuts from OS home --- src/infra/home-dir.test.ts | 30 +++++++++++++++++++++ src/infra/home-dir.ts | 46 ++++++++++++++++++++++++++++++--- src/plugins/marketplace.test.ts | 4 ++- src/plugins/marketplace.ts | 3 ++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index 9faeda1dee5..2382b56eaac 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -4,6 +4,8 @@ import { expandHomePrefix, resolveEffectiveHomeDir, resolveHomeRelativePath, + resolveOsHomeDir, + resolveOsHomeRelativePath, resolveRequiredHomeDir, } from "./home-dir.js"; @@ -95,6 +97,21 @@ describe("resolveRequiredHomeDir", () => { }); }); +describe("resolveOsHomeDir", () => { + it("ignores OPENCLAW_HOME and uses HOME", () => { + expect( + resolveOsHomeDir( + { + OPENCLAW_HOME: "/srv/openclaw-home", + HOME: "/home/alice", + USERPROFILE: "C:/Users/alice", + } as NodeJS.ProcessEnv, + () => "/fallback", + ), + ).toBe(path.resolve("/home/alice")); + }); +}); + describe("expandHomePrefix", () => { it.each([ { @@ -158,3 +175,16 @@ describe("resolveHomeRelativePath", () => { ).toBe(path.resolve(process.cwd())); }); }); + +describe("resolveOsHomeRelativePath", () => { + it("expands tilde paths using the OS home instead of OPENCLAW_HOME", () => { + expect( + resolveOsHomeRelativePath("~/docs", { + env: { + OPENCLAW_HOME: "/srv/openclaw-home", + HOME: "/home/alice", + } as NodeJS.ProcessEnv, + }), + ).toBe(path.resolve("/home/alice/docs")); + }); +}); diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 650cf0cadac..956eeebb278 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -14,12 +14,19 @@ export function resolveEffectiveHomeDir( return raw ? path.resolve(raw) : undefined; } +export function resolveOsHomeDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string | undefined { + const raw = resolveRawOsHomeDir(env, homedir); + return raw ? path.resolve(raw) : undefined; +} + function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined { const explicitHome = normalize(env.OPENCLAW_HOME); if (explicitHome) { if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { - const fallbackHome = - normalize(env.HOME) ?? normalize(env.USERPROFILE) ?? normalizeSafe(homedir); + const fallbackHome = resolveRawOsHomeDir(env, homedir); if (fallbackHome) { return explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome); } @@ -28,16 +35,18 @@ function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): strin return explicitHome; } + return resolveRawOsHomeDir(env, homedir); +} + +function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined { const envHome = normalize(env.HOME); if (envHome) { return envHome; } - const userProfile = normalize(env.USERPROFILE); if (userProfile) { return userProfile; } - return normalizeSafe(homedir); } @@ -56,6 +65,13 @@ export function resolveRequiredHomeDir( return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd()); } +export function resolveRequiredOsHomeDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + return resolveOsHomeDir(env, homedir) ?? path.resolve(process.cwd()); +} + export function expandHomePrefix( input: string, opts?: { @@ -97,3 +113,25 @@ export function resolveHomeRelativePath( } return path.resolve(trimmed); } + +export function resolveOsHomeRelativePath( + input: string, + opts?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + }, +): string { + const trimmed = input.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed.startsWith("~")) { + const expanded = expandHomePrefix(trimmed, { + home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir), + env: opts?.env, + homedir: opts?.homedir, + }); + return path.resolve(expanded); + } + return path.resolve(trimmed); +} diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 92918e256d4..6ae2b010556 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -111,7 +111,9 @@ describe("marketplace plugins", () => { it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => { await withTempDir(async (homeDir) => { + const openClawHome = path.join(homeDir, "openclaw-home"); await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true }); + await fs.mkdir(openClawHome, { recursive: true }); await fs.writeFile( path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), JSON.stringify({ @@ -127,7 +129,7 @@ describe("marketplace plugins", () => { const { resolveMarketplaceInstallShortcut } = await import("./marketplace.js"); const shortcut = await withEnvAsync( - { HOME: homeDir }, + { HOME: homeDir, OPENCLAW_HOME: openClawHome }, async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"), ); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 4999c3c8828..24d2fae8ba1 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveArchiveKind } from "../infra/archive.js"; +import { resolveOsHomeRelativePath } from "../infra/home-dir.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { installPluginFromPath, type InstallPluginResult } from "./install.js"; @@ -299,7 +300,7 @@ async function pathExists(target: string): Promise { } async function readClaudeKnownMarketplaces(): Promise> { - const knownPath = resolveUserPath(CLAUDE_KNOWN_MARKETPLACES_PATH); + const knownPath = resolveOsHomeRelativePath(CLAUDE_KNOWN_MARKETPLACES_PATH); if (!(await pathExists(knownPath))) { return {}; } From 639f78d257f6568ce3c7b5d47e024ceaaf0252f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:38:35 -0700 Subject: [PATCH 011/293] style(format): restore import order drift --- src/infra/outbound/channel-selection.ts | 2 +- src/plugins/bundle-mcp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index f9c6f558769..569ea343c52 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,6 @@ +import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { listChannelPlugins } from "../../channels/plugins/index.js"; import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 620eb4a0a1f..ebe1b369f3c 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import type { PluginBundleFormat } from "./types.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; @@ -15,6 +14,7 @@ import { import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { safeRealpathSync } from "./path-safety.js"; +import type { PluginBundleFormat } from "./types.js"; export type BundleMcpServerConfig = Record; From 7fb142d11525ff528539d62398e3843d6d9b0255 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:42:13 -0700 Subject: [PATCH 012/293] test(whatsapp): override config-runtime mock exports safely --- extensions/whatsapp/src/test-helpers.ts | 90 +++++++++++++++---------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 74c5f8c3584..b71f25f9d63 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -36,44 +36,64 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); - Object.defineProperty(mockModule, "loadConfig", { - configurable: true, - enumerable: true, - writable: true, - value: () => { - const getter = (globalThis as Record)[CONFIG_KEY]; - if (typeof getter === "function") { - return getter(); - } - return DEFAULT_CONFIG; + Object.defineProperties(mockModule, { + loadConfig: { + configurable: true, + enumerable: true, + writable: true, + value: () => { + const getter = (globalThis as Record)[CONFIG_KEY]; + if (typeof getter === "function") { + return getter(); + } + return DEFAULT_CONFIG; + }, }, - }); - Object.assign(mockModule, { - updateLastRoute: async (params: { - storePath: string; - sessionKey: string; - deliveryContext: { channel: string; to: string; accountId?: string }; - }) => { - const raw = await fs.readFile(params.storePath, "utf8").catch(() => "{}"); - const store = JSON.parse(raw) as Record>; - const current = store[params.sessionKey] ?? {}; - store[params.sessionKey] = { - ...current, - lastChannel: params.deliveryContext.channel, - lastTo: params.deliveryContext.to, - lastAccountId: params.deliveryContext.accountId, - }; - await fs.writeFile(params.storePath, JSON.stringify(store)); + updateLastRoute: { + configurable: true, + enumerable: true, + writable: true, + value: async (params: { + storePath: string; + sessionKey: string; + deliveryContext: { channel: string; to: string; accountId?: string }; + }) => { + const raw = await fs.readFile(params.storePath, "utf8").catch(() => "{}"); + const store = JSON.parse(raw) as Record>; + const current = store[params.sessionKey] ?? {}; + store[params.sessionKey] = { + ...current, + lastChannel: params.deliveryContext.channel, + lastTo: params.deliveryContext.to, + lastAccountId: params.deliveryContext.accountId, + }; + await fs.writeFile(params.storePath, JSON.stringify(store)); + }, }, - loadSessionStore: (storePath: string) => { - try { - return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record; - } catch { - return {}; - } + loadSessionStore: { + configurable: true, + enumerable: true, + writable: true, + value: (storePath: string) => { + try { + return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record; + } catch { + return {}; + } + }, + }, + recordSessionMetaFromInbound: { + configurable: true, + enumerable: true, + writable: true, + value: async () => undefined, + }, + resolveStorePath: { + configurable: true, + enumerable: true, + writable: true, + value: actual.resolveStorePath, }, - recordSessionMetaFromInbound: async () => undefined, - resolveStorePath: actual.resolveStorePath, }); return mockModule; }); From 401ffb59f538488349664fa42e554dfb36d53a3a Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 19 Mar 2026 12:51:10 -0400 Subject: [PATCH 013/293] CLI: support versioned plugin updates (#49998) Merged via squash. Prepared head SHA: 545ea60fa26bb742376237ca83c65665133bcf7c Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/cli/plugins.md | 14 +++- docs/tools/plugin.md | 2 +- src/cli/plugins-cli.test.ts | 134 ++++++++++++++++++++++++++++++++++++ src/cli/plugins-cli.ts | 59 +++++++++++++++- src/plugins/update.test.ts | 123 +++++++++++++++++++++++++++++++++ src/plugins/update.ts | 24 ++++--- 7 files changed, 345 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f4c317fb1..9a37dfe581c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67. - Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo. - Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus. +- Plugins/update: let `openclaw plugins update ` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo. ### Breaking diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 47ef4930b8a..3d4c482707f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -138,14 +138,24 @@ state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/`). Use ### Update ```bash -openclaw plugins update +openclaw plugins update openclaw plugins update --all -openclaw plugins update --dry-run +openclaw plugins update --dry-run +openclaw plugins update @openclaw/voice-call@beta ``` Updates apply to tracked installs in `plugins.installs`, currently npm and marketplace installs. +When you pass a plugin id, OpenClaw reuses the recorded install spec for that +plugin. That means previously stored dist-tags such as `@beta` and exact pinned +versions continue to be used on later `update ` runs. + +For npm installs, you can also pass an explicit npm package spec with a dist-tag +or exact version. OpenClaw resolves that package name back to the tracked plugin +record, updates that installed plugin, and records the new npm spec for future +id-based updates. + When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use global `--yes` to bypass prompts in CI/non-interactive runs. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 48b60d3fe1d..16291eab32d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -286,7 +286,7 @@ openclaw plugins install ./plugin.zip # install from a local zip openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev openclaw plugins install @openclaw/voice-call # install from npm openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version -openclaw plugins update +openclaw plugins update openclaw plugins update --all openclaw plugins enable openclaw plugins disable diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index 50bc8633e70..4efb1990354 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -379,6 +379,140 @@ describe("plugins cli", () => { expect(runtimeLogs.at(-1)).toBe("No tracked plugins to update."); }); + it("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "openclaw-codex-app-server@beta"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@beta", + }, + }), + ); + }); + + it("maps an explicit scoped npm dist-tag update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + resolvedName: "@openclaw/voice-call", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "@openclaw/voice-call@beta"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["voice-call"], + specOverrides: { + "voice-call": "@openclaw/voice-call@beta", + }, + }), + ); + }); + + it("maps an explicit npm version update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "openclaw-codex-app-server@0.2.0-beta.4"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", + }, + }), + ); + }); + + it("keeps using the recorded npm tag when update is invoked by plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "openclaw-codex-app-server"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + }), + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalledWith( + expect.objectContaining({ + specOverrides: expect.anything(), + }), + ); + }); + it("writes updated config when updater reports changes", async () => { const cfg = { plugins: { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 79fca829281..93e3d22c8d5 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -7,6 +7,7 @@ import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveArchiveKind } from "../infra/archive.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; @@ -227,6 +228,56 @@ function createPluginInstallLogger(): { info: (msg: string) => void; warn: (msg: }; } +function extractInstalledNpmPackageName(install: PluginInstallRecord): string | undefined { + if (install.source !== "npm") { + return undefined; + } + const resolvedName = install.resolvedName?.trim(); + if (resolvedName) { + return resolvedName; + } + return ( + (install.spec ? parseRegistryNpmSpec(install.spec)?.name : undefined) ?? + (install.resolvedSpec ? parseRegistryNpmSpec(install.resolvedSpec)?.name : undefined) + ); +} + +function resolvePluginUpdateSelection(params: { + installs: Record; + rawId?: string; + all?: boolean; +}): { pluginIds: string[]; specOverrides?: Record } { + if (params.all) { + return { pluginIds: Object.keys(params.installs) }; + } + if (!params.rawId) { + return { pluginIds: [] }; + } + + const parsedSpec = parseRegistryNpmSpec(params.rawId); + if (!parsedSpec || parsedSpec.selectorKind === "none") { + return { pluginIds: [params.rawId] }; + } + + const matches = Object.entries(params.installs).filter(([, install]) => { + return extractInstalledNpmPackageName(install) === parsedSpec.name; + }); + if (matches.length !== 1) { + return { pluginIds: [params.rawId] }; + } + + const [pluginId] = matches[0]; + if (!pluginId) { + return { pluginIds: [params.rawId] }; + } + return { + pluginIds: [pluginId], + specOverrides: { + [pluginId]: parsedSpec.raw, + }, + }; +} + function logSlotWarnings(warnings: string[]) { if (warnings.length === 0) { return; @@ -1032,7 +1083,12 @@ export function registerPluginsCli(program: Command) { .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const cfg = loadConfig(); const installs = cfg.plugins?.installs ?? {}; - const targets = opts.all ? Object.keys(installs) : id ? [id] : []; + const selection = resolvePluginUpdateSelection({ + installs, + rawId: id, + all: opts.all, + }); + const targets = selection.pluginIds; if (targets.length === 0) { if (opts.all) { @@ -1046,6 +1102,7 @@ export function registerPluginsCli(program: Command) { const result = await updateNpmInstalledPlugins({ config: cfg, pluginIds: targets, + specOverrides: selection.specOverrides, dryRun: opts.dryRun, logger: { info: (msg) => defaultRuntime.log(msg), diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 7e93ab7ba50..96c15443ded 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -161,6 +161,129 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("reuses a recorded npm dist-tag spec for id-based updates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.3", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }), + ); + expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({ + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + }); + }); + + it("uses and persists an explicit npm spec override during updates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }, + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@beta", + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }), + ); + expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({ + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }); + }); + + it("skips recorded integrity checks when an explicit npm version override changes the spec", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + }); + + await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@0.2.0-beta.3", + integrity: "sha512-old", + installPath: "/tmp/openclaw-codex-app-server", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@0.2.0-beta.4", + expectedIntegrity: undefined, + }), + ); + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 83733159cac..6898135e527 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -291,6 +291,7 @@ export async function updateNpmInstalledPlugins(params: { pluginIds?: string[]; skipIds?: Set; dryRun?: boolean; + specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; @@ -329,7 +330,14 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (record.source === "npm" && !record.spec) { + const effectiveSpec = + record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : undefined; + const expectedIntegrity = + record.source === "npm" && effectiveSpec === record.spec + ? expectedIntegrityForUpdate(record.spec, record.integrity) + : undefined; + + if (record.source === "npm" && !effectiveSpec) { outcomes.push({ pluginId, status: "skipped", @@ -371,11 +379,11 @@ export async function updateNpmInstalledPlugins(params: { probe = record.source === "npm" ? await installPluginFromNpmSpec({ - spec: record.spec!, + spec: effectiveSpec!, mode: "update", dryRun: true, expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: true, @@ -408,7 +416,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: record.spec!, + spec: effectiveSpec!, phase: "check", result: probe, }) @@ -452,10 +460,10 @@ export async function updateNpmInstalledPlugins(params: { result = record.source === "npm" ? await installPluginFromNpmSpec({ - spec: record.spec!, + spec: effectiveSpec!, mode: "update", expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: false, @@ -487,7 +495,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: record.spec!, + spec: effectiveSpec!, phase: "update", result: result, }) @@ -512,7 +520,7 @@ export async function updateNpmInstalledPlugins(params: { next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "npm", - spec: record.spec, + spec: effectiveSpec, installPath: result.targetDir, version: nextVersion, ...buildNpmResolutionInstallFields(result.npmResolution), From 3dfd8eef7f949b640f5f1e21cf7767578458ea46 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:55:43 -0700 Subject: [PATCH 014/293] ci(node22): drop duplicate config docs check from compat lane --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96ab35a297e..8f87c816488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -496,7 +496,9 @@ jobs: run: pnpm test - name: Verify npm pack under Node 22 - run: pnpm release:check + run: | + node scripts/stage-bundled-plugin-runtime-deps.mjs + node --import tsx scripts/release-check.ts skills-python: needs: [docs-scope, changed-scope] From 36f394c299a91301a84c455be2bdb418eeb2d08e Mon Sep 17 00:00:00 2001 From: fuller-stack-dev Date: Thu, 19 Mar 2026 11:16:40 -0600 Subject: [PATCH 015/293] fix(gateway): increase WS handshake timeout from 3s to 10s (#49262) * fix(gateway): increase WS handshake timeout from 3s to 10s The 3-second default is too aggressive when the event loop is under load (concurrent sessions, compaction, agent turns), causing spurious 'gateway closed (1000)' errors on CLI commands like `openclaw cron list`. Changes: - Increase DEFAULT_HANDSHAKE_TIMEOUT_MS from 3_000 to 10_000 - Add OPENCLAW_HANDSHAKE_TIMEOUT_MS env var for user override (no VITEST gate) - Keep OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS as fallback for existing tests Fixes #46892 * fix: restore VITEST guard on test env var, use || for empty-string fallback, fix formatting * fix: cover gateway handshake timeout env override (#49262) (thanks @fuller-stack-dev) --------- Co-authored-by: Wilfred Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/gateway/server-constants.ts | 10 +++++--- .../server.auth.default-token.suite.ts | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a37dfe581c..43aff8bd18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305. - Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. - ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman. +- Gateway/WS handshake: raise the default pre-auth handshake timeout to 10 seconds and add `OPENCLAW_HANDSHAKE_TIMEOUT_MS` as a runtime override so busy local gateways stop dropping healthy CLI connections at 3 seconds. (#49262) Thanks @fuller-stack-dev. - Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk. - Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham. - Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw. diff --git a/src/gateway/server-constants.ts b/src/gateway/server-constants.ts index 036ebc5b3fa..54dc3f794b6 100644 --- a/src/gateway/server-constants.ts +++ b/src/gateway/server-constants.ts @@ -21,10 +21,14 @@ export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => { maxChatHistoryMessagesBytes = value; } }; -export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3_000; +export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000; export const getHandshakeTimeoutMs = () => { - if (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS) { - const parsed = Number(process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + // User-facing env var (works in all environments); test-only var gated behind VITEST + const envKey = + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || + (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + if (envKey) { + const parsed = Number(envKey); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 4d090b78cb3..ed15150a029 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -93,6 +93,29 @@ export function registerDefaultAuthTokenSuite(): void { } }); + test("prefers OPENCLAW_HANDSHAKE_TIMEOUT_MS and falls back on empty string", () => { + const prevHandshakeTimeout = process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS; + const prevTestHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = "75"; + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20"; + try { + expect(getHandshakeTimeoutMs()).toBe(75); + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = ""; + expect(getHandshakeTimeoutMs()).toBe(20); + } finally { + if (prevHandshakeTimeout === undefined) { + delete process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; + } + if (prevTestHandshakeTimeout === undefined) { + delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = prevTestHandshakeTimeout; + } + } + }); + test("connect (req) handshake returns hello-ok payload", async () => { const { STATE_DIR, createConfigIO } = await import("../config/config.js"); const ws = await openWs(port); From 65a2917c8f741b464e3d883a104c2422a1aa4b95 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:27:41 -0700 Subject: [PATCH 016/293] docs: remove pi-mono jargon, fix features list, update Perplexity config path --- docs/concepts/agent.md | 15 ++++++--------- docs/concepts/features.md | 7 +------ docs/providers/perplexity-provider.md | 10 ++++++++-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 26d677745e4..57aff200e04 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -1,13 +1,13 @@ --- -summary: "Agent runtime (embedded pi-mono), workspace contract, and session bootstrap" +summary: "Agent runtime, workspace contract, and session bootstrap" read_when: - Changing agent runtime, workspace bootstrap, or session behavior title: "Agent Runtime" --- -# Agent Runtime 🤖 +# Agent Runtime -OpenClaw runs a single embedded agent runtime derived from **pi-mono**. +OpenClaw runs a single embedded agent runtime. ## Workspace (required) @@ -63,12 +63,9 @@ OpenClaw loads skills from three locations (workspace wins on name conflict): Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)). -## pi-mono integration +## Runtime boundaries -OpenClaw reuses pieces of the pi-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**. - -- No pi-coding agent runtime. -- No `~/.pi/agent` or `/.pi` settings are consulted. +Session management, discovery, and tool wiring are OpenClaw-owned. ## Sessions @@ -77,7 +74,7 @@ Session transcripts are stored as JSONL at: - `~/.openclaw/agents//sessions/.jsonl` The session ID is stable and chosen by OpenClaw. -Legacy Pi/Tau session folders are **not** read. +Legacy session folders from other tools are not read. ## Steering while streaming diff --git a/docs/concepts/features.md b/docs/concepts/features.md index 03528032b40..47e0d804c5d 100644 --- a/docs/concepts/features.md +++ b/docs/concepts/features.md @@ -37,7 +37,7 @@ title: "Features" - Discord bot support (channels.discord.js) - Mattermost bot support (plugin) - iMessage integration via local imsg CLI (macOS) -- Agent bridge for Pi in RPC mode with tool streaming +- Embedded agent runtime with tool streaming - Streaming and chunking for long responses - Multi-agent routing for isolated sessions per workspace or sender - Subscription auth for Anthropic and OpenAI via OAuth @@ -48,8 +48,3 @@ title: "Features" - WebChat and macOS menu bar app - iOS node with pairing, Canvas, camera, screen recording, location, and voice features - Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera, plus device, notifications, contacts/calendar, motion, photos, and SMS commands - - -Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only -coding agent path. - diff --git a/docs/providers/perplexity-provider.md b/docs/providers/perplexity-provider.md index c0945627e39..63880385353 100644 --- a/docs/providers/perplexity-provider.md +++ b/docs/providers/perplexity-provider.md @@ -18,14 +18,20 @@ This page covers the Perplexity **provider** setup. For the Perplexity - Type: web search provider (not a model provider) - Auth: `PERPLEXITY_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter) -- Config path: `tools.web.search.perplexity.apiKey` +- Config path: `plugins.entries.perplexity.config.webSearch.apiKey` ## Quick start 1. Set the API key: ```bash -openclaw config set tools.web.search.perplexity.apiKey "pplx-xxxxxxxxxxxx" +openclaw configure --section web +``` + +Or set it directly: + +```bash +openclaw config set plugins.entries.perplexity.config.webSearch.apiKey "pplx-xxxxxxxxxxxx" ``` 2. The agent will automatically use Perplexity for web searches when configured. From 1dd857f6a6a43b0f47999ec0b8d9021e1c009909 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:28:44 -0700 Subject: [PATCH 017/293] docs: add API key prereq, first-message step, fix landing page quick start --- docs/index.md | 12 +++++++---- docs/start/getting-started.md | 39 +++++++++++++---------------------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/docs/index.md b/docs/index.md index 25162bc9676..270f0835287 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,15 +106,19 @@ The Gateway is the single source of truth for sessions, routing, and channel con openclaw onboard --install-daemon ``` - + + Open the Control UI in your browser and send a message: + ```bash - openclaw channels login - openclaw gateway --port 18789 + openclaw dashboard ``` + + Or connect a channel ([Telegram](/channels/telegram) is fastest) and chat from your phone. + -Need the full install and dev setup? See [Quick start](/start/quickstart). +Need the full install and dev setup? See [Getting Started](/start/getting-started). ## Dashboard diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index bd3f554cdc4..fa719093739 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -20,9 +20,11 @@ Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui). ## Prereqs - Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported for compatibility) +- An API key from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you for this Check your Node version with `node --version` if you are unsure. +Windows users: WSL2 is strongly recommended. See [Windows](/platforms/windows). ## Quick setup (CLI) @@ -73,34 +75,21 @@ Check your Node version with `node --version` if you are unsure. ```bash openclaw dashboard ``` + + If the Control UI loads, your Gateway is ready. + + + + The fastest way to chat is directly in the Control UI browser tab. + Type a message and you should get an AI reply. + + Want to chat from a messaging app instead? The fastest channel setup + is usually [Telegram](/channels/telegram) (just a bot token, no QR + pairing). See [Channels](/channels) for all options. + - -If the Control UI loads, your Gateway is ready for use. - - -## Optional checks and extras - - - - Useful for quick tests or troubleshooting. - - ```bash - openclaw gateway --port 18789 - ``` - - - - Requires a configured channel. - - ```bash - openclaw message send --target +15555550123 --message "Hello from OpenClaw" - ``` - - - - ## Useful environment variables If you run OpenClaw as a service account or want custom config/state locations: From 624d5365513eb230545ac2c5ef9270f69025dcf5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:29:57 -0700 Subject: [PATCH 018/293] docs: remove quickstart stub from hubs, add redirect to getting-started --- docs/docs.json | 11 ++++++++++- docs/start/hubs.md | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index e80697ac63d..772a8a476cd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -47,6 +47,10 @@ ] }, "redirects": [ + { + "source": "/start/quickstart", + "destination": "/start/getting-started" + }, { "source": "/messages", "destination": "/concepts/messages" @@ -880,6 +884,7 @@ "group": "Hosting and deployment", "pages": [ "vps", + "install/docker-vm-runtime", "install/kubernetes", "install/fly", "install/hetzner", @@ -1024,7 +1029,8 @@ "pages": [ "tools/browser", "tools/browser-login", - "tools/browser-linux-troubleshooting" + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" ] }, { @@ -1211,6 +1217,7 @@ "gateway/heartbeat", "gateway/doctor", "gateway/logging", + "logging", "gateway/gateway-lock", "gateway/background-process", "gateway/multiple-gateways", @@ -1241,6 +1248,7 @@ { "group": "Networking and discovery", "pages": [ + "network", "gateway/network-model", "gateway/pairing", "gateway/discovery", @@ -1278,6 +1286,7 @@ "cli/agent", "cli/agents", "cli/approvals", + "cli/backup", "cli/browser", "cli/channels", "cli/clawbot", diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 260ec771de1..7e530f769b5 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -17,7 +17,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Index](/) - [Getting Started](/start/getting-started) -- [Quick start](/start/quickstart) - [Onboarding](/start/onboarding) - [Onboarding (CLI)](/start/wizard) - [Setup](/start/setup) From 0b11ee48f81daa087b335e134a4b7f948ae6534e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:31:20 -0700 Subject: [PATCH 019/293] docs: fix 26 broken anchor links across 18 files --- docs/automation/hooks.md | 2 +- docs/channels/groups.md | 2 +- docs/channels/matrix.md | 2 ++ docs/channels/signal.md | 2 +- docs/channels/troubleshooting.md | 4 ++-- docs/concepts/memory.md | 2 +- docs/concepts/messages.md | 2 +- docs/concepts/models.md | 6 +++--- docs/concepts/oauth.md | 2 +- docs/gateway/configuration.md | 6 +++--- docs/gateway/sandboxing.md | 2 +- docs/gateway/security/index.md | 4 ++-- docs/help/environment.md | 2 +- docs/help/faq.md | 22 +++++++++++----------- docs/help/index.md | 2 +- docs/install/docker.md | 2 +- docs/providers/anthropic.md | 4 ++-- docs/tools/browser.md | 2 +- docs/tools/multi-agent-sandbox-tools.md | 2 +- 19 files changed, 37 insertions(+), 35 deletions(-) diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index a470bef8540..4d7dbd02533 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -1046,4 +1046,4 @@ node -e "import('./path/to/handler.ts').then(console.log)" - [CLI Reference: hooks](/cli/hooks) - [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled) - [Webhook Hooks](/automation/webhook) -- [Configuration](/gateway/configuration#hooks) +- [Configuration](/gateway/configuration-reference#hooks) diff --git a/docs/channels/groups.md b/docs/channels/groups.md index a6bd8621784..8895cdd18f9 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -116,7 +116,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w Related: -- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox) +- Configuration keys and defaults: [Gateway configuration](/gateway/configuration-reference#agents-defaults-sandbox) - Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) - Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts) diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index d6ec40ff4db..360bc706748 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -204,6 +204,8 @@ Bootstrap cross-signing and verification state: openclaw matrix verify bootstrap ``` +Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [Configuration reference](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern. + Verbose bootstrap diagnostics: ```bash diff --git a/docs/channels/signal.md b/docs/channels/signal.md index cfc050b6e75..fb5747dc417 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -99,7 +99,7 @@ Example: } ``` -Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. +Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern. ## Setup path B: register dedicated bot number (SMS, Linux) diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md index a7850801948..106710ca926 100644 --- a/docs/channels/troubleshooting.md +++ b/docs/channels/troubleshooting.md @@ -38,7 +38,7 @@ Healthy baseline: | Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. | | Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. | -Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick) +Full troubleshooting: [/channels/whatsapp#troubleshooting](/channels/whatsapp#troubleshooting) ## Telegram @@ -90,7 +90,7 @@ Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubles Full troubleshooting: -- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc) +- [/channels/imessage#troubleshooting](/channels/imessage#troubleshooting) - [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting) ## Signal diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 2649125dc45..e020d4a9a49 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -208,7 +208,7 @@ out to QMD for retrieval. Key points: `commandTimeoutMs`, `updateTimeoutMs`, `embedTimeoutMs`). - `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`, `maxInjectedChars`, `timeoutMs`). -- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session). +- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration-reference#session). Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD hits in groups/channels. - `match.keyPrefix` matches the **normalized** session key (lowercased, with any diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 4930002187e..e94092e7bbc 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -151,4 +151,4 @@ Outbound message formatting is centralized in `messages`: - `messages.responsePrefix`, `channels..responsePrefix`, and `channels..accounts..responsePrefix` (outbound prefix cascade), plus `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix) - Reply threading via `replyToMode` and per-channel defaults -Details: [Configuration](/gateway/configuration#messages) and channel docs. +Details: [Configuration](/gateway/configuration-reference#messages) and channel docs. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 0a32e1b5d8b..d9a76cabc64 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -58,7 +58,7 @@ Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize to `zai/*`. Provider configuration examples (including OpenCode) live in -[/gateway/configuration](/gateway/configuration#opencode). +[/providers/opencode](/providers/opencode). ## "Model is not allowed" (and why replies stop) @@ -82,9 +82,9 @@ Example allowlist config: ```json5 { agent: { - model: { primary: "anthropic/claude-sonnet-4-5" }, + model: { primary: "anthropic/claude-sonnet-4-6" }, models: { - "anthropic/claude-sonnet-4-5": { alias: "Sonnet" }, + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, "anthropic/claude-opus-4-6": { alias: "Opus" }, }, }, diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 4766687ad51..2589dcaa8f9 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -50,7 +50,7 @@ Legacy import-only file (still supported, but not the main store): - `~/.openclaw/credentials/oauth.json` (imported into `auth-profiles.json` on first use) -All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys) +All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration-reference#auth-storage) For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index b8977ca10ac..42977c2b6f1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -112,11 +112,11 @@ When validation fails: agents: { defaults: { model: { - primary: "anthropic/claude-sonnet-4-5", + primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.2"], }, models: { - "anthropic/claude-sonnet-4-5": { alias: "Sonnet" }, + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, "openai/gpt-5.2": { alias: "GPT" }, }, }, @@ -251,7 +251,7 @@ When validation fails: Build the image first: `scripts/sandbox-setup.sh` - See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/configuration-reference#sandbox) for all options. + See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/configuration-reference#agents-defaults-sandbox) for all options. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 736dc7c6261..12650357724 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -463,7 +463,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden ## Related docs - [OpenShell](/gateway/openshell) -- managed sandbox backend setup, workspace modes, and config reference -- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox) +- [Sandbox Configuration](/gateway/configuration-reference#agents-defaults-sandbox) - [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?" - [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence - [Security](/gateway/security) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 8cea1b42766..26cfbc4d6df 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -5,7 +5,7 @@ read_when: title: "Security" --- -# Security 🔒 +# Security > [!WARNING] > **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model). @@ -25,7 +25,7 @@ This page explains hardening **within that model**. It does not claim hostile mu ## Quick check: `openclaw security audit` -See also: [Formal Verification (Security Models)](/security/formal-verification/) +See also: [Formal Verification (Security Models)](/security/formal-verification) Run this regularly (especially after changing config or exposing network surfaces): diff --git a/docs/help/environment.md b/docs/help/environment.md index 860129bde37..45faad7c66c 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -90,7 +90,7 @@ You can reference env vars directly in config string values using `${VAR_NAME}` } ``` -See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details. +See [Configuration: Env var substitution](/gateway/configuration-reference#env-var-substitution) for full details. ## Secret refs vs `${ENV}` strings diff --git a/docs/help/faq.md b/docs/help/faq.md index 5e892da6a7b..9122af6119e 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -13,7 +13,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ## Table of contents - [Quick start and first-run setup] - - [I am stuck - fastest way to get unstuck](#i-am-stuck---fastest-way-to-get-unstuck) + - [I am stuck - fastest way to get unstuck](#i-am-stuck-fastest-way-to-get-unstuck) - [Recommended way to install and set up OpenClaw](#recommended-way-to-install-and-set-up-openclaw) - [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding) - [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote) @@ -449,7 +449,7 @@ section is the latest shipped version. Entries are grouped by **Highlights**, ** Some Comcast/Xfinity connections incorrectly block `docs.openclaw.ai` via Xfinity Advanced Security. Disable it or allowlist `docs.openclaw.ai`, then retry. More -detail: [Troubleshooting](/help/troubleshooting#docsopenclawai-shows-an-ssl-error-comcastxfinity). +detail: [Troubleshooting](/help/faq#docsopenclawai-shows-an-ssl-error-comcast-xfinity). Please help us unblock it by reporting here: [https://spa.xfinity.com/check_url_status](https://spa.xfinity.com/check_url_status). If you still can't reach the site, the docs are mirrored on GitHub: @@ -497,7 +497,7 @@ Rough guide: - **Onboarding:** 5-15 minutes depending on how many channels/models you configure If it hangs, use [Installer stuck](/help/faq#installer-stuck-how-do-i-get-more-feedback) -and the fast debug loop in [I am stuck](/help/faq#i-am-stuck---fastest-way-to-get-unstuck). +and the fast debug loop in [I am stuck](/help/faq#i-am-stuck-fastest-way-to-get-unstuck). ### How do I try the latest bits @@ -858,7 +858,7 @@ Third-party (less private): - DM `@userinfobot` or `@getidsbot`. -See [/channels/telegram](/channels/telegram#access-control-dms--groups). +See [/channels/telegram](/channels/telegram#access-control-and-activation). ### Can multiple people use one WhatsApp number with different OpenClaw instances @@ -1259,7 +1259,7 @@ Use `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-ma Setup walkthrough + example config: [Groups: personal DMs + public groups](/channels/groups#pattern-personal-dms-public-groups-single-agent) -Key config reference: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox) +Key config reference: [Gateway configuration](/gateway/configuration-reference#agents-defaults-sandbox) ### How do I bind a host folder into the sandbox @@ -2293,7 +2293,7 @@ Aliases come from `agents.defaults.models..alias`. Example: model: { primary: "anthropic/claude-opus-4-6" }, models: { "anthropic/claude-opus-4-6": { alias: "opus" }, - "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, "anthropic/claude-haiku-4-5": { alias: "haiku" }, }, }, @@ -2311,8 +2311,8 @@ OpenRouter (pay-per-token; many models): { agents: { defaults: { - model: { primary: "openrouter/anthropic/claude-sonnet-4-5" }, - models: { "openrouter/anthropic/claude-sonnet-4-5": {} }, + model: { primary: "openrouter/anthropic/claude-sonnet-4-6" }, + models: { "openrouter/anthropic/claude-sonnet-4-6": {} }, }, }, env: { OPENROUTER_API_KEY: "sk-or-..." }, @@ -2635,7 +2635,7 @@ Service/supervisor logs (when the gateway runs via launchd/systemd): - Linux: `journalctl --user -u openclaw-gateway[-].service -n 200 --no-pager` - Windows: `schtasks /Query /TN "OpenClaw Gateway ()" /V /FO LIST` -See [Troubleshooting](/gateway/troubleshooting#log-locations) for more. +See [Troubleshooting](/gateway/troubleshooting) for more. ### How do I start/stop/restart the Gateway service @@ -2917,7 +2917,7 @@ If it is still noisy, check the session settings in the Control UI and set verbo to **inherit**. Also confirm you are not using a bot profile with `verboseDefault` set to `on` in config. -Docs: [Thinking and verbose](/tools/thinking), [Security](/gateway/security#reasoning--verbose-output-in-groups). +Docs: [Thinking and verbose](/tools/thinking), [Security](/gateway/security#reasoning-verbose-output-in-groups). ### How do I stopcancel a running task @@ -3000,7 +3000,7 @@ You can add options like `debounce:2s cap:25 drop:summarize` for followup modes. **Q: "What's the default model for Anthropic with an API key?"** -**A:** In OpenClaw, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agents.defaults.model.primary` (for example, `anthropic/claude-sonnet-4-5` or `anthropic/claude-opus-4-6`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldn't find Anthropic credentials in the expected `auth-profiles.json` for the agent that's running. +**A:** In OpenClaw, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agents.defaults.model.primary` (for example, `anthropic/claude-sonnet-4-6` or `anthropic/claude-opus-4-6`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldn't find Anthropic credentials in the expected `auth-profiles.json` for the agent that's running. --- diff --git a/docs/help/index.md b/docs/help/index.md index 80aa5d304e8..5d0942909b6 100644 --- a/docs/help/index.md +++ b/docs/help/index.md @@ -11,7 +11,7 @@ title: "Help" If you want a quick “get unstuck” flow, start here: - **Troubleshooting:** [Start here](/help/troubleshooting) -- **Install sanity (Node/npm/PATH):** [Install](/install#nodejs--npm-path-sanity) +- **Install sanity (Node/npm/PATH):** [Install](/install/node#troubleshooting) - **Gateway issues:** [Gateway troubleshooting](/gateway/troubleshooting) - **Logs:** [Logging](/logging) and [Gateway logging](/gateway/logging) - **Repairs:** [Doctor](/gateway/doctor) diff --git a/docs/install/docker.md b/docs/install/docker.md index f4913a5138a..f80d0809fc8 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -29,7 +29,7 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing) - At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137) - Enough disk for images + logs - If running on a VPS/public host, review - [Security hardening for network exposure](/gateway/security#04-network-exposure-bind--port--firewall), + [Security hardening for network exposure](/gateway/security#0-4-network-exposure-bind-port-firewall), especially Docker `DOCKER-USER` firewall policy. ## Containerized Gateway (Docker Compose) diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index d16d76f6315..a1f2e212463 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -57,7 +57,7 @@ OpenClaw's shared `/fast` toggle also supports direct Anthropic API-key traffic. agents: { defaults: { models: { - "anthropic/claude-sonnet-4-5": { + "anthropic/claude-sonnet-4-6": { params: { fastMode: true }, }, }, @@ -228,7 +228,7 @@ openclaw onboard --auth-choice setup-token ## Notes - Generate the setup-token with `claude setup-token` and paste it, or run `openclaw models auth setup-token` on the gateway host. -- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription). +- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token. See [/gateway/troubleshooting](/gateway/troubleshooting). - Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth). ## Troubleshooting diff --git a/docs/tools/browser.md b/docs/tools/browser.md index dc044450742..4797bc7409b 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -581,7 +581,7 @@ Notes: - `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref=""`). - `--format aria`: returns the accessibility tree (no refs; inspection only). - `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars). - - Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-openclaw-managed-browser)). + - Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration-reference#browser)). - Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`. - `--frame "