Merge branch 'main' into vincentkoc-code/docker-cache-layer-fixes
This commit is contained in:
commit
7757f6ff71
@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
|
||||
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
|
||||
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
|
||||
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
|
||||
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
|
||||
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
|
||||
- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
|
||||
@ -43,6 +44,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
|
||||
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
|
||||
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
|
||||
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
|
||||
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
||||
@ -46,3 +46,19 @@ export function isRetryableReconnectError(err) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isMissingTabError(err) {
|
||||
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
|
||||
return (
|
||||
message.includes("no tab with id") ||
|
||||
message.includes("no tab with given id") ||
|
||||
message.includes("tab not found")
|
||||
);
|
||||
}
|
||||
|
||||
export function isLastRemainingTab(allTabs, tabIdToClose) {
|
||||
if (!Array.isArray(allTabs)) {
|
||||
return true;
|
||||
}
|
||||
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
|
||||
}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
||||
import {
|
||||
buildRelayWsUrl,
|
||||
isLastRemainingTab,
|
||||
isMissingTabError,
|
||||
isRetryableReconnectError,
|
||||
reconnectDelayMs,
|
||||
} from './background-utils.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
@ -41,6 +47,9 @@ const reattachPending = new Set()
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer = null
|
||||
|
||||
const TAB_VALIDATION_ATTEMPTS = 2
|
||||
const TAB_VALIDATION_RETRY_DELAY_MS = 1000
|
||||
|
||||
function nowStack() {
|
||||
try {
|
||||
return new Error().stack || ''
|
||||
@ -49,6 +58,37 @@ function nowStack() {
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function validateAttachedTab(tabId) {
|
||||
try {
|
||||
await chrome.tabs.get(tabId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
if (isMissingTabError(err)) {
|
||||
return false
|
||||
}
|
||||
if (attempt < TAB_VALIDATION_ATTEMPTS - 1) {
|
||||
await sleep(TAB_VALIDATION_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function getRelayPort() {
|
||||
const stored = await chrome.storage.local.get(['relayPort'])
|
||||
const raw = stored.relayPort
|
||||
@ -108,15 +148,11 @@ async function rehydrateState() {
|
||||
tabBySession.set(entry.sessionId, entry.tabId)
|
||||
setBadge(entry.tabId, 'on')
|
||||
}
|
||||
// Phase 2: validate asynchronously, remove dead tabs.
|
||||
// Retry once so transient busy/navigation states do not permanently drop
|
||||
// a still-attached tab after a service worker restart.
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await chrome.tabs.get(entry.tabId)
|
||||
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
const valid = await validateAttachedTab(entry.tabId)
|
||||
if (!valid) {
|
||||
tabs.delete(entry.tabId)
|
||||
tabBySession.delete(entry.sessionId)
|
||||
setBadge(entry.tabId, 'off')
|
||||
@ -259,13 +295,10 @@ async function reannounceAttachedTabs() {
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
||||
|
||||
// Verify debugger is still attached.
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
// Retry once here as well; reconnect races can briefly make an otherwise
|
||||
// healthy tab look unavailable.
|
||||
const valid = await validateAttachedTab(tabId)
|
||||
if (!valid) {
|
||||
tabs.delete(tabId)
|
||||
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
||||
setBadge(tabId, 'off')
|
||||
@ -672,6 +705,11 @@ async function handleForwardCdpCommand(msg) {
|
||||
const toClose = target ? getTabByTargetId(target) : tabId
|
||||
if (!toClose) return { success: false }
|
||||
try {
|
||||
const allTabs = await chrome.tabs.query({})
|
||||
if (isLastRemainingTab(allTabs, toClose)) {
|
||||
console.warn('Refusing to close the last tab: this would kill the browser process')
|
||||
return { success: false, error: 'Cannot close the last tab' }
|
||||
}
|
||||
await chrome.tabs.remove(toClose)
|
||||
} catch {
|
||||
return { success: false }
|
||||
|
||||
299
docs/refactor/cluster.md
Normal file
299
docs/refactor/cluster.md
Normal file
@ -0,0 +1,299 @@
|
||||
---
|
||||
summary: "Refactor clusters with highest LOC reduction potential"
|
||||
read_when:
|
||||
- You want to reduce total LOC without changing behavior
|
||||
- You are choosing the next dedupe or extraction pass
|
||||
title: "Refactor Cluster Backlog"
|
||||
---
|
||||
|
||||
# Refactor Cluster Backlog
|
||||
|
||||
Ranked by likely LOC reduction, safety, and breadth.
|
||||
|
||||
## 1. Channel plugin config and security scaffolding
|
||||
|
||||
Highest-value cluster.
|
||||
|
||||
Repeated shapes across many channel plugins:
|
||||
|
||||
- `config.listAccountIds`
|
||||
- `config.resolveAccount`
|
||||
- `config.defaultAccountId`
|
||||
- `config.setAccountEnabled`
|
||||
- `config.deleteAccount`
|
||||
- `config.describeAccount`
|
||||
- `security.resolveDmPolicy`
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/telegram/src/channel.ts`
|
||||
- `extensions/googlechat/src/channel.ts`
|
||||
- `extensions/slack/src/channel.ts`
|
||||
- `extensions/discord/src/channel.ts`
|
||||
- `extensions/matrix/src/channel.ts`
|
||||
- `extensions/irc/src/channel.ts`
|
||||
- `extensions/signal/src/channel.ts`
|
||||
- `extensions/mattermost/src/channel.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `buildChannelConfigAdapter(...)`
|
||||
- `buildMultiAccountConfigAdapter(...)`
|
||||
- `buildDmSecurityAdapter(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~250-450 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization.
|
||||
|
||||
## 2. Extension runtime singleton boilerplate
|
||||
|
||||
Very safe.
|
||||
|
||||
Nearly every extension has the same runtime holder:
|
||||
|
||||
- `let runtime: PluginRuntime | null = null`
|
||||
- `setXRuntime`
|
||||
- `getXRuntime`
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/telegram/src/runtime.ts`
|
||||
- `extensions/matrix/src/runtime.ts`
|
||||
- `extensions/slack/src/runtime.ts`
|
||||
- `extensions/discord/src/runtime.ts`
|
||||
- `extensions/whatsapp/src/runtime.ts`
|
||||
- `extensions/imessage/src/runtime.ts`
|
||||
- `extensions/twitch/src/runtime.ts`
|
||||
|
||||
Special-case variants:
|
||||
|
||||
- `extensions/bluebubbles/src/runtime.ts`
|
||||
- `extensions/line/src/runtime.ts`
|
||||
- `extensions/synology-chat/src/runtime.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `createPluginRuntimeStore<T>(errorMessage)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~180-260 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Low
|
||||
|
||||
## 3. Onboarding prompt and config-patch steps
|
||||
|
||||
Large surface area.
|
||||
|
||||
Many onboarding files repeat:
|
||||
|
||||
- resolve account id
|
||||
- prompt allowlist entries
|
||||
- merge allowFrom
|
||||
- set DM policy
|
||||
- prompt secrets
|
||||
- patch top-level vs account-scoped config
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/bluebubbles/src/onboarding.ts`
|
||||
- `extensions/googlechat/src/onboarding.ts`
|
||||
- `extensions/msteams/src/onboarding.ts`
|
||||
- `extensions/zalo/src/onboarding.ts`
|
||||
- `extensions/zalouser/src/onboarding.ts`
|
||||
- `extensions/nextcloud-talk/src/onboarding.ts`
|
||||
- `extensions/matrix/src/onboarding.ts`
|
||||
- `extensions/irc/src/onboarding.ts`
|
||||
|
||||
Existing helper seam:
|
||||
|
||||
- `src/channels/plugins/onboarding/helpers.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `promptAllowFromList(...)`
|
||||
- `buildDmPolicyAdapter(...)`
|
||||
- `applyScopedAccountPatch(...)`
|
||||
- `promptSecretFields(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~300-600 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Medium. Easy to over-generalize; keep helpers narrow and composable.
|
||||
|
||||
## 4. Multi-account config-schema fragments
|
||||
|
||||
Repeated schema fragments across extensions.
|
||||
|
||||
Common patterns:
|
||||
|
||||
- `const allowFromEntry = z.union([z.string(), z.number()])`
|
||||
- account schema plus:
|
||||
- `accounts: z.object({}).catchall(accountSchema).optional()`
|
||||
- `defaultAccount: z.string().optional()`
|
||||
- repeated DM/group fields
|
||||
- repeated markdown/tool policy fields
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/bluebubbles/src/config-schema.ts`
|
||||
- `extensions/zalo/src/config-schema.ts`
|
||||
- `extensions/zalouser/src/config-schema.ts`
|
||||
- `extensions/matrix/src/config-schema.ts`
|
||||
- `extensions/nostr/src/config-schema.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `AllowFromEntrySchema`
|
||||
- `buildMultiAccountChannelSchema(accountSchema)`
|
||||
- `buildCommonDmGroupFields(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~120-220 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Low to medium. Some schemas are simple, some are special.
|
||||
|
||||
## 5. Webhook and monitor lifecycle startup
|
||||
|
||||
Good medium-value cluster.
|
||||
|
||||
Repeated `startAccount` / monitor setup patterns:
|
||||
|
||||
- resolve account
|
||||
- compute webhook path
|
||||
- log startup
|
||||
- start monitor
|
||||
- wait for abort
|
||||
- cleanup
|
||||
- status sink updates
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/googlechat/src/channel.ts`
|
||||
- `extensions/bluebubbles/src/channel.ts`
|
||||
- `extensions/zalo/src/channel.ts`
|
||||
- `extensions/telegram/src/channel.ts`
|
||||
- `extensions/nextcloud-talk/src/channel.ts`
|
||||
|
||||
Existing helper seam:
|
||||
|
||||
- `src/plugin-sdk/channel-lifecycle.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- helper for account monitor lifecycle
|
||||
- helper for webhook-backed account startup
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~150-300 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Medium to high. Transport details diverge quickly.
|
||||
|
||||
## 6. Small exact-clone cleanup
|
||||
|
||||
Low-risk cleanup bucket.
|
||||
|
||||
Examples:
|
||||
|
||||
- duplicated gateway argv detection:
|
||||
- `src/infra/gateway-lock.ts`
|
||||
- `src/cli/daemon-cli/lifecycle.ts`
|
||||
- duplicated port diagnostics rendering:
|
||||
- `src/cli/daemon-cli/restart-health.ts`
|
||||
- duplicated session-key construction:
|
||||
- `src/web/auto-reply/monitor/broadcast.ts`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~30-60 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Low
|
||||
|
||||
## Test clusters
|
||||
|
||||
### LINE webhook event fixtures
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `src/line/bot-handlers.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- `makeLineEvent(...)`
|
||||
- `runLineEvent(...)`
|
||||
- `makeLineAccount(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~120-180 LOC
|
||||
|
||||
### Telegram native command auth matrix
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `src/telegram/bot-native-commands.group-auth.test.ts`
|
||||
- `src/telegram/bot-native-commands.plugin-auth.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- forum context builder
|
||||
- denied-message assertion helper
|
||||
- table-driven auth cases
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~80-140 LOC
|
||||
|
||||
### Zalo lifecycle setup
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/zalo/src/monitor.lifecycle.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- shared monitor setup harness
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~50-90 LOC
|
||||
|
||||
### Brave llm-context unsupported-option tests
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `src/agents/tools/web-tools.enabled-defaults.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- `it.each(...)` matrix
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~30-50 LOC
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. Runtime singleton boilerplate
|
||||
2. Small exact-clone cleanup
|
||||
3. Config and security builder extraction
|
||||
4. Test-helper extraction
|
||||
5. Onboarding step extraction
|
||||
6. Monitor lifecycle helper extraction
|
||||
@ -1,9 +1,8 @@
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const bluebubblesActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().default(true),
|
||||
@ -34,8 +33,8 @@ const bluebubblesAccountSchema = z
|
||||
password: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
@ -60,8 +59,8 @@ const bluebubblesAccountSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
|
||||
bluebubblesAccountSchema,
|
||||
).extend({
|
||||
actions: bluebubblesActionSchema,
|
||||
});
|
||||
|
||||
@ -1,31 +1,26 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
|
||||
export function setBlueBubblesRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
|
||||
|
||||
export function clearBlueBubblesRuntime(): void {
|
||||
runtime = null;
|
||||
runtimeStore.clearRuntime();
|
||||
}
|
||||
|
||||
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
|
||||
return runtime;
|
||||
return runtimeStore.tryGetRuntime();
|
||||
}
|
||||
|
||||
export function getBlueBubblesRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("BlueBubbles runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
return runtimeStore.getRuntime();
|
||||
}
|
||||
|
||||
export function warnBlueBubbles(message: string): void {
|
||||
const formatted = `[bluebubbles] ${message}`;
|
||||
// Backward-compatible with tests/legacy injections that pass { log }.
|
||||
const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log;
|
||||
const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log;
|
||||
if (typeof log === "function") {
|
||||
log(formatted);
|
||||
return;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
@ -13,7 +14,6 @@ import {
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
discordOnboardingAdapter,
|
||||
DiscordConfigSchema,
|
||||
getChatChannelMeta,
|
||||
@ -33,7 +33,6 @@ import {
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedDiscordAccount,
|
||||
@ -63,6 +62,15 @@ const discordConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const discordConfigBase = createScopedChannelConfigBase({
|
||||
sectionKey: "discord",
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultDiscordAccountId,
|
||||
clearBaseFields: ["token", "name"],
|
||||
});
|
||||
|
||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
id: "discord",
|
||||
meta: {
|
||||
@ -93,25 +101,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
reload: { configPrefixes: ["channels.discord"] },
|
||||
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "discord",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "discord",
|
||||
accountId,
|
||||
clearBaseFields: ["token", "name"],
|
||||
}),
|
||||
...discordConfigBase,
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setDiscordRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getDiscordRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Discord runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
|
||||
export { getDiscordRuntime, setDiscordRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setFeishuRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getFeishuRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Feishu runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
|
||||
export { getFeishuRuntime, setFeishuRuntime };
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
||||
@ -11,7 +12,6 @@ import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
getChatChannelMeta,
|
||||
listDirectoryGroupEntriesFromMapKeys,
|
||||
listDirectoryUserEntriesFromAllowFrom,
|
||||
@ -21,7 +21,6 @@ import {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveGoogleChatGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelDock,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
@ -68,6 +67,23 @@ const googleChatConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAccount>({
|
||||
sectionKey: "googlechat",
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
});
|
||||
|
||||
export const googlechatDock: ChannelDock = {
|
||||
id: "googlechat",
|
||||
capabilities: {
|
||||
@ -142,33 +158,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg,
|
||||
sectionKey: "googlechat",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg,
|
||||
sectionKey: "googlechat",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
}),
|
||||
...googleChatConfigBase,
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setGoogleChatRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getGoogleChatRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Google Chat runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Google Chat runtime not initialized");
|
||||
export { getGoogleChatRuntime, setGoogleChatRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIMessageRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIMessageRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("iMessage runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
|
||||
export { getIMessageRuntime, setIMessageRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIrcRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIrcRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("IRC runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("IRC runtime not initialized");
|
||||
export { getIrcRuntime, setIrcRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setLineRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getLineRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("LINE runtime not initialized - plugin not registered");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("LINE runtime not initialized - plugin not registered");
|
||||
export { getLineRuntime, setLineRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMatrixRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMatrixRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Matrix runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Matrix runtime not initialized");
|
||||
export { getMatrixRuntime, setMatrixRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMattermostRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMattermostRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");
|
||||
export { getMattermostRuntime, setMattermostRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMSTeamsRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMSTeamsRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("MSTeams runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("MSTeams runtime not initialized");
|
||||
export { getMSTeamsRuntime, setMSTeamsRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setNextcloudTalkRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getNextcloudTalkRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Nextcloud Talk runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Nextcloud Talk runtime not initialized");
|
||||
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setNostrRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getNostrRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Nostr runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Nostr runtime not initialized");
|
||||
export { getNostrRuntime, setNostrRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSignalRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getSignalRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Signal runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
||||
export { getSignalRuntime, setSignalRuntime };
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
extractSlackToolSend,
|
||||
getChatChannelMeta,
|
||||
handleSlackMessageAction,
|
||||
@ -32,7 +32,6 @@ import {
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
SlackConfigSchema,
|
||||
type ChannelPlugin,
|
||||
@ -96,6 +95,15 @@ const slackConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const slackConfigBase = createScopedChannelConfigBase({
|
||||
sectionKey: "slack",
|
||||
listAccountIds: listSlackAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultSlackAccountId,
|
||||
clearBaseFields: ["botToken", "appToken", "name"],
|
||||
});
|
||||
|
||||
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
id: "slack",
|
||||
meta: {
|
||||
@ -144,25 +152,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
reload: { configPrefixes: ["channels.slack"] },
|
||||
configSchema: buildChannelConfigSchema(SlackConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listSlackAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "slack",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "slack",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "appToken", "name"],
|
||||
}),
|
||||
...slackConfigBase,
|
||||
isConfigured: (account) => isSlackAccountConfigured(account),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSlackRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getSlackRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Slack runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
|
||||
export { getSlackRuntime, setSlackRuntime };
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
/**
|
||||
* Plugin runtime singleton.
|
||||
* Stores the PluginRuntime from api.runtime (set during register()).
|
||||
* Used by channel.ts to access dispatch functions.
|
||||
*/
|
||||
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSynologyRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getSynologyRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Synology Chat runtime not initialized - plugin not registered");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>(
|
||||
"Synology Chat runtime not initialized - plugin not registered",
|
||||
);
|
||||
export { getSynologyRuntime, setSynologyRuntime };
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
@ -12,7 +13,6 @@ import {
|
||||
clearAccountEntryFields,
|
||||
collectTelegramStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
getChatChannelMeta,
|
||||
inspectTelegramAccount,
|
||||
listTelegramAccountIds,
|
||||
@ -31,7 +31,6 @@ import {
|
||||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
@ -100,6 +99,15 @@ const telegramConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const telegramConfigBase = createScopedChannelConfigBase<ResolvedTelegramAccount>({
|
||||
sectionKey: "telegram",
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultTelegramAccountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
});
|
||||
|
||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
@ -136,25 +144,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
reload: { configPrefixes: ["channels.telegram"] },
|
||||
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "telegram",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "telegram",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
}),
|
||||
...telegramConfigBase,
|
||||
isConfigured: (account, cfg) => {
|
||||
if (!account.token?.trim()) {
|
||||
return false;
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTelegramRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTelegramRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Telegram runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
|
||||
export { getTelegramRuntime, setTelegramRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTlonRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTlonRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Tlon runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Tlon runtime not initialized");
|
||||
export { getTlonRuntime, setTlonRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTwitchRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTwitchRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Twitch runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Twitch runtime not initialized");
|
||||
export { getTwitchRuntime, setTwitchRuntime };
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setWhatsAppRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getWhatsAppRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("WhatsApp runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
||||
export { getWhatsAppRuntime, setWhatsAppRuntime };
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const zaloAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@ -14,15 +13,12 @@ const zaloAccountSchema = z.object({
|
||||
webhookSecret: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZaloConfigSchema = zaloAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setZaloRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getZaloRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Zalo runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
|
||||
export { getZaloRuntime, setZaloRuntime };
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@ -16,16 +15,13 @@ const zalouserAccountSchema = z.object({
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZalouserConfigSchema = zalouserAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setZalouserRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getZalouserRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Zalouser runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
|
||||
export { getZalouserRuntime, setZalouserRuntime };
|
||||
|
||||
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
hookRunner,
|
||||
ensureRuntimePluginsLoaded,
|
||||
resolveModelMock,
|
||||
sessionCompactImpl,
|
||||
triggerInternalHook,
|
||||
@ -12,6 +13,7 @@ const {
|
||||
runBeforeCompaction: vi.fn(),
|
||||
runAfterCompaction: vi.fn(),
|
||||
},
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
resolveModelMock: vi.fn(() => ({
|
||||
model: { provider: "openai", api: "responses", id: "fake", input: [] },
|
||||
error: null,
|
||||
@ -32,6 +34,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => hookRunner,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/internal-hooks.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
|
||||
"../../hooks/internal-hooks.js",
|
||||
@ -254,6 +260,7 @@ const sessionHook = (action: string) =>
|
||||
|
||||
describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
beforeEach(() => {
|
||||
ensureRuntimePluginsLoaded.mockReset();
|
||||
triggerInternalHook.mockClear();
|
||||
hookRunner.hasHooks.mockReset();
|
||||
hookRunner.runBeforeCompaction.mockReset();
|
||||
@ -279,6 +286,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
|
||||
});
|
||||
|
||||
it("bootstraps runtime plugins with the resolved workspace", async () => {
|
||||
await compactEmbeddedPiSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: undefined,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits internal + plugin compaction hooks with counts", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
let sanitizedCount = 0;
|
||||
|
||||
@ -50,6 +50,7 @@ import {
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
|
||||
import { createOpenClawCodingTools } from "../pi-tools.js";
|
||||
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
|
||||
import { resolveSandboxContext } from "../sandbox.js";
|
||||
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
|
||||
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
||||
@ -269,6 +270,10 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
const maxAttempts = params.maxAttempts ?? 1;
|
||||
const runId = params.runId ?? params.sessionId;
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
// Resolve compaction model: prefer config override, then fall back to caller-supplied model
|
||||
@ -910,6 +915,10 @@ export async function compactEmbeddedPiSession(
|
||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const contextEngine = await resolveContextEngine(params.config);
|
||||
try {
|
||||
|
||||
@ -664,6 +664,60 @@ describe("resolveModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
cfg,
|
||||
expectedModel: {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
cfg,
|
||||
expectedModel: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes auth hint for unknown ollama models (#17328)", () => {
|
||||
// resetMockDiscoverModels() in beforeEach already sets find → null
|
||||
const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent");
|
||||
|
||||
@ -23,6 +23,8 @@ type InlineProviderConfig = {
|
||||
headers?: unknown;
|
||||
};
|
||||
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
function sanitizeModelHeaders(
|
||||
headers: unknown,
|
||||
opts?: { stripSecretRefMarkers?: boolean },
|
||||
@ -43,6 +45,60 @@ function sanitizeModelHeaders(
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed);
|
||||
}
|
||||
|
||||
function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed);
|
||||
}
|
||||
|
||||
function normalizeOpenAICodexTransport(params: {
|
||||
provider: string;
|
||||
model: Model<Api>;
|
||||
}): Model<Api> {
|
||||
if (normalizeProviderId(params.provider) !== "openai-codex") {
|
||||
return params.model;
|
||||
}
|
||||
|
||||
const useCodexTransport =
|
||||
!params.model.baseUrl ||
|
||||
isOpenAIApiBaseUrl(params.model.baseUrl) ||
|
||||
isOpenAICodexBaseUrl(params.model.baseUrl);
|
||||
|
||||
const nextApi =
|
||||
useCodexTransport && params.model.api === "openai-responses"
|
||||
? ("openai-codex-responses" as const)
|
||||
: params.model.api;
|
||||
const nextBaseUrl =
|
||||
nextApi === "openai-codex-responses" &&
|
||||
(!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl))
|
||||
? OPENAI_CODEX_BASE_URL
|
||||
: params.model.baseUrl;
|
||||
|
||||
if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) {
|
||||
return params.model;
|
||||
}
|
||||
|
||||
return {
|
||||
...params.model,
|
||||
api: nextApi,
|
||||
baseUrl: nextBaseUrl,
|
||||
} as Model<Api>;
|
||||
}
|
||||
|
||||
function normalizeResolvedModel(params: { provider: string; model: Model<Api> }): Model<Api> {
|
||||
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
|
||||
}
|
||||
|
||||
export { buildModelAliasLines };
|
||||
|
||||
function resolveConfiguredProviderConfig(
|
||||
@ -145,13 +201,14 @@ export function resolveModelWithRegistry(params: {
|
||||
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
|
||||
|
||||
if (model) {
|
||||
return normalizeModelCompat(
|
||||
applyConfiguredProviderOverrides({
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
model: applyConfiguredProviderOverrides({
|
||||
discoveredModel: model,
|
||||
providerConfig,
|
||||
modelId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const providers = cfg?.models?.providers ?? {};
|
||||
@ -161,64 +218,71 @@ export function resolveModelWithRegistry(params: {
|
||||
(entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId,
|
||||
);
|
||||
if (inlineMatch?.api) {
|
||||
return normalizeModelCompat(inlineMatch as Model<Api>);
|
||||
return normalizeResolvedModel({ provider, model: inlineMatch as Model<Api> });
|
||||
}
|
||||
|
||||
// Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback.
|
||||
// Otherwise, configured providers can default to a generic API and break specific transports.
|
||||
const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry);
|
||||
if (forwardCompat) {
|
||||
return normalizeModelCompat(
|
||||
applyConfiguredProviderOverrides({
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
model: applyConfiguredProviderOverrides({
|
||||
discoveredModel: forwardCompat,
|
||||
providerConfig,
|
||||
modelId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// OpenRouter is a pass-through proxy - any model ID available on OpenRouter
|
||||
// should work without being pre-registered in the local catalog.
|
||||
if (normalizedProvider === "openrouter") {
|
||||
return normalizeModelCompat({
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_TOKENS,
|
||||
// Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts
|
||||
maxTokens: 8192,
|
||||
} as Model<Api>);
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_TOKENS,
|
||||
// Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts
|
||||
maxTokens: 8192,
|
||||
} as Model<Api>,
|
||||
});
|
||||
}
|
||||
|
||||
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
|
||||
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers);
|
||||
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers);
|
||||
if (providerConfig || modelId.startsWith("mock-")) {
|
||||
return normalizeModelCompat({
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: providerConfig?.api ?? "openai-responses",
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow:
|
||||
configuredModel?.contextWindow ??
|
||||
providerConfig?.models?.[0]?.contextWindow ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens:
|
||||
configuredModel?.maxTokens ??
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers:
|
||||
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
|
||||
} as Model<Api>);
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: providerConfig?.api ?? "openai-responses",
|
||||
provider,
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow:
|
||||
configuredModel?.contextWindow ??
|
||||
providerConfig?.models?.[0]?.contextWindow ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens:
|
||||
configuredModel?.maxTokens ??
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers:
|
||||
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
|
||||
} as Model<Api>,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@ -54,6 +54,7 @@ import {
|
||||
pickFallbackThinkingLevel,
|
||||
type FailoverReason,
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
|
||||
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
|
||||
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
|
||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||
@ -287,6 +288,10 @@ export async function runEmbeddedPiAgent(
|
||||
`[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`,
|
||||
);
|
||||
}
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import "./run.overflow-compaction.mocks.shared.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtimePluginMocks = vi.hoisted(() => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
import { runEmbeddedPiAgent } from "./run.js";
|
||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||
|
||||
@ -10,6 +19,32 @@ describe("runEmbeddedPiAgent usage reporting", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("bootstraps runtime plugins with the resolved workspace before running", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
|
||||
aborted: false,
|
||||
promptError: null,
|
||||
timedOut: false,
|
||||
sessionIdUsed: "test-session",
|
||||
assistantTexts: ["Response 1"],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "hello",
|
||||
timeoutMs: 30000,
|
||||
runId: "run-plugin-bootstrap",
|
||||
});
|
||||
|
||||
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: undefined,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards sender identity fields into embedded attempts", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
|
||||
aborted: false,
|
||||
|
||||
18
src/agents/runtime-plugins.ts
Normal file
18
src/agents/runtime-plugins.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function ensureRuntimePluginsLoaded(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string | null;
|
||||
}): void {
|
||||
const workspaceDir =
|
||||
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
|
||||
? resolveUserPath(params.workspaceDir)
|
||||
: undefined;
|
||||
|
||||
loadOpenClawPlugins({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
91
src/agents/subagent-registry.context-engine.test.ts
Normal file
91
src/agents/subagent-registry.context-engine.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
resolveContextEngine: vi.fn(),
|
||||
onSubagentEnded: vi.fn(async () => {}),
|
||||
onAgentEvent: vi.fn(() => () => {}),
|
||||
persistSubagentRunsToDisk: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../context-engine/init.js", () => ({
|
||||
ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized,
|
||||
}));
|
||||
|
||||
vi.mock("../context-engine/registry.js", () => ({
|
||||
resolveContextEngine: mocks.resolveContextEngine,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/agent-events.js", () => ({
|
||||
onAgentEvent: mocks.onAgentEvent,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-registry-state.js", () => ({
|
||||
getSubagentRunsSnapshotForRead: vi.fn((runs: Map<string, unknown>) => new Map(runs)),
|
||||
persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk,
|
||||
restoreSubagentRunsFromDisk: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-announce-queue.js", () => ({
|
||||
resetAnnounceQueuesForTests: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./timeout.js", () => ({
|
||||
resolveAgentTimeoutMs: vi.fn(() => 1_000),
|
||||
}));
|
||||
|
||||
import {
|
||||
registerSubagentRun,
|
||||
releaseSubagentRun,
|
||||
resetSubagentRegistryForTests,
|
||||
} from "./subagent-registry.js";
|
||||
|
||||
describe("subagent-registry context-engine bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveContextEngine.mockResolvedValue({
|
||||
onSubagentEnded: mocks.onSubagentEnded,
|
||||
});
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
});
|
||||
|
||||
it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:session:child",
|
||||
requesterSessionKey: "agent:main:session:parent",
|
||||
requesterDisplayKey: "parent",
|
||||
task: "task",
|
||||
cleanup: "keep",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
releaseSubagentRun("run-1");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
|
||||
childSessionKey: "agent:main:session:child",
|
||||
reason: "released",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -16,6 +16,7 @@ import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
|
||||
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
|
||||
import {
|
||||
captureSubagentCompletionReply,
|
||||
@ -313,10 +314,16 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number;
|
||||
async function notifyContextEngineSubagentEnded(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
workspaceDir?: string;
|
||||
}) {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const engine = await resolveContextEngine(loadConfig());
|
||||
const engine = await resolveContextEngine(cfg);
|
||||
if (!engine.onSubagentEnded) {
|
||||
return;
|
||||
}
|
||||
@ -714,6 +721,7 @@ async function sweepSubagentRuns() {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "swept",
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
subagentRuns.delete(runId);
|
||||
mutated = true;
|
||||
@ -963,6 +971,7 @@ function completeCleanupBookkeeping(params: {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: params.entry.childSessionKey,
|
||||
reason: "deleted",
|
||||
workspaceDir: params.entry.workspaceDir,
|
||||
});
|
||||
subagentRuns.delete(params.runId);
|
||||
persistSubagentRuns();
|
||||
@ -972,6 +981,7 @@ function completeCleanupBookkeeping(params: {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: params.entry.childSessionKey,
|
||||
reason: "completed",
|
||||
workspaceDir: params.entry.workspaceDir,
|
||||
});
|
||||
params.entry.cleanupCompletedAt = params.completedAt;
|
||||
persistSubagentRuns();
|
||||
@ -1143,6 +1153,7 @@ export function registerSubagentRun(params: {
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
runTimeoutSeconds?: number;
|
||||
expectsCompletionMessage?: boolean;
|
||||
spawnMode?: "run" | "session";
|
||||
@ -1171,6 +1182,7 @@ export function registerSubagentRun(params: {
|
||||
spawnMode,
|
||||
label: params.label,
|
||||
model: params.model,
|
||||
workspaceDir: params.workspaceDir,
|
||||
runTimeoutSeconds,
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
@ -1285,6 +1297,7 @@ export function releaseSubagentRun(runId: string) {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "released",
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
}
|
||||
const didDelete = subagentRuns.delete(runId);
|
||||
|
||||
@ -13,6 +13,7 @@ export type SubagentRunRecord = {
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
runTimeoutSeconds?: number;
|
||||
spawnMode?: SpawnSubagentMode;
|
||||
createdAt: number;
|
||||
|
||||
@ -650,6 +650,7 @@ export async function spawnSubagentDirect(
|
||||
cleanup,
|
||||
label: label || undefined,
|
||||
model: resolvedModel,
|
||||
workspaceDir: spawnedMetadata.workspaceDir,
|
||||
runTimeoutSeconds,
|
||||
expectsCompletionMessage,
|
||||
spawnMode,
|
||||
|
||||
@ -112,16 +112,19 @@ export async function executeSnapshotAction(params: {
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
const { input, baseUrl, profile, proxyRequest } = params;
|
||||
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
|
||||
const format =
|
||||
input.snapshotFormat === "ai" || input.snapshotFormat === "aria" ? input.snapshotFormat : "ai";
|
||||
const mode =
|
||||
const format: "ai" | "aria" | undefined =
|
||||
input.snapshotFormat === "ai" || input.snapshotFormat === "aria"
|
||||
? input.snapshotFormat
|
||||
: undefined;
|
||||
const mode: "efficient" | undefined =
|
||||
input.mode === "efficient"
|
||||
? "efficient"
|
||||
: format === "ai" && snapshotDefaults?.mode === "efficient"
|
||||
: format !== "aria" && snapshotDefaults?.mode === "efficient"
|
||||
? "efficient"
|
||||
: undefined;
|
||||
const labels = typeof input.labels === "boolean" ? input.labels : undefined;
|
||||
const refs = input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
|
||||
const refs: "aria" | "role" | undefined =
|
||||
input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
|
||||
const hasMaxChars = Object.hasOwn(input, "maxChars");
|
||||
const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined;
|
||||
const limit =
|
||||
@ -130,6 +133,12 @@ export async function executeSnapshotAction(params: {
|
||||
typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0
|
||||
? Math.floor(input.maxChars)
|
||||
: undefined;
|
||||
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
|
||||
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
|
||||
const depth =
|
||||
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
|
||||
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
|
||||
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
|
||||
const resolvedMaxChars =
|
||||
format === "ai"
|
||||
? hasMaxChars
|
||||
@ -137,46 +146,32 @@ export async function executeSnapshotAction(params: {
|
||||
: mode === "efficient"
|
||||
? undefined
|
||||
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
|
||||
: undefined;
|
||||
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
|
||||
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
|
||||
const depth =
|
||||
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
|
||||
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
|
||||
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
|
||||
: hasMaxChars
|
||||
? maxChars
|
||||
: undefined;
|
||||
const snapshotQuery = {
|
||||
...(format ? { format } : {}),
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
};
|
||||
const snapshot = proxyRequest
|
||||
? ((await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile,
|
||||
query: {
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
},
|
||||
query: snapshotQuery,
|
||||
})) as Awaited<ReturnType<typeof browserSnapshot>>)
|
||||
: await browserSnapshot(baseUrl, {
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
...snapshotQuery,
|
||||
profile,
|
||||
});
|
||||
if (snapshot.format === "ai") {
|
||||
|
||||
@ -127,7 +127,7 @@ function registerBrowserToolAfterEachReset() {
|
||||
}
|
||||
|
||||
async function runSnapshotToolCall(params: {
|
||||
snapshotFormat: "ai" | "aria";
|
||||
snapshotFormat?: "ai" | "aria";
|
||||
refs?: "aria" | "dom";
|
||||
maxChars?: number;
|
||||
profile?: string;
|
||||
@ -243,6 +243,23 @@ describe("browser tool snapshot maxChars", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the server choose snapshot format when the user does not request one", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "chrome",
|
||||
}),
|
||||
);
|
||||
const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as
|
||||
| { format?: string; maxChars?: number }
|
||||
| undefined;
|
||||
expect(opts?.format).toBeUndefined();
|
||||
expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false);
|
||||
});
|
||||
|
||||
it("routes to node proxy when target=node", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
const tool = createBrowserTool();
|
||||
@ -250,15 +267,44 @@ describe("browser tool snapshot maxChars", () => {
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 20000 },
|
||||
{ timeoutMs: 25000 },
|
||||
expect.objectContaining({
|
||||
nodeId: "node-1",
|
||||
command: "browser.proxy",
|
||||
params: expect.objectContaining({
|
||||
timeoutMs: 20000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives node.invoke extra slack beyond the default proxy timeout", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
result: { ok: true, running: true },
|
||||
},
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "dialog",
|
||||
target: "node",
|
||||
accept: true,
|
||||
});
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 25000 },
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
timeoutMs: 20000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sandbox bridge url when node proxy is available", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
|
||||
@ -115,6 +115,7 @@ type BrowserProxyResult = {
|
||||
};
|
||||
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
|
||||
|
||||
type BrowserNodeTarget = {
|
||||
nodeId: string;
|
||||
@ -206,10 +207,11 @@ async function callBrowserProxy(params: {
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
}): Promise<BrowserProxyResult> {
|
||||
const gatewayTimeoutMs =
|
||||
const proxyTimeoutMs =
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
|
||||
const gatewayTimeoutMs = proxyTimeoutMs + BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS;
|
||||
const payload = await callGatewayTool<{ payloadJSON?: string; payload?: string }>(
|
||||
"node.invoke",
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
@ -221,7 +223,7 @@ async function callBrowserProxy(params: {
|
||||
path: params.path,
|
||||
query: params.query,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
timeoutMs: proxyTimeoutMs,
|
||||
profile: params.profile,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
|
||||
@ -772,7 +772,25 @@ describe("web_search external content wrapping", () => {
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects date_after/date_before in Brave llm-context mode", async () => {
|
||||
it.each([
|
||||
[
|
||||
"rejects date_after/date_before in Brave llm-context mode",
|
||||
{
|
||||
query: "test",
|
||||
date_after: "2025-01-01",
|
||||
date_before: "2025-01-31",
|
||||
},
|
||||
"unsupported_date_filter",
|
||||
],
|
||||
[
|
||||
"rejects ui_lang in Brave llm-context mode",
|
||||
{
|
||||
query: "test",
|
||||
ui_lang: "de-DE",
|
||||
},
|
||||
"unsupported_ui_lang",
|
||||
],
|
||||
])("%s", async (_name, input, expectedError) => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch({
|
||||
title: "unused",
|
||||
@ -795,45 +813,9 @@ describe("web_search external content wrapping", () => {
|
||||
},
|
||||
sandboxed: true,
|
||||
});
|
||||
const result = await tool?.execute?.("call-1", {
|
||||
query: "test",
|
||||
date_after: "2025-01-01",
|
||||
date_before: "2025-01-31",
|
||||
});
|
||||
const result = await tool?.execute?.("call-1", input);
|
||||
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_date_filter" });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ui_lang in Brave llm-context mode", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch({
|
||||
title: "unused",
|
||||
url: "https://example.com",
|
||||
snippets: ["unused"],
|
||||
});
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
brave: {
|
||||
mode: "llm-context",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: true,
|
||||
});
|
||||
const result = await tool?.execute?.("call-1", {
|
||||
query: "test",
|
||||
ui_lang: "de-DE",
|
||||
});
|
||||
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" });
|
||||
expect(result?.details).toMatchObject({ error: expectedError });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,11 @@ import { describe, expect, it } from "vitest";
|
||||
type BackgroundUtilsModule = {
|
||||
buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>;
|
||||
deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>;
|
||||
isLastRemainingTab: (
|
||||
allTabs: Array<{ id?: number | undefined } | null | undefined>,
|
||||
tabIdToClose: number,
|
||||
) => boolean;
|
||||
isMissingTabError: (err: unknown) => boolean;
|
||||
isRetryableReconnectError: (err: unknown) => boolean;
|
||||
reconnectDelayMs: (
|
||||
attempt: number,
|
||||
@ -26,8 +31,14 @@ async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
|
||||
}
|
||||
}
|
||||
|
||||
const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } =
|
||||
await loadBackgroundUtils();
|
||||
const {
|
||||
buildRelayWsUrl,
|
||||
deriveRelayToken,
|
||||
isLastRemainingTab,
|
||||
isMissingTabError,
|
||||
isRetryableReconnectError,
|
||||
reconnectDelayMs,
|
||||
} = await loadBackgroundUtils();
|
||||
|
||||
describe("chrome extension background utils", () => {
|
||||
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
|
||||
@ -107,4 +118,16 @@ describe("chrome extension background utils", () => {
|
||||
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
|
||||
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes missing-tab debugger errors", () => {
|
||||
expect(isMissingTabError(new Error("No tab with given id"))).toBe(true);
|
||||
expect(isMissingTabError(new Error("tab not found"))).toBe(true);
|
||||
expect(isMissingTabError(new Error("Cannot access a chrome:// URL"))).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks closing the final remaining tab only", () => {
|
||||
expect(isLastRemainingTab([{ id: 7 }], 7)).toBe(true);
|
||||
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 7)).toBe(false);
|
||||
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 8)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -101,6 +101,21 @@ describe("browser client", () => {
|
||||
expect(parsed.searchParams.get("refs")).toBe("aria");
|
||||
});
|
||||
|
||||
it("omits format when the caller wants server-side snapshot capability defaults", async () => {
|
||||
const calls: string[] = [];
|
||||
stubSnapshotFetch(calls);
|
||||
|
||||
await browserSnapshot("http://127.0.0.1:18791", {
|
||||
profile: "chrome",
|
||||
});
|
||||
|
||||
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
||||
expect(snapshotCall).toBeTruthy();
|
||||
const parsed = new URL(snapshotCall as string);
|
||||
expect(parsed.searchParams.get("format")).toBeNull();
|
||||
expect(parsed.searchParams.get("profile")).toBe("chrome");
|
||||
});
|
||||
|
||||
it("uses the expected endpoints + methods for common calls", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
|
||||
|
||||
@ -276,7 +276,7 @@ export async function browserTabAction(
|
||||
export async function browserSnapshot(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
format: "aria" | "ai";
|
||||
format?: "aria" | "ai";
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
maxChars?: number;
|
||||
@ -292,7 +292,9 @@ export async function browserSnapshot(
|
||||
},
|
||||
): Promise<SnapshotResult> {
|
||||
const q = new URLSearchParams();
|
||||
q.set("format", opts.format);
|
||||
if (opts.format) {
|
||||
q.set("format", opts.format);
|
||||
}
|
||||
if (opts.targetId) {
|
||||
q.set("targetId", opts.targetId);
|
||||
}
|
||||
|
||||
@ -115,4 +115,67 @@ describe("pw-session getPageForTargetId", () => {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => {
|
||||
const pageOn = vi.fn();
|
||||
const contextOn = vi.fn();
|
||||
const browserOn = vi.fn();
|
||||
const browserClose = vi.fn(async () => {});
|
||||
const newCDPSession = vi.fn(async () => {
|
||||
throw new Error("Target.attachToBrowserTarget: Not allowed");
|
||||
});
|
||||
|
||||
const context = {
|
||||
pages: () => [],
|
||||
on: contextOn,
|
||||
newCDPSession,
|
||||
} as unknown as import("playwright-core").BrowserContext;
|
||||
|
||||
const pageA = {
|
||||
on: pageOn,
|
||||
context: () => context,
|
||||
url: () => "https://alpha.example",
|
||||
} as unknown as import("playwright-core").Page;
|
||||
const pageB = {
|
||||
on: pageOn,
|
||||
context: () => context,
|
||||
url: () => "https://beta.example",
|
||||
} as unknown as import("playwright-core").Page;
|
||||
|
||||
(context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB];
|
||||
|
||||
const browser = {
|
||||
contexts: () => [context],
|
||||
on: browserOn,
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
connectOverCdpSpy.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Browser: "OpenClaw/extension-relay" }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||
{ id: "TARGET_B", url: "https://beta.example" },
|
||||
],
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
const resolved = await getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:19993",
|
||||
targetId: "TARGET_B",
|
||||
});
|
||||
expect(resolved).toBe(pageB);
|
||||
expect(newCDPSession).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
94
src/browser/pw-session.page-cdp.test.ts
Normal file
94
src/browser/pw-session.page-cdp.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cdpHelperMocks = vi.hoisted(() => ({
|
||||
fetchJson: vi.fn(),
|
||||
withCdpSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
const chromeMocks = vi.hoisted(() => ({
|
||||
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.helpers.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./cdp.helpers.js")>("./cdp.helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
fetchJson: cdpHelperMocks.fetchJson,
|
||||
withCdpSocket: cdpHelperMocks.withCdpSocket,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./chrome.js", () => chromeMocks);
|
||||
|
||||
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
describe("pw-session page-scoped CDP client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => {
|
||||
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
|
||||
const send = vi.fn(async () => ({ ok: true }));
|
||||
cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send));
|
||||
const newCDPSession = vi.fn();
|
||||
const page = {
|
||||
context: () => ({
|
||||
newCDPSession,
|
||||
}),
|
||||
};
|
||||
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: page as never,
|
||||
targetId: "tab-1",
|
||||
fn: async (pageSend) => {
|
||||
await pageSend("Page.bringToFront", { foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith("Page.bringToFront", {
|
||||
foo: "bar",
|
||||
targetId: "tab-1",
|
||||
});
|
||||
expect(newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to Playwright page sessions for non-relay endpoints", async () => {
|
||||
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" });
|
||||
const sessionSend = vi.fn(async () => ({ ok: true }));
|
||||
const sessionDetach = vi.fn(async () => {});
|
||||
const newCDPSession = vi.fn(async () => ({
|
||||
send: sessionSend,
|
||||
detach: sessionDetach,
|
||||
}));
|
||||
const page = {
|
||||
context: () => ({
|
||||
newCDPSession,
|
||||
}),
|
||||
};
|
||||
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
page: page as never,
|
||||
targetId: "tab-1",
|
||||
fn: async (pageSend) => {
|
||||
await pageSend("Emulation.setLocaleOverride", { locale: "en-US" });
|
||||
},
|
||||
});
|
||||
|
||||
expect(newCDPSession).toHaveBeenCalledWith(page);
|
||||
expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" });
|
||||
expect(sessionDetach).toHaveBeenCalledTimes(1);
|
||||
expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches extension-relay endpoint detection by cdpUrl", async () => {
|
||||
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
|
||||
|
||||
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true);
|
||||
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true);
|
||||
|
||||
expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
81
src/browser/pw-session.page-cdp.ts
Normal file
81
src/browser/pw-session.page-cdp.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { CDPSession, Page } from "playwright-core";
|
||||
import {
|
||||
appendCdpPath,
|
||||
fetchJson,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
withCdpSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
|
||||
const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay";
|
||||
|
||||
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
const extensionRelayByCdpUrl = new Map<string, boolean>();
|
||||
|
||||
function normalizeCdpUrl(raw: string) {
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise<boolean> {
|
||||
const normalized = normalizeCdpUrl(cdpUrl);
|
||||
const cached = extensionRelayByCdpUrl.get(normalized);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(normalized);
|
||||
const version = await fetchJson<{ Browser?: string }>(
|
||||
appendCdpPath(cdpHttpBase, "/json/version"),
|
||||
2000,
|
||||
);
|
||||
const isRelay = String(version?.Browser ?? "").trim() === OPENCLAW_EXTENSION_RELAY_BROWSER;
|
||||
extensionRelayByCdpUrl.set(normalized, isRelay);
|
||||
return isRelay;
|
||||
} catch {
|
||||
extensionRelayByCdpUrl.set(normalized, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function withPlaywrightPageCdpSession<T>(
|
||||
page: Page,
|
||||
fn: (session: CDPSession) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
return await fn(session);
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function withPageScopedCdpClient<T>(opts: {
|
||||
cdpUrl: string;
|
||||
page: Page;
|
||||
targetId?: string;
|
||||
fn: (send: PageCdpSend) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const targetId = opts.targetId?.trim();
|
||||
if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) {
|
||||
const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000);
|
||||
if (!wsUrl) {
|
||||
throw new Error("CDP websocket unavailable");
|
||||
}
|
||||
return await withCdpSocket(wsUrl, async (send) => {
|
||||
return await opts.fn((method, params) => send(method, { ...params, targetId }));
|
||||
});
|
||||
}
|
||||
|
||||
return await withPlaywrightPageCdpSession(opts.page, async (session) => {
|
||||
return await opts.fn((method, params) =>
|
||||
(
|
||||
session.send as unknown as (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => Promise<unknown>
|
||||
)(method, params),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -24,6 +24,7 @@ import {
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export type BrowserConsoleMessage = {
|
||||
type: string;
|
||||
@ -398,14 +399,70 @@ async function pageTargetId(page: Page): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function matchPageByTargetList(
|
||||
pages: Page[],
|
||||
targets: Array<{ id: string; url: string; title?: string }>,
|
||||
targetId: string,
|
||||
): Page | null {
|
||||
const target = targets.find((entry) => entry.id === targetId);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlMatch = pages.filter((page) => page.url() === target.url);
|
||||
if (urlMatch.length === 1) {
|
||||
return urlMatch[0] ?? null;
|
||||
}
|
||||
if (urlMatch.length > 1) {
|
||||
const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
|
||||
if (sameUrlTargets.length === urlMatch.length) {
|
||||
const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
|
||||
if (idx >= 0 && idx < urlMatch.length) {
|
||||
return urlMatch[idx] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findPageByTargetIdViaTargetList(
|
||||
pages: Page[],
|
||||
targetId: string,
|
||||
cdpUrl: string,
|
||||
): Promise<Page | null> {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
|
||||
const targets = await fetchJson<
|
||||
Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
|
||||
return matchPageByTargetList(pages, targets, targetId);
|
||||
}
|
||||
|
||||
async function findPageByTargetId(
|
||||
browser: Browser,
|
||||
targetId: string,
|
||||
cdpUrl?: string,
|
||||
): Promise<Page | null> {
|
||||
const pages = await getAllPages(browser);
|
||||
const isExtensionRelay = cdpUrl
|
||||
? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false)
|
||||
: false;
|
||||
if (cdpUrl && isExtensionRelay) {
|
||||
try {
|
||||
const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors and fall through to best-effort single-page fallback.
|
||||
}
|
||||
return pages.length === 1 ? (pages[0] ?? null) : null;
|
||||
}
|
||||
|
||||
let resolvedViaCdp = false;
|
||||
// First, try the standard CDP session approach
|
||||
for (const page of pages) {
|
||||
let tid: string | null = null;
|
||||
try {
|
||||
@ -418,46 +475,16 @@ async function findPageByTargetId(
|
||||
return page;
|
||||
}
|
||||
}
|
||||
// Extension relays can block CDP attachment APIs entirely. If that happens and
|
||||
// Playwright only exposes one page, return it as the best available mapping.
|
||||
if (!resolvedViaCdp && pages.length === 1) {
|
||||
return pages[0];
|
||||
}
|
||||
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
|
||||
// fall back to URL-based matching using the /json/list endpoint
|
||||
if (cdpUrl) {
|
||||
try {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
|
||||
const targets = await fetchJson<
|
||||
Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
|
||||
const target = targets.find((t) => t.id === targetId);
|
||||
if (target) {
|
||||
// Try to find a page with matching URL
|
||||
const urlMatch = pages.filter((p) => p.url() === target.url);
|
||||
if (urlMatch.length === 1) {
|
||||
return urlMatch[0];
|
||||
}
|
||||
// If multiple URL matches, use index-based matching as fallback
|
||||
// This works when Playwright and the relay enumerate tabs in the same order
|
||||
if (urlMatch.length > 1) {
|
||||
const sameUrlTargets = targets.filter((t) => t.url === target.url);
|
||||
if (sameUrlTargets.length === urlMatch.length) {
|
||||
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
|
||||
if (idx >= 0 && idx < urlMatch.length) {
|
||||
return urlMatch[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
|
||||
} catch {
|
||||
// Ignore fetch errors and fall through to return null
|
||||
// Ignore fetch errors and fall through to return null.
|
||||
}
|
||||
}
|
||||
if (!resolvedViaCdp && pages.length === 1) {
|
||||
return pages[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -806,14 +833,18 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
|
||||
try {
|
||||
await page.bringToFront();
|
||||
} catch (err) {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await session.send("Page.bringToFront");
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
await send("Page.bringToFront");
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
throw err;
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
storeRoleRefsForTarget,
|
||||
type WithSnapshotForAI,
|
||||
} from "./pw-session.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export async function snapshotAriaViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
@ -31,17 +32,21 @@ export async function snapshotAriaViaPlaywright(opts: {
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await session.send("Accessibility.enable").catch(() => {});
|
||||
const res = (await session.send("Accessibility.getFullAXTree")) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
const res = (await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
await send("Accessibility.enable").catch(() => {});
|
||||
return (await send("Accessibility.getFullAXTree")) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
},
|
||||
})) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||
}
|
||||
|
||||
export async function snapshotAiViaPlaywright(opts: {
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import type { CDPSession, Page } from "playwright-core";
|
||||
import { devices as playwrightDevices } from "playwright-core";
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
|
||||
async function withCdpSession<T>(page: Page, fn: (session: CDPSession) => Promise<T>): Promise<T> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
return await fn(session);
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export async function setOfflineViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
@ -112,15 +103,20 @@ export async function setLocaleViaPlaywright(opts: {
|
||||
if (!locale) {
|
||||
throw new Error("locale is required");
|
||||
}
|
||||
await withCdpSession(page, async (session) => {
|
||||
try {
|
||||
await session.send("Emulation.setLocaleOverride", { locale });
|
||||
} catch (err) {
|
||||
if (String(err).includes("Another locale override is already in effect")) {
|
||||
return;
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
try {
|
||||
await send("Emulation.setLocaleOverride", { locale });
|
||||
} catch (err) {
|
||||
if (String(err).includes("Another locale override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -135,19 +131,24 @@ export async function setTimezoneViaPlaywright(opts: {
|
||||
if (!timezoneId) {
|
||||
throw new Error("timezoneId is required");
|
||||
}
|
||||
await withCdpSession(page, async (session) => {
|
||||
try {
|
||||
await session.send("Emulation.setTimezoneOverride", { timezoneId });
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("Timezone override is already in effect")) {
|
||||
return;
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
try {
|
||||
await send("Emulation.setTimezoneOverride", { timezoneId });
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("Timezone override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
if (msg.includes("Invalid timezone")) {
|
||||
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (msg.includes("Invalid timezone")) {
|
||||
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -183,27 +184,32 @@ export async function setDeviceViaPlaywright(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
await withCdpSession(page, async (session) => {
|
||||
if (descriptor.userAgent || descriptor.locale) {
|
||||
await session.send("Emulation.setUserAgentOverride", {
|
||||
userAgent: descriptor.userAgent ?? "",
|
||||
acceptLanguage: descriptor.locale ?? undefined,
|
||||
});
|
||||
}
|
||||
if (descriptor.viewport) {
|
||||
await session.send("Emulation.setDeviceMetricsOverride", {
|
||||
mobile: Boolean(descriptor.isMobile),
|
||||
width: descriptor.viewport.width,
|
||||
height: descriptor.viewport.height,
|
||||
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||
screenWidth: descriptor.viewport.width,
|
||||
screenHeight: descriptor.viewport.height,
|
||||
});
|
||||
}
|
||||
if (descriptor.hasTouch) {
|
||||
await session.send("Emulation.setTouchEmulationEnabled", {
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
if (descriptor.userAgent || descriptor.locale) {
|
||||
await send("Emulation.setUserAgentOverride", {
|
||||
userAgent: descriptor.userAgent ?? "",
|
||||
acceptLanguage: descriptor.locale ?? undefined,
|
||||
});
|
||||
}
|
||||
if (descriptor.viewport) {
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
mobile: Boolean(descriptor.isMobile),
|
||||
width: descriptor.viewport.width,
|
||||
height: descriptor.viewport.height,
|
||||
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||
screenWidth: descriptor.viewport.width,
|
||||
screenHeight: descriptor.viewport.height,
|
||||
});
|
||||
}
|
||||
if (descriptor.hasTouch) {
|
||||
await send("Emulation.setTouchEmulationEnabled", {
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,17 +5,27 @@ const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(
|
||||
ensureChromeExtensionRelayServerMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({
|
||||
stopOpenClawChromeMock: vi.fn(async () => {}),
|
||||
stopChromeExtensionRelayServerMock: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({
|
||||
createBrowserRouteContextMock: vi.fn(),
|
||||
listKnownProfileNamesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./chrome.js", () => ({
|
||||
stopOpenClawChrome: stopOpenClawChromeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveProfile: resolveProfileMock,
|
||||
}));
|
||||
|
||||
vi.mock("./extension-relay.js", () => ({
|
||||
ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock,
|
||||
stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./server-context.js", () => ({
|
||||
@ -76,6 +86,8 @@ describe("stopKnownBrowserProfiles", () => {
|
||||
beforeEach(() => {
|
||||
createBrowserRouteContextMock.mockClear();
|
||||
listKnownProfileNamesMock.mockClear();
|
||||
stopOpenClawChromeMock.mockClear();
|
||||
stopChromeExtensionRelayServerMock.mockClear();
|
||||
});
|
||||
|
||||
it("stops all known profiles and ignores per-profile failures", async () => {
|
||||
@ -104,6 +116,53 @@ describe("stopKnownBrowserProfiles", () => {
|
||||
expect(onWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops tracked runtime browsers even when the profile no longer resolves", async () => {
|
||||
listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]);
|
||||
createBrowserRouteContextMock.mockReturnValue({
|
||||
forProfile: vi.fn(() => {
|
||||
throw new Error("profile not found");
|
||||
}),
|
||||
});
|
||||
const localRuntime = {
|
||||
profile: {
|
||||
name: "deleted-local",
|
||||
driver: "openclaw",
|
||||
},
|
||||
running: {
|
||||
pid: 42,
|
||||
cdpPort: 18888,
|
||||
},
|
||||
};
|
||||
const launchedBrowser = localRuntime.running;
|
||||
const extensionRuntime = {
|
||||
profile: {
|
||||
name: "deleted-extension",
|
||||
driver: "extension",
|
||||
cdpUrl: "http://127.0.0.1:19999",
|
||||
},
|
||||
running: null,
|
||||
};
|
||||
const profiles = new Map<string, unknown>([
|
||||
["deleted-local", localRuntime],
|
||||
["deleted-extension", extensionRuntime],
|
||||
]);
|
||||
const state = {
|
||||
resolved: { profiles: {} },
|
||||
profiles,
|
||||
};
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
getState: () => state as never,
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser);
|
||||
expect(localRuntime.running).toBeNull();
|
||||
expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:19999",
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when profile enumeration fails", async () => {
|
||||
listKnownProfileNamesMock.mockImplementation(() => {
|
||||
throw new Error("oops");
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { stopOpenClawChrome } from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||
import {
|
||||
ensureChromeExtensionRelayServer,
|
||||
stopChromeExtensionRelayServer,
|
||||
} from "./extension-relay.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
@ -40,6 +44,18 @@ export async function stopKnownBrowserProfiles(params: {
|
||||
try {
|
||||
for (const name of listKnownProfileNames(current)) {
|
||||
try {
|
||||
const runtime = current.profiles.get(name);
|
||||
if (runtime?.running) {
|
||||
await stopOpenClawChrome(runtime.running);
|
||||
runtime.running = null;
|
||||
continue;
|
||||
}
|
||||
if (runtime?.profile.driver === "extension") {
|
||||
await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch(
|
||||
() => false,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@ -1,10 +1,25 @@
|
||||
import type { ZodTypeAny } from "zod";
|
||||
import { z, type ZodTypeAny } from "zod";
|
||||
import type { ChannelConfigSchema } from "./types.plugin.js";
|
||||
|
||||
type ZodSchemaWithToJsonSchema = ZodTypeAny & {
|
||||
toJSONSchema?: (params?: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
type ExtendableZodObject = ZodTypeAny & {
|
||||
extend: (shape: Record<string, ZodTypeAny>) => ZodTypeAny;
|
||||
};
|
||||
|
||||
export const AllowFromEntrySchema = z.union([z.string(), z.number()]);
|
||||
|
||||
export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodObject>(
|
||||
accountSchema: T,
|
||||
): T {
|
||||
return accountSchema.extend({
|
||||
accounts: z.object({}).catchall(accountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
|
||||
const schemaWithJson = schema as ZodSchemaWithToJsonSchema;
|
||||
if (typeof schemaWithJson.toJSONSchema === "function") {
|
||||
|
||||
@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
|
||||
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
|
||||
const resolveGatewayPort = vi.fn(() => 18789);
|
||||
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
|
||||
const probeGateway = vi.fn<
|
||||
(opts: {
|
||||
url: string;
|
||||
auth?: { token?: string; password?: string };
|
||||
timeoutMs: number;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
configSnapshot: unknown;
|
||||
}>
|
||||
>();
|
||||
const probeGateway =
|
||||
vi.fn<
|
||||
(opts: {
|
||||
url: string;
|
||||
auth?: { token?: string; password?: string };
|
||||
timeoutMs: number;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
configSnapshot: unknown;
|
||||
}>
|
||||
>();
|
||||
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
|
||||
const loadConfig = vi.fn(() => ({}));
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js
|
||||
import { parseCmdScriptCommandLine } from "../../daemon/cmd-argv.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { probeGateway } from "../../gateway/probe.js";
|
||||
import { isGatewayArgv, parseProcCmdline } from "../../infra/gateway-process-argv.js";
|
||||
import { findGatewayPidsOnPortSync } from "../../infra/restart.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
@ -42,17 +43,6 @@ async function resolveGatewayLifecyclePort(service = resolveGatewayService()) {
|
||||
return portFromArgs ?? resolveGatewayPort(await readBestEffortConfig(), mergedEnv);
|
||||
}
|
||||
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractWindowsCommandLine(raw: string): string | null {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
@ -68,31 +58,6 @@ function extractWindowsCommandLine(raw: string): string | null {
|
||||
return lines.find((line) => line.toLowerCase() !== "commandline") ?? null;
|
||||
}
|
||||
|
||||
function stripExecutableExtension(value: string): string {
|
||||
return value.replace(/\.(bat|cmd|exe)$/i, "");
|
||||
}
|
||||
|
||||
function isGatewayArgv(args: string[]): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = stripExecutableExtension(normalized[0] ?? "");
|
||||
return exe.endsWith("/openclaw") || exe === "openclaw" || exe.endsWith("/openclaw-gateway");
|
||||
}
|
||||
|
||||
function readGatewayProcessArgsSync(pid: number): string[] | null {
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
@ -135,7 +100,7 @@ function resolveGatewayListenerPids(port: number): number[] {
|
||||
.filter((pid): pid is number => Number.isFinite(pid) && pid > 0)
|
||||
.filter((pid) => {
|
||||
const args = readGatewayProcessArgsSync(pid);
|
||||
return args != null && isGatewayArgv(args);
|
||||
return args != null && isGatewayArgv(args, { allowGatewayBinary: true });
|
||||
});
|
||||
}
|
||||
|
||||
@ -147,7 +112,7 @@ function resolveGatewayPortFallback(): Promise<number> {
|
||||
|
||||
function signalGatewayPid(pid: number, signal: "SIGTERM" | "SIGUSR1") {
|
||||
const args = readGatewayProcessArgsSync(pid);
|
||||
if (!args || !isGatewayArgv(args)) {
|
||||
if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) {
|
||||
throw new Error(`refusing to signal non-gateway process pid ${pid}`);
|
||||
}
|
||||
process.kill(pid, signal);
|
||||
|
||||
@ -242,6 +242,22 @@ export async function waitForGatewayHealthyListener(params: {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function renderPortUsageDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (snapshot.portUsage.status === "busy") {
|
||||
lines.push(...formatPortDiagnostics(snapshot.portUsage));
|
||||
} else {
|
||||
lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.errors?.length) {
|
||||
lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): string[] {
|
||||
const lines: string[] = [];
|
||||
const runtimeSummary = [
|
||||
@ -257,33 +273,13 @@ export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): stri
|
||||
lines.push(`Service runtime: ${runtimeSummary}`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.status === "busy") {
|
||||
lines.push(...formatPortDiagnostics(snapshot.portUsage));
|
||||
} else {
|
||||
lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.errors?.length) {
|
||||
lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`);
|
||||
}
|
||||
lines.push(...renderPortUsageDiagnostics(snapshot));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderGatewayPortHealthDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (snapshot.portUsage.status === "busy") {
|
||||
lines.push(...formatPortDiagnostics(snapshot.portUsage));
|
||||
} else {
|
||||
lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.errors?.length) {
|
||||
lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
return renderPortUsageDiagnostics(snapshot);
|
||||
}
|
||||
|
||||
export async function terminateStaleGatewayPids(pids: number[]): Promise<number[]> {
|
||||
|
||||
@ -354,8 +354,8 @@ describe("models list/status", () => {
|
||||
|
||||
await modelsListCommand({ all: true, json: true }, runtime);
|
||||
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig);
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalled();
|
||||
expect(ensureOpenClawModelsJson.mock.calls[0]?.[0]).toEqual(resolvedConfig);
|
||||
});
|
||||
|
||||
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
|
||||
|
||||
@ -38,6 +38,7 @@ const mocks = vi.hoisted(() => {
|
||||
loadModelRegistry: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }),
|
||||
loadModelCatalog: vi.fn().mockResolvedValue([]),
|
||||
resolveConfiguredEntries: vi.fn().mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
@ -66,6 +67,8 @@ const mocks = vi.hoisted(() => {
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
getRuntimeConfigSnapshot: vi.fn().mockReturnValue(null),
|
||||
getRuntimeConfigSourceSnapshot: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
||||
@ -77,6 +80,10 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: mocks.loadModelCatalog,
|
||||
}));
|
||||
|
||||
vi.mock("./list.registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./list.registry.js")>();
|
||||
return {
|
||||
@ -177,25 +184,163 @@ describe("modelsListCommand forward-compat", () => {
|
||||
availableKeys: new Set(),
|
||||
registry: {},
|
||||
});
|
||||
mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) =>
|
||||
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
|
||||
provider === "openai-codex" ? ([{ id: "profile-1" }] as Array<Record<string, unknown>>) : [],
|
||||
);
|
||||
const runtime = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
await modelsListCommand({ json: true }, runtime as never);
|
||||
try {
|
||||
await modelsListCommand({ json: true }, runtime as never);
|
||||
|
||||
expect(mocks.printModelTable).toHaveBeenCalled();
|
||||
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
|
||||
key: string;
|
||||
available: boolean;
|
||||
}>;
|
||||
|
||||
expect(rows).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "openai-codex/gpt-5.4",
|
||||
available: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
mocks.listProfilesForProvider.mockReturnValue([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => {
|
||||
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
|
||||
mocks.loadModelRegistry.mockResolvedValueOnce({
|
||||
models: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
input: ["text"],
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
|
||||
registry: {},
|
||||
});
|
||||
mocks.loadModelCatalog.mockResolvedValueOnce([
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
input: ["text"],
|
||||
contextWindow: 272000,
|
||||
},
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
input: ["text"],
|
||||
contextWindow: 272000,
|
||||
},
|
||||
]);
|
||||
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
|
||||
provider === "openai-codex" ? ([{ id: "profile-1" }] as Array<Record<string, unknown>>) : [],
|
||||
);
|
||||
mocks.resolveModelWithRegistry.mockImplementation(
|
||||
({ provider, modelId }: { provider: string; modelId: string }) => {
|
||||
if (provider !== "openai-codex") {
|
||||
return undefined;
|
||||
}
|
||||
if (modelId === "gpt-5.3-codex") {
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
input: ["text"],
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
}
|
||||
if (modelId === "gpt-5.4") {
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
input: ["text"],
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
const runtime = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
try {
|
||||
await modelsListCommand(
|
||||
{ all: true, provider: "openai-codex", json: true },
|
||||
runtime as never,
|
||||
);
|
||||
|
||||
expect(mocks.printModelTable).toHaveBeenCalled();
|
||||
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
|
||||
key: string;
|
||||
available: boolean;
|
||||
}>;
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "openai-codex/gpt-5.3-codex",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "openai-codex/gpt-5.4",
|
||||
available: true,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
mocks.listProfilesForProvider.mockReturnValue([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps discovered rows in --all output when catalog lookup is empty", async () => {
|
||||
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
|
||||
mocks.loadModelRegistry.mockResolvedValueOnce({
|
||||
models: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
input: ["text"],
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
|
||||
registry: {},
|
||||
});
|
||||
mocks.loadModelCatalog.mockResolvedValueOnce([]);
|
||||
const runtime = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never);
|
||||
|
||||
expect(mocks.printModelTable).toHaveBeenCalled();
|
||||
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
|
||||
key: string;
|
||||
available: boolean;
|
||||
}>;
|
||||
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>;
|
||||
|
||||
expect(rows).toContainEqual(
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "openai-codex/gpt-5.4",
|
||||
available: true,
|
||||
key: "openai-codex/gpt-5.3-codex",
|
||||
}),
|
||||
);
|
||||
]);
|
||||
});
|
||||
|
||||
it("exits with an error when configured-mode listing has no model registry", async () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||
import { parseModelRef } from "../../agents/model-selection.js";
|
||||
import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
@ -69,6 +70,7 @@ export async function modelsListCommand(
|
||||
const rows: ModelRow[] = [];
|
||||
|
||||
if (opts.all) {
|
||||
const seenKeys = new Set<string>();
|
||||
const sorted = [...models].toSorted((a, b) => {
|
||||
const p = a.provider.localeCompare(b.provider);
|
||||
if (p !== 0) {
|
||||
@ -97,6 +99,46 @@ export async function modelsListCommand(
|
||||
authStore,
|
||||
}),
|
||||
);
|
||||
seenKeys.add(key);
|
||||
}
|
||||
|
||||
if (modelRegistry) {
|
||||
const catalog = await loadModelCatalog({ config: cfg });
|
||||
for (const entry of catalog) {
|
||||
if (providerFilter && entry.provider.toLowerCase() !== providerFilter) {
|
||||
continue;
|
||||
}
|
||||
const key = modelKey(entry.provider, entry.id);
|
||||
if (seenKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const model = resolveModelWithRegistry({
|
||||
provider: entry.provider,
|
||||
modelId: entry.id,
|
||||
modelRegistry,
|
||||
cfg,
|
||||
});
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
if (opts.local && !isLocalBaseUrl(model.baseUrl)) {
|
||||
continue;
|
||||
}
|
||||
const configured = configuredByKey.get(key);
|
||||
rows.push(
|
||||
toModelRow({
|
||||
model,
|
||||
key,
|
||||
tags: configured ? Array.from(configured.tags) : [],
|
||||
aliases: configured?.aliases ?? [],
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
allowProviderAvailabilityFallback: !discoveredKeys.has(key),
|
||||
}),
|
||||
);
|
||||
seenKeys.add(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const registry = modelRegistry;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export {
|
||||
clearConfigCache,
|
||||
ConfigRuntimeRefreshError,
|
||||
clearRuntimeConfigSnapshot,
|
||||
createConfigIO,
|
||||
getRuntimeConfigSnapshot,
|
||||
@ -10,6 +11,7 @@ export {
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
resolveConfigSnapshotHash,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
writeConfigFile,
|
||||
} from "./io.js";
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
loadConfig,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
writeConfigFile,
|
||||
} from "./io.js";
|
||||
@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig {
|
||||
}
|
||||
|
||||
function resetRuntimeConfigState(): void {
|
||||
setRuntimeConfigSnapshotRefreshHandler(null);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
@ -96,4 +98,117 @@ describe("runtime config snapshot writes", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the runtime snapshot after writes so follow-up reads see persisted changes", async () => {
|
||||
await withTempHome("openclaw-config-runtime-write-refresh-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const nextRuntimeConfig: OpenClawConfig = {
|
||||
...runtimeConfig,
|
||||
gateway: { auth: { mode: "token" as const } },
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
|
||||
await writeConfigFile(nextRuntimeConfig);
|
||||
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBeDefined();
|
||||
|
||||
let persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: unknown };
|
||||
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||
};
|
||||
expect(persisted.gateway?.auth).toEqual({ mode: "token" });
|
||||
// Post-write secret-ref: apiKey must stay as source ref (not plaintext).
|
||||
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
});
|
||||
|
||||
// Follow-up write: runtimeConfigSourceSnapshot must be restored so second write
|
||||
// still runs secret-preservation merge-patch and keeps apiKey as ref (not plaintext).
|
||||
await writeConfigFile(loadConfig());
|
||||
persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: unknown };
|
||||
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||
};
|
||||
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => {
|
||||
await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const sourceConfig = createSourceConfig();
|
||||
const runtimeConfig = createRuntimeConfig();
|
||||
const nextRuntimeConfig: OpenClawConfig = {
|
||||
...runtimeConfig,
|
||||
gateway: { auth: { mode: "token" as const } },
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshPending = new Promise<boolean>((resolve) => {
|
||||
releaseRefresh = () => resolve(true);
|
||||
});
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
setRuntimeConfigSnapshotRefreshHandler({
|
||||
refresh: async ({ sourceConfig: refreshedSource }) => {
|
||||
expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
return await refreshPending;
|
||||
},
|
||||
});
|
||||
|
||||
const writePromise = writeConfigFile(nextRuntimeConfig);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
releaseRefresh();
|
||||
await writePromise;
|
||||
} finally {
|
||||
resetRuntimeConfigState();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -140,6 +140,22 @@ export type ReadConfigFileSnapshotForWriteResult = {
|
||||
writeOptions: ConfigWriteOptions;
|
||||
};
|
||||
|
||||
export type RuntimeConfigSnapshotRefreshParams = {
|
||||
sourceConfig: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type RuntimeConfigSnapshotRefreshHandler = {
|
||||
refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise<boolean>;
|
||||
clearOnRefreshFailure?: () => void;
|
||||
};
|
||||
|
||||
export class ConfigRuntimeRefreshError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "ConfigRuntimeRefreshError";
|
||||
}
|
||||
}
|
||||
|
||||
function hashConfigRaw(raw: string | null): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
@ -1306,6 +1322,7 @@ let configCache: {
|
||||
} | null = null;
|
||||
let runtimeConfigSnapshot: OpenClawConfig | null = null;
|
||||
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
|
||||
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
|
||||
|
||||
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
|
||||
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
|
||||
@ -1356,6 +1373,12 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
|
||||
return runtimeConfigSourceSnapshot;
|
||||
}
|
||||
|
||||
export function setRuntimeConfigSnapshotRefreshHandler(
|
||||
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
|
||||
): void {
|
||||
runtimeConfigSnapshotRefreshHandler = refreshHandler;
|
||||
}
|
||||
|
||||
export function loadConfig(): OpenClawConfig {
|
||||
if (runtimeConfigSnapshot) {
|
||||
return runtimeConfigSnapshot;
|
||||
@ -1402,9 +1425,11 @@ export async function writeConfigFile(
|
||||
): Promise<void> {
|
||||
const io = createConfigIO();
|
||||
let nextCfg = cfg;
|
||||
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
|
||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
|
||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
|
||||
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
|
||||
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
|
||||
if (hadBothSnapshots) {
|
||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
|
||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
|
||||
}
|
||||
const sameConfigPath =
|
||||
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
||||
@ -1412,4 +1437,38 @@ export async function writeConfigFile(
|
||||
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
||||
unsetPaths: options.unsetPaths,
|
||||
});
|
||||
// Keep the last-known-good runtime snapshot active until the specialized refresh path
|
||||
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
|
||||
const refreshHandler = runtimeConfigSnapshotRefreshHandler;
|
||||
if (refreshHandler) {
|
||||
try {
|
||||
const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg });
|
||||
if (refreshed) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
refreshHandler.clearOnRefreshFailure?.();
|
||||
} catch {
|
||||
// Keep the original refresh failure as the surfaced error.
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new ConfigRuntimeRefreshError(
|
||||
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hadBothSnapshots) {
|
||||
// Refresh both snapshots from disk atomically so follow-up reads get normalized config and
|
||||
// subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true).
|
||||
const fresh = io.loadConfig();
|
||||
setRuntimeConfigSnapshot(fresh, nextCfg);
|
||||
return;
|
||||
}
|
||||
if (hadRuntimeSnapshot) {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
// When we had no runtime snapshot, keep callers reading from disk/cache so external/manual
|
||||
// edits to openclaw.json remain visible (no stale snapshot).
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js";
|
||||
import { isPidAlive } from "../shared/pid-alive.js";
|
||||
import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
@ -46,38 +47,6 @@ export class GatewayLockError extends Error {
|
||||
|
||||
type LockOwnerStatus = "alive" | "dead" | "unknown";
|
||||
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isGatewayArgv(args: string[]): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = normalized[0] ?? "";
|
||||
return exe.endsWith("/openclaw") || exe === "openclaw";
|
||||
}
|
||||
|
||||
function readLinuxCmdline(pid: number): string[] | null {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||
|
||||
35
src/infra/gateway-process-argv.ts
Normal file
35
src/infra/gateway-process-argv.ts
Normal file
@ -0,0 +1,35 @@
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
export function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
|
||||
return (
|
||||
exe.endsWith("/openclaw") ||
|
||||
exe === "openclaw" ||
|
||||
(opts?.allowGatewayBinary === true && exe.endsWith("/openclaw-gateway"))
|
||||
);
|
||||
}
|
||||
99
src/node-host/invoke-browser.test.ts
Normal file
99
src/node-host/invoke-browser.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const controlServiceMocks = vi.hoisted(() => ({
|
||||
createBrowserControlContext: vi.fn(() => ({ control: true })),
|
||||
startBrowserControlServiceFromConfig: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const dispatcherMocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
createBrowserRouteDispatcher: vi.fn(() => ({
|
||||
dispatch: dispatcherMocks.dispatch,
|
||||
})),
|
||||
}));
|
||||
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true } },
|
||||
})),
|
||||
}));
|
||||
|
||||
const browserConfigMocks = vi.hoisted(() => ({
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
enabled: true,
|
||||
defaultProfile: "chrome",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../browser/control-service.js", () => controlServiceMocks);
|
||||
vi.mock("../browser/routes/dispatcher.js", () => dispatcherMocks);
|
||||
vi.mock("../config/config.js", () => configMocks);
|
||||
vi.mock("../browser/config.js", () => browserConfigMocks);
|
||||
vi.mock("../media/mime.js", () => ({
|
||||
detectMime: vi.fn(async () => "image/png"),
|
||||
}));
|
||||
|
||||
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
||||
|
||||
describe("runBrowserProxyCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true } },
|
||||
});
|
||||
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
defaultProfile: "chrome",
|
||||
});
|
||||
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("adds profile and browser status details on ws-backed timeouts", async () => {
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: {
|
||||
running: true,
|
||||
cdpHttp: true,
|
||||
cdpReady: false,
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile: "chrome",
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-timeout browser errors intact", async () => {
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 500,
|
||||
body: { error: "tab not found" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
profile: "chrome",
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("tab not found");
|
||||
});
|
||||
});
|
||||
@ -30,6 +30,8 @@ type BrowserProxyResult = {
|
||||
};
|
||||
|
||||
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
|
||||
|
||||
function normalizeProfileAllowlist(raw?: string[]): string[] {
|
||||
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
|
||||
@ -119,6 +121,87 @@ function decodeParams<T>(raw?: string | null): T {
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
function resolveBrowserProxyTimeout(timeoutMs?: number): number {
|
||||
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
||||
? Math.max(1, Math.floor(timeoutMs))
|
||||
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function isBrowserProxyTimeoutError(err: unknown): boolean {
|
||||
return String(err).includes("browser proxy request timed out");
|
||||
}
|
||||
|
||||
function isWsBackedBrowserProxyPath(path: string): boolean {
|
||||
return (
|
||||
path === "/act" ||
|
||||
path === "/navigate" ||
|
||||
path === "/pdf" ||
|
||||
path === "/screenshot" ||
|
||||
path === "/snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
async function readBrowserProxyStatus(params: {
|
||||
dispatcher: ReturnType<typeof createBrowserRouteDispatcher>;
|
||||
profile?: string;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
const query = params.profile ? { profile: params.profile } : {};
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
(signal) =>
|
||||
params.dispatcher.dispatch({
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query,
|
||||
signal,
|
||||
}),
|
||||
BROWSER_PROXY_STATUS_TIMEOUT_MS,
|
||||
"browser proxy status",
|
||||
);
|
||||
if (response.status >= 400 || !response.body || typeof response.body !== "object") {
|
||||
return null;
|
||||
}
|
||||
const body = response.body as Record<string, unknown>;
|
||||
return {
|
||||
running: body.running,
|
||||
cdpHttp: body.cdpHttp,
|
||||
cdpReady: body.cdpReady,
|
||||
cdpUrl: body.cdpUrl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBrowserProxyTimeoutMessage(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
profile?: string;
|
||||
timeoutMs: number;
|
||||
wsBacked: boolean;
|
||||
status: Record<string, unknown> | null;
|
||||
}): string {
|
||||
const parts = [
|
||||
`browser proxy timed out for ${params.method} ${params.path} after ${params.timeoutMs}ms`,
|
||||
params.wsBacked ? "ws-backed browser action" : "browser action",
|
||||
];
|
||||
if (params.profile) {
|
||||
parts.push(`profile=${params.profile}`);
|
||||
}
|
||||
if (params.status) {
|
||||
const statusParts = [
|
||||
`running=${String(params.status.running)}`,
|
||||
`cdpHttp=${String(params.status.cdpHttp)}`,
|
||||
`cdpReady=${String(params.status.cdpReady)}`,
|
||||
];
|
||||
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) {
|
||||
statusParts.push(`cdpUrl=${params.status.cdpUrl}`);
|
||||
}
|
||||
parts.push(`status(${statusParts.join(", ")})`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
|
||||
const params = decodeParams<BrowserProxyParams>(paramsJSON);
|
||||
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
|
||||
@ -151,6 +234,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
||||
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
|
||||
const body = params.body;
|
||||
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
|
||||
const query: Record<string, unknown> = {};
|
||||
if (requestedProfile) {
|
||||
query.profile = requestedProfile;
|
||||
@ -164,18 +248,41 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
const response = await withTimeout(
|
||||
(signal) =>
|
||||
dispatcher.dispatch({
|
||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||
let response;
|
||||
try {
|
||||
response = await withTimeout(
|
||||
(signal) =>
|
||||
dispatcher.dispatch({
|
||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
}),
|
||||
timeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isBrowserProxyTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const profileForStatus = requestedProfile || resolved.defaultProfile;
|
||||
const status = await readBrowserProxyStatus({
|
||||
dispatcher,
|
||||
profile: path === "/profiles" ? undefined : profileForStatus,
|
||||
});
|
||||
throw new Error(
|
||||
formatBrowserProxyTimeoutMessage({
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
profile: path === "/profiles" ? undefined : profileForStatus || undefined,
|
||||
timeoutMs,
|
||||
wsBacked: isWsBackedBrowserProxyPath(path),
|
||||
status,
|
||||
}),
|
||||
params.timeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
const message =
|
||||
response.body && typeof response.body === "object" && "error" in response.body
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js";
|
||||
import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
@ -55,6 +59,51 @@ export function createScopedAccountConfigAccessors<ResolvedAccount>(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function createScopedChannelConfigBase<
|
||||
ResolvedAccount,
|
||||
Config extends OpenClawConfig = OpenClawConfig,
|
||||
>(params: {
|
||||
sectionKey: string;
|
||||
listAccountIds: (cfg: Config) => string[];
|
||||
resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount;
|
||||
defaultAccountId: (cfg: Config) => string;
|
||||
inspectAccount?: (cfg: Config, accountId?: string | null) => unknown;
|
||||
clearBaseFields: string[];
|
||||
allowTopLevel?: boolean;
|
||||
}): Pick<
|
||||
ChannelConfigAdapter<ResolvedAccount>,
|
||||
| "listAccountIds"
|
||||
| "resolveAccount"
|
||||
| "inspectAccount"
|
||||
| "defaultAccountId"
|
||||
| "setAccountEnabled"
|
||||
| "deleteAccount"
|
||||
> {
|
||||
return {
|
||||
listAccountIds: (cfg) => params.listAccountIds(cfg as Config),
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId),
|
||||
inspectAccount: params.inspectAccount
|
||||
? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId)
|
||||
: undefined,
|
||||
defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as Config,
|
||||
sectionKey: params.sectionKey,
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: params.allowTopLevel ?? true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as Config,
|
||||
sectionKey: params.sectionKey,
|
||||
accountId,
|
||||
clearBaseFields: params.clearBaseFields,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWhatsAppConfigAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
||||
@ -194,6 +194,12 @@ export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { buildChannelSendResult } from "./channel-send-result.js";
|
||||
export type { ChannelSendRawResult } from "./channel-send-result.js";
|
||||
export { createPluginRuntimeStore } from "./runtime-store.js";
|
||||
export { createScopedChannelConfigBase } from "./channel-config-helpers.js";
|
||||
export {
|
||||
AllowFromEntrySchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
} from "../channels/plugins/config-schema.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { getChatChannelMeta } from "../channels/registry.js";
|
||||
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
||||
|
||||
26
src/plugin-sdk/runtime-store.ts
Normal file
26
src/plugin-sdk/runtime-store.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export function createPluginRuntimeStore<T>(errorMessage: string): {
|
||||
setRuntime: (next: T) => void;
|
||||
clearRuntime: () => void;
|
||||
tryGetRuntime: () => T | null;
|
||||
getRuntime: () => T;
|
||||
} {
|
||||
let runtime: T | null = null;
|
||||
|
||||
return {
|
||||
setRuntime(next: T) {
|
||||
runtime = next;
|
||||
},
|
||||
clearRuntime() {
|
||||
runtime = null;
|
||||
},
|
||||
tryGetRuntime() {
|
||||
return runtime;
|
||||
},
|
||||
getRuntime() {
|
||||
if (!runtime) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return runtime;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -3,10 +3,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, type OpenClawConfig, writeConfigFile } from "../config/config.js";
|
||||
import { withTempHome } from "../config/home-env.test-harness.js";
|
||||
import {
|
||||
activateSecretsRuntimeSnapshot,
|
||||
clearSecretsRuntimeSnapshot,
|
||||
getActiveSecretsRuntimeSnapshot,
|
||||
prepareSecretsRuntimeSnapshot,
|
||||
} from "./runtime.js";
|
||||
|
||||
@ -527,6 +529,248 @@ describe("secrets runtime snapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps active secrets runtime snapshots resolved after config writes", async () => {
|
||||
await withTempHome("openclaw-secrets-runtime-write-", async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const secretFile = path.join(configDir, "secrets.json");
|
||||
const agentDir = path.join(configDir, "agents", "main", "agent");
|
||||
const authStorePath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.chmod(configDir, 0o700).catch(() => {
|
||||
// best-effort on tmp dirs that already have secure perms
|
||||
});
|
||||
await fs.writeFile(
|
||||
secretFile,
|
||||
`${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, // pragma: allowlist secret
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
authStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "file", path: secretFile, mode: "json" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
agentDirs: [agentDir],
|
||||
});
|
||||
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
|
||||
expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
key: "sk-file-runtime",
|
||||
});
|
||||
|
||||
await writeConfigFile({
|
||||
...loadConfig(),
|
||||
gateway: { auth: { mode: "token" } },
|
||||
});
|
||||
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
|
||||
expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
key: "sk-file-runtime",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("clears active secrets runtime state and throws when refresh fails after a write", async () => {
|
||||
await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const secretFile = path.join(configDir, "secrets.json");
|
||||
const agentDir = path.join(configDir, "agents", "main", "agent");
|
||||
const authStorePath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.chmod(configDir, 0o700).catch(() => {
|
||||
// best-effort on tmp dirs that already have secure perms
|
||||
});
|
||||
await fs.writeFile(
|
||||
secretFile,
|
||||
`${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
authStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
let loadAuthStoreCalls = 0;
|
||||
const loadAuthStore = () => {
|
||||
loadAuthStoreCalls += 1;
|
||||
if (loadAuthStoreCalls > 1) {
|
||||
throw new Error("simulated secrets runtime refresh failure");
|
||||
}
|
||||
return loadAuthStoreWithProfiles({
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "file", path: secretFile, mode: "json" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
agentDirs: [agentDir],
|
||||
loadAuthStore,
|
||||
});
|
||||
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
|
||||
await expect(
|
||||
writeConfigFile({
|
||||
...loadConfig(),
|
||||
gateway: { auth: { mode: "token" } },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/runtime snapshot refresh failed: simulated secrets runtime refresh failure/i,
|
||||
);
|
||||
|
||||
expect(getActiveSecretsRuntimeSnapshot()).toBeNull();
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "file",
|
||||
provider: "default",
|
||||
id: "/providers/openai/apiKey",
|
||||
});
|
||||
|
||||
const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"];
|
||||
expect(persistedStore).toMatchObject({
|
||||
type: "api_key",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
});
|
||||
expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("recomputes config-derived agent dirs when refreshing active secrets runtime snapshots", async () => {
|
||||
await withTempHome("openclaw-secrets-runtime-agent-dirs-", async (home) => {
|
||||
const mainAgentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
||||
const opsAgentDir = path.join(home, ".openclaw", "agents", "ops", "agent");
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await fs.mkdir(opsAgentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(mainAgentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(opsAgentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:ops": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({}),
|
||||
env: {
|
||||
OPENAI_API_KEY: "sk-main-runtime", // pragma: allowlist secret
|
||||
ANTHROPIC_API_KEY: "sk-ops-runtime", // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toBeUndefined();
|
||||
|
||||
await writeConfigFile({
|
||||
agents: {
|
||||
list: [{ id: "ops", agentDir: opsAgentDir }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toMatchObject({
|
||||
type: "api_key",
|
||||
key: "sk-ops-runtime",
|
||||
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips inactive-surface refs and emits diagnostics", async () => {
|
||||
const config = asConfig({
|
||||
agents: {
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
} from "../agents/auth-profiles.js";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
@ -34,7 +35,18 @@ export type PreparedSecretsRuntimeSnapshot = {
|
||||
warnings: SecretResolverWarning[];
|
||||
};
|
||||
|
||||
type SecretsRuntimeRefreshContext = {
|
||||
env: Record<string, string | undefined>;
|
||||
explicitAgentDirs: string[] | null;
|
||||
loadAuthStore: (agentDir?: string) => AuthProfileStore;
|
||||
};
|
||||
|
||||
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
|
||||
let activeRefreshContext: SecretsRuntimeRefreshContext | null = null;
|
||||
const preparedSnapshotRefreshContext = new WeakMap<
|
||||
PreparedSecretsRuntimeSnapshot,
|
||||
SecretsRuntimeRefreshContext
|
||||
>();
|
||||
|
||||
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
|
||||
return {
|
||||
@ -48,6 +60,22 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRefreshContext(context: SecretsRuntimeRefreshContext): SecretsRuntimeRefreshContext {
|
||||
return {
|
||||
env: { ...context.env },
|
||||
explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null,
|
||||
loadAuthStore: context.loadAuthStore,
|
||||
};
|
||||
}
|
||||
|
||||
function clearActiveSecretsRuntimeState(): void {
|
||||
activeSnapshot = null;
|
||||
activeRefreshContext = null;
|
||||
setRuntimeConfigSnapshotRefreshHandler(null);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
|
||||
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
|
||||
const dirs = new Set<string>();
|
||||
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
|
||||
@ -57,6 +85,17 @@ function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
function resolveRefreshAgentDirs(
|
||||
config: OpenClawConfig,
|
||||
context: SecretsRuntimeRefreshContext,
|
||||
): string[] {
|
||||
const configDerived = collectCandidateAgentDirs(config);
|
||||
if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) {
|
||||
return configDerived;
|
||||
}
|
||||
return [...new Set([...context.explicitAgentDirs, ...configDerived])];
|
||||
}
|
||||
|
||||
export async function prepareSecretsRuntimeSnapshot(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@ -104,23 +143,61 @@ export async function prepareSecretsRuntimeSnapshot(params: {
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
const snapshot = {
|
||||
sourceConfig,
|
||||
config: resolvedConfig,
|
||||
authStores,
|
||||
warnings: context.warnings,
|
||||
};
|
||||
preparedSnapshotRefreshContext.set(snapshot, {
|
||||
env: { ...(params.env ?? process.env) } as Record<string, string | undefined>,
|
||||
explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null,
|
||||
loadAuthStore,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
|
||||
const next = cloneSnapshot(snapshot);
|
||||
const refreshContext =
|
||||
preparedSnapshotRefreshContext.get(snapshot) ??
|
||||
activeRefreshContext ??
|
||||
({
|
||||
env: { ...process.env } as Record<string, string | undefined>,
|
||||
explicitAgentDirs: null,
|
||||
loadAuthStore: loadAuthProfileStoreForSecretsRuntime,
|
||||
} satisfies SecretsRuntimeRefreshContext);
|
||||
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
|
||||
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
|
||||
activeSnapshot = next;
|
||||
activeRefreshContext = cloneRefreshContext(refreshContext);
|
||||
setRuntimeConfigSnapshotRefreshHandler({
|
||||
refresh: async ({ sourceConfig }) => {
|
||||
if (!activeSnapshot || !activeRefreshContext) {
|
||||
return false;
|
||||
}
|
||||
const refreshed = await prepareSecretsRuntimeSnapshot({
|
||||
config: sourceConfig,
|
||||
env: activeRefreshContext.env,
|
||||
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
|
||||
loadAuthStore: activeRefreshContext.loadAuthStore,
|
||||
});
|
||||
activateSecretsRuntimeSnapshot(refreshed);
|
||||
return true;
|
||||
},
|
||||
clearOnRefreshFailure: clearActiveSecretsRuntimeState,
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
|
||||
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
|
||||
if (!activeSnapshot) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = cloneSnapshot(activeSnapshot);
|
||||
if (activeRefreshContext) {
|
||||
preparedSnapshotRefreshContext.set(snapshot, cloneRefreshContext(activeRefreshContext));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
|
||||
@ -155,7 +232,5 @@ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
|
||||
}
|
||||
|
||||
export function clearSecretsRuntimeSnapshot(): void {
|
||||
activeSnapshot = null;
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
clearActiveSecretsRuntimeState();
|
||||
}
|
||||
|
||||
@ -11,6 +11,39 @@ import { whatsappInboundLog } from "../loggers.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import type { GroupHistoryEntry } from "./process-message.js";
|
||||
|
||||
function buildBroadcastRouteKeys(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
peerId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const sessionKey = buildAgentSessionKey({
|
||||
agentId: params.agentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "direct",
|
||||
id: params.peerId,
|
||||
},
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
});
|
||||
const mainSessionKey = buildAgentMainSessionKey({
|
||||
agentId: params.agentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
mainSessionKey,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey,
|
||||
mainSessionKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function maybeBroadcastMessage(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
@ -52,41 +85,17 @@ export async function maybeBroadcastMessage(params: {
|
||||
whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`);
|
||||
return false;
|
||||
}
|
||||
const routeKeys = buildBroadcastRouteKeys({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
route: params.route,
|
||||
peerId: params.peerId,
|
||||
agentId: normalizedAgentId,
|
||||
});
|
||||
const agentRoute = {
|
||||
...params.route,
|
||||
agentId: normalizedAgentId,
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "direct",
|
||||
id: params.peerId,
|
||||
},
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "direct",
|
||||
id: params.peerId,
|
||||
},
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
}),
|
||||
...routeKeys,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user