diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ef8610e67..34720222dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc. - Agents/tools: stop treating `tools.deny: ["write"]` as an implicit `apply_patch` deny; operators who want to block patch writes should deny `apply_patch` or `group:fs` explicitly. Fixes #76749. (#76795) Thanks @Nek-12 and @hclsys. - Plugins/release: verify published plugin npm tarballs expose compiled runtime entries after publish, catching TS-only package artifacts before release closeout. Thanks @vincentkoc. - CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168. diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index b08a3abf5b3..36032487e4a 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -2208,6 +2208,52 @@ describe("config cli", () => { unsetPaths: [["tools", "alsoAllow"]], }); }); + + it("removes only the specified array element", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [{ id: "agent-a" }, { id: "agent-b" }, { id: "agent-c" }], + }, + }; + const runtimeMerged: OpenClawConfig = { + ...withRuntimeDefaults(resolved), + }; + setSnapshot(resolved, runtimeMerged); + + await runConfigCommand(["config", "unset", "agents.list[1]"]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.agents?.list).toEqual([{ id: "agent-a" }, { id: "agent-c" }]); + expect(mockWriteConfigFile.mock.calls[0]?.[1]).toBeUndefined(); + }); + + it("preserves write-level unset handling for numeric object keys", async () => { + const resolved: OpenClawConfig = { + channels: { + discord: { + guilds: { + "123": { channels: ["general"] }, + "456": { channels: ["alerts"] }, + }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "unset", "channels.discord.guilds.123"]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0] as { + channels?: { discord?: { guilds?: Record } }; + }; + expect(written.channels?.discord?.guilds).toEqual({ + "456": { channels: ["alerts"] }, + }); + expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({ + unsetPaths: [["channels", "discord", "guilds", "123"]], + }); + }); }); describe("config file", () => { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index a9f38c47065..afd02c780dd 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -470,27 +470,29 @@ function assertNonDestructiveReplacement(params: { } } -function unsetAtPath(root: Record, path: PathSegment[]): boolean { +type UnsetAtPathResult = { removed: true; leafContainer: "array" | "object" } | { removed: false }; + +function unsetAtPath(root: Record, path: PathSegment[]): UnsetAtPathResult { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { const segment = path[i]; if (!current || typeof current !== "object") { - return false; + return { removed: false }; } if (Array.isArray(current)) { if (!isIndexSegment(segment)) { - return false; + return { removed: false }; } const index = Number.parseInt(segment, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { - return false; + return { removed: false }; } current = current[index]; continue; } const record = current as Record; if (!hasOwnPathKey(record, segment)) { - return false; + return { removed: false }; } current = record[segment]; } @@ -498,24 +500,24 @@ function unsetAtPath(root: Record, path: PathSegment[]): boolea const last = path[path.length - 1]; if (Array.isArray(current)) { if (!isIndexSegment(last)) { - return false; + return { removed: false }; } const index = Number.parseInt(last, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { - return false; + return { removed: false }; } current.splice(index, 1); - return true; + return { removed: true, leafContainer: "array" }; } if (!current || typeof current !== "object") { - return false; + return { removed: false }; } const record = current as Record; if (!hasOwnPathKey(record, last)) { - return false; + return { removed: false }; } delete record[last]; - return true; + return { removed: true, leafContainer: "object" }; } async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) { @@ -1692,8 +1694,8 @@ export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) const next = structuredClone(snapshot.resolved) as Record; - const removed = unsetAtPath(next, parsedPath); - if (!removed) { + const unsetResult = unsetAtPath(next, parsedPath); + if (!unsetResult.removed) { runtime.error(danger(`Config path not found: ${opts.path}`)); runtime.exit(1); return; @@ -1701,7 +1703,9 @@ export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv await replaceConfigFile({ nextConfig: next, ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - writeOptions: { unsetPaths: [parsedPath] }, + ...(unsetResult.leafContainer === "array" + ? {} + : { writeOptions: { unsetPaths: [parsedPath] } }), }); runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`)); } catch (err) {