fix(oc-path): support deep config edits (#86060)

This commit is contained in:
Gio Della-Libera
2026-05-24 18:10:02 -07:00
committed by GitHub
parent f3f4f29dba
commit 3a72a30074
8 changed files with 307 additions and 41 deletions

View File

@@ -197,9 +197,12 @@ the per-kind AST shape.
Markdown insertions append sections, frontmatter keys, or section items and
render a canonical markdown shape for the changed file.
- JSONC leaf writes coerce the string value to the existing leaf type
(`string`, finite `number`, `true`/`false`, or `null`). JSONC object and array
insertions parse `<value>` as JSON and use the `jsonc-parser` edit path for
ordinary leaf writes, preserving comments and nearby formatting.
(`string`, finite `number`, `true`/`false`, or `null`). Use `--value-json`
when a JSONC/JSON/JSONL leaf replacement should parse `<value>` as JSON and
may change shape, such as replacing a string SecretRef shorthand with an
object. JSONC object and array insertions parse `<value>` as JSON and use the
`jsonc-parser` edit path for ordinary leaf writes, preserving comments and
nearby formatting.
- JSONL leaf writes coerce like JSONC inside a line. Whole-line replacement and
append parse `<value>` as JSON. Rendered JSONL preserves the file's dominant
LF/CRLF line-ending convention.
@@ -245,6 +248,12 @@ More grammar examples:
# Quote keys containing / or .
openclaw path resolve 'oc://config.jsonc/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'
# Deep JSON/JSONC paths can use slash segments; they normalize to dotted subsegments
openclaw path set 'oc://openclaw.json/agents/list/0/tools/exec/security' 'allowlist' --dry-run
# Replace a JSONC leaf with a parsed object
openclaw path set 'oc://openclaw.json/gateway/auth/token' '{"source":"file","provider":"secrets","id":"/test"}' --value-json --dry-run
# Predicate search over JSONC children
openclaw path find 'oc://config.jsonc/plugins/[enabled=true]/id'

View File

@@ -255,6 +255,43 @@ describe("openclaw path CLI", () => {
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S08 sets slash-deep JSONC paths and parsed JSON values", async () => {
const filePath = join(workspaceDir, "openclaw.json");
writeFileSync(
filePath,
'{ "agents": { "list": [{ "tools": { "exec": { "security": "deny" } } }] }, "gateway": { "auth": { "token": "${TOKEN}" } } }\n',
"utf-8",
);
const rt = createTestRuntime();
await pathSetCommand(
"oc://openclaw.json/gateway/auth/token",
'{"source":"file","provider":"secrets","id":"/test"}',
{ cwd: workspaceDir, json: true, valueJson: true },
rt,
);
expect(rt.exitCode).toBe(0);
expect(JSON.parse(readFileSync(filePath, "utf8")).gateway.auth.token).toEqual({
source: "file",
provider: "secrets",
id: "/test",
});
const rt2 = createTestRuntime();
await pathSetCommand(
"oc://openclaw.json/agents/list/0/tools/exec/security",
"allowlist",
{ cwd: workspaceDir, json: true },
rt2,
);
expect(rt2.exitCode).toBe(0);
expect(JSON.parse(readFileSync(filePath, "utf8")).agents.list[0].tools.exec.security).toBe(
"allowlist",
);
});
it("CLI-S03 sentinel-bearing value is refused at emit", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
writeFileSync(filePath, '{ "token": "x" }', "utf-8");

View File

@@ -41,6 +41,7 @@ export type OutputRuntimeEnv = {
export interface PathCommandOptions {
readonly json?: boolean;
readonly human?: boolean;
readonly valueJson?: boolean;
readonly cwd?: string;
readonly file?: string;
readonly dryRun?: boolean;
@@ -334,7 +335,9 @@ export async function pathSetCommand(
const oldBytes = await fs.readFile(fsPath, "utf-8");
const ast = await loadAst(fsPath, ocPath.file);
const result = catchSentinel("set", runtime, mode, () => setOcPath(ast, ocPath, value));
const result = catchSentinel("set", runtime, mode, () =>
setOcPath(ast, ocPath, value, { valueJson: options.valueJson === true }),
);
if (result === null) {
return;
}
@@ -553,6 +556,7 @@ export function registerPathCli(program: Command): void {
.description("Write a leaf value at an oc:// path")
.argument("<oc-path>", "oc:// path to write")
.argument("<value>", "string value to write")
.option("--value-json", "Parse <value> as JSON for JSON/JSONC/JSONL leaf replacement")
.option("--dry-run", "Print bytes without writing")
.option("--diff", "With --dry-run, print a unified diff instead of full bytes"),
).action(async (pathStr: string, value: string, opts: PathCommandOptions) => {

View File

@@ -168,12 +168,17 @@ export function parseOcPath(input: string): OcPath {
fail(`Empty oc:// path: ${printable(input)}`, input, "OC_PATH_EMPTY");
}
const segments = splitRespectingBrackets(pathPart, "/", input);
for (const seg of segments) {
const rawSegments = splitRespectingBrackets(pathPart, "/", input);
for (const seg of rawSegments) {
if (seg.length === 0) {
fail(`Empty segment in oc:// path: ${printable(input)}`, input, "OC_PATH_EMPTY_SEGMENT");
}
}
const fileSeg = rawSegments[0];
const file = isQuotedSeg(fileSeg) ? unquoteSeg(fileSeg) : fileSeg;
validateFileSlot(file, input);
const segments = normalizeDeepJsonPathSegments(rawSegments, file, input);
if (segments.length > 4) {
fail(`Too many segments in oc:// path (max 4): ${printable(input)}`, input, "OC_PATH_TOO_DEEP");
}
@@ -193,13 +198,6 @@ export function parseOcPath(input: string): OcPath {
}
}
// Unquote the file slot — splitRespectingBrackets keeps a quoted file
// segment intact so its `/` isn't a slot separator; strip the quotes
// so consumers see the literal filename.
const fileSeg = segments[0];
const file = isQuotedSeg(fileSeg) ? unquoteSeg(fileSeg) : fileSeg;
validateFileSlot(file, input);
const session = extractSession(queryPart, input);
return {
file,
@@ -210,6 +208,33 @@ export function parseOcPath(input: string): OcPath {
};
}
function isJsonPathFile(file: string): boolean {
const lower = file.toLowerCase();
return lower.endsWith(".json") || lower.endsWith(".jsonc");
}
function normalizeDeepJsonPathSegments(
segments: readonly string[],
file: string,
input: string,
): readonly string[] {
if (segments.length <= 4 || !isJsonPathFile(file)) {
return segments;
}
const pathSegments = segments.slice(1);
if (pathSegments.length > MAX_TRAVERSAL_DEPTH) {
fail(
`JSON oc:// path exceeds ${MAX_TRAVERSAL_DEPTH} nested segments: ${printable(input)}`,
input,
"OC_PATH_TOO_DEEP",
);
}
const section = pathSegments.slice(0, -2).join(".");
const item = pathSegments[pathSegments.length - 2];
const field = pathSegments[pathSegments.length - 1];
return [segments[0], section, item, field];
}
/** Format an `OcPath` struct into its canonical string form. */
export function formatOcPath(path: OcPath): string {
if (!path.file || path.file.length === 0) {

View File

@@ -113,6 +113,64 @@ describe("findOcPaths — JSONC kind", () => {
});
});
describe("findOcPaths — slash-deep JSONC paths", () => {
const jsonc = parseJsonc(
JSON.stringify({
mcp: {
servers: {
github: { env: { GITHUB_TOKEN: "gh-token" } },
gitlab: { env: { GITHUB_TOKEN: "gl-token" } },
},
},
agents: [
{ id: "coder", tools: { exec: { security: "deny" } } },
{ id: "reviewer", tools: { exec: { security: "allowlist" } } },
],
}),
).ast;
it("expands * in a slash-deep JSON object path", () => {
const out = findOcPaths(
jsonc,
parseOcPath("oc://openclaw.json/mcp/servers/*/env/GITHUB_TOKEN"),
);
expect(out).toHaveLength(2);
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
expect(values.toSorted()).toEqual(["gh-token", "gl-token"]);
});
it("expands * in a slash-deep JSON array path", () => {
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/agents/*/tools/exec/security"));
expect(out).toHaveLength(2);
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
expect(values.toSorted()).toEqual(["allowlist", "deny"]);
});
it("expands predicates in slash-deep JSON array paths", () => {
const out = findOcPaths(
jsonc,
parseOcPath("oc://openclaw.json/agents/[id=reviewer]/tools/exec/security"),
);
expect(out).toHaveLength(1);
expect(out[0]?.match.kind === "leaf" && out[0].match.valueText).toBe("allowlist");
});
it("expands ** in slash-deep JSON paths", () => {
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/mcp/**/GITHUB_TOKEN"));
expect(out).toHaveLength(2);
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
expect(values.toSorted()).toEqual(["gh-token", "gl-token"]);
});
it("returns slash-deep JSON matches as concrete paths that resolve", () => {
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/agents/*/tools/exec/security"));
for (const m of out) {
expect(resolveOcPath(jsonc, m.path)?.kind).toBe("leaf");
expect(formatOcPath(m.path)).not.toContain("*");
}
});
});
describe("findOcPaths — JSONL kind", () => {
const jsonl = parseJsonl(
'{"event":"start","userId":"u1"}\n' +

View File

@@ -52,10 +52,7 @@ describe("parseOcPath", () => {
});
it("rejects control chars in ignored query values", () => {
expectOcPathError(
() => parseOcPath("oc://SOUL.md?ignored=\x00"),
"OC_PATH_CONTROL_CHAR",
);
expectOcPathError(() => parseOcPath("oc://SOUL.md?ignored=\x00"), "OC_PATH_CONTROL_CHAR");
});
it("rejects missing scheme", () => {
@@ -74,6 +71,15 @@ describe("parseOcPath", () => {
expectOcPathError(() => parseOcPath("oc://SOUL.md/a/b/c/d/e"), "OC_PATH_TOO_DEEP");
});
it("normalizes deep JSON paths into dotted subsegments", () => {
expect(parseOcPath("oc://openclaw.json/agents/list/8/tools/exec/security")).toEqual({
file: "openclaw.json",
section: "agents.list.8.tools",
item: "exec",
field: "security",
});
});
it("rejects non-string input", () => {
expectOcPathError(() => parseOcPath(123 as unknown as string), "OC_PATH_NOT_STRING");
});

View File

@@ -265,6 +265,62 @@ describe("setOcPath — jsonc leaf with coercion", () => {
expect(r.reason).toBe("parse-error");
}
});
it("resolves slash-deep JSONC paths", () => {
const ast = parseJsonc(
'{ "agents": { "list": [{ "tools": { "exec": { "security": "deny" } } }] } }',
).ast;
const r = setOcPath(
ast,
parseOcPath("oc://openclaw.json/agents/list/0/tools/exec/security"),
"allowlist",
);
expect(r.ok).toBe(true);
if (r.ok) {
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
expect(JSON.parse(emitJsonc(ast2))).toEqual({
agents: { list: [{ tools: { exec: { security: "allowlist" } } }] },
});
}
});
it("keeps JSON-looking strings as strings by default", () => {
const ast = parseJsonc('{ "token": "${TOKEN}" }').ast;
const r = setOcPath(ast, parseOcPath("oc://openclaw.json/token"), '{"source":"file"}');
expect(r.ok).toBe(true);
if (r.ok) {
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
expect(JSON.parse(emitJsonc(ast2))).toEqual({ token: '{"source":"file"}' });
}
});
it("replaces a JSONC leaf with parsed JSON when requested", () => {
const ast = parseJsonc('{ "token": "${TOKEN}" }').ast;
const r = setOcPath(
ast,
parseOcPath("oc://openclaw.json/token"),
'{"source":"file","provider":"secrets","id":"/test"}',
{ valueJson: true },
);
expect(r.ok).toBe(true);
if (r.ok) {
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
expect(JSON.parse(emitJsonc(ast2))).toEqual({
token: { source: "file", provider: "secrets", id: "/test" },
});
}
});
it("rejects non-finite parsed JSON replacement values", () => {
const ast = parseJsonc('{ "limit": 1 }').ast;
const r = setOcPath(ast, parseOcPath("oc://openclaw.json/limit"), "1e999", {
valueJson: true,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe("parse-error");
}
});
});
describe("setOcPath — jsonl leaf", () => {

View File

@@ -98,6 +98,10 @@ export type SetResult =
readonly detail?: string;
};
export type SetOcPathOptions = {
readonly valueJson?: boolean;
};
/**
* Insertion marker on the deepest path segment: `+`, `+<key>`, or
* `+<index>`. Returns parent path + marker; null for plain paths.
@@ -409,7 +413,12 @@ function resolveYamlInsertion(ast: YamlAst, info: InsertionInfo): OcMatch | null
* kind-appropriate content (JSON for jsonc/jsonl; raw text for md).
* Sentinel-guard violations throw `OcEmitSentinelError`.
*/
export function setOcPath(ast: OcAst, path: OcPath, value: string): SetResult {
export function setOcPath(
ast: OcAst,
path: OcPath,
value: string,
options: SetOcPathOptions = {},
): SetResult {
if (hasWildcard(path)) {
return {
ok: false,
@@ -436,21 +445,37 @@ export function setOcPath(ast: OcAst, path: OcPath, value: string): SetResult {
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
}
case "jsonc":
return setStructuredLeaf(ast, path, value, resolveJsoncOcPath, setJsoncOcPath);
return setStructuredLeaf(ast, path, value, options, resolveJsoncOcPath, setJsoncOcPath);
case "jsonl":
return setStructuredLeaf(ast, path, value, resolveJsonlOcPath, setJsonlOcPath, () => {
// jsonl line replacement: value must be JSON for the whole line.
const parsed = tryParseJson(value);
if (parsed === undefined) {
return {
ok: false,
reason: "parse-error",
detail: "line replacement requires JSON value",
};
}
const r = setJsonlOcPath(ast, path, jsonToJsoncValue(parsed));
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
});
return setStructuredLeaf(
ast,
path,
value,
options,
resolveJsonlOcPath,
setJsonlOcPath,
() => {
// jsonl line replacement: value must be JSON for the whole line.
const parsed = tryParseJson(value);
if (parsed === undefined) {
return {
ok: false,
reason: "parse-error",
detail: "line replacement requires JSON value",
};
}
const parsedValue = jsonToJsoncValue(parsed);
if (parsedValue === null) {
return {
ok: false,
reason: "parse-error",
detail: "line replacement requires finite JSON value",
};
}
const r = setJsonlOcPath(ast, path, parsedValue);
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
},
);
case "yaml":
return setYamlLeaf(ast, path, value);
}
@@ -463,6 +488,7 @@ function setStructuredLeaf<A extends OcAst>(
ast: A,
path: OcPath,
value: string,
options: SetOcPathOptions,
resolve: (a: A, p: OcPath) => StructuredLeafMatch | null,
set: (a: A, p: OcPath, c: JsoncValue) => SetOpResult<A>,
onLine?: () => SetResult,
@@ -482,7 +508,10 @@ function setStructuredLeaf<A extends OcAst>(
return onLine !== undefined ? onLine() : { ok: false, reason: "not-writable" };
}
const leafValue = existing.kind === "object-entry" ? existing.node.value : existing.node;
const coerced = coerceJsoncLeaf(value, leafValue);
const coerced =
options.valueJson === true
? parseJsoncReplacement(value, leafValue)
: coerceJsoncLeaf(value, leafValue);
if (coerced === null) {
return {
ok: false,
@@ -494,6 +523,18 @@ function setStructuredLeaf<A extends OcAst>(
return r.ok ? { ok: true, ast: r.ast } : { ok: false, reason: r.reason };
}
function parseJsoncReplacement(valueText: string, existing: JsoncValue): JsoncValue | null {
const parsed = tryParseJson(valueText);
if (parsed === undefined) {
return null;
}
const parsedValue = jsonToJsoncValue(parsed);
if (parsedValue === null) {
return null;
}
return existing.line === undefined ? parsedValue : { ...parsedValue, line: existing.line };
}
type StructuredLeafMatch =
| { readonly kind: "root" }
| { readonly kind: "line" }
@@ -595,6 +636,13 @@ function setJsoncInsertion(ast: JsoncAst, info: InsertionInfo, value: string): S
return { ok: false, reason: "parse-error", detail: "jsonc insertion requires JSON value" };
}
const newJsoncValue = jsonToJsoncValue(parsed);
if (newJsoncValue === null) {
return {
ok: false,
reason: "parse-error",
detail: "jsonc insertion requires finite JSON value",
};
}
if (containerMatch.kind !== "insertion-point") {
return { ok: false, reason: "unresolved" };
@@ -656,7 +704,15 @@ function setJsonlInsertion(ast: JsonlAst, info: InsertionInfo, value: string): S
if (parsed === undefined) {
return { ok: false, reason: "parse-error", detail: "jsonl line append requires JSON value" };
}
return { ok: true, ast: appendJsonlLine(ast, jsonToJsoncValue(parsed)) };
const parsedValue = jsonToJsoncValue(parsed);
if (parsedValue === null) {
return {
ok: false,
reason: "parse-error",
detail: "jsonl line append requires finite JSON value",
};
}
return { ok: true, ast: appendJsonlLine(ast, parsedValue) };
}
function setYamlLeaf(ast: YamlAst, path: OcPath, value: string): SetResult {
@@ -754,7 +810,7 @@ function tryParseJson(value: string): unknown {
}
}
function jsonToJsoncValue(v: unknown): JsoncValue {
function jsonToJsoncValue(v: unknown): JsoncValue | null {
// Synthetic values omit `line` — only the parser sets line metadata.
if (v === null) {
return { kind: "null" };
@@ -763,23 +819,38 @@ function jsonToJsoncValue(v: unknown): JsoncValue {
return { kind: "string", value: v };
}
if (typeof v === "number") {
if (!Number.isFinite(v)) {
return null;
}
return { kind: "number", value: v };
}
if (typeof v === "boolean") {
return { kind: "boolean", value: v };
}
if (Array.isArray(v)) {
return { kind: "array", items: v.map(jsonToJsoncValue) };
const items = v.map(jsonToJsoncValue);
if (items.some((item) => item === null)) {
return null;
}
return { kind: "array", items: items as JsoncValue[] };
}
if (typeof v === "object") {
const obj = v as Record<string, unknown>;
const entries: JsoncEntry[] = [];
for (const [key, value] of Object.entries(obj)) {
const jsoncValue = jsonToJsoncValue(value);
if (jsoncValue === null) {
return null;
}
entries.push({
key,
value: jsoncValue,
line: 0,
});
}
return {
kind: "object",
entries: Object.entries(obj).map(([key, value]) => ({
key,
value: jsonToJsoncValue(value),
line: 0,
})),
entries,
};
}
// JSON.parse never produces undefined / function / symbol.