Skip to content

Commit baf68eb

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/openrouter-usage-accounting-fidelity
2 parents 8d18cb5 + 431da14 commit baf68eb

58 files changed

Lines changed: 1632 additions & 241 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.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- **Control UI who's-online roster:** click the sidebar footer facepile to open a scrollable roster of everyone online, showing each person's avatar, name, and email with your own entry pinned first.
910
- **Discord and Slack native login:** register `/login` in native command menus while keeping pairing-code issuance limited to private chats and the Web UI.
1011
- **Control UI user profiles:** let trusted-proxy users manage their own display name and avatar, resolve attributed chat and presence identities through uploaded avatars or a private cached Gravatar proxy, and keep other users' profiles admin-only.
1112
- **Trusted-proxy browser pairing:** optionally auto-approve new Control UI and WebChat devices from allowlisted proxy identities with non-admin scope caps, while keeping existing-device upgrades manual.
@@ -46,6 +47,7 @@ Docs: https://docs.openclaw.ai
4647

4748
### Fixes
4849

50+
- **Standalone plugin files:** let manifestless files explicitly listed in `plugins.load.paths` pass config validation and load independently when several files share a directory.
4951
- **Control UI terminal error messages:** preserve message-only assistant output beginning with `Error:` or a warning marker instead of treating text prefixes as synthetic failures. Thanks @shakkernerd.
5052
- **Channel outbound echo suppression:** drop recently emitted platform message and source identities at shared inbound admission and migrate Discord thread unbinds off channel-local expiry state, preventing delayed webhook copies from re-entering agents.
5153
- **Reef startup reconciliation:** contain retryable relay failures during startup without supervisor restart loops, while preserving definitive-error and cancellation handling. Thanks @Yigtwxx.

docs/gateway/trusted-proxy-auth.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Internal Gateway clients that do not travel through the reverse proxy should use
124124
Automatically approve new Control UI and WebChat device identities after trusted-proxy authentication.
125125
</ParamField>
126126
<ParamField path="gateway.auth.trustedProxy.deviceAutoApprove.scopes" type="string[]" default='["operator.read", "operator.write", "operator.approvals"]'>
127-
Maximum scopes granted to an auto-approved browser device. `operator.admin` is not allowed.
127+
Maximum scopes granted to an auto-approved browser device. Explicitly listing `operator.admin` lets every proxy-authenticated user request an automatic full-admin device grant, makes scope-less requests receive full admin automatically, and triggers the CRITICAL `gateway.trusted_proxy_device_auto_approve_admin` security audit finding plus a Gateway startup warning.
128128
</ParamField>
129129

130130
<Warning>
@@ -158,10 +158,10 @@ The default is `enabled: false`. When enabled, all of these rules apply:
158158
1. The WebSocket must have authenticated through the `trusted-proxy` method with a non-empty user identity that passed `allowUsers` when an allowlist is configured. Token, password, Tailscale, and unauthenticated connections never use this policy.
159159
2. Only a new Control UI or WebChat browser device can be approved automatically. Any request for an existing device, including a scope upgrade, remains pending for manual approval with `openclaw devices approve <requestId>`.
160160
3. The device is approved with role `operator`. If the connect request includes scopes, the grant is the exact intersection of the requested scopes and `deviceAutoApprove.scopes`. If the request omits scopes, the configured list is granted; when that list is omitted, it defaults to `operator.read`, `operator.write`, and `operator.approvals`. The resulting grant is then additionally capped by the connection's [`x-openclaw-scopes`](#control-ui-pairing-behavior) proxy header when present, so a proxy that narrows a user's scopes also limits the **persistent** device grant, not just the session — a present-but-empty header yields no scopes. This cap applies even when the client omits its own scope list.
161-
4. `operator.admin` cannot appear in `deviceAutoApprove.scopes`; configuration validation rejects it. Grant admin access manually with `openclaw devices approve` or `openclaw devices rotate`.
161+
4. `operator.admin` is allowed only through explicit listing in `deviceAutoApprove.scopes`. When listed, every proxy-authenticated user can request and automatically receive full admin on a new browser device; requests without scopes receive full admin automatically. `openclaw security audit` reports the CRITICAL `gateway.trusted_proxy_device_auto_approve_admin` finding, and the Gateway logs a warning once at startup. Prefer manual admin approval with `openclaw devices approve` or `openclaw devices rotate` until per-identity roles are available.
162162

