Compare commits

..

5 Commits

Author SHA1 Message Date
joshavant
e357c82ac4 Move cron caller identity into gateway context 2026-06-26 02:56:43 -05:00
joshavant
ed53735bc5 Preserve unscoped cron update retargeting 2026-06-25 22:02:48 -05:00
joshavant
1f60f84b33 Address cron scope review feedback 2026-06-25 21:47:08 -05:00
joshavant
6ff3a87305 Scope OpenClaw tools MCP cron by session 2026-06-25 21:01:44 -05:00
joshavant
71a6ee8a66 Scope agent cron operations to caller 2026-06-25 20:24:11 -05:00
211 changed files with 3251 additions and 12999 deletions

View File

@@ -2419,8 +2419,7 @@ jobs:
- macos-swift
- ios-build
- android
# Re-enable this job when we want to collect CI timing data for timing optimization.
if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:

View File

@@ -2,12 +2,6 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt.
## 2026.6.10
### Highlights

View File

@@ -1,2 +1,2 @@
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl

View File

@@ -24,31 +24,17 @@ OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install @owner/<slug>
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
openclaw skills verify @owner/<slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins update <id-or-npm-spec>
```
Skill installs target the active workspace `skills/` directory by default. Add
`--global` to install into the shared managed skills directory.
OpenClaw checks the selected community ClawHub skill or plugin trust state
before downloading it. Versioned community skill and plugin releases use
exact-release trust metadata; resolver-backed GitHub skills rely on ClawHub's
install resolver to enforce scan and force-install policy before it returns a
pinned commit. Malicious or blocked community releases are refused. Risky
community releases require review and `--acknowledge-clawhub-risk` when a
non-interactive command should continue after that review.
Official ClawHub publishers/packages and bundled OpenClaw sources bypass this
release-trust prompt and security-verdict fetch during install and update.
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
instead of npm or another install source.

View File

@@ -40,7 +40,6 @@ openclaw doctor
openclaw doctor --lint
openclaw doctor --lint --json
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --all
openclaw doctor --lint --allow-exec
openclaw doctor --deep
openclaw doctor --fix
@@ -74,7 +73,6 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--all`: with `--lint`, run all registered checks, including opt-in checks excluded from the default automation set
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
@@ -84,14 +82,13 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
It uses the structured health-check path, does not prompt, and does not repair
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
when you want machine-readable findings instead of guided repair prompts.
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and `--skip`
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
are only accepted with `--lint`.
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --allow-exec
openclaw doctor --lint --only core/doctor/gateway-config --json
```
@@ -133,13 +130,6 @@ Exit behavior:
example, `openclaw doctor --lint --severity-min error` can print no findings and
exit `0` even when lower-severity `info` or `warning` findings exist.
`--all` controls which checks are selected before severity filtering. The
default lint run is the stable automation gate and excludes checks that are
intentionally opt-in because they are deep, historical, or more likely to
surface repairable legacy residue. Use `--all` when you want the complete lint
inventory without listing each check id. `--only <id>` remains the most precise
selector and can run any registered check by id.
## Structured Health Checks
Modern doctor checks use a small structured contract:
@@ -196,7 +186,6 @@ Use `--only` and `--skip` when a workflow wants a focused gate:
```bash
openclaw doctor --lint --only core/doctor/gateway-config --json
openclaw doctor --lint --skip core/doctor/skills-readiness
openclaw doctor --lint --all --skip core/doctor/session-locks
```
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`

View File

@@ -111,7 +111,6 @@ openclaw plugins install git:github.com/<owner>/<repo> # git repo
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
openclaw plugins install <plugin>@<marketplace> # marketplace
@@ -164,12 +163,6 @@ is available, then fall back to `latest`.
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk">
Community ClawHub installs check the selected release trust record before downloading the package. If ClawHub disables download for the release, reports malicious scan findings, or puts the release in a blocking moderation state such as quarantine, OpenClaw refuses the release. For non-blocking risky scan statuses, risky moderation states, or registry reasons, OpenClaw shows the trust details and asks for confirmation before continuing.
Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
</Accordion>
<Accordion title="Hook packs and npm specs">
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
@@ -397,7 +390,6 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call
openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
```
@@ -429,9 +421,6 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
<Accordion title="--dangerously-force-unsafe-install on update">
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk on update">
Community ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
</Accordion>
</AccordionGroup>
### Inspect

View File

@@ -31,11 +31,9 @@ openclaw skills install git:owner/repo
openclaw skills install git:owner/repo@main
openclaw skills install ./path/to/skill --as custom-name
openclaw skills install @owner/<slug> --force
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
openclaw skills install @owner/<slug> --agent <id>
openclaw skills install @owner/<slug> --global
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
@@ -99,14 +97,6 @@ Notes:
- `install --version <version>` applies only to ClawHub skill refs.
- `install --force` overwrites an existing workspace skill folder for the same
slug.
- Community ClawHub skill installs and updates check trust before downloading.
Versioned community archive releases use exact-release trust metadata.
Resolver-backed GitHub skills rely on ClawHub's install resolver to enforce
scan and force-install policy before it returns a pinned commit. Malicious or
blocked community releases are refused. Risky community releases require
review and `--acknowledge-clawhub-risk` when a non-interactive command should
continue after that review. Official ClawHub skill publishers and bundled
OpenClaw skill sources bypass this release-trust prompt.
- `--global` targets the shared managed skills directory and cannot be combined
with `--agent <id>`.
- `--agent <id>` targets one configured agent workspace and overrides current

View File

@@ -28,7 +28,6 @@ openclaw update --tag main
openclaw update --dry-run
openclaw update --no-restart
openclaw update --yes
openclaw update --acknowledge-clawhub-risk
openclaw update --json
openclaw --update
```
@@ -46,11 +45,6 @@ openclaw --update
when npm plugin artifact drift is detected during post-update plugin sync.
- `--timeout <seconds>`: per-step timeout (default is 1800s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
warnings, allow post-update plugin sync to continue without an interactive
prompt. Without this, risky community ClawHub plugin releases are skipped and
left unchanged when OpenClaw cannot prompt. Official ClawHub packages and
bundled OpenClaw plugin sources bypass this release-trust prompt.
`openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview
the planned channel/tag/install/restart actions, `--json` for machine-readable
@@ -94,7 +88,6 @@ converge.
```bash
openclaw update repair
openclaw update repair --channel beta
openclaw update repair --acknowledge-clawhub-risk
openclaw update repair --json
```
@@ -105,10 +98,6 @@ Options:
- `--json`: print machine-readable finalization JSON.
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
- `--yes`: skip confirmation prompts.
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
warnings, allow repair-time plugin convergence to continue without an
interactive prompt. Official ClawHub packages and bundled OpenClaw plugin
sources bypass this release-trust prompt.
- `--no-restart`: accepted for update command parity; repair never restarts the
Gateway.

View File

@@ -104,7 +104,6 @@ Examples:
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --only core/doctor/gateway-config --json
```
@@ -112,7 +111,7 @@ JSON output includes:
- `ok`: whether any visible finding met the selected severity threshold
- `checksRun`: number of health checks executed
- `checksSkipped`: checks skipped by the selected profile, `--only`, or `--skip`
- `checksSkipped`: checks skipped by `--only` or `--skip`
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
@@ -123,13 +122,11 @@ Exit codes:
- `2`: command/runtime failure before lint findings could be emitted
Use `--severity-min info|warning|error` to control both what is printed and what
causes a non-zero lint exit. Use `--all` to run the complete lint inventory,
including deeper opt-in checks excluded from the default automation set. Use `--only <id>` for narrow preflight gates and
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
lint run active.
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and
`--skip` must be paired with `--lint`; regular doctor and repair runs reject
them.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
must be paired with `--lint`; regular doctor and repair runs reject them.
## What it does (summary)

View File

@@ -155,13 +155,9 @@ shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native Codex
plugins are configured, OpenClaw uses the connected app-server's plugin control
plane to install or refresh those plugins and then refreshes app inventory so
plugin-owned apps are visible to the Codex thread. `app/list` is still the
authoritative inventory and metadata source, but OpenClaw policy decides whether
`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible
app even if Codex currently marks it disabled. Unknown or missing app ids remain
fail-closed; this path only activates marketplace plugins via `plugin/install`
and refreshes inventory. Only connect OpenClaw to remote app-servers that are
trusted to accept OpenClaw-managed plugin installs and app inventory refreshes.
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
## Approval and sandbox modes

View File

@@ -465,13 +465,7 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the
remote app-server's own account.
When native Codex plugins are configured, OpenClaw installs or refreshes those
plugins through the connected app-server before exposing plugin-owned apps to
the Codex thread. `app/list` remains the source of truth for app ids,
accessibility, and metadata, but OpenClaw owns the per-thread enablement
decision: if policy allows a listed accessible app, OpenClaw sends
`thread/start.config.apps[appId].enabled = true` even when `app/list` currently
reports that app disabled. This path does not invent app installation for
unknown ids; OpenClaw only activates marketplace plugins with `plugin/install`
and then refreshes inventory.
the Codex thread.
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
time when Codex reports one and tries the next ordered auth profile for the same

View File

@@ -192,6 +192,109 @@ describe("AcpxRuntime fresh reset wrapper", () => {
);
});
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const readScopedMcpEnv = (sessionKey: string) => {
const delegate = (
runtime as unknown as {
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
}
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
options: {
mcpServers?: Array<{
env?: Array<{ name: string; value: string }>;
name: string;
}>;
};
};
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
};
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
value: "agent:worker:main",
});
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
value: "agent:research:main",
});
});
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const exposedRuntime = runtime as unknown as {
openclawToolsSessionDelegates: Map<string, unknown>;
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
};
const firstDelegate =
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
firstDelegate,
);
});
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
const safeProbe = vi
.spyOn(bridgeSafeDelegate, "probeAvailability")
.mockResolvedValue(undefined);
await runtime.probeAvailability();
expect(safeProbe).toHaveBeenCalledTimes(1);
expect(defaultProbe).not.toHaveBeenCalled();
});
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
@@ -1163,6 +1266,46 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(baseStore["load"]).toHaveBeenCalledOnce();
});
it("releases managed OpenClaw tools MCP delegates after close", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const exposedRuntime = runtime as unknown as {
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
close: AcpRuntime["close"];
};
};
const scopedDelegate =
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
await runtime.close({
handle: {
sessionKey: "agent:codex:main",
backend: "acpx",
runtimeSessionName: "agent:codex:main",
},
reason: "closed",
});
expect(close).toHaveBeenCalledOnce();
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
});
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => ({

View File

@@ -50,6 +50,7 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
openclawWrapperRoot?: string;
openclawGatewayInstanceId?: string;
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
openclawToolsMcpBridgeEnabled?: boolean;
};
type AcpxRuntimeTestOptions = Record<string, unknown> & {
openclawProcessCleanup?: AcpxProcessCleanupDeps;
@@ -57,6 +58,10 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
type ResetAwareSessionStore = AcpSessionStore & {
markFresh: (sessionKey: string) => void;
@@ -682,6 +687,33 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
return Array.isArray(mcpServers) && mcpServers.length > 0;
}
function withOpenClawToolsMcpSessionEnv(params: {
enabled: boolean | undefined;
mcpServers: AcpRuntimeOptions["mcpServers"];
sessionKey: string;
}): AcpRuntimeOptions["mcpServers"] {
const sessionKey = params.sessionKey.trim();
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
return params.mcpServers;
}
let changed = false;
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
return server;
}
changed = true;
const env = [
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
{
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
value: sessionKey,
},
];
return { ...server, env };
});
return changed ? nextServers : params.mcpServers;
}
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
export class AcpxRuntime implements AcpRuntime {
private readonly sessionStore: ResetAwareSessionStore;
@@ -693,6 +725,10 @@ export class AcpxRuntime implements AcpRuntime {
private readonly delegate: BaseAcpxRuntime;
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
private readonly probeDelegate: BaseAcpxRuntime;
private readonly delegateOptions: AcpRuntimeOptions;
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
private readonly openclawToolsMcpBridgeEnabled: boolean;
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
private readonly wrapperRoot: string | undefined;
private readonly gatewayInstanceId: string | undefined;
@@ -706,6 +742,7 @@ export class AcpxRuntime implements AcpRuntime {
this.wrapperRoot = options.openclawWrapperRoot;
this.gatewayInstanceId = options.openclawGatewayInstanceId;
this.processLeaseStore = options.openclawProcessLeaseStore;
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
this.cwd = options.cwd;
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
gatewayInstanceId: this.gatewayInstanceId,
@@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime {
sessionStore: this.sessionStore,
agentRegistry: this.scopedAgentRegistry,
};
this.delegate = new BaseAcpxRuntime(
sharedOptions,
delegateTestOptions as BaseAcpxRuntimeTestOptions,
);
this.delegateOptions = sharedOptions;
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
? new BaseAcpxRuntime(
{
...sharedOptions,
mcpServers: [],
},
delegateTestOptions as BaseAcpxRuntimeTestOptions,
this.delegateTestOptions,
)
: this.delegate;
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
? this.bridgeSafeDelegate
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
}
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
@@ -751,6 +789,57 @@ export class AcpxRuntime implements AcpRuntime {
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
}
private resolveDelegateForSession(params: {
command: string | undefined;
sessionKey: string;
}): BaseAcpxRuntime {
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
return this.bridgeSafeDelegate;
}
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
}
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
if (!this.openclawToolsMcpBridgeEnabled) {
return this.delegate;
}
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return this.delegate;
}
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
if (cached) {
return cached;
}
// Upstream acpx captures mcpServers at runtime construction. The managed
// OpenClaw tools bridge needs per-session identity, so cache one delegate
// per session with the scoped MCP env already embedded.
const delegate = new BaseAcpxRuntime(
{
...this.delegateOptions,
mcpServers: withOpenClawToolsMcpSessionEnv({
enabled: this.openclawToolsMcpBridgeEnabled,
mcpServers: this.delegateOptions.mcpServers,
sessionKey: normalizedSessionKey,
}),
},
this.delegateTestOptions,
);
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
return delegate;
}
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
if (!this.openclawToolsMcpBridgeEnabled) {
return;
}
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return;
}
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
}
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
return this.resolveDelegateForLoadedRecord(handle, record);
@@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime {
): BaseAcpxRuntime {
const recordCommand = readAgentCommandFromRecord(record);
if (recordCommand) {
return this.resolveDelegateForCommand(recordCommand);
return this.resolveDelegateForSession({
command: recordCommand,
sessionKey: handle.sessionKey,
});
}
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
const agentName = readAgentFromHandle(handle);
const command = resolveAgentCommandForName({
agentName,
agentRegistry: this.agentRegistry,
});
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
}
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
@@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime {
agentName: input.agent,
agentRegistry: this.agentRegistry,
});
const delegate = this.resolveDelegateForCommand(command);
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
const claudeModelOverride = isClaudeAcpCommand(command)
? normalizeClaudeAcpModelOverride(input.model)
: undefined;
@@ -1264,6 +1361,9 @@ export class AcpxRuntime implements AcpRuntime {
}
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
// Fresh reset has no ACP handle to close the delegate's upstream client.
// Keep the scoped delegate reachable so the next ensure can replace it;
// close() owns cache release when the session lifecycle ends.
this.sessionStore.markFresh(input.sessionKey);
}
@@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime {
input.handle.acpxRecordId ?? input.handle.sessionKey,
);
let closeSucceeded;
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
try {
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
await delegate.close({
handle: input.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState,
@@ -1282,6 +1383,9 @@ export class AcpxRuntime implements AcpRuntime {
} finally {
await this.cleanupProcessTreeForRecord(input.handle, record);
}
if (closeSucceeded) {
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
}
if (closeSucceeded && input.discardPersistentState) {
this.sessionStore.markFresh(input.handle.sessionKey);
}

View File

@@ -111,6 +111,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),

View File

@@ -254,7 +254,7 @@ describe("Codex plugin thread config", () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null };
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
}
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
@@ -317,117 +317,6 @@ describe("Codex plugin thread config", () => {
]);
});
it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true, false)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.inventory?.records[0]?.apps).toStrictEqual([
{
id: "google-calendar-app",
name: "google-calendar-app",
accessible: true,
enabled: false,
needsAuth: false,
},
]);
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(config.diagnostics).toStrictEqual([]);
});
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
const appCache = new CodexAppInventoryCache();
const appListParams: v2.AppsListParams[] = [];
let pluginListCalls = 0;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
pluginListCalls += 1;
const active = pluginListCalls > 1;
return pluginList([
pluginSummary("google-calendar", { installed: active, enabled: active }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return {
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
} satisfies v2.AppsListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request,
});
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install");
expect(appListParams).toEqual([
{
cursor: undefined,
limit: 100,
forceRefetch: true,
},
]);
});
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -486,59 +375,11 @@ describe("Codex plugin thread config", () => {
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible for google-calendar.",
message: "google-calendar-app is not accessible or enabled for google-calendar.",
},
]);
});
it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([]);
});
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -731,7 +572,9 @@ describe("Codex plugin thread config", () => {
let installed = false;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
return pluginList([
pluginSummary("google-calendar", { installed, enabled: installed }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -895,70 +738,6 @@ describe("Codex plugin thread config", () => {
]);
});
it("fails closed when app inventory entries are malformed", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () =>
({
data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[],
nextCursor: null,
}) satisfies v2.AppsListResponse,
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "app_not_ready",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
const appCache = new CodexAppInventoryCache();
const first = buildCodexPluginThreadConfigInputFingerprint({

View File

@@ -125,9 +125,6 @@ export async function buildCodexPluginThreadConfig(
nowMs: params.nowMs,
suppressAppInventoryRefresh: true,
});
const appInventoryRefreshDeferredForActivation =
inventory.records.some((record) => record.activationRequired) &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
@@ -169,19 +166,10 @@ export async function buildCodexPluginThreadConfig(
});
}
}
const postInstallRefreshRequired = activationResults.some(
(activation) => activation.ok && activation.installAttempted,
);
// Activation can become unnecessary or fail before it refreshes apps. Rebuild the
// deferred missing snapshot so unrelated active plugin apps are not silently erased.
const deferredMissingRefreshRequired =
appInventoryRefreshDeferredForActivation &&
!postInstallRefreshRequired &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (postInstallRefreshRequired || deferredMissingRefreshRequired) {
if (activationResults.some((activation) => activation.ok && activation.installAttempted)) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
reason: postInstallRefreshRequired ? "post_install" : "deferred_missing",
reason: "post_install",
targetAppIds: collectInventoryOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
@@ -231,22 +219,24 @@ export async function buildCodexPluginThreadConfig(
const policyApps: Record<string, PluginAppPolicyContextEntry> = {};
const pluginAppIds: Record<string, string[]> = {};
for (const record of inventory.records) {
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (activation?.ok === false || (record.activationRequired && !activation?.ok)) {
continue;
if (record.activationRequired) {
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (!activation?.ok) {
continue;
}
}
if (record.appOwnership !== "proven") {
continue;
}
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
if (!isPluginAppReadyForThreadStart(app)) {
if (!app.accessible || !app.enabled) {
diagnostics.push({
code: "app_not_ready",
plugin: record.policy,
message: `${app.id} is not accessible for ${record.policy.pluginName}.`,
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
});
continue;
}
@@ -372,18 +362,9 @@ function shouldWaitForInitialAppInventory(
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
// Install/enable first so the initial app/list can observe newly activated plugin apps.
if (inventory.records.some((record) => record.activationRequired)) {
return false;
}
return shouldRefreshMissingAppInventory(params, policy, inventory);
}
function shouldRefreshMissingAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
return Boolean(
params.appCacheKey &&
policy.pluginPolicies.some((plugin) => plugin.enabled) &&
@@ -438,13 +419,6 @@ function resolveThreadConfigAppsForRecord(params: {
return params.record.apps;
}
function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean {
// `app/list` is the source of truth for inventory and access posture, but
// OpenClaw owns the per-thread enablement decision. A listed app that is
// accessible can be re-enabled for this thread via `config.apps[app.id]`.
return app.accessible;
}
function shouldForceRefreshForNotReadyPluginApps(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
@@ -460,7 +434,7 @@ function shouldForceRefreshForNotReadyPluginApps(
(record) =>
record.appOwnership === "proven" &&
record.ownedAppIds.length > 0 &&
(record.apps.length === 0 || record.apps.some((app) => !app.accessible)),
(record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)),
);
}

View File

@@ -4416,131 +4416,6 @@ describe("runCodexAppServerAttempt", () => {
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("sends a thread/start app enable override when app/list cached the app as disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
const pluginConfig = {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
},
},
};
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: readCodexPluginConfig(pluginConfig),
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
runtimeIdentity: getMockRuntimeIdentity(),
}),
request: async () => ({
data: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: false,
pluginDisplayNames: [],
},
],
nextCursor: null,
}),
});
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
if (method === "plugin/list") {
return {
marketplaces: [
{
name: "openai-curated",
path: "/marketplaces/openai-curated",
interface: null,
plugins: [
{
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
return {
plugin: {
marketplaceName: "openai-curated",
marketplacePath: "/marketplaces/openai-curated",
summary: {
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
description: null,
skills: [],
apps: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
installUrl: null,
needsAuth: false,
},
],
mcpServers: ["google-calendar"],
},
};
}
if (method === "app/list") {
throw new Error("app/list should use the cached inventory entry");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
const run = runCodexAppServerAttempt(params, { pluginConfig });
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const threadStart = requests.find((entry) => entry.method === "thread/start");
const threadStartParams = threadStart?.params as
| { config?: { apps?: Record<string, { enabled?: boolean }> } }
| undefined;
expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(true);
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("keys plugin app inventory by inherited API key fallback credentials", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -31,21 +31,15 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
};
});
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
}));
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
@@ -69,13 +63,6 @@ function requireFirstMockObjectArg(mock: ReturnType<typeof vi.fn>, label: string
return value;
}
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("deepinfra image generation provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
@@ -99,9 +86,11 @@ describe("deepinfra image generation provider", () => {
const release = vi.fn(async () => {});
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
}),
response: {
json: async () => ({
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
}),
},
release,
});
@@ -179,15 +168,17 @@ describe("deepinfra image generation provider", () => {
it("sends image edits as multipart OpenAI-compatible requests", async () => {
postMultipartRequestMock.mockResolvedValue({
response: jsonResponse({
data: [
{
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
"base64",
),
},
],
}),
response: {
json: async () => ({
data: [
{
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
"base64",
),
},
],
}),
},
release: vi.fn(async () => {}),
});

View File

@@ -12,7 +12,6 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import {
assertOkOrThrowHttpError,
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
@@ -646,9 +645,7 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "fal image generation failed");
const payload = parseFalImageGenerationResponse(
await readProviderJsonResponse(response, "fal.image-generation"),
);
const payload = parseFalImageGenerationResponse(await response.json());
const images: GeneratedImageAsset[] = [];
let imageIndex = 0;
for (const entry of payload.images) {

View File

@@ -29,30 +29,21 @@ function expectExplicitDefaultAccountSelection(
expect(account.appId).toBe(appId);
}
function setTestEnvValue(key: string, value: string | undefined): () => void {
function withEnvVar(key: string, value: string | undefined, run: () => void) {
const prev = process.env[key];
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
delete process.env[key];
} else {
Reflect.set(process.env, key, value);
process.env[key] = value;
}
return () => restoreTestEnvValue(key, prev);
}
function restoreTestEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
Reflect.set(process.env, key, value);
}
}
function withEnvVar(key: string, value: string | undefined, run: () => void): void {
const restore = setTestEnvValue(key, value);
try {
run();
} finally {
restore();
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
}
@@ -223,7 +214,8 @@ describe("resolveFeishuCredentials", () => {
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_TEST";
const restore = setTestEnvValue(key, " secret_from_env ");
const prev = process.env[key];
process.env[key] = " secret_from_env ";
try {
const creds = resolveFeishuCredentials(
@@ -242,13 +234,18 @@ describe("resolveFeishuCredentials", () => {
domain: "feishu",
});
} finally {
restore();
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
});
it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
const restore = setTestEnvValue(key, " secret_from_env_alias ");
const prev = process.env[key];
process.env[key] = " secret_from_env_alias ";
try {
const creds = resolveFeishuCredentials(
@@ -261,7 +258,11 @@ describe("resolveFeishuCredentials", () => {
expect(creds?.appSecret).toBe("secret_from_env_alias");
} finally {
restore();
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
});

View File

@@ -83,14 +83,6 @@ let FEISHU_USER_AGENT: string;
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
let priorFeishuTimeoutEnv: string | undefined;
function setFeishuTestEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
Reflect.set(process.env, key, value);
}
}
vi.mock("./channel.js", () => ({
feishuPlugin: feishuPluginMock,
}));
@@ -221,10 +213,10 @@ beforeAll(async () => {
beforeEach(() => {
priorProxyEnv = {};
priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, undefined);
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
for (const key of proxyEnvKeys) {
priorProxyEnv[key] = process.env[key];
setFeishuTestEnvValue(key, undefined);
delete process.env[key];
}
vi.clearAllMocks();
clearClientCache();
@@ -246,9 +238,18 @@ beforeEach(() => {
afterEach(() => {
for (const key of proxyEnvKeys) {
setFeishuTestEnvValue(key, priorProxyEnv[key]);
const value = priorProxyEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (priorFeishuTimeoutEnv === undefined) {
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
} else {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
}
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, priorFeishuTimeoutEnv);
setFeishuClientRuntimeForTest();
});
@@ -358,7 +359,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("uses env timeout override when provided and no direct timeout is set", async () => {
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
createFeishuClient({
appId: "app_8",
@@ -372,7 +373,7 @@ describe("createFeishuClient HTTP timeout", () => {
it("ignores non-decimal env timeout overrides", async () => {
for (const value of ["0x10", "1e3", "10.5"]) {
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, value);
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = value;
createFeishuClient({
appId: `app-${value}`,
@@ -386,7 +387,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("prefers direct timeout over env override", async () => {
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
createFeishuClient({
appId: "app_10",
@@ -400,10 +401,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("clamps env timeout override to max bound", async () => {
setFeishuTestEnvValue(
FEISHU_HTTP_TIMEOUT_ENV_VAR,
String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456),
);
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456);
createFeishuClient({
appId: "app_9",
@@ -507,7 +505,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("creates a ws proxy agent when lowercase https_proxy is set", async () => {
setFeishuTestEnvValue("https_proxy", "http://lower-https:8001");
process.env.https_proxy = "http://lower-https:8001";
await createFeishuWSClient(baseAccount);
@@ -517,7 +515,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => {
setFeishuTestEnvValue("HTTPS_PROXY", "http://upper-https:8002");
process.env.HTTPS_PROXY = "http://upper-https:8002";
await createFeishuWSClient(baseAccount);
@@ -527,7 +525,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("falls back to HTTP_PROXY for ws proxy agent creation", async () => {
setFeishuTestEnvValue("HTTP_PROXY", "http://upper-http:8999");
process.env.HTTP_PROXY = "http://upper-http:8999";
await createFeishuWSClient(baseAccount);

View File

@@ -855,7 +855,7 @@ describe("google-meet plugin", () => {
});
it("registers the node-host command used by chrome-node transport", () => {
const { nodeHostCommands, nodeInvokePolicies } = setup();
const { nodeHostCommands } = setup();
const command = nodeHostCommands.find(
(entry): entry is Record<string, unknown> =>
@@ -865,13 +865,7 @@ describe("google-meet plugin", () => {
throw new Error("expected googlemeet.chrome node host command");
}
expect(command.cap).toBe("google-meet");
expect(command.dangerous).toBe(true);
expect(typeof command.handle).toBe("function");
expect(nodeInvokePolicies).toHaveLength(1);
expect(nodeInvokePolicies[0]).toMatchObject({
commands: ["googlemeet.chrome"],
dangerous: true,
});
});
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
@@ -2245,9 +2239,6 @@ describe("google-meet plugin", () => {
try {
const { methods, runCommandWithTimeout } = setup({
defaultMode: "transcribe",
chrome: {
browserProfile: "meet-devtools",
},
});
const callGatewayFromCli = mockLocalMeetBrowserRequest({
inCall: true,
@@ -3437,12 +3428,7 @@ describe("google-meet plugin", () => {
},
);
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
const { tools, nodesInvoke } = setup({
defaultTransport: "chrome",
chrome: {
browserProfile: "meet-devtools",
},
});
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
const tool = tools[0] as {
execute: (
id: string,
@@ -3472,7 +3458,6 @@ describe("google-meet plugin", () => {
expect(focusCall[0]).toBe("browser.request");
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
expect(focusCall[3]).toEqual({ progress: false });
expect(nodesInvoke).not.toHaveBeenCalled();
});

View File

@@ -35,10 +35,6 @@ import {
fetchGoogleMeetSpace,
} from "./src/meet.js";
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./src/node-invoke-policy.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
@@ -1200,12 +1196,10 @@ export default definePluginEntry({
);
api.registerNodeHostCommand({
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
command: "googlemeet.chrome",
cap: "google-meet",
dangerous: true,
handle: handleGoogleMeetNodeHostCommand,
});
api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config));
api.registerCli(
async ({ program }) => {

View File

@@ -91,41 +91,6 @@ describe("google-meet node host bridge sessions", () => {
}
});
it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => {
const originalPlatform = process.platform;
children.length = 0;
vi.mocked(spawnSync).mockClear();
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
try {
const start = JSON.parse(
await handleGoogleMeetNodeHostCommand(
JSON.stringify({
action: "start",
url: "https://meet.google.com/xyz-abcd-uvw",
mode: "transcribe",
browserProfile: "Profile 2",
}),
),
);
expect(start.launched).toBe(true);
expect(spawnSync).toHaveBeenCalledWith(
"open",
[
"-a",
"Google Chrome",
"https://meet.google.com/xyz-abcd-uvw",
"--args",
"--profile-directory=Profile 2",
],
expect.objectContaining({ encoding: "utf8" }),
);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
it("clears output playback without closing the active bridge when the old output exits", async () => {
const originalPlatform = process.platform;
children.length = 0;

View File

@@ -332,11 +332,12 @@ function startChrome(params: Record<string, unknown>) {
}
if (params.launch !== false) {
const argv = ["open", "-a", "Google Chrome", url];
const argv = ["open", "-a", "Google Chrome"];
const browserProfile = readString(params.browserProfile);
if (browserProfile) {
argv.push("--args", `--profile-directory=${browserProfile}`);
}
argv.push(url);
const result = runCommandWithTimeout(argv, timeoutMs);
if (result.code !== 0) {
if (bridgeId) {

View File

@@ -1,134 +0,0 @@
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { resolveGoogleMeetConfig } from "./config.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./node-invoke-policy.js";
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
ok: true,
payload: { ok: true },
}));
const ctx: OpenClawPluginNodeInvokePolicyContext = {
nodeId: "node-1",
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
params,
config: {} as never,
pluginConfig,
invokeNode,
};
return { ctx, invokeNode };
}
describe("Google Meet node invoke policy", () => {
it("rewrites start executable fields from trusted config", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: true,
browserProfile: "Attacker Profile",
joinTimeoutMs: 1,
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioInputCommand: ["malicious-capture"],
audioOutputCommand: ["malicious-play"],
});
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: {
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
});
});
it("uses trusted configured external bridge commands for start", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
const call = invokeNode.mock.calls[0]?.[0];
expect(call?.params).toMatchObject({
action: "start",
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
});
});
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://example.com/private",
mode: "bidi",
});
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
message: "url must be an explicit https://meet.google.com/... URL",
});
expect(invokeNode).not.toHaveBeenCalled();
});
it("keeps direct setup diagnostics but strips extra fields", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "setup",
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
});
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
});
expect(invokeNode).not.toHaveBeenCalled();
});
});

View File

@@ -1,192 +0,0 @@
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
} from "openclaw/plugin-sdk/plugin-entry";
import type { GoogleMeetConfig } from "./config.js";
import { normalizeMeetUrl } from "./runtime.js";
export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome";
const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]);
type PolicyDecision =
| { approved: true; params: Record<string, unknown> }
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function copyCommand(command: string[] | undefined): string[] | undefined {
return command && command.length > 0 ? [...command] : undefined;
}
function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") {
return { ok: false as const, code, message };
}
function approved(params: Record<string, unknown>): PolicyDecision {
return { approved: true, params };
}
function buildStartParams(
params: Record<string, unknown>,
config: GoogleMeetConfig,
): PolicyDecision {
let url: string;
try {
url = normalizeMeetUrl(params.url);
} catch (error) {
return {
approved: false,
result: denied(
error instanceof Error ? error.message : "googlemeet.chrome start requires url",
),
};
}
const mode = readString(params.mode);
if (mode && !START_MODES.has(mode)) {
return {
approved: false,
result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`),
};
}
const startParams: Record<string, unknown> = {
action: "start",
url,
launch: params.launch === false ? false : config.chrome.launch,
browserProfile: config.chrome.browserProfile,
joinTimeoutMs: config.chrome.joinTimeoutMs,
};
if (mode) {
startParams.mode = mode;
}
const audioInputCommand = copyCommand(config.chrome.audioInputCommand);
if (audioInputCommand) {
startParams.audioInputCommand = audioInputCommand;
}
const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand);
if (audioOutputCommand) {
startParams.audioOutputCommand = audioOutputCommand;
}
const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand);
if (audioBridgeCommand) {
startParams.audioBridgeCommand = audioBridgeCommand;
}
const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand);
if (audioBridgeHealthCommand) {
startParams.audioBridgeHealthCommand = audioBridgeHealthCommand;
}
return approved(startParams);
}
function buildForwardParams(params: Record<string, unknown>): Record<string, unknown> | null {
const action = readString(params.action);
switch (action) {
case "setup":
return { action };
case "status": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
case "list": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
return forwarded;
}
case "stopByUrl": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
const exceptBridgeId = readString(params.exceptBridgeId);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
if (exceptBridgeId) {
forwarded.exceptBridgeId = exceptBridgeId;
}
return forwarded;
}
case "pullAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const timeoutMs = readPositiveNumber(params.timeoutMs);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (timeoutMs) {
forwarded.timeoutMs = timeoutMs;
}
return forwarded;
}
case "pushAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const base64 = readString(params.base64);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (base64) {
forwarded.base64 = base64;
}
return forwarded;
}
case "clearAudio":
case "stop": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
default:
return null;
}
}
export function createGoogleMeetChromeNodeInvokePolicy(
config: GoogleMeetConfig,
): OpenClawPluginNodeInvokePolicy {
return {
commands: [GOOGLE_MEET_CHROME_NODE_COMMAND],
dangerous: true,
async handle(ctx: OpenClawPluginNodeInvokePolicyContext) {
if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) {
return denied(`unsupported Google Meet node command: ${ctx.command}`);
}
const params = asRecord(ctx.params);
const action = readString(params.action);
let decision: PolicyDecision;
if (action === "start") {
decision = buildStartParams(params, config);
} else {
const forwardParams = buildForwardParams(params);
decision = forwardParams
? approved(forwardParams)
: { approved: false, result: denied("unsupported googlemeet.chrome action") };
}
if (!decision.approved) {
return decision.result;
}
return await ctx.invokeNode({ params: decision.params });
},
};
}

