refactor(agents): move implicit-main fallback into load-time roster injection (#112678)

* refactor(agents): require explicit roster defaults

* feat(onboard): create named first roster agent

* refactor(agents): remove runtime main fallbacks

* style(agents): apply roster refactor formatting

* refactor(agents): finish roster-only runtime sweep

* fix(doctor): migrate legacy main session sqlite

* fix(doctor): harden roster session migrations

* fix(onboard): commit first agent atomically

* fix(config): support empty-roster analysis

* fix(agents): preserve legacy main state during creation

* fix(setup): materialize baseline agent roster

* fix(agents): harden legacy default transfer recovery

* fix(agents): simplify roster-only legacy compatibility

* fix(agents): preserve staged first-agent entries

* fix(config): migrate persisted implicit-main rosters

* fix(config): preserve staged empty rosters

* fix(agents): finalize roster-only upgrade paths

* fix(sessions): close legacy main migration outcomes

* fix(config): migrate legacy roster markers at load

* fix(sessions): preserve roster upgrade history

* refactor(sessions): restore lean legacy main compatibility

* fix(setup): prepare first-agent credentials before publish

* fix(config): stabilize roster snapshot migration

* refactor(sessions): shrink legacy main compatibility

* fix(agents): restore roster compatibility fidelity

* fix(sessions): preserve divergent legacy history

* refactor(agents): narrow roster-only scope

* fix(config): isolate roster migration

* test(agents): align roster-only fixtures

* fix(agents): keep main agent undeletable

* fix(agents): harden roster migration invariants

* fix(agents): close setup and audit scope gaps

* fix(cron): scope session reaper throttles by agent

* fix(agents): preserve scoped owner precedence

* fix(config): preserve authored config ownership

* fix(setup): keep default workspace and roster in sync

* fix(setup): preserve default entry workspace on bare runs

* fix(agents): adapt roster rebase to keyed entries

* fix(agents): honor both roster representations

* fix(agents): route roster reads through shared helpers

* fix(config): preserve canonical roster writes

* fix(cron): resolve dynamic default for session reaper

* fix(agents): close dynamic default migration gaps

* fix(agents): align scoped session ownership

* fix(sessions): preserve legacy main directory casing

* fix(agents): align cron and legacy auth ownership

* fix(setup): provision the committed default workspace

* fix(cron): align scoped ownership and reaping

* fix(cron): treat blank agent ids as absent

* fix(cron): retain configured session-store owners

* fix(agents): repair roster-aware CI boundaries

* fix(cron): preserve scoped ownership resolution

* fix(agents): preserve rosterless maintenance paths

* fix(agents): propagate roster ownership through runtime boundaries

* fix(agents): preserve roster ownership across runtime paths

* fix(agents): harden roster diagnostics and legacy routing

* fix(agents): remove redundant diagnostic import

* test(agents): type CLI policy fixture explicitly

* fix(config): preserve canonical roster mutation identity

* fix(doctor): read canonical agent rosters consistently

* fix(config): resolve compound roster unsets safely

* fix(config): finalize main-session reconciliation

* fix(doctor): read canonical session state safely

* fix(sessions): preserve current visibility alias

* fix(config): track roster include provenance

* test(config): type roster provenance cases

* fix(config): refine roster include ownership

* fix(agents): preserve staged roster invariants

* test(config): align fixtures with explicit roster ownership

* test(node-host): preserve optional plan typing

* fix(config): preserve authored roster projections

* test(config): keep raw roster fixtures explicit

* test(config): normalize rosters at runtime fixtures

* fix(config): protect authored roster ownership

* fix(agents): require explicit session ownership

* fix(agents): enforce scoped roster ownership

* fix(sessions): merge fixed-store agent partitions

* fix(agents): harden roster ownership boundaries

* fix(config): reject ambiguous roster projections

* fix(sessions): preserve persisted store ownership

* fix(sessions): keep collision diagnostics additive

* fix(security): scan malformed roster workspaces

* test(config): align snapshot fixtures after rebase

* test(agents): use explicit roster fixtures

* fix(config): harden roster diagnostic boundaries

* fix(sessions): isolate fixed-store agent databases

* test(agents): type malformed default markers

* refactor(sessions): extract store collision resolution

* test(system-agent): split oversized setup coverage

* style(system-agent): format split setup suite

* fix(sessions): preserve promoted store ownership

* fix(sessions): derive scoped owner before target

* fix(sessions): preserve explicit sqlite ownership

* fix(agents): restore roster compatibility across CI

* fix(agents): enforce roster-owned runtime boundaries

* fix(agents): satisfy default lookup lint

* test(sessions): split known-owner coverage

* fix(state): satisfy path identity lint

* fix(agents): preserve malformed roster safety boundaries

* fix(agents): restore roster compatibility at runtime boundaries

* fix(config): satisfy roster boundary type checks

* fix(agents): preserve roster ownership across runtime probes

Setup inference probes now execute as the configured roster owner. Malformed agent-prefixed session rows are intentionally omitted by the fail-closed visibility contract rather than normalized by tests.

* fix(agents): satisfy session list owner lint

* fix(agents): preserve roster-owned runtime boundaries

Restore shared logical rows for exact SQLite session locators while keeping their physical database owner separate. The ownership regression test now constructs an explicit sole-owner database directly instead of relying on first-touch capture, matching the intentional shared-store contract.

* fix(sessions): preserve multiply owned exact stores

* fix(sessions): restore runtime owner boundaries

Keep incognito sentinels agent-owned, fold default-agent approvals into the global snapshot, and preserve the configless legacy-main CLI policy fallback. Also repair the existing CLI watchdog test lifecycle so the compact shard observes its timeout without an unawaited assertion or async timer stall; product behavior is unchanged by that test-only fix.

* test(ci): align owner-scoped fixtures

These assertions are unchanged. The fixtures now declare the intended non-default runner, expose the session-key constant imported by production status code, and select the main approvals bucket explicitly on Windows.

* fix(agents): close final roster ownership gaps
This commit is contained in:
Peter Steinberger
2026-07-24 22:38:09 -07:00
committed by GitHub
parent 5f63f744ea
commit 82d1a03f25
430 changed files with 13818 additions and 2606 deletions
@@ -84,8 +84,8 @@ a5f59c9acbcaa3f82247bf806eb5ba08032373fb853719f0ec9457690f16fc70 module/media-m
c5e3eb1a584f4b8126d9d6c177a840ec9103671e8d1242634ee67db9b5b9e573 module/media-understanding
c0ffaed532578cf33493992e1ff806b2268b8e3774a92edbaede5cf5bda162a6 module/media-understanding-runtime
bebd2931dc51d67c063ff19fa1c278f8dcfe00ab23cfbd480d47329ea8e5088e module/meeting-runtime
d09ddb38c9d20a41fcf4cedaf6a2c28eb5333a9fba2287e34b458fa5caba751c module/memory-core-host-engine-foundation
646773d8282a2ac6a89101685c406200935ddb3459a2830e820da132fa433b3c module/memory-host-core
6b396a29c4efcd562accbd22dfa474f7815ce039cb8314a4771c2e37b5431ab4 module/memory-core-host-engine-foundation
5d4d709d5ae573186459462fe5119bd253c13104554eb94aefbdab5f5c7ad46f module/memory-host-core
87b7a3206346c0d4b294fb3a2395cbaabc3e73ff8b1b9ea925bc3aade3e52687 module/messaging-targets
09f842a2787b87117d88ba6545aa0441ff8afdb85e2e66ae4d1838ebecb16148 module/model-session-runtime
44655a08ce111a036c837b8d2e796cf0036941379ab317e5764c6c01e73b39f3 module/models-provider-runtime
@@ -105,7 +105,7 @@ aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/questio
ac2b199e95c5c8b1e2a65e62bd41d1b6322e531bca294ef4979a297a12640bce module/reply-history
f394fe4d5a7ed9e4d574063ae44e8d6af85c9a0e7d8b329f750ca16b0664325f module/reply-payload
94356c388b1c5bc4ffb7fef6ece1e2fc5f6c9f0eaf0c280594b29230baf9af81 module/reply-runtime
d78db621b8f4f0cc679cad2d5d21b6c95b5418c58611dd347cdf09049ed124f6 module/routing
536341e301631a14ac67bd7e8d10d2ba770ff91c5f7a2c5dc8dd9dfd1c1c7ec4 module/routing
ff6cca86f54f94f238205f5b122af36666314e0a380f3ec7f0ccb9ed9208df31 module/run-command
53b0295cec105696a1664c5c7f5576a7b55d197eb95dcd9185486f010bd53750 module/runtime
fcf103d18c1d64ffa9719f1d5a1ca349df3a6125d00c9d54a1b76c195ba8691b module/runtime-config-snapshot
@@ -117,7 +117,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime
17a6a199714ba8308e62928c0491bcf9fd214c923c3879aa99a047a30138253d module/secret-ref-runtime
596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
82f1f06f875097ee894348e74346fde79325c8fee7106b51438aaec1f539cbff module/session-store-runtime
32fb6d253abf22440bc76c7a68d1f35fc0ef369b0ad738aedba9c3054a76e48e module/session-store-runtime
7cad408673562b0ff33f60071698bea2c39d02c64f4f235851c5c2ceb946910d module/setup
fbcb853789db5a1ad5d81e8094ed7f5bcea657ac2281e7887a19c1d6f1194571 module/setup-runtime
cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools
+1
View File
@@ -810,6 +810,7 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
agents: {
entries: {
codex: {
default: true,
runtime: {
type: "acp",
acp: {
+1
View File
@@ -44,6 +44,7 @@ Make display-name pings work even when WhatsApp strips the visual `@` from the t
agents: {
entries: {
main: {
default: true,
groupChat: {
mentionPatterns: ["@?openclaw", "\\+?15555550123"],
},
+1
View File
@@ -346,6 +346,7 @@ Each fact defaults to enabled when the channel produces it. Set the correspondin
agents: {
entries: {
main: {
default: true,
groupChat: {
mentionPatterns: ["@openclaw", "openclaw", "\\+15555550123"],
historyLimit: 50,
+8 -2
View File
@@ -147,8 +147,15 @@ function resolveLegacySanitizedSessionKey(
return matches.length === 1 && match ? match : null;
}
function listAgentIds(config: { agents?: { list?: Array<{ id?: unknown }> } }): string[] {
function listAgentIds(config: OpenClawConfig): string[] {
const ids = new Set<string>(["main"]);
if (isRecord(config.agents?.entries)) {
for (const agentId of Object.keys(config.agents.entries)) {
if (agentId.trim()) {
ids.add(agentId.trim());
}
}
}
for (const agent of config.agents?.list ?? []) {
if (typeof agent.id === "string" && agent.id.trim()) {
ids.add(agent.id.trim());
@@ -162,7 +169,6 @@ function listCandidateStorePaths(params: {
env: NodeJS.ProcessEnv;
}): string[] {
const paths = new Set<string>();
paths.add(resolveStorePath(params.config.session?.store, { env: params.env }));
for (const agentId of listAgentIds(params.config)) {
paths.add(resolveStorePath(params.config.session?.store, { agentId, env: params.env }));
}
@@ -1,5 +1,6 @@
// Telegram reply-chain cache and prompt-context projection.
import type { Message } from "grammy/types";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking";
@@ -55,7 +56,11 @@ export function createTelegramMessageContextRuntime({
"cfg" | "accountId" | "opts" | "telegramCfg" | "telegramDeps"
>) {
const messageCache = createTelegramMessageCache({
scope: resolveTelegramMessageCacheScope(telegramDeps.resolveStorePath(cfg.session?.store)),
scope: resolveTelegramMessageCacheScope(
telegramDeps.resolveStorePath(cfg.session?.store, {
agentId: cfg.agents ? resolveDefaultAgentId(cfg) : "main",
}),
),
});
const resolvePromptSender = (
node: TelegramCachedMessageNode,
@@ -17,6 +17,7 @@ import {
import type { TelegramRuntime } from "./runtime.types.js";
const cfg = {
agents: { entries: { main: { default: true } } },
channels: { telegram: { botToken: "tok" } },
session: { store: "/tmp/openclaw-telegram-topic-binding-test.json" },
} as OpenClawConfig;
@@ -66,7 +67,9 @@ async function recordMessage(params: {
accountId?: string;
}) {
const cache = createTelegramMessageCache({
scope: resolveTelegramMessageCacheScope(resolveStorePath(cfg.session?.store)),
scope: resolveTelegramMessageCacheScope(
resolveStorePath(cfg.session?.store, { agentId: "main" }),
),
});
await cache.record({
accountId: params.accountId ?? "default",
@@ -1,5 +1,6 @@
// Telegram provider-owned authorization for message mutations in forum topics.
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-core";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type {
ChannelMessageActionContext,
ChannelThreadingToolContext,
@@ -112,7 +113,11 @@ export async function resolveTelegramMessageMutationChatId(params: {
}
const cache = createTelegramMessageCache({
scope: resolveTelegramMessageCacheScope(resolveStorePath(params.cfg.session?.store)),
scope: resolveTelegramMessageCacheScope(
resolveStorePath(params.cfg.session?.store, {
agentId: params.cfg.agents ? resolveDefaultAgentId(params.cfg) : "main",
}),
),
});
const cached = await cache.get({
accountId: selectedAccountId,
@@ -1,5 +1,6 @@
// Telegram plugin module implements outbound message context behavior.
import type { Message } from "grammy/types";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
@@ -151,7 +152,11 @@ export async function recordOutboundMessageForPromptContext(params: {
...(messageThreadId !== undefined ? { messageThreadId } : {}),
});
const cache = createTelegramMessageCache({
scope: resolveTelegramMessageCacheScope(resolveStorePath(params.cfg.session?.store)),
scope: resolveTelegramMessageCacheScope(
resolveStorePath(params.cfg.session?.store, {
agentId: params.cfg.agents ? resolveDefaultAgentId(params.cfg) : "main",
}),
),
});
await cache.record({
accountId: params.account.accountId,
+33 -11
View File
@@ -1,6 +1,7 @@
// Telegram plugin module implements sent message cache behavior.
import { createHash } from "node:crypto";
import fs from "node:fs";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -31,6 +32,8 @@ type SentMessageState = {
bucketsByScope: Map<string, SentMessageBucket>;
};
type SentMessageConfig = Pick<OpenClawConfig, "agents" | "session">;
function getSentMessageState(): SentMessageState {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] as SentMessageState | undefined;
@@ -48,15 +51,30 @@ function createSentMessageStore(): SentMessageStore {
return new Map<string, Map<string, number>>();
}
function resolveSentMessageStorePath(cfg?: Pick<OpenClawConfig, "session">): string {
return `${resolveStorePath(cfg?.session?.store)}.telegram-sent-messages.json`;
function resolveSentMessageAgentId(cfg?: SentMessageConfig, agentId?: string): string {
return agentId?.trim() || (cfg?.agents ? resolveDefaultAgentId(cfg as OpenClawConfig) : "main");
}
function resolveSentMessageScopeKey(cfg?: Pick<OpenClawConfig, "session">): string {
const storePath = resolveStorePath(cfg?.session?.store);
function resolveSentMessageStorePath(cfg?: SentMessageConfig, agentId?: string): string {
return `${resolveStorePath(cfg?.session?.store, {
agentId: resolveSentMessageAgentId(cfg, agentId),
})}.telegram-sent-messages.json`;
}
function sentMessageScopeKeyForStorePath(storePath: string): string {
return createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24);
}
function resolveSentMessageScopeKey(cfg?: SentMessageConfig, agentId?: string): string {
// This 24-hour cache follows the current agent owner. Do not revive a prior owner's
// transient bucket when the configured default changes.
return sentMessageScopeKeyForStorePath(
resolveStorePath(cfg?.session?.store, {
agentId: resolveSentMessageAgentId(cfg, agentId),
}),
);
}
function sentMessageEntryKey(scopeKey: string, chatId: string, messageId: string): string {
return createHash("sha256")
.update(`${scopeKey}\0${chatId}\0${messageId}`, "utf8")
@@ -142,7 +160,7 @@ function readPersistedSentMessages(scopeKey: string): SentMessageStore {
return store;
}
function getSentMessageBucket(cfg?: Pick<OpenClawConfig, "session">): SentMessageBucket {
function getSentMessageBucket(cfg?: SentMessageConfig): SentMessageBucket {
const state = getSentMessageState();
const scopeKey = resolveSentMessageScopeKey(cfg);
const existing = state.bucketsByScope.get(scopeKey);
@@ -157,7 +175,7 @@ function getSentMessageBucket(cfg?: Pick<OpenClawConfig, "session">): SentMessag
return bucket;
}
function getSentMessages(cfg?: Pick<OpenClawConfig, "session">): SentMessageStore {
function getSentMessages(cfg?: SentMessageConfig): SentMessageStore {
return getSentMessageBucket(cfg).store;
}
@@ -177,7 +195,7 @@ function persistSentMessage(
export function recordSentMessage(
chatId: number | string,
messageId: number,
cfg?: Pick<OpenClawConfig, "session">,
cfg?: SentMessageConfig,
): void {
const scopeKey = String(chatId);
const idKey = String(messageId);
@@ -201,7 +219,7 @@ export function recordSentMessage(
export function wasSentByBot(
chatId: number | string,
messageId: number,
cfg?: Pick<OpenClawConfig, "session">,
cfg?: SentMessageConfig,
): boolean {
const scopeKey = String(chatId);
const idKey = String(messageId);
@@ -215,11 +233,15 @@ export function wasSentByBot(
}
export function listTelegramLegacySentMessageCacheEntries(params: {
cfg?: Pick<OpenClawConfig, "session">;
cfg?: SentMessageConfig;
agentId?: string;
persistedPath?: string;
targetStorePath?: string;
}): Array<{ key: string; value: PersistedSentMessage; ttlMs?: number; timestamp?: number }> {
const scopeKey = resolveSentMessageScopeKey(params.cfg);
const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg);
const scopeKey = params.targetStorePath
? sentMessageScopeKeyForStorePath(params.targetStorePath)
: resolveSentMessageScopeKey(params.cfg, params.agentId);
const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg, params.agentId);
const legacy = fs.existsSync(filePath)
? readLegacySentMessages(filePath)
: createSentMessageStore();
@@ -1,4 +1,5 @@
// Telegram tests cover state migrations plugin behavior.
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -128,7 +129,7 @@ describe("telegram state migrations", () => {
it("detects legacy message-cache import for the runtime sidecar path", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const storePath = resolveStorePath(undefined, { env });
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
const persistedPath = resolveTelegramMessageCachePath(storePath);
try {
await mkdir(path.dirname(persistedPath), { recursive: true });
@@ -339,7 +340,7 @@ describe("telegram state migrations", () => {
it("detects remaining Telegram JSON sidecars for plugin-state import", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const storePath = resolveStorePath(undefined, { env });
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
const now = Date.now();
const updateOffsetPath = path.join(dir, "telegram", "update-offset-ops.json");
const stickerCachePath = path.join(dir, "telegram", "sticker-cache.json");
@@ -483,7 +484,7 @@ describe("telegram state migrations", () => {
vi.setSystemTime(new Date("2026-07-15T12:00:00.000Z"));
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const storePath = resolveStorePath(undefined, { env });
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
const sentMessagePath = `${storePath}.telegram-sent-messages.json`;
const dispatchPath = resolveTelegramMessageDispatchLegacyPath({
storePath,
@@ -711,7 +712,7 @@ describe("telegram state migrations", () => {
it("imports legacy session-store sidecars into the current runtime scope", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const storePath = resolveStorePath(undefined, { env });
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
const currentSentPath = `${storePath}.telegram-sent-messages.json`;
const legacySentPath = `${legacyStorePath}.telegram-sent-messages.json`;
@@ -771,9 +772,11 @@ describe("telegram state migrations", () => {
const stripTtl = (entries: Awaited<ReturnType<typeof currentSentPlan.readEntries>>) =>
entries.map(({ ttlMs: _ttlMs, ...entry }) => entry);
expect(stripTtl(await legacySentPlan.readEntries())).toStrictEqual(
stripTtl(await currentSentPlan.readEntries()),
);
const currentSentEntries = stripTtl(await currentSentPlan.readEntries());
expect(stripTtl(await legacySentPlan.readEntries())).toStrictEqual(currentSentEntries);
expect(currentSentEntries[0]?.value).toMatchObject({
scopeKey: createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24),
});
const stripDispatchSourceKey = (
entries: Awaited<ReturnType<typeof currentDispatchPlan.readEntries>>,
) => entries.map(({ key: _key, ttlMs: _ttlMs, ...entry }) => entry);
+57 -16
View File
@@ -1,6 +1,7 @@
// Telegram plugin module implements state migrations behavior.
import fs from "node:fs";
import path from "node:path";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
@@ -87,11 +88,26 @@ function resolveLegacySessionStorePath(params: {
return path.join(resolveMigrationStateDir(params), "sessions", "sessions.json");
}
function resolveAgentSessionStorePath(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
agentId: string;
}): string {
return resolveStorePath(params.cfg.session?.store, {
env: params.env,
agentId: params.agentId,
});
}
function resolveMigrationStateDir(params: { env: NodeJS.ProcessEnv; stateDir?: string }): string {
return (
params.stateDir ??
path.dirname(
path.dirname(path.dirname(path.dirname(resolveStorePath(undefined, { env: params.env })))),
path.dirname(
path.dirname(
path.dirname(resolveStorePath(undefined, { env: params.env, agentId: "main" })),
),
),
)
);
}
@@ -309,12 +325,20 @@ function detectTelegramMessageCacheLegacyStateMigration(params: {
env: NodeJS.ProcessEnv;
stateDir?: string;
}): ChannelLegacyStateMigrationPlan[] {
const storePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
const storePath = resolveAgentSessionStorePath({
...params,
agentId: resolveDefaultAgentId(params.cfg),
});
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
const runtimePersistedPath = resolveTelegramMessageCachePath(storePath);
const legacyStorePath = resolveLegacySessionStorePath(params);
const legacyPersistedPath = resolveTelegramMessageCachePath(legacyStorePath);
const scopeKey = resolveTelegramMessageCachePersistentScopeKey(runtimePersistedPath);
return uniqueStrings([runtimePersistedPath, legacyPersistedPath]).flatMap((persistedPath) => {
return uniqueStrings([
runtimePersistedPath,
resolveTelegramMessageCachePath(legacyMainStorePath),
legacyPersistedPath,
]).flatMap((persistedPath) => {
if (!fileExists(persistedPath)) {
return [];
}
@@ -444,12 +468,16 @@ function detectTelegramSentMessageCacheLegacyStateMigration(params: {
env: NodeJS.ProcessEnv;
stateDir?: string;
}): ChannelLegacyStateMigrationPlan[] {
const storePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
const defaultAgentId = resolveDefaultAgentId(params.cfg);
const storePath = resolveAgentSessionStorePath({ ...params, agentId: defaultAgentId });
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
const legacyStorePath = resolveLegacySessionStorePath(params);
const sources = uniqueStrings([storePath, legacyStorePath]).map((sourceStorePath) => ({
targetStorePath: storePath,
sourcePath: `${sourceStorePath}.telegram-sent-messages.json`,
}));
const sources = uniqueStrings([storePath, legacyMainStorePath, legacyStorePath]).map(
(sourceStorePath) => ({
targetStorePath: storePath,
sourcePath: `${sourceStorePath}.telegram-sent-messages.json`,
}),
);
return sources.flatMap((source) => {
if (!fileExists(source.sourcePath)) {
return [];
@@ -468,8 +496,10 @@ function detectTelegramSentMessageCacheLegacyStateMigration(params: {
preview: `- Telegram sent-message cache: ${source.sourcePath} → plugin state (${TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE})`,
readEntries: () =>
listTelegramLegacySentMessageCacheEntries({
cfg: { session: { store: source.targetStorePath } },
cfg: params.cfg,
agentId: defaultAgentId,
persistedPath: source.sourcePath,
targetStorePath: source.targetStorePath,
}),
};
});
@@ -512,7 +542,11 @@ function detectTelegramMessageDispatchLegacyStateMigration(params: {
env: NodeJS.ProcessEnv;
stateDir?: string;
}): ChannelLegacyStateMigrationPlan[] {
const storePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
const storePath = resolveAgentSessionStorePath({
...params,
agentId: resolveDefaultAgentId(params.cfg),
});
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
const legacyStorePath = resolveLegacySessionStorePath(params);
const env = params.stateDir ? { ...params.env, OPENCLAW_STATE_DIR: params.stateDir } : params.env;
const namespace = resolvePersistentDedupePluginStateNamespace({
@@ -520,12 +554,14 @@ function detectTelegramMessageDispatchLegacyStateMigration(params: {
namespacePrefix: TELEGRAM_MESSAGE_DISPATCH_DEDUPE_NAMESPACE_PREFIX,
});
return listTelegramAccountIds(params.cfg).flatMap((accountId) => {
const sources = uniqueStrings([storePath, legacyStorePath]).map((sourceStorePath) => ({
sourcePath: resolveTelegramMessageDispatchLegacyPath({
storePath: sourceStorePath,
namespace: accountId,
const sources = uniqueStrings([storePath, legacyMainStorePath, legacyStorePath]).map(
(sourceStorePath) => ({
sourcePath: resolveTelegramMessageDispatchLegacyPath({
storePath: sourceStorePath,
namespace: accountId,
}),
}),
}));
);
const jsonPlans: ChannelLegacyStateMigrationPlan[] = sources.flatMap((source) => {
const sourcePath = source.sourcePath;
if (!fileExists(sourcePath)) {
@@ -612,7 +648,11 @@ function detectTelegramTopicNameCacheLegacyStateMigration(params: {
});
return topicNameCacheImportSource({ sourceStorePath: storePath });
});
const defaultStorePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
const defaultStorePath = resolveAgentSessionStorePath({
...params,
agentId: resolveDefaultAgentId(params.cfg),
});
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
const defaultAccountStorePath = resolveStorePath(params.cfg.session?.store, {
env: params.env,
agentId: resolveDefaultTelegramAccountId(params.cfg),
@@ -622,6 +662,7 @@ function detectTelegramTopicNameCacheLegacyStateMigration(params: {
[
...accountSources,
topicNameCacheImportSource({ sourceStorePath: defaultStorePath }),
topicNameCacheImportSource({ sourceStorePath: legacyMainStorePath }),
topicNameCacheImportSource({
sourceStorePath: legacyStorePath,
targetStorePath: defaultAccountStorePath,
@@ -64,6 +64,33 @@ describe("configured model refs", () => {
).toEqual(["openai/gpt-5.5"]);
});
it("preserves legacy list indices when collecting agent model refs", () => {
expect(
collectConfiguredModelRefs({
agents: {
list: [
{ id: "10", model: "openai/gpt-5.6" },
{ id: "2", utilityModel: "anthropic/claude-sonnet-4-6" },
],
},
}),
).toEqual([
{ path: "agents.list.0.model", value: "openai/gpt-5.6" },
{ path: "agents.list.1.utilityModel", value: "anthropic/claude-sonnet-4-6" },
]);
});
it("ignores a shadowed legacy list when keyed entries are authoritative", () => {
expect(
collectConfiguredModelRefs({
agents: {
entries: { ops: { model: "openai/gpt-5.6" } },
list: [{ id: "stale", model: "anthropic/claude-opus-4-8" }],
},
}),
).toEqual([{ path: "agents.entries.ops.model", value: "openai/gpt-5.6" }]);
});
it("ignores array-shaped malformed records", () => {
expect(
collectConfiguredModelRefs({
@@ -86,6 +86,10 @@ export function collectConfiguredModelRefs(
for (const [agentId, entry] of Object.entries(agents.entries)) {
collectFromAgent(`agents.entries.${agentId}`, entry);
}
} else if (Array.isArray(agents.list)) {
for (const [index, entry] of agents.list.entries()) {
collectFromAgent(`agents.list.${index}`, entry);
}
}
if (options.includeChannelModelOverrides !== false) {
const channels = isRecord(root.channels) ? root.channels : {};
+2 -1
View File
@@ -173,7 +173,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +6: canonical session delivery normalization, access, and projection helpers.
// +5: focused media-local-roots helpers and typed hook media contracts.
// +1: model-independent agent-harness preflight failure contract.
4717,
// +1: typed owner-required error for session store path resolution.
4718,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+4 -6
View File
@@ -4,6 +4,7 @@ import { safeParseJson } from "@openclaw/normalization-core";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Insertable, Selectable } from "kysely";
import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js";
import { getRuntimeConfig } from "../../config/config.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import {
@@ -82,14 +83,11 @@ export function resolveSessionStorePathForAcp(params: {
}): { cfg: OpenClawConfig; agentId?: string; storePath: string } {
const cfg = params.cfg ?? getRuntimeConfig();
const parsed = parseAgentSessionKey(params.sessionKey);
const storePath = resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
env: params.env,
});
const agentId = parsed?.agentId ?? resolveDefaultAgentId(cfg);
return {
cfg,
...(parsed?.agentId ? { agentId: parsed.agentId } : {}),
storePath,
agentId,
storePath: resolveStorePath(cfg.session?.store, { agentId, env: params.env }),
};
}
+5 -4
View File
@@ -68,6 +68,7 @@ import {
type AcpSpawnParentRelayHandle,
startAcpSpawnParentStreamRelay,
} from "./acp-spawn-parent-stream.js";
import { listAgentEntries } from "./agent-scope-config.js";
import { listAgentIds, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
import {
findAcpUnsupportedInheritedToolAllow,
@@ -349,7 +350,7 @@ function isHeartbeatEnabledForSessionAgent(params: {
return true;
}
const agentEntries = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : [];
const agentEntries = listAgentEntries(params.cfg);
const hasExplicitHeartbeatAgents = agentEntries.some((entry) => Boolean(entry?.heartbeat));
const enabledByPolicy = hasExplicitHeartbeatAgents
? agentEntries.some(
@@ -435,7 +436,7 @@ function resolveTargetAcpAgentId(params: {
}): { ok: true; agentId: string; configAgentId?: string } | { ok: false; error: string } {
const requested = normalizeOptionalAgentId(params.requestedAgentId);
if (requested) {
const configuredAgent = params.cfg.agents?.list?.find(
const configuredAgent = listAgentEntries(params.cfg).find(
(agent) => normalizeOptionalAgentId(agent.id) === requested,
);
if (configuredAgent?.runtime?.type === "acp") {
@@ -485,7 +486,7 @@ function isExplicitlyAllowedAcpAgent(cfg: OpenClawConfig, agentId: string): bool
function resolveConfiguredAcpSubagentTargetIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>(listAgentIds(cfg));
for (const agent of cfg.agents?.list ?? []) {
for (const agent of listAgentEntries(cfg)) {
if (agent.runtime?.type !== "acp") {
continue;
}
@@ -1246,7 +1247,7 @@ export async function spawnAcpDirect(
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
const childIdem = crypto.randomUUID();
const parentAgentId = parentSessionKey
? resolveAgentIdFromSessionKey(parentSessionKey)
? resolveAgentIdFromSessionKey(parentSessionKey, resolveDefaultAgentId(cfg))
: undefined;
// Resolve parent session delivery context so system events route to the
// correct thread/topic instead of falling back to the main DM.
+80 -1
View File
@@ -1,6 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { mutateConfigFileWithRetry } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { createAgent } from "./agent-create.js";
@@ -38,3 +40,80 @@ it("keeps a fresh named workspace pending through the first run setup", async ()
await state.cleanup();
}
});
describe("agent roster persistence", () => {
async function addWorkerToConfig(config: unknown): Promise<OpenClawConfig> {
const state = await createOpenClawTestState({
layout: "state-only",
scenario: "empty",
label: "agent-roster-write",
});
try {
await state.writeConfig(config);
const result = await createAgent({ name: "Worker", workspace: state.path("worker") });
expect(result).toMatchObject({ status: "created", agentId: "worker" });
return JSON.parse(await fs.readFile(state.configPath, "utf8")) as OpenClawConfig;
} finally {
closeOpenClawStateDatabaseForTest();
await state.cleanup();
}
}
it("writes injected main and a new worker as one complete keyed roster", async () => {
const persisted = await addWorkerToConfig({ gateway: { mode: "local" } });
expect(persisted.agents?.entries).toMatchObject({
main: { default: true },
worker: { workspace: expect.any(String) },
});
expect(
Object.values(persisted.agents?.entries ?? {}).filter((entry) => entry.default === true),
).toHaveLength(1);
});
it("replaces a legacy list with the complete keyed roster", async () => {
const persisted = await addWorkerToConfig({
agents: {
list: [
{ id: "main", default: true },
{ id: "ops", workspace: "/srv/ops" },
],
},
});
expect(persisted.agents).not.toHaveProperty("list");
expect(persisted.agents?.entries).toMatchObject({
main: { default: true },
ops: { workspace: "/srv/ops" },
worker: { workspace: expect.any(String) },
});
});
it("preserves a legacy list byte-for-byte during a non-roster mutation", async () => {
const state = await createOpenClawTestState({
layout: "state-only",
scenario: "empty",
label: "legacy-roster-non-roster-write",
});
const list = [
{ id: "main", default: true },
{ id: "ops", workspace: "/srv/ops" },
];
try {
await state.writeConfig({ agents: { list }, gateway: { port: 18789 } });
await mutateConfigFileWithRetry({
mutate: (config) => {
config.gateway = { ...config.gateway, port: 19001 };
},
});
const persisted = JSON.parse(await fs.readFile(state.configPath, "utf8")) as OpenClawConfig;
expect(JSON.stringify(persisted.agents?.list)).toBe(JSON.stringify(list));
expect(persisted.agents).not.toHaveProperty("entries");
expect(persisted.gateway?.port).toBe(19001);
} finally {
closeOpenClawStateDatabaseForTest();
await state.cleanup();
}
});
});
+217 -7
View File
@@ -72,7 +72,7 @@ import { createAgent } from "./agent-create.js";
describe("createAgent", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.config = {};
mocks.config = { agents: { list: [{ id: "main", default: true }] } };
mocks.persisted = {};
mocks.readAgentDeletionJournal.mockReturnValue(undefined);
mocks.claimCompletedAgentDeletion.mockReturnValue(true);
@@ -93,9 +93,11 @@ describe("createAgent", () => {
async ({
transform,
}: {
transform: (config: Record<string, unknown>) => Promise<unknown>;
transform: (config: Record<string, unknown>, context: unknown) => Promise<unknown>;
}) => {
const transformed = (await transform(structuredClone(mocks.config))) as {
const transformed = (await transform(structuredClone(mocks.config), {
snapshot: { exists: false },
})) as {
nextConfig: Record<string, unknown>;
result: unknown;
};
@@ -111,6 +113,10 @@ describe("createAgent", () => {
status: "error",
reason: "invalid-name",
});
await expect(createAgent({ name: "###" })).resolves.toMatchObject({
status: "error",
reason: "invalid-name",
});
for (const name of ["main", "OpenClaw", "crestodian"]) {
await expect(createAgent({ name })).resolves.toMatchObject({
status: "error",
@@ -132,8 +138,208 @@ describe("createAgent", () => {
});
});
it("accepts a complete staged entry", async () => {
const result = await createAgent({
entry: {
id: "researcher",
name: "Researcher",
workspace: "/tmp/staged-work",
agentDir: "/tmp/staged-agent",
model: "openai/gpt-5.5",
identity: { name: "Researcher", emoji: "🔎" },
},
});
expect(result).toMatchObject({
status: "created",
agentId: "researcher",
workspace: "/tmp/staged-work",
agentDir: "/tmp/staged-agent",
});
expect(mocks.persisted).toMatchObject({
agents: {
entries: {
researcher: expect.objectContaining({ model: "openai/gpt-5.5" }),
},
},
});
expect((mocks.persisted.agents as { list?: unknown }).list).toBeUndefined();
});
it("keeps the first staged roster entry as the default", async () => {
mocks.config = { agents: { list: [] } };
await createAgent({
entry: { id: "researcher", name: "Researcher", default: false },
});
expect(mocks.persisted).toMatchObject({
agents: { entries: { researcher: expect.objectContaining({ default: true }) } },
});
});
it.each([
{
label: "staged model only",
entryModel: "openai/staged",
paramsModel: undefined,
},
{
label: "staged model over explicit parameter",
entryModel: "openai/staged",
paramsModel: "openai/parameter",
},
])(
"preserves $label when workspace setup normalizes the path",
async ({ entryModel, paramsModel }) => {
mocks.ensureAgentWorkspace.mockResolvedValue({
dir: "/normalized/work",
bootstrapPending: true,
});
await createAgent({
entry: {
id: "researcher",
name: "Researcher",
workspace: "/staged/work",
model: entryModel,
},
model: paramsModel,
});
expect(mocks.persisted).toMatchObject({
agents: {
entries: {
researcher: expect.objectContaining({
model: entryModel,
workspace: "/normalized/work",
}),
},
},
});
},
);
it("preserves every legacy-list agent when staging a new entry", async () => {
mocks.config = {
agents: {
list: [
{ id: "main", default: true, name: "Main" },
{ id: "ops", name: "Ops" },
],
},
};
await createAgent({
entry: { id: "researcher", name: "Researcher", model: "openai/gpt-5.5" },
});
expect(mocks.persisted).toMatchObject({
agents: {
entries: {
main: { default: true, name: "Main" },
ops: { name: "Ops" },
researcher: expect.objectContaining({ model: "openai/gpt-5.5" }),
},
},
});
expect((mocks.persisted.agents as { list?: unknown }).list).toBeUndefined();
});
it("provisions the injected main roster only through a bootstrap entry", async () => {
await expect(
createAgent({
entry: {
id: "main",
name: "main",
default: true,
workspace: "/tmp/main-work",
},
}),
).resolves.toMatchObject({ status: "existing", agentId: "main" });
expect(mocks.ensureAgentWorkspace).toHaveBeenCalledOnce();
expect(mocks.persisted).toMatchObject({
agents: { entries: { main: expect.objectContaining({ workspace: "/tmp/main-work" }) } },
});
});
it("does not overwrite an already materialized main agent", async () => {
mocks.config = {
agents: {
list: [{ id: "main", default: true, name: "Existing", workspace: "/tmp/existing" }],
},
};
mocks.resolveAgentWorkspaceDir.mockReturnValueOnce("/tmp/existing");
await expect(
createAgent({
entry: { id: "main", name: "Replacement", default: true, workspace: "/tmp/new" },
}),
).resolves.toMatchObject({
status: "existing",
name: "Existing",
workspace: "/tmp/existing",
bootstrapPending: false,
});
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
});
it("does not materialize a minimal main entry from a persisted snapshot", async () => {
mocks.resolveAgentWorkspaceDir.mockReturnValueOnce("/tmp/persisted");
mocks.transformConfigFileWithRetry.mockImplementationOnce(async ({ transform }) => {
const transformed = await transform(structuredClone(mocks.config), {
snapshot: { exists: true },
});
return { ...transformed, result: transformed.result };
});
await expect(
createAgent({
entry: { id: "main", default: true, workspace: "/tmp/replacement" },
}),
).resolves.toMatchObject({ status: "existing", workspace: "/tmp/persisted" });
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
});
it("rejects a default marker when a roster already exists", async () => {
const before = structuredClone(mocks.config);
await expect(
createAgent({
entry: { id: "researcher", name: "Researcher", default: true },
}),
).resolves.toMatchObject({
status: "error",
reason: "default-conflict",
message: expect.stringContaining("Reassign the default separately"),
});
expect(mocks.config).toEqual(before);
expect(mocks.persisted).toEqual({});
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
});
it("rejects a concurrent non-main roster during main bootstrap", async () => {
const transformConfig = vi.fn(async ({ transform }) =>
transform({ agents: { list: [{ id: "main" }, { id: "ops", default: true }] } }),
);
await expect(
createAgent({
entry: { id: "main", default: true, workspace: "/tmp/main" },
transformConfig,
}),
).resolves.toMatchObject({
status: "error",
reason: "default-conflict",
message: expect.stringContaining("Reassign the default separately"),
});
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
});
it("respects skipBootstrap from the current config", async () => {
mocks.config = { agents: { defaults: { skipBootstrap: true } } };
mocks.config = {
agents: { defaults: { skipBootstrap: true }, list: [{ id: "main", default: true }] },
};
await createAgent({ name: "researcher", workspace: "/tmp/work" });
@@ -249,7 +455,9 @@ describe("createAgent", () => {
});
it("claims a recovered completed tombstone only once for an existing roster entry", async () => {
mocks.config = { agents: { list: [{ id: "researcher" }] } };
mocks.config = {
agents: { list: [{ id: "main", default: true }, { id: "researcher" }] },
};
mocks.readAgentDeletionJournal.mockReturnValue({
operationId: "delete-1",
cleanupCompleted: true,
@@ -279,7 +487,9 @@ describe("createAgent", () => {
});
it("rejects a concurrent duplicate from the mutation snapshot", async () => {
mocks.config = { agents: { list: [{ id: "researcher" }] } };
mocks.config = {
agents: { list: [{ id: "main", default: true }, { id: "researcher" }] },
};
await expect(createAgent({ name: "researcher" })).resolves.toMatchObject({
status: "error",
@@ -295,7 +505,7 @@ describe("createAgent", () => {
});
const transformConfig = vi.fn(async ({ maxAttempts, transform }) => {
expect(maxAttempts).toBe(1);
return await transform({ agents: { list: [] } });
return await transform({ agents: { list: [{ id: "main", default: true }] } });
});
await expect(
+152 -32
View File
@@ -8,12 +8,15 @@ import {
} from "../commands/agents.config.js";
import { transformConfigFileWithRetry, withConfigMutationExclusive } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { FsSafeError, root } from "../infra/fs-safe.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
import { resolveUserPath } from "../utils.js";
import { claimCompletedAgentDeletion } from "./agent-lifecycle-registry.js";
import { toAgentEntriesRecord } from "./agent-scope-config.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "./agent-scope.js";
import {
createAgentIdentityConfig,
@@ -22,9 +25,11 @@ import {
} from "./identity-file.js";
import { DEFAULT_IDENTITY_FILENAME, ensureAgentWorkspace } from "./workspace.js";
const RESERVED_BOOTSTRAP_AGENT_ID = "main";
type CreateAgentResult =
| {
status: "created";
status: "created" | "existing";
agentId: string;
name: string;
workspace: string;
@@ -38,6 +43,7 @@ type CreateAgentResult =
reason:
| "invalid-name"
| "reserved-id"
| "default-conflict"
| "already-exists"
| "deletion-pending"
| "invalid-bindings"
@@ -47,19 +53,25 @@ type CreateAgentResult =
};
type CreateError = Extract<CreateAgentResult, { status: "error" }>;
type AgentEntryConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["entries"]>[string];
type CreateAgentEntry = AgentEntryConfig & { id: string };
type CreateAgentParams = {
name: string;
name?: string;
entry?: CreateAgentEntry;
workspace?: string;
model?: string;
emoji?: unknown;
avatar?: unknown;
agentDir?: string;
skipBootstrap?: boolean;
skipOptionalBootstrapFiles?: OptionalBootstrapFileName[];
bindingSpecs?: string[];
transformConfig?: typeof transformConfigFileWithRetry;
};
class DuplicateAgentError extends Error {}
class DefaultAgentConflictError extends Error {}
class InvalidAgentBindingsError extends Error {}
function createError(
@@ -70,6 +82,19 @@ function createError(
return { status: "error", reason, message, ...(agentId ? { agentId } : {}) };
}
/** True when raw user input contains a character that can survive agent-id normalization. */
function hasValidRawAgentIdCharacters(value: string): boolean {
return /[a-z0-9]/iu.test(value);
}
function isInjectedBootstrapMainEntry(entry: CreateAgentEntry | undefined): boolean {
return (
entry?.id === RESERVED_BOOTSTRAP_AGENT_ID &&
entry.default === true &&
Object.keys(entry).every((key) => key === "id" || key === "default")
);
}
async function writeIdentityFile(params: {
workspaceDir: string;
identity: NonNullable<ReturnType<typeof createAgentIdentityConfig>>;
@@ -92,27 +117,38 @@ async function writeIdentityFile(params: {
}
export async function createAgent(params: CreateAgentParams): Promise<CreateAgentResult> {
const rawName = params.name.trim();
const rawName = (params.entry?.name?.trim() || params.entry?.id || params.name || "").trim();
if (!rawName) {
return createError("invalid-name", "agent name is required");
}
const agentId = normalizeAgentId(rawName);
if (agentId === DEFAULT_AGENT_ID || isReservedSystemAgentId(agentId)) {
const rawId = params.entry?.id ?? rawName;
if (!hasValidRawAgentIdCharacters(rawId)) {
return createError("invalid-name", `agent name "${rawName}" has no valid id characters`);
}
const agentId = normalizeAgentId(rawId);
const isBootstrapMain = agentId === RESERVED_BOOTSTRAP_AGENT_ID && params.entry?.default === true;
if (
(!isBootstrapMain && agentId === RESERVED_BOOTSTRAP_AGENT_ID) ||
isReservedSystemAgentId(agentId)
) {
return createError("reserved-id", `"${agentId}" is reserved`, agentId);
}
const safeName = sanitizeAgentIdentityLine(rawName);
const model = normalizeOptionalString(params.model);
const identity = createAgentIdentityConfig({
name: safeName,
emoji: params.emoji,
avatar: params.avatar,
}) ?? { name: safeName };
const explicitWorkspace = params.workspace?.trim()
? resolveUserPath(params.workspace.trim())
const identity = params.entry?.identity ??
createAgentIdentityConfig({
name: safeName,
emoji: params.emoji,
avatar: params.avatar,
}) ?? { name: safeName };
const requestedWorkspace = params.entry?.workspace ?? params.workspace;
const explicitWorkspace = requestedWorkspace?.trim()
? resolveUserPath(requestedWorkspace.trim())
: undefined;
const explicitAgentDir = params.agentDir?.trim()
? resolveUserPath(params.agentDir.trim())
const requestedAgentDir = params.entry?.agentDir ?? params.agentDir;
const explicitAgentDir = requestedAgentDir?.trim()
? resolveUserPath(requestedAgentDir.trim())
: undefined;
const transformConfig = params.transformConfig ?? transformConfigFileWithRetry;
@@ -139,22 +175,83 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
const committed = await transformConfig<CreateAgentResult>({
afterWrite: { mode: "auto" },
maxAttempts: 1,
transform: async (currentConfig) => {
if (findAgentEntryIndex(listAgentEntries(currentConfig), agentId) >= 0) {
transform: async (currentConfig, context) => {
const currentEntries = listAgentEntries(currentConfig);
const existingIndex = findAgentEntryIndex(currentEntries, agentId);
const existingEntry = currentEntries[existingIndex];
const currentDefaults = currentEntries.filter((entry) => entry.default === true);
const stagedDefaultMatchesCurrent =
existingEntry?.default === true && currentDefaults.length === 1;
if (
params.entry?.default === true &&
currentEntries.length > 0 &&
!stagedDefaultMatchesCurrent
) {
throw new DefaultAgentConflictError();
}
if (existingIndex >= 0 && !isBootstrapMain) {
throw new DuplicateAgentError();
}
if (
existingIndex >= 0 &&
isBootstrapMain &&
(!isInjectedBootstrapMainEntry(existingEntry) || context.snapshot.exists)
) {
return {
nextConfig: currentConfig,
result: {
status: "existing",
agentId,
name: existingEntry?.name ?? safeName,
workspace: resolveAgentWorkspaceDir(currentConfig, agentId),
agentDir: resolveAgentDir(currentConfig, agentId),
bootstrapPending: false,
},
};
}
const workspaceDir =
explicitWorkspace ?? resolveAgentWorkspaceDir(currentConfig, agentId);
const agentDir = explicitAgentDir ?? resolveAgentDir(currentConfig, agentId);
let nextConfig = applyAgentConfig(currentConfig, {
agentId,
name: safeName,
workspace: workspaceDir,
agentDir,
model,
identity,
});
const materializeInjectedMain =
existingIndex >= 0 &&
isBootstrapMain &&
isInjectedBootstrapMainEntry(existingEntry) &&
!context.snapshot.exists;
let nextConfig =
existingIndex < 0 || materializeInjectedMain
? applyAgentConfig(currentConfig, {
agentId,
name: safeName,
workspace: workspaceDir,
agentDir,
model,
identity,
})
: currentConfig;
if (params.entry) {
const list = listAgentEntries(nextConfig);
const index = findAgentEntryIndex(list, agentId);
list[index] = {
...list[index],
...params.entry,
id: agentId,
name: safeName,
workspace: workspaceDir,
agentDir,
identity,
...(list.length === 1 ? { default: true } : {}),
};
const { list: _legacyList, ...agentsConfig } = nextConfig.agents ?? {};
nextConfig = {
...nextConfig,
agents: {
...agentsConfig,
entries: toAgentEntriesRecord(list),
},
};
}
const bindingParse = parseBindingSpecs({
agentId,
specs: params.bindingSpecs,
@@ -172,14 +269,30 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
// finishes before the final entry becomes visible to readers or delete flows.
const workspace = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !nextConfig.agents?.defaults?.skipBootstrap,
skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
ensureBootstrapFiles:
params.skipBootstrap === undefined
? !nextConfig.agents?.defaults?.skipBootstrap
: !params.skipBootstrap,
skipOptionalBootstrapFiles:
params.skipOptionalBootstrapFiles ??
nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
});
if (workspace.dir !== workspaceDir) {
nextConfig = applyAgentConfig(nextConfig, {
agentId,
workspace: workspace.dir,
});
const entries = listAgentEntries(nextConfig);
const entryIndex = findAgentEntryIndex(entries, agentId);
const currentEntry = entries[entryIndex];
if (entryIndex >= 0 && currentEntry) {
entries[entryIndex] = {
...currentEntry,
id: agentId,
workspace: workspace.dir,
};
const { list: _legacyList, ...agentsConfig } = nextConfig.agents ?? {};
nextConfig = {
...nextConfig,
agents: { ...agentsConfig, entries: toAgentEntriesRecord(entries) },
};
}
}
await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true });
// A creation-time name is config, not proof that the fresh workspace hatched.
@@ -191,7 +304,7 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
return {
nextConfig,
result: {
status: "created",
status: existingIndex >= 0 ? "existing" : "created",
agentId,
name: safeName,
workspace: workspace.dir,
@@ -217,6 +330,13 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
if (error instanceof DuplicateAgentError) {
return createError("already-exists", `agent "${agentId}" already exists`, agentId);
}
if (error instanceof DefaultAgentConflictError) {
return createError(
"default-conflict",
`Cannot create agent "${agentId}" with default=true while a roster already exists. Reassign the default separately.`,
agentId,
);
}
if (error instanceof InvalidAgentBindingsError) {
return createError("invalid-bindings", error.message, agentId);
}
+5 -4
View File
@@ -1,6 +1,7 @@
/** Agent runtime id normalization and retired runtime-selection compatibility helpers. */
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
export type EmbeddedAgentRuntime = "openclaw" | "auto" | (string & {});
@@ -40,10 +41,10 @@ export function resolveAgentScopedRuntimeOverride(params: {
agentId?: string;
}): EmbeddedAgentRuntime | undefined {
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const agentRuntime = agentId
? params.config?.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)
?.agentRuntime?.id
: undefined;
const agentRuntime =
agentId && params.config
? resolveAgentConfig(params.config, agentId)?.agentRuntime?.id
: undefined;
return normalizeOptionalAgentRuntimeId(
agentRuntime ?? params.config?.agents?.defaults?.agentRuntime?.id,
);
+74 -2
View File
@@ -1,7 +1,79 @@
// Agent scope tests cover which per-agent fields may flatten into runtime defaults.
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
import {
listAgentEntriesWithSource,
listAgentIds,
resolveAgentConfig,
resolveDefaultAgentId,
tryResolveDefaultAgentId,
} from "./agent-scope-config.js";
vi.unmock("./agent-scope-config.js");
describe("agent roster resolution", () => {
it("preserves the Plugin SDK fallback only when the roster property is absent", () => {
expect(listAgentIds({})).toEqual(["main"]);
expect(listAgentIds({ agents: { entries: {} } })).toEqual([]);
expect(resolveDefaultAgentId({})).toBe("main");
expect(resolveDefaultAgentId({ agents: { list: undefined } })).toBe("main");
expect(resolveDefaultAgentId({ agents: { defaults: { workspace: "/srv/main" } } })).toBe(
"main",
);
expect(() => resolveDefaultAgentId({ agents: { entries: {} } })).toThrow(
"No agents configured",
);
expect(() => resolveDefaultAgentId({ agents: { list: [] } })).toThrow("No agents configured");
});
it("preserves legacy first-entry selection while diagnostic lookup stays strict", () => {
const missingDefault = { agents: { list: [{ id: "alpha" }, { id: "beta" }] } };
expect(resolveDefaultAgentId(missingDefault)).toBe("alpha");
expect(tryResolveDefaultAgentId(missingDefault)).toBeUndefined();
expect(
resolveDefaultAgentId({
agents: { list: [{ id: "alpha" }, { id: "beta", default: true }] },
}),
).toBe("beta");
const duplicateDefaults = {
agents: {
list: [
{ id: "alpha", default: true },
{ id: "beta", default: true },
],
},
};
expect(resolveDefaultAgentId(duplicateDefaults)).toBe("alpha");
expect(tryResolveDefaultAgentId(duplicateDefaults)).toBeUndefined();
});
it("offers a non-throwing diagnostic lookup for malformed rosters", () => {
expect(tryResolveDefaultAgentId({ agents: { list: [{ id: "alpha" }] } })).toBeUndefined();
for (const marker of ["false", 1]) {
expect(
tryResolveDefaultAgentId({
agents: { entries: { alpha: { default: marker } } },
} as unknown as OpenClawConfig),
).toBeUndefined();
}
});
it("copies own __proto__ fields without changing the listed entry prototype", () => {
const entry = JSON.parse('{"__proto__":{"tools":{"allow":["*"]}}}') as Record<string, unknown>;
const [listed] = listAgentEntriesWithSource({
agents: { entries: { ops: entry } },
} as OpenClawConfig);
expect(listed).toBeDefined();
const listedEntry = listed!.entry;
expect(Object.getPrototypeOf(listedEntry)).toBe(Object.prototype);
expect(Object.hasOwn(listedEntry, "__proto__")).toBe(true);
expect(Object.getOwnPropertyDescriptor(listedEntry, "__proto__")?.value).toEqual({
tools: { allow: ["*"] },
});
expect(listedEntry.tools).toBeUndefined();
});
});
describe("resolveAgentConfig model policy", () => {
it("keeps an empty per-agent policy inherited instead of flattening it", () => {
+110 -34
View File
@@ -8,12 +8,18 @@ import type {
AgentDefaultsConfig,
} from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { registerResolvedAgentDir } from "./agent-dir-registry.js";
import { resolveDefaultAgentWorkspaceDir } from "./workspace-default.js";
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
type AgentEntriesConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["entries"]>;
type AgentRosterProperty = { kind: "entries" | "list"; value: unknown };
export type ListedAgentEntry = {
entry: AgentEntry;
source: { kind: "entries"; key: string } | { kind: "list"; index: number };
};
/** Per-agent config after applying agent defaults and normalizing scalar fields. */
export type ResolvedAgentConfig = {
@@ -22,7 +28,10 @@ export type ResolvedAgentConfig = {
agentDir?: string;
model?: AgentEntry["model"];
models?: AgentEntry["models"];
params?: AgentEntry["params"];
runtime?: AgentEntry["runtime"];
modelPolicy?: AgentEntry["modelPolicy"];
agentRuntime?: AgentEntry["agentRuntime"];
utilityModel?: AgentEntry["utilityModel"];
thinkingDefault?: AgentEntry["thinkingDefault"];
verboseDefault?: AgentDefaultsConfig["verboseDefault"];
@@ -48,41 +57,76 @@ export type ResolvedAgentConfig = {
tools?: AgentEntry["tools"];
};
let defaultAgentWarned = false;
function warnMultipleDefaultAgents(): void {
void import("../logging/subsystem.js")
.then(({ createSubsystemLogger }) => {
createSubsystemLogger("agent-scope").warn(
"Multiple agents marked default=true; using the first entry as default.",
);
})
.catch(() => undefined);
}
/** Strip null bytes from paths to prevent ENOTDIR errors. */
function stripNullBytes(s: string): string {
return s.replaceAll("\0", "");
}
/** Lists valid configured agent entries from config. */
export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] {
const entries = cfg.agents?.entries;
if (entries && typeof entries === "object") {
return Object.entries(entries).map(([id, entry]) => Object.assign({ id }, entry));
export function listAgentEntriesWithSource(cfg: OpenClawConfig): ListedAgentEntry[] {
const roster = readAgentRosterProperty(cfg);
if (roster?.kind === "entries" && roster.value && typeof roster.value === "object") {
return Object.entries(roster.value).map(([id, entry]) => ({
entry: { ...(entry as Omit<AgentEntry, "id">), id },
source: { kind: "entries", key: id },
}));
}
const list = cfg.agents?.list;
if (!Array.isArray(list)) {
if (roster?.kind !== "list" || !Array.isArray(roster.value)) {
return [];
}
return list.filter((entry): entry is AgentEntry => entry !== null && typeof entry === "object");
return roster.value.flatMap((entry, index) =>
entry !== null && typeof entry === "object"
? [{ entry: entry as AgentEntry, source: { kind: "list" as const, index } }]
: [],
);
}
/** Lists unique configured agent ids, falling back to the default agent id. */
/** Lists valid configured agent entries from either supported representation. */
export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] {
return listAgentEntriesWithSource(cfg).map(({ entry }) => entry);
}
/** Converts either supported roster representation into the canonical keyed shape. */
export function toAgentEntriesRecord(entries: readonly AgentEntry[]): AgentEntriesConfig {
return Object.fromEntries(
entries.map((entry) => {
const { id, ...config } = entry;
return [id, config];
}),
);
}
/** Reads the explicitly owned raw roster without normalizing malformed values. */
export function readAgentRosterProperty(raw: unknown): AgentRosterProperty | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const agents = (raw as { agents?: unknown }).agents;
if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
return undefined;
}
const entries = (agents as Record<string, unknown>)["entries"];
if (Object.hasOwn(agents, "entries") && entries !== undefined) {
return { kind: "entries", value: entries };
}
const list = (agents as Record<string, unknown>)["list"];
if (Object.hasOwn(agents, "list") && list !== undefined) {
return { kind: "list", value: list };
}
return undefined;
}
/** True when raw config explicitly owns either supported roster representation. */
export function hasAgentRosterProperty(raw: unknown): boolean {
return readAgentRosterProperty(raw) !== undefined;
}
/** Lists unique configured agent ids. */
export function listAgentIds(cfg: OpenClawConfig): string[] {
const agents = listAgentEntries(cfg);
if (agents.length === 0) {
return [DEFAULT_AGENT_ID];
if (agents.length === 0 && !hasAgentRosterProperty(cfg)) {
// Match resolveDefaultAgentId's Plugin SDK compatibility for raw pre-roster configs.
return [LEGACY_IMPLICIT_AGENT_ID];
}
const seen = new Set<string>();
const ids: string[] = [];
@@ -94,29 +138,58 @@ export function listAgentIds(cfg: OpenClawConfig): string[] {
seen.add(id);
ids.push(id);
}
return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
return ids;
}
/** Resolves the default agent id, warning once when multiple defaults exist. */
/** Resolves the configured default while preserving the shipped Plugin SDK legacy shape. */
export function resolveDefaultAgentId(cfg: OpenClawConfig): string {
const agents = listAgentEntries(cfg);
if (agents.length === 0) {
return DEFAULT_AGENT_ID;
// Runtime config loading materializes this entry. Keep the roster-property-absent
// case for shipped Plugin SDK callers that still pass a pre-roster config object.
if (!hasAgentRosterProperty(cfg)) {
return LEGACY_IMPLICIT_AGENT_ID;
}
throw new Error("No agents configured. Run `openclaw onboard` or `openclaw agents add` first.");
}
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
warnMultipleDefaultAgents();
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
// Runtime config loading canonicalizes zero/multiple markers before this helper is called.
// External SDK callers may still pass the shipped list shape, which chose the first candidate.
return normalizeAgentId((agents.find((agent) => agent?.default === true) ?? agents[0])!.id);
}
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
/** Returns the configured default when diagnostics must tolerate an invalid raw roster. */
export function tryResolveDefaultAgentId(cfg: OpenClawConfig): string | undefined {
const agents = listAgentEntries(cfg);
const defaults = agents.filter((agent) => agent?.default === true);
if (defaults.length !== 1) {
return undefined;
}
return normalizeAgentId(defaults[0]!.id);
}
export function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgentEntries(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
/** Resolves the authored entry object for in-place canonical config mutations. */
export function resolveMutableAgentEntry(
cfg: OpenClawConfig,
agentId: string,
): Pick<AgentEntry, "model"> | undefined {
const id = normalizeAgentId(agentId);
const roster = readAgentRosterProperty(cfg);
if (roster?.kind === "entries" && roster.value && typeof roster.value === "object") {
const entries = roster.value as AgentEntriesConfig;
const key = Object.keys(entries).find((candidate) => normalizeAgentId(candidate) === id);
return key ? entries[key] : undefined;
}
if (roster?.kind === "list" && Array.isArray(roster.value)) {
return (roster.value as AgentEntry[]).find((entry) => normalizeAgentId(entry?.id) === id);
}
return undefined;
}
/** Resolves merged config for one agent id. */
export function resolveAgentConfig(
cfg: OpenClawConfig,
@@ -137,7 +210,10 @@ export function resolveAgentConfig(
? entry.model
: undefined,
...(entry.models ? { models: entry.models } : {}),
...(entry.params ? { params: entry.params } : {}),
...(entry.runtime ? { runtime: entry.runtime } : {}),
...(hasExplicitModelPolicyAllow(entry.modelPolicy) ? { modelPolicy: entry.modelPolicy } : {}),
...(entry.agentRuntime ? { agentRuntime: entry.agentRuntime } : {}),
utilityModel: readStringValue(entry.utilityModel),
thinkingDefault: entry.thinkingDefault,
verboseDefault: entry.verboseDefault ?? agentDefaults?.verboseDefault,
+8 -2
View File
@@ -1118,7 +1118,10 @@ describe("resolveAgentConfig", () => {
it("uses OPENCLAW_HOME for default agent workspace", () => {
const home = path.join(path.sep, "srv", "openclaw-home");
withEnv({ OPENCLAW_HOME: home }, () => {
const workspace = resolveAgentWorkspaceDir({} as OpenClawConfig, "main");
const workspace = resolveAgentWorkspaceDir(
{ agents: { entries: { main: { default: true } } } },
"main",
);
expect(workspace).toBe(path.join(path.resolve(home), ".openclaw", "workspace"));
});
});
@@ -1131,7 +1134,10 @@ describe("resolveAgentConfig", () => {
OPENCLAW_HOME: path.join(path.sep, "srv", "openclaw-home"),
},
() => {
const workspace = resolveAgentWorkspaceDir({} as OpenClawConfig, "main");
const workspace = resolveAgentWorkspaceDir(
{ agents: { entries: { main: { default: true } } } },
"main",
);
expect(workspace).toBe(path.resolve(workspaceDir));
},
);
+12 -19
View File
@@ -26,19 +26,24 @@ import { resolveEffectiveAgentSkillFilter } from "../skills/discovery/agent-filt
import { resolveUserPath } from "../utils.js";
import {
listAgentIds,
resolveMutableAgentEntry,
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "./agent-scope-config.js";
export {
listAgentEntries,
listAgentEntriesWithSource,
listAgentIds,
resolveMutableAgentEntry,
toAgentEntriesRecord,
resolveAgentConfig,
resolveAgentContextLimits,
resolveAgentDir,
resolveDefaultAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
tryResolveDefaultAgentId,
type ResolvedAgentConfig,
} from "./agent-scope-config.js";
@@ -367,20 +372,6 @@ export function resolveAgentEffectiveModelPrimary(
);
}
function findMutableAgentEntry(
cfg: OpenClawConfig,
agentId: string,
): Pick<AgentConfig, "model"> | undefined {
const id = normalizeAgentId(agentId);
const entryKey = Object.keys(cfg.agents?.entries ?? {}).find(
(key) => normalizeAgentId(key) === id,
);
if (entryKey) {
return cfg.agents?.entries?.[entryKey];
}
return cfg.agents?.list?.find((entry) => normalizeAgentId(entry?.id) === id);
}
function updateAgentModelPrimary(
existing: AgentModelConfig | undefined,
primary: string,
@@ -403,7 +394,7 @@ export function setAgentEffectiveModelPrimary(
// forceAgent pins the write to the agent entry even without an explicit
// model, so a per-agent override never rewrites the shared default route.
if (options.forceAgent || resolveAgentExplicitModelPrimary(cfg, id)) {
const entry = findMutableAgentEntry(cfg, id);
const entry = resolveMutableAgentEntry(cfg, id);
if (entry) {
entry.model = updateAgentModelPrimary(entry.model, primary);
return "agent";
@@ -543,10 +534,12 @@ export function resolveRunModelFallbacksOverride(params: {
if (!params.cfg) {
return undefined;
}
return resolveAgentModelFallbacksOverride(
params.cfg,
resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }),
);
const explicitAgentId = normalizeOptionalString(params.agentId);
const agentId = explicitAgentId
? normalizeAgentId(explicitAgentId)
: (parseAgentSessionKey(params.sessionKey)?.agentId ??
(listAgentIds(params.cfg).length > 0 ? resolveDefaultAgentId(params.cfg) : undefined));
return agentId ? resolveAgentModelFallbacksOverride(params.cfg, agentId) : undefined;
}
export function hasConfiguredModelFallbacks(params: {
+26
View File
@@ -395,6 +395,32 @@ describe("resolveSubagentToolPolicyForSession", () => {
});
describe("resolveEffectiveToolPolicy", () => {
it("applies implicit-main defaults tool restrictions to a pre-roster config", () => {
const cfg = {
agents: { defaults: { tools: { deny: ["exec"] } } },
} as unknown as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.agentId).toBe("main");
expect(result.agentPolicy).toEqual({ deny: ["exec"] });
});
it("uses the configured default agent policy for an unscoped session alias", () => {
const cfg = {
agents: {
entries: {
ops: { default: true, tools: { deny: ["exec"] } },
},
},
} satisfies OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg, sessionKey: "main" });
expect(result.agentId).toBe("ops");
expect(result.agentPolicy).toEqual({ deny: ["exec"] });
});
it("keeps slash-containing modelId scoped to the selected provider", () => {
const cfg = {
tools: {
+16 -4
View File
@@ -15,13 +15,14 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { logWarn } from "../logger.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import {
parseRawSessionConversationRef,
parseThreadSessionSuffix,
} from "../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
import { hasAgentRosterProperty } from "./agent-scope-config.js";
import { listAgentEntries, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
import { resolveProviderToolPolicy } from "./provider-tool-policy.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
@@ -382,10 +383,21 @@ export function resolveEffectiveToolPolicy(params: {
: undefined;
const agentId =
explicitAgentId ??
(params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined);
(params.sessionKey ? parseAgentSessionKey(params.sessionKey)?.agentId : undefined) ??
(params.config &&
(!hasAgentRosterProperty(params.config) || listAgentEntries(params.config).length > 0)
? resolveDefaultAgentId(params.config)
: undefined);
const agentConfig =
params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined;
const agentTools = agentConfig?.tools;
// Shipped pre-roster SDK inputs allowed this raw defaults shape. Runtime-loaded
// configs materialize main, but direct SDK callers still need its deny policy.
const implicitDefaultTools = params.config
? (params.config.agents?.defaults as { tools?: AgentToolsConfig } | undefined)?.tools
: undefined;
const agentTools =
agentConfig?.tools ??
(params.config && !hasAgentRosterProperty(params.config) ? implicitDefaultTools : undefined);
const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
@@ -10,6 +10,7 @@ import {
} from "../../config/model-input.js";
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { listAgentEntries } from "../agent-scope-config.js";
/** Provider/profile ids that may need external CLI auth discovery. */
export type ExternalCliAuthScope = {
@@ -123,7 +124,7 @@ export function resolveExternalCliAuthScopeFromConfig(
}
}
for (const agent of Object.values(cfg.agents?.entries ?? {})) {
for (const agent of listAgentEntries(cfg)) {
addProviderScopeFromModelConfig(providerIds, agent.model);
addProviderScopeFromModelConfig(providerIds, agent.subagents?.model);
addExternalCliRuntimeScopeFromModelMap(providerIds, agent.models);
+16 -7
View File
@@ -6,7 +6,6 @@
import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { resolveUserPath } from "../../utils.js";
import { resolveDefaultAgentDir } from "../agent-scope-config.js";
import {
AUTH_PROFILE_FILENAME,
AUTH_STATE_FILENAME,
@@ -14,22 +13,32 @@ import {
} from "./path-constants.js";
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
function resolveAuthAgentDir(agentDir?: string): string {
if (agentDir) {
return resolveUserPath(agentDir);
}
const configuredMainAgentDir = process.env.OPENCLAW_AGENT_DIR?.trim();
if (configuredMainAgentDir) {
return resolveUserPath(configuredMainAgentDir);
}
// The no-argument auth-store API names the shipped shared main store, not a
// configured-agent fallback. Roster-aware callers pass their resolved dir.
return path.join(resolveStateDir(), "agents", "main", "agent");
}
/** Resolve the persisted auth profile store path for an agent dir. */
export function resolveAuthStorePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
return path.join(resolved, AUTH_PROFILE_FILENAME);
return path.join(resolveAuthAgentDir(agentDir), AUTH_PROFILE_FILENAME);
}
/** Resolve the legacy auth store path used by migration code. */
export function resolveLegacyAuthStorePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
return path.join(resolved, LEGACY_AUTH_FILENAME);
return path.join(resolveAuthAgentDir(agentDir), LEGACY_AUTH_FILENAME);
}
/** Resolve the auth-state sidecar path for usage/cooldown metadata. */
export function resolveAuthStatePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
return path.join(resolved, AUTH_STATE_FILENAME);
return path.join(resolveAuthAgentDir(agentDir), AUTH_STATE_FILENAME);
}
/** Resolve the user-facing auth profile database path. */
@@ -44,6 +44,16 @@ describe("path-resolve helpers (direct-import coverage attribution)", () => {
});
});
it("honors OPENCLAW_AGENT_DIR in both no-argument auth path implementations", () => {
const relocatedAgentDir = path.join(stateDir, "relocated-main-agent");
withEnv({ OPENCLAW_STATE_DIR: stateDir, OPENCLAW_AGENT_DIR: relocatedAgentDir }, () => {
expect(path.dirname(resolveAuthStorePath())).toBe(relocatedAgentDir);
expect(resolveAuthStorePathForDisplay()).toBe(
path.join(relocatedAgentDir, "openclaw-agent.sqlite"),
);
});
});
it("resolveLegacyAuthStorePath joins agentDir with the legacy auth filename", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveLegacyAuthStorePath(agentDir);
+8 -2
View File
@@ -6,6 +6,7 @@
import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { resolveStateDir } from "../../config/paths.js";
import { sha256HexPrefix } from "../../infra/crypto-digest.js";
import {
clearNodeSqliteKyselyCacheForDatabase,
@@ -25,7 +26,6 @@ import {
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "../../state/openclaw-state-db.js";
import { resolveUserPath } from "../../utils.js";
import { resolveRegisteredAgentIdForDir } from "../agent-dir-registry.js";
import { resolveDefaultAgentDir } from "../agent-scope-config.js";
type AuthProfileDatabase = Pick<
OpenClawAgentKyselyDatabase,
@@ -37,7 +37,13 @@ type AuthProfileDatabase = Pick<
const PRIMARY_ROW_KEY = "primary";
function resolveAgentDir(agentDir?: string): string {
return resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
if (agentDir) {
return resolveUserPath(agentDir);
}
const configuredMainAgentDir = process.env.OPENCLAW_AGENT_DIR?.trim();
return configuredMainAgentDir
? resolveUserPath(configuredMainAgentDir)
: path.join(resolveStateDir(), "agents", "main", "agent");
}
function inferAgentIdFromDir(agentDir: string): string {
@@ -12,9 +12,13 @@ import { loadExecApprovals } from "../infra/exec-approvals.js";
import { sendMessage } from "../infra/outbound/message.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
import { createExecTool } from "./bash-tools.exec.js";
import { createExecTool as createExecToolImpl } from "./bash-tools.exec.js";
import { callGatewayTool } from "./tools/gateway.js";
const createExecTool = (
defaults?: Parameters<typeof createExecToolImpl>[0],
): ReturnType<typeof createExecToolImpl> => createExecToolImpl({ agentId: "main", ...defaults });
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(),
readGatewayCallOptions: vi.fn(() => ({})),
@@ -10,9 +10,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { resetProcessRegistryForTests } from "./bash-process-registry.test-support.js";
import { createExecTool } from "./bash-tools.exec.js";
import { createExecTool as createExecToolImpl } from "./bash-tools.exec.js";
import { callGatewayTool } from "./tools/gateway.js";
const createExecTool = (
defaults?: Parameters<typeof createExecToolImpl>[0],
): ReturnType<typeof createExecToolImpl> => createExecToolImpl({ agentId: "main", ...defaults });
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(),
readGatewayCallOptions: vi.fn(() => ({})),
+2 -3
View File
@@ -49,6 +49,7 @@ import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { safeJsonStringify } from "../utils/safe-json.js";
import { splitShellArgs } from "../utils/shell-argv.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
import type { HookContext } from "./agent-tools.before-tool-call.js";
import { stripMalformedXmlArgValueSuffixFromKeys } from "./agent-tools.params.js";
import { markBackgrounded } from "./bash-process-registry.js";
@@ -1305,9 +1306,7 @@ function resolveExecReviewerDefaults(params: { defaults?: ExecToolDefaults; agen
}
const cfg = params.defaults?.config;
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const agentExec = agentId
? cfg?.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)?.tools?.exec
: undefined;
const agentExec = agentId && cfg ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
return agentExec?.reviewer ?? cfg?.tools?.exec?.reviewer;
}
+11 -10
View File
@@ -366,12 +366,14 @@ function supportsPreparedOpenAIAuth(ctx: Parameters<AgentHarness["supports"]>[0]
function runSideQuestion(overrides: Partial<RunBtwSideQuestionParams> = {}) {
return runBtwSideQuestion({
cfg: {} as never,
cfg: { agents: { entries: { main: { default: true } } } } as never,
agentDir: DEFAULT_AGENT_DIR,
provider: DEFAULT_PROVIDER,
model: DEFAULT_MODEL,
question: DEFAULT_QUESTION,
sessionEntry: createSessionEntry(),
sessionKey: DEFAULT_SESSION_KEY,
storePath: DEFAULT_STORE_PATH,
resolvedReasoningLevel: DEFAULT_REASONING_LEVEL,
opts: {},
isNewSession: false,
@@ -704,7 +706,7 @@ describe("runBtwSideQuestion", () => {
);
const result = await runBtwSideQuestion({
cfg: {} as never,
cfg: { agents: { entries: { main: { default: true } } } } as never,
agentDir: DEFAULT_AGENT_DIR,
provider: DEFAULT_PROVIDER,
model: DEFAULT_MODEL,
@@ -979,12 +981,16 @@ describe("runBtwSideQuestion", () => {
resolveModelWithRegistryMock.mockReturnValue(platformModel);
resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined);
ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} });
getApiKeyForModelMock.mockResolvedValue({
apiKey: undefined,
mode: "api-key",
source: "none",
});
await expect(runSideQuestion({ provider: "openai", model: "gpt-5.5" })).resolves.toEqual({
text: "Codex side answer.",
});
expect(getApiKeyForModelMock).not.toHaveBeenCalled();
expect(codexSideQuestionMock).toHaveBeenCalledOnce();
const preparedRuntimeAuth = (
mockArg(codexSideQuestionMock, 0, 0) as {
@@ -997,20 +1003,13 @@ describe("runBtwSideQuestion", () => {
).preparedRuntimeAuth;
expect(preparedRuntimeAuth?.plan).toMatchObject({
harnessAuthProvider: "openai",
deferredRouteSupport: {
requestTransportOverrides: "none",
runtimePolicy: { compatibleIds: ["openclaw", "codex"] },
},
});
expect(preparedRuntimeAuth?.plan?.modelRoute).toBeUndefined();
expect(preparedRuntimeAuth?.plan?.forwardedAuthProfileId).toBeUndefined();
expect(preparedRuntimeAuth?.resolvedApiKey).toBeUndefined();
expect(Object.keys(preparedRuntimeAuth?.authProfileStore?.profiles ?? {})).toEqual([]);
expect(supports).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: expect.objectContaining({
requestTransportOverrides: "none",
runtimePolicy: { compatibleIds: ["openclaw", "codex"] },
preparedAuth: { source: "harness" },
}),
}),
@@ -2209,6 +2208,8 @@ describe("runBtwSideQuestion", () => {
model: "us.anthropic.claude-sonnet-4-5-v1:0",
question: DEFAULT_QUESTION,
sessionEntry: createSessionEntry(),
sessionKey: DEFAULT_SESSION_KEY,
storePath: DEFAULT_STORE_PATH,
resolvedReasoningLevel: DEFAULT_REASONING_LEVEL,
opts: {},
isNewSession: false,
+9 -4
View File
@@ -2227,7 +2227,10 @@ describe("runCliAgent spawn path", () => {
timeoutMs: 3_600_000,
}),
);
const rejection = expect(run).rejects.toThrow(/produced no output for 900s/);
const rejection = run.then(
() => undefined,
(error: unknown) => error,
);
await vi.waitFor(() => {
expect(stdin.write).toHaveBeenCalledOnce();
});
@@ -2243,14 +2246,16 @@ describe("runCliAgent spawn path", () => {
);
// Base watchdog (600s cap for a 1h budget) must not kill the quiet tool.
await vi.advanceTimersByTimeAsync(650_000);
vi.advanceTimersByTime(650_000);
expect(cancel).not.toHaveBeenCalled();
// The blocked-tool floor (15min of quiet) still terminates a wedged tool.
try {
await vi.advanceTimersByTimeAsync(300_000);
vi.advanceTimersByTime(300_000);
expect(cancel).toHaveBeenCalledWith("manual-cancel");
await rejection;
const error = await rejection;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/produced no output for 900s/);
// Watchdog-killed turns must keep timeout provenance for active tools.
expect(toolErrorEvents).toContainEqual(
expect.objectContaining({
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveClaudeLiveMode } from "./claude-live-session-policy.js";
import { readConfiguredExecPolicy } from "./claude-live-session.test-support.js";
import type { PreparedCliRunContext } from "./types.js";
describe("resolveClaudeLiveMode", () => {
it("keeps root on Claude default permissions while preserving YOLO elsewhere", () => {
@@ -11,3 +13,28 @@ describe("resolveClaudeLiveMode", () => {
expect(resolveClaudeLiveMode("allowlist", "on-miss", 1000)).toBe("default");
});
});
describe("Claude live configured exec policy", () => {
it("uses the configured default agent for an unscoped legacy session key", () => {
const context = {
params: {
sessionKey: "main",
config: {
tools: { exec: { security: "full", ask: "off" } },
agents: {
entries: {
main: {},
ops: { default: true, tools: { exec: { security: "deny", ask: "always" } } },
},
},
},
},
} as unknown as PreparedCliRunContext;
expect(readConfiguredExecPolicy(context)).toEqual({
agentId: "ops",
security: "deny",
ask: "always",
});
});
});
@@ -1,5 +1,6 @@
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
import "./claude-live-session.js";
import type { PreparedCliRunContext } from "./types.js";
type BuildClaudeLiveArgsParams = {
args: string[];
@@ -11,6 +12,11 @@ type BuildClaudeLiveArgsParams = {
type ClaudeLiveSessionTestApi = {
buildClaudeLiveArgs(params: BuildClaudeLiveArgsParams): string[];
readConfiguredExecPolicy(context: PreparedCliRunContext): {
security: string;
ask: string;
agentId: string;
};
resetClaudeLiveSessionsForTest(): void;
};
@@ -24,6 +30,10 @@ export function buildClaudeLiveArgs(params: BuildClaudeLiveArgsParams): string[]
return getTestApi().buildClaudeLiveArgs(params);
}
export function readConfiguredExecPolicy(context: PreparedCliRunContext) {
return getTestApi().readConfiguredExecPolicy(context);
}
export function resetClaudeLiveSessionsForTest(): void {
getTestApi().resetClaudeLiveSessionsForTest();
}
+17 -4
View File
@@ -24,7 +24,11 @@ import {
} from "../../infra/exec-approvals.js";
import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js";
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import {
LEGACY_IMPLICIT_AGENT_ID,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { resolveAgentConfig, resolveDefaultAgentId } from "../agent-scope-config.js";
import {
CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS,
createCliJsonlStreamingParser,
@@ -333,6 +337,7 @@ function buildClaudeLiveArgs(params: {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.claudeLiveSessionTestApi")] = {
buildClaudeLiveArgs,
readConfiguredExecPolicy,
resetClaudeLiveSessionsForTest,
};
}
@@ -974,9 +979,17 @@ function readConfiguredExecPolicy(context: PreparedCliRunContext): {
ask: ExecAsk;
agentId: string;
} {
const agentId = context.params.agentId ?? resolveAgentIdFromSessionKey(context.params.sessionKey);
const agentExec = context.params.config?.agents?.list?.find((agent) => agent.id === agentId)
?.tools?.exec;
const agentId =
context.params.agentId ??
resolveAgentIdFromSessionKey(
context.params.sessionKey,
context.params.config
? resolveDefaultAgentId(context.params.config)
: LEGACY_IMPLICIT_AGENT_ID,
);
const agentExec = context.params.config
? resolveAgentConfig(context.params.config, agentId)?.tools?.exec
: undefined;
const exec = agentExec ?? context.params.config?.tools?.exec;
const configured = resolveExecModePolicy({
mode: exec?.mode,
@@ -104,7 +104,9 @@ function buildPreparedCliRunContext(params: {
return {
params: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
+28 -4
View File
@@ -5,6 +5,7 @@ import { ensureSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
*/
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
assertContextEngineHostSupport,
buildGenericCliContextEngineHostSupport,
@@ -34,13 +35,18 @@ import type {
} from "../../plugins/cli-backend.types.js";
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import {
LEGACY_IMPLICIT_AGENT_ID,
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js";
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
import { resolveUserPath } from "../../utils.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveAgentWorkspaceDir } from "../agent-scope-config.js";
import { hasAgentRosterProperty, resolveAgentWorkspaceDir } from "../agent-scope-config.js";
import { resolveAgentConfig, resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js";
import { resolveApiKeyForProfile } from "../auth-profiles/oauth.js";
@@ -325,7 +331,25 @@ function shouldRefreshAuthProfileForExecution(params: {
export async function prepareCliRunContext(
inputParams: RunCliAgentParams,
): Promise<PreparedCliRunContext> {
let params = inputParams;
let params = inputParams.config ? inputParams : { ...inputParams, config: getRuntimeConfig() };
const runConfig = params.config!;
const selectedOwner = normalizeAgentId(
params.agentId?.trim() ||
parseAgentSessionKey(params.sessionKey)?.agentId ||
LEGACY_IMPLICIT_AGENT_ID,
);
// Direct CLI-runner callers predate roster-aware ownership. Adapt that SDK
// input only for strict workspace admission; keep the original config object
// for backend hooks, sandboxing, and context-engine identity contracts.
const workspaceConfig = hasAgentRosterProperty(runConfig)
? runConfig
: ({
...runConfig,
agents: {
...runConfig.agents,
entries: { [selectedOwner]: { default: true } },
},
} satisfies OpenClawConfig);
const started = Date.now();
const executionMode = params.executionMode ?? "agent";
const isSideQuestion = executionMode === "side-question";
@@ -333,7 +357,7 @@ export async function prepareCliRunContext(
workspaceDir: params.workspaceDir,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
config: workspaceConfig,
});
const resolvedWorkspace = workspaceResolution.workspaceDir;
const redactedSessionId = redactRunIdentifier(params.sessionId);
@@ -91,12 +91,14 @@ describe("attempt execution prompt materialization", () => {
});
describe("persistSessionEntry", () => {
const sessionKey = "agent:main:main";
it("clears stale local entries when guarded persistence sees no persisted entry", async () => {
const dir = tempDirs.make("openclaw-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const sessionStore = {
main: {
[sessionKey]: {
sessionId: "stale",
updatedAt: 1,
},
@@ -106,9 +108,9 @@ describe("persistSessionEntry", () => {
// memory must be cleared too so later turns do not reuse stale entries.
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry: sessionStore.main,
initialEntry: sessionStore[sessionKey],
entry: {
sessionId: "stale",
updatedAt: 2,
@@ -117,7 +119,7 @@ describe("persistSessionEntry", () => {
});
expect(persisted).toBeUndefined();
expect(sessionStore.main).toBeUndefined();
expect(sessionStore[sessionKey]).toBeUndefined();
} finally {
clearSessionStoreCacheForTest();
}
@@ -155,12 +157,12 @@ describe("persistSessionEntry", () => {
if (current.pinnedAt === undefined) {
delete currentEntry.pinnedAt;
}
await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry);
const sessionStore = { main: staleEntry };
await replaceSessionEntry({ sessionKey, storePath }, currentEntry);
const sessionStore = { [sessionKey]: staleEntry };
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry: staleEntry,
entry: {
@@ -174,10 +176,10 @@ describe("persistSessionEntry", () => {
expect(persisted?.label).toBe(expected.label);
expect(persisted?.pinnedAt).toBe(expected.pinnedAt);
expect(persisted?.updatedAt).toBeGreaterThanOrEqual(currentEntry.updatedAt);
expect(sessionStore.main).toEqual(persisted);
expect(
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
).toEqual(persisted);
expect(sessionStore[sessionKey]).toEqual(persisted);
expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual(
persisted,
);
} finally {
clearSessionStoreCacheForTest();
}
@@ -201,12 +203,12 @@ describe("persistSessionEntry", () => {
model: "gpt-5.4",
sendPolicy: "deny",
};
await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry);
const sessionStore = { main: initialEntry };
await replaceSessionEntry({ sessionKey, storePath }, currentEntry);
const sessionStore = { [sessionKey]: initialEntry };
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry,
entry: {
@@ -224,9 +226,9 @@ describe("persistSessionEntry", () => {
});
expect(persisted?.elevatedLevel).toBeUndefined();
expect(persisted?.inheritedToolAllow).toBeUndefined();
expect(
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
).toEqual(persisted);
expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual(
persisted,
);
} finally {
clearSessionStoreCacheForTest();
}
@@ -240,11 +242,11 @@ describe("persistSessionEntry", () => {
sessionId: "deleted-session",
updatedAt: 1,
};
const sessionStore = { main: staleEntry };
const sessionStore = { [sessionKey]: staleEntry };
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry: staleEntry,
entry: {
@@ -254,9 +256,9 @@ describe("persistSessionEntry", () => {
});
expect(persisted).toBeUndefined();
expect(sessionStore.main).toBeUndefined();
expect(sessionStore[sessionKey]).toBeUndefined();
expect(
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" }),
).toBeUndefined();
} finally {
clearSessionStoreCacheForTest();
@@ -271,20 +273,18 @@ describe("persistSessionEntry", () => {
sessionId: "deleted-session",
updatedAt: 1,
};
const sessionStore = {
main: staleEntry,
};
const sessionStore = { [sessionKey]: staleEntry };
const first = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry: staleEntry,
entry: staleEntry,
});
const second = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry: staleEntry,
entry: {
@@ -295,9 +295,9 @@ describe("persistSessionEntry", () => {
expect(first).toBeUndefined();
expect(second).toBeUndefined();
expect(sessionStore.main).toBeUndefined();
expect(sessionStore[sessionKey]).toBeUndefined();
expect(
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" }),
).toBeUndefined();
} finally {
clearSessionStoreCacheForTest();
@@ -316,7 +316,7 @@ describe("persistSessionEntry", () => {
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
sessionKey,
storePath,
initialEntry: entry,
entry,
@@ -324,7 +324,7 @@ describe("persistSessionEntry", () => {
});
expect(persisted?.sessionId).toBe("created-session");
expect(sessionStore.main?.sessionId).toBe("created-session");
expect(sessionStore[sessionKey]?.sessionId).toBe("created-session");
} finally {
clearSessionStoreCacheForTest();
}
+2
View File
@@ -13,6 +13,7 @@ import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-m
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { resolveNonNegativeNumber } from "../../shared/number-coercion.js";
import { resolveDefaultAgentId } from "../agent-scope.js";
import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { clearMainSessionRecoveryAfterAgentRun } from "../main-session-recovery-clear.js";
@@ -141,6 +142,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
entry,
sessionKey,
storePath,
defaultAgentId: resolveDefaultAgentId(cfg),
newSessionId: sessionId,
});
next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey;
+6 -4
View File
@@ -256,13 +256,15 @@ export function resolveStoredSessionKeyForSessionId(opts: {
agentId?: string;
}): SessionKeyResolution {
const sessionId = opts.sessionId.trim();
const storeAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
const storeAgentId = opts.agentId?.trim()
? normalizeAgentId(opts.agentId)
: resolveDefaultAgentId(opts.cfg);
const storePath = resolveStorePath(opts.cfg.session?.store, {
agentId: storeAgentId,
});
const sessionStore = loadCommandSessionStore({
storePath,
...(storeAgentId ? { agentId: storeAgentId } : {}),
agentId: storeAgentId,
});
if (!sessionId) {
return { sessionKey: undefined, sessionStore, storePath };
@@ -311,7 +313,7 @@ export function resolveSessionKeyForRequest(opts: {
const storeAgentId = explicitSessionKey
? isUnscopedSessionKeySentinel(explicitSessionKey)
? (requestedAgentId ?? defaultAgentId)
: resolveAgentIdFromSessionKey(explicitSessionKey)
: resolveAgentIdFromSessionKey(explicitSessionKey, defaultAgentId)
: (requestedAgentId ?? defaultAgentId);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
@@ -407,7 +409,7 @@ export function resolveSession(opts: {
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
const sessionAgentId = opts.agentId?.trim()
? normalizeAgentId(opts.agentId)
: resolveAgentIdFromSessionKey(sessionKey);
: resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(opts.cfg));
const resetType = resolveSessionResetType({ sessionKey });
const channelReset = resolveChannelResetConfig({
+16 -9
View File
@@ -3,20 +3,27 @@ import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { modelSelectionShouldEnsureCopilotRuntimePlugin } from "./copilot-routing.js";
const emptyCfg = {} as OpenClawConfig;
function withDefaultRoster(config: OpenClawConfig = {}): OpenClawConfig {
return {
...config,
agents: { entries: { main: { default: true } }, ...config.agents },
};
}
const emptyCfg = withDefaultRoster();
function cfgWithProviderRuntime(id: string): OpenClawConfig {
return {
return withDefaultRoster({
models: {
providers: {
"github-copilot": { agentRuntime: { id } },
},
},
} as unknown as OpenClawConfig;
} as unknown as OpenClawConfig);
}
function cfgWithModelRuntime(modelId: string, id: string): OpenClawConfig {
return {
return withDefaultRoster({
models: {
providers: {
"github-copilot": {
@@ -24,7 +31,7 @@ function cfgWithModelRuntime(modelId: string, id: string): OpenClawConfig {
},
},
},
} as unknown as OpenClawConfig;
} as unknown as OpenClawConfig);
}
describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
@@ -84,7 +91,7 @@ describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
it("model-scope override takes precedence over provider scope", () => {
// A model override can intentionally opt out even when the provider default
// opts into the Copilot runtime plugin.
const cfg = {
const cfg = withDefaultRoster({
models: {
providers: {
"github-copilot": {
@@ -93,7 +100,7 @@ describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
},
},
},
} as unknown as OpenClawConfig;
} as unknown as OpenClawConfig);
expect(
modelSelectionShouldEnsureCopilotRuntimePlugin({
model: "github-copilot/gpt-4o",
@@ -110,13 +117,13 @@ describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
});
it("returns false for other providers regardless of agentRuntime config", () => {
const cfg = {
const cfg = withDefaultRoster({
models: {
providers: {
openai: { agentRuntime: { id: "copilot" } },
},
},
} as unknown as OpenClawConfig;
} as unknown as OpenClawConfig);
expect(
modelSelectionShouldEnsureCopilotRuntimePlugin({ model: "openai/gpt-4o", config: cfg }),
).toBe(false);
@@ -304,7 +304,8 @@ async function runCompactionHooks(params: { sessionKey?: string; messageProvider
beforeAll(async () => {
const loaded = await loadCompactHooksHarness();
compactEmbeddedAgentSessionDirect = loaded.compactEmbeddedAgentSessionDirect;
compactEmbeddedAgentSessionDirect = (params) =>
loaded.compactEmbeddedAgentSessionDirect({ agentId: "main", ...params });
compactEmbeddedAgentSession = loaded.compactEmbeddedAgentSession;
compactTesting = loaded.testing;
onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate;
@@ -416,6 +416,7 @@ export const mockedMarkAuthProfileSuccess = vi.fn(async () => {});
const mockedShouldPreferExplicitConfigApiKeyAuth = vi.fn(() => false);
export const overflowBaseRunParams = {
agentId: "main",
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
@@ -1003,7 +1004,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
});
const { runEmbeddedAgent } = await import("./run.js");
return { runEmbeddedAgent };
return {
runEmbeddedAgent: (params) =>
runEmbeddedAgent({ ...params, agentId: params.agentId ?? "main" }),
};
}
/** Move one-time runner compilation out of individual behavior timings. */
@@ -2524,6 +2524,7 @@ describe("embedded attempt session lock lifecycle", () => {
},
async () =>
await appendExactAssistantMessageToSessionTranscript({
agentId: "main",
sessionKey,
storePath,
message: {
+33 -22
View File
@@ -1,9 +1,17 @@
// Verifies exec host, sandbox, and approval-default resolution for embedded agents.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import * as execApprovals from "../infra/exec-approvals.js";
import { resolveExecDefaults, resolveNodeExecEligibility } from "./exec-defaults.js";
function withDefaultAgent(config: OpenClawConfig): OpenClawConfig {
return {
...config,
agents: { ...config.agents, list: [{ id: "main", default: true }] },
};
}
describe("resolveExecDefaults", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -16,13 +24,13 @@ describe("resolveExecDefaults", () => {
it("does not advertise node routing when exec host is pinned to gateway", () => {
expect(
resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "gateway",
},
},
},
}),
sandboxAvailable: false,
}).canRequestNode,
).toBe(false);
@@ -30,13 +38,13 @@ describe("resolveExecDefaults", () => {
it("does not advertise node routing when exec host is auto and sandbox is available", () => {
const defaults = resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "auto",
},
},
},
}),
sandboxAvailable: true,
});
@@ -47,13 +55,13 @@ describe("resolveExecDefaults", () => {
it("keeps node routing available when exec host is auto without sandbox", () => {
const defaults = resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "auto",
},
},
},
}),
sandboxAvailable: false,
});
@@ -68,13 +76,13 @@ describe("resolveExecDefaults", () => {
} as SessionEntry;
expect(
resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "gateway",
},
},
},
}),
sessionEntry,
sandboxAvailable: false,
}).canRequestNode,
@@ -83,13 +91,13 @@ describe("resolveExecDefaults", () => {
it("uses host approval defaults for gateway when exec policy is unset", () => {
const defaults = resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "auto",
},
},
},
}),
sandboxAvailable: false,
});
@@ -102,13 +110,13 @@ describe("resolveExecDefaults", () => {
it("keeps sandbox deny by default when auto resolves to sandbox", () => {
const defaults = resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "auto",
},
},
},
}),
sandboxAvailable: true,
});
@@ -130,13 +138,13 @@ describe("resolveExecDefaults", () => {
});
const defaults = resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "auto",
},
},
},
}),
sandboxAvailable: true,
});
@@ -151,13 +159,13 @@ describe("resolveExecDefaults", () => {
it("maps normalized auto mode to allowlist plus on-miss approvals", () => {
expect(
resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
mode: "auto",
},
},
},
}),
sandboxAvailable: false,
}),
).toMatchObject({
@@ -181,13 +189,13 @@ describe("resolveExecDefaults", () => {
// mapping so persisted host policy remains the final safety boundary.
expect(
resolveExecDefaults({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
mode: "auto",
},
},
},
}),
sandboxAvailable: false,
}),
).toMatchObject({
@@ -216,6 +224,7 @@ describe("resolveExecDefaults", () => {
mode: "full",
},
},
agents: { list: [{ id: "agent-a", default: true }] },
},
agentId: "agent-a",
sandboxAvailable: false,
@@ -240,6 +249,7 @@ describe("resolveExecDefaults", () => {
list: [
{
id: "agent-a",
default: true,
tools: {
exec: {
mode: "full",
@@ -272,6 +282,7 @@ describe("resolveExecDefaults", () => {
list: [
{
id: "agent-a",
default: true,
tools: {
exec: {
mode: "allowlist",
@@ -294,7 +305,7 @@ describe("resolveExecDefaults", () => {
it("blocks node skill eligibility for deny policy and preserves node bindings", () => {
expect(
resolveNodeExecEligibility({
cfg: {
cfg: withDefaultAgent({
tools: {
exec: {
host: "node",
@@ -302,7 +313,7 @@ describe("resolveExecDefaults", () => {
node: "build-mac",
},
},
},
}),
}),
).toEqual({ canExec: false, node: "build-mac" });
});
@@ -310,10 +321,10 @@ describe("resolveExecDefaults", () => {
it("blocks node skill eligibility when the gateway denies system.run", () => {
expect(
resolveNodeExecEligibility({
cfg: {
cfg: withDefaultAgent({
gateway: { nodes: { commands: { deny: [" system.run "] } } },
tools: { exec: { host: "node", mode: "full" } },
},
}),
}),
).toEqual({ canExec: false });
});
+10 -5
View File
@@ -73,6 +73,7 @@ function resolveExecConfigState(params: {
execOverrides?: ExecPolicyOverrides;
agentId?: string;
sessionKey?: string;
scope?: { kind: "defaults" };
}): {
cfg: OpenClawConfig;
host: ExecTarget;
@@ -82,11 +83,13 @@ function resolveExecConfigState(params: {
} {
const cfg = params.cfg ?? {};
const resolvedAgentId =
params.agentId ??
resolveSessionAgentId({
sessionKey: params.sessionKey,
config: cfg,
});
params.scope?.kind === "defaults"
? undefined
: (params.agentId ??
resolveSessionAgentId({
sessionKey: params.sessionKey,
config: cfg,
}));
const globalExec = cfg.tools?.exec;
const agentExec = resolvedAgentId
? resolveAgentConfig(cfg, resolvedAgentId)?.tools?.exec
@@ -132,6 +135,8 @@ export function resolveExecDefaults(params: {
execOverrides?: ExecPolicyOverrides;
agentId?: string;
sessionKey?: string;
/** Resolve agents.defaults/tools.exec without applying any roster entry override. */
scope?: { kind: "defaults" };
sandboxAvailable?: boolean;
elevatedRequested?: boolean;
}): {
+6 -1
View File
@@ -6,7 +6,9 @@ import { isStrictAgenticExecutionContractActive } from "./execution-contract.js"
describe("isStrictAgenticExecutionContractActive", () => {
const supportedProvider = "openai";
const unsupportedProvider = "anthropic";
const emptyConfig: OpenClawConfig = {};
const emptyConfig: OpenClawConfig = {
agents: { entries: { main: { default: true } } },
};
describe("supported provider + model detection", () => {
it("auto-activates on bare gpt-5 model ids", () => {
@@ -136,6 +138,7 @@ describe("isStrictAgenticExecutionContractActive", () => {
it("honors explicit strict-agentic on the supported lane", () => {
const config: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
embeddedAgent: {
executionContract: "strict-agentic",
@@ -155,6 +158,7 @@ describe("isStrictAgenticExecutionContractActive", () => {
it("honors explicit default opt-out even on the supported lane", () => {
const config: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
embeddedAgent: {
executionContract: "default",
@@ -174,6 +178,7 @@ describe("isStrictAgenticExecutionContractActive", () => {
it("collapses explicit strict-agentic to default on an unsupported lane", () => {
const config: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
embeddedAgent: {
executionContract: "strict-agentic",
+14 -2
View File
@@ -1,7 +1,18 @@
// Covers config scanning for agent harness runtime requirements.
import { describe, expect, it } from "vitest";
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { collectConfiguredAgentHarnessRuntimes } from "./harness-runtimes.js";
import { collectConfiguredAgentHarnessRuntimes as collectConfiguredAgentHarnessRuntimesBase } from "./harness-runtimes.js";
function collectConfiguredAgentHarnessRuntimes(
config: OpenClawConfig,
options?: Parameters<typeof collectConfiguredAgentHarnessRuntimesBase>[1],
) {
return collectConfiguredAgentHarnessRuntimesBase(
migratePersistedImplicitMainRoster(config).config as OpenClawConfig,
options,
);
}
describe("collectConfiguredAgentHarnessRuntimes", () => {
it("requires Codex for selectable default OpenAI agent models", () => {
@@ -100,7 +111,7 @@ describe("collectConfiguredAgentHarnessRuntimes", () => {
expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual([]);
});
it("ignores malformed agents.list while scanning best-effort config", () => {
it("ignores a malformed legacy list when canonical entries are available", () => {
// Runtime collection is diagnostic/setup support, so malformed optional
// agent lists should not hide valid defaults-level runtime requirements.
const config = {
@@ -112,6 +123,7 @@ describe("collectConfiguredAgentHarnessRuntimes", () => {
},
},
},
entries: { main: { default: true } },
list: {
ops: {
id: "ops",
+3 -5
View File
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isRecord } from "../utils.js";
import { OPENCLAW_AGENT_RUNTIME_ID, isDefaultAgentRuntimeId } from "./agent-runtime-id.js";
import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js";
import { listAgentEntries } from "./agent-scope-config.js";
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
// Harness runtime discovery feeds plugin preloading/setup. Only plugin runtimes
@@ -115,7 +116,7 @@ function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set<str
}
};
pushModelMapRuntimeIds(config.agents?.defaults?.models);
const agents = Array.isArray(config.agents?.list) ? config.agents.list : [];
const agents = listAgentEntries(config);
for (const agent of agents) {
pushModelMapRuntimeIds(isRecord(agent) ? agent.models : undefined);
}
@@ -152,10 +153,7 @@ function pushConfiguredAgentModelRuntimeIds(
pushModelRefs(defaultsModelRefs);
pushModelMapRefs(config.agents?.defaults?.models);
if (!Array.isArray(config.agents?.list)) {
return;
}
for (const agent of config.agents.list) {
for (const agent of listAgentEntries(config)) {
if (!isRecord(agent)) {
continue;
}
@@ -75,7 +75,7 @@ function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEng
const sessionParams = {
sessionIdUsed: "session-1",
sessionId: "session-1",
sessionKey: "agent:main",
sessionKey: "agent:main:main",
sessionFile: "sessions/main.jsonl",
};
+11 -1
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveAgentHarnessPolicy } from "./policy.js";
import { resolveAgentHarnessPolicy as resolveAgentHarnessPolicyBase } from "./policy.js";
function resolveAgentHarnessPolicy(
params: Parameters<typeof resolveAgentHarnessPolicyBase>[0],
): ReturnType<typeof resolveAgentHarnessPolicyBase> {
return resolveAgentHarnessPolicyBase({
...params,
config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig,
});
}
function openAIProviderConfig(overrides: Record<string, unknown>): OpenClawConfig {
return {
+11 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { runWithAgentRingZeroTools } from "../agent-tools.ring-zero-context.js";
import { createStubTool } from "../test-helpers/agent-tool-stubs.js";
@@ -9,7 +10,16 @@ import {
TOOL_SEARCH_RAW_TOOL_NAME,
} from "../tool-search.js";
import { testing } from "../tool-search.test-support.js";
import { createAgentHarnessToolSurfaceRuntime } from "./tool-surface-bridge.js";
import { createAgentHarnessToolSurfaceRuntime as createAgentHarnessToolSurfaceRuntimeBase } from "./tool-surface-bridge.js";
function createAgentHarnessToolSurfaceRuntime(
params: Parameters<typeof createAgentHarnessToolSurfaceRuntimeBase>[0],
): ReturnType<typeof createAgentHarnessToolSurfaceRuntimeBase> {
return createAgentHarnessToolSurfaceRuntimeBase({
...params,
config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig,
});
}
function tools(names: string[]) {
return names.map(createStubTool);
@@ -9,6 +9,7 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => {
config: {
agents: {
defaults: { heartbeat: {} },
entries: { main: { default: true } },
},
},
agentId: "main",
@@ -27,6 +28,7 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => {
every: "0m",
},
},
entries: { main: { default: true } },
},
},
agentId: "main",
+2 -2
View File
@@ -114,7 +114,7 @@ describe("internal session effects", () => {
delivery: { kind: "internal" },
createdAt: expect.any(Number),
});
expect(listSessionEntries({ storePath })).toEqual([]);
expect(listSessionEntries({ agentId: "main", storePath })).toEqual([]);
await expect(loadTranscriptEvents(target)).resolves.toEqual([
expect.objectContaining({ id: target.sessionId, type: "session" }),
]);
@@ -173,7 +173,7 @@ describe("internal session effects", () => {
type: "message",
}),
);
expect(listSessionEntries({ storePath })).toEqual([
expect(listSessionEntries({ agentId: "main", storePath })).toEqual([
expect.objectContaining({ sessionKey: source.sessionKey }),
]);
});
+7 -1
View File
@@ -56,6 +56,7 @@ describe("local model lean tool filtering", () => {
it("keeps explicitly preserved tools when lean mode is enabled", () => {
const cfg: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
experimental: {
localModelLean: true,
@@ -98,6 +99,7 @@ describe("local model lean tool filtering", () => {
it("keeps image understanding while trimming optional media production tools", () => {
const cfg: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
experimental: {
localModelLean: true,
@@ -136,6 +138,7 @@ describe("local model lean tool filtering", () => {
it("does not treat wildcard preservation as disabling lean mode", () => {
const cfg: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
experimental: {
localModelLean: true,
@@ -155,7 +158,10 @@ describe("local model lean tool filtering", () => {
it("matches wildcard preservation without treating a bare wildcard as an override", () => {
const cfg: OpenClawConfig = {
agents: { defaults: { experimental: { localModelLean: true } } },
agents: {
defaults: { experimental: { localModelLean: true } },
entries: { main: { default: true } },
},
};
expect(
filterLocalModelLeanTools({
+5 -2
View File
@@ -26,7 +26,7 @@ import {
type DeliveryContext,
} from "../utils/delivery-context.shared.js";
import { isDeliverableMessageChannel } from "../utils/message-channel.js";
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js";
import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js";
import {
@@ -99,7 +99,10 @@ export function resolveRestartRecoveryResumeBlockReason(params: {
return "pre-hook recovery runtime config is unavailable";
}
try {
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const agentId = resolveAgentIdFromSessionKey(
params.sessionKey,
resolveDefaultAgentId(params.cfg),
);
ensureRuntimePluginsLoaded({
config: params.cfg,
workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId),
@@ -2318,6 +2318,7 @@ describe("main-session-restart-recovery", () => {
]);
const result = await retryRestartAbortedMainSessionRecoveryAfterOwnerRelease({
cfg: { agents: { entries: { main: { default: true } } } },
expectedSessionId: "legacy-session",
sessionKey: "main",
storePath,
@@ -2327,7 +2328,7 @@ describe("main-session-restart-recovery", () => {
expect(callGateway).toHaveBeenCalledOnce();
expect(gatewayParams()).toMatchObject({
expectedExistingSessionId: "legacy-session",
sessionKey: "main",
sessionKey: "agent:main:main",
});
expect(
sessionAccessor.loadExactSessionEntry({ sessionKey: "main", storePath })?.entry,
@@ -2562,7 +2563,7 @@ describe("main-session-restart-recovery", () => {
.mockResolvedValueOnce({ runId: "run-resumed" });
scheduleRestartAbortedMainSessionRecovery({
cfg: {},
cfg: { agents: { entries: { main: { default: true } } } },
delayMs: 0,
maxRetries: 1,
stateDir: tmpDir,
+15 -3
View File
@@ -48,6 +48,7 @@ import {
} from "../sessions/session-lifecycle-admission.js";
import { buildRunUserTurnIdempotencyKey } from "../sessions/user-turn-transcript.js";
import type { DeliveryContext } from "../utils/delivery-context.shared.js";
import { resolveDefaultAgentId } from "./agent-scope-config.js";
import { isAnnounceRunId } from "./announce-idempotency.js";
import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME } from "./code-mode-control-tools.js";
import {
@@ -1232,6 +1233,7 @@ type RecoveryCheckpointCompletion =
| { outcome: "unsafe-transcript"; reason: string };
async function markSessionCompletedAfterRecoveryCheckpoint(params: {
agentId: string;
entry: SessionEntry;
messages: readonly unknown[];
reason: "delivered-terminal" | "delivered-terminal-receipt" | "handled-silent";
@@ -1390,7 +1392,7 @@ async function markSessionCompletedAfterRecoveryCheckpoint(params: {
};
const persisted = await persistSessionTranscriptTurn(
{
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
agentId: params.agentId,
sessionId: params.entry.sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
@@ -1497,12 +1499,13 @@ async function sendUnresumableSessionNotice(params: {
}
async function writeUnresumableSessionNotice(params: {
agentId: string;
entry: SessionEntry;
sessionKey: string;
storePath: string;
}): Promise<boolean> {
const result = await appendAssistantMessageToSessionTranscript({
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
agentId: params.agentId,
sessionKey: params.sessionKey,
expectedSessionId: params.entry.sessionId,
expectedSessionState: {
@@ -1554,6 +1557,10 @@ async function failUnresumableMainSession(params: {
if (
!deliveryContext &&
!(await writeUnresumableSessionNotice({
agentId: resolveAgentIdFromSessionKey(
params.sessionKey,
params.cfg ? resolveDefaultAgentId(params.cfg) : undefined,
),
entry: params.entry,
sessionKey: params.sessionKey,
storePath: params.storePath,
@@ -1711,6 +1718,10 @@ async function recoverStore(params: {
a.sessionKey.localeCompare(b.sessionKey),
)) {
let entry = loadedEntry;
const agentId = resolveAgentIdFromSessionKey(
sessionKey,
params.cfg ? resolveDefaultAgentId(params.cfg) : undefined,
);
if (!entry || entry.status !== "running" || entry.abortedLastRun !== true) {
continue;
}
@@ -1899,7 +1910,7 @@ async function recoverStore(params: {
try {
messages = await readSessionMessagesAsync(
{
agentId: resolveAgentIdFromSessionKey(sessionKey),
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
@@ -2000,6 +2011,7 @@ async function recoverStore(params: {
);
if (resumePolicy.action === "complete") {
const completion = await markSessionCompletedAfterRecoveryCheckpoint({
agentId,
entry,
messages,
reason: resumePolicy.reason,
+5 -3
View File
@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { modelKey } from "../shared/model-key.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
type ModelExtraParamSources = {
defaultParams?: Record<string, unknown>;
@@ -28,9 +29,10 @@ export function resolveModelExtraParamSources(params: {
? (configuredModels?.[canonicalKey]?.params ??
(legacyKey ? configuredModels?.[legacyKey]?.params : undefined))
: undefined;
const agentParams = params.agentId
? params.config?.agents?.list?.find((agent) => agent.id === params.agentId)?.params
: undefined;
const agentParams =
params.agentId && params.config
? resolveAgentConfig(params.config, params.agentId)?.params
: undefined;
return { defaultParams, modelParams, agentParams };
}
+11 -1
View File
@@ -1,13 +1,23 @@
// Covers model runtime policy precedence and private QA runtime overrides.
import { afterEach, describe, expect, it } from "vitest";
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { resolveModelRuntimePolicy } from "./model-runtime-policy.js";
import { resolveModelRuntimePolicy as resolveModelRuntimePolicyBase } from "./model-runtime-policy.js";
const ORIGINAL_BUILD_PRIVATE_QA = process.env.OPENCLAW_BUILD_PRIVATE_QA;
const ORIGINAL_QA_FORCE_RUNTIME = process.env.OPENCLAW_QA_FORCE_RUNTIME;
function resolveModelRuntimePolicy(
params: Parameters<typeof resolveModelRuntimePolicyBase>[0],
): ReturnType<typeof resolveModelRuntimePolicyBase> {
return resolveModelRuntimePolicyBase({
...params,
config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig,
});
}
const createModelConfig = (
agentRuntimeId: string,
modelId = "qwen-local",
+3 -9
View File
@@ -17,7 +17,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "../plugins/runtime-state.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
import { findModelCatalogEntry } from "./model-catalog-lookup.js";
@@ -132,10 +132,7 @@ function createModelManifestPluginContext(params: {
function listModelAliasCandidates(cfg: OpenClawConfig, agentId?: string): ModelAliasCandidate[] {
const modelMaps = [cfg.agents?.defaults?.models];
if (agentId) {
const normalizedAgentId = normalizeAgentId(agentId);
const agentModels = cfg.agents?.list?.find(
(entry) => normalizeAgentId(entry.id) === normalizedAgentId,
)?.models;
const agentModels = resolveAgentConfig(cfg, agentId)?.models;
modelMaps.push(agentModels);
}
return modelMaps.flatMap((models) =>
@@ -1499,10 +1496,7 @@ export function resolveConfiguredModelPolicyAllow(params: {
}): { refs: readonly string[]; configPath: string | null; repairConfigPath: string } {
const defaults = params.cfg?.agents?.defaults;
if (params.agentId) {
const normalizedAgentId = normalizeAgentId(params.agentId);
const agent = params.cfg?.agents?.list?.find(
(entry) => normalizeAgentId(entry.id) === normalizedAgentId,
);
const agent = params.cfg ? resolveAgentConfig(params.cfg, params.agentId) : undefined;
const agentPolicy = agent?.modelPolicy;
if (hasExplicitModelPolicyAllow(agentPolicy)) {
return {
+4 -1
View File
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { createOpenClawTools } from "./openclaw-tools.js";
function computerTool(modelHasVision?: boolean) {
return createOpenClawTools({ modelHasVision }).find((tool) => tool.name === "computer");
return createOpenClawTools({
modelHasVision,
config: { agents: { entries: { main: { default: true } } } },
}).find((tool) => tool.name === "computer");
}
describe("computer tool vision gating", () => {
+4 -1
View File
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { createOpenClawTools } from "./openclaw-tools.js";
function mobileUiTool(modelHasVision?: boolean) {
return createOpenClawTools({ modelHasVision }).find((tool) => tool.name === "mobile_ui");
return createOpenClawTools({
config: { agents: { entries: { main: { default: true } } } },
modelHasVision,
}).find((tool) => tool.name === "mobile_ui");
}
describe("mobile UI tool registration", () => {
+29 -5
View File
@@ -4,11 +4,16 @@ import { createOpenClawCodingTools } from "./agent-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
function toolNames(options: NonNullable<Parameters<typeof createOpenClawTools>[0]>) {
const config = options.config ?? {};
return createOpenClawTools({
disableMessageTool: true,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
...options,
config: {
...config,
agents: config.agents ?? { entries: { main: { default: true } } },
},
}).map((tool) => tool.name);
}
@@ -29,7 +34,12 @@ describe("openclaw-tools Swarm gating", () => {
...base,
config: {
tools: { swarm: false },
agents: { list: [{ id: "worker", tools: { swarm: true } }] },
agents: {
list: [
{ id: "main", default: true },
{ id: "worker", tools: { swarm: true } },
],
},
},
}),
).toContain("agents_wait");
@@ -38,7 +48,12 @@ describe("openclaw-tools Swarm gating", () => {
...base,
config: {
tools: { swarm: true },
agents: { list: [{ id: "worker", tools: { swarm: false } }] },
agents: {
list: [
{ id: "main", default: true },
{ id: "worker", tools: { swarm: false } },
],
},
},
}),
).not.toContain("agents_wait");
@@ -64,7 +79,10 @@ describe("openclaw-tools Swarm gating", () => {
const names = createOpenClawCodingTools({
sessionKey: "agent:worker:subagent:child",
runId: "collector-run",
config: { tools: { allow: ["read"], swarm: true } },
config: {
agents: { entries: { main: { default: true } } },
tools: { allow: ["read"], swarm: true },
},
swarmCollector: true,
swarmOutputSchema: { type: "object", properties: { answer: { type: "string" } } },
}).map((tool) => tool.name);
@@ -78,7 +96,10 @@ describe("openclaw-tools Swarm gating", () => {
const names = createOpenClawCodingTools({
sessionKey: "agent:worker:subagent:child",
runId: "collector-run",
config: { tools: { swarm: true } },
config: {
agents: { entries: { main: { default: true } } },
tools: { swarm: true },
},
swarmCollector: true,
}).map((tool) => tool.name);
@@ -89,7 +110,10 @@ describe("openclaw-tools Swarm gating", () => {
const names = createOpenClawCodingTools({
sessionKey: "agent:worker:main",
runId: "collector-run",
config: { tools: { swarm: true } },
config: {
agents: { entries: { main: { default: true } } },
tools: { swarm: true },
},
swarmCollector: true,
}).map((tool) => tool.name);
+27 -8
View File
@@ -14,8 +14,20 @@ import { createUpdatePlanTool } from "./tools/update-plan-tool.js";
type UpdatePlanGatingParams = Parameters<typeof shouldIncludeUpdatePlanToolForOpenClawTools>[0];
type CreateOpenClawToolsOptions = NonNullable<Parameters<typeof createOpenClawTools>[0]>;
function withDefaultRoster(config: OpenClawConfig | undefined): OpenClawConfig {
return {
...config,
agents: config?.agents ?? { entries: { main: { default: true } } },
};
}
function expectUpdatePlanEnabled(params: UpdatePlanGatingParams, expected: boolean): void {
expect(shouldIncludeUpdatePlanToolForOpenClawTools(params)).toBe(expected);
expect(
shouldIncludeUpdatePlanToolForOpenClawTools({
...params,
config: withDefaultRoster(params.config),
}),
).toBe(expected);
}
function toolNames(tools: ReturnType<typeof createOpenClawTools>): string[] {
@@ -25,7 +37,7 @@ function toolNames(tools: ReturnType<typeof createOpenClawTools>): string[] {
function createFastToolNames(options: CreateOpenClawToolsOptions): string[] {
// Disable unrelated dynamic surfaces so registration assertions stay deterministic.
return toolNames(
createOpenClawTools({
createTestOpenClawTools({
disableMessageTool: true,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
@@ -34,6 +46,13 @@ function createFastToolNames(options: CreateOpenClawToolsOptions): string[] {
);
}
function createTestOpenClawTools(options: CreateOpenClawToolsOptions = {}) {
return createOpenClawTools({
...options,
config: withDefaultRoster(options.config),
});
}
function expectToolNamed(
tools: ReturnType<typeof createOpenClawTools>,
name: string,
@@ -127,11 +146,11 @@ describe("openclaw-tools update_plan gating", () => {
});
it("wraps constructed tools with before-tool-call hooks by default", () => {
const tools = createOpenClawTools({
const tools = createTestOpenClawTools({
config: {} as OpenClawConfig,
disablePluginTools: true,
});
const unwrappedTools = createOpenClawTools({
const unwrappedTools = createTestOpenClawTools({
config: {} as OpenClawConfig,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
@@ -145,7 +164,7 @@ describe("openclaw-tools update_plan gating", () => {
it("keeps message tool in embedded message-tool-only completions", () => {
setEmbeddedMode(true);
const tools = createOpenClawTools({
const tools = createTestOpenClawTools({
config: {} as OpenClawConfig,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
@@ -220,18 +239,18 @@ describe("openclaw-tools update_plan gating", () => {
it("keeps explicitly allowed message tool in embedded completions", () => {
setEmbeddedMode(true);
const fromRuntimeAllowlist = createOpenClawTools({
const fromRuntimeAllowlist = createTestOpenClawTools({
config: {} as OpenClawConfig,
disablePluginTools: true,
pluginToolAllowlist: ["message"],
wrapBeforeToolCallHook: false,
});
const fromGlobalAlsoAllow = createOpenClawTools({
const fromGlobalAlsoAllow = createTestOpenClawTools({
config: { tools: { profile: "minimal", alsoAllow: ["message"] } } as OpenClawConfig,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
});
const denied = createOpenClawTools({
const denied = createTestOpenClawTools({
config: {} as OpenClawConfig,
disablePluginTools: true,
pluginToolAllowlist: ["message"],
+15
View File
@@ -316,6 +316,21 @@ describe("resolveRequesterToolPolicies", () => {
});
});
it("fails closed when a trusted internal handoff has no configuration", () => {
expect(() =>
resolveRequesterToolPolicies({
agentId: "ops",
sessionKey: "agent:ops:main",
trustedInternalHandoff: true,
inputProvenance: {
kind: "inter_session",
sourceSessionKey: "agent:ops:subagent:child",
sourceTool: "subagent_announce",
},
}),
).toThrow("Trusted internal handoff policy resolution requires configuration.");
});
it("restores a verified completion handoff to a distinct immutable completion owner", async () => {
const controllerSessionKey = "agent:main:discord:direct:alice";
const completionOwnerSessionKey = "agent:main:main";
+3
View File
@@ -120,6 +120,9 @@ function resolveDelegatedPolicy(
) {
return { delegated: false };
}
if (!params.config) {
throw new Error("Trusted internal handoff policy resolution requires configuration.");
}
const targetSessionKey = resolveRequesterStoreKey(params.config, params.sessionKey);
let currentSessionKey = resolveRequesterStoreKey(params.config, provenance.sourceSessionKey);
const visited = new Set<string>();
+1
View File
@@ -11,6 +11,7 @@ describe("sandbox SSH secret owner", () => {
it("rejects an unmaterialized inherited ref without active degraded-owner state", async () => {
const config: OpenClawConfig = {
agents: {
entries: { main: { default: true } },
defaults: {
sandbox: {
mode: "all",
+9 -7
View File
@@ -8,7 +8,7 @@ import {
assertRuntimeSandboxSecretOwnerAvailable,
runtimeSandboxSecretOwnerId,
} from "../../secrets/runtime-sandbox-secret-owner.js";
import { resolveAgentConfig } from "../agent-scope-config.js";
import { listAgentEntriesWithSource, resolveAgentConfig } from "../agent-scope-config.js";
import type { SandboxScope } from "./types.js";
const SSH_SECRET_KEYS = ["identityData", "certificateData", "knownHostsData"] as const;
@@ -32,10 +32,10 @@ export function assertSshSandboxSecretOwnerAvailable(params: {
? resolveAgentConfig(params.config, params.agentId)?.sandbox?.ssh
: undefined;
const normalizedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
const agentIndex = normalizedAgentId
? params.config.agents?.list?.findIndex(
(entry) => normalizeAgentId(entry?.id) === normalizedAgentId,
)
const agentSource = normalizedAgentId
? listAgentEntriesWithSource(params.config).find(
({ entry }) => normalizeAgentId(entry.id) === normalizedAgentId,
)?.source
: undefined;
const unresolved: Array<{ path: string; refKey: string }> = [];
for (const key of SSH_SECRET_KEYS) {
@@ -47,8 +47,10 @@ export function assertSshSandboxSecretOwnerAvailable(params: {
}
unresolved.push({
path:
usesAgentValue && agentIndex !== undefined && agentIndex >= 0
? `agents.list.${agentIndex}.sandbox.ssh.${key}`
usesAgentValue && agentSource
? agentSource.kind === "entries"
? `agents.entries.${agentSource.key}.sandbox.ssh.${key}`
: `agents.list.${agentSource.index}.sandbox.ssh.${key}`
: `agents.defaults.sandbox.ssh.${key}`,
refKey: secretRefKey(ref),
});
+38 -4
View File
@@ -2,12 +2,46 @@
// guidance for sandboxed agent sessions.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveSandboxConfigForAgent } from "./config.js";
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
import { resolveSandboxConfigForAgent as resolveSandboxConfigForAgentBase } from "./config.js";
import {
formatSandboxToolPolicyBlockedMessage,
resolveSandboxRuntimeStatus,
formatSandboxToolPolicyBlockedMessage as formatSandboxToolPolicyBlockedMessageBase,
resolveSandboxRuntimeStatus as resolveSandboxRuntimeStatusBase,
} from "./runtime-status.js";
import { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./tool-policy.js";
import {
isToolAllowed,
resolveSandboxToolPolicyForAgent as resolveSandboxToolPolicyForAgentBase,
} from "./tool-policy.js";
function loadedConfig(config: OpenClawConfig | undefined): OpenClawConfig {
return migratePersistedImplicitMainRoster(config ?? {}).config as OpenClawConfig;
}
function resolveSandboxConfigForAgent(config: OpenClawConfig, agentId: string) {
return resolveSandboxConfigForAgentBase(loadedConfig(config), agentId);
}
function resolveSandboxToolPolicyForAgent(config: OpenClawConfig, agentId: string) {
return resolveSandboxToolPolicyForAgentBase(loadedConfig(config), agentId);
}
function resolveSandboxRuntimeStatus(
params: Parameters<typeof resolveSandboxRuntimeStatusBase>[0],
) {
return resolveSandboxRuntimeStatusBase({
...params,
cfg: loadedConfig(params.cfg),
});
}
function formatSandboxToolPolicyBlockedMessage(
params: Parameters<typeof formatSandboxToolPolicyBlockedMessageBase>[0],
) {
return formatSandboxToolPolicyBlockedMessageBase({
...params,
cfg: loadedConfig(params.cfg),
});
}
describe("sandbox/tool-policy", () => {
it("merges sandbox alsoAllow into the default sandbox allowlist", () => {
@@ -1,7 +1,17 @@
// Verifies simple-completion model selection preserves provider, model, and profile refs.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSimpleCompletionSelectionForAgent } from "./simple-completion-runtime.js";
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
import { resolveSimpleCompletionSelectionForAgent as resolveSimpleCompletionSelectionForAgentBase } from "./simple-completion-runtime.js";
function resolveSimpleCompletionSelectionForAgent(
params: Parameters<typeof resolveSimpleCompletionSelectionForAgentBase>[0],
) {
return resolveSimpleCompletionSelectionForAgentBase({
...params,
cfg: migratePersistedImplicitMainRoster(params.cfg).config as OpenClawConfig,
});
}
function requireSelection(selection: ReturnType<typeof resolveSimpleCompletionSelectionForAgent>) {
// Narrows absent selections so each case can assert parsed provider/model fields.
+11 -4
View File
@@ -54,6 +54,7 @@ import {
normalizeMessageChannel,
} from "../utils/message-channel.js";
import { hasAcceptedSessionSpawn } from "./accepted-session-spawn.js";
import { resolveDefaultAgentId } from "./agent-scope-config.js";
import {
collectDeliveredMediaUrls,
collectMessagingToolDeliveredMediaUrls,
@@ -747,7 +748,7 @@ export async function resolveSubagentCompletionOrigin(params: {
export function loadRequesterSessionEntry(requesterSessionKey: string) {
const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig();
const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey);
const agentId = resolveAgentIdFromSessionKey(canonicalKey);
const agentId = resolveAgentIdFromSessionKey(canonicalKey, resolveDefaultAgentId(cfg));
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const entry = subagentAnnounceDeliveryDeps.loadSessionEntry({
storePath,
@@ -759,7 +760,7 @@ export function loadRequesterSessionEntry(requesterSessionKey: string) {
export function loadSessionEntryByKey(sessionKey: string) {
const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig();
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const agentId = resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(cfg));
const storePath = resolveStorePath(cfg.session?.store, { agentId });
return subagentAnnounceDeliveryDeps.loadSessionEntry({
storePath,
@@ -1054,7 +1055,10 @@ async function deliverGeneratedMediaCompletionDirect(params: {
sourceTool: params.sourceTool,
internalEvents: params.internalEvents,
});
const agentId = resolveAgentIdFromSessionKey(params.requesterSessionKey);
const agentId = resolveAgentIdFromSessionKey(
params.requesterSessionKey,
resolveDefaultAgentId(params.cfg),
);
const idempotencyKey = `${params.directIdempotencyKey}:generated-media-direct`;
try {
await subagentAnnounceDeliveryDeps.sendMessage({
@@ -1205,7 +1209,10 @@ async function deliverTextCompletionDirect(params: {
) {
return undefined;
}
const agentId = resolveAgentIdFromSessionKey(params.requesterSessionKey);
const agentId = resolveAgentIdFromSessionKey(
params.requesterSessionKey,
resolveDefaultAgentId(params.cfg),
);
const idempotencyKey = `${params.directIdempotencyKey}:text-direct`;
try {
await subagentAnnounceDeliveryDeps.sendMessage({
+4 -10
View File
@@ -7,18 +7,12 @@ import {
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
} from "../config/sessions/main-session.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeMainKey } from "../routing/session-key.js";
type RequesterStoreKeyConfig = {
session?: { mainKey?: string };
agents?: { list?: Array<{ id?: string; default?: boolean }> };
};
import { resolveDefaultAgentId } from "./agent-scope-config.js";
/** Resolve the canonical store key for a subagent requester session. */
export function resolveRequesterStoreKey(
cfg: RequesterStoreKeyConfig | undefined,
requesterSessionKey: string,
): string {
export function resolveRequesterStoreKey(cfg: OpenClawConfig, requesterSessionKey: string): string {
const raw = (requesterSessionKey ?? "").trim();
if (!raw) {
return raw;
@@ -33,6 +27,6 @@ export function resolveRequesterStoreKey(
if (raw === "main" || raw === mainKey) {
return resolveMainSessionKey(cfg);
}
const agentId = resolveAgentIdFromSessionKey(raw);
const agentId = resolveAgentIdFromSessionKey(raw, resolveDefaultAgentId(cfg));
return `agent:${agentId}:${raw}`;
}
@@ -6,7 +6,10 @@ import { resolveAgentToolSearchRuntimeConfig } from "./tool-search-runtime-confi
function createRuntimeConfigPair() {
const sourceConfig = {
agents: { defaults: { experimental: { localModelLean: true } } },
agents: {
defaults: { experimental: { localModelLean: true } },
entries: { main: { default: true } },
},
plugins: {
entries: {
"example-plugin": {
@@ -80,6 +83,7 @@ describe("resolveAgentToolSearchRuntimeConfig", () => {
const { runtimeConfig, sourceConfig } = createRuntimeConfigPair();
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const explicitConfig = {
agents: { entries: { main: { default: true } } },
plugins: {
entries: {
"example-plugin": { config: { marker: "explicit" } },
@@ -92,7 +96,10 @@ describe("resolveAgentToolSearchRuntimeConfig", () => {
});
it("uses the input config when no runtime snapshot exists", () => {
const config = { tools: { toolSearch: false } } as OpenClawConfig;
const config = {
agents: { entries: { main: { default: true } } },
tools: { toolSearch: false },
} as OpenClawConfig;
expect(resolveAgentRuntimeToolConfig(config)).toBe(config);
expect(resolveAgentToolSearchRuntimeConfig({ config })).toBe(config);
-30
View File
@@ -139,36 +139,6 @@ describe("agents_list tool", () => {
});
});
it("uses the implicit default agent as a configured target", async () => {
loadConfigMock.mockReturnValue({
agents: {
defaults: {
subagents: { allowAgents: ["main"] },
},
},
} satisfies OpenClawConfig);
const result = await createAgentsListTool({ agentSessionKey: "agent:main:main" }).execute(
"call",
{},
);
const details = result.details as AgentListDetails;
expect(details).toStrictEqual({
requester: "main",
allowAny: false,
agents: [
{
id: "main",
name: undefined,
configured: true,
model: undefined,
agentRuntime: { id: "codex", source: "implicit" },
},
],
});
});
it("ignores legacy env-forced plugin runtime selections", async () => {
// Runtime selection now comes from config/model routing, not a process-wide
// legacy env override.
+4 -8
View File
@@ -5,13 +5,9 @@
*/
import { Type } from "typebox";
import { getRuntimeConfig } from "../../config/config.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveModelAgentRuntimeMetadata } from "../agent-runtime-metadata.js";
import { listAgentIds } from "../agent-scope-config.js";
import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agent-scope-config.js";
import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "../agent-scope.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { resolveSubagentAllowedTargetIds } from "../subagent-target-policy.js";
@@ -102,14 +98,14 @@ export function createAgentsListTool(opts?: {
const requesterAgentId = normalizeAgentId(
opts?.requesterAgentIdOverride ??
parseAgentSessionKey(requesterInternalKey)?.agentId ??
DEFAULT_AGENT_ID,
resolveDefaultAgentId(cfg),
);
const allowAgents =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
cfg?.agents?.defaults?.subagents?.allowAgents;
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const configuredAgents = listAgentEntries(cfg);
const configuredIds = listAgentIds(cfg);
const configuredNameMap = new Map<string, string>();
for (const entry of configuredAgents) {
@@ -10,7 +10,7 @@ import { createMessageTool } from "./message-tool.js";
describe("WebChat message tool internal source reply", () => {
it("projects a real targetless send and preserves the automatic final reply", async () => {
const tool = createMessageTool({
config: {},
config: { agents: { entries: { main: { default: true } } } },
currentChannelProvider: "webchat",
sourceReplyDeliveryMode: "automatic",
agentSessionKey: "agent:main:webchat:dm:dashboard",
@@ -2,11 +2,7 @@
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { buildAgentMainSessionKey, parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveInternalSessionKey } from "./sessions-helpers.js";
type ResolvedStatusSessionEntry = {
@@ -39,21 +35,21 @@ export function resolveSessionStatusEntry(params: {
const candidates: string[] = [keyRaw];
if (!keyRaw.startsWith("agent:")) {
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
candidates.push(`agent:${params.agentId}:${keyRaw}`);
}
if (includeAliasFallback && internal !== keyRaw) {
candidates.push(internal);
}
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
const agentInternal = `agent:${params.agentId}:${internal}`;
const agentRaw = `agent:${params.agentId}:${keyRaw}`;
if (agentInternal !== agentRaw) {
candidates.push(agentInternal);
}
}
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
const defaultMainKey = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
agentId: params.agentId,
mainKey: params.mainKey,
});
if (!candidates.includes(defaultMainKey)) {
+15 -5
View File
@@ -50,6 +50,7 @@ import {
isDeliverableMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { resolveDefaultAgentId } from "../agent-scope-config.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agent-scope.js";
import {
buildModelAliasIndex,
@@ -571,8 +572,10 @@ export function createSessionStatusTool(opts?: {
sandboxed: opts?.sandboxed,
});
const a2aPolicy = createAgentToAgentPolicy(cfg);
const configuredDefaultAgentId = resolveDefaultAgentId(cfg);
const requesterAgentId = resolveAgentIdFromSessionKey(
opts?.agentSessionKey ?? effectiveRequesterKey,
configuredDefaultAgentId,
);
const visibilityRequesterKey = (opts?.agentSessionKey ?? effectiveRequesterKey).trim();
const usesLegacyMainAlias = alias === mainKey;
@@ -583,7 +586,8 @@ export function createSessionStatusTool(opts?: {
const resolveVisibilityMainSessionKey = (sessionAgentId: string) => {
const requesterParsed = parseAgentSessionKey(visibilityRequesterKey);
if (
resolveAgentIdFromSessionKey(visibilityRequesterKey) === sessionAgentId &&
resolveAgentIdFromSessionKey(visibilityRequesterKey, configuredDefaultAgentId) ===
sessionAgentId &&
(requesterParsed?.rest === mainKey || isLegacyMainVisibilityKey(visibilityRequesterKey))
) {
return visibilityRequesterKey;
@@ -613,6 +617,7 @@ export function createSessionStatusTool(opts?: {
};
const visibilityGuard = await createSessionVisibilityGuard({
action: "status",
defaultAgentId: resolveDefaultAgentId(cfg),
requesterSessionKey: visibilityRequesterKey,
visibility: resolveEffectiveSessionToolsVisibility({
cfg,
@@ -683,7 +688,10 @@ export function createSessionStatusTool(opts?: {
};
if (requestedKeyInput.startsWith("agent:") && !isSemanticCurrentRequest) {
const requestedAgentId = resolveAgentIdFromSessionKey(requestedKeyInput);
const requestedAgentId = resolveAgentIdFromSessionKey(
requestedKeyInput,
configuredDefaultAgentId,
);
ensureAgentAccess(requestedAgentId);
const access = visibilityGuard.check(
normalizeVisibilityTargetSessionKey(requestedKeyInput, requestedAgentId),
@@ -695,7 +703,7 @@ export function createSessionStatusTool(opts?: {
const isExplicitAgentKey = requestedKeyInput.startsWith("agent:");
let agentId = isExplicitAgentKey
? resolveAgentIdFromSessionKey(requestedKeyInput)
? resolveAgentIdFromSessionKey(requestedKeyInput, configuredDefaultAgentId)
: requesterAgentId;
let storePath = resolveStorePath(cfg.session?.store, { agentId });
let storeScopedRequesterKey = resolveStoreScopedRequesterKey({
@@ -738,11 +746,13 @@ export function createSessionStatusTool(opts?: {
throw new Error("Session status visibility is restricted to the current session tree.");
}
// If resolution points at another agent, enforce A2A policy before switching stores.
ensureAgentAccess(resolveAgentIdFromSessionKey(visibleSession.key));
ensureAgentAccess(
resolveAgentIdFromSessionKey(visibleSession.key, configuredDefaultAgentId),
);
resolvedViaSessionId = true;
requestedKeyRaw = visibleSession.key;
requestedKeyInput = requestedKeyRaw.trim();
agentId = resolveAgentIdFromSessionKey(visibleSession.key);
agentId = resolveAgentIdFromSessionKey(visibleSession.key, configuredDefaultAgentId);
storePath = resolveStorePath(cfg.session?.store, { agentId });
storeScopedRequesterKey = resolveStoreScopedRequesterKey({
requesterKey: effectiveRequesterKey,
@@ -13,6 +13,7 @@ import { capArrayByJsonBytes } from "../../gateway/session-transcript-readers.js
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
import { redactToolPayloadText } from "../../logging/redact.js";
import { truncateUtf16Safe } from "../../utils.js";
import { resolveDefaultAgentId } from "../agent-scope-config.js";
import { optionalPositiveIntegerSchema } from "../schema/typebox.js";
import {
describeSessionsHistoryTool,
@@ -432,6 +433,7 @@ export function createSessionsHistoryTool(opts?: {
});
const visibilityGuard = await createSessionVisibilityGuard({
action: "history",
defaultAgentId: resolveDefaultAgentId(cfg),
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
+15 -11
View File
@@ -2,9 +2,14 @@
// helpers, and numeric argument validation.
import { Value } from "typebox/value";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { compactToolOutputHint } from "../tool-schema-hints.js";
import { createSessionsListTool } from "./sessions-list-tool.js";
const VALID_CONFIG: OpenClawConfig = {
agents: { entries: { main: { default: true } } },
};
const mocks = vi.hoisted(() => ({
gatewayCall: vi.fn(),
createAgentToAgentPolicy: vi.fn(() => ({})),
@@ -88,7 +93,7 @@ describe("sessions-list-tool", () => {
main: { "agent:main:main": 7, "agent:main:subagent:child": 0 },
});
const result = await createSessionsListTool({ config: {} as never }).execute("call-state", {});
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute("call-state", {});
expect(mocks.getSessionStateVersions).toHaveBeenCalledWith([
{ sessionKey: "agent:main:main", agentId: "main" },
@@ -107,7 +112,7 @@ describe("sessions-list-tool", () => {
],
});
const result = await createSessionsListTool({ config: {} as never }).execute("blind", {});
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute("blind", {});
expect(getSessionsListDetails(result).sessions?.map((session) => session.key)).toEqual([
"agent:main:dashboard:visible",
@@ -143,7 +148,7 @@ describe("sessions-list-tool", () => {
mocks.getSessionStateVersions.mockReturnValue({
main: { "agent:main:subagent:child": 4 },
});
const tool = createSessionsListTool({ config: {} as never });
const tool = createSessionsListTool({ config: VALID_CONFIG });
const result = await tool.execute("contract", {});
expect(tool.outputSchema).toBeDefined();
@@ -213,7 +218,7 @@ describe("sessions-list-tool", () => {
}
return {};
});
const tool = createSessionsListTool({ config: {} as never });
const tool = createSessionsListTool({ config: VALID_CONFIG });
const result = await tool.execute("call-1", {});
const details = getSessionsListDetails(result);
@@ -237,14 +242,14 @@ describe("sessions-list-tool", () => {
],
});
const result = await createSessionsListTool({ config: {} as never }).execute("lineage", {});
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute("lineage", {});
expect(getSessionsListDetails(result).sessions?.[0]?.parentSessionKey).toBe(
"agent:main:subagent:parent",
);
});
it("derives channels only from structurally valid group session keys", async () => {
it("omits malformed agent keys and derives channels only from valid group keys", async () => {
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.list") {
@@ -281,7 +286,7 @@ describe("sessions-list-tool", () => {
}
return {};
});
const tool = createSessionsListTool({ config: {} as never });
const tool = createSessionsListTool({ config: VALID_CONFIG });
const result = await tool.execute("call-agent-scoped-channel", {});
const details = getSessionsListDetails(result);
@@ -291,7 +296,6 @@ describe("sessions-list-tool", () => {
"discord",
"matrix",
"unknown",
"unknown",
]);
});
@@ -321,7 +325,7 @@ describe("sessions-list-tool", () => {
}
return {};
});
const tool = createSessionsListTool({ config: {} as never });
const tool = createSessionsListTool({ config: VALID_CONFIG });
const result = await tool.execute("call-3", {});
const details = getSessionsListDetails(result);
@@ -350,7 +354,7 @@ describe("sessions-list-tool", () => {
},
],
});
const tool = createSessionsListTool({ config: {} as never });
const tool = createSessionsListTool({ config: VALID_CONFIG });
const result = await tool.execute("call-archived", { archived: true });
@@ -375,7 +379,7 @@ describe("sessions-list-tool", () => {
])("rejects invalid numeric parameter %o", async (params, message) => {
// Reject before gateway dispatch so malformed limits cannot reach session
// store queries.
const tool = createSessionsListTool({ config: {} as never });
const tool = createSessionsListTool({ config: VALID_CONFIG });
await expect(tool.execute("call-4", params)).rejects.toThrow(message);
expect(mocks.gatewayCall).not.toHaveBeenCalled();
+21 -14
View File
@@ -17,6 +17,7 @@ import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session
import { deriveSessionTitle } from "../../gateway/session-utils.js";
import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { getSessionStateVersions } from "../../sessions/session-state-events.js";
import { resolveDefaultAgentId } from "../agent-scope-config.js";
import {
optionalNonNegativeIntegerSchema,
optionalPositiveIntegerSchema,
@@ -207,24 +208,30 @@ export function createSessionsListTool(opts?: {
const sessions = (Array.isArray(list?.sessions) ? list.sessions : []).filter(
(entry) => !entry || typeof entry !== "object" || !isIncognitoSessionKey(entry.key),
);
const defaultAgentId = resolveDefaultAgentId(cfg);
const stateVersions = getSessionStateVersions(
sessions.flatMap((entry) =>
entry && typeof entry === "object" && typeof entry.key === "string"
? [
{
sessionKey: entry.key,
agentId:
typeof entry.agentId === "string" && entry.agentId
? entry.agentId
: resolveAgentIdFromSessionKey(entry.key),
},
]
: [],
),
sessions.flatMap((entry) => {
if (!entry || typeof entry !== "object" || typeof entry.key !== "string") {
return [];
}
let stateAgentId =
typeof entry.agentId === "string" && entry.agentId ? entry.agentId : undefined;
if (!stateAgentId) {
try {
stateAgentId = resolveAgentIdFromSessionKey(entry.key, defaultAgentId);
} catch {
// Malformed rows remain subject to the fail-closed visibility checker below,
// but cannot participate in agent state-version lookup.
return [];
}
}
return [{ sessionKey: entry.key, agentId: stateAgentId }];
}),
);
const storePath = typeof list?.path === "string" ? list.path : undefined;
const visibilityGuard = createSessionVisibilityRowChecker({
action: "list",
defaultAgentId,
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
@@ -301,7 +308,7 @@ export function createSessionsListTool(opts?: {
const sessionId = readStringValue(entry.sessionId);
const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
const sessionFile = readStringValue(sessionFileRaw);
const resolvedAgentId = resolveAgentIdFromSessionKey(key);
const resolvedAgentId = resolveAgentIdFromSessionKey(key, defaultAgentId);
// Version lookup keys on the store-owning agent (gateway row agentId), not the
// key-derived agent: bare "global" keys parse to the default agent id.
const stateVersionAgentId =
+12 -2
View File
@@ -30,8 +30,15 @@ function createTool(params: {
requests?: CallGatewayRequest[];
truncated?: boolean;
}) {
const config = params.config ?? { tools: { sessions: { visibility: "self" } } };
return createSessionsSearchTool({
config: params.config ?? { tools: { sessions: { visibility: "self" } } },
config: {
...config,
agents: {
entries: { main: { default: true } },
...(config.agents as Record<string, unknown> | undefined),
},
},
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
@@ -273,7 +280,10 @@ describe("sessions_search tool", () => {
it("accepts the gateway's canonical key for the current-session alias", async () => {
const tool = createSessionsSearchTool({
config: { tools: { sessions: { visibility: "self" } } },
config: {
agents: { entries: { main: { default: true } } },
tools: { sessions: { visibility: "self" } },
},
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
if (request.method === "sessions.list") {
return { sessions: [], hasMore: false } as T;
+12 -12
View File
@@ -11,6 +11,7 @@ import {
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { truncateUtf16Safe } from "../../utils.js";
import { resolveDefaultAgentId } from "../agent-scope-config.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { optionalPositiveIntegerSchema } from "../schema/typebox.js";
import {
@@ -386,41 +387,40 @@ export function createSessionsSearchTool(opts?: {
sandboxed: opts?.sandboxed === true,
});
const a2aPolicy = createAgentToAgentPolicy(cfg);
const defaultAgentId = resolveDefaultAgentId(cfg);
const requesterAgentId =
opts?.agentId ?? resolveSessionAgentId({ sessionKey: effectiveRequesterKey, config: cfg });
const guard = await createSessionVisibilityGuard({
action: "history",
requesterAgentId: opts?.agentId,
defaultAgentId,
requesterAgentId,
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
});
const rowGuard = createSessionVisibilityRowChecker({
action: "history",
requesterAgentId: opts?.agentId,
defaultAgentId,
requesterAgentId,
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
});
if (sessionKey) {
const access =
opts?.agentId && !parseAgentSessionKey(sessionKey)
? rowGuard.check({ key: sessionKey, agentId: opts.agentId })
: guard.check(sessionKey);
const access = !parseAgentSessionKey(sessionKey)
? rowGuard.check({ key: sessionKey, agentId: requesterAgentId })
: guard.check(sessionKey);
if (!access.allowed) {
return jsonResult({ status: access.status, error: access.error });
}
}
const requesterAgentId =
opts?.agentId ?? resolveSessionAgentId({ sessionKey: effectiveRequesterKey, config: cfg });
const searchSessions = (
sessionKey
? [
{
key: sessionKey,
access: "direct" as const,
...(opts?.agentId && !parseAgentSessionKey(sessionKey)
? { agentId: opts.agentId }
: {}),
...(!parseAgentSessionKey(sessionKey) ? { agentId: requesterAgentId } : {}),
},
]
: await listVisibleSearchSessions({
+11 -3
View File
@@ -29,6 +29,7 @@ import {
type GatewayMessageChannel,
INTERNAL_MESSAGE_CHANNEL,
} from "../../utils/message-channel.js";
import { resolveDefaultAgentId } from "../agent-scope-config.js";
import { listAgentIds } from "../agent-scope.js";
import {
type EmbeddedAgentQueueMessageOptions,
@@ -177,7 +178,10 @@ function isConfiguredAgentMainSessionKey(params: {
sessionKey: string;
mainKey: string;
}): boolean {
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const agentId = resolveAgentIdFromSessionKey(
params.sessionKey,
resolveDefaultAgentId(params.cfg),
);
return (
params.sessionKey ===
resolveConfiguredAgentMainSessionKey({
@@ -217,7 +221,7 @@ async function ensureConfiguredAgentMainSession(params: {
try {
const createParams = {
key: params.sessionKey,
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)),
};
if (
params.useTrustedInProcessCreation &&
@@ -469,7 +473,10 @@ export function createSessionsSendTool(opts?: {
sessionKey = agentMainKey;
}
if (!sessionKey && labelParam) {
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
const requesterAgentId = resolveAgentIdFromSessionKey(
effectiveRequesterKey,
resolveDefaultAgentId(cfg),
);
const requestedAgentId = labelAgentIdParam
? normalizeAgentId(labelAgentIdParam)
: undefined;
@@ -615,6 +622,7 @@ export function createSessionsSendTool(opts?: {
}
const visibilityGuard = await createSessionVisibilityGuard({
action: "send",
defaultAgentId: resolveDefaultAgentId(cfg),
requesterSessionKey: effectiveRequesterKey,
visibility: sessionVisibility,
a2aPolicy,
+6 -1
View File
@@ -6,6 +6,7 @@ import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js";
import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { resolveDefaultAgentId } from "../agent-scope-config.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam, ToolAuthorizationError, ToolInputError } from "./common.js";
@@ -158,8 +159,12 @@ async function resolvePatchTarget(
// the action only selects error copy. Owner gating remains separate.
const guard = await createSessionVisibilityGuard({
action: "status",
defaultAgentId: resolveDefaultAgentId(context.cfg),
requesterSessionKey: context.effectiveRequesterKey,
requesterAgentId: resolveAgentIdFromSessionKey(context.effectiveRequesterKey),
requesterAgentId: resolveAgentIdFromSessionKey(
context.effectiveRequesterKey,
resolveDefaultAgentId(context.cfg),
),
visibility: resolveEffectiveSessionToolsVisibility({
cfg: context.cfg,
sandboxed: opts.sandboxed === true,
+20 -8
View File
@@ -5,19 +5,31 @@
* plus the default agent workspace without duplicating agent-scope logic.
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
import { resolveUserPath } from "../utils.js";
import {
listAgentEntries,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "./agent-scope.js";
/** Lists unique workspace directories for configured agents and the default agent. */
export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] {
const dirs = new Set<string>();
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && typeof entry.id === "string") {
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
}
}
for (const entry of listAgentEntries(cfg)) {
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
}
dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
return [...dirs];
}
/** Lists only entry-authored workspace paths without requiring a valid default marker. */
export function listExplicitAgentWorkspaceDirs(cfg: OpenClawConfig): string[] {
const dirs = new Set<string>();
for (const entry of listAgentEntries(cfg)) {
const workspace = typeof entry.workspace === "string" ? entry.workspace.trim() : "";
if (workspace) {
dirs.add(resolveUserPath(workspace));
}
}
return [...dirs];
}
+50 -26
View File
@@ -1,16 +1,19 @@
// Workspace run tests cover runtime workspace resolution from explicit input,
// agent config, session keys, and environment fallback.
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveRunWorkspaceDir } from "./workspace-run.js";
vi.unmock("./agent-scope-config.js");
describe("resolveRunWorkspaceDir", () => {
it("resolves explicit workspace values without fallback", () => {
const explicit = path.join(process.cwd(), "tmp", "workspace-run-explicit");
const result = resolveRunWorkspaceDir({
workspaceDir: explicit,
sessionKey: "agent:main:subagent:test",
config: { agents: { list: [{ id: "main", default: true }] } },
});
expect(result.usedFallback).toBe(false);
@@ -22,7 +25,7 @@ describe("resolveRunWorkspaceDir", () => {
it("recognizes an explicitly supplied configured workspace as canonical", () => {
const workspaceDir = path.join(process.cwd(), "tmp", "workspace-run-canonical");
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
agents: { defaults: { workspace: workspaceDir }, list: [{ id: "main", default: true }] },
} satisfies OpenClawConfig;
const result = resolveRunWorkspaceDir({
@@ -41,7 +44,7 @@ describe("resolveRunWorkspaceDir", () => {
const cfg = {
agents: {
defaults: { workspace: defaultWorkspace },
list: [{ id: "research", workspace: researchWorkspace }],
list: [{ id: "research", workspace: researchWorkspace, default: true }],
},
} satisfies OpenClawConfig;
@@ -63,6 +66,7 @@ describe("resolveRunWorkspaceDir", () => {
const cfg = {
agents: {
defaults: { workspace: defaultWorkspace },
list: [{ id: "main", default: true }],
},
} satisfies OpenClawConfig;
@@ -78,19 +82,16 @@ describe("resolveRunWorkspaceDir", () => {
expect(result.workspaceDir).toBe(path.resolve(defaultWorkspace));
});
it("falls back to built-in main workspace when config is unavailable", () => {
it("refuses to invent an agent when config is unavailable", () => {
const workspaceDir = path.join(path.sep, "srv", "openclaw-workspace");
const result = resolveRunWorkspaceDir({
workspaceDir: null,
sessionKey: "agent:main:subagent:test",
config: undefined,
env: { ...process.env, OPENCLAW_WORKSPACE_DIR: workspaceDir },
});
expect(result.usedFallback).toBe(true);
expect(result.fallbackReason).toBe("missing");
expect(result.agentId).toBe("main");
expect(result.workspaceDir).toBe(path.resolve(workspaceDir));
expect(() =>
resolveRunWorkspaceDir({
workspaceDir: null,
sessionKey: "custom-main-key",
config: undefined,
env: { ...process.env, OPENCLAW_WORKSPACE_DIR: workspaceDir },
}),
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_ROSTER_REQUIRED" }));
});
it("throws for malformed agent session keys", () => {
@@ -103,24 +104,46 @@ describe("resolveRunWorkspaceDir", () => {
).toThrow("Malformed agent session key");
});
it("uses explicit agent id for per-agent fallback when config is unavailable", () => {
it("requires roster config for per-agent fallback", () => {
const env = {
...process.env,
HOME: "/home/runner",
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
} satisfies NodeJS.ProcessEnv;
const result = resolveRunWorkspaceDir({
workspaceDir: undefined,
sessionKey: "definitely-not-a-valid-session-key",
agentId: "research",
config: undefined,
env,
});
expect(() =>
resolveRunWorkspaceDir({
workspaceDir: undefined,
sessionKey: "definitely-not-a-valid-session-key",
agentId: "research",
config: undefined,
env,
}),
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_ROSTER_REQUIRED" }));
});
expect(result.agentId).toBe("research");
expect(result.agentIdSource).toBe("explicit");
expect(result.workspaceDir).toBe(path.resolve("/tmp/openclaw-state", "workspace-research"));
it("rejects an explicit agent when the supplied config has no roster", () => {
expect(() =>
resolveRunWorkspaceDir({
workspaceDir: undefined,
agentId: "research",
config: {},
}),
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_ROSTER_REQUIRED" }));
});
it.each([
{ agentId: "research", sessionKey: undefined },
{ agentId: undefined, sessionKey: "agent:research:subagent:test" },
])("rejects an unconfigured workspace owner for $sessionKey", ({ agentId, sessionKey }) => {
expect(() =>
resolveRunWorkspaceDir({
workspaceDir: undefined,
agentId,
sessionKey,
config: { agents: { entries: { ops: { default: true } } } },
}),
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_AGENT_NOT_CONFIGURED" }));
});
it("throws for malformed agent session keys even when config has a default agent", () => {
@@ -152,6 +175,7 @@ describe("resolveRunWorkspaceDir", () => {
const cfg = {
agents: {
defaults: { workspace: fallbackWorkspace },
list: [{ id: "main", default: true }],
},
} satisfies OpenClawConfig;
+47 -12
View File
@@ -8,12 +8,16 @@ import { logWarn } from "../logger.js";
import { redactIdentifier } from "../logging/redact-identifier.js";
import {
classifySessionKeyShape,
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
import { hasAgentRosterProperty } from "./agent-scope-config.js";
import {
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "./agent-scope.js";
import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type";
@@ -28,10 +32,32 @@ export type ResolveRunWorkspaceResult = {
agentIdSource: AgentIdSource;
};
const RUN_WORKSPACE_ROSTER_REQUIRED_ERROR_CODE = "RUN_WORKSPACE_ROSTER_REQUIRED";
class RunWorkspaceRosterRequiredError extends Error {
readonly code = RUN_WORKSPACE_ROSTER_REQUIRED_ERROR_CODE;
constructor() {
super("No agents configured; run workspace resolution requires an explicit roster.");
this.name = "RunWorkspaceRosterRequiredError";
}
}
class RunWorkspaceAgentNotConfiguredError extends Error {
readonly code = "RUN_WORKSPACE_AGENT_NOT_CONFIGURED";
readonly agentId: string;
constructor(agentId: string) {
super(`Agent ${agentId} is not present in the configured roster.`);
this.name = "RunWorkspaceAgentNotConfiguredError";
this.agentId = agentId;
}
}
function resolveRunAgentId(params: {
sessionKey?: string;
agentId?: string;
config?: OpenClawConfig;
config: OpenClawConfig;
}): {
agentId: string;
agentIdSource: AgentIdSource;
@@ -50,10 +76,9 @@ function resolveRunAgentId(params: {
return { agentId: explicit, agentIdSource: "explicit" };
}
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
if (shape === "missing" || shape === "legacy_or_alias") {
return {
agentId: defaultAgentId || DEFAULT_AGENT_ID,
agentId: resolveDefaultAgentId(params.config),
agentIdSource: "default",
};
}
@@ -67,10 +92,7 @@ function resolveRunAgentId(params: {
}
// Defensive fallback, should be unreachable for non-malformed shapes.
return {
agentId: defaultAgentId || DEFAULT_AGENT_ID,
agentIdSource: "default",
};
throw new Error("Session key does not resolve to a configured agent.");
}
/** Redacts a run/session identifier for logs and prompts. */
@@ -86,13 +108,26 @@ export function resolveRunWorkspaceDir(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): ResolveRunWorkspaceResult {
const rawSessionKey = params.sessionKey?.trim() ?? "";
if (classifySessionKeyShape(rawSessionKey) === "malformed_agent") {
throw new Error("Malformed agent session key; refusing workspace resolution.");
}
// Workspace ownership is an isolation boundary. Raw/configless SDK inputs may
// retain implicit-main routing compatibility, but must not invent an owner here.
const config = params.config;
if (!config || !hasAgentRosterProperty(config)) {
throw new RunWorkspaceRosterRequiredError();
}
const env = params.env ?? process.env;
const requested = params.workspaceDir;
const { agentId, agentIdSource } = resolveRunAgentId({
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
config,
});
if (!resolveAgentConfig(config, agentId)) {
throw new RunWorkspaceAgentNotConfiguredError(agentId);
}
if (typeof requested === "string") {
const trimmed = requested.trim();
if (trimmed) {
@@ -102,7 +137,7 @@ export function resolveRunWorkspaceDir(params: {
}
const workspaceDir = resolveUserPath(sanitized, env);
const canonicalWorkspaceDir = resolveUserPath(
resolveAgentWorkspaceDir(params.config ?? {}, agentId, env),
resolveAgentWorkspaceDir(config, agentId, env),
env,
);
return {
@@ -117,7 +152,7 @@ export function resolveRunWorkspaceDir(params: {
const fallbackReason: WorkspaceFallbackReason =
requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type";
const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId, env);
const fallbackWorkspace = resolveAgentWorkspaceDir(config, agentId, env);
const sanitizedFallback = sanitizeForPromptLiteral(fallbackWorkspace);
if (sanitizedFallback !== fallbackWorkspace) {
logWarn("Control/format characters stripped from fallback workspaceDir (OC-19 hardening).");
@@ -3,6 +3,7 @@ import "./reply.directive.directive-behavior.e2e-mocks.js";
import { describe, expect, it } from "vitest";
import type { ModelAliasIndex } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
import type { SessionEntry } from "../config/sessions.js";
import { installDirectiveBehaviorE2EHooks } from "./reply.directive.directive-behavior.e2e-harness.js";
import { runEmbeddedAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
@@ -42,10 +43,11 @@ async function runDirectiveStatus(
sessionKey: _ignoredSessionKey,
sessionEntry: _ignoredSessionEntry,
sessionStore: _ignoredSessionStore,
cfg: overrideCfg,
...restOverrides
} = overrides;
const result = await handleDirectiveOnly({
cfg,
cfg: migratePersistedImplicitMainRoster(overrideCfg ?? cfg).config as OpenClawConfig,
directives: parseInlineDirectives(body),
sessionEntry: effectiveSessionEntry,
sessionStore: effectiveSessionStore,
@@ -2,6 +2,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { resolveAgentConfig } from "../../agents/agent-scope-config.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import { resolveModelRefFromString } from "../../agents/model-selection.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -23,11 +24,7 @@ function resolveAgentHeartbeatModelRaw(params: {
const defaultModel = normalizeOptionalString(params.cfg.agents?.defaults?.heartbeat?.model);
const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
const agentModel = agentId
? normalizeOptionalString(
params.cfg.agents?.list?.find(
(entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
)?.heartbeat?.model,
)
? normalizeOptionalString(resolveAgentConfig(params.cfg, agentId)?.heartbeat?.model)
: undefined;
return agentModel ?? defaultModel;
}
@@ -75,11 +72,7 @@ function resolveAgentContextTokensForHint(params: {
);
const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
const agentContextTokens = agentId
? normalizePositiveContextTokens(
params.cfg.agents?.list?.find(
(entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
)?.contextTokens,
)
? normalizePositiveContextTokens(resolveAgentConfig(params.cfg, agentId)?.contextTokens)
: undefined;
return agentContextTokens ?? defaultContextTokens;
}
+11 -7
View File
@@ -410,7 +410,7 @@ function resolveSessionLogPath(
sessionId?: string,
sessionEntry?: SessionEntry,
sessionKey?: string,
opts?: { storePath?: string },
opts?: { agentId?: string; storePath?: string },
): string | undefined {
if (!sessionId) {
return undefined;
@@ -424,7 +424,9 @@ function resolveSessionLogPath(
if (parseSqliteSessionFileMarker(sessionFile)) {
return sessionFile;
}
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const agentId =
opts?.agentId ??
(sessionKey?.startsWith("agent:") ? resolveAgentIdFromSessionKey(sessionKey) : undefined);
if (!sessionFile && agentId && opts?.storePath) {
return formatSqliteSessionFileMarker({
agentId,
@@ -511,7 +513,7 @@ async function readSessionLogSnapshot(params: {
sessionId?: string;
sessionEntry?: SessionEntry;
sessionKey?: string;
opts?: { storePath?: string };
opts?: { agentId?: string; storePath?: string };
includeByteSize: boolean;
includeUsage: boolean;
}): Promise<SessionLogSnapshot> {
@@ -652,6 +654,7 @@ type TranscriptTokenEstimate = {
};
async function estimatePromptTokensFromSessionTranscript(params: {
agentId?: string;
sessionId?: string;
sessionEntry?: SessionEntry;
sessionKey?: string;
@@ -666,7 +669,7 @@ async function estimatePromptTokensFromSessionTranscript(params: {
sessionId,
sessionEntry: params.sessionEntry,
sessionKey: params.sessionKey,
opts: { storePath: params.storePath },
opts: { agentId: params.agentId, storePath: params.storePath },
includeByteSize: true,
includeUsage: true,
});
@@ -840,6 +843,7 @@ export async function runPreflightCompactionIfNeeded(params: {
typeof freshPersistedTokens === "number" && !freshNeedsOutputRead
? undefined
: await estimatePromptTokensFromSessionTranscript({
agentId: params.followupRun.run.agentId,
sessionId: entry.sessionId,
sessionEntry: entry,
sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey,
@@ -851,7 +855,7 @@ export async function runPreflightCompactionIfNeeded(params: {
sessionId: entry.sessionId,
sessionEntry: entry,
sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey,
opts: { storePath: params.storePath },
opts: { agentId: params.followupRun.run.agentId, storePath: params.storePath },
includeByteSize: true,
includeUsage: false,
})
@@ -954,7 +958,7 @@ export async function runPreflightCompactionIfNeeded(params: {
entry.sessionId,
entry,
params.sessionKey ?? params.followupRun.run.sessionKey,
{ storePath: params.storePath },
{ agentId: params.followupRun.run.agentId, storePath: params.storePath },
);
if (!sessionFile) {
await notifyTerminalCompaction("skipped");
@@ -1191,7 +1195,7 @@ export async function runMemoryFlushIfNeeded(params: {
sessionId: params.followupRun.run.sessionId,
sessionEntry: entry,
sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey,
opts: { storePath: params.storePath },
opts: { agentId: params.followupRun.run.agentId, storePath: params.storePath },
includeByteSize: shouldCheckTranscriptSizeForForcedFlush,
includeUsage: shouldReadTranscript,
})
@@ -3,7 +3,6 @@ import { clearAllCliSessions } from "../../agents/cli-session.js";
// Handles session reset requests produced during agent runner execution.
import { transitionMainSessionRecovery } from "../../agents/main-session-recovery-state.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveAgentIdFromSessionKey } from "../../config/sessions.js";
import { persistSessionResetLifecycle } from "../../config/sessions/session-accessor.js";
import {
formatSqliteSessionFileMarker,
@@ -107,7 +106,7 @@ export async function resetReplyRunSession(params: {
clearAllCliSessions(nextEntry);
nextEntry.agentHarnessId = undefined;
transitionMainSessionRecovery(nextEntry, { kind: "clear" });
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const agentId = params.followupRun.run.agentId;
const nextSessionFile =
(sqliteSessionFileMarkerMatchesTarget(prevEntry.sessionFile, {
agentId,
@@ -541,7 +541,7 @@ describe("runReplyAgent auto-compaction token update", () => {
it("keeps an unarmed preflight drain visible instead of dropping the reply", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-preflight-drain-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionKey = "agent:main:main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
@@ -579,6 +579,7 @@ describe("runReplyAgent auto-compaction token update", () => {
typingMode: "instant",
});
expect(compactState.compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
expectReplyText(result, "⚠️ Gateway is restarting. Please wait a few seconds and try again.");
});
+12 -5
View File
@@ -38,7 +38,12 @@ function firstBeforeResetCall() {
}
describe("emitResetCommandHooks", () => {
async function runBeforeResetContext(sessionKey?: string) {
async function runBeforeResetContext(
sessionKey?: string,
cfg: HandleCommandsParams["cfg"] = {
agents: { entries: { main: { default: true } } },
},
) {
const command = {
surface: "discord",
senderId: "rai",
@@ -51,7 +56,7 @@ describe("emitResetCommandHooks", () => {
await emitResetCommandHooks({
action: "new",
ctx: {} as HandleCommandsParams["ctx"],
cfg: {} as HandleCommandsParams["cfg"],
cfg,
command,
sessionKey,
previousSessionEntry: {
@@ -87,9 +92,11 @@ describe("emitResetCommandHooks", () => {
expect(ctx?.workspaceDir).toBe("/tmp/openclaw-workspace");
});
it("falls back to main when the reset hook has no session key", async () => {
const ctx = await runBeforeResetContext(undefined);
expect(ctx?.agentId).toBe("main");
it("uses the configured default when the reset hook has no session key", async () => {
const ctx = await runBeforeResetContext(undefined, {
agents: { entries: { ops: { default: true } } },
});
expect(ctx?.agentId).toBe("ops");
expect(ctx?.sessionKey).toBeUndefined();
expect(ctx?.sessionId).toBe("prev-session");
expect(ctx?.workspaceDir).toBe("/tmp/openclaw-workspace");
+3 -1
View File
@@ -1,5 +1,6 @@
// Tests /learn prompt rewriting, defaults, standards, and availability gating.
import { describe, expect, it } from "vitest";
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { DEFAULT_LEARN_REQUEST } from "../../skills/workshop/learn-prompt.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
@@ -30,8 +31,9 @@ function buildLearnParams(
commandBodyNormalized: string,
cfg: OpenClawConfig = {},
): HandleCommandsParams {
const loadedConfig = migratePersistedImplicitMainRoster(cfg).config as OpenClawConfig;
return {
cfg: { ...cfg, models: cfg.models ?? DEFAULT_TEST_MODELS },
cfg: { ...loadedConfig, models: loadedConfig.models ?? DEFAULT_TEST_MODELS },
ctx: {
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,

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