Skip to content

Commit be07c6b

Browse files
committed
fix(kiloclaw): preserve manual Composio provenance until cleanup
1 parent 8709183 commit be07c6b

6 files changed

Lines changed: 131 additions & 12 deletions

File tree

.specs/kiloclaw-composio.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,16 @@ Kilo previously shipped a managed Composio onboarding experiment that created Ki
5555
15. Retired managed Composio identities MUST NOT be reused for new instances or configuration updates.
5656
16. After managed creation paths are disabled, the system MUST verify whether an active instance associated with retired managed identity state retains managed Composio credentials, including a possible partial-write case where runtime injection succeeded before a tracking marker was persisted.
5757
17. Any confirmed managed credential material in a live instance MUST be cleared before obsolete stored managed identity state is deleted. Verification and clearing MUST NOT remove manually configured Composio credentials.
58-
18. If no live managed runtime credential remains, obsolete managed identity rows, encrypted credential residue, connected-account identifiers, and destroyed-instance tracking markers MAY be removed by dropping the retired managed-state schema.
59-
19. Obsolete managed-state database structures MUST NOT be dropped until managed creation is disabled and live runtime residue has been ruled out or cleared.
58+
18. Until obsolete managed-state schema is dropped, an instance that receives user-provided Composio credentials through provision or Settings MUST be recorded as manually configured, and an instance that clears both Composio fields MUST clear that provenance marker. This marker retention is cleanup safety metadata only and MUST NOT restore managed onboarding behavior.
59+
19. If no live managed runtime credential remains, obsolete managed identity rows, encrypted credential residue, connected-account identifiers, and destroyed-instance tracking markers MAY be removed by dropping the retired managed-state schema.
60+
20. Obsolete managed-state database structures MUST NOT be dropped until managed creation is disabled and live runtime residue has been ruled out or cleared.
6061

6162
### Credential Boundary and Data Protection
6263

63-
20. Kilo central or retired managed Composio credentials MUST NOT be injected into a user or organization OpenClaw instance.
64-
21. Logs, analytics, audit records, Sentry events, command output, and user-facing errors MUST NOT include raw Composio credentials, OAuth tokens, Connect Links containing secret material, or decrypted stored identity data.
65-
22. User-provided Composio secrets MUST continue to follow the normal KiloClaw secret encryption, transport, and deletion rules.
66-
23. Retired managed rows containing encrypted credentials or user-linked provider identifiers MUST be deleted after the required live-runtime verification or otherwise scrubbed in accordance with account-deletion requirements.
64+
21. Kilo central or retired managed Composio credentials MUST NOT be injected into a user or organization OpenClaw instance.
65+
22. Logs, analytics, audit records, Sentry events, command output, and user-facing errors MUST NOT include raw Composio credentials, OAuth tokens, Connect Links containing secret material, or decrypted stored identity data.
66+
23. User-provided Composio secrets MUST continue to follow the normal KiloClaw secret encryption, transport, and deletion rules.
67+
24. Retired managed rows containing encrypted credentials or user-linked provider identifiers MUST be deleted after the required live-runtime verification or otherwise scrubbed in accordance with account-deletion requirements.
6768

6869
## Error Handling
6970

@@ -78,7 +79,7 @@ Kilo previously shipped a managed Composio onboarding experiment that created Ki
7879

7980
- Removed managed identity provisioning, managed Connect Link onboarding, and managed callback injection from supported product behavior.
8081
- Retained explicit user-provided Composio credentials through Settings and the encrypted secret pipeline.
81-
- Added post-deploy live-runtime verification and subsequent stored-state removal requirements for managed credentials created or injected while the experiment was shipped.
82+
- Added post-deploy live-runtime verification, temporary manual provenance tracking, and subsequent stored-state removal requirements for managed credentials created or injected while the experiment was shipped.
8283

8384
### 2026-05-20 -- Managed onboarding experiment
8485

apps/web/src/lib/kiloclaw/instance-registry.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,22 @@ export async function restoreDestroyedInstance(instanceId: string): Promise<void
196196
.where(eq(kiloclaw_instances.id, instanceId));
197197
}
198198