163163
<Warning>
164-
Enabling this option delegates new browser device enrollment entirely to the reverse-proxy identity. A compromised proxy account can enroll a persistent device with every configured scope. Keep the Gateway reachable only through the proxy, require strong proxy authentication, overwrite identity headers, and use a narrow `allowUsers` list.
164+
Enabling this option delegates new browser device enrollment entirely to the reverse-proxy identity. A compromised proxy account can enroll a persistent device with every configured scope. Listing `operator.admin` makes that device a full administrator without manual approval. Keep the Gateway reachable only through the proxy, require strong proxy authentication, overwrite identity headers, and use a narrow `allowUsers` list.
165165
</Warning>
166166

167167
## Control UI pairing behavior

docs/tools/subagents.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ Per-agent override: `agents.list[].subagents.delegationMode`.
221221
<ParamField path="mode" type='"run" | "session"' default="run">
222222
If `thread: true` and `mode` is omitted, default becomes `session`. `mode: "session"` requires `thread: true`.
223223
If thread binding is unavailable for the requester channel, use `mode: "run"` instead.
224+
With `visible: true`, omit `mode`; visible sessions are persistent and do not support `mode: "run"`.
224225
</ParamField>
225226
<ParamField path="cleanup" type='"delete" | "keep"' default="keep">
226227
`"delete"` archives the session immediately after announce (still keeps the transcript via rename).
@@ -251,7 +252,7 @@ their latest assistant turn back to the requester; external delivery stays with
251252
the parent/requester agent.
252253
</Warning>
253254

254-
With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, light bootstrap context, and attachment staging are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. Visible spawning is also rejected when inherited tool restrictions cannot be carried into the dashboard session. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior.
255+
With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. Visible spawning is rejected when the requester was itself spawned with an inherited tool allowlist or denylist; that restriction is fixed at spawn time and has no config override. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior.
255256

256257
### Task names and targeting
257258

docs/tools/swarm.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,16 @@ not validate, the collector completion keeps the child's raw text, leaves
251251
`structured` unset, and includes `schemaError`. The low-level `agents_wait`
252252
result exposes those fields for explicit recovery logic.
253253

254-
Swarm enforces all three group caps before starting more work. Children above
255-
`maxConcurrent` queue FIFO. A spawn that exceeds `maxChildrenPerGroup` or
256-
`maxTotalPerGroup` is rejected with the relevant config key in the error.
254+
Every child has one admission owner. Announce and interactive children use
255+
`agents.defaults.subagents.maxChildrenPerAgent` (default `5`) and do not count
256+
collector children. Collector children use only `maxChildrenPerGroup` and
257+
`maxTotalPerGroup`; they do not consume the per-session child budget. The spawn
258+
depth guard still applies to both modes.
259+
260+
After admission, children above `maxConcurrent` queue FIFO within their swarm
261+
group, nested inside the global sub-agent lane. These concurrency layers queue
262+
work rather than rejecting it. A collector spawn that exceeds either group cap
263+
is rejected with the relevant config key in the error.
257264

