Skip to content

Commit 9f48254

Browse files
authored
Fix config.patch explicit array replacement (#91551)
* fix config patch explicit array replacement * fix generated config patch protocol model * fix config patch test helper typing * fix shared auth patch replacement tests * update config patch prompt snapshots * harden qa lab config patch replace paths
1 parent 329fa44 commit 9f48254

28 files changed

Lines changed: 850 additions & 57 deletions

apps/ios/Sources/Design/AgentProModels.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,13 @@ struct AgentConfigLite: Decodable {
329329
struct ConfigPatchParams: Encodable {
330330
let raw: String
331331
let baseHash: String
332+
let replacePaths: [String]?
333+
334+
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
335+
self.raw = raw
336+
self.baseHash = baseHash
337+
self.replacePaths = replacePaths
338+
}
332339
}
333340

334341
enum SkillMutationError: LocalizedError {

apps/ios/Sources/Design/AgentProTab+Skills.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,10 @@ extension AgentProTab {
621621
}
622622

623623
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
624-
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
624+
let params = ConfigPatchParams(
625+
raw: raw,
626+
baseHash: baseHash,
627+
replacePaths: ["agents.list[].skills"])
625628
let data = try JSONEncoder().encode(params)
626629
guard let json = String(data: data, encoding: .utf8) else {
627630
throw SkillMutationError.invalidPatchPayload

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2773,21 +2773,24 @@ public struct ConfigPatchParams: Codable, Sendable {
27732773
public let deliverycontext: [String: AnyCodable]?
27742774
public let note: String?
27752775
public let restartdelayms: Int?
2776+
public let replacepaths: [String]?
27762777

27772778
public init(
27782779
raw: String,
27792780
basehash: String?,
27802781
sessionkey: String?,
27812782
deliverycontext: [String: AnyCodable]?,
27822783
note: String?,
2783-
restartdelayms: Int?)
2784+
restartdelayms: Int?,
2785+
replacepaths: [String]?)
27842786
{
27852787
self.raw = raw
27862788
self.basehash = basehash
27872789
self.sessionkey = sessionkey
27882790
self.deliverycontext = deliverycontext
27892791
self.note = note
27902792
self.restartdelayms = restartdelayms
2793+
self.replacepaths = replacepaths
27912794
}
27922795

27932796
private enum CodingKeys: String, CodingKey {
@@ -2797,6 +2800,7 @@ public struct ConfigPatchParams: Codable, Sendable {
27972800
case deliverycontext = "deliveryContext"
27982801
case note
27992802
case restartdelayms = "restartDelayMs"
2803+
case replacepaths = "replacePaths"
28002804
}
28012805
}
28022806

docs/gateway/configuration.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,8 @@ For tooling that writes config over the gateway API, prefer this flow:
601601
summaries)
602602
- `config.get` to fetch the current snapshot plus `hash`
603603
- `config.patch` for partial updates (JSON merge patch: objects merge, `null`
604-
deletes, arrays replace)
604+
deletes, arrays replace when explicitly confirmed with `replacePaths` if
605+
entries would be removed)
605606
- `config.apply` only when you intend to replace the entire config
606607
- `update.run` for explicit self-update plus restart; include `continuationMessage` when the post-restart session should run one follow-up turn
607608
- `update.status` to inspect the latest update restart sentinel and verify the running version after a restart
@@ -633,6 +634,14 @@ Both `config.apply` and `config.patch` accept `raw`, `baseHash`, `sessionKey`,
633634
`note`, and `restartDelayMs`. `baseHash` is required for both methods when a
634635
config already exists.
635636

637+
`config.patch` also accepts `replacePaths`, an array of config paths whose array
638+
replacement is intentional. If a patch would replace or delete an existing array
639+
with fewer entries, the Gateway rejects the write unless that exact path appears
640+
in `replacePaths`; nested arrays under array entries use `[]`, such as
641+
`agents.list[].skills`. This prevents truncated `config.get` snapshots from
642+
silently clobbering routing or allowlist arrays. Use `config.apply` when you
643+
intend to replace the full config.
644+
636645
## Environment variables
637646

638647
OpenClaw reads env vars from the parent process plus:

docs/gateway/protocol.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
405405
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
406406
- `config.get` returns the current config snapshot and hash.
407407
- `config.set` writes a validated config payload.
408-
- `config.patch` merges a partial config update.
408+
- `config.patch` merges a partial config update. Destructive array
409+
replacement requires the affected path in `replacePaths`; nested arrays
410+
under array entries use `[]` paths such as `agents.list[].skills`.
409411
- `config.apply` validates + replaces the full config payload.
410412
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
411413
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.

extensions/qa-lab/src/suite-runtime-gateway.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,18 +261,25 @@ describe("qa suite gateway helpers", () => {
261261
patchConfig({
262262
env,
263263
patch: { tools: { deny: ["read"] } },
264+
replacePaths: ["tools.deny"],
264265
restartDelayMs: 0,
265266
}),
266267
).resolves.toEqual({ ok: true });
267268

268269
expect(gatewayCall).toHaveBeenCalledWith(
269270
"config.patch",
270-
expect.objectContaining({ baseHash: "hash-1" }),
271+
expect.objectContaining({
272+
baseHash: "hash-1",
273+
replacePaths: ["tools.deny"],
274+
}),
271275
{ timeoutMs: 180_000 },
272276
);
273277
expect(gatewayCall).toHaveBeenCalledWith(
274278
"config.patch",
275-
expect.objectContaining({ baseHash: "hash-2" }),
279+
expect.objectContaining({
280+
baseHash: "hash-2",
281+
replacePaths: ["tools.deny"],
282+
}),
276283
{ timeoutMs: 180_000 },
277284
);
278285
});

