fix(config): tighten json and json5 parsing paths (#51153)
This commit is contained in:
parent
87eeab7034
commit
93fbe26adb
@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
|
||||||
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
||||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||||
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
||||||
|
|||||||
@ -76,6 +76,33 @@ describe("getSubagentDepthFromSessionStore", () => {
|
|||||||
expect(depth).toBe(2);
|
expect(depth).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts JSON5 syntax in the on-disk depth store for backward compatibility", () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-json5-"));
|
||||||
|
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
|
||||||
|
const storePath = storeTemplate.replaceAll("{agentId}", "main");
|
||||||
|
fs.writeFileSync(
|
||||||
|
storePath,
|
||||||
|
`{
|
||||||
|
// hand-edited legacy store
|
||||||
|
"agent:main:subagent:flat": {
|
||||||
|
sessionId: "subagent-flat",
|
||||||
|
spawnDepth: 2,
|
||||||
|
},
|
||||||
|
}`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const depth = getSubagentDepthFromSessionStore("subagent:flat", {
|
||||||
|
cfg: {
|
||||||
|
session: {
|
||||||
|
store: storeTemplate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(depth).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to session-key segment counting when metadata is missing", () => {
|
it("falls back to session-key segment counting when metadata is missing", () => {
|
||||||
const key = "agent:main:subagent:flat";
|
const key = "agent:main:subagent:flat";
|
||||||
const depth = getSubagentDepthFromSessionStore(key, {
|
const depth = getSubagentDepthFromSessionStore(key, {
|
||||||
|
|||||||
@ -11,6 +11,14 @@ type SessionDepthEntry = {
|
|||||||
spawnedBy?: unknown;
|
spawnedBy?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function parseSessionDepthStore(raw: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return JSON5.parse(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSpawnDepth(value: unknown): number | undefined {
|
function normalizeSpawnDepth(value: unknown): number | undefined {
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||||
@ -37,7 +45,7 @@ function normalizeSessionKey(value: unknown): string | undefined {
|
|||||||
function readSessionStore(storePath: string): Record<string, SessionDepthEntry> {
|
function readSessionStore(storePath: string): Record<string, SessionDepthEntry> {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(storePath, "utf-8");
|
const raw = fs.readFileSync(storePath, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = parseSessionDepthStore(raw);
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
return parsed as Record<string, SessionDepthEntry>;
|
return parsed as Record<string, SessionDepthEntry>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -442,6 +442,15 @@ describe("config cli", () => {
|
|||||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects JSON5-only object syntax when strict parsing is enabled", async () => {
|
||||||
|
await expect(
|
||||||
|
runConfigCommand(["config", "set", "gateway.auth", "{mode:'token'}", "--strict-json"]),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||||
|
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts --strict-json with batch mode and applies batch payload", async () => {
|
it("accepts --strict-json with batch mode and applies batch payload", async () => {
|
||||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||||
setSnapshot(resolved, resolved);
|
setSnapshot(resolved, resolved);
|
||||||
@ -470,6 +479,8 @@ describe("config cli", () => {
|
|||||||
expect(helpText).toContain("--strict-json");
|
expect(helpText).toContain("--strict-json");
|
||||||
expect(helpText).toContain("--json");
|
expect(helpText).toContain("--json");
|
||||||
expect(helpText).toContain("Legacy alias for --strict-json");
|
expect(helpText).toContain("Legacy alias for --strict-json");
|
||||||
|
expect(helpText).toContain("Value (JSON/JSON5 or raw string)");
|
||||||
|
expect(helpText).toContain("Strict JSON parsing (error instead of");
|
||||||
expect(helpText).toContain("--ref-provider");
|
expect(helpText).toContain("--ref-provider");
|
||||||
expect(helpText).toContain("--provider-source");
|
expect(helpText).toContain("--provider-source");
|
||||||
expect(helpText).toContain("--batch-json");
|
expect(helpText).toContain("--batch-json");
|
||||||
|
|||||||
@ -159,9 +159,9 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
|
|||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (opts.strictJson) {
|
if (opts.strictJson) {
|
||||||
try {
|
try {
|
||||||
return JSON5.parse(trimmed);
|
return JSON.parse(trimmed);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err });
|
throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1280,8 +1280,8 @@ export function registerConfigCli(program: Command) {
|
|||||||
.command("set")
|
.command("set")
|
||||||
.description(CONFIG_SET_DESCRIPTION)
|
.description(CONFIG_SET_DESCRIPTION)
|
||||||
.argument("[path]", "Config path (dot or bracket notation)")
|
.argument("[path]", "Config path (dot or bracket notation)")
|
||||||
.argument("[value]", "Value (JSON5 or raw string)")
|
.argument("[value]", "Value (JSON/JSON5 or raw string)")
|
||||||
.option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false)
|
.option("--strict-json", "Strict JSON parsing (error instead of raw string fallback)", false)
|
||||||
.option("--json", "Legacy alias for --strict-json", false)
|
.option("--json", "Legacy alias for --strict-json", false)
|
||||||
.option(
|
.option(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
|
|||||||
@ -99,7 +99,7 @@ function resolveUserPath(
|
|||||||
export const STATE_DIR = resolveStateDir();
|
export const STATE_DIR = resolveStateDir();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config file path (JSON5).
|
* Config file path (JSON or JSON5).
|
||||||
* Can be overridden via OPENCLAW_CONFIG_PATH.
|
* Can be overridden via OPENCLAW_CONFIG_PATH.
|
||||||
* Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json)
|
* Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -56,6 +56,38 @@ describe("cron store", () => {
|
|||||||
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
|
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts JSON5 syntax when loading an existing cron store", async () => {
|
||||||
|
const store = await makeStorePath();
|
||||||
|
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
store.storePath,
|
||||||
|
`{
|
||||||
|
// hand-edited legacy store
|
||||||
|
version: 1,
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
id: 'job-1',
|
||||||
|
name: 'Job 1',
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: 1,
|
||||||
|
updatedAtMs: 1,
|
||||||
|
schedule: { kind: 'every', everyMs: 60000 },
|
||||||
|
sessionTarget: 'main',
|
||||||
|
wakeMode: 'next-heartbeat',
|
||||||
|
payload: { kind: 'systemEvent', text: 'tick-job-1' },
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(loadCronStore(store.storePath)).resolves.toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
jobs: [{ id: "job-1", enabled: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not create a backup file when saving unchanged content", async () => {
|
it("does not create a backup file when saving unchanged content", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const payload = makeStore("job-1", true);
|
const payload = makeStore("job-1", true);
|
||||||
|
|||||||
@ -10,6 +10,14 @@ export const DEFAULT_CRON_DIR = path.join(CONFIG_DIR, "cron");
|
|||||||
export const DEFAULT_CRON_STORE_PATH = path.join(DEFAULT_CRON_DIR, "jobs.json");
|
export const DEFAULT_CRON_STORE_PATH = path.join(DEFAULT_CRON_DIR, "jobs.json");
|
||||||
const serializedStoreCache = new Map<string, string>();
|
const serializedStoreCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function parseCronStoreRaw(raw: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return JSON5.parse(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCronStorePath(storePath?: string) {
|
export function resolveCronStorePath(storePath?: string) {
|
||||||
if (storePath?.trim()) {
|
if (storePath?.trim()) {
|
||||||
const raw = storePath.trim();
|
const raw = storePath.trim();
|
||||||
@ -26,7 +34,7 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
|
|||||||
const raw = await fs.promises.readFile(storePath, "utf-8");
|
const raw = await fs.promises.readFile(storePath, "utf-8");
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON5.parse(raw);
|
parsed = parseCronStoreRaw(raw);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, {
|
throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, {
|
||||||
cause: err,
|
cause: err,
|
||||||
|
|||||||
@ -1062,7 +1062,7 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
}
|
}
|
||||||
<div class="field config-raw-field">
|
<div class="field config-raw-field">
|
||||||
<span style="display:flex;align-items:center;gap:8px;">
|
<span style="display:flex;align-items:center;gap:8px;">
|
||||||
Raw JSON5
|
Raw config (JSON/JSON5)
|
||||||
${
|
${
|
||||||
sensitiveCount > 0
|
sensitiveCount > 0
|
||||||
? html`
|
? html`
|
||||||
@ -1087,7 +1087,7 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
class="${blurred ? "config-raw-redacted" : ""}"
|
class="${blurred ? "config-raw-redacted" : ""}"
|
||||||
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw JSON5 config"}
|
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw config (JSON/JSON5)"}
|
||||||
.value=${blurred ? "" : props.raw}
|
.value=${blurred ? "" : props.raw}
|
||||||
?readonly=${blurred}
|
?readonly=${blurred}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: Event) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user