258265
## Observe a Swarm
259266

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Qa Lab integration tests cover the real QA Channel runtime contract.
2+
import { qaChannelPlugin, setQaChannelRuntime } from "@openclaw/qa-channel/api.js";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { startQaBusServer } from "./bus-server.js";
5+
import { createQaBusState } from "./bus-state.js";
6+
import { createQaRunnerRuntime } from "./harness-runtime.js";
7+
import { createQaChannelGatewayConfig } from "./qa-channel-transport.js";
8+
9+
describe("QA runner runtime integration", () => {
10+
it("dispatches a QA Channel inbound turn through the embedded runner", async () => {
11+
const state = createQaBusState();
12+
const bus = await startQaBusServer({ state });
13+
const runtime = createQaRunnerRuntime();
14+
setQaChannelRuntime(runtime);
15+
const config = createQaChannelGatewayConfig({ baseUrl: bus.baseUrl });
16+
const account = qaChannelPlugin.config.resolveAccount(config, "default");
17+
const abort = new AbortController();
18+
const startAccount = qaChannelPlugin.gateway?.startAccount;
19+
if (!startAccount) {
20+
throw new Error("QA Channel gateway is unavailable");
21+
}
22+
const gatewayTask = startAccount({
23+
accountId: account.accountId,
24+
account,
25+
cfg: config,
26+
runtime: {
27+
log: () => undefined,
28+
error: () => undefined,
29+
exit: () => undefined,
30+
},
31+
abortSignal: abort.signal,
32+
log: {
33+
info: () => undefined,
34+
warn: () => undefined,
35+
error: () => undefined,
36+
debug: () => undefined,
37+
},
38+
getStatus: () => ({
39+
accountId: account.accountId,
40+
configured: true,
41+
enabled: true,
42+
running: true,
43+
}),
44+
setStatus: () => undefined,
45+
});
46+
47+
try {
48+
state.addInboundMessage({
49+
accountId: "default",
50+
conversation: { kind: "direct", id: "alice" },
51+
senderId: "alice",
52+
senderName: "Alice",
53+
text: "ping",
54+
});
55+
56+
await Promise.race([
57+
vi.waitFor(
58+
() => {
59+
expect(state.getSnapshot().messages).toContainEqual(
60+
expect.objectContaining({ direction: "outbound", text: "qa-echo: ping" }),
61+
);
62+
},
63+
{ interval: 25, timeout: 2_000 },
64+
),
65+
gatewayTask.then(() => {
66+
throw new Error("QA Channel gateway stopped before delivering the turn");
67+
}),
68+
]);
69+
} finally {
70+
abort.abort();
71+
try {
72+
await gatewayTask;
73+
} finally {
74+
await bus.stop();
75+
}
76+
}
77+
});
78+
});