View File

@@ -69,7 +69,6 @@ export function setupGoogleMeetPlugin(
const tools: unknown[] = [];
const cliRegistrations: unknown[] = [];
const nodeHostCommands: unknown[] = [];
const nodeInvokePolicies: unknown[] = [];
const nodesList = vi.fn(
async () =>
options.nodesListResult ?? {
@@ -166,7 +165,6 @@ export function setupGoogleMeetPlugin(
},
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy),
});
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
@@ -186,7 +184,6 @@ export function setupGoogleMeetPlugin(
nodesList,
nodesInvoke,
nodeHostCommands,
nodeInvokePolicies,
};
}

View File

@@ -8,13 +8,6 @@ import { testing as geminiWebSearchTesting } from "./src/gemini-web-search-provi
let ssrfMock: { mockRestore: () => void } | undefined;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGoogleApiKeyAuth() {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
@@ -31,8 +24,9 @@ function installGoogleFetchMock(params?: {
const mimeType = params?.mimeType ?? "image/png";
const data = params?.data ?? "png-data";
const inlineDataKey = params?.inlineDataKey ?? "inlineData";
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
@@ -48,7 +42,7 @@ function installGoogleFetchMock(params?: {
},
],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
@@ -106,8 +100,9 @@ describe("Google image-generation provider", () => {
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
@@ -124,7 +119,7 @@ describe("Google image-generation provider", () => {
},
],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
@@ -213,7 +208,10 @@ describe("Google image-generation provider", () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ candidates: { content: { parts: [] } } })),
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ candidates: { content: { parts: [] } } }),
}),
);
const provider = buildGoogleImageGenerationProvider();
@@ -231,8 +229,9 @@ describe("Google image-generation provider", () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
@@ -241,7 +240,7 @@ describe("Google image-generation provider", () => {
},
],
}),
),
}),
);
const provider = buildGoogleImageGenerationProvider();
@@ -261,8 +260,9 @@ describe("Google image-generation provider", () => {
source: "profile",
mode: "token",
});
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
@@ -278,7 +278,7 @@ describe("Google image-generation provider", () => {
},
],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
@@ -305,74 +305,6 @@ describe("Google image-generation provider", () => {
});
});
it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => {
mockGoogleApiKeyAuth();
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
const imagePayload = imageBytes.toString("base64");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
parts: Array.from({ length: 3 }, () => ({
inlineData: {
mimeType: "image/png",
data: imagePayload,
},
})),
},
},
],
}),
),
);
const provider = buildGoogleImageGenerationProvider();
const result = await provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
});
expect(result.images).toHaveLength(3);
expect(result.images.map((image) => image.buffer.byteLength)).toEqual([
imageBytes.byteLength,
imageBytes.byteLength,
imageBytes.byteLength,
]);
});
it("still rejects oversized Google image JSON responses", async () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
parts: [{ text: "x".repeat(35 * 1024 * 1024) }],
},
},
],
}),
),
);
const provider = buildGoogleImageGenerationProvider();
await expect(
provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("google.image-generation: JSON response exceeds");
});
it("sends reference images and explicit resolution for edit flows", async () => {
mockGoogleApiKeyAuth();
const fetchMock = installGoogleFetchMock();

View File

@@ -1,18 +1,15 @@
// Google provider module implements model/runtime integration.
import {
generatedImageAssetFromBase64,
resolveInlineImageJsonResponseMaxBytes,
type GeneratedImageAsset,
type ImageGenerationProvider,
} from "openclaw/plugin-sdk/image-generation";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -25,8 +22,6 @@ import { normalizeGoogleModelId, resolveGoogleGenerativeAiHttpRequestConfig } fr
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
const DEFAULT_OUTPUT_MIME = "image/png";
const GOOGLE_MAX_IMAGE_RESULTS = 4;
const MB = 1024 * 1024;
const GOOGLE_SUPPORTED_SIZES = [
"1024x1024",
"1024x1536",
@@ -54,16 +49,6 @@ function normalizeGoogleImageModel(model: string | undefined): string {
return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL);
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function mapSizeToImageConfig(
size: string | undefined,
): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined {
@@ -164,14 +149,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
}),
capabilities: {
generate: {
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
maxCount: 4,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
},
edit: {
enabled: true,
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
maxCount: 4,
maxInputImages: 5,
supportsSize: true,
supportsAspectRatio: true,
@@ -246,12 +231,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(res, "Google image generation failed");
const payload = await readProviderJsonResponse(res, "google.image-generation", {
maxBytes: resolveInlineImageJsonResponseMaxBytes(
GOOGLE_MAX_IMAGE_RESULTS,
resolveGeneratedImageMaxBytes(req),
),
});
const payload = await res.json();
let imageIndex = 0;
const images: GeneratedImageAsset[] = [];
for (const part of googleResponseParts(payload)) {

View File

@@ -33,21 +33,15 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
@@ -55,18 +49,13 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGeneratedPngResponse() {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
response: {
json: async () => ({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
},
release: vi.fn(async () => {}),
});
}

View File

@@ -24,4 +24,4 @@ export {
listMemoryFiles,
normalizeExtraMemoryPaths,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
export { getMemorySearchManager } from "openclaw/plugin-sdk/memory-core-engine-runtime";
export { getMemorySearchManager } from "./memory/index.js";

View File

@@ -2342,7 +2342,7 @@ describe("memory cli", () => {
lastRecalledAt: "<now>",
queryHashes: ["<hash>"],
recallDays: ["<today>"],
conceptTags: ["backup", "backups", "glacier", "s3"],
conceptTags: ["backup", "backups", "glacier"],
});
expect(close).toHaveBeenCalled();
});

View File

@@ -35,19 +35,6 @@ const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for(
"openclaw.memoryCore.dreamingNarrative.sessionLocks",
);
const EXPECTS_POSIX_PRIVATE_FILE_MODE = process.platform !== "win32";
const originalNarrativeStateDir = process.env.OPENCLAW_STATE_DIR;
function setNarrativeTestEnv(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreNarrativeTestEnv(): void {
if (originalNarrativeStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalNarrativeStateDir);
}
}
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
@@ -102,7 +89,7 @@ async function expectPathMissing(targetPath: string): Promise<void> {
afterEach(() => {
vi.restoreAllMocks();
restoreNarrativeTestEnv();
vi.unstubAllEnvs();
resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY).clear();
resolveGlobalMap<string, unknown>(NARRATIVE_SESSION_LOCKS_KEY).clear();
});
@@ -1241,7 +1228,7 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
setNarrativeTestEnv(stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
@@ -1310,7 +1297,7 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
setNarrativeTestEnv(stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("A forgotten endpoint hummed in the dark.");

View File

@@ -11,7 +11,7 @@ import {
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
testing,
filterRecallEntriesWithinLookback,
@@ -30,8 +30,6 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
const DREAMING_TEST_BASE_TIME = new Date("2026-04-05T10:00:00.000Z");
const DREAMING_TEST_DAY = "2026-04-05";
const originalDreamingTestFast = process.env.OPENCLAW_TEST_FAST;
const originalDreamingStateDir = process.env.OPENCLAW_STATE_DIR;
const EMPTY_SESSION_CONTENT_HASH =
"75a11da44c802486bc6f65640aa48a730f0f684c5c07a42ba3cd1735eb3fb070";
const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
@@ -61,28 +59,6 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
},
};
function setDreamingTestEnv(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_TEST_FAST", "1");
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreDreamingTestEnv(): void {
if (originalDreamingTestFast === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_TEST_FAST");
} else {
Reflect.set(process.env, "OPENCLAW_TEST_FAST", originalDreamingTestFast);
}
if (originalDreamingStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalDreamingStateDir);
}
}
afterEach(() => {
restoreDreamingTestEnv();
});
function requireCandidateByKey<T extends { key: string }>(candidates: T[], key: string): T {
const candidate = candidates.find((entry) => entry.key === key);
if (!candidate) {
@@ -971,7 +947,8 @@ describe("memory-core dreaming phases", () => {
it("checkpoints session transcript ingestion and skips unchanged transcripts", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1045,7 +1022,7 @@ describe("memory-core dreaming phases", () => {
([target]) => typeof target === "string" && target === transcriptPath,
).length;
readSpy.mockRestore();
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
expect(transcriptReadCount).toBeLessThanOrEqual(1);
@@ -1074,7 +1051,8 @@ describe("memory-core dreaming phases", () => {
it("keeps primary session transcripts out of configured subagent workspaces", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagentWorkspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const mainSessionsDir = resolveSessionTranscriptsDirForAgent("main");
const subagentSessionsDir = resolveSessionTranscriptsDirForAgent("agi-ceo");
@@ -1144,7 +1122,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const mainCorpus = await fs.readFile(
@@ -1163,7 +1141,8 @@ describe("memory-core dreaming phases", () => {
it("redacts sensitive session content before writing session corpus", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1219,7 +1198,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const corpusPath = path.join(
@@ -1236,7 +1215,8 @@ describe("memory-core dreaming phases", () => {
it("skips dreaming-generated narrative transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1311,7 +1291,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
await expectPathMissing(
@@ -1328,7 +1308,8 @@ describe("memory-core dreaming phases", () => {
it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1406,7 +1387,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
await expectPathMissing(
@@ -1423,7 +1404,8 @@ describe("memory-core dreaming phases", () => {
it("skips isolated cron run transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "cron-run.jsonl");
@@ -1498,7 +1480,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
await expectPathMissing(
@@ -1514,7 +1496,8 @@ describe("memory-core dreaming phases", () => {
it("drops generated system wrapper text without suppressing paired assistant replies", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "ordinary-session.jsonl");
@@ -1597,7 +1580,7 @@ describe("memory-core dreaming phases", () => {
);
} finally {
vi.useRealTimers();
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const corpus = await fs.readFile(
@@ -1612,7 +1595,8 @@ describe("memory-core dreaming phases", () => {
it("drops archive, cron, and heartbeat chatter from fresh session corpus output", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
@@ -1745,7 +1729,7 @@ describe("memory-core dreaming phases", () => {
);
} finally {
vi.useRealTimers();
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const corpus = await fs.readFile(
@@ -1797,7 +1781,8 @@ describe("memory-core dreaming phases", () => {
it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1874,13 +1859,14 @@ describe("memory-core dreaming phases", () => {
readFileSpy.mockRestore();
} finally {
vi.restoreAllMocks();
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
});
it("dedupes reset/deleted session archives instead of double-ingesting", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1972,7 +1958,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 910);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const ranked = await rankShortTermPromotionCandidates({
@@ -2003,7 +1989,8 @@ describe("memory-core dreaming phases", () => {
it("skips reset/deleted archive artifacts without active transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const archivePath = path.join(
@@ -2061,7 +2048,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
await expectPathMissing(
@@ -2074,7 +2061,8 @@ describe("memory-core dreaming phases", () => {
it("buckets session snippets by per-message day rather than file mtime", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2139,7 +2127,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const corpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
@@ -2154,7 +2142,8 @@ describe("memory-core dreaming phases", () => {
it("drains >80 unseen transcript messages across multiple unchanged sweeps", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2211,7 +2200,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 7);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const corpusPath = path.join(
@@ -2233,7 +2222,8 @@ describe("memory-core dreaming phases", () => {
it("re-ingests rewritten session transcripts after truncate/reset", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2310,7 +2300,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 910);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const ranked = await rankShortTermPromotionCandidates({
@@ -2327,7 +2317,8 @@ describe("memory-core dreaming phases", () => {
it("ingests sessions when dreaming is enabled even if memorySearch is disabled", async () => {
const workspaceDir = await createDreamingWorkspace();
setDreamingTestEnv(path.join(workspaceDir, ".state"));
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2385,7 +2376,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
restoreDreamingTestEnv();
vi.unstubAllEnvs();
}
const ranked = await rankShortTermPromotionCandidates({

View File

@@ -43,7 +43,6 @@ let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
@@ -59,18 +58,6 @@ function createLocalWorkerExitError(): Error {
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", () => {
const embedText = (text: string) => {
const lower = text.toLowerCase();
@@ -289,7 +276,7 @@ describe("memory index", () => {
closeOpenClawStateDatabaseForTest();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
});
beforeEach(async () => {
@@ -311,7 +298,7 @@ describe("memory index", () => {
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-memory-index"));
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
@@ -501,7 +488,7 @@ describe("memory index", () => {
stateDirName: string;
}): Promise<MemoryIndexManager | null> {
forceNoProvider = true;
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, params.stateDirName));
const cfg = createCfg({
sources: ["memory", "sessions"],
sessionMemory: true,
@@ -586,7 +573,7 @@ describe("memory index", () => {
it("reindexes memory tables in place without deleting unrelated agent rows", async () => {
const stateDir = path.join(workspaceDir, "managed-memory-state");
setMemoryIndexStateDir(stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const agentDbPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
const agentDb = openOpenClawAgentDatabase({ agentId: "main" });
agentDb.db
@@ -1130,7 +1117,7 @@ describe("memory index", () => {
it("clears dirty after sessions-only identity reindex", async () => {
try {
setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-only-reindex"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1180,13 +1167,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
}
});
it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => {
try {
setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-missing-meta"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1236,13 +1223,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
}
});
it("keeps provider cutover vector search paused during targeted session sync", async () => {
try {
setMemoryIndexStateDir(path.join(workspaceDir, ".state-targeted-cutover"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl");
@@ -1300,13 +1287,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
}
});
it("preserves memory dirty events raised during session identity reindex", async () => {
try {
setMemoryIndexStateDir(path.join(workspaceDir, ".state-dirty-during-session"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1364,7 +1351,7 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
}
});
@@ -2243,7 +2230,7 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
}
});
@@ -2287,7 +2274,7 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
restoreMemoryIndexStateDir();
vi.unstubAllEnvs();
}
});
});

View File

@@ -56,32 +56,9 @@ type MemoryTranscriptUpdateSubscriber = (
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR;
const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH;
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
function setStartupStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function setStartupConfigPath(configPath: string): void {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", configPath);
}
function restoreStartupEnv(): void {
if (originalStartupStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalStartupStateDir);
}
if (originalStartupConfigPath === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH");
} else {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", originalStartupConfigPath);
}
}
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
protected readonly cfg = {} as OpenClawConfig;
protected readonly agentId = "main";
@@ -253,13 +230,13 @@ describe("session startup catch-up", () => {
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-startup-"));
setStartupStateDir(stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
});
afterEach(async () => {
vi.clearAllTimers();
vi.useRealTimers();
restoreStartupEnv();
vi.unstubAllEnvs();
clearRuntimeConfigSnapshot();
clearConfigCache();
await fs.rm(stateDir, { recursive: true, force: true });
@@ -481,7 +458,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
setStartupConfigPath(configPath);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -528,7 +505,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
setStartupConfigPath(configPath);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -576,7 +553,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
setStartupConfigPath(configPath);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -683,7 +660,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
setStartupConfigPath(configPath);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);

View File

@@ -13,23 +13,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { buildSessionEntryMock } = vi.hoisted(() => ({
buildSessionEntryMock: vi.fn(),
}));
const originalSyncYieldStateDir = process.env.OPENCLAW_STATE_DIR;
function setSyncYieldStateDir(): void {
Reflect.set(
process.env,
"OPENCLAW_STATE_DIR",
path.join(os.tmpdir(), "openclaw-session-sync-yield"),
);
}
function restoreSyncYieldStateDir(): void {
if (originalSyncYieldStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSyncYieldStateDir);
}
}
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
@@ -179,7 +162,7 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps {
describe("session sync responsiveness", () => {
beforeEach(() => {
setSyncYieldStateDir();
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(os.tmpdir(), "openclaw-session-sync-yield"));
buildSessionEntryMock.mockImplementation(async (absPath: string) => {
const name = path.basename(absPath);
return {
@@ -194,7 +177,7 @@ describe("session sync responsiveness", () => {
});
afterEach(() => {
restoreSyncYieldStateDir();
vi.unstubAllEnvs();
vi.clearAllMocks();
});

View File

@@ -18,19 +18,6 @@ const createEmbeddingProviderMock = vi.hoisted(() =>
providerUnavailableReason: "No embeddings provider available.",
})),
);
const originalFtsOnlyStateDir = process.env.OPENCLAW_STATE_DIR;
function setFtsOnlyStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreFtsOnlyStateDir(): void {
if (originalFtsOnlyStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalFtsOnlyStateDir);
}
}
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: createEmbeddingProviderMock,
@@ -57,7 +44,7 @@ describe("memory manager FTS-only reindex", () => {
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note.");
setFtsOnlyStateDir(path.join(workspaceDir, "state"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
});
@@ -67,7 +54,7 @@ describe("memory manager FTS-only reindex", () => {
manager = null;
}
await closeAllMemorySearchManagers();
restoreFtsOnlyStateDir();
vi.unstubAllEnvs();
});
afterAll(async () => {

View File

@@ -13,19 +13,6 @@ import type { MemoryIndexMeta } from "./manager-reindex-state.js";
type SessionDeltaState = { lastSize: number; pendingBytes: number; pendingMessages: number };
type SyncSessionParams = { needsFullReindex: boolean; targetSessionFiles?: string[] };
const originalReindexStateDir = process.env.OPENCLAW_STATE_DIR;
function setReindexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreReindexStateDir(): void {
if (originalReindexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalReindexStateDir);
}
}
type ReindexHarness = {
sync: (params: { reason?: string; force?: boolean }) => Promise<void>;
@@ -55,11 +42,11 @@ describe("memory manager reindex recovery", () => {
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
setReindexStateDir(path.join(fixtureRoot, "state"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, "state"));
});
afterEach(async () => {
restoreReindexStateDir();
vi.unstubAllEnvs();
vi.restoreAllMocks();
if (manager) {
await manager.close();

View File

@@ -16,19 +16,6 @@ const createEmbeddingProviderMock = vi.hoisted(() =>
providerUnavailableReason: "No embeddings provider available.",
})),
);
const originalSelfHealStateDir = process.env.OPENCLAW_STATE_DIR;
function setSelfHealStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreSelfHealStateDir(): void {
if (originalSelfHealStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSelfHealStateDir);
}
}
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: createEmbeddingProviderMock,
@@ -62,7 +49,7 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note.");
setSelfHealStateDir(path.join(workspaceDir, "state"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
});
@@ -72,7 +59,7 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () =>
manager = null;
}
await closeAllMemorySearchManagers();
restoreSelfHealStateDir();
vi.unstubAllEnvs();
});
afterAll(async () => {

View File

@@ -17,21 +17,18 @@ describe("memory manager sync failures", () => {
unhandled.push(reason);
};
process.on("unhandledRejection", handler);
try {
const syncSpy = vi
.fn()
.mockRejectedValueOnce(new Error("openai embeddings failed: 400 bad request"));
setTimeout(() => {
runDetachedMemorySync(syncSpy, "watch");
}, 1);
const syncSpy = vi
.fn()
.mockRejectedValueOnce(new Error("openai embeddings failed: 400 bad request"));
setTimeout(() => {
runDetachedMemorySync(syncSpy, "watch");
}, 1);
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
await syncSpy.mock.results[0]?.value?.catch(() => undefined);
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
await syncSpy.mock.results[0]?.value?.catch(() => undefined);
expect(unhandled).toHaveLength(0);
} finally {
process.off("unhandledRejection", handler);
}
process.off("unhandledRejection", handler);
expect(unhandled).toHaveLength(0);
});
});

View File

@@ -121,19 +121,6 @@ const {
const CHOKIDAR_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory");
const NATIVE_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory");
const originalWatcherStateDir = process.env.OPENCLAW_STATE_DIR;
function setWatcherStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreWatcherStateDir(): void {
if (originalWatcherStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalWatcherStateDir);
}
}
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async (importOriginal) => {
const actual =
@@ -207,7 +194,7 @@ describe("memory watcher config", () => {
}
await closeAllMemorySearchManagers();
clearRegistry();
restoreWatcherStateDir();
vi.unstubAllEnvs();
if (workspaceDir) {
await fs.rm(workspaceDir, { recursive: true, force: true });
workspaceDir = "";
@@ -217,7 +204,7 @@ describe("memory watcher config", () => {
async function setupWatcherWorkspace(seedFile: { name: string; contents: string }) {
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-watch-"));
setWatcherStateDir(path.join(workspaceDir, "state"));
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
extraDir = path.join(workspaceDir, "extra");
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.mkdir(extraDir, { recursive: true });

View File

@@ -76,19 +76,6 @@ import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host
import { QmdMemoryManager } from "./qmd-manager.js";
const spawnMock = mockedSpawn as unknown as Mock;
const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR;
function setQmdStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreQmdStateDir(): void {
if (originalQmdStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir);
}
}
describe("QmdMemoryManager slugified path resolution", () => {
let tmpRoot: string;
@@ -185,7 +172,7 @@ describe("QmdMemoryManager slugified path resolution", () => {
workspaceDir = path.join(tmpRoot, "workspace");
stateDir = path.join(tmpRoot, "state");
await fs.mkdir(workspaceDir, { recursive: true });
setQmdStateDir(stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
cfg = {
agents: {
@@ -210,7 +197,7 @@ describe("QmdMemoryManager slugified path resolution", () => {
);
openManagers.clear();
await fs.rm(tmpRoot, { recursive: true, force: true });
restoreQmdStateDir();
delete process.env.OPENCLAW_STATE_DIR;
});
it("maps slugified workspace qmd URIs back to the indexed filesystem path", async () => {

View File

@@ -199,17 +199,11 @@ vi.mock("openclaw/plugin-sdk/file-lock", async () => {
import { spawn as mockedSpawn } from "node:child_process";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import {
type MemorySearchRuntimeDebug,
requireNodeSqlite,
resolveMemoryBackendConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js";
import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js";
@@ -217,19 +211,6 @@ const spawnMock = mockedSpawn as unknown as Mock;
const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT;
const originalWindowsPath = process.env.Path;
const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR;
function setQmdStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreQmdStateDir(): void {
if (originalQmdStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir);
}
}
describe("QmdMemoryManager", () => {
let fixtureRoot: string;
@@ -276,14 +257,6 @@ describe("QmdMemoryManager", () => {
return mock.mock.calls.map((call: unknown[]) => String(call[0]));
}
function qmdCommandCalls(): string[][] {
return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
}
function countQmdCommand(predicate: (args: string[]) => boolean): number {
return qmdCommandCalls().filter(predicate).length;
}
function expectMockMessageContains(mock: Mock, text: string): void {
expect(mockMessages(mock).join("\n")).toContain(text);
}
@@ -304,387 +277,6 @@ describe("QmdMemoryManager", () => {
);
});
it("reuses persisted collection validation across transient cli managers", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0);
});
it("does not cache incomplete collection validation", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "permission denied", 1);
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.close();
spawnMock.mockClear();
spawnMock.mockImplementation(() => createMockChild());
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("runs collection validation when the runtime cache store is unavailable", async () => {
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
const manager = await createManager({ mode: "cli" });
await manager.manager.close();
} finally {
await configureMemoryCoreDreamingStateForTests();
}
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("reports collection validation debug only once per validation run", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined();
});
it("misses collection validation cache when managed collection config changes", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
await fs.mkdir(otherWorkspaceDir, { recursive: true });
const changedCfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
...(cfg.memory?.qmd ?? {}),
paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockClear();
const second = await createManager({ mode: "cli", cfg: changedCfg });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
});
it("bypasses validation cache for missing-collection search repair", async () => {
await configureMemoryCoreDreamingStateForTests();
const { manager } = await createManager();
spawnMock.mockClear();
let searchAttempts = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
searchAttempts += 1;
if (searchAttempts === 1) {
emitAndClose(child, "stderr", "collection workspace-main not found", 1);
} else {
emitAndClose(child, "stdout", "[]");
}
return child;
}
return createMockChild();
});
const debug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
expect(searchAttempts).toBe(2);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force");
});
it("reuses persisted qmd multi-collection support probe across managers", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
});
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
const debug: MemorySearchRuntimeDebug[] = [];
await second.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit");
expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2);
});
it("reports multi-collection probe debug only when the probe runs", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined();
});
it("keeps concurrent search debug isolated on a shared qmd manager", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
let firstSearchChild: MockChild | undefined;
let searchCalls = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
searchCalls += 1;
const child = createMockChild({ autoClose: false });
if (searchCalls === 1) {
firstSearchChild = child;
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
}
if (args[0] === "--version") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "qmd 1.0.0");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
const firstSearch = manager.search("memory fact", {
sessionKey: "agent:main:slack:dm:u123",
sources: ["memory"],
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await waitUntil(() => searchCalls === 1);
const secondSearch = manager.search("session fact", {
sessionKey: "agent:main:slack:dm:u123",
sources: ["sessions"],
onDebug: (entry) => {
secondDebug.push(entry);
},
});
await waitUntil(() => searchCalls === 2);
emitAndClose(requireValue(firstSearchChild, "first search child missing"), "stdout", "[]");
await Promise.all([firstSearch, secondSearch]);
expect(firstDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["memory"]);
expect(secondDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["sessions"]);
});
it("rewrites stale multi-collection probe cache when combined filters are rejected", async () => {
await configureMemoryCoreDreamingStateForTests();
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
await fs.mkdir(otherWorkspaceDir, { recursive: true });
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
{ path: otherWorkspaceDir, pattern: "**/*.md", name: "other" },
],
},
},
} as OpenClawConfig;
const isCombinedSearch = (args: string[]) =>
(args[0] === "search" || args[0] === "query") &&
args.filter((token) => token === "-c").length > 1;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--version") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "qmd 1.0.0");
return child;
}
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (isCombinedSearch(args)) {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "unknown flag: -c", 1);
return child;
}
if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
await first.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await first.manager.close();
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({
cacheState: "write",
supported: false,
});
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
const secondDebug: MemorySearchRuntimeDebug[] = [];
await second.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
expect(countQmdCommand(isCombinedSearch)).toBe(0);
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({
cacheState: "hit",
supported: false,
});
});
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.lstat(targetPath);
@@ -748,7 +340,7 @@ describe("QmdMemoryManager", () => {
// Only workspace must exist for configured collection paths; state paths are
// created lazily by manager code when needed.
await fs.mkdir(workspaceDir, { recursive: true });
setQmdStateDir(stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
// Keep the default Windows path unresolved for most tests so spawn mocks can
// match the logical package command. Tests that verify wrapper resolution
// install explicit shim fixtures inline.
@@ -795,7 +387,7 @@ describe("QmdMemoryManager", () => {
embedStartupJitterSpy?.mockRestore();
embedStartupJitterSpy = null;
vi.useRealTimers();
restoreQmdStateDir();
delete process.env.OPENCLAW_STATE_DIR;
if (originalPath === undefined) {
delete process.env.PATH;
} else {
@@ -814,7 +406,6 @@ describe("QmdMemoryManager", () => {
delete (globalThis as Record<PropertyKey, unknown>)[MCPORTER_STATE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[QMD_EMBED_QUEUE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[MEMORY_EMBEDDING_PROVIDERS_KEY];
resetMemoryCoreDreamingStateForTests();
});
it("debounces back-to-back sync calls", async () => {
@@ -6859,7 +6450,7 @@ describe("QmdMemoryManager", () => {
// directory instead of the real ~/.cache.
savedXdgCacheHome = process.env.XDG_CACHE_HOME;
const fakeCacheHome = path.join(tmpRoot, "fake-cache");
Reflect.set(process.env, "XDG_CACHE_HOME", fakeCacheHome);
process.env.XDG_CACHE_HOME = fakeCacheHome;
defaultModelsDir = path.join(fakeCacheHome, "qmd", "models");
await fs.mkdir(defaultModelsDir, { recursive: true });
@@ -6870,9 +6461,9 @@ describe("QmdMemoryManager", () => {
afterEach(() => {
if (savedXdgCacheHome === undefined) {
Reflect.deleteProperty(process.env, "XDG_CACHE_HOME");
delete process.env.XDG_CACHE_HOME;
} else {
Reflect.set(process.env, "XDG_CACHE_HOME", savedXdgCacheHome);
process.env.XDG_CACHE_HOME = savedXdgCacheHome;
}
});

View File

@@ -74,16 +74,6 @@ import {
type QmdSessionArtifactMapping,
} from "../qmd-session-artifacts.js";
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
import {
clearQmdMultiCollectionProbeCache,
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
} from "./qmd-runtime-cache.js";
import {
countChokidarWatchedEntries,
type MemoryWatchPressureWarningState,
@@ -334,19 +324,6 @@ type ManagedCollection = {
kind: "memory" | "custom" | "sessions";
};
type QmdCollectionValidationDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["collectionValidation"]
>;
type QmdMultiCollectionProbeDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["multiCollectionProbe"]
>;
type QmdSearchPlanDebug = NonNullable<NonNullable<MemorySearchRuntimeDebug["qmd"]>["searchPlan"]>;
type QmdSearchRuntimeDebugContext = {
collectionValidation?: QmdCollectionValidationDebug;
multiCollectionProbe?: QmdMultiCollectionProbeDebug;
searchPlan?: QmdSearchPlanDebug;
};
type QmdManagerMode = "full" | "status" | "cli";
type QmdManagerRuntimeConfig = {
workspaceDir: string;
@@ -464,7 +441,6 @@ export class QmdMemoryManager implements MemorySearchManager {
private mode: QmdManagerMode = "full";
private readonly closeSignal: Promise<void>;
private resolveCloseSignal!: () => void;
private qmdRuntimeIdentityPromise: Promise<string> | null = null;
private db: SqliteDatabase | null = null;
private lastUpdateAt: number | null = null;
private lastEmbedAt: number | null = null;
@@ -477,7 +453,6 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly sessionWarm = new Set<string>();
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
private multiCollectionFilterSupported: boolean | null = null;
private pendingCollectionValidationDebug: QmdCollectionValidationDebug | undefined;
private constructor(params: {
agentId: string;
@@ -637,171 +612,11 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private qmdRuntimeCacheSources(): string[] {
return [...this.sources].toSorted();
}
private qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] {
return this.qmd.collections.map((collection) => ({
name: collection.name,
kind: collection.kind,
path: collection.path,
pattern: collection.pattern,
}));
}
private buildQmdRuntimeEnvironmentHash(): string {
const relevantEnv = Object.fromEntries(
Object.keys(this.env)
.filter(
(key) =>
key === "PATH" ||
key === "HOME" ||
key === "LOCALAPPDATA" ||
key === "XDG_CONFIG_HOME" ||
key === "XDG_CACHE_HOME" ||
key === "QMD_CONFIG_DIR" ||
key.startsWith("QMD_"),
)
.toSorted()
.map((key) => [key, this.env[key] ?? ""]),
);
return crypto.createHash("sha256").update(JSON.stringify(relevantEnv)).digest("hex");
}
private async buildQmdCollectionValidationCacheContext(): Promise<QmdRuntimeCollectionValidationCacheContext> {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdVersion: await this.resolveQmdRuntimeIdentity(),
qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(),
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
collections: this.qmdRuntimeCacheCollections(),
sources: this.qmdRuntimeCacheSources(),
};
}
private async buildQmdMultiCollectionProbeCacheContext(): Promise<QmdRuntimeMultiCollectionProbeCacheContext> {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdVersion: await this.resolveQmdRuntimeIdentity(),
qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(),
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
sources: this.qmdRuntimeCacheSources(),
};
}
private resolveQmdRuntimeIdentity(): Promise<string> {
this.qmdRuntimeIdentityPromise ??= this.readQmdRuntimeIdentity();
return this.qmdRuntimeIdentityPromise;
}
private async readQmdRuntimeIdentity(): Promise<string> {
const commandIdentity = `command:${this.qmd.command}`;
try {
const result = await this.runQmd(["--version"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 2_000),
});
const versionText = `${result.stdout}\n${result.stderr}`.trim();
return versionText ? `${commandIdentity};version:${versionText}` : commandIdentity;
} catch {
return commandIdentity;
}
}
private recordSearchPlanDebug(params: {
debugContext: QmdSearchRuntimeDebugContext;
command: "query" | "search" | "vsearch";
collectionNames: string[];
collectionGroups: string[][];
}): void {
const sources = uniqueValues(
params.collectionNames
.map((collectionName) => this.collectionRoots.get(collectionName)?.kind)
.filter((source): source is MemorySource => Boolean(source)),
);
params.debugContext.searchPlan = {
command: params.command,
collectionCount: params.collectionNames.length,
groupCount: params.collectionGroups.length,
sources,
};
}
private beginQmdSearchRuntimeDebug(): QmdSearchRuntimeDebugContext {
const debugContext: QmdSearchRuntimeDebugContext = {};
if (this.pendingCollectionValidationDebug) {
debugContext.collectionValidation = this.pendingCollectionValidationDebug;
this.pendingCollectionValidationDebug = undefined;
}
return debugContext;
}
private consumeQmdRuntimeDebug(
debugContext: QmdSearchRuntimeDebugContext,
): MemorySearchRuntimeDebug["qmd"] | undefined {
const debug: NonNullable<MemorySearchRuntimeDebug["qmd"]> = {};
if (debugContext.collectionValidation) {
debug.collectionValidation = debugContext.collectionValidation;
}
if (debugContext.multiCollectionProbe) {
debug.multiCollectionProbe = debugContext.multiCollectionProbe;
}
if (debugContext.searchPlan) {
debug.searchPlan = debugContext.searchPlan;
}
return Object.keys(debug).length > 0 ? debug : undefined;
}
private async ensureCollectionPathsBestEffort(): Promise<void> {
for (const collection of this.qmd.collections) {
try {
await this.ensureCollectionPath(collection);
} catch (err) {
log.warn(
`qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`,
);
}
}
}
private async ensureCollections(options?: {
force?: boolean;
debugContext?: QmdSearchRuntimeDebugContext;
}): Promise<void> {
const startedAt = Date.now();
const cacheContext = await this.buildQmdCollectionValidationCacheContext();
if (!options?.force) {
const cached = await readQmdCollectionValidationCache(cacheContext);
if (cached.state === "hit") {
await this.ensureCollectionPathsBestEffort();
const debug: QmdCollectionValidationDebug = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: cached.value.validation.collectionCount,
listCalls: 0,
showCalls: 0,
};
if (options?.debugContext) {
options.debugContext.collectionValidation = debug;
} else {
this.pendingCollectionValidationDebug = debug;
}
return;
}
}
const stats = { listCalls: 0, showCalls: 0 };
let validationComplete = true;
private async ensureCollections(): Promise<void> {
// QMD collections are persisted inside the index database and must be created
// via the CLI. Prefer listing existing collections when supported, otherwise
// fall back to best-effort idempotent `qmd collection add`.
const existing = await this.listCollectionsBestEffort(stats);
const existing = await this.listCollectionsBestEffort();
await this.migrateLegacyUnscopedCollections(existing);
@@ -816,7 +631,6 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) {
const message = formatErrorMessage(err);
if (!this.isCollectionMissingError(message)) {
validationComplete = false;
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
}
}
@@ -847,36 +661,13 @@ export class QmdMemoryManager implements MemorySearchManager {
pattern: collection.pattern,
});
} else {
validationComplete = false;
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
}
continue;
}
validationComplete = false;
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
const wroteCache = validationComplete
? await writeQmdCollectionValidationCache(cacheContext)
: false;
const debug: QmdCollectionValidationDebug = {
cacheState: validationComplete
? options?.force
? "bypass-force"
: wroteCache
? "write"
: "error"
: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: this.qmd.collections.length,
listCalls: stats.listCalls,
showCalls: stats.showCalls,
};
if (options?.debugContext) {
options.debugContext.collectionValidation = debug;
} else {
this.pendingCollectionValidationDebug = debug;
}
}
private async tryRebindSameNameCollection(params: {
@@ -922,15 +713,9 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async listCollectionsBestEffort(stats?: {
listCalls: number;
showCalls: number;
}): Promise<Map<string, ListedCollection>> {
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
try {
if (stats) {
stats.listCalls += 1;
}
const result = await this.runQmd(["collection", "list", "--json"], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -952,9 +737,6 @@ export class QmdMemoryManager implements MemorySearchManager {
continue;
}
try {
if (stats) {
stats.showCalls += 1;
}
const showResult = await this.runQmd(["collection", "show", collection.name], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -1174,17 +956,14 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async tryRepairMissingCollectionSearch(
err: unknown,
debugContext: QmdSearchRuntimeDebugContext,
): Promise<boolean> {
private async tryRepairMissingCollectionSearch(err: unknown): Promise<boolean> {
if (!this.isMissingCollectionSearchError(err)) {
return false;
}
log.warn(
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
);
await this.ensureCollections({ force: true, debugContext });
await this.ensureCollections();
return true;
}
@@ -1539,7 +1318,6 @@ export class QmdMemoryManager implements MemorySearchManager {
if (searchSignal?.aborted) {
throw asAbortError(searchSignal);
}
const debugContext = this.beginQmdSearchRuntimeDebug();
const trimmed = query.trim();
if (!trimmed) {
return [];
@@ -1566,7 +1344,6 @@ export class QmdMemoryManager implements MemorySearchManager {
const runSearchAttempt = async (
allowMissingCollectionRepair: boolean,
): Promise<QmdQueryResult[]> => {
let attemptedCombinedCollectionFilter = false;
try {
if (mcporterEnabled) {
const minScore = opts?.minScore ?? 0;
@@ -1625,15 +1402,7 @@ export class QmdMemoryManager implements MemorySearchManager {
const collectionGroups = await this.resolveCollectionSearchGroups(
collectionNames,
searchSignal,
debugContext,
);
this.recordSearchPlanDebug({
debugContext,
command: qmdSearchCommand,
collectionNames,
collectionGroups,
});
attemptedCombinedCollectionFilter = collectionGroups.some((group) => group.length > 1);
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1655,9 +1424,6 @@ export class QmdMemoryManager implements MemorySearchManager {
qmdSearchCommand !== "query" &&
this.isUnsupportedQmdOptionError(err)
) {
if (attemptedCombinedCollectionFilter) {
await this.markQmdMultiCollectionFiltersUnsupported(debugContext);
}
effectiveSearchMode = "query";
searchFallbackReason = "unsupported-search-flags";
log.warn(
@@ -1667,14 +1433,7 @@ export class QmdMemoryManager implements MemorySearchManager {
const collectionGroups = await this.resolveCollectionSearchGroups(
collectionNames,
searchSignal,
debugContext,
);
this.recordSearchPlanDebug({
debugContext,
command: "query",
collectionNames,
collectionGroups,
});
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1704,7 +1463,7 @@ export class QmdMemoryManager implements MemorySearchManager {
try {
parsed = await runSearchAttempt(true);
} catch (err) {
if (!(await this.tryRepairMissingCollectionSearch(err, debugContext))) {
if (!(await this.tryRepairMissingCollectionSearch(err))) {
throw err instanceof Error ? err : new Error(String(err));
}
parsed = await runSearchAttempt(false);
@@ -1753,7 +1512,6 @@ export class QmdMemoryManager implements MemorySearchManager {
configuredMode: qmdSearchCommand,
effectiveMode: effectiveSearchMode,
fallback: searchFallbackReason,
qmd: this.consumeQmdRuntimeDebug(debugContext),
});
let ranked = results;
if (opts?.sources?.length) {
@@ -3612,41 +3370,23 @@ export class QmdMemoryManager implements MemorySearchManager {
private async resolveCollectionSearchGroups(
collectionNames: string[],
signal?: AbortSignal,
debugContext?: QmdSearchRuntimeDebugContext,
): Promise<string[][]> {
if (collectionNames.length <= 1) {
return [collectionNames];
}
if (!(await this.supportsQmdMultiCollectionFilters(signal, debugContext))) {
if (!(await this.supportsQmdMultiCollectionFilters(signal))) {
return collectionNames.map((collectionName) => [collectionName]);
}
return this.groupCollectionNamesBySource(collectionNames);
}
private async supportsQmdMultiCollectionFilters(
signal?: AbortSignal,
debugContext?: QmdSearchRuntimeDebugContext,
): Promise<boolean> {
private async supportsQmdMultiCollectionFilters(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) {
throw asAbortError(signal);
}
if (this.multiCollectionFilterSupported !== null) {
return this.multiCollectionFilterSupported;
}
const startedAt = Date.now();
const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext();
const cached = await readQmdMultiCollectionProbeCache(cacheContext);
if (cached.state === "hit") {
this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported;
if (debugContext) {
debugContext.multiCollectionProbe = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
}
return this.multiCollectionFilterSupported;
}
try {
const result = await this.runQmd(["--help"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
@@ -3655,50 +3395,17 @@ export class QmdMemoryManager implements MemorySearchManager {
const helpText = `${result.stdout}\n${result.stderr}`;
this.multiCollectionFilterSupported =
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
const wroteCache = await writeQmdMultiCollectionProbeCache(
cacheContext,
this.multiCollectionFilterSupported,
);
if (debugContext) {
debugContext.multiCollectionProbe = {
cacheState: wroteCache ? "write" : "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
}
} catch (err) {
// Cancellation says nothing about QMD capabilities; leave the probe uncached.
if (signal?.aborted) {
throw asAbortError(signal);
}
this.multiCollectionFilterSupported = false;
if (debugContext) {
debugContext.multiCollectionProbe = {
cacheState: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: false,
};
}
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
}
return this.multiCollectionFilterSupported;
}
private async markQmdMultiCollectionFiltersUnsupported(
debugContext: QmdSearchRuntimeDebugContext,
): Promise<void> {
const startedAt = Date.now();
const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext();
this.multiCollectionFilterSupported = false;
await clearQmdMultiCollectionProbeCache(cacheContext);
const wroteCache = await writeQmdMultiCollectionProbeCache(cacheContext, false);
debugContext.multiCollectionProbe = {
cacheState: wroteCache ? "write" : "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: false,
};
}
private async runQueryAcrossCollectionGroups(
query: string,
limit: number,

View File

@@ -1,317 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
openMemoryCoreStateStore,
memoryCoreWorkspaceEntryKey,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import {
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
buildQmdMultiCollectionProbeCacheContextHash,
clearQmdCollectionValidationCache,
clearQmdMultiCollectionProbeCache,
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
} from "./qmd-runtime-cache.js";
const tempRoots: string[] = [];
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
});
afterAll(async () => {
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) {
await fs.rm(root, { recursive: true, force: true });
}
}
resetMemoryCoreDreamingStateForTests();
});
async function clearStore(namespace: string): Promise<void> {
try {
await openMemoryCoreStateStore({
namespace,
maxEntries: 1_000,
}).clear();
} catch {
// fail open
}
}
afterEach(async () => {
await clearStore(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE);
await clearStore(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE);
});
function makeWorkspace(): Promise<string> {
const prefix = path.join(os.tmpdir(), `qmd-runtime-cache-${Date.now()}-`);
return fs.mkdtemp(prefix).then((workspaceDir) => {
tempRoots.push(workspaceDir);
return workspaceDir;
});
}
function managedCollections(): QmdRuntimeManagedCollection[] {
return [
{
name: "project-notes",
kind: "memory",
path: "/repo/project-notes",
pattern: "*.md",
},
{
name: "sessions",
kind: "sessions",
path: "/repo/sessions",
pattern: "*",
},
];
}
function collectionValidationContext(
workspaceDir: string,
): QmdRuntimeCollectionValidationCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
collections: managedCollections(),
sources: ["memory", "sessions"],
};
}
function multiCollectionProbeContext(
workspaceDir: string,
): QmdRuntimeMultiCollectionProbeCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
sources: ["memory", "sessions"],
};
}
describe("qmd-runtime-cache", () => {
it("writes and reads collection validation cache entries", async () => {
const workspaceDir = await makeWorkspace();
const context = collectionValidationContext(workspaceDir);
const writeStartedAtMs = 1_000;
const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdCollectionValidationCache(
{ ...context, sources: ["sessions", "memory"] },
writeStartedAtMs + 1,
);
expect(read).toMatchObject({
state: "hit",
value: {
validation: {
ok: true,
collectionCount: context.collections.length,
},
},
});
});
it("writes and reads multi-collection probe cache entries", async () => {
const workspaceDir = await makeWorkspace();
const context = multiCollectionProbeContext(workspaceDir);
const writeStartedAtMs = 2_000;
const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1);
expect(read).toMatchObject({
state: "hit",
value: {
multiCollectionProbe: {
supported: true,
},
},
});
});
it("scopes cache entries by workspace", async () => {
const firstWorkspace = await makeWorkspace();
const secondWorkspace = await makeWorkspace();
const context = collectionValidationContext(firstWorkspace);
expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true);
const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = {
...context,
workspaceDir: secondWorkspace,
qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"),
};
const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001);
expect(miss).toStrictEqual({ state: "miss" });
});
it("misses collection validation cache when managed collection paths change", async () => {
const workspaceDir = await makeWorkspace();
const context = collectionValidationContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true);
const changedContext: QmdRuntimeCollectionValidationCacheContext = {
...context,
collections: context.collections.map((collection) =>
collection.name === "project-notes"
? { ...collection, path: `${collection.path}-moved` }
: collection,
),
};
expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({
state: "miss",
});
});
it("misses validation and probe caches when qmd runtime environment changes", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = {
...collectionValidationContext(workspaceDir),
qmdEnvironmentHash: "env-a",
};
const probeContext = {
...multiCollectionProbeContext(workspaceDir),
qmdEnvironmentHash: "env-a",
};
expect(await writeQmdCollectionValidationCache(validationContext, 3_600)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 3_600)).toBe(true);
expect(
await readQmdCollectionValidationCache(
{ ...validationContext, qmdEnvironmentHash: "env-b" },
3_601,
),
).toStrictEqual({ state: "miss" });
expect(
await readQmdMultiCollectionProbeCache(
{ ...probeContext, qmdEnvironmentHash: "env-b" },
3_601,
),
).toStrictEqual({ state: "miss" });
});
it("treats cache misses for malformed values and expired entries", async () => {
const workspaceDir = await makeWorkspace();
const context = multiCollectionProbeContext(workspaceDir);
const nowMs = 4_000;
await writeQmdMultiCollectionProbeCache(context, false, nowMs);
const key = memoryCoreWorkspaceEntryKey(
workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(context)}`,
);
const store = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
await store.register(key, {
version: 1,
createdAtMs: "bad",
expiresAtMs: 0,
keyHash: "bad",
multiCollectionProbe: { supported: true },
});
const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1);
expect(malformed).toStrictEqual({ state: "miss" });
const expired = await readQmdMultiCollectionProbeCache(
context,
nowMs + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS + 1,
);
expect(expired).toStrictEqual({ state: "miss" });
});
it("uses separate namespaces for validation and probe entries", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true);
const validationStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: 1_000,
});
const probeStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
expect((await validationStore.entries()).length).toBeGreaterThan(0);
expect((await probeStore.entries()).length).toBeGreaterThan(0);
});
it("fails open when state store is unavailable", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false);
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false);
} finally {
await configureMemoryCoreDreamingStateForTests();
}
});
it("exposes bounded TTL windows", () => {
expect(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS).toBe(5 * 60_000);
expect(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS).toBe(10 * 60_000);
});
it("can clear cache keys explicitly", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true);
await clearQmdCollectionValidationCache(validationContext);
await clearQmdMultiCollectionProbeCache(probeContext);
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
});
});

View File

@@ -1,435 +0,0 @@
// Memory Core QMD runtime cache helpers.
import { createHash } from "node:crypto";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE =
"qmd-runtime-cache.collection-validation";
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
"qmd-runtime-cache.multi-collection-probe";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1;
export type QmdRuntimeManagedCollection = {
name: string;
kind: "memory" | "custom" | "sessions";
path: string;
pattern: string;
};
type QmdRuntimeCacheContextBase = {
workspaceDir: string;
agentId: string;
qmdCommand: string;
qmdVersion?: string;
qmdEnvironmentHash?: string;
qmdIndexPath: string;
searchMode: string;
};
export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & {
collections: readonly QmdRuntimeManagedCollection[];
sources: readonly string[];
};
export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & {
sources: readonly string[];
};
export type QmdRuntimeCacheCollectionValidationEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
validation: {
ok: true;
collectionConfigHash: string;
collectionCount: number;
};
};
export type QmdRuntimeCacheMultiCollectionProbeEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
multiCollectionProbe: {
supported: boolean;
};
};
export type QmdRuntimeCacheResult<T> =
| {
state: "hit";
value: T;
}
| { state: "miss" };
function normalizeText(value: string): string {
return value.trim();
}
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
return {
name: normalizeText(collection.name),
kind: collection.kind,
pathHash: normalizePathIdentity(collection.path),
pattern: normalizeText(collection.pattern),
};
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function normalizePathIdentity(value: string): string {
const normalized =
process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value);
return hashText(normalized);
}
function sortedUnique(values: readonly string[]): string[] {
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted();
}
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
const normalized = collections
.map((collection) => ({
...normalizeCollection(collection),
}))
.toSorted(
(left, right) =>
left.name.localeCompare(right.name) ||
left.kind.localeCompare(right.kind) ||
left.pathHash.localeCompare(right.pathHash) ||
left.pattern.localeCompare(right.pattern),
)
.map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`)
.join(";");
return hashText(normalized);
}
function buildCollectionValidationCacheContextInput(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
collectionConfigHash: buildCollectionConfigHash(params.collections),
});
}
function buildMultiCollectionProbeCacheContextInput(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
});
}
function buildCollectionValidationCacheHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return hashText(buildCollectionValidationCacheContextInput(params));
}
function buildMultiCollectionProbeCacheHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return hashText(buildMultiCollectionProbeCacheContextInput(params));
}
export function buildQmdCollectionValidationCacheContextHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return buildCollectionValidationCacheHash(params);
}
export function buildQmdMultiCollectionProbeCacheContextHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return buildMultiCollectionProbeCacheHash(params);
}
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
});
}
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
});
}
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.collection-validation:${buildCollectionValidationCacheHash(params)}`,
);
}
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildMultiCollectionProbeCacheHash(params)}`,
);
}
function normalizeCollectionValidationEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheCollectionValidationEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const validation = record.validation as unknown;
if (typeof validation !== "object" || validation === null) {
return undefined;
}
const validationRecord = validation as Record<string, unknown>;
if (validationRecord.ok !== true) {
return undefined;
}
if (typeof validationRecord.collectionConfigHash !== "string") {
return undefined;
}
if (typeof validationRecord.collectionCount !== "number") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
validation: {
ok: true,
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
},
};
}
function normalizeMultiCollectionProbeEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheMultiCollectionProbeEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const probe = record.multiCollectionProbe as unknown;
if (typeof probe !== "object" || probe === null) {
return undefined;
}
const probeRecord = probe as Record<string, unknown>;
if (typeof probeRecord.supported !== "boolean") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
multiCollectionProbe: {
supported: probeRecord.supported,
},
};
}
export async function readQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
try {
const store = collectionValidationStore();
const key = collectionValidationEntryKey(params);
const expectedKeyHash = buildCollectionValidationCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = collectionValidationEntryKey(params);
const keyHash = buildCollectionValidationCacheHash(params);
const collectionConfigHash = buildCollectionConfigHash(params.collections);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
const store = collectionValidationStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
validation: {
ok: true,
collectionConfigHash,
collectionCount: params.collections.length,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
): Promise<void> {
try {
const store = collectionValidationStore();
await store.delete(collectionValidationEntryKey(params));
} catch {
// fail open
}
}
export async function readQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
try {
const store = multiCollectionProbeStore();
const key = multiCollectionProbeEntryKey(params);
const expectedKeyHash = buildMultiCollectionProbeCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
supported: boolean,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = multiCollectionProbeEntryKey(params);
const keyHash = buildMultiCollectionProbeCacheHash(params);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
const store = multiCollectionProbeStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
multiCollectionProbe: {
supported,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): Promise<void> {
try {
const store = multiCollectionProbeStore();
await store.delete(multiCollectionProbeEntryKey(params));
} catch {
// fail open
}
}

View File

@@ -326,10 +326,6 @@ describe("getMemorySearchManager caching", () => {
expect(first.manager).toBe(second.manager);
expect(createQmdManagerMock.mock.calls).toHaveLength(1);
expect(first.debug?.managerCacheState).toBe("cached-full-miss");
expect(second.debug?.managerCacheState).toBe("cached-full-hit");
expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash);
});
it("keeps the cached QMD manager active when the caller cancels a search", async () => {
@@ -810,10 +806,6 @@ describe("getMemorySearchManager caching", () => {
const fullManager = requireManager(full);
const cliManager = requireManager(cli);
expect(cli.debug?.managerCacheState).toBe("transient-cli");
expect(full.debug?.managerCacheState).toBe("cached-full-miss");
expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash);
expect(cliManager).toBe(cliPrimary);
expect(cliManager).not.toBe(fullManager);
const fullCreateParams = qmdCreateParams();

View File

@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
// Memory Core plugin module implements search manager behavior.
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -49,24 +48,6 @@ type QmdManagerOpenFailure = {
retryAfterMs: number;
};
type MemorySearchManagerCacheState =
| "cached-full-hit"
| "cached-full-miss"
| "transient-cli"
| "transient-status"
| "pending-create-wait"
| "fallback-builtin"
| "recent-failure-cooldown";
export type MemorySearchManagerDebug = {
backend?: "builtin" | "qmd";
purpose?: MemorySearchManagerPurpose;
managerMs?: number;
managerCacheState?: MemorySearchManagerCacheState;
qmdIdentityHash?: string;
failureCode?: "qmd-unavailable";
};
type MemorySearchManagerCacheStore = {
qmdManagerCache: Map<string, CachedQmdManagerEntry>;
pendingQmdManagerCreates: Map<string, PendingQmdManagerCreate>;
@@ -128,7 +109,6 @@ function loadQmdManagerModule() {
export type MemorySearchManagerResult = {
manager: Maybe<MemorySearchManager>;
error?: string;
debug?: MemorySearchManagerDebug;
};
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
@@ -169,42 +149,11 @@ function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void
}
}
function hashQmdManagerIdentity(identityKey: string): string {
return createHash("sha256").update(identityKey).digest("hex");
}
function applyManagerDebug(
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult {
if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) {
return result;
}
return {
...result,
debug: {
...(result.debug ?? {}),
...debug,
},
};
}
export async function getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: MemorySearchManagerPurpose;
}): Promise<MemorySearchManagerResult> {
const acquireStartedAt = Date.now();
const purpose = params.purpose ?? "default";
const finish = (
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult =>
applyManagerDebug(result, {
purpose,
managerMs: Math.max(0, Date.now() - acquireStartedAt),
...debug,
});
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const qmdResolved = resolved.qmd;
@@ -214,7 +163,6 @@ export async function getMemorySearchManager(params: {
const transient = params.purpose === "status" || params.purpose === "cli";
const scopeKey = buildQmdManagerScopeKey(normalizedAgentId);
const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig);
const debugIdentityHash = hashQmdManagerIdentity(identityKey);
const createPrimaryQmdManager = async (
mode: "full" | "status" | "cli",
@@ -306,24 +254,10 @@ export async function getMemorySearchManager(params: {
// Status callers often close the manager they receive. Wrap the live
// full manager with a no-op close so health/status probes do not tear
// down the active QMD manager for the process.
return finish(
{ manager: new BorrowedMemoryManager(cached.manager) },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
return { manager: new BorrowedMemoryManager(cached.manager) };
}
if (params.purpose !== "cli") {
return finish(
{ manager: cached.manager },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
return { manager: cached.manager };
}
}
@@ -332,44 +266,20 @@ export async function getMemorySearchManager(params: {
params.purpose === "cli" ? "cli" : "status",
);
return manager
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
}
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
if (recentFailure) {
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
return finish(
await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason),
{
backend: "qmd",
managerCacheState: "recent-failure-cooldown",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
},
);
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
}
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
if (pending) {
await pending.promise;
return finish(await getMemorySearchManager(params), {
backend: "qmd",
managerCacheState: "pending-create-wait",
qmdIdentityHash: debugIdentityHash,
});
return await getMemorySearchManager(params);
}
let pendingFailureReason: string | undefined;
@@ -399,25 +309,11 @@ export async function getMemorySearchManager(params: {
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
const manager = await pendingCreate.promise;
return manager
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: "cached-full-miss",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
}
return finish(await getBuiltinMemorySearchManager(params), {
backend: "builtin",
});
return await getBuiltinMemorySearchManager(params);
}
async function getBuiltinMemorySearchManagerAfterQmdFailure(

View File

@@ -1,44 +0,0 @@
// Memory Core provider tests cover plugin runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { describe, expect, it, vi } from "vitest";
const managerDebug = {
backend: "qmd" as const,
purpose: "default" as const,
managerMs: 7,
managerCacheState: "cached-full-hit" as const,
qmdIdentityHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
};
const getMemorySearchManagerMock = vi.hoisted(() =>
vi.fn(async () => ({
manager: null,
debug: managerDebug,
error: undefined,
})),
);
vi.mock("./memory/index.js", () => ({
closeAllMemorySearchManagers: vi.fn(async () => {}),
closeMemorySearchManager: vi.fn(async () => {}),
getMemorySearchManager: getMemorySearchManagerMock,
}));
import { memoryRuntime } from "./runtime-provider.js";
describe("memoryRuntime", () => {
it("preserves manager debug metadata", async () => {
const cfg = {} as OpenClawConfig;
const result = await memoryRuntime.getMemorySearchManager({
cfg,
agentId: "main",
});
expect(result.debug).toEqual(managerDebug);
expect(getMemorySearchManagerMock).toHaveBeenCalledWith({
cfg,
agentId: "main",
});
});
});

View File

@@ -9,10 +9,9 @@ import {
export const memoryRuntime: MemoryPluginRuntime = {
async getMemorySearchManager(params) {
const { manager, debug, error } = await getMemorySearchManager(params);
const { manager, error } = await getMemorySearchManager(params);
return {
manager,
debug,
error,
};
},

View File

@@ -67,28 +67,18 @@ export async function getMemoryManagerContextWithPurpose(params: {
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
debug?: NonNullable<MemorySearchManagerResult["debug"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const startedAt = Date.now();
const { manager, debug, error } = await getMemorySearchManager({
const { manager, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager
? {
manager,
debug: {
...debug,
managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt),
},
}
: { error };
return manager ? { manager } : { error };
}
export function createMemoryTool(params: {

View File

@@ -1,5 +1,4 @@
// Memory Core tests cover tools plugin behavior.
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getMemoryCloseMockCalls,
@@ -382,95 +381,6 @@ describe("memory_search unavailable payloads", () => {
expect(searchCalls).toBe(2);
});
it("merges qmd runtime debug across zero-hit retry attempts", async () => {
setMemoryBackend("qmd");
let searchCalls = 0;
setMemorySearchImpl(async (opts) => {
searchCalls += 1;
if (searchCalls === 1) {
opts?.onDebug?.({
backend: "qmd",
configuredMode: "search",
effectiveMode: "search",
qmd: {
collectionValidation: {
cacheState: "hit",
elapsedMs: 2,
collectionCount: 2,
listCalls: 0,
showCalls: 0,
},
multiCollectionProbe: {
cacheState: "hit",
elapsedMs: 1,
supported: true,
},
},
});
return [];
}
opts?.onDebug?.({
backend: "qmd",
configuredMode: "search",
effectiveMode: "query",
fallback: "unsupported-search-flags",
qmd: {
searchPlan: {
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
},
},
});
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Thread-hidden codename: ORBIT-22.",
source: "memory" as const,
},
];
});
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { backend: "qmd", citations: "off" },
},
});
const result = await tool.execute("zero-hit-debug-retry", {
query: "hidden thread codename",
});
const details = result.details as {
debug?: {
effectiveMode?: string;
fallback?: string;
qmd?: MemorySearchRuntimeDebug["qmd"];
};
};
expect(searchCalls).toBe(2);
expect(details.debug?.effectiveMode).toBe("query");
expect(details.debug?.fallback).toBe("unsupported-search-flags");
expect(details.debug?.qmd?.collectionValidation).toMatchObject({
cacheState: "hit",
collectionCount: 2,
});
expect(details.debug?.qmd?.multiCollectionProbe).toMatchObject({
cacheState: "hit",
supported: true,
});
expect(details.debug?.qmd?.searchPlan).toEqual({
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
});
});
it("returns unavailable metadata when the index identity is paused", async () => {
let searchCalls = 0;
setMemorySearchImpl(async () => {
@@ -512,14 +422,6 @@ describe("memory_search unavailable payloads", () => {
configuredMode: opts.qmdSearchModeOverride ?? "query",
effectiveMode: "query",
fallback: "unsupported-search-flags",
qmd: {
searchPlan: {
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
},
},
});
return [
{
@@ -568,18 +470,6 @@ describe("memory_search unavailable payloads", () => {
fallback?: unknown;
hits?: unknown;
searchMs?: number;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
managerCacheState?: unknown;
qmd?: {
searchPlan?: {
command?: unknown;
collectionCount?: unknown;
groupCount?: unknown;
sources?: unknown;
};
};
};
};
expect(details.mode).toBe("query");
@@ -589,94 +479,6 @@ describe("memory_search unavailable payloads", () => {
expect(details.debug?.fallback).toBe("unsupported-search-flags");
expect(details.debug?.hits).toBe(1);
expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBeUndefined();
expect(details.debug?.qmd?.searchPlan).toEqual({
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
});
});
it("includes manager acquisition timing and cache-state debug payload", async () => {
setMemorySearchManagerImpl(
async () =>
({
manager: {
search: vi.fn(async () => {
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
];
}),
readFile: vi.fn(),
status: vi.fn(() => ({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/workspace",
dbPath: "/tmp/workspace/index.sqlite",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
},
debug: {
managerMs: 17,
managerCacheState: "cached-full-hit",
},
}) as any,
);
setMemorySearchImpl(async () => [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
]);
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { backend: "qmd" },
},
});
const result = await tool.execute("manager-debug", { query: "favorite food" });
const details = result.details as {
debug?: {
backend?: string;
managerMs?: number;
toolMs?: number;
outsideSearchMs?: number;
managerCacheState?: string;
hits?: number;
searchMs?: number;
};
};
expect(details.debug?.backend).toBe("qmd");
expect(details.debug?.managerMs).toBe(17);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBe("cached-full-hit");
});
});

View File

@@ -44,35 +44,12 @@ type MemorySearchToolResult =
| MemoryCorpusSearchResult;
type MemoryManagerContext = Awaited<ReturnType<typeof getMemoryManagerContextWithPurpose>>;
type ActiveMemoryManagerContext = Extract<MemoryManagerContext, { manager: unknown }>;
type QmdRuntimeDebug = NonNullable<MemorySearchRuntimeDebug["qmd"]>;
const MEMORY_SEARCH_TOOL_TIMEOUT_MS = 15_000;
const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000;
const memorySearchToolCooldowns = new Map<string, { until: number; error: string }>();
function mergeQmdRuntimeDebug(
entries: readonly MemorySearchRuntimeDebug[],
): MemorySearchRuntimeDebug["qmd"] | undefined {
const merged: QmdRuntimeDebug = {};
for (const entry of entries) {
const qmd = entry.qmd;
if (!qmd) {
continue;
}
if (!merged.collectionValidation && qmd.collectionValidation) {
merged.collectionValidation = qmd.collectionValidation;
}
if (qmd.multiCollectionProbe) {
merged.multiCollectionProbe = qmd.multiCollectionProbe;
}
if (qmd.searchPlan) {
merged.searchPlan = qmd.searchPlan;
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
function resolveMemorySearchToolCooldownKey(options: {
agentId?: string;
agentSessionKey?: string;
@@ -438,7 +415,6 @@ export function createMemorySearchTool(options: {
const outcome = await runMemorySearchToolWithDeadline({
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
run: async (deadlineSignal) => {
const toolStartedAt = Date.now();
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
@@ -495,20 +471,13 @@ export function createMemorySearchTool(options: {
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let managerMs: number | undefined;
let managerCacheState: string | undefined;
let searchDebug:
| {
backend: string;
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
searchMs: number;
managerCacheState?: string;
qmd?: MemorySearchRuntimeDebug["qmd"];
hits: number;
}
| undefined;
@@ -537,8 +506,6 @@ export function createMemorySearchTool(options: {
},
...(searchSources ? { sources: searchSources } : {}),
};
managerMs = memory.debug?.managerMs;
managerCacheState = memory.debug?.managerCacheState;
try {
rawResults = await activeMemory.manager.search(query, searchOptions);
} catch (error) {
@@ -555,8 +522,6 @@ export function createMemorySearchTool(options: {
if ("error" in refreshed) {
throw error;
}
managerMs = refreshed.debug?.managerMs;
managerCacheState = refreshed.debug?.managerCacheState;
activeMemory = refreshed;
rawResults = await activeMemory.manager.search(query, searchOptions);
}
@@ -615,9 +580,7 @@ export function createMemorySearchTool(options: {
model = status.model;
fallback = status.fallback;
const latestDebug = runtimeDebug.at(-1);
const qmdDebug = mergeQmdRuntimeDebug(runtimeDebug);
searchMode = latestDebug?.effectiveMode;
const searchMs = Math.max(0, Date.now() - searchStartedAt);
searchDebug = {
backend: status.backend,
configuredMode: latestDebug?.configuredMode,
@@ -626,10 +589,7 @@ export function createMemorySearchTool(options: {
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
: "n/a",
fallback: latestDebug?.fallback,
managerMs,
searchMs,
managerCacheState,
qmd: qmdDebug,
searchMs: Math.max(0, Date.now() - searchStartedAt),
hits: rawResults.length,
};
});
@@ -660,14 +620,6 @@ export function createMemorySearchTool(options: {
maxResults: effectiveMax,
balanceCorpora: requestedCorpus === "all",
});
if (searchDebug) {
const finalToolMs = Math.max(0, Date.now() - toolStartedAt);
searchDebug = {
...searchDebug,
toolMs: finalToolMs,
outsideSearchMs: Math.max(0, finalToolMs - searchDebug.searchMs),
};
}
return jsonResult({
results,
provider,

View File

@@ -49,21 +49,15 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));
vi.mock("./runtime.js", () => ({
prepareFoundryRuntimeAuth: prepareFoundryRuntimeAuthMock,
@@ -75,16 +69,12 @@ function buildConfig(
modelName?: string;
baseUrl?: string;
includeModel?: boolean;
mediaMaxMb?: number;
} = {},
): OpenClawConfig {
const baseUrl = params.baseUrl ?? "https://example.services.ai.azure.com/openai/v1";
const modelId = params.modelId ?? "image-deployment";
const modelName = params.modelName ?? "MAI-Image-2.5";
return {
...(params.mediaMaxMb !== undefined
? { agents: { defaults: { mediaMaxMb: params.mediaMaxMb } } }
: {}),
models: {
providers: {
[PROVIDER_ID]: {
@@ -237,64 +227,6 @@ describe("microsoft foundry image generation provider", () => {
expect(result.images[0]?.mimeType).toBe("image/png");
});
it("accepts a valid max-size MAI image JSON response", async () => {
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
postJsonRequestMock.mockResolvedValue(
releasedJson({
data: [{ b64_json: imageBytes.toString("base64") }],
}),
);
const provider = buildMicrosoftFoundryImageGenerationProvider();
const result = await provider.generateImage({
provider: PROVIDER_ID,
model: "image-deployment",
prompt: "draw it",
cfg: buildConfig(),
});
expect(result.images).toHaveLength(1);
expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength);
});
it("honors configured generated media caps above the default image limit", async () => {
const imageBytes = Buffer.alloc(7 * 1024 * 1024, 1);
postJsonRequestMock.mockResolvedValue(
releasedJson({
data: [{ b64_json: imageBytes.toString("base64") }],
}),
);
const provider = buildMicrosoftFoundryImageGenerationProvider();
const result = await provider.generateImage({
provider: PROVIDER_ID,
model: "image-deployment",
prompt: "draw it",
cfg: buildConfig({ mediaMaxMb: 8 }),
});
expect(result.images).toHaveLength(1);
expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength);
});
it("rejects oversized MAI image JSON responses", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
data: [{ b64_json: "x".repeat(10 * 1024 * 1024) }],
}),
);
const provider = buildMicrosoftFoundryImageGenerationProvider();
await expect(
provider.generateImage({
provider: PROVIDER_ID,
model: "image-deployment",
prompt: "draw it",
cfg: buildConfig(),
}),
).rejects.toThrow("microsoft-foundry.image-generation: JSON response exceeds");
});
it("uses AZURE_OPENAI_ENDPOINT when env API-key auth has no configured base URL", async () => {
vi.stubEnv("AZURE_OPENAI_ENDPOINT", "https://env.services.ai.azure.com");
postJsonRequestMock.mockResolvedValue(

View File

@@ -10,9 +10,7 @@ import type {
import {
imageSourceUploadFileName,
parseOpenAiCompatibleImageResponse,
resolveInlineImageJsonResponseMaxBytes,
} from "openclaw/plugin-sdk/image-generation";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -20,7 +18,6 @@ import {
createProviderOperationDeadline,
postJsonRequest,
postMultipartRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
resolveProviderOperationTimeoutMs,
sanitizeConfiguredModelProviderRequest,
@@ -43,9 +40,7 @@ const DEFAULT_IMAGE_SIZE = { width: 1024, height: 1024 };
const MAI_MIN_IMAGE_SIDE_PX = 768;
const MAI_MAX_IMAGE_PIXELS = 1_048_576;
const MAI_IMAGE_BASE_PATH = "/mai/v1";
const MAI_IMAGE_MAX_RESULTS = 1;
const MAI_IMAGE_OUTPUT_MIME = "image/png";
const MB = 1024 * 1024;
const MAI_IMAGE_UPLOAD_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]);
type ModelProviderConfig = NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>[string];
@@ -113,16 +108,6 @@ function resolveMaiImageSize(size: string | undefined): { width: number; height:
return { width, height };
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function assertSingleImageCount(count: number | undefined): void {
if (count === undefined || count === 1) {
return;
@@ -271,12 +256,12 @@ export function buildMicrosoftFoundryImageGenerationProvider(): ImageGenerationP
}),
capabilities: {
generate: {
maxCount: MAI_IMAGE_MAX_RESULTS,
maxCount: 1,
supportsSize: true,
},
edit: {
enabled: true,
maxCount: MAI_IMAGE_MAX_RESULTS,
maxCount: 1,
maxInputImages: 1,
supportsSize: false,
},
@@ -382,18 +367,8 @@ export function buildMicrosoftFoundryImageGenerationProvider(): ImageGenerationP
const { response, release } = await request;
try {
await assertOkOrThrowHttpError(response, `${label} failed`);
const payload = await readProviderJsonResponse(
response,
"microsoft-foundry.image-generation",
{
maxBytes: resolveInlineImageJsonResponseMaxBytes(
MAI_IMAGE_MAX_RESULTS,
resolveGeneratedImageMaxBytes(req),
),
},
);
return {
images: parseMaiImageResponse(payload, label),
images: parseMaiImageResponse(await response.json(), label),
model,
};
} finally {

View File

@@ -1,15 +1,11 @@
// Minimax provider module implements model/runtime integration.
import {
resolveInlineImageJsonResponseMaxBytes,
type ImageGenerationProvider,
} from "openclaw/plugin-sdk/image-generation";
import { canonicalizeBase64, MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
@@ -17,8 +13,6 @@ const DEFAULT_MINIMAX_IMAGE_BASE_URL = "https://api.minimax.io";
const CN_MINIMAX_IMAGE_BASE_URL = "https://api.minimaxi.com";
const DEFAULT_MODEL = "image-01";
const DEFAULT_OUTPUT_MIME = "image/png";
const MINIMAX_MAX_IMAGE_RESULTS = 9;
const MB = 1024 * 1024;
const MINIMAX_SUPPORTED_ASPECT_RATIOS = [
"1:1",
"16:9",
@@ -78,16 +72,6 @@ function resolveMinimaxImageBaseUrl(
return DEFAULT_MINIMAX_IMAGE_BASE_URL;
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider {
return {
id: providerId,
@@ -101,14 +85,14 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider
}),
capabilities: {
generate: {
maxCount: MINIMAX_MAX_IMAGE_RESULTS,
maxCount: 9,
supportsSize: false,
supportsAspectRatio: true,
supportsResolution: false,
},
edit: {
enabled: true,
maxCount: MINIMAX_MAX_IMAGE_RESULTS,
maxCount: 9,
maxInputImages: 1,
supportsSize: false,
supportsAspectRatio: true,
@@ -179,16 +163,7 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider
try {
await assertOkOrThrowHttpError(response, "MiniMax image generation failed");
const data = await readProviderJsonResponse<MinimaxImageApiResponse>(
response,
"minimax.image-generation",
{
maxBytes: resolveInlineImageJsonResponseMaxBytes(
MINIMAX_MAX_IMAGE_RESULTS,
resolveGeneratedImageMaxBytes(req),
),
},
);
const data = (await response.json()) as MinimaxImageApiResponse;
const baseResp = data.base_resp;
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {

View File

@@ -64,8 +64,6 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
// Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests.
readProviderJsonResponse: async (response: { json(): Promise<unknown> }) => response.json(),
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));

View File

@@ -8,13 +8,11 @@ import type {
} from "openclaw/plugin-sdk/image-generation";
import {
parseOpenAiCompatibleImageResponse,
resolveInlineImageJsonResponseMaxBytes,
toImageDataUrl,
} from "openclaw/plugin-sdk/image-generation";
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveClosestSize } from "openclaw/plugin-sdk/media-generation-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import {
ensureAuthProfileStore,
isProviderApiKeyConfigured,
@@ -26,7 +24,6 @@ import {
assertOkOrThrowHttpError,
postJsonRequest,
postMultipartRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
@@ -69,7 +66,6 @@ const MOCK_OPENAI_PROVIDER_ID = "mock-openai";
const OPENAI_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
const OPENAI_BACKGROUNDS = ["transparent", "opaque", "auto"] as const;
const OPENAI_QUALITIES = ["low", "medium", "high", "auto"] as const;
const MB = 1024 * 1024;
const OPENAI_IMAGE_MODELS = [
DEFAULT_OPENAI_IMAGE_MODEL,
OPENAI_TRANSPARENT_BACKGROUND_IMAGE_MODEL,
@@ -124,14 +120,6 @@ function resolveOpenAIImageCount(count: number | undefined): number {
return Math.max(1, Math.min(OPENAI_MAX_IMAGE_RESULTS, Math.trunc(count)));
}
function resolveGeneratedImageMaxBytes(cfg: OpenClawConfig): number {
const configured = cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function isPublicOpenAIImageBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) {
@@ -1024,12 +1012,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
isEdit ? "OpenAI image edit failed" : "OpenAI image generation failed",
);
const data = await readProviderJsonResponse(response, "openai.image-generation", {
maxBytes: resolveInlineImageJsonResponseMaxBytes(
count,
resolveGeneratedImageMaxBytes(req.cfg),
),
});
const data = await response.json();
const output = resolveOutputMime(req.outputFormat);
const images = parseOpenAiCompatibleImageResponse(data, {
defaultMimeType: output.mimeType,

View File

@@ -86,18 +86,6 @@ function mockOpenAIImageApiResponse(params: {
imageData: string;
revisedPrompt?: string;
}) {
const response = () =>
new Response(
JSON.stringify({
data: [
{
b64_json: Buffer.from(params.imageData).toString("base64"),
...(params.revisedPrompt ? { revised_prompt: params.revisedPrompt } : {}),
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const resolveApiKeySpy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "sk-test",
source: "env",
@@ -105,12 +93,32 @@ function mockOpenAIImageApiResponse(params: {
});
const postJsonRequestSpy = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({
finalUrl: params.finalUrl,
response: response(),
response: {
ok: true,
json: async () => ({
data: [
{
b64_json: Buffer.from(params.imageData).toString("base64"),
...(params.revisedPrompt ? { revised_prompt: params.revisedPrompt } : {}),
},
],
}),
} as Response,
release: vi.fn(async () => {}),
});
const postMultipartRequestSpy = vi.spyOn(providerHttp, "postMultipartRequest").mockResolvedValue({
finalUrl: params.finalUrl,
response: response(),
response: {
ok: true,
json: async () => ({
data: [
{
b64_json: Buffer.from(params.imageData).toString("base64"),
...(params.revisedPrompt ? { revised_prompt: params.revisedPrompt } : {}),
},
],
}),
} as Response,
release: vi.fn(async () => {}),
});
vi.spyOn(providerHttp, "assertOkOrThrowHttpError").mockResolvedValue(undefined);

View File

@@ -31,8 +31,6 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
// Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests.
readProviderJsonResponse: async (response: { json(): Promise<unknown> }) => response.json(),
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));

View File

@@ -7,16 +7,13 @@ import type {
import {
generatedImageAssetFromBase64,
generatedImageAssetFromDataUrl,
resolveInlineImageJsonResponseMaxBytes,
toImageDataUrl,
} from "openclaw/plugin-sdk/image-generation";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -25,7 +22,6 @@ import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const MAX_IMAGE_RESULTS = 4;
const MB = 1024 * 1024;
const SUPPORTED_MODELS = [
DEFAULT_MODEL,
"google/gemini-3-pro-image-preview",
@@ -217,16 +213,6 @@ function resolveImageCount(count: number | undefined): number {
return Math.max(1, Math.min(MAX_IMAGE_RESULTS, Math.trunc(count)));
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function isGeminiImageModel(model: string): boolean {
return model.startsWith("google/gemini-");
}
@@ -321,7 +307,6 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
transport: "http",
});
const count = resolveImageCount(req.count);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/chat/completions`,
headers,
@@ -329,7 +314,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
model,
messages: [{ role: "user", content: buildMessageContent(req) }],
modalities: ["image", "text"],
n: count,
n: resolveImageCount(req.count),
...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}),
},
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@@ -341,12 +326,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
try {
await assertOkOrThrowHttpError(response, "OpenRouter image generation failed");
const payload = await readProviderJsonResponse(response, "openrouter.image-generation", {
maxBytes: resolveInlineImageJsonResponseMaxBytes(
count,
resolveGeneratedImageMaxBytes(req),
),
});
const payload = await response.json();
const images = extractOpenRouterImagesFromResponse(payload, {
malformedResponseError: OPENROUTER_IMAGE_MALFORMED_RESPONSE,
});

View File

@@ -23,7 +23,6 @@ import {
import { probeSignal } from "./probe.js";
import { clearSignalRuntime } from "./runtime.js";
import {
createSignalCliPathTextInput,
normalizeSignalAccountInput,
parseSignalAllowFromEntries,
signalDmPolicy,
@@ -215,13 +214,6 @@ describe("probeSignal", () => {
expect(status.configured).toBe(true);
});
it("does not show a second missing-binary note before the cliPath prompt", () => {
const input = createSignalCliPathTextInput(async () => true);
expect(input.helpLines).toBeUndefined();
expect(input.helpTitle).toBeUndefined();
});
});
describe("signal outbound", () => {

View File

@@ -5,36 +5,20 @@ import path from "node:path";
import JSZip from "jszip";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import * as tar from "tar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReleaseAsset } from "./install-signal-cli.js";
const { fetchWithSsrFGuardMock, resolveBrewExecutableMock, runPluginCommandWithTimeoutMock } =
vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
resolveBrewExecutableMock: vi.fn(),
runPluginCommandWithTimeoutMock: vi.fn(),
}));
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/setup-tools")>();
return {
...actual,
resolveBrewExecutable: resolveBrewExecutableMock,
};
});
vi.mock("openclaw/plugin-sdk/run-command", () => ({
runPluginCommandWithTimeout: runPluginCommandWithTimeoutMock,
}));
const {
downloadToFile,
extractSignalCliArchive,
installSignalCli,
installSignalCliFromRelease,
looksLikeArchive,
pickAsset,
@@ -90,8 +74,6 @@ async function withTempFile(run: (filePath: string) => Promise<void>) {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
resolveBrewExecutableMock.mockReset();
runPluginCommandWithTimeoutMock.mockReset();
});
function requireAsset(asset: ReleaseAsset | undefined, label: string): ReleaseAsset {
@@ -161,25 +143,6 @@ describe("pickAsset", () => {
const result = requireAsset(pickAsset(SAMPLE_ASSETS, "darwin", "x64"), "darwin x64");
expect(result.name).toContain("macOS-native");
});
it("does not fall back to Linux client archives when macOS assets are absent", () => {
const currentUpstreamAssets: ReleaseAsset[] = [
{
name: "signal-cli-0.14.5-Linux-client.tar.gz",
browser_download_url: "https://example.com/linux-client.tar.gz",
},
{
name: "signal-cli-0.14.5-Linux-native.tar.gz",
browser_download_url: "https://example.com/linux-native.tar.gz",
},
{
name: "signal-cli-0.14.5.tar.gz",
browser_download_url: "https://example.com/jvm.tar.gz",
},
];
expect(pickAsset(currentUpstreamAssets, "darwin", "arm64")).toBeUndefined();
});
});
describe("win32", () => {
@@ -342,46 +305,6 @@ describe("installSignalCliFromRelease", () => {
});
});
describe("installSignalCli", () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
function setProcessPlatform(platform: NodeJS.Platform, arch: string) {
Object.defineProperty(process, "platform", { configurable: true, value: platform });
Object.defineProperty(process, "arch", { configurable: true, value: arch });
}
afterEach(() => {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
Object.defineProperty(process, "arch", { configurable: true, value: originalArch });
});
it("uses Homebrew on macOS instead of downloading the first GitHub release archive", async () => {
setProcessPlatform("darwin", "arm64");
const brewPrefix = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-brew-"));
await fs.mkdir(path.join(brewPrefix, "bin"), { recursive: true });
await fs.writeFile(path.join(brewPrefix, "bin", "signal-cli"), "");
resolveBrewExecutableMock.mockReturnValue("/opt/homebrew/bin/brew");
runPluginCommandWithTimeoutMock
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: `${brewPrefix}\n`, stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: "signal-cli 0.14.5\n", stderr: "" });
try {
const result = await installSignalCli({ log: vi.fn() } as unknown as RuntimeEnv);
expect(result).toEqual({
ok: true,
cliPath: path.join(brewPrefix, "bin", "signal-cli"),
version: "0.14.5",
});
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
} finally {
await fs.rm(brewPrefix, { recursive: true, force: true });
}
});
});
describe("extractSignalCliArchive", () => {
async function withArchiveWorkspace(run: (workDir: string) => Promise<void>) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));

View File

@@ -105,7 +105,7 @@ export function pickAsset(
}
if (platform === "darwin") {
return byName(/macos|osx|darwin/);
return byName(/macos|osx|darwin/) || archives[0];
}
if (platform === "win32") {
@@ -228,7 +228,7 @@ async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInsta
return {
ok: false,
error:
`No native signal-cli build is available for ${process.platform}/${process.arch}. ` +
`No native signal-cli build is available for ${process.arch}. ` +
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
};
}
@@ -372,9 +372,9 @@ export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInsta
}
// The official signal-cli GitHub releases only ship a native binary for
// x86-64 Linux. Other platforms use Homebrew instead of guessing from
// unrelated release archives.
const hasNativeRelease = process.platform === "linux" && process.arch === "x64";
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
// to Homebrew which builds from source and bundles the JRE automatically.
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
if (hasNativeRelease) {
return installSignalCliFromRelease(runtime);

View File

@@ -193,6 +193,10 @@ export function createSignalCliPathTextInput(
resolvePath: ({ cfg, accountId, credentialValues }) =>
resolveSignalCliPath({ cfg, accountId, credentialValues }),
shouldPrompt,
helpTitle: "Signal",
helpLines: [
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
],
});
}

View File

@@ -486,49 +486,6 @@ async function pendingUpdateIds(spoolDir: string, limit: number | "all" = 100):
return (await listTelegramSpooledUpdates({ spoolDir, limit })).map((update) => update.updateId);
}
async function claimedAtForUpdate(spoolDir: string, updateId: number): Promise<number> {
const claim = (await listTelegramSpooledUpdateClaims({ spoolDir })).find(
(entry) => entry.updateId === updateId,
);
if (!claim?.claim) {
throw new Error(`Expected claimed spooled update ${updateId}`);
}
return claim.claim.claimedAt;
}
function installSpooledClaimRefreshHarness(): {
restore: () => void;
triggerRefresh: () => void;
} {
let refresh: (() => void) | undefined;
const realSetInterval = globalThis.setInterval.bind(globalThis);
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(((
handler: Parameters<typeof setInterval>[0],
timeout?: number,
) => {
if (timeout === pollingSessionTesting.spooledClaimRefreshIntervalMs) {
refresh = () => {
if (typeof handler === "function") {
handler();
}
};
const timer = realSetInterval(() => undefined, 2_147_483_647);
timer.unref?.();
return timer;
}
return realSetInterval(handler, timeout);
}) as typeof setInterval);
return {
restore: () => setIntervalSpy.mockRestore(),
triggerRefresh: () => {
if (!refresh) {
throw new Error("Expected spooled claim refresh interval to be registered");
}
refresh();
},
};
}
function normalizeTelegramTestAccountId(spoolDir: string): string {
const trimmed = path.basename(spoolDir).trim();
return trimmed ? trimmed.replace(/[^a-z0-9._-]+/gi, "_") : "default";
@@ -1618,49 +1575,6 @@ describe("TelegramPollingSession", () => {
});
});
it("refreshes active spooled claims while the handler is still running", async () => {
const refreshHarness = installSpooledClaimRefreshHarness();
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const events: string[] = [];
let releaseHandler: (() => void) | undefined;
const handlerDone = new Promise<void>((resolve) => {
releaseHandler = resolve;
});
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "long topic 10 turn")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
handleUpdate: async (update) => {
events.push(`topic10:${update.update_id}`);
await handlerDone;
},
});
try {
await vi.waitFor(() => expect(events).toEqual(["topic10:42"]));
const before = await claimedAtForUpdate(tempDir, 42);
refreshHarness.triggerRefresh();
await vi.waitFor(async () =>
expect(await claimedAtForUpdate(tempDir, 42)).toBeGreaterThan(before),
);
releaseHandler?.();
await vi.waitFor(async () =>
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]),
);
} finally {
releaseHandler?.();
abort.abort();
stopWorker();
refreshHarness.restore();
await runPromise;
}
});
});
it("holds buffered spooled claims until deferred processing settles without blocking same-lane buffering", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
@@ -1711,50 +1625,6 @@ describe("TelegramPollingSession", () => {
});
});
it("refreshes deferred spooled claims after the active handler hands off", async () => {
const refreshHarness = installSpooledClaimRefreshHarness();
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const participants: TelegramSpooledReplayDeferredParticipant[] = [];
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "buffered topic 10 turn")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
handleUpdate: async (update) => {
const participant = createTelegramSpooledReplayDeferredParticipant(
`test-buffer:${update.update_id}`,
);
if (!participant) {
throw new Error("expected spooled replay participant");
}
participants.push(participant);
},
});
try {
await vi.waitFor(() => expect(participants).toHaveLength(1));
const before = await claimedAtForUpdate(tempDir, 42);
refreshHarness.triggerRefresh();
await vi.waitFor(async () =>
expect(await claimedAtForUpdate(tempDir, 42)).toBeGreaterThan(before),
);
participants[0]?.settle({ kind: "completed" });
await vi.waitFor(async () =>
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]),
);
} finally {
participants[0]?.settle({ kind: "completed" });
abort.abort();
stopWorker();
refreshHarness.restore();
await runPromise;
}
});
});
it("releases buffered spooled claims for retry when deferred processing fails", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
@@ -3715,106 +3585,6 @@ describe("TelegramPollingSession", () => {
}
});
it("marks isolated ingress unhealthy when a spooled backlog stalls before handler timeout", async () => {
vi.useFakeTimers({ now: 1_000, shouldAdvanceTime: true });
const abort = new AbortController();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
const setStatus = vi.fn();
let releaseRegularTurn: (() => void) | undefined;
const regularTurnDone = new Promise<void>((resolve) => {
releaseRegularTurn = resolve;
});
const handleUpdate = vi.fn(async () => {
await regularTurnDone;
});
createTelegramBotMock.mockReturnValueOnce({
api: {
deleteWebhook: vi.fn(async () => true),
config: { use: vi.fn() },
},
init: vi.fn(async () => undefined),
handleUpdate,
stop: vi.fn(async () => undefined),
});
await writeSpooledTestUpdates(tempDir, [
topicUpdate(42, 10, "active topic 10 turn"),
topicUpdate(43, 10, "later topic 10 turn"),
]);
const workerListeners: WorkerMessageListener[] = [];
let stopWorker: (() => void) | undefined;
const workerDone = new Promise<void>((resolve) => {
stopWorker = resolve;
});
const createWorker = vi.fn(() => ({
onMessage: vi.fn((listener: WorkerMessageListener) => {
workerListeners.push(listener);
return () => undefined;
}),
stop: vi.fn(async () => {
stopWorker?.();
}),
task: vi.fn(async () => {
await workerDone;
}),
}));
try {
const session = createPollingSession({
abortSignal: abort.signal,
setStatus,
isolatedIngress: {
enabled: true,
spoolDir: tempDir,
createWorker,
drainIntervalMs: pollingSessionTesting.isolatedIngressBacklogStallMs * 2,
spooledUpdateHandlerTimeoutMs: pollingSessionTesting.isolatedIngressBacklogStallMs * 2,
},
});
const runPromise = session.runUntilAbort();
await vi.waitFor(() => expect(handleUpdate).toHaveBeenCalledTimes(1));
workerListeners[0]?.({
type: "poll-success",
offset: null,
count: 0,
finishedAt: Date.now(),
});
expect(statusPatches(setStatus).some((patch) => patch.connected === true)).toBe(true);
vi.setSystemTime(1_000 + pollingSessionTesting.isolatedIngressBacklogStallMs + 1);
workerListeners[0]?.({ type: "spooled", updateId: 43, queued: 1 });
await vi.waitFor(() =>
expect(
statusPatches(setStatus).some(
(patch) =>
patch.connected === false &&
String(patch.lastError).includes("isolated polling spool backlog stalled"),
),
).toBe(true),
);
expect(await failedUpdateIds(tempDir)).toEqual([]);
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
expect(
(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).map(
(claim) => claim.updateId,
),
).toEqual([42]);
releaseRegularTurn?.();
abort.abort();
stopWorker?.();
await vi.advanceTimersByTimeAsync(20_000);
await runPromise;
} finally {
releaseRegularTurn?.();
abort.abort();
stopWorker?.();
vi.useRealTimers();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("marks isolated ingress unhealthy when a spooled backlog handler times out", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const abort = new AbortController();

View File

@@ -41,7 +41,6 @@ import {
listTelegramSpooledUpdateClaims,
listTelegramSpooledUpdates,
recoverStaleTelegramSpooledUpdateClaims,
refreshTelegramSpooledUpdateClaim,
releaseTelegramSpooledUpdateClaim,
resolveTelegramIngressSpoolDir,
writeTelegramSpooledUpdate,
@@ -132,7 +131,6 @@ const TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS = 5_000;
const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100;
const TELEGRAM_SPOOLED_DRAIN_SCAN_LIMIT = TELEGRAM_SPOOLED_DRAIN_START_LIMIT * 10;
const TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS = 5_000;
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS = 60_000;
const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil(
@@ -293,8 +291,6 @@ type SpooledUpdateHandlerState = {
update: ClaimedTelegramSpooledUpdate;
updateId: number;
startedAt: number;
stopClaimRefresh: () => void;
backlogStatusMessage?: string;
timedOutAt?: number;
timeoutMessage?: string;
};
@@ -307,7 +303,6 @@ type DeferredSpooledUpdateClaimState = {
timedOutMessage?: string;
update: ClaimedTelegramSpooledUpdate;
updateId: number;
stopClaimRefresh: () => void;
};
const deferredSpooledUpdateClaimsByKey = new Map<string, DeferredSpooledUpdateClaimState>();
@@ -577,46 +572,8 @@ export class TelegramPollingSession {
}
}
#startSpooledUpdateClaimRefresh(update: ClaimedTelegramSpooledUpdate): () => void {
// Refresh only while this process still owns useful work for this claim token.
// Stopping before release/fail/delete lets stale recovery take over if work stalls.
let stopped = false;
let refreshing = false;
const refresh = async (): Promise<void> => {
if (stopped || refreshing) {
return;
}
refreshing = true;
try {
const refreshed = await refreshTelegramSpooledUpdateClaim(update);
if (!refreshed && !stopped) {
stopped = true;
clearInterval(timer);
}
} catch (err) {
this.opts.log(
`[telegram][diag] spooled update ${update.updateId} claim refresh failed: ${formatErrorMessage(err)}`,
);
} finally {
refreshing = false;
}
};
const timer = setInterval(() => {
void refresh();
}, TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
if (stopped) {
return;
}
stopped = true;
clearInterval(timer);
};
}
async #handleClaimedSpooledUpdate(params: {
bot: TelegramBot;
stopClaimRefresh: () => void;
update: ClaimedTelegramSpooledUpdate;
}): Promise<boolean> {
let replay: { deferredWork?: TelegramSpooledReplayDeferredParticipant };
@@ -626,7 +583,6 @@ export class TelegramPollingSession {
await params.bot.handleUpdate(update);
});
} catch (err) {
params.stopClaimRefresh();
await this.#releaseFailedSpooledUpdate({
err,
update: params.update,
@@ -637,13 +593,11 @@ export class TelegramPollingSession {
this.#registerDeferredSpooledUpdate({
deferredWork: replay.deferredWork,
laneKey: this.#spooledUpdateLaneKey(params.update),
stopClaimRefresh: params.stopClaimRefresh,
update: params.update,
});
return true;
}
try {
params.stopClaimRefresh();
await deleteTelegramSpooledUpdate(params.update);
return true;
} catch (err) {
@@ -657,7 +611,6 @@ export class TelegramPollingSession {
#registerDeferredSpooledUpdate(params: {
deferredWork: TelegramSpooledReplayDeferredParticipant;
laneKey: string;
stopClaimRefresh: () => void;
update: ClaimedTelegramSpooledUpdate;
}): void {
const claimKey = buildDeferredSpooledUpdateClaimKey(params.update);
@@ -666,7 +619,6 @@ export class TelegramPollingSession {
if (previous.timer) {
clearTimeout(previous.timer);
}
previous.stopClaimRefresh();
deferredSpooledUpdateClaimsByKey.delete(claimKey);
}
let settled = false;
@@ -678,7 +630,6 @@ export class TelegramPollingSession {
if (state.timer) {
clearTimeout(state.timer);
}
state.stopClaimRefresh();
if (deferredSpooledUpdateClaimsByKey.get(claimKey) === state) {
deferredSpooledUpdateClaimsByKey.delete(claimKey);
}
@@ -710,12 +661,10 @@ export class TelegramPollingSession {
}),
update: params.update,
updateId: params.update.updateId,
stopClaimRefresh: params.stopClaimRefresh,
};
state.timer = setTimeout(() => {
const age = formatDurationPrecise(this.#spooledUpdateHandlerTimeoutMs);
state.timedOutMessage = `Telegram isolated polling spool buffered processing timed out behind update ${params.update.updateId} on lane ${params.laneKey} after ${age}; marking the update failed, aborting active reply work, and keeping the claim out of retry while the buffered task settles.`;
state.stopClaimRefresh();
params.deferredWork.settle({
kind: "failed-retryable",
error: new Error(state.timedOutMessage),
@@ -956,10 +905,8 @@ export class TelegramPollingSession {
claimedLaneKeys.add(laneKey);
continue;
}
const stopClaimRefresh = this.#startSpooledUpdateClaimRefresh(claimedUpdate);
const handler = this.#handleClaimedSpooledUpdate({
bot: params.bot,
stopClaimRefresh,
update: claimedUpdate,
});
const state: SpooledUpdateHandlerState = {
@@ -969,17 +916,11 @@ export class TelegramPollingSession {
update: claimedUpdate,
updateId: update.updateId,
startedAt: Date.now(),
stopClaimRefresh,
};
activeSpooledUpdateHandlersByLane.set(handlerKey, state);
this.#spooledUpdateHandlerKeys.add(handlerKey);
claimedLaneKeys.add(laneKey);
void handler.finally(() => {
if (
!deferredSpooledUpdateClaimsByKey.has(buildDeferredSpooledUpdateClaimKey(claimedUpdate))
) {
state.stopClaimRefresh();
}
if (activeSpooledUpdateHandlersByLane.get(handlerKey) === state) {
activeSpooledUpdateHandlersByLane.delete(handlerKey);
}
@@ -1028,7 +969,6 @@ export class TelegramPollingSession {
}
const age = formatDurationPrecise(timedOutHandler.ageMs);
activeHandler.timedOutAt = Date.now();
activeHandler.stopClaimRefresh();
const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; marking the update failed, aborting active reply work, and restarting isolated ingress so later updates can drain.`;
activeHandler.timeoutMessage = message;
try {
@@ -1085,27 +1025,6 @@ export class TelegramPollingSession {
return { handlerKey: handler.handlerKey, restart: true };
}
#noteSpooledBacklogStalls(blockedHandlerKeys: Set<string>): Set<string> {
const stalled = new Set<string>();
const now = Date.now();
for (const handlerKey of blockedHandlerKeys) {
const handler = activeSpooledUpdateHandlersByLane.get(handlerKey);
if (!handler || handler.timedOutAt !== undefined) {
continue;
}
const ageMs = now - handler.startedAt;
if (ageMs < ISOLATED_INGRESS_BACKLOG_STALL_MS) {
continue;
}
stalled.add(handlerKey);
if (!handler.backlogStatusMessage) {
handler.backlogStatusMessage = `Telegram isolated polling spool backlog stalled behind update ${handler.updateId} on lane ${handler.laneKey} for ${formatDurationPrecise(ageMs)}; marking polling unhealthy until the backlog drains.`;
this.#status.notePollingError(handler.backlogStatusMessage);
}
}
return stalled;
}
async #runIsolatedIngressCycle(bot: TelegramBot): Promise<"continue" | "exit"> {
const ingress = this.opts.isolatedIngress;
if (!ingress?.enabled) {
@@ -1303,9 +1222,6 @@ export class TelegramPollingSession {
this.#status.notePollingError(handler.timeoutMessage);
}
}
for (const handlerKey of this.#noteSpooledBacklogStalls(drain.blockedByLane)) {
stalledBacklogKeys.add(handlerKey);
}
// Active handlers can outlive their owning session after shutdown grace.
// Recover every handler for this spool, including lone handlers with no backlog.
const timeoutCandidateHandlerKeys = this.#activeSpooledUpdateHandlerKeysForSpool(spoolDir);
@@ -1645,8 +1561,6 @@ export const testing = {
resetTelegramRestartBackoffState,
resolveTelegramRestartDelayMs,
resolveSpooledUpdateRetryDelayMs,
isolatedIngressBacklogStallMs: ISOLATED_INGRESS_BACKLOG_STALL_MS,
spooledClaimRefreshIntervalMs: TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS,
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>
resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS),
};

View File

@@ -17,7 +17,6 @@ import {
listTelegramSpooledUpdateClaims,
listTelegramSpooledUpdates,
recoverStaleTelegramSpooledUpdateClaims,
refreshTelegramSpooledUpdateClaim,
releaseTelegramSpooledUpdateClaim,
TELEGRAM_SPOOLED_UPDATE_PROCESSING_STALE_MS,
writeTelegramSpooledUpdate,
@@ -141,32 +140,6 @@ describe("Telegram ingress spool", () => {
});
});
it("refreshes active claim timestamps through the Telegram spool queue", async () => {
await withTempSpool(async (spoolDir) => {
await writeTelegramSpooledUpdate({
spoolDir,
update: { update_id: 31, message: { text: "refresh me" } },
});
const update = (await listTelegramSpooledUpdates({ spoolDir }))[0];
if (!update) {
throw new Error("Expected a spooled update");
}
const claimed = await claimTelegramSpooledUpdate(update);
if (!claimed) {
throw new Error("Expected a claimed update");
}
await expect(refreshTelegramSpooledUpdateClaim(claimed, { refreshedAt: 123 })).resolves.toBe(
true,
);
const claims = await listTelegramSpooledUpdateClaims({ spoolDir });
expect(claims).toHaveLength(1);
expect(claims[0]?.updateId).toBe(31);
expect(claims[0]?.claim?.claimedAt).toBe(123);
});
});
it("marks timed out claims failed without requeueing them", async () => {
await withTempSpool(async (spoolDir) => {
await writeTelegramSpooledUpdate({

View File

@@ -281,23 +281,6 @@ export async function releaseTelegramSpooledUpdateClaim(
);
}
export async function refreshTelegramSpooledUpdateClaim(
update: ClaimedTelegramSpooledUpdate,
options?: { refreshedAt?: number },
): Promise<boolean> {
const claimToken = update.claim?.claimToken;
if (!claimToken) {
return false;
}
const queue = createTelegramIngressQueue(path.dirname(update.pendingPath));
return (
(await queue.refreshClaim?.(
{ id: queueEventId(update.updateId), claim: { token: claimToken } },
options,
)) ?? false
);
}
export async function failTelegramSpooledUpdateClaim(params: {
update: ClaimedTelegramSpooledUpdate;
reason: string;

View File

@@ -1,11 +1,7 @@
// Vydra provider module implements model/runtime integration.
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { assertOkOrThrowHttpError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VYDRA_IMAGE_MODEL,
downloadVydraAsset,
@@ -79,7 +75,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "Vydra image generation failed");
const submitted = await readProviderJsonResponse(response, "vydra.image-generation");
const submitted = await response.json();
const completedPayload = await resolveCompletedVydraPayload({
submitted,
baseUrl,

View File

@@ -52,21 +52,15 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));
vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({
normalizeOptionalString: (v: unknown) => (typeof v === "string" ? v.trim() : undefined),
@@ -75,13 +69,6 @@ vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({
readStringValue: (v: unknown) => (typeof v === "string" ? v.trim() : undefined),
}));
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function requirePostJsonCall(index = 0): {
url?: string;
timeoutMs?: number;
@@ -146,9 +133,11 @@ describe("xai image generation provider", () => {
it("uses main provider URL and resolves auth for generation", async () => {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("testpng").toString("base64") }],
}),
response: {
json: async () => ({
data: [{ b64_json: Buffer.from("testpng").toString("base64") }],
}),
},
release: vi.fn(async () => {}),
});
@@ -201,15 +190,17 @@ describe("xai image generation provider", () => {
it("supports edit with exact user-provided payload format including image object with type image_url", async () => {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [
{
b64_json:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4z0ABAAEfAG0B0xMAAAAASUVORK5CYII=",
mime_type: "image/png",
},
],
}),
response: {
json: async () => ({
data: [
{
b64_json:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4z0ABAAEfAG0B0xMAAAAASUVORK5CYII=",
mime_type: "image/png",
},
],
}),
},
release: vi.fn(async () => {}),
});
@@ -241,9 +232,11 @@ describe("xai image generation provider", () => {
it("forwards xAI attribution User-Agent through the SDK image request", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("ua-png").toString("base64") }],
}),
response: {
json: async () => ({
data: [{ b64_json: Buffer.from("ua-png").toString("base64") }],
}),
},
release: vi.fn(async () => {}),
});
@@ -264,14 +257,16 @@ describe("xai image generation provider", () => {
it("uses the plural xAI images payload for multiple edit inputs", async () => {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [
{
b64_json: Buffer.from("edited").toString("base64"),
mime_type: "image/png",
},
],
}),
response: {
json: async () => ({
data: [
{
b64_json: Buffer.from("edited").toString("base64"),
mime_type: "image/png",
},
],
}),
},
release: vi.fn(async () => {}),
});

View File

@@ -328,6 +328,7 @@ type SelectedConnectAuth = {
authDeviceToken?: string;
authPassword?: string;
authApprovalRuntimeToken?: string;
authAgentRuntimeIdentityToken?: string;
signatureToken?: string;
resolvedDeviceToken?: string;
storedToken?: string;
@@ -343,6 +344,7 @@ type StoredDeviceAuth = {
type AssembledConnect = {
params: ConnectParams;
authApprovalRuntimeToken: string | undefined;
authAgentRuntimeIdentityToken: string | undefined;
resolvedDeviceToken: string | undefined;
storedToken: string | undefined;
usingStoredDeviceToken: boolean | undefined;
@@ -430,6 +432,7 @@ export type GatewayClientOptions = {
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
agentRuntimeIdentityToken?: string;
instanceId?: string;
clientName?: GatewayClientName;
clientDisplayName?: string;
@@ -969,6 +972,24 @@ export class GatewayClient {
this.ws?.close(1013, "gateway starting");
return;
}
if (
this.shouldFailClosedForUnsupportedAgentRuntimeIdentity({
error: err,
authAgentRuntimeIdentityToken: assembled.authAgentRuntimeIdentityToken,
})
) {
const unsupportedIdentityError = new Error(
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
);
this.notifyConnectError(unsupportedIdentityError);
this.logError(`gateway connect failed: ${unsupportedIdentityError.message}`);
// This identity scopes model-mediated cron calls. Retrying without it
// would turn an old/new mismatch into an unscoped operator call.
this.closed = true;
this.clearReconnectTimer();
this.ws?.close(1008, "connect failed");
return;
}
if (
this.shouldRetryWithoutApprovalRuntimeToken({
error: err,
@@ -1004,6 +1025,7 @@ export class GatewayClient {
authDeviceToken,
authPassword,
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
signatureToken,
resolvedDeviceToken,
storedToken,
@@ -1020,13 +1042,15 @@ export class GatewayClient {
authBootstrapToken ||
authPassword ||
resolvedDeviceToken ||
authApprovalRuntimeToken
authApprovalRuntimeToken ||
authAgentRuntimeIdentityToken
? {
token: authToken,
bootstrapToken: authBootstrapToken,
deviceToken: authDeviceToken ?? resolvedDeviceToken,
password: authPassword,
approvalRuntimeToken: authApprovalRuntimeToken,
agentRuntimeIdentityToken: authAgentRuntimeIdentityToken,
}
: undefined;
const signedAtMs = Date.now();
@@ -1069,6 +1093,7 @@ export class GatewayClient {
}),
},
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
resolvedDeviceToken,
storedToken,
usingStoredDeviceToken,
@@ -1294,6 +1319,25 @@ export class GatewayClient {
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
}
private shouldFailClosedForUnsupportedAgentRuntimeIdentity(params: {
error: unknown;
authAgentRuntimeIdentityToken?: string;
}): boolean {
if (!params.authAgentRuntimeIdentityToken) {
return false;
}
if (!(params.error instanceof GatewayClientRequestError)) {
return false;
}
if (params.error.gatewayCode !== "INVALID_REQUEST") {
return false;
}
const message = normalizeLowercaseStringOrEmpty(params.error.message);
return (
message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken")
);
}
private isTrustedDeviceRetryEndpoint(): boolean {
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
try {
@@ -1321,6 +1365,9 @@ export class GatewayClient {
const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled
? undefined
: normalizeOptionalString(this.opts.approvalRuntimeToken);
const authAgentRuntimeIdentityToken = normalizeOptionalString(
this.opts.agentRuntimeIdentityToken,
);
const storedAuth = this.loadStoredDeviceAuth(role);
const storedToken = storedAuth?.token ?? null;
const storedScopes = storedAuth?.scopes;
@@ -1354,6 +1401,7 @@ export class GatewayClient {
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
authPassword,
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
signatureToken: authToken ?? authBootstrapToken ?? undefined,
resolvedDeviceToken,
storedToken: storedToken ?? undefined,

View File

@@ -1,66 +0,0 @@
/** Structured ClawHub trust details carried in gateway error payloads. */
export const ClawHubTrustErrorCodes = {
SECURITY_UNAVAILABLE: "clawhub_security_unavailable",
RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required",
DOWNLOAD_BLOCKED: "clawhub_download_blocked",
} as const;
export type ClawHubTrustErrorCode =
(typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes];
export type ClawHubTrustErrorDetails = {
clawhubTrustCode?: ClawHubTrustErrorCode;
version?: string;
warning?: string;
};
function normalizeNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
export function isClawHubTrustErrorCode(value: unknown): value is ClawHubTrustErrorCode {
return (
value === ClawHubTrustErrorCodes.SECURITY_UNAVAILABLE ||
value === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED ||
value === ClawHubTrustErrorCodes.DOWNLOAD_BLOCKED
);
}
export function buildClawHubTrustErrorDetails(params: {
code?: ClawHubTrustErrorCode;
version?: string;
warning?: string;
}): ClawHubTrustErrorDetails | undefined {
if (!params.code && !params.version && !params.warning) {
return undefined;
}
return {
...(params.code ? { clawhubTrustCode: params.code } : {}),
...(params.version ? { version: params.version } : {}),
...(params.warning ? { warning: params.warning } : {}),
};
}
export function readClawHubTrustErrorDetails(
details: unknown,
): ClawHubTrustErrorDetails | undefined {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return undefined;
}
const raw = details as {
clawhubTrustCode?: unknown;
version?: unknown;
warning?: unknown;
};
const code = isClawHubTrustErrorCode(raw.clawhubTrustCode) ? raw.clawhubTrustCode : undefined;
const version = normalizeNonEmptyString(raw.version);
const warning = normalizeNonEmptyString(raw.warning);
if (!code && !version && !warning) {
return undefined;
}
return {
...(code ? { clawhubTrustCode: code } : {}),
...(version ? { version } : {}),
...(warning ? { warning } : {}),
};
}

View File

@@ -26,11 +26,36 @@ const minimalAddParams = {
payload: { kind: "systemEvent", text: "tick" },
} as const;
const agentToolCallerScope = {
kind: "agentTool",
agentId: "ops",
} as const;
describe("cron protocol validators", () => {
it("accepts minimal add params", () => {
expect(validateCronAddParams(minimalAddParams)).toBe(true);
});
it("rejects public caller scope on cron admin params", () => {
expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronAddParams({ ...minimalAddParams, callerScope: agentToolCallerScope })).toBe(
false,
);
expect(
validateCronUpdateParams({
id: "job-1",
patch: { enabled: false },
callerScope: agentToolCallerScope,
}),
).toBe(false);
expect(validateCronRemoveParams({ jobId: "job-1", callerScope: agentToolCallerScope })).toBe(
false,
);
expect(validateCronRunParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronRunsParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
});
it("accepts current and custom session targets", () => {
expect(
validateCronAddParams({

View File

@@ -1,13 +1,5 @@
// Public gateway protocol entrypoint. Keep this barrel aligned with schema.ts
// so clients can import wire types, JSON schemas, and validators from one place.
export {
buildClawHubTrustErrorDetails,
ClawHubTrustErrorCodes,
isClawHubTrustErrorCode,
readClawHubTrustErrorDetails,
type ClawHubTrustErrorCode,
type ClawHubTrustErrorDetails,
} from "./clawhub-trust-error-details.js";
import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
import {
type AgentEvent,

View File

@@ -3,7 +3,6 @@ import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
AgentsListResultSchema,
SkillsDetailResultSchema,
SkillsProposalInspectResultSchema,
SkillsProposalRequestRevisionResultSchema,
ToolsEffectiveResultSchema,
@@ -174,34 +173,3 @@ describe("SkillsProposalRequestRevisionResultSchema", () => {
).toBe(false);
});
});
describe("SkillsDetailResultSchema", () => {
it("accepts official ClawHub skill publisher metadata", () => {
const result = {
skill: {
slug: "tao-setup-nvidia-gpu-host",
displayName: "TAO Setup NVIDIA GPU Host",
summary: "Prepare an NVIDIA GPU host for TAO workflows.",
tags: { gpu: "GPU" },
channel: "official",
isOfficial: true,
createdAt: 1_700_000_000,
updatedAt: 1_700_010_000,
},
latestVersion: {
version: "1.0.0",
createdAt: 1_700_010_000,
},
owner: {
handle: "nvidia",
displayName: "NVIDIA",
image: "https://example.test/nvidia.png",
official: true,
channel: "official",
isOfficial: true,
},
};
expect(Value.Check(SkillsDetailResultSchema, result)).toBe(true);
});
});

View File

@@ -344,7 +344,6 @@ export const SkillsInstallParamsSchema = Type.Union([
slug: NonEmptyString,
version: Type.Optional(NonEmptyString),
force: Type.Optional(Type.Boolean()),
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
},
{ additionalProperties: false },
@@ -380,7 +379,6 @@ export const SkillsUpdateParamsSchema = Type.Union([
source: Type.Literal("clawhub"),
slug: Type.Optional(NonEmptyString),
all: Type.Optional(Type.Boolean()),
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
@@ -441,8 +439,6 @@ export const SkillsDetailResultSchema = Type.Object(
displayName: NonEmptyString,
summary: Type.Optional(Type.String()),
tags: Type.Optional(Type.Record(NonEmptyString, Type.String())),
channel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
createdAt: Type.Integer(),
updatedAt: Type.Integer(),
},
@@ -482,9 +478,6 @@ export const SkillsDetailResultSchema = Type.Object(
handle: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
displayName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
image: Type.Optional(Type.Union([Type.String(), Type.Null()])),
official: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
channel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
},
{ additionalProperties: false },
),

View File

@@ -70,6 +70,7 @@ export const ConnectParamsSchema = Type.Object(
deviceToken: Type.Optional(Type.String()),
password: Type.Optional(Type.String()),
approvalRuntimeToken: Type.Optional(Type.String()),
agentRuntimeIdentityToken: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),

View File

@@ -55,39 +55,11 @@ export type MemorySyncParams = {
};
/** Runtime backend/mode diagnostics for memory search. */
export type MemorySearchRuntimeQmdCollectionValidationDebug = {
cacheState?: "hit" | "miss" | "write" | "bypass-force" | "error";
elapsedMs: number;
collectionCount: number;
listCalls?: number;
showCalls?: number;
};
export type MemorySearchRuntimeQmdMultiCollectionProbeDebug = {
cacheState?: "hit" | "miss" | "write" | "error";
elapsedMs: number;
supported: boolean;
};
export type MemorySearchRuntimeQmdSearchPlanDebug = {
command?: "query" | "search" | "vsearch";
collectionCount?: number;
groupCount?: number;
sources?: MemorySource[];
};
export type MemorySearchRuntimeQmdDebug = {
collectionValidation?: MemorySearchRuntimeQmdCollectionValidationDebug;
multiCollectionProbe?: MemorySearchRuntimeQmdMultiCollectionProbeDebug;
searchPlan?: MemorySearchRuntimeQmdSearchPlanDebug;
};
export type MemorySearchRuntimeDebug = {
backend: "builtin" | "qmd";
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
qmd?: MemorySearchRuntimeQmdDebug;
};
/** Result of reading a memory file, optionally paginated/truncated. */

View File

@@ -403,20 +403,6 @@ async function main() {
npmShasum: clawpack.npmShasum,
},
};
const securityDetail = {
package: artifactResolverDetail.package,
release: {
version: fixture.version,
},
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: false,
},
};
const server = http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
@@ -443,13 +429,6 @@ async function main() {
json(response, artifactResolverDetail);
return;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/security`
) {
json(response, securityDetail);
return;
}
if (
betaStatus !== undefined &&
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`

View File

@@ -3,14 +3,6 @@ import fs from "node:fs";
import path from "node:path";
export const PLAIN_GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
export const PLAIN_GH_SYSTEM_CANDIDATES = [
// Prefer package-manager opt paths: bin/gh may intentionally be an Octopool shim.
"/opt/homebrew/opt/gh/bin/gh",
"/usr/local/opt/gh/bin/gh",
"/home/linuxbrew/.linuxbrew/opt/gh/bin/gh",
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
];
function isExecutable(filePath) {
try {
@@ -40,10 +32,7 @@ export function plainGhEnv(env = process.env) {
return next;
}
export function resolvePlainGhBin(
env = process.env,
systemCandidates = PLAIN_GH_SYSTEM_CANDIDATES,
) {
export function resolvePlainGhBin(env = process.env) {
if (env.OPENCLAW_GH_BIN) {
if (isExecutable(env.OPENCLAW_GH_BIN)) {
return env.OPENCLAW_GH_BIN;
@@ -51,7 +40,7 @@ export function resolvePlainGhBin(
throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`);
}
for (const candidate of systemCandidates) {
for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) {
if (isExecutable(candidate)) {
return candidate;
}

View File

@@ -24,12 +24,12 @@ resolve_plain_gh_bin() {
fi
local candidate
while IFS= read -r candidate; do
for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(plain_gh_system_candidates)
done
if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then
printf '%s\n' "$candidate"
@@ -39,16 +39,6 @@ resolve_plain_gh_bin() {
type -P gh 2>/dev/null
}
plain_gh_system_candidates() {
# bin/gh may intentionally be an Octopool shim; prefer package-manager opt paths.
printf '%s\n' \
/opt/homebrew/opt/gh/bin/gh \
/usr/local/opt/gh/bin/gh \
/home/linuxbrew/.linuxbrew/opt/gh/bin/gh \
/opt/homebrew/bin/gh \
/usr/local/bin/gh
}
plain_gh_search_path() {
local path_value="${PATH:-}"
local home_bin="${HOME:-}/bin"

View File

@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10388),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5214),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10386),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5212),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -189,38 +189,6 @@ describe("agent tool definition adapter", () => {
});
});
it("does not throw WeakMap errors when preparing malformed backend sandbox exec params", async () => {
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir: process.cwd(),
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
},
});
const [definition] = toToolDefinitions([tool]);
const result = await definition.execute(
"call-malformed-backend-sandbox-exec-params",
"not-an-object",
undefined,
undefined,
extensionContext,
);
expect(result.details).toMatchObject({
status: "error",
error: "Provide a command to start.",
});
expect(JSON.stringify(result)).not.toContain("WeakMap");
expect(validateWorkdir).not.toHaveBeenCalled();
});
it("reports malformed exec params when elevated logging is enabled", async () => {
const tool = createExecTool({
security: "full",

View File

@@ -5,8 +5,8 @@
*/
import { copyPluginToolMeta } from "../plugins/tools.js";
import { bindAbortRelay } from "../utils/fetch-timeout.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
function throwAbortError(): never {

View File

@@ -73,6 +73,18 @@ export {
consumePreExecutionBlockedToolCall,
peekAdjustedParamsForToolCall,
} from "./agent-tools.before-tool-call.state.js";
import {
BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS,
BEFORE_TOOL_CALL_HOOK_CONTEXT,
BEFORE_TOOL_CALL_SOURCE_TOOL,
BEFORE_TOOL_CALL_WRAPPED,
type BeforeToolCallDiagnosticOptions,
} from "./before-tool-call-metadata.js";
export {
copyBeforeToolCallHookMarker,
isToolWrappedWithBeforeToolCallHook,
setBeforeToolCallDiagnosticsEnabled,
} from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js";
import {
getCodeModeExecBeforeHookMetadata,
@@ -83,6 +95,7 @@ import {
} from "./code-mode-control-tools.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { normalizeToolName } from "./tool-policy.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
import { getToolTerminalPresentation } from "./tool-terminal-presentation.js";
import type { AnyAgentTool } from "./tools/common.js";
import { callGatewayTool } from "./tools/gateway.js";
@@ -212,10 +225,6 @@ export function hasBeforeToolCallPolicy(): boolean {
}
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool");
const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext");
const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON =
"Tool call blocked because before_tool_call hook failed";
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
@@ -1558,12 +1567,13 @@ export function wrapToolWithBeforeToolCallHook(
};
copyPluginToolMeta(tool, wrappedTool);
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
copyToolTerminalPresentation(tool, wrappedTool);
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS, {
value: hookOptions,
value: hookOptions satisfies BeforeToolCallDiagnosticOptions,
enumerable: false,
});
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_SOURCE_TOOL, {
@@ -1577,21 +1587,6 @@ export function wrapToolWithBeforeToolCallHook(
return wrappedTool;
}
/** Return true when a tool already carries the before_tool_call wrapper marker. */
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
const taggedTool = tool as unknown as Record<symbol, unknown>;
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */
export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void {
const taggedTool = tool as unknown as Record<symbol, unknown>;
const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS];
if (options && typeof options === "object" && "emitDiagnostics" in options) {
(options as { emitDiagnostics: boolean }).emitDiagnostics = enabled;
}
}
/** Rebuild a before_tool_call wrapper while preserving the original source tool. */
export function rewrapToolWithBeforeToolCallHook(
tool: AnyAgentTool,
@@ -1618,33 +1613,10 @@ export function rewrapToolWithBeforeToolCallHook(
delete (rewrapSource as unknown as Record<symbol, unknown>)[BEFORE_TOOL_CALL_WRAPPED];
copyPluginToolMeta(tool, rewrapSource);
copyChannelAgentToolMeta(tool as never, rewrapSource as never);
copyToolTerminalPresentation(tool, rewrapSource);
return wrapToolWithBeforeToolCallHook(rewrapSource, ctx ?? preservedContext, options);
}
/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
if (!isToolWrappedWithBeforeToolCallHook(source)) {
return;
}
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
const taggedSource = source as unknown as Record<symbol, unknown>;
const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL];
if (sourceTool && typeof sourceTool === "object") {
Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, {
value: sourceTool,
enumerable: false,
});
}
const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT];
Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, {
value: hookContext,
enumerable: false,
});
}
function recordPreExecutionBlockedToolCall(toolCallId?: string, runId?: string): void {
if (!toolCallId) {
return;

View File

@@ -1,5 +1,4 @@
import { copyPluginToolMeta } from "../plugins/tools.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
/**
* Adjusts exec/process tool descriptions for long-running follow-up behavior.
* Cron-aware runs can point models at scheduled follow-ups; cronless runs keep
@@ -7,6 +6,7 @@ import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js"
*/
import type { AnyAgentTool } from "./agent-tools.types.js";
import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js";
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";

View File

@@ -8,8 +8,8 @@ import {
normalizeToolParameterSchema,
type ToolParameterSchemaOptions,
} from "./agent-tools-parameter-schema.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";

View File

@@ -367,62 +367,6 @@ describe("exec foreground failures", () => {
}
});
it("finalizes backend sandbox exec tokens when process spawn fails", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const finalizeToken = { session: "remote-session" };
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
finalizeToken,
}),
);
const finalizeExec = vi.fn<NonNullable<BashSandboxConfig["finalizeExec"]>>(async () => {});
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
supervisorMock.spawn.mockRejectedValueOnce(new Error("spawn failed"));
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
finalizeExec,
},
});
try {
await expect(
tool.execute("call-remote-sandbox-spawn-failure", {
command: "echo ok",
workdir: "/remote/workspace/generated",
}),
).rejects.toThrow("spawn failed");
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(buildExecSpec).toHaveBeenCalledOnce();
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledWith({
status: "failed",
exitCode: null,
timedOut: false,
token: finalizeToken,
});
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("rejects unsafe commands before backend workdir validation", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(

View File

@@ -715,21 +715,6 @@ export async function runExecProcess(opts: {
const timeoutMs = resolveExecTimeoutMs(opts.timeoutSec);
let sandboxFinalizeToken: unknown;
let sandboxFinalized = false;
const finalizeSandboxExec = async (params: {
status: "completed" | "failed";
exitCode: number | null;
timedOut: boolean;
}) => {
if (sandboxFinalized || !opts.sandbox?.finalizeExec) {
return;
}
sandboxFinalized = true;
await opts.sandbox.finalizeExec({
...params,
token: sandboxFinalizeToken,
});
};
const spawnSpec:
| {
@@ -876,13 +861,6 @@ export async function runExecProcess(opts: {
} catch (retryErr) {
markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
await finalizeSandboxExec({
status: "failed",
exitCode: null,
timedOut: false,
}).catch((finalizeErr: unknown) => {
logWarn(`exec: sandbox finalize after spawn failure failed (${String(finalizeErr)}).`);
});
emitExecProcessCompleted({
command: opts.command,
mode: "child",
@@ -899,13 +877,6 @@ export async function runExecProcess(opts: {
} else {
markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
await finalizeSandboxExec({
status: "failed",
exitCode: null,
timedOut: false,
}).catch((finalizeErr: unknown) => {
logWarn(`exec: sandbox finalize after spawn failure failed (${String(finalizeErr)}).`);
});
emitExecProcessCompleted({
command: opts.command,
mode: spawnSpec.mode,
@@ -944,11 +915,14 @@ export async function runExecProcess(opts: {
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
await finalizeSandboxExec({
status: outcome.status,
exitCode: exit.exitCode ?? null,
timedOut: exit.timedOut,
});
if (opts.sandbox?.finalizeExec) {
await opts.sandbox.finalizeExec({
status: outcome.status,
exitCode: exit.exitCode ?? null,
timedOut: exit.timedOut,
token: sandboxFinalizeToken,
});
}
emitExecProcessCompleted({
command: opts.command,
mode: usingPty ? "pty" : "child",

View File

@@ -6,7 +6,6 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
import type { ExtensionContext } from "./sessions/index.js";
declare module "../plugins/hook-types.js" {
@@ -517,71 +516,6 @@ describe("exec resolve_exec_env hook wiring", () => {
expect(mocks.spawnInputs).toHaveLength(0);
});
it("preserves hook context when backend sandbox env resolution is deferred", async () => {
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
mocks.hookRunner = {
hasHooks: vi.fn(
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
),
runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })),
runBeforeToolCall: vi.fn(async () => undefined),
};
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir: process.cwd(),
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
},
});
const [definition] = toToolDefinitions([tool], {
agentId: "ctx-agent",
sessionKey: "agent:ctx-agent:telegram:chat-2",
channelId: "ctx-channel",
});
const result = await definition.execute(
"call-backend-deferred-env-context",
{
command: "echo ok",
workdir: "/remote/workspace/generated",
},
undefined,
undefined,
testExtensionContext,
);
expect((result.details as { status?: unknown } | undefined)?.status).toBe("completed");
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce();
expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[0]).toMatchObject({
sessionKey: "agent:ctx-agent:telegram:chat-2",
toolName: "exec",
host: "sandbox",
});
expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[1]).toMatchObject({
agentId: "ctx-agent",
sessionKey: "agent:ctx-agent:telegram:chat-2",
channelId: "ctx-channel",
});
expect(buildExecSpec.mock.calls[0]?.[0]?.env).toMatchObject({
PLUGIN_SAFE: "yes",
});
});
it("lets lazy before_tool_call see invalid workdirs before failing unchanged params", async () => {
mocks.hookRunner = {
hasHooks: vi.fn(

View File

@@ -143,13 +143,6 @@ type ResolvedExecEnvPreparedState = {
pluginEnv?: Record<string, string>;
};
const resolvedExecEnvPreparedStates = new WeakMap<ExecToolArgs, ResolvedExecEnvPreparedState>();
type DeferredResolveExecEnvPreparedState = {
hookContext?: HookContext;
};
const deferredResolveExecEnvPreparedStates = new WeakMap<
ExecToolArgs,
DeferredResolveExecEnvPreparedState
>();
type ResolvedExecWorkdirPreparedState = {
host: ExecHost;
inputWorkdir?: string;
@@ -169,10 +162,6 @@ const XML_ARG_VALUE_EXEC_PARAM_KEYS = [
"node",
] as const;
function isExecToolArgsObject(value: unknown): value is ExecToolArgs {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, string> | undefined {
const env: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(rawEnv)) {
@@ -212,20 +201,6 @@ function isResolveExecEnvPrepared(params: ExecToolArgs): boolean {
return Boolean(getResolvedExecEnvPreparedState(params));
}
function markDeferredResolveExecEnvPrepared<T extends ExecToolArgs>(
params: T,
state: DeferredResolveExecEnvPreparedState,
): T {
deferredResolveExecEnvPreparedStates.set(params, state);
return params;
}
function getDeferredResolveExecEnvPreparedState(
params: ExecToolArgs,
): DeferredResolveExecEnvPreparedState | undefined {
return deferredResolveExecEnvPreparedStates.get(params);
}
function markResolvedExecWorkdirPrepared<T extends ExecToolArgs>(
params: T,
state: ResolvedExecWorkdirPreparedState,
@@ -1500,31 +1475,20 @@ export function createExecTool(
if (workdirState?.resolution.kind === "unavailable") {
return params;
}
if (!isExecToolArgsObject(params)) {
return params;
}
if (shouldDeferResolveExecEnvUntilWorkdirValidated(params)) {
return markDeferredResolveExecEnvPrepared(params, {
hookContext: context.hookContext as HookContext | undefined,
});
return params;
}
return prepareParamsWithResolvedExecEnv(params, {
hookContext: context.hookContext as HookContext | undefined,
});
},
finalizeBeforeToolCallParams: (params, preparedParams) => {
const execParams = params as ExecToolArgs;
const envState = getResolvedExecEnvPreparedState(preparedParams as ExecToolArgs);
const deferredEnvState = getDeferredResolveExecEnvPreparedState(
preparedParams as ExecToolArgs,
);
const workdirState = getResolvedExecWorkdirPreparedState(preparedParams as ExecToolArgs);
if (!envState && !deferredEnvState && !workdirState) {
if (!envState && !workdirState) {
return params;
}
if (!isExecToolArgsObject(params)) {
return params;
}
const execParams = params;
let host: ExecHost | undefined;
const resolveFinalHost = () => {
host ??= resolveHostForParams(execParams);
@@ -1547,9 +1511,6 @@ export function createExecTool(
if (envState) {
markResolveExecEnvPrepared(execParams, envState);
}
if (deferredEnvState) {
markDeferredResolveExecEnvPrepared(execParams, deferredEnvState);
}
if (workdirState) {
markResolvedExecWorkdirPrepared(execParams, workdirState);
}
@@ -1561,7 +1522,6 @@ export function createExecTool(
XML_ARG_VALUE_EXEC_PARAM_KEYS,
);
const resolveExecEnvPrepared = isResolveExecEnvPrepared(args as ExecToolArgs);
const deferredResolveExecEnvState = getDeferredResolveExecEnvPreparedState(params);
const preparedWorkdirState = getResolvedExecWorkdirPreparedState(params);
const maxOutput = DEFAULT_MAX_OUTPUT;
@@ -1764,9 +1724,7 @@ export function createExecTool(
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
}
if (!resolveExecEnvPrepared) {
params = await prepareParamsWithResolvedExecEnv(params, {
hookContext: deferredResolveExecEnvState?.hookContext,
});
params = await prepareParamsWithResolvedExecEnv(params);
}
const inheritedBaseEnv = coerceEnv(process.env);

View File

@@ -0,0 +1,49 @@
import type { AnyAgentTool } from "./tools/common.js";
export type BeforeToolCallDiagnosticOptions = {
emitDiagnostics: boolean;
};
export const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
export const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
export const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool");
export const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext");
/** Return true when a tool already carries the before_tool_call wrapper marker. */
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
const taggedTool = tool as unknown as Record<symbol, unknown>;
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */
export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void {
const taggedTool = tool as unknown as Record<symbol, unknown>;
const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS];
if (options && typeof options === "object" && "emitDiagnostics" in options) {
(options as BeforeToolCallDiagnosticOptions).emitDiagnostics = enabled;
}
}
/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
if (!isToolWrappedWithBeforeToolCallHook(source)) {
return;
}
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
const taggedSource = source as unknown as Record<symbol, unknown>;
const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL];
if (sourceTool && typeof sourceTool === "object") {
Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, {
value: sourceTool,
enumerable: false,
});
}
const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT];
Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, {
value: hookContext,
enumerable: false,
});
}

View File

@@ -43,6 +43,7 @@ import { createAgentsListTool } from "./tools/agents-list-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js";
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import {
createCreateGoalTool,
@@ -576,10 +577,17 @@ export function createOpenClawTools(
options?.recordToolPrepStage?.("openclaw-tools:plugin-tools");
}
if (options?.wrapBeforeToolCallHook === false) {
return allTools;
}
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
const gatewayCallerIdentity =
hookAgentId && options?.agentSessionKey?.trim()
? { agentId: hookAgentId, sessionKey: options.agentSessionKey.trim() }
: undefined;
const wrapGatewayCallerIdentity = (tool: AnyAgentTool) =>
wrapToolWithGatewayCallerIdentity(tool, gatewayCallerIdentity);
if (options?.wrapBeforeToolCallHook === false) {
return allTools.map(wrapGatewayCallerIdentity);
}
const defaultHookContext: HookContext = {
...(hookAgentId ? { agentId: hookAgentId } : {}),
...(resolvedConfig ? { config: resolvedConfig } : {}),
@@ -593,11 +601,13 @@ export function createOpenClawTools(
...options?.beforeToolCallHookContext,
};
options?.recordToolPrepStage?.("openclaw-tools:tool-hooks");
return allTools.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, hookContext),
);
return allTools
.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, hookContext),
)
.map(wrapGatewayCallerIdentity);
}
export const testing = {

View File

@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { copyPluginToolMeta } from "../../plugins/tools.js";
import { copyBeforeToolCallHookMarker } from "../agent-tools.before-tool-call.js";
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "../channel-tools.js";
import {
logProviderToolSchemaDiagnostics,

View File

@@ -53,6 +53,7 @@ export {
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,
VALIDATE_REMOTE_WORKDIR_SCRIPT,
} from "./sandbox/ssh.js";
export { sanitizeEnvVars } from "./sandbox/sanitize-env-vars.js";
export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js";

View File

@@ -6,9 +6,13 @@ const { callGatewayToolMock } = vi.hoisted(() => ({
callGatewayToolMock: vi.fn(),
}));
vi.mock("../agent-scope.js", () => ({
resolveSessionAgentId: () => "agent-123",
}));
vi.mock("../agent-scope.js", async () => {
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
return {
...actual,
resolveSessionAgentId: actual.resolveSessionAgentId,
};
});
import { getToolTerminalPresentation } from "../tool-terminal-presentation.js";
import { createCronTool } from "./cron-tool.js";

View File

@@ -11,7 +11,7 @@ vi.mock("../agent-scope.js", async () => {
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
return {
...actual,
resolveSessionAgentId: () => "agent-123",
resolveSessionAgentId: actual.resolveSessionAgentId,
};
});
@@ -182,7 +182,10 @@ describe("cron tool", () => {
it("allows scoped isolated cron runs to remove the current job", async () => {
// Self-removal scope lets a cron-triggered run clean up its own schedule
// without granting broad cron mutation access.
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
await tool.execute("call-self-remove", {
action: "remove",
@@ -194,7 +197,10 @@ describe("cron tool", () => {
});
it("denies scoped isolated cron runs from removing another job", async () => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
await expect(
tool.execute("call-remove-other", {
@@ -215,7 +221,10 @@ describe("cron tool", () => {
hasMore: false,
nextOffset: null,
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-self-runs", {
action: "runs",
@@ -238,7 +247,10 @@ describe("cron tool", () => {
["another job", { action: "runs", jobId: "job-other" }],
["missing job id", { action: "runs" }],
])("denies scoped isolated cron runs from reading %s run history", async (_label, args) => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
"Cron tool is restricted to the current cron job.",
@@ -281,7 +293,10 @@ describe("cron tool", () => {
it("allows scoped isolated cron runs to get the current job", async () => {
callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" });
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const result = await tool.execute("call-get", {
action: "get",
@@ -329,7 +344,6 @@ describe("cron tool", () => {
const result = await tool.execute("call-list", {
action: "list",
agentId: "other-agent",
includeDisabled: true,
});
@@ -448,22 +462,44 @@ describe("cron tool", () => {
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({ includeDisabled: false, compact: true, agentId: "agent-123" });
expect(params).toEqual({
includeDisabled: false,
compact: true,
agentId: "agent-123",
});
});
it("prefers explicit cron list agent id over the requester session", async () => {
it("rejects explicit cron list agent id outside the requester session", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-list-explicit", {
await expect(
tool.execute("call-list-explicit", {
action: "list",
agentId: "ops",
includeDisabled: true,
}),
).rejects.toThrow("cron list agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves explicit agentId for sessionless cron list callers", async () => {
const tool = createTestCronTool();
await tool.execute("call-sessionless-list", {
action: "list",
agentId: "ops",
agentId: "worker",
includeDisabled: true,
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({ includeDisabled: true, compact: true, agentId: "ops" });
expect(params).toEqual({
includeDisabled: true,
compact: true,
agentId: "worker",
});
});
it("retries cron.list without compact for older gateways", async () => {
@@ -483,11 +519,18 @@ describe("cron tool", () => {
expect(readGatewayCall(0)).toEqual({
method: "cron.list",
params: { includeDisabled: false, compact: true, agentId: "agent-123" },
params: {
includeDisabled: false,
compact: true,
agentId: "agent-123",
},
});
expect(readGatewayCall(1)).toEqual({
method: "cron.list",
params: { includeDisabled: false, agentId: "agent-123" },
params: {
includeDisabled: false,
agentId: "agent-123",
},
});
});
@@ -744,7 +787,10 @@ describe("cron tool", () => {
id: "job-legacy",
});
expect(readGatewayCall().params).toEqual({ id: "job-primary", mode: "due" });
expect(readGatewayCall().params).toEqual({
id: "job-primary",
mode: "due",
});
});
it("supports due-only run mode", async () => {
@@ -755,7 +801,10 @@ describe("cron tool", () => {
runMode: "due",
});
expect(readGatewayCall().params).toEqual({ id: "job-due", mode: "due" });
expect(readGatewayCall().params).toEqual({
id: "job-due",
mode: "due",
});
});
it("supports force run mode", async () => {
@@ -766,7 +815,10 @@ describe("cron tool", () => {
runMode: "force",
});
expect(readGatewayCall().params).toEqual({ id: "job-force", mode: "force" });
expect(readGatewayCall().params).toEqual({
id: "job-force",
mode: "force",
});
});
it("normalizes cron.add job payloads", async () => {
@@ -794,18 +846,43 @@ describe("cron tool", () => {
});
});
it("does not default agentId when job.agentId is null", async () => {
it("rejects null agentId on add from the scoped agent cron tool", async () => {
const tool = createTestCronTool({ agentSessionKey: "main" });
await tool.execute("call-null", {
await expect(
tool.execute("call-null", {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
agentId: null,
},
}),
).rejects.toThrow("cron job agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves explicit agentId for sessionless cron add callers", async () => {
const tool = createTestCronTool();
await tool.execute("call-sessionless-add", {
action: "add",
job: {
name: "wake-up",
name: "worker job",
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "agentTurn", message: "hello" },
agentId: "worker",
},
});
expect(readGatewayCall().params?.agentId).toBeNull();
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toMatchObject({
name: "worker job",
agentId: "worker",
payload: { kind: "agentTurn", message: "hello" },
});
expect(params).not.toHaveProperty("callerScope");
});
it("infers session agentId when job.agentId is omitted", async () => {
@@ -828,6 +905,71 @@ describe("cron tool", () => {
).resolves.toBe("agent-123");
});
it("accepts matching explicit agentId on add", async () => {
await expect(
executeAddAndReadAgentId({
callId: "call-matching-agent-id",
agentSessionKey: "agent:agent-123:telegram:direct:channing",
includeAgentId: true,
agentId: "agent-123",
}),
).resolves.toBe("agent-123");
});
it("rejects foreign explicit agentId on add", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-foreign-agent-id", {
action: "add",
job: {
name: "foreign",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
agentId: "worker",
},
}),
).rejects.toThrow("cron job agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects foreign agent-prefixed session refs on add", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-foreign-session-ref", {
action: "add",
job: {
name: "foreign session",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
sessionTarget: "session:agent:worker:telegram:direct:alice",
},
}),
).rejects.toThrow("cron sessionTarget must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not forward model-supplied callerScope", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-spoofed-caller-scope", {
action: "remove",
jobId: "job-1",
callerScope: { kind: "agentTool", agentId: "worker" },
});
expect(readGatewayCall().params).toEqual({
id: "job-1",
});
});
it("passes through failureAlert=false for add", async () => {
const tool = createTestCronTool();
await tool.execute("call-disable-alerts-add", {
@@ -1231,23 +1373,23 @@ describe("cron tool", () => {
expect(text).not.toContain("Recent context:");
});
it("preserves explicit agentId null on add", async () => {
it("rejects explicit agentId null on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ agentSessionKey: "main" });
await tool.execute("call6", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
await expect(
tool.execute("call6", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
}),
).rejects.toThrow("cron job agentId must match the calling agent");
const call = readGatewayCall();
expect(call.method).toBe("cron.add");
expect(call.params?.agentId).toBeNull();
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not infer delivery from raw session-key fragments without delivery context", async () => {
@@ -1767,6 +1909,55 @@ describe("cron tool", () => {
});
});
it("rejects agentId retargeting on update", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-update-agent-id", {
action: "update",
id: "job-1",
patch: { agentId: "worker" },
}),
).rejects.toThrow("cron patch agentId cannot be changed");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows unscoped operator cron.update agentId retargeting", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-unscoped-update-agent-id", {
action: "update",
id: "job-1",
patch: { agentId: "worker" },
});
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { agentId?: string } }
| undefined;
expect(params).toEqual({
id: "job-1",
patch: { agentId: "worker" },
});
});
it("rejects foreign sessionTarget retargeting on update", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-update-session-target", {
action: "update",
id: "job-1",
patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" },
}),
).rejects.toThrow("cron sessionTarget must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("recovers additional flat patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });

Some files were not shown because too many files have changed in this diff Show More