mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user