fix(config): unset array index once

Fixes #76290.
This commit is contained in:
Vincent Koc
2026-05-03 10:39:45 -07:00
parent a989d248e9
commit fb73f2161e
3 changed files with 65 additions and 14 deletions

View File

@@ -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.

View File

@@ -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<string, unknown> } };
};
expect(written.channels?.discord?.guilds).toEqual({
"456": { channels: ["alerts"] },
});
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({
unsetPaths: [["channels", "discord", "guilds", "123"]],
});
});
});
describe("config file", () => {

View File

@@ -470,27 +470,29 @@ function assertNonDestructiveReplacement(params: {
}
}
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
type UnsetAtPathResult = { removed: true; leafContainer: "array" | "object" } | { removed: false };
function unsetAtPath(root: Record<string, unknown>, 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<string, unknown>;
if (!hasOwnPathKey(record, segment)) {
return false;
return { removed: false };
}
current = record[segment];
}
@@ -498,24 +500,24 @@ function unsetAtPath(root: Record<string, unknown>, 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<string, unknown>;
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<string, unknown>;
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) {