Spaces:
Sleeping
Sleeping
nyk commited on
fix(sync): support commented/trailing-comma OpenClaw config (#235)
Browse files- src/app/api/gateway-config/route.ts +3 -2
- src/lib/__tests__/json-relaxed.test.ts +49 -0
- src/lib/agent-sync.ts +3 -2
- src/lib/json-relaxed.ts +112 -0
src/app/api/gateway-config/route.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { logAuditEvent } from '@/lib/db'
|
|
| 4 |
import { config } from '@/lib/config'
|
| 5 |
import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation'
|
| 6 |
import { mutationLimiter } from '@/lib/rate-limit'
|
|
|
|
| 7 |
|
| 8 |
function getConfigPath(): string | null {
|
| 9 |
return config.openclawConfigPath || null
|
|
@@ -24,7 +25,7 @@ export async function GET(request: NextRequest) {
|
|
| 24 |
try {
|
| 25 |
const { readFile } = require('fs/promises')
|
| 26 |
const raw = await readFile(configPath, 'utf-8')
|
| 27 |
-
const parsed =
|
| 28 |
|
| 29 |
// Redact sensitive fields for display
|
| 30 |
const redacted = redactSensitive(JSON.parse(JSON.stringify(parsed)))
|
|
@@ -76,7 +77,7 @@ export async function PUT(request: NextRequest) {
|
|
| 76 |
try {
|
| 77 |
const { readFile, writeFile } = require('fs/promises')
|
| 78 |
const raw = await readFile(configPath, 'utf-8')
|
| 79 |
-
const parsed =
|
| 80 |
|
| 81 |
// Apply updates via dot-notation
|
| 82 |
const appliedKeys: string[] = []
|
|
|
|
| 4 |
import { config } from '@/lib/config'
|
| 5 |
import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation'
|
| 6 |
import { mutationLimiter } from '@/lib/rate-limit'
|
| 7 |
+
import { parseJsonRelaxed } from '@/lib/json-relaxed'
|
| 8 |
|
| 9 |
function getConfigPath(): string | null {
|
| 10 |
return config.openclawConfigPath || null
|
|
|
|
| 25 |
try {
|
| 26 |
const { readFile } = require('fs/promises')
|
| 27 |
const raw = await readFile(configPath, 'utf-8')
|
| 28 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 29 |
|
| 30 |
// Redact sensitive fields for display
|
| 31 |
const redacted = redactSensitive(JSON.parse(JSON.stringify(parsed)))
|
|
|
|
| 77 |
try {
|
| 78 |
const { readFile, writeFile } = require('fs/promises')
|
| 79 |
const raw = await readFile(configPath, 'utf-8')
|
| 80 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 81 |
|
| 82 |
// Apply updates via dot-notation
|
| 83 |
const appliedKeys: string[] = []
|
src/lib/__tests__/json-relaxed.test.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest'
|
| 2 |
+
import { parseJsonRelaxed } from '@/lib/json-relaxed'
|
| 3 |
+
|
| 4 |
+
describe('parseJsonRelaxed', () => {
|
| 5 |
+
it('parses strict JSON unchanged', () => {
|
| 6 |
+
const parsed = parseJsonRelaxed<{ a: number; b: string }>('{"a":1,"b":"ok"}')
|
| 7 |
+
expect(parsed).toEqual({ a: 1, b: 'ok' })
|
| 8 |
+
})
|
| 9 |
+
|
| 10 |
+
it('parses JSON with line comments and trailing commas', () => {
|
| 11 |
+
const raw = `{
|
| 12 |
+
// top-level comment
|
| 13 |
+
"agents": {
|
| 14 |
+
"list": [
|
| 15 |
+
{ "id": "a", "name": "A", },
|
| 16 |
+
],
|
| 17 |
+
},
|
| 18 |
+
}`
|
| 19 |
+
|
| 20 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 21 |
+
expect(parsed.agents.list[0].id).toBe('a')
|
| 22 |
+
expect(parsed.agents.list[0].name).toBe('A')
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
it('parses JSON with block comments', () => {
|
| 26 |
+
const raw = `{
|
| 27 |
+
/* comment */
|
| 28 |
+
"gateway": { "port": 18789 }
|
| 29 |
+
}`
|
| 30 |
+
|
| 31 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 32 |
+
expect(parsed.gateway.port).toBe(18789)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('does not strip URL fragments inside strings', () => {
|
| 36 |
+
const raw = `{
|
| 37 |
+
"url": "https://example.com/a//b",
|
| 38 |
+
"ok": true,
|
| 39 |
+
}`
|
| 40 |
+
|
| 41 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 42 |
+
expect(parsed.url).toBe('https://example.com/a//b')
|
| 43 |
+
expect(parsed.ok).toBe(true)
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
it('throws on invalid JSON after normalization', () => {
|
| 47 |
+
expect(() => parseJsonRelaxed<any>('{ broken: true }')).toThrow()
|
| 48 |
+
})
|
| 49 |
+
})
|
src/lib/agent-sync.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { join, isAbsolute, resolve } from 'path'
|
|
| 12 |
import { existsSync, readFileSync } from 'fs'
|
| 13 |
import { resolveWithin } from './paths'
|
| 14 |
import { logger } from './logger'
|
|
|
|
| 15 |
|
| 16 |
interface OpenClawAgent {
|
| 17 |
id: string
|
|
@@ -184,7 +185,7 @@ async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
|
|
| 184 |
|
| 185 |
const { readFile } = require('fs/promises')
|
| 186 |
const raw = await readFile(configPath, 'utf-8')
|
| 187 |
-
const parsed =
|
| 188 |
return parsed?.agents?.list || []
|
| 189 |
}
|
| 190 |
|
|
@@ -345,7 +346,7 @@ export async function writeAgentToConfig(agentConfig: any): Promise<void> {
|
|
| 345 |
|
| 346 |
const { readFile, writeFile } = require('fs/promises')
|
| 347 |
const raw = await readFile(configPath, 'utf-8')
|
| 348 |
-
const parsed =
|
| 349 |
|
| 350 |
if (!parsed.agents) parsed.agents = {}
|
| 351 |
if (!parsed.agents.list) parsed.agents.list = []
|
|
|
|
| 12 |
import { existsSync, readFileSync } from 'fs'
|
| 13 |
import { resolveWithin } from './paths'
|
| 14 |
import { logger } from './logger'
|
| 15 |
+
import { parseJsonRelaxed } from './json-relaxed'
|
| 16 |
|
| 17 |
interface OpenClawAgent {
|
| 18 |
id: string
|
|
|
|
| 185 |
|
| 186 |
const { readFile } = require('fs/promises')
|
| 187 |
const raw = await readFile(configPath, 'utf-8')
|
| 188 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 189 |
return parsed?.agents?.list || []
|
| 190 |
}
|
| 191 |
|
|
|
|
| 346 |
|
| 347 |
const { readFile, writeFile } = require('fs/promises')
|
| 348 |
const raw = await readFile(configPath, 'utf-8')
|
| 349 |
+
const parsed = parseJsonRelaxed<any>(raw)
|
| 350 |
|
| 351 |
if (!parsed.agents) parsed.agents = {}
|
| 352 |
if (!parsed.agents.list) parsed.agents.list = []
|
src/lib/json-relaxed.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Parse JSON with tolerant fallback for JSONC-style inputs.
|
| 3 |
+
* Supports comments and trailing commas, then validates with JSON.parse.
|
| 4 |
+
*/
|
| 5 |
+
export function parseJsonRelaxed<T>(raw: string): T {
|
| 6 |
+
try {
|
| 7 |
+
return JSON.parse(raw) as T
|
| 8 |
+
} catch {
|
| 9 |
+
const stripped = stripJsonComments(raw)
|
| 10 |
+
const normalized = removeTrailingCommas(stripped)
|
| 11 |
+
return JSON.parse(normalized) as T
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function stripJsonComments(input: string): string {
|
| 16 |
+
let output = ''
|
| 17 |
+
let inString = false
|
| 18 |
+
let stringDelimiter = '"'
|
| 19 |
+
let inLineComment = false
|
| 20 |
+
let inBlockComment = false
|
| 21 |
+
|
| 22 |
+
for (let i = 0; i < input.length; i++) {
|
| 23 |
+
const current = input[i]
|
| 24 |
+
const next = i + 1 < input.length ? input[i + 1] : ''
|
| 25 |
+
const prev = i > 0 ? input[i - 1] : ''
|
| 26 |
+
|
| 27 |
+
if (inLineComment) {
|
| 28 |
+
if (current === '\n') {
|
| 29 |
+
inLineComment = false
|
| 30 |
+
output += current
|
| 31 |
+
}
|
| 32 |
+
continue
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (inBlockComment) {
|
| 36 |
+
if (current === '*' && next === '/') {
|
| 37 |
+
inBlockComment = false
|
| 38 |
+
i += 1
|
| 39 |
+
}
|
| 40 |
+
continue
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (inString) {
|
| 44 |
+
output += current
|
| 45 |
+
if (current === stringDelimiter && prev !== '\\') {
|
| 46 |
+
inString = false
|
| 47 |
+
}
|
| 48 |
+
continue
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if ((current === '"' || current === "'") && prev !== '\\') {
|
| 52 |
+
inString = true
|
| 53 |
+
stringDelimiter = current
|
| 54 |
+
output += current
|
| 55 |
+
continue
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (current === '/' && next === '/') {
|
| 59 |
+
inLineComment = true
|
| 60 |
+
i += 1
|
| 61 |
+
continue
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (current === '/' && next === '*') {
|
| 65 |
+
inBlockComment = true
|
| 66 |
+
i += 1
|
| 67 |
+
continue
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
output += current
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return output
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function removeTrailingCommas(input: string): string {
|
| 77 |
+
let output = ''
|
| 78 |
+
let inString = false
|
| 79 |
+
let stringDelimiter = '"'
|
| 80 |
+
|
| 81 |
+
for (let i = 0; i < input.length; i++) {
|
| 82 |
+
const current = input[i]
|
| 83 |
+
const prev = i > 0 ? input[i - 1] : ''
|
| 84 |
+
|
| 85 |
+
if (inString) {
|
| 86 |
+
output += current
|
| 87 |
+
if (current === stringDelimiter && prev !== '\\') {
|
| 88 |
+
inString = false
|
| 89 |
+
}
|
| 90 |
+
continue
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if ((current === '"' || current === "'") && prev !== '\\') {
|
| 94 |
+
inString = true
|
| 95 |
+
stringDelimiter = current
|
| 96 |
+
output += current
|
| 97 |
+
continue
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (current === ',') {
|
| 101 |
+
let j = i + 1
|
| 102 |
+
while (j < input.length && /\s/.test(input[j])) j += 1
|
| 103 |
+
if (j < input.length && (input[j] === '}' || input[j] === ']')) {
|
| 104 |
+
continue
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
output += current
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return output
|
| 112 |
+
}
|