sipeed/picoclaw · warning

Invalid JSON: %v

Error message

Invalid JSON: %v

What it means

Returned by PUT /api/system/launcher-config when json.NewDecoder(r.Body).Decode of the update payload fails. The body must be a single JSON object with the expected types: port a number, public a boolean, allowed_cidrs/trusted_proxy_cidrs arrays of strings, allow_localhost_bypass a boolean or null. Unlike the config test endpoint there is no explicit size cap, and the decoder reads only the first JSON value, so trailing garbage after the object is ignored rather than erroring.

Source

Thrown at web/backend/api/launcher_config.go:74

	if err != nil {
		http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(launcherConfigPayload{
		Port:                 cfg.Port,
		Public:               cfg.Public,
		AllowedCIDRs:         append([]string(nil), cfg.AllowedCIDRs...),
		AllowLocalhostBypass: cfg.AllowLocalhostBypass,
		TrustedProxyCIDRs:    append([]string(nil), cfg.TrustedProxyCIDRs...),
	})
}

func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {
	var payload launcherConfigUpdatePayload
	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
		http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
		return
	}

	cfg, err := h.loadLauncherConfig()
	if err != nil {
		http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
		return
	}
	cfg.Port = payload.Port
	cfg.Public = payload.Public
	cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
	if payload.AllowLocalhostBypass != nil {
		cfg.AllowLocalhostBypass = *payload.AllowLocalhostBypass
	}
	cfg.TrustedProxyCIDRs = append([]string(nil), payload.TrustedProxyCIDRs...)
	cfg.LegacyLauncherToken = ""
	if err := launcherconfig.Validate(cfg); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Send a well-formed object, e.g. {"port":18800,"public":false,"allowed_cidrs":["127.0.0.0/8"],"allow_localhost_bypass":true,"trusted_proxy_cidrs":[]}
  2. Use JSON.stringify client-side so syntax is always valid
  3. Match types exactly: numbers for port, booleans for public/allow_localhost_bypass, string arrays for CIDR lists
  4. Check the wrapped decoder error for the offending byte offset if the body is large

Example fix

// before - port as string, cidrs as scalar string
fetch('/api/system/launcher-config', {method:'PUT', body: '{"port":"18800","allowed_cidrs":"127.0.0.0/8"}'})

// after - correct types
fetch('/api/system/launcher-config', {
  method: 'PUT',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({port: 18800, public: false, allowed_cidrs: ['127.0.0.0/8'], trusted_proxy_cidrs: []})
})
Defensive patterns

Strategy: validation

Validate before calling

const payload = {
  port: Number(cfg.port) | 0,
  public: Boolean(cfg.public),
  allowed_cidrs: (cfg.allowed_cidrs ?? []).map(s => String(s).trim()).filter(Boolean),
  allow_localhost_bypass: cfg.allow_localhost_bypass ?? undefined,
  trusted_proxy_cidrs: (cfg.trusted_proxy_cidrs ?? []).map(s => String(s).trim()).filter(Boolean)
};
const body = JSON.stringify(payload);
JSON.parse(body); // guarantees syntactic validity before the request

Type guard

type LauncherConfigUpdate = {
  port: number;
  public: boolean;
  allowed_cidrs: string[];
  allow_localhost_bypass?: boolean | null;
  trusted_proxy_cidrs: string[];
};
function isLauncherConfigUpdate(v: unknown): v is LauncherConfigUpdate {
  if (typeof v !== 'object' || v === null) return false;
  const o = v as Record<string, unknown>;
  if (!Number.isInteger(o.port) || typeof o.public !== 'boolean') return false;
  for (const k of ['allowed_cidrs', 'trusted_proxy_cidrs'] as const) {
    if (o[k] !== undefined && (!Array.isArray(o[k]) || o[k].some(x => typeof x !== 'string'))) return false;
  }
  if (o.allow_localhost_bypass !== undefined && o.allow_localhost_bypass !== null && typeof o.allow_localhost_bypass !== 'boolean') return false;
  return true;
}

Try / catch

const res = await fetch('/api/system/launcher-config', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body});
if (res.status === 400 && (await res.text()).startsWith('Invalid JSON')) {
  throw new Error('client bug: serialized body was not valid JSON - ' + body.slice(0, 80));
}

Prevention

When it happens

Trigger: Malformed JSON body (trailing comma, single quotes, unquoted keys); port sent as string "18800"; allowed_cidrs sent as a comma-separated string instead of an array; empty request body.

Common situations: Hand-built fetch/curl requests; a client serializing with a template engine instead of JSON.stringify; proxies stripping or re-encoding the body.

Understand the failure class

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/778f83df53785dbc. Report an issue: GitHub.