199+
// Retained until retired managed Composio state is verified and its schema is dropped.
200+
// Manual provenance prevents follow-up cleanup from removing user-owned secrets.
201+
export async function markComposioInstanceConfigManual(instanceId: string): Promise<void> {
202+
await db
203+
.update(kiloclaw_instances)
204+
.set({ composio_config_source: 'manual' })
205+
.where(eq(kiloclaw_instances.id, instanceId));
206+
}
207+
208+
export async function clearComposioInstanceConfigSource(instanceId: string): Promise<void> {
209+
await db
210+
.update(kiloclaw_instances)
211+
.set({ composio_config_source: null })
212+
.where(eq(kiloclaw_instances.id, instanceId));
213+
}
214+
199215
/**
200216
* Fetch the user's active personal KiloClaw instance (read-only, no upsert).
201217
*

apps/web/src/lib/kiloclaw/provision-secrets.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ jest.mock('@/lib/kiloclaw/encryption', () => ({
22
encryptKiloClawSecret: jest.fn((value: string) => `encrypted:${value}`),
33
}));
44

5-
import { encryptProvisionSecretsForWorker } from './provision-secrets';
5+
import {
6+
encryptProvisionSecretsForWorker,
7+
getComposioSecretsPatchSource,
8+
hasComposioProvisionSecrets,
9+
} from './provision-secrets';
610

711
describe('encryptProvisionSecretsForWorker', () => {
812
it('maps valid manual Composio secret keys to worker env var names before encrypting', () => {
@@ -27,4 +31,40 @@ describe('encryptProvisionSecretsForWorker', () => {
2731
})
2832
).toThrow('Composio user API keys start with uak_');
2933
});
34+
35+
const partialComposioCredentialPairs: Array<Record<string, string>> = [
36+
{ composioUserApiKey: 'uak_manual_credential_123' },
37+
{ composioOrg: 'org-1' },
38+
];
39+
40+
it.each(partialComposioCredentialPairs)(
41+
'rejects a partial manual Composio credential pair during provision',
42+
secrets => {
43+
expect(() => encryptProvisionSecretsForWorker(secrets)).toThrow(
44+
'Composio requires all fields to be set together'
45+
);
46+
}
47+
);
48+
});
49+
50+
describe('Composio manual source tracking', () => {
51+
it('identifies provision inputs that require a manual source marker', () => {
52+
expect(
53+
hasComposioProvisionSecrets({
54+
composioUserApiKey: 'uak_manual_credential_123',
55+
composioOrg: 'org-1',
56+
})
57+
).toBe(true);
58+
expect(hasComposioProvisionSecrets({ CUSTOM_SECRET: 'kept' })).toBe(false);
59+
});
60+
61+
it('marks manual patch updates and clears source only when both fields are removed', () => {
62+
expect(getComposioSecretsPatchSource({ composioUserApiKey: 'new-value' })).toBe(
63+
'upsert_manual'
64+
);
65+
expect(getComposioSecretsPatchSource({ composioUserApiKey: null, composioOrg: null })).toBe(
66+
'clear'
67+
);
68+
expect(getComposioSecretsPatchSource({ CUSTOM_SECRET: null })).toBe('none');
69+
});
3070
});

apps/web/src/lib/kiloclaw/provision-secrets.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,31 @@ import { encryptKiloClawSecret } from '@/lib/kiloclaw/encryption';
88

99
const COMPOSIO_SECRET_FIELD_KEYS = ['composioUserApiKey', 'composioOrg'] as const;
1010

11+
export function hasComposioProvisionSecrets(secrets: Record<string, string> | undefined): boolean {
12+
return COMPOSIO_SECRET_FIELD_KEYS.some(key => secrets?.[key] !== undefined);
13+
}
14+
15+
export function getComposioSecretsPatchSource(
16+
secrets: Record<string, string | null>
17+
): 'upsert_manual' | 'clear' | 'none' {
18+
const touchedValues = COMPOSIO_SECRET_FIELD_KEYS.filter(key => secrets[key] !== undefined).map(
19+
key => secrets[key]
20+
);
21+
if (touchedValues.length === 0) return 'none';
22+
if (touchedValues.every(value => value === null)) return 'clear';
23+
return 'upsert_manual';
24+
}
25+
1126
function validateComposioProvisionSecrets(secrets: Record<string, string>): void {
27+
if (!hasComposioProvisionSecrets(secrets)) return;
28+
const hasAllFields = COMPOSIO_SECRET_FIELD_KEYS.every(key => secrets[key] !== undefined);
29+
if (!hasAllFields) {
30+
throw new TRPCError({
31+
code: 'BAD_REQUEST',
32+
message: 'Composio requires all fields to be set together',
33+
});
34+
}
35+
1236
for (const key of COMPOSIO_SECRET_FIELD_KEYS) {
1337
const value = secrets[key];
1438
if (value === undefined) continue;

apps/web/src/routers/kiloclaw-router.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ import {
6464
getInboundEmailAddressForInstance,
6565
} from '@/lib/kiloclaw/inbound-email-alias';
6666
import {
67+
clearComposioInstanceConfigSource,
6768
getActiveInstance,
6869
listAllActiveInstances,
6970
markActiveInstanceDestroyed,
71+
markComposioInstanceConfigManual,
7072
renameInstance,
7173
restoreDestroyedInstance,
7274
workerInstanceId,
@@ -76,7 +78,11 @@ import {
7678
getPersonalProvisionLockKey,
7779
withKiloclawProvisionContextLock,
7880
} from '@/lib/kiloclaw/provision-lock';
79-
import { encryptProvisionSecretsForWorker } from '@/lib/kiloclaw/provision-secrets';
81+
import {
82+
encryptProvisionSecretsForWorker,
83+
getComposioSecretsPatchSource,
84+
hasComposioProvisionSecrets,
85+
} from '@/lib/kiloclaw/provision-secrets';
8086
import {
8187
clearSubscriptionLifecycleAfterInstanceDestroy,
8288
clearTrialInactivityStopAfterStart,
@@ -1133,6 +1139,10 @@ async function provisionInstance(
11331139
: undefined
11341140
);
11351141

1142+
if (hasComposioProvisionSecrets(input.secrets)) {
1143+
await markComposioInstanceConfigManual(result.instanceId);
1144+
}
1145+
11361146
return result;
11371147
}
11381148

@@ -3453,11 +3463,18 @@ export const kiloclawRouter = createTRPCRouter({
34533463
const instance = await getActiveInstance(ctx.user.id);
34543464
const client = new KiloClawInternalClient();
34553465
try {
3456-
return await client.patchSecrets(
3466+
const result = await client.patchSecrets(
34573467
ctx.user.id,
34583468
{ secrets: encryptedPatch, meta: input.meta },
34593469
workerInstanceId(instance)
34603470
);
3471+
const composioSourceAction = getComposioSecretsPatchSource(secrets);
3472+
if (instance && composioSourceAction === 'upsert_manual') {
3473+
await markComposioInstanceConfigManual(instance.id);
3474+
} else if (instance && composioSourceAction === 'clear') {
3475+
await clearComposioInstanceConfigSource(instance.id);
3476+
}
3477+
return result;
34613478
} catch (err) {
34623479
if (err instanceof KiloClawApiError && err.statusCode >= 400 && err.statusCode < 500) {
34633480
// Extract message from worker response body (JSON or plain text)

apps/web/src/routers/organizations/organization-kiloclaw-router.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ import {
3939
getInboundEmailAddressForInstance,
4040
} from '@/lib/kiloclaw/inbound-email-alias';
4141
import {
42+
clearComposioInstanceConfigSource,
4243
getActiveOrgInstance,
4344
markActiveInstanceDestroyed,
45+
markComposioInstanceConfigManual,
4446
renameOrgInstance,
4547
restoreDestroyedInstance,
4648
workerInstanceId,
@@ -50,7 +52,11 @@ import {
5052
getOrganizationProvisionLockKey,
5153
withKiloclawProvisionContextLock,
5254
} from '@/lib/kiloclaw/provision-lock';
53-
import { encryptProvisionSecretsForWorker } from '@/lib/kiloclaw/provision-secrets';
55+
import {
56+
encryptProvisionSecretsForWorker,
57+
getComposioSecretsPatchSource,
58+
hasComposioProvisionSecrets,
59+
} from '@/lib/kiloclaw/provision-secrets';
5460
import {
5561
organizationMemberProcedure,
5662
organizationMemberMutationProcedure,
@@ -481,6 +487,10 @@ export const organizationKiloclawRouter = createTRPCRouter({
481487
{ orgId: input.organizationId }
482488
);
483489

490+
if (hasComposioProvisionSecrets(input.secrets)) {
491+
await markComposioInstanceConfigManual(result.instanceId);
492+
}
493+
484494
PostHogClient().capture({
485495
distinctId: ctx.user.google_user_email,
486496
event: 'claw_org_instance_provisioned',
@@ -534,6 +544,10 @@ export const organizationKiloclawRouter = createTRPCRouter({
534544
{ instanceId: instance.id, orgId: input.organizationId }
535545
);
536546

547+
if (hasComposioProvisionSecrets(input.secrets)) {
548+
await markComposioInstanceConfigManual(result.instanceId);
549+
}
550+
537551
return result;
538552
}),
539553

@@ -737,11 +751,18 @@ export const organizationKiloclawRouter = createTRPCRouter({
737751
const instance = await requireOrgInstance(ctx.user.id, input.organizationId);
738752
const client = new KiloClawInternalClient();
739753
try {
740-
return await client.patchSecrets(
754+
const result = await client.patchSecrets(
741755
ctx.user.id,
742756
{ secrets: encryptedPatch, meta: input.meta },
743757
workerInstanceId(instance)
744758
);
759+
const composioSourceAction = getComposioSecretsPatchSource(input.secrets);
760+
if (composioSourceAction === 'upsert_manual') {
761+
await markComposioInstanceConfigManual(instance.id);
762+
} else if (composioSourceAction === 'clear') {
763+
await clearComposioInstanceConfigSource(instance.id);
764+
}
765+
return result;
745766
} catch (err) {
746767
if (err instanceof KiloClawApiError && err.statusCode >= 400 && err.statusCode < 500) {
747768
let message = `Secret patch failed (${err.statusCode})`;

0 commit comments

Comments
 (0)