Skip to content

Commit 73ade32

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/pr109591-refresh
2 parents e29f737 + ff9b291 commit 73ade32

63 files changed

Lines changed: 3110 additions & 374 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2999,6 +2999,40 @@ jobs:
29992999
# workflow-owned as before. Load them from the workflow revision so old targets work.
30003000
uses: ./.ci-workflow/.github/actions/setup-android-toolchain
30013001

3002+
# Same-repo runs carry the Gradle user home (dependency, wrapper, and
3003+
# build caches) on a Blacksmith sticky disk: the setup-java gradle cache
3004+
# above is evicted so quickly under the repo quota that it rarely
3005+
# restores, leaving every run to re-resolve the full dependency graph.
3006+
# Fork PRs and manual dispatches must never produce writable
3007+
# repository-global snapshots, so they keep the GitHub cache path (their
3008+
# runs-on already selects non-Blacksmith ubuntu-24.04 runners).
3009+
- name: Mount Gradle sticky disk
3010+
if: github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
3011+
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
3012+
with:
3013+
# Task-scoped keys keep one semantic writer per snapshot: each matrix
3014+
# task resolves a different dependency set, and a shared if-missing
3015+
# key would let a light task (ktlint) win the cold-key race and pin a
3016+
# thin snapshot the heavy build lanes can never repair. Gradle's own
3017+
# caches are content-addressed, so stale restored entries are ignored
3018+
# and dependency-file changes mint a fresh key/snapshot.
3019+
key: ${{ github.repository }}-gradle-v1-${{ matrix.task }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ hashFiles('apps/android/**/*.gradle*', 'apps/android/**/gradle-wrapper.properties', 'apps/android/gradle/libs.versions.toml') }}
3020+
path: /var/tmp/openclaw-gradle
3021+
# on-change keeps the snapshot learning: Gradle's build cache
3022+
# accumulates across runs (if-missing would freeze the first
3023+
# hydration forever). Entries are content-addressed, task-scoped
3024+
# keys give one semantic writer, and v1.4.0 still skips commit
3025+
# after failed/cancelled steps.
3026+
commit: on-change
3027+
3028+
- name: Point Gradle at the sticky disk
3029+
if: github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
3030+
shell: bash
3031+
run: |
3032+
set -euo pipefail
3033+
mkdir -p /var/tmp/openclaw-gradle/gradle-user-home
3034+
echo "GRADLE_USER_HOME=/var/tmp/openclaw-gradle/gradle-user-home" >> "$GITHUB_ENV"
3035+
30023036
- name: Run Android ${{ matrix.task }}
30033037
working-directory: apps/android
30043038
env:

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
4242

4343
### Fixes
4444