extensions/qa-lab/src/harness-runtime.ts

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ type SessionRecord = {
1515

1616
export function createQaRunnerRuntime(): PluginRuntime {
1717
const sessions = new Map<string, SessionRecord>();
18+
const dispatchReplyWithBufferedBlockDispatcher: PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"] =
19+
async ({ ctx, dispatcherOptions }) => {
20+
await dispatcherOptions.deliver(
21+
{
22+
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`,
23+
},
24+
{ kind: "final" },
25+
);
26+
return {
27+
queuedFinal: false,
28+
counts: { tool: 0, block: 0, final: 1 },
29+
};
30+
};
1831
return {
1932
channel: {
2033
routing: {
@@ -73,39 +86,28 @@ export function createQaRunnerRuntime(): PluginRuntime {
7386
finalizeInboundContext(ctx: Record<string, unknown>) {
7487
return ctx as typeof ctx & { CommandAuthorized: boolean };
7588
},
76-
async dispatchReplyWithBufferedBlockDispatcher({
77-
ctx,
78-
dispatcherOptions,
79-
}: {
80-
ctx: { BodyForAgent?: string; Body?: string };
81-
dispatcherOptions: { deliver: (payload: { text: string }) => Promise<void> };
82-
}) {
83-
await dispatcherOptions.deliver({
84-
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`,
85-
});
86-
},
89+
dispatchReplyWithBufferedBlockDispatcher,
8790
},
8891
inbound: {
89-
async dispatchReply(
90-
params: Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0],
91-
) {
92+
async dispatch(params: Parameters<PluginRuntime["channel"]["inbound"]["dispatch"]>[0]) {
9293
const sessionKey =
9394
typeof params.ctxPayload.SessionKey === "string"
9495
? params.ctxPayload.SessionKey
95-
: params.routeSessionKey;
96-
await params.recordInboundSession({
97-
storePath: params.storePath,
96+
: params.route.sessionKey;
97+
sessions.set(sessionKey, {
9898
sessionKey,
99-
ctx: params.ctxPayload,
100-
onRecordError: params.record?.onRecordError ?? (() => undefined),
99+
body: params.ctxPayload.BodyForAgent ?? params.ctxPayload.Body ?? "",
101100
});
102-
const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({
101+
const delivery =
102+
params.admission?.kind === "observeOnly"
103+
? async () => ({ visibleReplySent: false })
104+
: params.delivery.deliver;
105+
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
103106
ctx: params.ctxPayload,
104107
cfg: params.cfg,
105108
dispatcherOptions: {
106-
...params.dispatcherOptions,
107109
deliver: async (payload, info) => {
108-
await params.delivery.deliver(payload, info);
110+
await delivery(payload, info);
109111
},
110112
onError: params.delivery.onError,
111113
},
@@ -116,7 +118,7 @@ export function createQaRunnerRuntime(): PluginRuntime {
116118
admission: params.admission ?? { kind: "dispatch" },
117119
dispatched: true,
118120
ctxPayload: params.ctxPayload,
119-
routeSessionKey: params.routeSessionKey,
121+
routeSessionKey: params.route.sessionKey,
120122
dispatchResult,
121123
};
122124
},

scripts/postinstall-bundled-plugins.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,10 +823,17 @@ export async function runPluginRegistryPostinstallMigration(params = {}) {
823823
const log = params.log ?? console;
824824
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
825825
const env = params.env ?? process.env;
826+
const pathExists = params.existsSync ?? existsSync;
827+
828+
// Registry migration belongs to installed-package upgrades. Source checkouts
829+
// can contain stale dist from a different build and must not touch operator state.
830+
if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) {
831+
return { status: "skipped", reason: "source-checkout" };
832+
}
826833

827834
try {
828835
const migrationModule = await importInstalledDistModule(
829-
params,
836+
{ ...params, existsSync: pathExists },
830837
"dist/commands/doctor/shared/plugin-registry-migration.js",
831838
);
832839
if (!migrationModule) {

src/agents/child-admission.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export type ChildAdmissionCap =
2+
| "subagents.maxSpawnDepth"
3+
| "subagents.maxChildrenPerAgent"
4+
| "tools.swarm.maxChildrenPerGroup"
5+
| "tools.swarm.maxTotalPerGroup";
6+
7+
type ChildAdmissionResult =
8+
| { ok: true }
9+
| { ok: false; governingCap: ChildAdmissionCap; error: string };
10+
11+
type ChildAdmissionParams = {
12+
callerDepth: number;
13+
maxSpawnDepth: number;
14+
activeChildren: number;
15+
maxActiveChildren: number;
16+
} & ({ collect: false } | { collect: true; totalChildren: number; maxTotalChildren: number });
17+
18+
const rejectChildAdmission = (
19+
governingCap: ChildAdmissionCap,
20+
error: string,
21+
): ChildAdmissionResult => ({ ok: false, governingCap, error });
22+
23+
export function resolveChildAdmission(params: ChildAdmissionParams): ChildAdmissionResult {
24+
if (params.callerDepth >= params.maxSpawnDepth) {
25+
return rejectChildAdmission(
26+
"subagents.maxSpawnDepth",
27+
`sessions_spawn is not allowed at this depth (current depth: ${params.callerDepth}, max: ${params.maxSpawnDepth}; agents.defaults.subagents.maxSpawnDepth).`,
28+
);
29+
}
30+
if (params.collect && params.totalChildren >= params.maxTotalChildren) {
31+
return rejectChildAdmission(
32+
"tools.swarm.maxTotalPerGroup",
33+
`sessions_spawn reached tools.swarm.maxTotalPerGroup (${params.totalChildren}/${params.maxTotalChildren}).`,
34+
);
35+
}
36+
if (params.activeChildren < params.maxActiveChildren) {
37+
return { ok: true };
38+
}
39+
return params.collect
40+
? rejectChildAdmission(
41+
"tools.swarm.maxChildrenPerGroup",
42+
`sessions_spawn reached tools.swarm.maxChildrenPerGroup (${params.activeChildren}/${params.maxActiveChildren}).`,
43+
)
44+
: rejectChildAdmission(
45+
"subagents.maxChildrenPerAgent",
46+
`sessions_spawn has reached max active children for this session (${params.activeChildren}/${params.maxActiveChildren}; agents.defaults.subagents.maxChildrenPerAgent).`,
47+
);
48+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveChildAdmission, type ChildAdmissionCap } from "./child-admission.js";
3+
4+
type AdmissionParams = Parameters<typeof resolveChildAdmission>[0];
5+
type AnnounceAdmissionParams = Extract<AdmissionParams, { collect: false }>;
6+
type CollectorAdmissionParams = Extract<AdmissionParams, { collect: true }>;
7+
8+
const announce = (
9+
overrides: Partial<Omit<AnnounceAdmissionParams, "collect">> = {},
10+
): AnnounceAdmissionParams => ({
11+
collect: false,
12+
callerDepth: 0,
13+
maxSpawnDepth: 2,
14+
activeChildren: 0,
15+
maxActiveChildren: 5,
16+
...overrides,
17+
});
18+
19+
const collector = (
20+
overrides: Partial<Omit<CollectorAdmissionParams, "collect">> = {},
21+
): CollectorAdmissionParams => ({
22+
collect: true,
23+
callerDepth: 0,
24+
maxSpawnDepth: 2,
25+
activeChildren: 0,
26+
maxActiveChildren: 50,
27+
totalChildren: 0,
28+
maxTotalChildren: 200,
29+
...overrides,
30+
});
31+
32+
const cases: Array<{
33+
name: string;
34+
params: AdmissionParams;
35+
governingCap?: ChildAdmissionCap;
36+
}> = [
37+
{ name: "announce below depth", params: announce({ callerDepth: 1 }) },
38+
{
39+
name: "announce at depth",
40+
params: announce({ callerDepth: 2 }),
41+
governingCap: "subagents.maxSpawnDepth",
42+
},
43+
{ name: "collector below depth", params: collector({ callerDepth: 1 }) },
44+
{
45+
name: "collector at depth",
46+
params: collector({ callerDepth: 2 }),
47+
governingCap: "subagents.maxSpawnDepth",
48+
},
49+
{ name: "announce below session cap", params: announce({ activeChildren: 4 }) },
50+
{
51+
name: "announce at session cap",
52+
params: announce({ activeChildren: 5 }),
53+
governingCap: "subagents.maxChildrenPerAgent",
54+
},
55+
{ name: "collector below live group cap", params: collector({ activeChildren: 49 }) },
56+
{
57+
name: "collector at live group cap",
58+
params: collector({ activeChildren: 50 }),
59+
governingCap: "tools.swarm.maxChildrenPerGroup",
60+
},
61+
{ name: "collector below lifetime group cap", params: collector({ totalChildren: 199 }) },
62+
{
63+
name: "collector at lifetime group cap",
64+
params: collector({ totalChildren: 200 }),
65+
governingCap: "tools.swarm.maxTotalPerGroup",
66+
},
67+
];
68+
69+
describe("resolveChildAdmission", () => {
70+
it.each(cases)("decides $name", ({ params, governingCap }) => {
71+
const result = resolveChildAdmission(params);
72+
73+
if (!governingCap) {
74+
expect(result).toEqual({ ok: true });
75+
return;
76+
}
77+
expect(result).toMatchObject({ ok: false, governingCap });
78+
if (!result.ok) {
79+
expect(result.error).toContain(governingCap);
80+
}
81+
});
82+
});

0 commit comments

Comments
 (0)