paperclipai/paperclip · error
must be a JSON object
Error message
${name} must be a JSON object What it means
HTTP 404 with body {"error":"Provider vault not found"} from PATCH /api/secret-provider-configs/:id, second guard (secrets.ts:463). Earlier in the same handler getAccessibleResource (authz.ts:182-195) already verified the secret provider config exists and is company-accessible; this 404 fires only because svc.updateProviderConfig(id, ...) then returned null - i.e. no row matched the update. That is a check-then-act race: the config was deleted (or concurrently soft-deleted) between the two calls. This route is board-only (assertBoard).
Solutions
- Re-fetch GET /api/secret-provider-configs/:id after the 404 - if it is also 404, the vault was genuinely deleted; update your local state.
- Serialize vault mutations (one writer at a time, or an admin lock) to avoid delete/update races.
- If the vault must exist, recreate it via POST /api/secret-provider-configs and reapply the intended changes.
- Treat this 404 on a previously verified ID as a concurrency signal, not a bad-ID bug - do not simply retry the same PATCH forever.
Example fix
// before
const existing = await api.get(`/api/secret-provider-configs/${id}`);
await api.patch(`/api/secret-provider-configs/${id}`, changes); // 404 despite existing==200
// after
const existing = await api.get(`/api/secret-provider-configs/${id}`);
if (!existing) throw new Error('vault already deleted');
try {
await api.patch(`/api/secret-provider-configs/${id}`, changes);
} catch (e) {
if (e.status === 404) {
// lost a race with a concurrent delete: re-sync and decide
await syncVaults();
return;
}
throw e;
} Defensive patterns
Strategy: validation
Validate before calling
async function patchVaultConfig(api: ApiClient, id: string, changes: unknown) {
const existing = await api.fetch(`/api/secret-provider-configs/${id}`);
if (existing.status === 404) throw new Error(`vault ${id} not found`);
const res = await api.fetch(`/api/secret-provider-configs/${id}`, {
method: 'PATCH',
body: JSON.stringify(changes),
});
if (res.status === 404) {
// lost a race with a concurrent delete
await syncVaultList();
return null;
}
return res.json();
} Type guard
function isApiErrorBody(body: unknown): body is { error: string } {
return typeof body === 'object' && body !== null &&
typeof (body as Record<string, unknown>).error === 'string';
}
const isVaultNotFound = (b: unknown): boolean => isApiErrorBody(b) && b.error === 'Provider vault not found'; Try / catch
try {
await api.patch(`/api/secret-provider-configs/${id}`, changes);
} catch (err) {
if (err instanceof ApiError && err.status === 404 && isVaultNotFound(err.body)) {
const again = await api.get(`/api/secret-provider-configs/${id}`);
if (!again) { await resyncVaults(); return; } // deleted concurrently
throw err; // transient inconsistency - retry once
}
throw err;
} Prevention
- Serialize vault config mutations per vault (single writer or admin lock) to kill the TOCTOU window.
- Refresh admin forms right before submit; warn when the record changed underneath.
- Treat 404-after-verified-exists as a delete race, not a bad ID.
- Audit secret_provider_config.removed activity entries to identify the racing actor.
When it happens
Trigger: Concurrent DELETE /api/secret-provider-configs/:id winning the race against a PATCH; two board users editing the same vault config where one removes it mid-request; a soft-deleted status change racing the update so the UPDATE statement matches zero rows.
Common situations: Config-management tooling doing read-modify-write on vault configs while operators clean up test vaults; double-pane admin UIs with stale forms submitting after another admin removed the vault.
Related errors
- --file is required
- Invalid JSON
- Request failed with status
- Cannot build API path with an empty path segment.
- Challenge secret is required. Pass --token or --token-env.
AI-assisted analysis of paperclipai/paperclip@120ae5428f (2026-08-18).
Data as JSON: /api/errors/a71cf6f2ee5960b9.
Report an issue: GitHub.
Appendix: source
Thrown at cli/src/commands/client/approval.ts:254
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function parseCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
const rows = value.split(",").map((v) => v.trim()).filter(Boolean);
return rows.length > 0 ? rows : undefined;
}
function parseJsonObject(value: string, name: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name} must be a JSON object`);
}
return parsed as Record<string, unknown>;
} catch (err) {
throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`);
}
}
View on GitHub (pinned to 120ae5428f)