45+
- **Gateway control-plane rate limiting:** use per-method buckets with a 30-per-minute budget so interactive admin writes remain responsive while retaining runaway-loop protection.
4546
- **External supervisor restart health:** accept device-identity policy closes only when the replacement gateway lock and listener PID agree, preventing OCM-managed restarts from timing out after a successful handoff. Thanks @shakkernerd.
4647
- **ACPX cleanup process inspection:** bound host process-table reads so stalled `ps` calls cannot hang gateway startup or session cleanup while retaining fail-closed ownership checks. Thanks @Alix-007.
4748
- **Cron lifecycle conflict retries:** preserve execution-phase retry decisions across scheduled, manual, and startup-recovered runs so post-execution claim conflicts cannot replay completed messages or tools. Fixes #108428. Thanks @yetval.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12473,6 +12473,7 @@ public struct PluginCatalogEntry: Codable, Sendable {
1247312473
public let state: AnyCodable
1247412474
public let featured: Bool?
1247512475
public let order: Double?
12476+
public let hasicon: Bool?
1247612477
public let install: PluginCatalogInstallAction?
1247712478
public let error: String?
1247812479
public let category: String?
@@ -12491,6 +12492,7 @@ public struct PluginCatalogEntry: Codable, Sendable {
1249112492
state: AnyCodable,
1249212493
featured: Bool? = nil,
1249312494
order: Double? = nil,
12495+
hasicon: Bool? = nil,
1249412496
install: PluginCatalogInstallAction? = nil,
1249512497
error: String? = nil,
1249612498
category: String? = nil,
@@ -12508,6 +12510,7 @@ public struct PluginCatalogEntry: Codable, Sendable {
1250812510
self.state = state
1250912511
self.featured = featured
1251012512
self.order = order
12513+
self.hasicon = hasicon
1251112514
self.install = install
1251212515
self.error = error
1251312516
self.category = category
@@ -12527,6 +12530,7 @@ public struct PluginCatalogEntry: Codable, Sendable {
1252712530
case state
1252812531
case featured
1252912532
case order
12533+
case hasicon = "hasIcon"
1253012534
case install
1253112535
case error
1253212536
case category
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
e53aad267b77bc3ae033e271e3501ece9e8e4113d243ea9b0740e627bb2acf26 plugin-sdk-api-baseline.json
2-
e73d9ab6836d36fdfe47452e3f0337d5b15577e395cedb152fcb843928c3cd72 plugin-sdk-api-baseline.jsonl
1+
4de3095c7c71d6ed2f1744a73373e9b868934a81a1921d7245fae7e64725fb73 plugin-sdk-api-baseline.json
2+
0cf729db6b115dc8b1cb98a595efeaa5febe4b90d424f5774400176b71561423 plugin-sdk-api-baseline.jsonl

docs/reference/code-mode.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,13 @@ type ToolCatalogEntry = {
385385
description: string;
386386
source: "openclaw" | "mcp" | "client";
387387
sourceName?: string;
388+
input: string;
388389
};
389390
```
390391

392+
`input` is a bounded TypeScript-style signature for the common case. Use
393+
`tools.describe(...)` when the exact full schema is still needed.
394+
391395
Plugin tools use `source: "openclaw"` with `sourceName` set to the owning
392396
plugin id; there is no separate `"plugin"` source value. `source: "mcp"` is
393397
used only for MCP entries in `sourceName`/`mcp` metadata (and is filtered out
@@ -407,6 +411,7 @@ Catalog helpers:
407411
type ToolCatalog = {
408412
search(query: string, options?: { limit?: number }): Promise<ToolCatalogEntry[]>;
409413
describe(id: string): Promise<ToolCatalogEntryWithSchema>;
414+
callValue(id: string, input?: unknown): Promise<unknown>;
410415
call(id: string, input?: unknown): Promise<unknown>;
411416
[safeToolName: string]: unknown;
412417
};
@@ -417,17 +422,21 @@ Convenience tool functions are installed only for unambiguous safe names:
417422
```typescript
418423
const files = await tools.search("read local file");
419424
const fileRead = await tools.describe(files[0].id);
420-
const content = await tools.call(fileRead.id, { path: "README.md" });
425+
const content = await tools.callValue(fileRead.id, { path: "README.md" });
421426

422427
// If the hidden catalog has an unambiguous `web_search` entry:
423428
const hits = await tools.web_search({ query: "OpenClaw code mode" });
424429
```
425430

426-
MCP catalog entries are not callable through `tools.call(...)` or convenience
427-
functions in code mode; they are exposed only through the generated `MCP`
428-
namespace. TypeScript-style declaration files are available through the
429-
read-only `API` virtual file surface, so agents can inspect MCP signatures
430-
without adding MCP schemas to the prompt:
431+
`tools.callValue(...)` returns a normal tool's JSON `details` value directly.
432+
`tools.call(...)` preserves the raw `{ tool, result }` envelope for callers
433+
that need content blocks or other result metadata.
434+
435+
MCP catalog entries are not callable through `tools.callValue(...)`,
436+
`tools.call(...)`, or convenience functions in code mode; they are exposed
437+
only through the generated `MCP` namespace. TypeScript-style declaration files
438+
are available through the read-only `API` virtual file surface, so agents can
439+
inspect MCP signatures without adding MCP schemas to the prompt:
431440

432441
```typescript
433442
const files = await API.list("mcp");
@@ -731,7 +740,7 @@ because their structured results cannot cross the QuickJS bridge.
731740
MCP entries stay in the run-scoped catalog so policy, approvals, hooks,
732741
telemetry, transcript projection, and exact tool ids remain shared with
733742
normal tool execution. The guest-facing `ALL_TOOLS`, `tools.search(...)`,
734-
`tools.describe(...)`, and `tools.call(...)` views omit MCP entries. The
743+
`tools.describe(...)`, `tools.callValue(...)`, and `tools.call(...)` views omit MCP entries. The
735744
generated `MCP.<server>.<tool>({ ...input })` namespace resolves back to the
736745
exact catalog id and dispatches through the same executor path.
737746

@@ -944,7 +953,7 @@ Code mode coverage should prove:
944953
- all catalog-eligible effective non-MCP tools appear in `ALL_TOOLS`
945954
- direct-only tools stay model-visible and do not appear in `ALL_TOOLS`
946955
- denied tools do not appear in `ALL_TOOLS`
947-
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
956+
- `tools.search`, `tools.describe`, `tools.callValue`, and `tools.call` work for OpenClaw tools
948957
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
949958
MCP declarations without a bridge/tool call
950959
- MCP namespace `$api()` remains available as an inline fallback for schemas
@@ -981,7 +990,7 @@ Run these as integration or end-to-end tests when changing the runtime:
981990
7. In `exec`, read `ALL_TOOLS` and assert the catalog-eligible effective test
982991
tools are present while direct-only tools are absent.
983992
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
984-
`tools.describe`, and `tools.call`.
993+
`tools.describe`, and `tools.callValue` (or raw `tools.call`).
985994
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
986995
assert the declaration files describe visible MCP tools.
987996
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and

docs/tools/tool-search.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ client-provided app tools.
118118
`openclaw.tools.search(query, options?)`
119119

120120
Searches the effective catalog for the current run. Results are compact and safe
121-
to put back into prompt context.
121+
to put back into prompt context. Each hit includes a bounded TypeScript-style
122+
`input` signature, such as `{ id: string; mode?: "drip" | "flood" }`, so the
123+
model can skip `describe` when that signature is sufficient.
122124

123125
```js
124126
const hits = await openclaw.tools.search("calendar event", { limit: 5 });
@@ -134,7 +136,9 @@ const calendarCreate = await openclaw.tools.describe("mcp:calendar:create_event"
134136

135137
`openclaw.tools.call(id, args)`
136138

137-
Calls a selected tool through OpenClaw.
139+
Calls a selected tool through OpenClaw and returns the raw `{ tool, result }`
140+
envelope. JSON-returning tools normally place their value in
141+
`result.details`.
138142

139143
```js
140144
await openclaw.tools.call(calendarCreate.id, {

extensions/codex/src/app-server/attempt-startup.test.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -466,13 +466,15 @@ describe("startCodexAttemptThread", () => {
466466
});
467467

468468
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
469+
vi.useFakeTimers();
469470
const { harness, run } = startThreadWithHarness(500);
470471
const runError = run.then(
471472
() => undefined,
472473
(error: unknown) => error,
473474
);
474475
await answerInitialize(harness);
475476
await waitForThreadStart(harness);
477+
await vi.advanceTimersByTimeAsync(500);
476478

477479
const error = await runError;
478480
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
@@ -514,6 +516,7 @@ describe("startCodexAttemptThread", () => {
514516
});
515517

516518
it("closes the shared app-server when startup times out during initialize", async () => {
519+
vi.useFakeTimers();
517520
const initializeTimeoutPluginConfig = {
518521
...pluginConfig,
519522
appServer: { command: "codex", requestTimeoutMs: 1_000 },
@@ -528,6 +531,7 @@ describe("startCodexAttemptThread", () => {
528531

529532
const initialize = await waitForRequest(harness, "initialize");
530533
expect(initialize.id).toBeDefined();
534+
await vi.advanceTimersByTimeAsync(1_000);
531535

532536
const error = await runError;
533537
expect(error).toBeInstanceOf(Error);
@@ -678,6 +682,7 @@ describe("startCodexAttemptThread", () => {
678682
});
679683

680684
it("closes a startup client that arrives after startup timeout", async () => {
685+
vi.useFakeTimers();
681686
let observedFactoryOptions:
682687
| {
683688
onStartedClient?: (client: CodexAppServerClient) => void;
@@ -690,6 +695,10 @@ describe("startCodexAttemptThread", () => {
690695
const factoryDone = new Promise<void>((resolve) => {
691696
resolveFactoryDone = resolve;
692697
});
698+
let releaseFactory: () => void = () => {};
699+
const factoryRelease = new Promise<void>((resolve) => {
700+
releaseFactory = resolve;
701+
});
693702
const delayedFactoryPluginConfig = {
694703
...pluginConfig,
695704
appServer: { command: "codex", requestTimeoutMs: 2_500 },
@@ -700,9 +709,7 @@ describe("startCodexAttemptThread", () => {
700709
try {
701710
factoryCalls += 1;
702711
observedFactoryOptions = options;
703-
await new Promise<void>((resolve) => {
704-
setTimeout(resolve, 250);
705-
});
712+
await factoryRelease;
706713
options?.onStartedClient?.(factoryHarness.client);
707714
return factoryHarness.client;
708715
} finally {
@@ -711,9 +718,15 @@ describe("startCodexAttemptThread", () => {
711718
},
712719
});
713720
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
721+
await vi.waitFor(() => expect(factoryCalls).toBe(1), { interval: 1 });
722+
await vi.advanceTimersByTimeAsync(100);
714723

715-
await rejected;
716-
await factoryDone;
724+
try {
725+
await rejected;
726+
} finally {
727+
releaseFactory();
728+
await factoryDone;
729+
}
717730
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
718731
interval: 1,
719732
timeout: 2_000,
@@ -787,6 +800,7 @@ describe("startCodexAttemptThread", () => {
787800
});
788801

789802
it("clears the shared app-server when a startup RPC times out", async () => {
803+
vi.useFakeTimers();
790804
const perRpcTimeoutPluginConfig = {
791805
...pluginConfig,
792806
appServer: { command: "codex", requestTimeoutMs: 1_000 },
@@ -801,6 +815,7 @@ describe("startCodexAttemptThread", () => {
801815
);
802816
await answerInitialize(harness);
803817
await waitForRequest(harness, "plugin/list");
818+
await vi.advanceTimersByTimeAsync(1_000);
804819

805820
const error = await runError;
806821
expect(error).toBeInstanceOf(Error);

extensions/discord/src/monitor/allow-list.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// Discord plugin module implements allow list behavior.
2-
import type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from";
2+
import {
3+
type AllowlistMatch,
4+
resolveAllowlistMatchByCandidates,
5+
} from "openclaw/plugin-sdk/allow-from";
36
import {
47
buildChannelKeyCandidates,
58
resolveChannelEntryMatchWithFallback,
@@ -604,13 +607,14 @@ export function resolveGroupDmAllow(params: {
604607
if (!channels || channels.length === 0) {
605608
return true;
606609
}
607-
const allowList = new Set(channels.map((entry) => normalizeDiscordSlug(entry)));
608-
const candidates = [
609-
normalizeDiscordSlug(channelId),
610-
channelSlug,
611-
channelName ? normalizeDiscordSlug(channelName) : "",
612-
].filter(Boolean);
613-
return allowList.has("*") || candidates.some((candidate) => allowList.has(candidate));
610+
return resolveAllowlistMatchByCandidates({
611+
allowList: channels.map((entry) => normalizeDiscordSlug(entry)),
612+
candidates: [
613+
{ value: normalizeDiscordSlug(channelId), source: "id" },
614+
{ value: channelSlug, source: "slug" },
615+
{ value: channelName ? normalizeDiscordSlug(channelName) : undefined, source: "name" },
616+
],
617+
}).allowed;
614618
}
615619

616620
export function shouldEmitDiscordReactionNotification(params: {

extensions/discord/src/monitor/inbound-context.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Discord plugin module implements inbound context behavior.
2+
import { resolveInboundSupplementalSenderAllowed } from "openclaw/plugin-sdk/channel-inbound";
23
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
34
import {
45
resolveDiscordMemberAllowed,
@@ -20,18 +21,24 @@ export function createDiscordSupplementalContextAccessChecker(params: {
2021
allowNameMatching?: boolean;
2122
isGuild: boolean;
2223
}) {
24+
const userAllowList = params.channelConfig?.users ?? params.guildInfo?.users ?? [];
25+
const roleAllowList = params.channelConfig?.roles ?? params.guildInfo?.roles ?? [];
26+
const allowFrom = [...userAllowList, ...roleAllowList];
2327
return (sender: DiscordSupplementalContextSender): boolean => {
24-
if (!params.isGuild) {
25-
return true;
26-
}
27-
return resolveDiscordMemberAllowed({
28-
userAllowList: params.channelConfig?.users ?? params.guildInfo?.users,
29-
roleAllowList: params.channelConfig?.roles ?? params.guildInfo?.roles,
30-
memberRoleIds: sender.memberRoleIds ?? [],
31-
userId: sender.id ?? "",
32-
userName: sender.name,
33-
userTag: sender.tag,
34-
allowNameMatching: params.allowNameMatching,
28+
return resolveInboundSupplementalSenderAllowed({
29+
isGroup: params.isGuild,
30+
groupPolicy: allowFrom.length === 0 ? "open" : "allowlist",
31+
allowFrom,
32+
isSenderAllowed: () =>
33+
resolveDiscordMemberAllowed({
34+
userAllowList,
35+
roleAllowList,
36+
memberRoleIds: sender.memberRoleIds ?? [],
37+
userId: sender.id ?? "",
38+
userName: sender.name,
39+
userTag: sender.tag,
40+
allowNameMatching: params.allowNameMatching,
41+
}),
3542
});
3643
};
3744
}

extensions/discord/src/normalize.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Discord helper module supports normalize behavior.
2+
import { resolveAllowlistMatchByCandidates } from "openclaw/plugin-sdk/allow-from";
23
import { parseDiscordTarget } from "./target-parsing.js";
34

45
export function normalizeDiscordMessagingTarget(raw: string): string | undefined {
@@ -43,9 +44,13 @@ export function allowFromContainsDiscordUserId(
4344
if (!normalizedUserId) {
4445
return false;
4546
}
46-
return (allowFrom ?? []).some(
47-
(entry) => normalizeAllowFromDiscordUserId(entry) === normalizedUserId,
48-
);
47+
const normalizedAllowFrom = (allowFrom ?? [])
48+
.map(normalizeAllowFromDiscordUserId)
49+
.filter((entry): entry is string => Boolean(entry));
50+
return resolveAllowlistMatchByCandidates({
51+
allowList: normalizedAllowFrom,
52+
candidates: [{ value: normalizedUserId, source: "id" }],
53+
}).allowed;
4954
}
5055

5156
function normalizeAllowFromDiscordUserId(entry: string): string | undefined {

0 commit comments

Comments
 (0)