From 0e0da35ae12f3c6851d2d67f99e15b251c75054a Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:02:38 -0500 Subject: [PATCH] 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. --- .../kiloclaw/src/routes/controller.test.ts | 62 +++++++++++++++++++ services/kiloclaw/src/routes/controller.ts | 21 +++++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/services/kiloclaw/src/routes/controller.test.ts b/services/kiloclaw/src/routes/controller.test.ts index c96b1ab61..e6c4c6775 100644 --- a/services/kiloclaw/src/routes/controller.test.ts +++ b/services/kiloclaw/src/routes/controller.test.ts @@ -1384,6 +1384,68 @@ describe('POST /google/migrate-legacy', () => { ); }); + it('handles empty scopes and capabilities when inserting a new legacy row', async () => { + const encryptionKey = Buffer.alloc(32, 7).toString('base64'); + const execute = vi.fn().mockResolvedValue(undefined); + mockGetWorkerDb.mockReturnValue({ execute }); + const env = makeEnv({ + hyperdriveConnectionString: 'postgres://example', + googleWorkspaceRefreshTokenEncryptionKey: encryptionKey, + }); + const headers = await makeAuthHeaders(); + + mockGetInstanceBySandboxId.mockResolvedValue({ id: 'instance-1' }); + mockGetGoogleOAuthConnectionByInstanceId.mockResolvedValueOnce(null).mockResolvedValueOnce( + makeGoogleConnection(encryptionKey, { + credential_profile: 'legacy', + account_email: 'legacy@example.com', + account_subject: 'legacy-subject', + oauth_client_id: 'legacy-client-id', + oauth_client_secret_encrypted: encryptWithSymmetricKey( + 'legacy-client-secret', + encryptionKey + ), + refresh_token_encrypted: encryptWithSymmetricKey('legacy-refresh-token', encryptionKey), + grants_by_source: {}, + capabilities: [], + scopes: [], + status: 'active', + }) + ); + + const response = await controller.request( + '/google/migrate-legacy', + { + method: 'POST', + headers, + body: JSON.stringify({ + sandboxId, + accountEmail: 'legacy@example.com', + accountSubject: 'legacy-subject', + oauthClientId: 'legacy-client-id', + oauthClientSecret: 'legacy-client-secret', + refreshToken: 'legacy-refresh-token', + scopes: [], + capabilities: [], + }), + }, + env + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ migrated: true, profile: 'legacy' }); + expect(execute).toHaveBeenCalledTimes(1); + + const instanceStub = getInstanceStub(env); + expect(instanceStub.updateGoogleOAuthConnection).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'active', + scopes: [], + capabilities: [], + }) + ); + }); + it('does not clobber concurrent kilo_owned row when migration insert conflicts', async () => { const encryptionKey = Buffer.alloc(32, 7).toString('base64'); const execute = vi.fn().mockResolvedValue(undefined); diff --git a/services/kiloclaw/src/routes/controller.ts b/services/kiloclaw/src/routes/controller.ts index 2b35a8728..167b2dda9 100644 --- a/services/kiloclaw/src/routes/controller.ts +++ b/services/kiloclaw/src/routes/controller.ts @@ -107,6 +107,17 @@ type GoogleGrantsBySource = { oauth?: string[]; }; +function sqlTextArray(values: readonly string[]) { + if (values.length === 0) { + return sql`ARRAY[]::text[]`; + } + + return sql`ARRAY[${sql.join( + values.map(value => sql`${value}`), + sql`, ` + )}]::text[]`; +} + function normalizeCapabilities(capabilities: readonly string[]): string[] { return [...new Set(capabilities.map(capability => capability.trim()).filter(Boolean))].sort(); } @@ -689,7 +700,7 @@ controller.post('/google/migrate-legacy', async (c: Context) => { UPDATE kiloclaw_google_oauth_connections SET grants_by_source = ${JSON.stringify(grantsBySource)}::jsonb, - capabilities = ${capabilities}, + capabilities = ${sqlTextArray(capabilities)}, updated_at = ${now} WHERE instance_id = ${instance.id} `); @@ -714,7 +725,7 @@ controller.post('/google/migrate-legacy', async (c: Context) => { account_email = ${parsed.data.accountEmail}, account_subject = ${parsed.data.accountSubject}, grants_by_source = ${JSON.stringify(grantsBySource)}::jsonb, - capabilities = ${capabilities}, + capabilities = ${sqlTextArray(capabilities)}, connected_at = ${now}, updated_at = ${now} WHERE instance_id = ${instance.id} @@ -749,9 +760,9 @@ controller.post('/google/migrate-legacy', async (c: Context) => { ${encryptWithSymmetricKey(parsed.data.oauthClientSecret, encryptionKey)}, 'legacy', ${encryptWithSymmetricKey(parsed.data.refreshToken, encryptionKey)}, - ${scopes}, + ${sqlTextArray(scopes)}, ${JSON.stringify(grantsBySource)}::jsonb, - ${capabilities}, + ${sqlTextArray(capabilities)}, 'active', ${now}, ${now}, @@ -813,7 +824,7 @@ controller.post('/google/migrate-legacy', async (c: Context) => { UPDATE kiloclaw_google_oauth_connections SET grants_by_source = ${JSON.stringify(mergedGrantsBySource)}::jsonb, - capabilities = ${resolvedCapabilities}, + capabilities = ${sqlTextArray(resolvedCapabilities)}, updated_at = ${now} WHERE instance_id = ${instance.id} `);