diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 182c18185037d..468add3ea2642 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5086,52 +5086,60 @@ public struct AgentSummary: Codable, Sendable { } public struct AgentsCreateParams: Codable, Sendable { - public let name: String + public let agentid: String + public let displayname: String public let workspace: String public let model: String? public let emoji: String? public let avatar: String? + public let config: [String: AnyCodable]? public init( - name: String, + agentid: String, + displayname: String, workspace: String, model: String?, emoji: String?, - avatar: String?) + avatar: String?, + config: [String: AnyCodable]?) { - self.name = name + self.agentid = agentid + self.displayname = displayname self.workspace = workspace self.model = model self.emoji = emoji self.avatar = avatar + self.config = config } private enum CodingKeys: String, CodingKey { - case name + case agentid = "agentId" + case displayname = "displayName" case workspace case model case emoji case avatar + case config } } public struct AgentsCreateResult: Codable, Sendable { public let ok: Bool public let agentid: String - public let name: String + public let displayname: String public let workspace: String public let model: String? public init( ok: Bool, agentid: String, - name: String, + displayname: String, workspace: String, model: String?) { self.ok = ok self.agentid = agentid - self.name = name + self.displayname = displayname self.workspace = workspace self.model = model } @@ -5139,7 +5147,7 @@ public struct AgentsCreateResult: Codable, Sendable { private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" - case name + case displayname = "displayName" case workspace case model } diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 7bf917276efcb..9aee7dfff80bf 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -104,11 +104,13 @@ export const AgentsListResultSchema = Type.Object( /** Creates a configured agent with workspace, identity, and optional model. */ export const AgentsCreateParamsSchema = Type.Object( { - name: NonEmptyString, + agentId: NonEmptyString, + displayName: NonEmptyString, workspace: NonEmptyString, model: Type.Optional(NonEmptyString), emoji: Type.Optional(Type.String()), avatar: Type.Optional(Type.String()), + config: Type.Optional(Type.Object({}, { additionalProperties: true })), }, { additionalProperties: false }, ); @@ -118,7 +120,7 @@ export const AgentsCreateResultSchema = Type.Object( { ok: Type.Literal(true), agentId: NonEmptyString, - name: NonEmptyString, + displayName: NonEmptyString, workspace: NonEmptyString, model: Type.Optional(NonEmptyString), }, diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 5e24f43de388c..bea20eb47e722 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -13,6 +13,7 @@ import { import type { AgentIdentityFile } from "../agents/identity-file.js"; import { identityHasValues, loadAgentIdentityFromWorkspace } from "../agents/identity-file.js"; import { listRouteBindings } from "../config/bindings.js"; +import type { AgentConfig } from "../config/types.agents.js"; import type { IdentityConfig } from "../config/types.base.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -154,6 +155,33 @@ export function applyAgentConfig( }; } +/** + * Merges arbitrary AgentConfig overrides into an existing agent entry. + * Protected fields (id, name, workspace, agentDir) are stripped from overrides + * so explicit create/update params always win. + */ +export function mergeAgentConfigOverrides( + cfg: OpenClawConfig, + agentId: string, + overrides: Partial>, +): OpenClawConfig { + const id = normalizeAgentId(agentId); + const list = listAgentEntries(cfg); + const index = list.findIndex((e) => normalizeAgentId(e.id) === id); + if (index < 0) { + return cfg; + } + const nextList = [...list]; + nextList[index] = { ...list[index], ...overrides }; + return { + ...cfg, + agents: { + ...cfg.agents, + list: nextList, + }, + }; +} + /** Remove an agent and any config references that route or allow traffic to it. */ export function pruneAgentConfig( cfg: OpenClawConfig, diff --git a/src/gateway/server-methods/agents-config-mutations.ts b/src/gateway/server-methods/agents-config-mutations.ts index 03766b6969f77..6d7848ac4b996 100644 --- a/src/gateway/server-methods/agents-config-mutations.ts +++ b/src/gateway/server-methods/agents-config-mutations.ts @@ -5,10 +5,12 @@ import { applyAgentConfig, findAgentEntryIndex, listAgentEntries, + mergeAgentConfigOverrides, pruneAgentConfig, } from "../../commands/agents.config.js"; import { mutateConfigFileWithRetry } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions.js"; +import type { AgentConfig } from "../../config/types.agents.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -47,6 +49,7 @@ export async function createAgentConfigEntry(params: { model?: string; identity?: IdentityConfig; agentDir: string; + configOverrides?: Partial>; }): Promise { await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, @@ -62,7 +65,12 @@ export async function createAgentConfigEntry(params: { identity: params.identity, agentDir: params.agentDir, }); - Object.assign(draft, latestNextConfig); + Object.assign( + draft, + params.configOverrides + ? mergeAgentConfigOverrides(latestNextConfig, params.agentId, params.configOverrides) + : latestNextConfig, + ); }, }); } diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 748d7ed3b5efc..34b9d97addc41 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ listAgentEntries: vi.fn((_cfg?: unknown) => [] as Array>), findAgentEntryIndex: vi.fn((_list?: unknown, _agentId?: string) => -1), applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})), + mergeAgentConfigOverrides: vi.fn((cfg: unknown, _agentId: unknown, _overrides: unknown) => cfg), pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })), writeConfigFile: vi.fn(async (_nextConfig?: unknown) => {}), ensureAgentWorkspace: vi.fn( @@ -101,8 +102,10 @@ vi.mock("../../config/config.js", async () => { vi.mock("../../commands/agents.config.js", () => ({ applyAgentConfig: mocks.applyAgentConfig, + mergeAgentConfigOverrides: mocks.mergeAgentConfigOverrides, findAgentEntryIndex: mocks.findAgentEntryIndex, listAgentEntries: mocks.listAgentEntries, + mergeAgentConfigOverrides: mocks.mergeAgentConfigOverrides, pruneAgentConfig: mocks.pruneAgentConfig, })); @@ -522,7 +525,8 @@ describe("agents.create", () => { it("creates a new agent successfully", async () => { const { respond, promise } = makeCall("agents.create", { - name: "Test Agent", + agentId: "test-agent", + displayName: "Test Agent", workspace: "/home/user/agents/test", }); await promise; @@ -530,7 +534,7 @@ describe("agents.create", () => { expectRespondOk(respond, { ok: true, agentId: "test-agent", - name: "Test Agent", + displayName: "Test Agent", }); expect(mocks.ensureAgentWorkspace).toHaveBeenCalled(); expect(mocks.writeConfigFile).toHaveBeenCalled(); @@ -547,7 +551,8 @@ describe("agents.create", () => { }); const { promise } = makeCall("agents.create", { - name: "Order Test", + agentId: "order-test", + displayName: "Order Test", workspace: "/tmp/ws", }); await promise; @@ -559,7 +564,8 @@ describe("agents.create", () => { it("rejects creating an agent with reserved 'main' id", async () => { const { respond, promise } = makeCall("agents.create", { - name: "main", + agentId: "main", + displayName: "Main Agent", workspace: "/tmp/ws", }); await promise; @@ -571,7 +577,8 @@ describe("agents.create", () => { mocks.findAgentEntryIndex.mockReturnValue(0); const { respond, promise } = makeCall("agents.create", { - name: "Existing", + agentId: "existing", + displayName: "Existing", workspace: "/tmp/ws", }); await promise; @@ -588,7 +595,8 @@ describe("agents.create", () => { }); const { respond, promise } = makeCall("agents.create", { - name: "Race Agent", + agentId: "race-agent", + displayName: "Race Agent", workspace: "/tmp/ws", }); await promise; @@ -597,7 +605,7 @@ describe("agents.create", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); - it("rejects invalid params (missing name)", async () => { + it("rejects invalid params (missing id)", async () => { const { respond, promise } = makeCall("agents.create", { workspace: "/tmp/ws", }); @@ -608,7 +616,8 @@ describe("agents.create", () => { it("writes identity to both config and IDENTITY.md", async () => { const { promise } = makeCall("agents.create", { - name: "Plain Agent", + agentId: "plain-agent", + displayName: "Plain Agent", workspace: "/tmp/ws", }); await promise; @@ -624,7 +633,8 @@ describe("agents.create", () => { it("writes emoji and avatar to both config and IDENTITY.md", async () => { const { promise } = makeCall("agents.create", { - name: "Fancy Agent", + agentId: "fancy-agent", + displayName: "Fancy Agent", workspace: "/tmp/ws", emoji: "🤖", avatar: "https://example.com/avatar.png", @@ -659,7 +669,8 @@ describe("agents.create", () => { ); const { respond, promise } = makeCall("agents.create", { - name: "Unsafe Agent", + agentId: "unsafe-agent", + displayName: "Unsafe Agent", workspace: "/tmp/ws", }); await promise; @@ -682,7 +693,8 @@ describe("agents.create", () => { }); const { promise } = makeCall("agents.create", { - name: "Unreadable Identity", + agentId: "unreadable-identity", + displayName: "Unreadable Identity", workspace: "/tmp/ws", }); @@ -701,7 +713,8 @@ describe("agents.create", () => { }); const { respond, promise } = makeCall("agents.create", { - name: "Unsafe Identity Read", + agentId: "unsafe-identity-read", + displayName: "Unsafe Identity Read", workspace: "/tmp/ws", }); await promise; @@ -718,7 +731,8 @@ describe("agents.create", () => { agentsTesting.setDepsForTests({ root: makeRootForTest({ read: rootRead }) }); const { promise } = makeCall("agents.create", { - name: "NB Agent", + agentId: "nb-agent", + displayName: "NB Agent", workspace: "/tmp/ws", }); await promise; @@ -731,7 +745,8 @@ describe("agents.create", () => { it("passes model to applyAgentConfig when provided", async () => { const { respond, promise } = makeCall("agents.create", { - name: "Model Agent", + agentId: "model-agent", + displayName: "Model Agent", workspace: "/tmp/ws", model: "sonnet-4.6", }); @@ -740,6 +755,62 @@ describe("agents.create", () => { expectRespondOk(respond, { ok: true, model: "sonnet-4.6" }); expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), { model: "sonnet-4.6" }); }); + + it("merges config overrides into agent entry when config is provided", async () => { + const sandboxConfig = { + browser: { enabled: false, allowHostControl: true }, + mode: "non-main", + backend: "ssh", + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + ssh: { + target: "user@sandbox-host.example.com:22", + strictHostKeyChecking: true, + updateHostKeys: true, + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + sessionToolsVisibility: "all", + prune: { idleHours: 4, maxAgeDays: 3 }, + }; + + const { promise } = makeCall("agents.create", { + agentId: "ssh-agent", + displayName: "SSH Agent", + workspace: "/tmp/ws", + config: { sandbox: sandboxConfig }, + }); + await promise; + + expect(mocks.mergeAgentConfigOverrides).toHaveBeenCalledWith( + expect.anything(), + "ssh-agent", + expect.objectContaining({ sandbox: sandboxConfig }), + ); + // Protected fields must not be forwarded to the merge helper + expect(mocks.mergeAgentConfigOverrides).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.not.objectContaining({ id: expect.anything() }), + ); + expect(mocks.mergeAgentConfigOverrides).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.not.objectContaining({ workspace: expect.anything() }), + ); + }); + + it("skips mergeAgentConfigOverrides when no config is provided", async () => { + const { promise } = makeCall("agents.create", { + agentId: "plain-no-config", + displayName: "Plain", + workspace: "/tmp/ws", + }); + await promise; + + expect(mocks.mergeAgentConfigOverrides).not.toHaveBeenCalled(); + }); }); describe("agents.update", () => { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 226d398ddce20..a1fd0476d2a49 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -37,11 +37,12 @@ import { resolveWorkspaceAttestationPaths, shouldRemoveWorkspaceAttestation, } from "../../agents/workspace.js"; -import { applyAgentConfig } from "../../commands/agents.config.js"; +import { applyAgentConfig, mergeAgentConfigOverrides } from "../../commands/agents.config.js"; import { purgeAgentSessionStoreEntries, resolveSessionTranscriptsDirForAgent, } from "../../config/sessions.js"; +import type { AgentConfig } from "../../config/types.agents.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js"; @@ -510,8 +511,7 @@ export const agentsHandlers: GatewayRequestHandlers = { } const cfg = context.getRuntimeConfig(); - const rawName = params.name.trim(); - const agentId = normalizeAgentId(rawName); + const agentId = normalizeAgentId(params.agentId.trim()); if (agentId === DEFAULT_AGENT_ID) { respond( false, @@ -532,7 +532,7 @@ export const agentsHandlers: GatewayRequestHandlers = { const workspaceDir = resolveUserPath(params.workspace.trim()); - const safeName = sanitizeIdentityLine(rawName); + const safeName = sanitizeIdentityLine(params.displayName.trim()); const model = resolveOptionalStringParam(params.model); const identity = createAgentIdentityConfig({ safeName, @@ -552,6 +552,23 @@ export const agentsHandlers: GatewayRequestHandlers = { const agentDir = resolveAgentDir(nextConfig, agentId); nextConfig = applyAgentConfig(nextConfig, { agentId, agentDir }); + // Apply optional config overrides AFTER explicit params so that id/name/workspace/agentDir + // set above always win. + let configOverrides: + | Partial> + | undefined; + if (params.config && typeof params.config === "object") { + const { + id: _id, + name: _name, + workspace: _ws, + agentDir: _dir, + ...overrides + } = params.config as Partial; + configOverrides = overrides; + nextConfig = mergeAgentConfigOverrides(nextConfig, agentId, overrides); + } + // Ensure workspace & transcripts exist BEFORE writing config so a failure // here does not leave a broken config entry behind. const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap); @@ -562,25 +579,27 @@ export const agentsHandlers: GatewayRequestHandlers = { }); await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true }); - const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId)); - if (persistedIdentity) { - const identityContent = await buildIdentityMarkdownOrRespondUnsafe({ - respond, - workspaceDir, - identity: persistedIdentity, - }); - if (identityContent === null) { - return; - } - if ( - !(await writeWorkspaceFileOrRespond({ + if (!skipBootstrap) { + const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId)); + if (persistedIdentity) { + const identityContent = await buildIdentityMarkdownOrRespondUnsafe({ respond, workspaceDir, - name: DEFAULT_IDENTITY_FILENAME, - content: identityContent, - })) - ) { - return; + identity: persistedIdentity, + }); + if (identityContent === null) { + return; + } + if ( + !(await writeWorkspaceFileOrRespond({ + respond, + workspaceDir, + name: DEFAULT_IDENTITY_FILENAME, + content: identityContent, + })) + ) { + return; + } } } try { @@ -591,6 +610,7 @@ export const agentsHandlers: GatewayRequestHandlers = { model, identity, agentDir, + configOverrides, }); } catch (error) { if (error instanceof AgentConfigPreconditionError) { @@ -600,7 +620,11 @@ export const agentsHandlers: GatewayRequestHandlers = { throw error; } - respond(true, { ok: true, agentId, name: safeName, workspace: workspaceDir, model }, undefined); + respond( + true, + { ok: true, agentId, displayName: safeName, workspace: workspaceDir, model }, + undefined, + ); }, "agents.update": async ({ params, respond, context }) => { if (!validateAgentsUpdateParams(params)) { @@ -653,8 +677,9 @@ export const agentsHandlers: GatewayRequestHandlers = { }); } + const skipBootstrapForUpdate = Boolean(nextConfig.agents?.defaults?.skipBootstrap); const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId)); - if (persistedIdentity && (workspaceDir || hasIdentityFields)) { + if (!skipBootstrapForUpdate && persistedIdentity && (workspaceDir || hasIdentityFields)) { const identityWorkspaceDir = resolveAgentWorkspaceDir(nextConfig, agentId); const previousWorkspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const fallbackWorkspaceDir =