diff --git a/src/commands/agent-exec.agent-owner.test.ts b/src/commands/agent-exec.agent-owner.test.ts new file mode 100644 index 000000000000..e7304df812b2 --- /dev/null +++ b/src/commands/agent-exec.agent-owner.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { resolveSessionStorePathCore } from "../config/sessions/paths.js"; +import { resolveSqliteScope } from "../config/sessions/session-accessor.sqlite-scope.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentExecCommand } from "./agent-exec.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("agent exec owner selection", () => { + it.each([ + { + name: "a migrated legacy default", + config: { + agents: { + entries: { + alpha: { default: true }, + beta: {}, + }, + }, + }, + }, + { + name: "the canonical system-agent owner", + config: { + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "alpha" } }, + entries: { alpha: {}, beta: {} }, + }, + }, + }, + ])("uses $name for the run and its SQLite store scope", async ({ config }) => { + const configRoot = tempDirs.make("openclaw-agent-exec-default-agent-"); + const configPath = path.join(configRoot, "openclaw.json"); + await fs.writeFile(configPath, JSON.stringify(config), "utf8"); + const runAgent = vi.fn(async (options: Record) => { + const requestedAgentId = typeof options.agentId === "string" ? options.agentId : "main"; + const sessionId = String(options.sessionId); + const storePath = resolveSessionStorePathCore(undefined, { + agentId: "alpha", + env: process.env, + }); + + // Exercise the production guard that rejects keys owned by another agent. + expect(() => + resolveSqliteScope({ + agentId: requestedAgentId, + defaultAgentId: "alpha", + env: process.env, + sessionKey: `agent:${requestedAgentId}:explicit:${sessionId}`, + storePath, + }), + ).not.toThrow(); + expect(requestedAgentId).toBe("alpha"); + return { + payloads: [{ text: "done" }], + meta: { durationMs: 1 }, + }; + }); + + const result = await agentExecCommand("inspect", { config: configPath }, runtime, { runAgent }); + + expect(result.envelope.error).toBeUndefined(); + expect(result.exitCode).toBe(0); + expect(runAgent).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/commands/agent-exec.ts b/src/commands/agent-exec.ts index 5ea0253dfbac..f8f637484e77 100644 --- a/src/commands/agent-exec.ts +++ b/src/commands/agent-exec.ts @@ -635,7 +635,11 @@ export async function agentExecCommand( const pluginInstallRoots = pluginInstallContext?.resolvePluginInstallRoots(); const timeout = normalizeTimeoutSeconds(opts.timeout); const fallbacks = normalizeFallbacks(opts.model, opts.fallback); - const { resolveDefaultAgentDir } = await import("../agents/agent-scope-config.js"); + const { + resolveAgentDir, + resolveSystemAgentTargetAgentId, + tryResolveLegacyCompatibilityAgentId, + } = await import("../agents/agent-scope-config.js"); // Resolve from the inherited config, not `{}`: the default agent may declare // its own `agentDir`, and that is where its stored auth profiles live. This // reads `baseConfig` rather than `runConfig` because the run config @@ -643,7 +647,15 @@ export async function agentExecCommand( // credential ownership must still follow the operator's configuration. // Computed before the environment repoints the state dir so the unconfigured // case still resolves against the real one. - const storedAuthAgentDir = resolveDefaultAgentDir(baseConfig); + const execAgentId = + tryResolveLegacyCompatibilityAgentId(baseConfig) ?? + resolveSystemAgentTargetAgentId(baseConfig, undefined, { + surface: "agent exec", + hint: "Set agents.defaults.systemAgent.agentId.", + }); + // Auth, session keys, and SQLite ownership must share one resolved owner. + // Splitting these paths can select an agent's store but emit a `main` key. + const storedAuthAgentDir = resolveAgentDir(baseConfig, execAgentId); restoreEnvironment = setAgentExecEnvironment({ stateDir, cwd }); runtimePaths = await import("../config/paths.js"); runtimePaths.pinRuntimePaths(); @@ -698,6 +710,7 @@ export async function agentExecCommand( { message: prompt, sessionId, + agentId: execAgentId, workspaceDir: cwd, cwd, model: opts.model,