From d92e01d64f53de44e07a16e1dc9789b34bdde0a8 Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:52:40 +0700 Subject: [PATCH] fix(sync): support commented/trailing-comma OpenClaw config (#235) --- src/app/api/gateway-config/route.ts | 5 +- src/lib/__tests__/json-relaxed.test.ts | 49 +++++++++++ src/lib/agent-sync.ts | 5 +- src/lib/json-relaxed.ts | 112 +++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/json-relaxed.test.ts create mode 100644 src/lib/json-relaxed.ts diff --git a/src/app/api/gateway-config/route.ts b/src/app/api/gateway-config/route.ts index 9aac4d0..cf339c5 100644 --- a/src/app/api/gateway-config/route.ts +++ b/src/app/api/gateway-config/route.ts @@ -4,6 +4,7 @@ import { logAuditEvent } from '@/lib/db' import { config } from '@/lib/config' import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation' import { mutationLimiter } from '@/lib/rate-limit' +import { parseJsonRelaxed } from '@/lib/json-relaxed' function getConfigPath(): string | null { return config.openclawConfigPath || null @@ -24,7 +25,7 @@ export async function GET(request: NextRequest) { try { const { readFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') - const parsed = JSON.parse(raw) + const parsed = parseJsonRelaxed(raw) // Redact sensitive fields for display const redacted = redactSensitive(JSON.parse(JSON.stringify(parsed))) @@ -76,7 +77,7 @@ export async function PUT(request: NextRequest) { try { const { readFile, writeFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') - const parsed = JSON.parse(raw) + const parsed = parseJsonRelaxed(raw) // Apply updates via dot-notation const appliedKeys: string[] = [] diff --git a/src/lib/__tests__/json-relaxed.test.ts b/src/lib/__tests__/json-relaxed.test.ts new file mode 100644 index 0000000..90613a3 --- /dev/null +++ b/src/lib/__tests__/json-relaxed.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { parseJsonRelaxed } from '@/lib/json-relaxed' + +describe('parseJsonRelaxed', () => { + it('parses strict JSON unchanged', () => { + const parsed = parseJsonRelaxed<{ a: number; b: string }>('{"a":1,"b":"ok"}') + expect(parsed).toEqual({ a: 1, b: 'ok' }) + }) + + it('parses JSON with line comments and trailing commas', () => { + const raw = `{ + // top-level comment + "agents": { + "list": [ + { "id": "a", "name": "A", }, + ], + }, + }` + + const parsed = parseJsonRelaxed(raw) + expect(parsed.agents.list[0].id).toBe('a') + expect(parsed.agents.list[0].name).toBe('A') + }) + + it('parses JSON with block comments', () => { + const raw = `{ + /* comment */ + "gateway": { "port": 18789 } + }` + + const parsed = parseJsonRelaxed(raw) + expect(parsed.gateway.port).toBe(18789) + }) + + it('does not strip URL fragments inside strings', () => { + const raw = `{ + "url": "https://example.com/a//b", + "ok": true, + }` + + const parsed = parseJsonRelaxed(raw) + expect(parsed.url).toBe('https://example.com/a//b') + expect(parsed.ok).toBe(true) + }) + + it('throws on invalid JSON after normalization', () => { + expect(() => parseJsonRelaxed('{ broken: true }')).toThrow() + }) +}) diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index e0d6416..f20665e 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -12,6 +12,7 @@ import { join, isAbsolute, resolve } from 'path' import { existsSync, readFileSync } from 'fs' import { resolveWithin } from './paths' import { logger } from './logger' +import { parseJsonRelaxed } from './json-relaxed' interface OpenClawAgent { id: string @@ -184,7 +185,7 @@ async function readOpenClawAgents(): Promise { const { readFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') - const parsed = JSON.parse(raw) + const parsed = parseJsonRelaxed(raw) return parsed?.agents?.list || [] } @@ -345,7 +346,7 @@ export async function writeAgentToConfig(agentConfig: any): Promise { const { readFile, writeFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') - const parsed = JSON.parse(raw) + const parsed = parseJsonRelaxed(raw) if (!parsed.agents) parsed.agents = {} if (!parsed.agents.list) parsed.agents.list = [] diff --git a/src/lib/json-relaxed.ts b/src/lib/json-relaxed.ts new file mode 100644 index 0000000..2be2b4b --- /dev/null +++ b/src/lib/json-relaxed.ts @@ -0,0 +1,112 @@ +/** + * Parse JSON with tolerant fallback for JSONC-style inputs. + * Supports comments and trailing commas, then validates with JSON.parse. + */ +export function parseJsonRelaxed(raw: string): T { + try { + return JSON.parse(raw) as T + } catch { + const stripped = stripJsonComments(raw) + const normalized = removeTrailingCommas(stripped) + return JSON.parse(normalized) as T + } +} + +function stripJsonComments(input: string): string { + let output = '' + let inString = false + let stringDelimiter = '"' + let inLineComment = false + let inBlockComment = false + + for (let i = 0; i < input.length; i++) { + const current = input[i] + const next = i + 1 < input.length ? input[i + 1] : '' + const prev = i > 0 ? input[i - 1] : '' + + if (inLineComment) { + if (current === '\n') { + inLineComment = false + output += current + } + continue + } + + if (inBlockComment) { + if (current === '*' && next === '/') { + inBlockComment = false + i += 1 + } + continue + } + + if (inString) { + output += current + if (current === stringDelimiter && prev !== '\\') { + inString = false + } + continue + } + + if ((current === '"' || current === "'") && prev !== '\\') { + inString = true + stringDelimiter = current + output += current + continue + } + + if (current === '/' && next === '/') { + inLineComment = true + i += 1 + continue + } + + if (current === '/' && next === '*') { + inBlockComment = true + i += 1 + continue + } + + output += current + } + + return output +} + +function removeTrailingCommas(input: string): string { + let output = '' + let inString = false + let stringDelimiter = '"' + + for (let i = 0; i < input.length; i++) { + const current = input[i] + const prev = i > 0 ? input[i - 1] : '' + + if (inString) { + output += current + if (current === stringDelimiter && prev !== '\\') { + inString = false + } + continue + } + + if ((current === '"' || current === "'") && prev !== '\\') { + inString = true + stringDelimiter = current + output += current + continue + } + + if (current === ',') { + let j = i + 1 + while (j < input.length && /\s/.test(input[j])) j += 1 + if (j < input.length && (input[j] === '}' || input[j] === ']')) { + continue + } + } + + output += current + } + + return output +}