Skip to content

Commit c013ac7

Browse files
committed
fix: remove unsafe array secret restoration and preserve 409 conflict codes
Remove index-based array walking in walkAndRestore() which silently swapped secrets between entries when users reordered arrays. Placeholders in array entries are now stripped instead of restored. Wire up UpstreamApiError so the tRPC error formatter surfaces upstreamCode to clients. The config editor now checks for config_etag_conflict specifically, so non-etag 409s (e.g. "Instance not provisioned") show the actual error instead of a misleading reload prompt.
1 parent 16f3bc8 commit c013ac7

5 files changed

Lines changed: 61 additions & 46 deletions

File tree

src/app/(app)/claw/components/OpenclawConfigEditor.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,10 @@ export function OpenclawConfigEditor({
225225
onOpenChange(false);
226226
},
227227
onError: err => {
228-
if (err.data?.code === 'CONFLICT') {
228+
if (
229+
err.data?.code === 'CONFLICT' &&
230+
err.data?.upstreamCode === 'config_etag_conflict'
231+
) {
229232
void refetch();
230233
toast.error(
231234
'Config was modified externally — click "Reload latest" to sync, then re-apply your changes'

src/lib/kiloclaw/config-redaction.test.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -295,14 +295,22 @@ describe('array handling', () => {
295295
expect(providers[1].name).toBe('anthropic');
296296
});
297297

298-
it('restores secrets inside array entries from current config', () => {
298+
it('strips placeholders in array entries on restore (no index-based matching)', () => {
299299
const redacted = redactOpenclawConfig(CONFIG_WITH_ARRAYS);
300300
const merged = restoreRedactedSecrets(redacted, CONFIG_WITH_ARRAYS);
301301

302-
expect(merged).toEqual(CONFIG_WITH_ARRAYS);
302+
// Array secrets are NOT restored — placeholders are stripped to avoid
303+
// position-dependent mismatches when users reorder entries.
304+
const providers = (merged.models as Record<string, unknown>).providers as Array<
305+
Record<string, unknown>
306+
>;
307+
expect(providers[0].apiKey).toBeUndefined();
308+
expect(providers[0].name).toBe('openai');
309+
expect(providers[1].apiKey).toBeUndefined();
310+
expect(providers[1].name).toBe('anthropic');
303311
});
304312

305-
it('keeps new values in array entries when user changed a secret', () => {
313+
it('keeps new values in array entries when user set a non-placeholder value', () => {
306314
const redacted = redactOpenclawConfig(CONFIG_WITH_ARRAYS);
307315
const providers = (redacted.models as Record<string, unknown>).providers as Array<
308316
Record<string, unknown>
@@ -314,10 +322,11 @@ describe('array handling', () => {
314322
Record<string, unknown>
315323
>;
316324
expect(mergedProviders[0].apiKey).toBe('sk-new-key');
317-
expect(mergedProviders[1].apiKey).toBe('sk-secret-2');
325+
// Placeholder in second entry is stripped
326+
expect(mergedProviders[1].apiKey).toBeUndefined();
318327
});
319328

320-
it('strips unresolvable placeholders in array entries for new subtrees', () => {
329+
it('strips placeholders in array entries even when currentConfig has data', () => {
321330
const userConfig = {
322331
models: {
323332
providers: [
@@ -335,7 +344,7 @@ describe('array handling', () => {
335344
expect(providers[0].baseUrl).toBe('https://example.com');
336345
});
337346

338-
it('redacts secrets in nested arrays (arrays inside array elements)', () => {
347+
it('redacts secrets in nested arrays', () => {
339348
const config = {
340349
groups: [{ members: [{ apiKey: 'nested-secret', name: 'bot' }] }],
341350
};
@@ -345,10 +354,6 @@ describe('array handling', () => {
345354

346355
expect(members[0].apiKey).toBe(REDACTED_PLACEHOLDER);
347356
expect(members[0].name).toBe('bot');
348-
349-
// round-trip
350-
const restored = restoreRedactedSecrets(redacted, config);
351-
expect(restored).toEqual(config);
352357
});
353358

354359
it('handles mixed array contents (strings, numbers, objects, nulls)', () => {
@@ -374,21 +379,25 @@ describe('array handling', () => {
374379
expect(restored.providers).toEqual([]);
375380
});
376381

377-
it('handles deeply nested objects inside array elements', () => {
382+
it('strips placeholders in deeply nested objects inside array elements', () => {
378383
const config = {
379384
list: [{ inner: { deep: { apiKey: 'deep-array-secret' } } }],
380385
};
381386
const redacted = redactOpenclawConfig(config);
382387
const list = redacted.list as Array<Record<string, unknown>>;
383388
const deep = (list[0].inner as Record<string, unknown>).deep as Record<string, unknown>;
384-
385389
expect(deep.apiKey).toBe(REDACTED_PLACEHOLDER);
386390

387391
const restored = restoreRedactedSecrets(redacted, config);
388-
expect(restored).toEqual(config);
392+
const restoredList = restored.list as Array<Record<string, unknown>>;
393+
const restoredDeep = (restoredList[0].inner as Record<string, unknown>).deep as Record<
394+
string,
395+
unknown
396+
>;
397+
expect(restoredDeep.apiKey).toBeUndefined();
389398
});
390399

391-
it('strips placeholders when user array is longer than current array', () => {
400+
it('strips all placeholders regardless of array length mismatch', () => {
392401
const userConfig = {
393402
providers: [
394403
{ apiKey: REDACTED_PLACEHOLDER, name: 'existing' },
@@ -401,8 +410,7 @@ describe('array handling', () => {
401410

402411
const merged = restoreRedactedSecrets(userConfig, currentConfig);
403412
const providers = merged.providers as Array<Record<string, unknown>>;
404-
expect(providers[0].apiKey).toBe('real-key');
405-
// Second element has no match in currentConfig — placeholder stripped
413+
expect(providers[0].apiKey).toBeUndefined();
406414
expect(providers[1].apiKey).toBeUndefined();
407415
expect(providers[1].name).toBe('new-extra');
408416
});

src/lib/kiloclaw/config-redaction.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -172,32 +172,12 @@ function walkAndRestore(obj: Record<string, unknown>, current: Record<string, un
172172
delete obj[key];
173173
}
174174
} else if (Array.isArray(value)) {
175-
const currentArr = current[key];
176-
if (Array.isArray(currentArr)) {
177-
for (let i = 0; i < value.length; i++) {
178-
const item = value[i];
179-
const currentItem = currentArr[i];
180-
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
181-
if (
182-
typeof currentItem === 'object' &&
183-
currentItem !== null &&
184-
!Array.isArray(currentItem)
185-
) {
186-
walkAndRestore(
187-
item as Record<string, unknown>,
188-
currentItem as Record<string, unknown>
189-
);
190-
} else {
191-
stripUnresolvablePlaceholders(item as Record<string, unknown>);
192-
}
193-
}
194-
}
195-
} else {
196-
// Array doesn't exist in currentConfig — strip placeholders from all elements
197-
for (const item of value) {
198-
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
199-
stripUnresolvablePlaceholders(item as Record<string, unknown>);
200-
}
175+
// Arrays are not walked for secret restoration — index-based matching
176+
// is position-dependent and silently swaps secrets when users reorder
177+
// entries. Strip any leftover placeholders so they don't leak through.
178+
for (const item of value) {
179+
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
180+
stripUnresolvablePlaceholders(item as Record<string, unknown>);
201181
}
202182
}
203183
} else if (typeof value === 'object' && value !== null) {

src/lib/trpc/init.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,26 @@ export const createTRPCContext = async (): Promise<TRPCContext> => {
3030
// since it's not very descriptive.
3131
// For instance, the use of a t variable
3232
// is common in i18n libraries.
33+
/**
34+
* Marker class used to attach an upstream API error code to a TRPCError so the
35+
* error-formatter can surface it to the client in `err.data.upstreamCode`.
36+
*
37+
* Usage:
38+
* throw new TRPCError({
39+
* code: 'CONFLICT',
40+
* message: 'Config was modified',
41+
* cause: new UpstreamApiError('etag_mismatch'),
42+
* });
43+
*
44+
* The client then sees `err.data.upstreamCode === 'etag_mismatch'`.
45+
*/
46+
export class UpstreamApiError extends Error {
47+
constructor(public readonly upstreamCode: string) {
48+
super(upstreamCode);
49+
this.name = 'UpstreamApiError';
50+
}
51+
}
52+
3353
const t = initTRPC.context<TRPCContext>().create({
3454
errorFormatter(opts) {
3555
const { shape, error } = opts;
@@ -41,6 +61,8 @@ const t = initTRPC.context<TRPCContext>().create({
4161
error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError
4262
? z.flattenError(error.cause)
4363
: null,
64+
upstreamCode:
65+
error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined,
4466
},
4567
};
4668
},

src/routers/kiloclaw-router.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'server-only';
22

33
import * as z from 'zod';
44
import { TRPCError } from '@trpc/server';
5-
import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init';
5+
import { baseProcedure, createTRPCRouter, UpstreamApiError } from '@/lib/trpc/init';
66
import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens';
77
import { KiloClawInternalClient, KiloClawApiError } from '@/lib/kiloclaw/kiloclaw-internal-client';
88
import { KiloClawUserClient } from '@/lib/kiloclaw/kiloclaw-user-client';
@@ -796,10 +796,11 @@ export const kiloclawRouter = createTRPCRouter({
796796
});
797797
}
798798
if (err instanceof KiloClawApiError && err.statusCode === 409) {
799-
const { message } = getKiloClawApiErrorPayload(err);
799+
const { message, code } = getKiloClawApiErrorPayload(err);
800800
throw new TRPCError({
801801
code: 'CONFLICT',
802802
message: message ?? 'Instance is not provisioned or not running',
803+
cause: code ? new UpstreamApiError(code) : undefined,
803804
});
804805
}
805806
throw new TRPCError({
@@ -836,10 +837,11 @@ export const kiloclawRouter = createTRPCRouter({
836837
});
837838
}
838839
if (err instanceof KiloClawApiError && err.statusCode === 409) {
839-
const { message } = getKiloClawApiErrorPayload(err);
840+
const { message, code } = getKiloClawApiErrorPayload(err);
840841
throw new TRPCError({
841842
code: 'CONFLICT',
842843
message: message ?? 'Instance is not provisioned or not running',
844+
cause: code ? new UpstreamApiError(code) : undefined,
843845
});
844846
}
845847
throw new TRPCError({

0 commit comments

Comments
 (0)