Skip to content

Commit f744f9b

Browse files
authored
fix(kiloclaw): bind migrate-legacy arrays as text[] (#2838)
fix(kiloclaw): bind legacy migration arrays as text[] Serialize scopes and capabilities with explicit text[] SQL in migrate-legacy writes so PostgreSQL array columns are bound correctly. Add coverage for empty scopes/capabilities on insert to prevent regressions.
1 parent c6a61be commit f744f9b

2 files changed

Lines changed: 78 additions & 5 deletions

File tree

services/kiloclaw/src/routes/controller.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,6 +1384,68 @@ describe('POST /google/migrate-legacy', () => {
13841384
);
13851385
});
13861386

1387+
it('handles empty scopes and capabilities when inserting a new legacy row', async () => {
1388+
const encryptionKey = Buffer.alloc(32, 7).toString('base64');
1389+
const execute = vi.fn().mockResolvedValue(undefined);
1390+
mockGetWorkerDb.mockReturnValue({ execute });
1391+
const env = makeEnv({
1392+
hyperdriveConnectionString: 'postgres://example',
1393+
googleWorkspaceRefreshTokenEncryptionKey: encryptionKey,
1394+
});
1395+
const headers = await makeAuthHeaders();
1396+
1397+
mockGetInstanceBySandboxId.mockResolvedValue({ id: 'instance-1' });
1398+
mockGetGoogleOAuthConnectionByInstanceId.mockResolvedValueOnce(null).mockResolvedValueOnce(
1399+
makeGoogleConnection(encryptionKey, {
1400+
credential_profile: 'legacy',
1401+
account_email: 'legacy@example.com',
1402+
account_subject: 'legacy-subject',
1403+
oauth_client_id: 'legacy-client-id',
1404+
oauth_client_secret_encrypted: encryptWithSymmetricKey(
1405+
'legacy-client-secret',
1406+
encryptionKey
1407+
),
1408+
refresh_token_encrypted: encryptWithSymmetricKey('legacy-refresh-token', encryptionKey),
1409+
grants_by_source: {},
1410+
capabilities: [],
1411+
scopes: [],
1412+
status: 'active',
1413+
})
1414+
);
1415+
1416+
const response = await controller.request(
1417+
'/google/migrate-legacy',
1418+
{
1419+
method: 'POST',
1420+
headers,
1421+
body: JSON.stringify({
1422+
sandboxId,
1423+
accountEmail: 'legacy@example.com',
1424+
accountSubject: 'legacy-subject',
1425+
oauthClientId: 'legacy-client-id',
1426+
oauthClientSecret: 'legacy-client-secret',
1427+
refreshToken: 'legacy-refresh-token',
1428+
scopes: [],
1429+
capabilities: [],
1430+
}),
1431+
},
1432+
env
1433+
);
1434+
1435+
expect(response.status).toBe(200);
1436+
await expect(response.json()).resolves.toEqual({ migrated: true, profile: 'legacy' });
1437+
expect(execute).toHaveBeenCalledTimes(1);
1438+
1439+
const instanceStub = getInstanceStub(env);
1440+
expect(instanceStub.updateGoogleOAuthConnection).toHaveBeenCalledWith(
1441+
expect.objectContaining({
1442+
status: 'active',
1443+
scopes: [],
1444+
capabilities: [],
1445+
})
1446+
);
1447+
});
1448+
13871449
it('does not clobber concurrent kilo_owned row when migration insert conflicts', async () => {
13881450
const encryptionKey = Buffer.alloc(32, 7).toString('base64');
13891451
const execute = vi.fn().mockResolvedValue(undefined);

services/kiloclaw/src/routes/controller.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ type GoogleGrantsBySource = {
107107
oauth?: string[];
108108
};
109109

110+
function sqlTextArray(values: readonly string[]) {
111+
if (values.length === 0) {
112+
return sql`ARRAY[]::text[]`;
113+
}
114+
115+
return sql`ARRAY[${sql.join(
116+
values.map(value => sql`${value}`),
117+
sql`, `
118+
)}]::text[]`;
119+
}
120+
110121
function normalizeCapabilities(capabilities: readonly string[]): string[] {
111122
return [...new Set(capabilities.map(capability => capability.trim()).filter(Boolean))].sort();
112123
}
@@ -689,7 +700,7 @@ controller.post('/google/migrate-legacy', async (c: Context<AppEnv>) => {
689700
UPDATE kiloclaw_google_oauth_connections
690701
SET
691702
grants_by_source = ${JSON.stringify(grantsBySource)}::jsonb,
692-
capabilities = ${capabilities},
703+
capabilities = ${sqlTextArray(capabilities)},
693704
updated_at = ${now}
694705
WHERE instance_id = ${instance.id}
695706
`);
@@ -714,7 +725,7 @@ controller.post('/google/migrate-legacy', async (c: Context<AppEnv>) => {
714725
account_email = ${parsed.data.accountEmail},
715726
account_subject = ${parsed.data.accountSubject},
716727
grants_by_source = ${JSON.stringify(grantsBySource)}::jsonb,
717-
capabilities = ${capabilities},
728+
capabilities = ${sqlTextArray(capabilities)},
718729
connected_at = ${now},
719730
updated_at = ${now}
720731
WHERE instance_id = ${instance.id}
@@ -749,9 +760,9 @@ controller.post('/google/migrate-legacy', async (c: Context<AppEnv>) => {
749760
${encryptWithSymmetricKey(parsed.data.oauthClientSecret, encryptionKey)},
750761
'legacy',
751762
${encryptWithSymmetricKey(parsed.data.refreshToken, encryptionKey)},
752-
${scopes},
763+
${sqlTextArray(scopes)},
753764
${JSON.stringify(grantsBySource)}::jsonb,
754-
${capabilities},
765+
${sqlTextArray(capabilities)},
755766
'active',
756767
${now},
757768
${now},
@@ -813,7 +824,7 @@ controller.post('/google/migrate-legacy', async (c: Context<AppEnv>) => {
813824
UPDATE kiloclaw_google_oauth_connections
814825
SET
815826
grants_by_source = ${JSON.stringify(mergedGrantsBySource)}::jsonb,
816-
capabilities = ${resolvedCapabilities},
827+
capabilities = ${sqlTextArray(resolvedCapabilities)},
817828
updated_at = ${now}
818829
WHERE instance_id = ${instance.id}
819830
`);

0 commit comments

Comments
 (0)