extensions/qa-lab/src/suite-runtime-gateway.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ async function runConfigMutation(params: {
241241
};
242242
note?: string;
243243
restartDelayMs?: number;
244+
replacePaths?: readonly string[];
244245
}) {
245246
const restartDelayMs = params.restartDelayMs ?? 1_000;
246247
const timeoutMs = liveTurnTimeoutMs(params.env, 180_000);
@@ -263,6 +264,7 @@ async function runConfigMutation(params: {
263264
...(params.deliveryContext ? { deliveryContext: params.deliveryContext } : {}),
264265
...(params.note ? { note: params.note } : {}),
265266
restartDelayMs,
267+
...(params.replacePaths?.length ? { replacePaths: params.replacePaths } : {}),
266268
},
267269
{ timeoutMs },
268270
);
@@ -316,6 +318,7 @@ async function patchConfig(params: {
316318
};
317319
note?: string;
318320
restartDelayMs?: number;
321+
replacePaths?: readonly string[];
319322
}) {
320323
return await runConfigMutation({
321324
env: params.env,
@@ -325,6 +328,7 @@ async function patchConfig(params: {
325328
deliveryContext: params.deliveryContext,
326329
note: params.note,
327330
restartDelayMs: params.restartDelayMs,
331+
replacePaths: params.replacePaths,
328332
});
329333
}
330334

extensions/qa-matrix/src/runners/contract/runtime.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ describe("matrix live qa runtime", () => {
613613
await liveTesting.patchMatrixQaGatewayConfig({
614614
gateway: gateway as never,
615615
patch,
616+
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
616617
restartDelayMs: 250,
617618
});
618619

@@ -623,6 +624,7 @@ describe("matrix live qa runtime", () => {
623624
{
624625
baseHash: "hash-old",
625626
raw: JSON.stringify(patch, null, 2),
627+
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
626628
restartDelayMs: 250,
627629
},
628630
{ timeoutMs: 60_000 },
@@ -634,6 +636,7 @@ describe("matrix live qa runtime", () => {
634636
{
635637
baseHash: "hash-fresh",
636638
raw: JSON.stringify(patch, null, 2),
639+
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
637640
restartDelayMs: 250,
638641
},
639642
{ timeoutMs: 60_000 },

extensions/qa-matrix/src/runners/contract/runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ async function waitForMatrixChannelReady(
546546
async function patchMatrixQaGatewayConfig(params: {
547547
gateway: MatrixQaGatewayChild;
548548
patch: Record<string, unknown>;
549+
replacePaths?: string[];
549550
restartDelayMs?: number;
550551
}) {
551552
for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -561,6 +562,7 @@ async function patchMatrixQaGatewayConfig(params: {
561562
{
562563
raw: JSON.stringify(params.patch, null, 2),
563564
baseHash: snapshot.hash,
565+
...(params.replacePaths?.length ? { replacePaths: params.replacePaths } : {}),
564566
restartDelayMs: params.restartDelayMs ?? 0,
565567
},
566568
{ timeoutMs: 60_000 },
@@ -973,6 +975,7 @@ export async function runMatrixQaLive(params: {
973975
await patchMatrixQaGatewayConfig({
974976
gateway: scenarioGateway.harness.gateway,
975977
patch,
978+
replacePaths: opts?.replacePaths,
976979
restartDelayMs: opts?.restartDelayMs,
977980
});
978981
},

extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ export async function runAllowlistHotReloadScenario(context: MatrixQaScenarioCon
526526
},
527527
},
528528
{
529+
replacePaths: [`channels.matrix.accounts.${accountId}.groupAllowFrom`],
529530
restartDelayMs: MATRIX_QA_HOT_RELOAD_RESTART_DELAY_MS,
530531
},
531532
);

0 commit comments

Comments
 (0)