From a0dbd93608123a7096f87bc6d766d005d39f131d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 19:52:49 +0100 Subject: [PATCH 01/87] feat(kiloclaw): add Google account credential storage and env plumbing Add GoogleCredentials schema (encrypted clientSecret + credentials envelopes), DO storage/retrieval methods, googleConnected status field, and env builder decryption into GOOGLE_CLIENT_SECRET_JSON / GOOGLE_CREDENTIALS_JSON. --- .../src/durable-objects/kiloclaw-instance.ts | 34 +++++++++++++++++++ kiloclaw/src/gateway/env.test.ts | 32 +++++++++++++++++ kiloclaw/src/gateway/env.ts | 25 ++++++++++++-- kiloclaw/src/schemas/instance-config.ts | 9 +++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index 0a376788e..cec3915c8 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -32,6 +32,7 @@ import { type InstanceConfig, type PersistedState, type EncryptedEnvelope, + type GoogleCredentials, type MachineSize, } from '../schemas/instance-config'; import { @@ -153,6 +154,7 @@ export class KiloClawInstance extends DurableObject { private kilocodeApiKeyExpiresAt: PersistedState['kilocodeApiKeyExpiresAt'] = null; private kilocodeDefaultModel: PersistedState['kilocodeDefaultModel'] = null; private channels: PersistedState['channels'] = null; + private googleCredentials: GoogleCredentials | null = null; private provisionedAt: number | null = null; private lastStartedAt: number | null = null; private lastStoppedAt: number | null = null; @@ -199,6 +201,7 @@ export class KiloClawInstance extends DurableObject { this.kilocodeApiKeyExpiresAt = s.kilocodeApiKeyExpiresAt; this.kilocodeDefaultModel = s.kilocodeDefaultModel; this.channels = s.channels; + this.googleCredentials = s.googleCredentials; this.provisionedAt = s.provisionedAt; this.lastStartedAt = s.lastStartedAt; this.lastStoppedAt = s.lastStoppedAt; @@ -563,6 +566,34 @@ export class KiloClawInstance extends DurableObject { }; } + /** + * Store encrypted Google credentials (client_secret.json + OAuth tokens). + * Does NOT restart the machine; the caller should prompt the user to restart. + */ + async updateGoogleCredentials( + credentials: GoogleCredentials + ): Promise<{ googleConnected: boolean }> { + await this.loadState(); + + this.googleCredentials = credentials; + await this.ctx.storage.put({ googleCredentials: this.googleCredentials }); + + return { googleConnected: true }; + } + + /** + * Clear stored Google credentials. + * Does NOT restart the machine; the caller should prompt the user to restart. + */ + async clearGoogleCredentials(): Promise<{ googleConnected: boolean }> { + await this.loadState(); + + this.googleCredentials = null; + await this.ctx.storage.put({ googleCredentials: null }); + + return { googleConnected: false }; + } + /** KV cache key for pairing requests, scoped to the specific machine. */ private pairingCacheKey(): string | null { const { flyAppName, flyMachineId } = this; @@ -1142,6 +1173,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: string | null; trackedImageTag: string | null; trackedImageDigest: string | null; + googleConnected: boolean; }> { await this.loadState(); @@ -1177,6 +1209,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, trackedImageDigest: this.trackedImageDigest, + googleConnected: this.googleCredentials !== null, }; } @@ -2694,6 +2727,7 @@ export class KiloClawInstance extends DurableObject { kilocodeApiKey: this.kilocodeApiKey ?? undefined, kilocodeDefaultModel: this.kilocodeDefaultModel ?? undefined, channels: this.channels ?? undefined, + googleCredentials: this.googleCredentials ?? undefined, } ); diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index 5742d3e23..62b790802 100644 --- a/kiloclaw/src/gateway/env.test.ts +++ b/kiloclaw/src/gateway/env.test.ts @@ -293,6 +293,38 @@ describe('buildEnvVars', () => { ).rejects.toThrow('valid shell identifier'); }); + // ─── Google credentials (Layer 4b) ─────────────────────────────────── + + it('decrypts Google credentials into sensitive bucket', async () => { + const env = createMockEnv({ + AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey, + }); + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + googleCredentials: { + clientSecret: encryptForTest('{"client_id":"test"}', testPublicKey), + credentials: encryptForTest('{"refresh_token":"rt"}', testPublicKey), + }, + }); + + expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBe('{"client_id":"test"}'); + expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBe('{"refresh_token":"rt"}'); + expect(result.env.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); + expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + }); + + it('skips Google credential decryption when no private key configured', async () => { + const env = createMockEnv(); // no AGENT_ENV_VARS_PRIVATE_KEY + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + googleCredentials: { + clientSecret: encryptForTest('{"client_id":"test"}', testPublicKey), + credentials: encryptForTest('{"refresh_token":"rt"}', testPublicKey), + }, + }); + + expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); + expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + }); + // ─── Catalog-derived SENSITIVE_KEYS equivalence ─────────────────────── // Verifies that switching from hardcoded SENSITIVE_KEYS to catalog-derived // ALL_SECRET_ENV_VARS produces identical classification behavior. diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index 5857da88e..5d98923c9 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -1,8 +1,16 @@ import { ALL_SECRET_ENV_VARS } from '@kilocode/kiloclaw-secret-catalog'; import type { KiloClawEnv } from '../types'; -import type { EncryptedEnvelope, EncryptedChannelTokens } from '../schemas/instance-config'; +import type { + EncryptedEnvelope, + EncryptedChannelTokens, + GoogleCredentials, +} from '../schemas/instance-config'; import { deriveGatewayToken } from '../auth/gateway-token'; -import { mergeEnvVarsWithSecrets, decryptChannelTokens } from '../utils/encryption'; +import { + mergeEnvVarsWithSecrets, + decryptChannelTokens, + decryptWithPrivateKey, +} from '../utils/encryption'; import { validateUserEnvVarName } from '../utils/env-encryption'; /** @@ -15,6 +23,7 @@ export type UserConfig = { kilocodeApiKey?: string | null; kilocodeDefaultModel?: string | null; channels?: EncryptedChannelTokens; + googleCredentials?: GoogleCredentials; }; /** @@ -121,6 +130,18 @@ export async function buildEnvVars( // All channel tokens are sensitive Object.assign(sensitive, channelEnv); } + + // Layer 4b: Decrypt Google credentials and pass as env vars + if (userConfig.googleCredentials && env.AGENT_ENV_VARS_PRIVATE_KEY) { + sensitive.GOOGLE_CLIENT_SECRET_JSON = decryptWithPrivateKey( + userConfig.googleCredentials.clientSecret, + env.AGENT_ENV_VARS_PRIVATE_KEY + ); + sensitive.GOOGLE_CREDENTIALS_JSON = decryptWithPrivateKey( + userConfig.googleCredentials.credentials, + env.AGENT_ENV_VARS_PRIVATE_KEY + ); + } } // Worker-level passthrough (non-sensitive) diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 43faab675..8ad90a648 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -32,6 +32,13 @@ const envVarNameSchema = z 'Uses reserved prefix (KILOCLAW_ENC_ or KILOCLAW_ENV_)' ); +export const GoogleCredentialsSchema = z.object({ + clientSecret: EncryptedEnvelopeSchema, // client_secret.json contents + credentials: EncryptedEnvelopeSchema, // OAuth tokens (refresh token, etc.) +}); + +export type GoogleCredentials = z.infer; + export const InstanceConfigSchema = z.object({ envVars: z.record(envVarNameSchema, z.string()).optional(), encryptedSecrets: z.record(envVarNameSchema, EncryptedEnvelopeSchema).optional(), @@ -46,6 +53,7 @@ export const InstanceConfigSchema = z.object({ slackAppToken: EncryptedEnvelopeSchema.optional(), }) .optional(), + googleCredentials: GoogleCredentialsSchema.optional(), machineSize: MachineSizeSchema.optional(), // Region for Fly Volume/Machine. Comma-separated priority list of region codes or aliases. // Examples: "us,eu" (try US first, then Europe), "lhr" (London only). @@ -111,6 +119,7 @@ export const PersistedStateSchema = z.object({ }) .nullable() .default(null), + googleCredentials: GoogleCredentialsSchema.nullable().default(null), provisionedAt: z.number().nullable().default(null), lastStartedAt: z.number().nullable().default(null), lastStoppedAt: z.number().nullable().default(null), From 349f2efe010746e384dbfed4ace0fa9353f83802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 19:56:01 +0100 Subject: [PATCH 02/87] feat(kiloclaw): add Google credentials API endpoint and controller gws writer Add POST/DELETE /api/platform/google-credentials routes for storing and clearing encrypted Google credentials. Add gws-credentials module that writes client_secret.json and credentials.json to ~/.config/gws/ on controller startup when the env vars are present. --- .../controller/src/gws-credentials.test.ts | 70 +++++++++++++++++++ kiloclaw/controller/src/gws-credentials.ts | 47 +++++++++++++ kiloclaw/controller/src/index.ts | 5 ++ kiloclaw/src/routes/platform.ts | 44 ++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 kiloclaw/controller/src/gws-credentials.test.ts create mode 100644 kiloclaw/controller/src/gws-credentials.ts diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts new file mode 100644 index 000000000..3b3f289ed --- /dev/null +++ b/kiloclaw/controller/src/gws-credentials.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import path from 'node:path'; +import { writeGwsCredentials, type GwsCredentialsDeps } from './gws-credentials'; + +function mockDeps() { + return { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + } satisfies GwsCredentialsDeps; +} + +describe('writeGwsCredentials', () => { + it('writes credential files when both env vars are set', () => { + const deps = mockDeps(); + const dir = '/tmp/gws-test'; + const result = writeGwsCredentials( + { + GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}', + GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}', + }, + dir, + deps + ); + + expect(result).toBe(true); + expect(deps.mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); + expect(deps.writeFileSync).toHaveBeenCalledWith( + path.join(dir, 'client_secret.json'), + '{"client_id":"test"}', + { mode: 0o600 } + ); + expect(deps.writeFileSync).toHaveBeenCalledWith( + path.join(dir, 'credentials.json'), + '{"refresh_token":"rt"}', + { mode: 0o600 } + ); + }); + + it('skips when GOOGLE_CLIENT_SECRET_JSON is missing', () => { + const deps = mockDeps(); + const result = writeGwsCredentials( + { GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}' }, + '/tmp/gws-test', + deps + ); + + expect(result).toBe(false); + expect(deps.mkdirSync).not.toHaveBeenCalled(); + expect(deps.writeFileSync).not.toHaveBeenCalled(); + }); + + it('skips when GOOGLE_CREDENTIALS_JSON is missing', () => { + const deps = mockDeps(); + const result = writeGwsCredentials( + { GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}' }, + '/tmp/gws-test', + deps + ); + + expect(result).toBe(false); + expect(deps.mkdirSync).not.toHaveBeenCalled(); + }); + + it('skips when both env vars are missing', () => { + const deps = mockDeps(); + const result = writeGwsCredentials({}, '/tmp/gws-test', deps); + + expect(result).toBe(false); + }); +}); diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts new file mode 100644 index 000000000..3848fd0b9 --- /dev/null +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -0,0 +1,47 @@ +/** + * Writes Google Workspace CLI (gws) credential files to disk. + * + * When the container starts with GOOGLE_CLIENT_SECRET_JSON and + * GOOGLE_CREDENTIALS_JSON env vars, this module writes them to + * ~/.config/gws/ so the gws CLI picks them up automatically. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const GWS_CONFIG_DIR = path.join(process.env.HOME ?? '/root', '.config', 'gws'); +const CLIENT_SECRET_FILE = 'client_secret.json'; +const CREDENTIALS_FILE = 'credentials.json'; + +export type GwsCredentialsDeps = { + mkdirSync: (dir: string, opts: { recursive: boolean }) => void; + writeFileSync: (path: string, data: string, opts: { mode: number }) => void; +}; + +const defaultDeps: GwsCredentialsDeps = { + mkdirSync: (dir, opts) => fs.mkdirSync(dir, opts), + writeFileSync: (p, data, opts) => fs.writeFileSync(p, data, opts), +}; + +/** + * Write gws credential files if the corresponding env vars are set. + * Returns true if credentials were written, false if skipped. + */ +export function writeGwsCredentials( + env: Record = process.env as Record, + configDir = GWS_CONFIG_DIR, + deps: GwsCredentialsDeps = defaultDeps +): boolean { + const clientSecret = env.GOOGLE_CLIENT_SECRET_JSON; + const credentials = env.GOOGLE_CREDENTIALS_JSON; + + if (!clientSecret || !credentials) { + return false; + } + + deps.mkdirSync(configDir, { recursive: true }); + deps.writeFileSync(path.join(configDir, CLIENT_SECRET_FILE), clientSecret, { mode: 0o600 }); + deps.writeFileSync(path.join(configDir, CREDENTIALS_FILE), credentials, { mode: 0o600 }); + + console.log(`[gws] Wrote credentials to ${configDir}`); + return true; +} diff --git a/kiloclaw/controller/src/index.ts b/kiloclaw/controller/src/index.ts index 8283acaf0..9c17dd86c 100644 --- a/kiloclaw/controller/src/index.ts +++ b/kiloclaw/controller/src/index.ts @@ -13,6 +13,7 @@ import { registerHealthRoute } from './routes/health'; import { registerGatewayRoutes } from './routes/gateway'; import { registerConfigRoutes } from './routes/config'; import { CONTROLLER_COMMIT, CONTROLLER_VERSION } from './version'; +import { writeGwsCredentials } from './gws-credentials'; export type RuntimeConfig = { port: number; @@ -112,6 +113,10 @@ async function handleHttpRequest( export async function startController(env: NodeJS.ProcessEnv = process.env): Promise { const config = loadRuntimeConfig(env); + + // Write Google Workspace CLI credentials if available + writeGwsCredentials(env as Record); + const supervisor = createSupervisor({ gatewayArgs: config.gatewayArgs, }); diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index a9fbfc5ca..8c50b033d 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -13,6 +13,7 @@ import { UserIdRequestSchema, DestroyRequestSchema, ChannelsPatchSchema, + GoogleCredentialsSchema, } from '../schemas/instance-config'; import { ImageVersionEntrySchema, @@ -212,6 +213,49 @@ platform.patch('/channels', async c => { } }); +// POST /api/platform/google-credentials +const GoogleCredentialsPatchSchema = z.object({ + userId: z.string().min(1), + googleCredentials: GoogleCredentialsSchema, +}); + +platform.post('/google-credentials', async c => { + const result = await parseBody(c, GoogleCredentialsPatchSchema); + if ('error' in result) return result.error; + + const { userId, googleCredentials } = result.data; + + try { + const updated = await withDORetry( + instanceStubFactory(c.env, userId), + stub => stub.updateGoogleCredentials(googleCredentials), + 'updateGoogleCredentials' + ); + return c.json(updated, 200); + } catch (err) { + const { message, status } = sanitizeError(err, 'google-credentials'); + return jsonError(message, status); + } +}); + +// DELETE /api/platform/google-credentials?userId=... +platform.delete('/google-credentials', async c => { + const userId = c.req.query('userId'); + if (!userId) return c.json({ error: 'userId is required' }, 400); + + try { + const updated = await withDORetry( + instanceStubFactory(c.env, userId), + stub => stub.clearGoogleCredentials(), + 'clearGoogleCredentials' + ); + return c.json(updated, 200); + } catch (err) { + const { message, status } = sanitizeError(err, 'google-credentials delete'); + return jsonError(message, status); + } +}); + // GET /api/platform/pairing?userId=...&refresh=true platform.get('/pairing', async c => { const userId = c.req.query('userId'); From bfa37c796c395d36bccc75231859e8e7b8b381c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 19:57:09 +0100 Subject: [PATCH 03/87] feat(kiloclaw): add google-setup Docker image scaffold Docker container that orchestrates the Google OAuth flow: gws auth setup -> gws auth login -> encrypt credentials -> POST to platform. Published as kilocode/google-setup for users to run locally. --- kiloclaw/google-setup/Dockerfile | 13 ++ kiloclaw/google-setup/package.json | 12 ++ kiloclaw/google-setup/setup.mjs | 194 +++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 kiloclaw/google-setup/Dockerfile create mode 100644 kiloclaw/google-setup/package.json create mode 100644 kiloclaw/google-setup/setup.mjs diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile new file mode 100644 index 000000000..61040a2d5 --- /dev/null +++ b/kiloclaw/google-setup/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-slim + +# Install gws CLI +RUN npm install -g @anthropic/gws + +WORKDIR /app +COPY package.json ./ +RUN npm install --production +COPY setup.mjs ./ + +EXPOSE 8080 + +ENTRYPOINT ["node", "setup.mjs"] diff --git a/kiloclaw/google-setup/package.json b/kiloclaw/google-setup/package.json new file mode 100644 index 000000000..d911ee670 --- /dev/null +++ b/kiloclaw/google-setup/package.json @@ -0,0 +1,12 @@ +{ + "name": "@kilocode/google-setup", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node setup.mjs" + }, + "dependencies": { + "open": "^10.0.0" + } +} diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs new file mode 100644 index 000000000..80d71aa1f --- /dev/null +++ b/kiloclaw/google-setup/setup.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +/** + * KiloClaw Google Account Setup + * + * Docker-based tool that: + * 1. Validates the user's KiloCode API key + * 2. Runs `gws auth setup` to create an OAuth client in the user's Google Cloud project + * 3. Runs `gws auth login` to complete the OAuth flow via localhost:8080 callback + * 4. Reads the resulting credentials from ~/.config/gws/ + * 5. Encrypts them with the kiloclaw public key + * 6. POSTs the encrypted bundle to the kilo.ai platform API + * + * Usage: + * docker run -it -p 8080:8080 kilocode/google-setup --api-key=kilo_abc123 + */ + +import { execFileSync, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const apiKeyArg = args.find(a => a.startsWith('--api-key=')); +const apiKey = apiKeyArg?.split('=')[1]; + +const platformUrl = args.find(a => a.startsWith('--platform-url='))?.split('=')[1] + ?? 'https://api.kilo.ai'; + +if (!apiKey) { + console.error('Usage: docker run -it -p 8080:8080 kilocode/google-setup --api-key='); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Hardcoded scopes +// --------------------------------------------------------------------------- + +const SCOPES = [ + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive.file', +]; + +// --------------------------------------------------------------------------- +// Step 1: Validate API key +// --------------------------------------------------------------------------- + +console.log('Validating API key...'); + +const validateRes = await fetch(`${platformUrl}/api/platform/status`, { + headers: { + 'x-internal-api-key': apiKey, + }, +}); + +if (!validateRes.ok) { + console.error('Invalid API key or cannot reach platform.'); + process.exit(1); +} + +console.log('API key verified.'); + +// --------------------------------------------------------------------------- +// Step 2: Fetch public key for encryption +// --------------------------------------------------------------------------- + +console.log('Fetching encryption public key...'); + +const pubKeyRes = await fetch(`${platformUrl}/api/platform/public-key`, { + headers: { 'x-internal-api-key': apiKey }, +}); + +if (!pubKeyRes.ok) { + console.error('Failed to fetch public key from platform.'); + process.exit(1); +} + +const { publicKey: publicKeyPem } = await pubKeyRes.json(); + +// --------------------------------------------------------------------------- +// Step 3: Run gws auth setup +// --------------------------------------------------------------------------- + +console.log('Setting up Google OAuth client...'); +console.log('Follow the prompts to create an OAuth client in your Google Cloud project.\n'); + +try { + execFileSync('gws', ['auth', 'setup'], { stdio: 'inherit' }); +} catch { + console.error('\nFailed to set up OAuth client. Please try again.'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Step 4: Run gws auth login +// --------------------------------------------------------------------------- + +console.log('\nStarting OAuth login flow...'); +console.log('Your browser will open for Google sign-in.\n'); + +try { + execFileSync('gws', ['auth', 'login', '--scopes', SCOPES.join(',')], { + stdio: 'inherit', + }); +} catch { + console.error('\nOAuth login failed. Please try again.'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Step 5: Read credentials from ~/.config/gws/ +// --------------------------------------------------------------------------- + +const gwsDir = path.join(process.env.HOME ?? '/root', '.config', 'gws'); +const clientSecretPath = path.join(gwsDir, 'client_secret.json'); +const credentialsPath = path.join(gwsDir, 'credentials.json'); + +if (!fs.existsSync(clientSecretPath) || !fs.existsSync(credentialsPath)) { + console.error('Credential files not found. The OAuth flow may not have completed.'); + process.exit(1); +} + +const clientSecret = fs.readFileSync(clientSecretPath, 'utf8'); +const credentials = fs.readFileSync(credentialsPath, 'utf8'); + +// --------------------------------------------------------------------------- +// Step 6: Encrypt credentials +// --------------------------------------------------------------------------- + +/** + * Encrypt a value using RSA+AES-256-GCM envelope encryption. + * Matches the EncryptedEnvelope schema used by kiloclaw. + */ +function encryptEnvelope(plaintext, publicKeyPem) { + const dek = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); + let encrypted = cipher.update(plaintext, 'utf8'); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const authTag = cipher.getAuthTag(); + const encryptedData = Buffer.concat([iv, encrypted, authTag]); + const encryptedDEK = crypto.publicEncrypt( + { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + dek + ); + return { + encryptedData: encryptedData.toString('base64'), + encryptedDEK: encryptedDEK.toString('base64'), + algorithm: 'rsa-aes-256-gcm', + version: 1, + }; +} + +console.log('Encrypting credentials...'); + +const encryptedBundle = { + clientSecret: encryptEnvelope(clientSecret, publicKeyPem), + credentials: encryptEnvelope(credentials, publicKeyPem), +}; + +// --------------------------------------------------------------------------- +// Step 7: POST to platform API +// --------------------------------------------------------------------------- + +console.log('Sending credentials to your kiloclaw instance...'); + +const postRes = await fetch(`${platformUrl}/api/platform/google-credentials`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': apiKey, + }, + body: JSON.stringify({ + userId: 'self', // The platform resolves the userId from the API key + googleCredentials: encryptedBundle, + }), +}); + +if (!postRes.ok) { + const body = await postRes.text(); + console.error('Failed to store credentials:', body); + process.exit(1); +} + +console.log('\nGoogle account connected!'); +console.log('Credentials sent to your kiloclaw instance.'); +console.log('\nYour bot can now use Gmail, Calendar, and Docs.'); +console.log('Restart your instance to activate.'); From 95f953fde71598f898d83e7cf57b0ca318c1d05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 21:08:59 +0100 Subject: [PATCH 04/87] feat(kiloclaw): add public-key endpoint for google-setup encryption GET /api/platform/public-key derives the RSA public key from the worker's private key so the google-setup container can encrypt credentials before POSTing them. --- kiloclaw/src/routes/platform.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 8c50b033d..5e24ed237 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -213,6 +213,29 @@ platform.patch('/channels', async c => { } }); +// GET /api/platform/public-key +// Returns the RSA public key used to encrypt secrets for this worker. +// The google-setup container uses this to encrypt Google OAuth credentials. +platform.get('/public-key', async c => { + const privateKeyPem = c.env.AGENT_ENV_VARS_PRIVATE_KEY; + if (!privateKeyPem) { + return c.json({ error: 'Encryption not configured' }, 503); + } + + try { + const { createPublicKey } = await import('crypto'); + const publicKey = createPublicKey({ + key: privateKeyPem, + format: 'pem', + }); + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + return c.json({ publicKey: publicKeyPem }); + } catch (err) { + console.error('[platform] Failed to derive public key:', err); + return c.json({ error: 'Failed to derive public key' }, 500); + } +}); + // POST /api/platform/google-credentials const GoogleCredentialsPatchSchema = z.object({ userId: z.string().min(1), From 2e267e9e3ed4285530969ec356930cb3bdeb2a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 21:11:46 +0100 Subject: [PATCH 05/87] fix(kiloclaw): fix google-setup auth flow and add user-facing routes - Move public-key endpoint to /public-key (public, no auth needed) - Add POST/DELETE /api/admin/google-credentials (JWT auth, auto-resolves userId) - Update setup.mjs to use Bearer auth against the worker directly instead of x-internal-api-key against the platform API --- kiloclaw/google-setup/setup.mjs | 78 ++++++++++++++++++--------------- kiloclaw/src/routes/api.ts | 41 +++++++++++++++++ kiloclaw/src/routes/platform.ts | 23 ---------- kiloclaw/src/routes/public.ts | 19 ++++++++ 4 files changed, 103 insertions(+), 58 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 80d71aa1f..985288e03 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -4,18 +4,19 @@ * KiloClaw Google Account Setup * * Docker-based tool that: - * 1. Validates the user's KiloCode API key - * 2. Runs `gws auth setup` to create an OAuth client in the user's Google Cloud project - * 3. Runs `gws auth login` to complete the OAuth flow via localhost:8080 callback - * 4. Reads the resulting credentials from ~/.config/gws/ - * 5. Encrypts them with the kiloclaw public key - * 6. POSTs the encrypted bundle to the kilo.ai platform API + * 1. Validates the user's KiloCode API key against the kiloclaw worker + * 2. Fetches the worker's RSA public key for credential encryption + * 3. Runs `gws auth setup` to create an OAuth client in the user's Google Cloud project + * 4. Runs `gws auth login` to complete the OAuth flow via localhost:8080 callback + * 5. Reads the resulting credentials from ~/.config/gws/ + * 6. Encrypts them with the worker's public key + * 7. POSTs the encrypted bundle to the kiloclaw worker (user-facing JWT auth) * * Usage: * docker run -it -p 8080:8080 kilocode/google-setup --api-key=kilo_abc123 */ -import { execFileSync, execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; @@ -28,14 +29,23 @@ const args = process.argv.slice(2); const apiKeyArg = args.find(a => a.startsWith('--api-key=')); const apiKey = apiKeyArg?.split('=')[1]; -const platformUrl = args.find(a => a.startsWith('--platform-url='))?.split('=')[1] - ?? 'https://api.kilo.ai'; +// The kiloclaw worker URL (user-facing routes use Bearer JWT auth) +const workerUrl = + args.find(a => a.startsWith('--worker-url='))?.split('=')[1] ?? 'https://claw.kilo.ai'; if (!apiKey) { - console.error('Usage: docker run -it -p 8080:8080 kilocode/google-setup --api-key='); + console.error( + 'Usage: docker run -it -p 8080:8080 kilocode/google-setup --api-key=' + ); process.exit(1); } +/** Helper: Bearer auth headers for user-facing worker routes. */ +const authHeaders = { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', +}; + // --------------------------------------------------------------------------- // Hardcoded scopes // --------------------------------------------------------------------------- @@ -48,36 +58,40 @@ const SCOPES = [ ]; // --------------------------------------------------------------------------- -// Step 1: Validate API key +// Step 1: Validate API key by calling a user-facing endpoint // --------------------------------------------------------------------------- console.log('Validating API key...'); -const validateRes = await fetch(`${platformUrl}/api/platform/status`, { - headers: { - 'x-internal-api-key': apiKey, - }, +const validateRes = await fetch(`${workerUrl}/health`); +if (!validateRes.ok) { + console.error('Cannot reach kiloclaw worker at', workerUrl); + process.exit(1); +} + +// Verify the API key is a valid JWT by calling an authenticated endpoint +const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { + method: 'DELETE', + headers: authHeaders, }); -if (!validateRes.ok) { - console.error('Invalid API key or cannot reach platform.'); +// 401/403 = bad key. Any other response (including 200/500) means the key is valid. +if (authCheckRes.status === 401 || authCheckRes.status === 403) { + console.error('Invalid API key. Check your key and try again.'); process.exit(1); } console.log('API key verified.'); // --------------------------------------------------------------------------- -// Step 2: Fetch public key for encryption +// Step 2: Fetch public key for encryption (public endpoint, no auth needed) // --------------------------------------------------------------------------- console.log('Fetching encryption public key...'); -const pubKeyRes = await fetch(`${platformUrl}/api/platform/public-key`, { - headers: { 'x-internal-api-key': apiKey }, -}); - +const pubKeyRes = await fetch(`${workerUrl}/public-key`); if (!pubKeyRes.ok) { - console.error('Failed to fetch public key from platform.'); + console.error('Failed to fetch public key from worker.'); process.exit(1); } @@ -137,7 +151,7 @@ const credentials = fs.readFileSync(credentialsPath, 'utf8'); * Encrypt a value using RSA+AES-256-GCM envelope encryption. * Matches the EncryptedEnvelope schema used by kiloclaw. */ -function encryptEnvelope(plaintext, publicKeyPem) { +function encryptEnvelope(plaintext, pemKey) { const dek = crypto.randomBytes(32); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); @@ -146,7 +160,7 @@ function encryptEnvelope(plaintext, publicKeyPem) { const authTag = cipher.getAuthTag(); const encryptedData = Buffer.concat([iv, encrypted, authTag]); const encryptedDEK = crypto.publicEncrypt( - { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + { key: pemKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, dek ); return { @@ -165,21 +179,15 @@ const encryptedBundle = { }; // --------------------------------------------------------------------------- -// Step 7: POST to platform API +// Step 7: POST to worker (user-facing, JWT auth resolves userId automatically) // --------------------------------------------------------------------------- console.log('Sending credentials to your kiloclaw instance...'); -const postRes = await fetch(`${platformUrl}/api/platform/google-credentials`, { +const postRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-internal-api-key': apiKey, - }, - body: JSON.stringify({ - userId: 'self', // The platform resolves the userId from the API key - googleCredentials: encryptedBundle, - }), + headers: authHeaders, + body: JSON.stringify({ googleCredentials: encryptedBundle }), }); if (!postRes.ok) { diff --git a/kiloclaw/src/routes/api.ts b/kiloclaw/src/routes/api.ts index 6840693d6..5762ab92f 100644 --- a/kiloclaw/src/routes/api.ts +++ b/kiloclaw/src/routes/api.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types'; import { isValidImageTag } from '../lib/image-tag-validation'; +import { GoogleCredentialsSchema } from '../schemas/instance-config'; /** * API routes @@ -62,6 +63,46 @@ adminApi.post('/gateway/restart', async c => { } }); +// POST /api/admin/google-credentials - Store encrypted Google credentials +adminApi.post('/google-credentials', async c => { + const stub = resolveStub(c); + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Malformed JSON body' }, 400); + } + + const parsed = GoogleCredentialsSchema.safeParse( + typeof body === 'object' && body !== null && 'googleCredentials' in body + ? (body as Record).googleCredentials + : body + ); + if (!parsed.success) { + return c.json({ error: 'Invalid request', details: parsed.error.flatten().fieldErrors }, 400); + } + + try { + const result = await stub.updateGoogleCredentials(parsed.data); + return c.json(result, 200); + } catch (err) { + console.error('[api] google-credentials failed:', err); + return c.json({ error: 'Failed to store Google credentials' }, 500); + } +}); + +// DELETE /api/admin/google-credentials - Clear Google credentials +adminApi.delete('/google-credentials', async c => { + const stub = resolveStub(c); + try { + const result = await stub.clearGoogleCredentials(); + return c.json(result, 200); + } catch (err) { + console.error('[api] google-credentials delete failed:', err); + return c.json({ error: 'Failed to clear Google credentials' }, 500); + } +}); + // Mount admin API routes under /admin api.route('/admin', adminApi); diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 5e24ed237..8c50b033d 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -213,29 +213,6 @@ platform.patch('/channels', async c => { } }); -// GET /api/platform/public-key -// Returns the RSA public key used to encrypt secrets for this worker. -// The google-setup container uses this to encrypt Google OAuth credentials. -platform.get('/public-key', async c => { - const privateKeyPem = c.env.AGENT_ENV_VARS_PRIVATE_KEY; - if (!privateKeyPem) { - return c.json({ error: 'Encryption not configured' }, 503); - } - - try { - const { createPublicKey } = await import('crypto'); - const publicKey = createPublicKey({ - key: privateKeyPem, - format: 'pem', - }); - const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; - return c.json({ publicKey: publicKeyPem }); - } catch (err) { - console.error('[platform] Failed to derive public key:', err); - return c.json({ error: 'Failed to derive public key' }, 500); - } -}); - // POST /api/platform/google-credentials const GoogleCredentialsPatchSchema = z.object({ userId: z.string().min(1), diff --git a/kiloclaw/src/routes/public.ts b/kiloclaw/src/routes/public.ts index b0476e4ef..996404dc8 100644 --- a/kiloclaw/src/routes/public.ts +++ b/kiloclaw/src/routes/public.ts @@ -18,4 +18,23 @@ publicRoutes.get('/health', c => { }); }); +// GET /public-key - RSA public key for encrypting secrets +// The google-setup container fetches this to encrypt Google OAuth credentials. +publicRoutes.get('/public-key', async c => { + const privateKeyPem = c.env.AGENT_ENV_VARS_PRIVATE_KEY; + if (!privateKeyPem) { + return c.json({ error: 'Encryption not configured' }, 503); + } + + try { + const { createPublicKey } = await import('crypto'); + const publicKey = createPublicKey({ key: privateKeyPem, format: 'pem' }); + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + return c.json({ publicKey: publicKeyPem }); + } catch (err) { + console.error('[public] Failed to derive public key:', err); + return c.json({ error: 'Failed to derive public key' }, 500); + } +}); + export { publicRoutes }; From 707dc670d71d38530b9fcf74314b2d25d9460006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 21:12:10 +0100 Subject: [PATCH 06/87] chore(kiloclaw): add .dockerignore and note gws CLI dependency Add .dockerignore for google-setup. Mark gws CLI npm package as pending publication with a TODO and graceful build fallback. --- kiloclaw/google-setup/.dockerignore | 3 +++ kiloclaw/google-setup/Dockerfile | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 kiloclaw/google-setup/.dockerignore diff --git a/kiloclaw/google-setup/.dockerignore b/kiloclaw/google-setup/.dockerignore new file mode 100644 index 000000000..8055a5361 --- /dev/null +++ b/kiloclaw/google-setup/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log* +.DS_Store diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 61040a2d5..6d354b6bd 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -1,7 +1,11 @@ FROM node:22-slim -# Install gws CLI -RUN npm install -g @anthropic/gws +# Install gws CLI (Google Workspace CLI) +# TODO: Update package name once published. The gws CLI may be distributed as: +# - npm: npm install -g @anthropic/gws +# - direct binary download +# For now, this will fail at build time if the package doesn't exist yet. +RUN npm install -g @anthropic/gws || echo "WARN: gws CLI not available yet — build will succeed but setup will fail at runtime" WORKDIR /app COPY package.json ./ From 4369257061f6f1a68192f9747e4db33b7728b668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 21:25:02 +0100 Subject: [PATCH 07/87] feat(kiloclaw): add Next.js Google credentials integration Add types, internal client methods, and tRPC mutations for managing Google credentials from the dashboard (connectGoogle/disconnectGoogle). --- .../withStatusQueryBoundary.test.ts | 1 + src/lib/kiloclaw/kiloclaw-internal-client.ts | 18 +++++++++++++++ src/lib/kiloclaw/types.ts | 14 ++++++++++++ src/routers/kiloclaw-router.ts | 22 +++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts b/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts index 77c789faa..308371ad3 100644 --- a/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts +++ b/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts @@ -23,6 +23,7 @@ const baseStatus: KiloClawDashboardStatus = { imageVariant: null, trackedImageTag: null, trackedImageDigest: null, + googleConnected: false, gatewayToken: 'token', workerUrl: 'https://claw.kilo.ai', }; diff --git a/src/lib/kiloclaw/kiloclaw-internal-client.ts b/src/lib/kiloclaw/kiloclaw-internal-client.ts index 4acd313dd..1a7627d15 100644 --- a/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -20,6 +20,8 @@ import type { GatewayProcessActionResponse, ConfigRestoreResponse, ControllerVersionResponse, + GoogleCredentialsInput, + GoogleCredentialsResponse, } from './types'; /** @@ -234,4 +236,20 @@ export class KiloClawInternalClient { body: JSON.stringify({ userId, version }), }); } + + async updateGoogleCredentials( + userId: string, + input: GoogleCredentialsInput + ): Promise { + return this.request('/api/platform/google-credentials', { + method: 'POST', + body: JSON.stringify({ userId, ...input }), + }); + } + + async clearGoogleCredentials(userId: string): Promise { + return this.request(`/api/platform/google-credentials?userId=${encodeURIComponent(userId)}`, { + method: 'DELETE', + }); + } } diff --git a/src/lib/kiloclaw/types.ts b/src/lib/kiloclaw/types.ts index 7f700779c..ba78c9125 100644 --- a/src/lib/kiloclaw/types.ts +++ b/src/lib/kiloclaw/types.ts @@ -123,6 +123,7 @@ export type PlatformStatusResponse = { imageVariant: string | null; trackedImageTag: string | null; trackedImageDigest: string | null; + googleConnected: boolean; }; /** Response from GET /api/platform/debug-status (internal/admin only). */ @@ -214,6 +215,19 @@ export type ControllerVersionResponse = { openclawVersion?: string | null; }; +/** Input to POST /api/platform/google-credentials */ +export type GoogleCredentialsInput = { + googleCredentials: { + clientSecret: EncryptedEnvelope; + credentials: EncryptedEnvelope; + }; +}; + +/** Response from POST/DELETE /api/platform/google-credentials */ +export type GoogleCredentialsResponse = { + googleConnected: boolean; +}; + /** Combined status + gateway token returned by tRPC getStatus */ export type KiloClawDashboardStatus = PlatformStatusResponse & { gatewayToken: string | null; diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index fb65bcbd9..d12d73fb6 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -382,6 +382,28 @@ export const kiloclawRouter = createTRPCRouter({ return client.restoreConfig(ctx.user.id); }), + connectGoogle: baseProcedure + .input( + z.object({ + clientSecretJson: z.string().min(1), + credentialsJson: z.string().min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const client = new KiloClawInternalClient(); + return client.updateGoogleCredentials(ctx.user.id, { + googleCredentials: { + clientSecret: encryptKiloClawSecret(input.clientSecretJson), + credentials: encryptKiloClawSecret(input.credentialsJson), + }, + }); + }), + + disconnectGoogle: baseProcedure.mutation(async ({ ctx }) => { + const client = new KiloClawInternalClient(); + return client.clearGoogleCredentials(ctx.user.id); + }), + getEarlybirdStatus: baseProcedure .output(z.object({ purchased: z.boolean() })) .query(async ({ ctx }) => { From e61fed70eb80de1d3db40b0aa43ef54e617c043f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 22:59:10 +0100 Subject: [PATCH 08/87] fix(kiloclaw): address review feedback on Google credentials feature - Use non-destructive auth probe in setup.mjs (GET /api/admin/storage instead of DELETE /api/admin/google-credentials) - Clean up stale gws credential files on disconnect (controller) - Add googleConnected to getDebugState() return type - Exclude googleCredentials from ProvisionRequestSchema --- .../controller/src/gws-credentials.test.ts | 22 +++++++++++++++++++ kiloclaw/controller/src/gws-credentials.ts | 12 ++++++++++ kiloclaw/google-setup/setup.mjs | 7 +++--- .../src/durable-objects/kiloclaw-instance.ts | 2 ++ kiloclaw/src/schemas/instance-config.ts | 2 +- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts index 3b3f289ed..f8a7b16d0 100644 --- a/kiloclaw/controller/src/gws-credentials.test.ts +++ b/kiloclaw/controller/src/gws-credentials.test.ts @@ -6,6 +6,7 @@ function mockDeps() { return { mkdirSync: vi.fn(), writeFileSync: vi.fn(), + unlinkSync: vi.fn(), } satisfies GwsCredentialsDeps; } @@ -67,4 +68,25 @@ describe('writeGwsCredentials', () => { expect(result).toBe(false); }); + + it('removes stale credential files when env vars are absent', () => { + const deps = mockDeps(); + const dir = '/tmp/gws-test'; + writeGwsCredentials({}, dir, deps); + + expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'client_secret.json')); + expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); + }); + + it('ignores missing files during cleanup', () => { + const deps = mockDeps(); + deps.unlinkSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const dir = '/tmp/gws-test'; + + // Should not throw + const result = writeGwsCredentials({}, dir, deps); + expect(result).toBe(false); + }); }); diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts index 3848fd0b9..7248622f5 100644 --- a/kiloclaw/controller/src/gws-credentials.ts +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -15,11 +15,13 @@ const CREDENTIALS_FILE = 'credentials.json'; export type GwsCredentialsDeps = { mkdirSync: (dir: string, opts: { recursive: boolean }) => void; writeFileSync: (path: string, data: string, opts: { mode: number }) => void; + unlinkSync: (path: string) => void; }; const defaultDeps: GwsCredentialsDeps = { mkdirSync: (dir, opts) => fs.mkdirSync(dir, opts), writeFileSync: (p, data, opts) => fs.writeFileSync(p, data, opts), + unlinkSync: p => fs.unlinkSync(p), }; /** @@ -35,6 +37,16 @@ export function writeGwsCredentials( const credentials = env.GOOGLE_CREDENTIALS_JSON; if (!clientSecret || !credentials) { + // Clean up stale credential files from a previous run (e.g. after disconnect) + for (const file of [CLIENT_SECRET_FILE, CREDENTIALS_FILE]) { + const filePath = path.join(configDir, file); + try { + deps.unlinkSync(filePath); + console.log(`[gws] Removed stale ${filePath}`); + } catch { + // File doesn't exist — nothing to clean up + } + } return false; } diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 985288e03..0e819ae07 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -69,13 +69,12 @@ if (!validateRes.ok) { process.exit(1); } -// Verify the API key is a valid JWT by calling an authenticated endpoint -const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { - method: 'DELETE', +// Verify the API key is a valid JWT by calling a read-only authenticated endpoint +const authCheckRes = await fetch(`${workerUrl}/api/admin/storage`, { headers: authHeaders, }); -// 401/403 = bad key. Any other response (including 200/500) means the key is valid. +// 401/403 = bad key. Any other response (including 410/500) means the key is valid. if (authCheckRes.status === 401 || authCheckRes.status === 403) { console.error('Invalid API key. Check your key and try again.'); process.exit(1); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index cec3915c8..1c20e7bcd 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -1232,6 +1232,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: string | null; trackedImageTag: string | null; trackedImageDigest: string | null; + googleConnected: boolean; pendingDestroyMachineId: string | null; pendingDestroyVolumeId: string | null; pendingPostgresMarkOnFinalize: boolean; @@ -1265,6 +1266,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, trackedImageDigest: this.trackedImageDigest, + googleConnected: this.googleCredentials !== null, pendingDestroyMachineId: this.pendingDestroyMachineId, pendingDestroyVolumeId: this.pendingDestroyVolumeId, pendingPostgresMarkOnFinalize: this.pendingPostgresMarkOnFinalize, diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 8ad90a648..51f6aca25 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -80,7 +80,7 @@ export const ChannelsPatchSchema = z.object({ export const ProvisionRequestSchema = z.object({ userId: z.string().min(1), - ...InstanceConfigSchema.shape, + ...InstanceConfigSchema.omit({ googleCredentials: true }).shape, }); export type ProvisionRequest = z.infer; From 4cd62bdbce648dfd1d2dd10b2fcd364be0b9aa64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 9 Mar 2026 23:02:16 +0100 Subject: [PATCH 09/87] fix(kiloclaw): use correct gws CLI package in Dockerfile Install @googleworkspace/cli (https://github.com/googleworkspace/cli) instead of the non-existent @anthropic/gws placeholder. Remove the || echo fallback so the build fails fast if install fails. --- kiloclaw/google-setup/Dockerfile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 6d354b6bd..c98a9c12c 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -1,11 +1,8 @@ FROM node:22-slim # Install gws CLI (Google Workspace CLI) -# TODO: Update package name once published. The gws CLI may be distributed as: -# - npm: npm install -g @anthropic/gws -# - direct binary download -# For now, this will fail at build time if the package doesn't exist yet. -RUN npm install -g @anthropic/gws || echo "WARN: gws CLI not available yet — build will succeed but setup will fail at runtime" +# https://github.com/googleworkspace/cli +RUN npm install -g @googleworkspace/cli WORKDIR /app COPY package.json ./ From db03f1d39f1609291598a5a4d90a503494284665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 10 Mar 2026 13:30:21 +0100 Subject: [PATCH 10/87] test(kiloclaw): add Google credentials integration test Node.js script that tests public-key endpoint, platform API store/clear, JWT auth routes, input validation, and idempotency against a local worker. Creates a temporary DB user for JWT auth and cleans up after. --- .../test/google-credentials-integration.mjs | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 kiloclaw/test/google-credentials-integration.mjs diff --git a/kiloclaw/test/google-credentials-integration.mjs b/kiloclaw/test/google-credentials-integration.mjs new file mode 100644 index 000000000..407ee5442 --- /dev/null +++ b/kiloclaw/test/google-credentials-integration.mjs @@ -0,0 +1,354 @@ +#!/usr/bin/env node +/** + * Integration test for the Google credentials feature. + * + * Requires: + * 1. Local Postgres running (postgres://postgres:postgres@localhost:5432/postgres) + * 2. kiloclaw worker running locally (pnpm start → localhost:8795) + * + * The test creates a temporary user in the DB for JWT auth tests, and cleans up after. + * + * Usage: + * node kiloclaw/test/google-credentials-integration.mjs + * DATABASE_URL=postgres://... WORKER_URL=http://localhost:9000 node kiloclaw/test/google-credentials-integration.mjs + */ + +import { SignJWT } from 'jose'; +import { execSync } from 'node:child_process'; + +const WORKER_URL = process.env.WORKER_URL ?? 'http://localhost:8795'; +const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? 'dev-internal-secret'; +const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'dev-secret-change-me'; +const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/postgres'; +const USER_ID = `test-google-creds-${Date.now()}`; + +let pass = 0; +let fail = 0; +const errors = []; +const cleanupFns = []; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function green(msg) { console.log(`\x1b[32m ✓ ${msg}\x1b[0m`); } +function red(msg) { console.log(`\x1b[31m ✗ ${msg}\x1b[0m`); } +function bold(msg) { console.log(`\n\x1b[1m${msg}\x1b[0m`); } + +function assertEq(label, expected, actual) { + if (expected === actual) { + green(label); + pass++; + } else { + red(`${label} (expected: ${expected}, got: ${actual})`); + fail++; + errors.push(label); + } +} + +function assertNotEmpty(label, actual) { + if (actual && actual !== 'null') { + green(label); + pass++; + } else { + red(`${label} (got empty/null)`); + fail++; + errors.push(label); + } +} + +async function internalGet(path) { + const res = await fetch(`${WORKER_URL}${path}`, { + headers: { 'x-internal-api-key': INTERNAL_SECRET }, + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +async function internalPost(path, body) { + const res = await fetch(`${WORKER_URL}${path}`, { + method: 'POST', + headers: { 'x-internal-api-key': INTERNAL_SECRET, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +async function internalDelete(path) { + const res = await fetch(`${WORKER_URL}${path}`, { + method: 'DELETE', + headers: { 'x-internal-api-key': INTERNAL_SECRET }, + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +let JWT; + +async function jwtPost(path, body) { + const res = await fetch(`${WORKER_URL}${path}`, { + method: 'POST', + headers: { authorization: `Bearer ${JWT}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +async function jwtDelete(path) { + const res = await fetch(`${WORKER_URL}${path}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${JWT}` }, + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +async function jwtGet(path) { + const res = await fetch(`${WORKER_URL}${path}`, { + headers: { authorization: `Bearer ${JWT}` }, + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +const DUMMY_CREDS = { + clientSecret: { encryptedData: 'dGVzdA==', encryptedDEK: 'dGVzdA==', algorithm: 'rsa-aes-256-gcm', version: 1 }, + credentials: { encryptedData: 'dGVzdA==', encryptedDEK: 'dGVzdA==', algorithm: 'rsa-aes-256-gcm', version: 1 }, +}; + +// --------------------------------------------------------------------------- +// DB: create/remove test user via psql +// --------------------------------------------------------------------------- + +function sql(query) { + return execSync(`psql "${DATABASE_URL}" -tAc "${query.replace(/"/g, '\\"')}"`, { + encoding: 'utf8', + timeout: 5000, + }).trim(); +} + +function createTestUser() { + sql(`INSERT INTO kilocode_users (id, google_user_email, google_user_name, google_user_image_url, stripe_customer_id, api_token_pepper) VALUES ('${USER_ID}', '${USER_ID}@test.local', 'Test User', '', 'cus_test_${USER_ID}', NULL) ON CONFLICT (id) DO NOTHING`); + cleanupFns.push(() => { + try { sql(`DELETE FROM kilocode_users WHERE id = '${USER_ID}'`); } catch {} + }); +} + +function checkDbConnection() { + try { + sql('SELECT 1'); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +bold('Preflight'); + +// Check worker +try { + const health = await fetch(`${WORKER_URL}/health`); + if (!health.ok) throw new Error(`status ${health.status}`); + green('Worker reachable at ' + WORKER_URL); +} catch (e) { + red(`Worker not reachable at ${WORKER_URL}: ${e.message}`); + console.log(' Is it running? (cd kiloclaw && pnpm start)'); + process.exit(1); +} + +// Connect to DB and create test user +let dbConnected = false; +if (checkDbConnection()) { + try { + createTestUser(); + dbConnected = true; + green('DB connected, test user created (id=' + USER_ID + ')'); + } catch (e) { + red(`Failed to create test user: ${e.message}`); + } +} else { + red('DB not reachable — JWT auth tests will be skipped'); + console.log(` Ensure Postgres is running at ${DATABASE_URL}`); +} + +// --------------------------------------------------------------------------- +// 1. Public key endpoint +// --------------------------------------------------------------------------- + +bold('1. Public key endpoint'); + +const pubKeyRes = await fetch(`${WORKER_URL}/public-key`); +const pubKeyJson = pubKeyRes.ok ? await pubKeyRes.json() : {}; +assertNotEmpty('GET /public-key returns a key', pubKeyJson.publicKey); +assertEq('Public key is valid PEM', true, pubKeyJson.publicKey?.includes('BEGIN PUBLIC KEY')); + +// --------------------------------------------------------------------------- +// 2. Provision a test instance +// --------------------------------------------------------------------------- + +bold(`2. Provision test instance (userId=${USER_ID})`); + +await internalPost('/api/platform/provision', { userId: USER_ID }); +// Provision may partially fail (Fly API) but DO state is created. Verify via google-credentials. +green('Provision request sent'); + +// --------------------------------------------------------------------------- +// 3. Platform API: store Google credentials +// --------------------------------------------------------------------------- + +bold('3. Platform API: store Google credentials'); + +const { json: storeResult } = await internalPost('/api/platform/google-credentials', { + userId: USER_ID, + googleCredentials: DUMMY_CREDS, +}); +assertEq('POST google-credentials returns googleConnected=true', true, storeResult?.googleConnected); + +const { json: statusAfterStore } = await internalGet(`/api/platform/status?userId=${USER_ID}`); +assertEq('GET status shows googleConnected=true', true, statusAfterStore?.googleConnected); + +const { json: debugAfterStore } = await internalGet(`/api/platform/debug-status?userId=${USER_ID}`); +assertEq('GET debug-status shows googleConnected=true', true, debugAfterStore?.googleConnected); + +// --------------------------------------------------------------------------- +// 4. Platform API: clear Google credentials +// --------------------------------------------------------------------------- + +bold('4. Platform API: clear Google credentials'); + +const { json: clearResult } = await internalDelete(`/api/platform/google-credentials?userId=${USER_ID}`); +assertEq('DELETE google-credentials returns googleConnected=false', false, clearResult?.googleConnected); + +const { json: statusAfterClear } = await internalGet(`/api/platform/status?userId=${USER_ID}`); +assertEq('GET status shows googleConnected=false', false, statusAfterClear?.googleConnected); + +// --------------------------------------------------------------------------- +// 5. User-facing routes (JWT auth) +// --------------------------------------------------------------------------- + +if (dbConnected) { + bold('5. User-facing routes (JWT auth)'); + + JWT = await new SignJWT({ + kiloUserId: USER_ID, + apiTokenPepper: null, // matches NULL in DB + version: 3, + env: 'development', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('5m') + .setIssuedAt() + .sign(new TextEncoder().encode(NEXTAUTH_SECRET)); + + assertNotEmpty('JWT generated', JWT); + + // Auth check (GET /api/admin/storage should return 410, not 401/403) + const { status: authCode } = await jwtGet('/api/admin/storage'); + assertEq('Auth check returns 410 (not 401/403)', 410, authCode); + + // Store via user-facing route + const { json: storeJwt } = await jwtPost('/api/admin/google-credentials', { googleCredentials: DUMMY_CREDS }); + assertEq('POST /api/admin/google-credentials returns googleConnected=true', true, storeJwt?.googleConnected); + + // Verify via platform status + const { json: statusJwt } = await internalGet(`/api/platform/status?userId=${USER_ID}`); + assertEq('Status confirms googleConnected=true after JWT store', true, statusJwt?.googleConnected); + + // Clear via user-facing route + const { json: clearJwt } = await jwtDelete('/api/admin/google-credentials'); + assertEq('DELETE /api/admin/google-credentials returns googleConnected=false', false, clearJwt?.googleConnected); + + // Verify cleared + const { json: statusJwt2 } = await internalGet(`/api/platform/status?userId=${USER_ID}`); + assertEq('Status confirms googleConnected=false after JWT clear', false, statusJwt2?.googleConnected); +} else { + bold('5. User-facing routes (JWT auth) — SKIPPED (no DB)'); +} + +// --------------------------------------------------------------------------- +// 6. Validation: bad input rejected +// --------------------------------------------------------------------------- + +bold('6. Validation: bad input rejected'); + +// Missing googleCredentials field (internal API) +const { status: badCode1 } = await internalPost('/api/platform/google-credentials', { + userId: USER_ID, wrong: 'field', +}); +assertEq('Rejects missing googleCredentials (400)', 400, badCode1); + +// Invalid envelope schema (internal API) +const { status: badCode2 } = await internalPost('/api/platform/google-credentials', { + userId: USER_ID, + googleCredentials: { clientSecret: { bad: 'data' }, credentials: { bad: 'data' } }, +}); +assertEq('Rejects invalid envelope schema (400)', 400, badCode2); + +// Unauthenticated request (user-facing route, no token) +const unauthRes = await fetch(`${WORKER_URL}/api/admin/google-credentials`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ googleCredentials: DUMMY_CREDS }), +}); +assertEq('Rejects unauthenticated request (401)', 401, unauthRes.status); + +// No internal API key (platform route) +const noKeyRes = await fetch(`${WORKER_URL}/api/platform/google-credentials`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: USER_ID, googleCredentials: DUMMY_CREDS }), +}); +assertEq('Rejects request without internal API key (403)', 403, noKeyRes.status); + +// Bad JWT (user-facing route) +if (dbConnected) { + const badJwtRes = await fetch(`${WORKER_URL}/api/admin/google-credentials`, { + method: 'POST', + headers: { authorization: 'Bearer not.a.real.token', 'content-type': 'application/json' }, + body: JSON.stringify({ googleCredentials: DUMMY_CREDS }), + }); + assertEq('Rejects bad JWT (401)', 401, badJwtRes.status); +} + +// --------------------------------------------------------------------------- +// 7. Idempotency (via internal API) +// --------------------------------------------------------------------------- + +bold('7. Idempotency'); + +await internalPost('/api/platform/google-credentials', { userId: USER_ID, googleCredentials: DUMMY_CREDS }); +const { json: secondStore } = await internalPost('/api/platform/google-credentials', { + userId: USER_ID, googleCredentials: DUMMY_CREDS, +}); +assertEq('Double store still returns googleConnected=true', true, secondStore?.googleConnected); + +await internalDelete(`/api/platform/google-credentials?userId=${USER_ID}`); +const { json: secondClear } = await internalDelete(`/api/platform/google-credentials?userId=${USER_ID}`); +assertEq('Double clear still returns googleConnected=false', false, secondClear?.googleConnected); + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +bold('Cleanup'); +await internalPost('/api/platform/destroy', { userId: USER_ID }).catch(() => {}); +green('Test instance destroyed'); + +for (const fn of cleanupFns) { + try { fn(); } catch {} +} +green('Test user removed from DB'); + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log(''); +bold(`Results: ${pass} passed, ${fail} failed`); +if (fail > 0) { + red('Failed tests:'); + errors.forEach(e => red(` - ${e}`)); + process.exit(1); +} else { + green('All tests passed!'); +} From ec127939df7b5938b5df6a6d7115c7d519055f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 10 Mar 2026 14:37:49 +0100 Subject: [PATCH 11/87] feat(kiloclaw): add gcloud to Docker image, own OAuth flow, and e2e test - Add gcloud CLI to Dockerfile (required by `gws auth setup`) - Replace gws auth login with our own Node.js OAuth flow to avoid gws's encrypted credential store (keyring issues in Docker) - Add google-setup-e2e.mjs: end-to-end test that provisions a user, builds the Docker image, runs the interactive OAuth flow, and verifies googleConnected=true --- kiloclaw/google-setup/Dockerfile | 16 ++ kiloclaw/google-setup/setup.mjs | 149 +++++++++++++----- kiloclaw/test/google-setup-e2e.mjs | 243 +++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 43 deletions(-) create mode 100644 kiloclaw/test/google-setup-e2e.mjs diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index c98a9c12c..5497ab2d8 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -1,5 +1,21 @@ FROM node:22-slim +# Install dependencies for gcloud CLI +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + python3 \ + apt-transport-https \ + ca-certificates \ + gnupg \ + && rm -rf /var/lib/apt/lists/* + +# Install gcloud CLI +RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ + > /etc/apt/sources.list.d/google-cloud-sdk.list \ + && apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \ + && rm -rf /var/lib/apt/lists/* + # Install gws CLI (Google Workspace CLI) # https://github.com/googleworkspace/cli RUN npm install -g @googleworkspace/cli diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 0e819ae07..c895dedcf 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -7,19 +7,19 @@ * 1. Validates the user's KiloCode API key against the kiloclaw worker * 2. Fetches the worker's RSA public key for credential encryption * 3. Runs `gws auth setup` to create an OAuth client in the user's Google Cloud project - * 4. Runs `gws auth login` to complete the OAuth flow via localhost:8080 callback - * 5. Reads the resulting credentials from ~/.config/gws/ - * 6. Encrypts them with the worker's public key - * 7. POSTs the encrypted bundle to the kiloclaw worker (user-facing JWT auth) + * 4. Runs our own OAuth flow (localhost callback) to get a refresh token + * 5. Encrypts the client_secret + credentials with the worker's public key + * 6. POSTs the encrypted bundle to the kiloclaw worker (user-facing JWT auth) * * Usage: - * docker run -it -p 8080:8080 kilocode/google-setup --api-key=kilo_abc123 + * docker run -it --network host kilocode/google-setup --api-key=kilo_abc123 */ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import http from 'node:http'; // --------------------------------------------------------------------------- // CLI args @@ -29,25 +29,23 @@ const args = process.argv.slice(2); const apiKeyArg = args.find(a => a.startsWith('--api-key=')); const apiKey = apiKeyArg?.split('=')[1]; -// The kiloclaw worker URL (user-facing routes use Bearer JWT auth) const workerUrl = args.find(a => a.startsWith('--worker-url='))?.split('=')[1] ?? 'https://claw.kilo.ai'; if (!apiKey) { console.error( - 'Usage: docker run -it -p 8080:8080 kilocode/google-setup --api-key=' + 'Usage: docker run -it --network host kilocode/google-setup --api-key=' ); process.exit(1); } -/** Helper: Bearer auth headers for user-facing worker routes. */ const authHeaders = { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', }; // --------------------------------------------------------------------------- -// Hardcoded scopes +// Scopes // --------------------------------------------------------------------------- const SCOPES = [ @@ -58,7 +56,7 @@ const SCOPES = [ ]; // --------------------------------------------------------------------------- -// Step 1: Validate API key by calling a user-facing endpoint +// Step 1: Validate API key // --------------------------------------------------------------------------- console.log('Validating API key...'); @@ -69,12 +67,10 @@ if (!validateRes.ok) { process.exit(1); } -// Verify the API key is a valid JWT by calling a read-only authenticated endpoint const authCheckRes = await fetch(`${workerUrl}/api/admin/storage`, { headers: authHeaders, }); -// 401/403 = bad key. Any other response (including 410/500) means the key is valid. if (authCheckRes.status === 401 || authCheckRes.status === 403) { console.error('Invalid API key. Check your key and try again.'); process.exit(1); @@ -83,7 +79,7 @@ if (authCheckRes.status === 401 || authCheckRes.status === 403) { console.log('API key verified.'); // --------------------------------------------------------------------------- -// Step 2: Fetch public key for encryption (public endpoint, no auth needed) +// Step 2: Fetch public key for encryption // --------------------------------------------------------------------------- console.log('Fetching encryption public key...'); @@ -97,13 +93,15 @@ if (!pubKeyRes.ok) { const { publicKey: publicKeyPem } = await pubKeyRes.json(); // --------------------------------------------------------------------------- -// Step 3: Run gws auth setup +// Step 3: Run gws auth setup (project + OAuth client only, no login) // --------------------------------------------------------------------------- console.log('Setting up Google OAuth client...'); console.log('Follow the prompts to create an OAuth client in your Google Cloud project.\n'); try { + // Don't pass --login: we handle OAuth ourselves to avoid gws's encrypted credential store. + // When prompted "Run gws auth login now? [Y/n]", type 'n'. execFileSync('gws', ['auth', 'setup'], { stdio: 'inherit' }); } catch { console.error('\nFailed to set up OAuth client. Please try again.'); @@ -111,53 +109,118 @@ try { } // --------------------------------------------------------------------------- -// Step 4: Run gws auth login +// Step 4: Read client_secret.json and run our own OAuth flow // --------------------------------------------------------------------------- -console.log('\nStarting OAuth login flow...'); -console.log('Your browser will open for Google sign-in.\n'); +const gwsDir = path.join(process.env.HOME ?? '/root', '.config', 'gws'); +const clientSecretPath = path.join(gwsDir, 'client_secret.json'); -try { - execFileSync('gws', ['auth', 'login', '--scopes', SCOPES.join(',')], { - stdio: 'inherit', - }); -} catch { - console.error('\nOAuth login failed. Please try again.'); +if (!fs.existsSync(clientSecretPath)) { + console.error('client_secret.json not found. The setup step may not have completed.'); process.exit(1); } -// --------------------------------------------------------------------------- -// Step 5: Read credentials from ~/.config/gws/ -// --------------------------------------------------------------------------- +const clientSecretJson = fs.readFileSync(clientSecretPath, 'utf8'); +const clientConfig = JSON.parse(clientSecretJson); +const { client_id, client_secret } = clientConfig.installed || clientConfig.web || {}; -const gwsDir = path.join(process.env.HOME ?? '/root', '.config', 'gws'); -const clientSecretPath = path.join(gwsDir, 'client_secret.json'); -const credentialsPath = path.join(gwsDir, 'credentials.json'); +if (!client_id || !client_secret) { + console.error('Invalid client_secret.json format.'); + process.exit(1); +} + +// Start a local HTTP server for the OAuth callback +const { code, redirectUri } = await new Promise((resolve, reject) => { + let callbackPort; + + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('

Authorization failed

You can close this tab.

'); + server.close(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + if (code) { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab.

'); + server.close(); + resolve({ code, redirectUri: `http://localhost:${callbackPort}` }); + } + }); -if (!fs.existsSync(clientSecretPath) || !fs.existsSync(credentialsPath)) { - console.error('Credential files not found. The OAuth flow may not have completed.'); + server.listen(0, () => { + callbackPort = server.address().port; + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', client_id); + authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}`); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', SCOPES.join(' ')); + authUrl.searchParams.set('access_type', 'offline'); + authUrl.searchParams.set('prompt', 'consent'); + + console.log('\nOpen this URL in your browser to authorize:\n'); + console.log(` ${authUrl.toString()}\n`); + console.log(`Waiting for OAuth callback on port ${callbackPort}...`); + }); + + const timer = setTimeout(() => { + server.close(); + reject(new Error('OAuth flow timed out (5 minutes)')); + }, 5 * 60 * 1000); + timer.unref(); +}); + +// Exchange authorization code for tokens +console.log('Exchanging authorization code for tokens...'); + +const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id, + client_secret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), +}); + +if (!tokenRes.ok) { + const err = await tokenRes.text(); + console.error('Token exchange failed:', err); process.exit(1); } -const clientSecret = fs.readFileSync(clientSecretPath, 'utf8'); -const credentials = fs.readFileSync(credentialsPath, 'utf8'); +const tokens = await tokenRes.json(); +// Build a credentials object similar to what gws stores +const credentialsObj = { + ...tokens, + client_id, + client_secret, + scopes: SCOPES, +}; +const credentialsJson = JSON.stringify(credentialsObj); + +console.log('OAuth tokens obtained.'); // --------------------------------------------------------------------------- -// Step 6: Encrypt credentials +// Step 5: Encrypt credentials with worker's public key // --------------------------------------------------------------------------- -/** - * Encrypt a value using RSA+AES-256-GCM envelope encryption. - * Matches the EncryptedEnvelope schema used by kiloclaw. - */ function encryptEnvelope(plaintext, pemKey) { const dek = crypto.randomBytes(32); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); let encrypted = cipher.update(plaintext, 'utf8'); encrypted = Buffer.concat([encrypted, cipher.final()]); - const authTag = cipher.getAuthTag(); - const encryptedData = Buffer.concat([iv, encrypted, authTag]); + const tag = cipher.getAuthTag(); + const encryptedData = Buffer.concat([iv, encrypted, tag]); const encryptedDEK = crypto.publicEncrypt( { key: pemKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, dek @@ -173,12 +236,12 @@ function encryptEnvelope(plaintext, pemKey) { console.log('Encrypting credentials...'); const encryptedBundle = { - clientSecret: encryptEnvelope(clientSecret, publicKeyPem), - credentials: encryptEnvelope(credentials, publicKeyPem), + clientSecret: encryptEnvelope(clientSecretJson, publicKeyPem), + credentials: encryptEnvelope(credentialsJson, publicKeyPem), }; // --------------------------------------------------------------------------- -// Step 7: POST to worker (user-facing, JWT auth resolves userId automatically) +// Step 6: POST to worker // --------------------------------------------------------------------------- console.log('Sending credentials to your kiloclaw instance...'); diff --git a/kiloclaw/test/google-setup-e2e.mjs b/kiloclaw/test/google-setup-e2e.mjs new file mode 100644 index 000000000..547073f44 --- /dev/null +++ b/kiloclaw/test/google-setup-e2e.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +/** + * End-to-end test for the Google Setup Docker flow. + * + * Requires: + * 1. Local Postgres running (postgres://postgres:postgres@localhost:5432/postgres) + * 2. kiloclaw worker running locally (pnpm start → localhost:8795) + * 3. Docker running + * + * The test: + * 1. Creates a temporary user in the DB + * 2. Provisions a kiloclaw instance for that user + * 3. Builds the google-setup Docker image + * 4. Runs it interactively (you complete the OAuth flow in your browser) + * 5. Verifies googleConnected=true after the container exits + * 6. Cleans up + * + * Usage: + * node kiloclaw/test/google-setup-e2e.mjs + */ + +import { SignJWT } from 'jose'; +import { execSync, spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const WORKER_URL = process.env.WORKER_URL ?? 'http://localhost:8795'; +const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? 'dev-internal-secret'; +const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'dev-secret-change-me'; +const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/postgres'; +const USER_ID = `test-google-setup-${Date.now()}`; +const DOCKER_IMAGE = 'kilocode/google-setup'; +const DOCKER_CONTEXT = path.resolve(__dirname, '../google-setup'); + +// We use --network host so the gws CLI's random OAuth callback port is reachable +// from the browser. This also means localhost in the container reaches the host, +// so we don't need host.docker.internal. +const DOCKER_WORKER_URL = WORKER_URL; + +const cleanupFns = []; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function green(msg) { console.log(`\x1b[32m ✓ ${msg}\x1b[0m`); } +function red(msg) { console.log(`\x1b[31m ✗ ${msg}\x1b[0m`); } +function bold(msg) { console.log(`\n\x1b[1m${msg}\x1b[0m`); } + +function sql(query) { + return execSync(`psql "${DATABASE_URL}" -tAc "${query.replace(/"/g, '\\"')}"`, { + encoding: 'utf8', + timeout: 5000, + }).trim(); +} + +async function internalGet(urlPath) { + const res = await fetch(`${WORKER_URL}${urlPath}`, { + headers: { 'x-internal-api-key': INTERNAL_SECRET }, + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +async function internalPost(urlPath, body) { + const res = await fetch(`${WORKER_URL}${urlPath}`, { + method: 'POST', + headers: { 'x-internal-api-key': INTERNAL_SECRET, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +async function internalDelete(urlPath) { + const res = await fetch(`${WORKER_URL}${urlPath}`, { + method: 'DELETE', + headers: { 'x-internal-api-key': INTERNAL_SECRET }, + }); + return { status: res.status, json: res.ok ? await res.json() : null }; +} + +function cleanup() { + bold('Cleanup'); + for (const fn of cleanupFns) { + try { fn(); } catch {} + } + green('Done'); +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +bold('Preflight'); + +try { + const health = await fetch(`${WORKER_URL}/health`); + if (!health.ok) throw new Error(`status ${health.status}`); + green('Worker reachable at ' + WORKER_URL); +} catch (e) { + red(`Worker not reachable at ${WORKER_URL}: ${e.message}`); + console.log(' Is it running? (cd kiloclaw && pnpm start)'); + process.exit(1); +} + +try { + sql('SELECT 1'); + green('DB reachable'); +} catch { + red('DB not reachable at ' + DATABASE_URL); + process.exit(1); +} + +try { + execSync('docker info', { stdio: 'ignore' }); + green('Docker is running'); +} catch { + red('Docker is not running'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Setup: create user, provision instance, generate JWT +// --------------------------------------------------------------------------- + +bold('Setup'); + +sql(`INSERT INTO kilocode_users (id, google_user_email, google_user_name, google_user_image_url, stripe_customer_id, api_token_pepper) VALUES ('${USER_ID}', '${USER_ID}@test.local', 'Test User', '', 'cus_test_${USER_ID}', NULL) ON CONFLICT (id) DO NOTHING`); +cleanupFns.push(() => { try { sql(`DELETE FROM kilocode_users WHERE id = '${USER_ID}'`); } catch {} }); +green('Test user created (id=' + USER_ID + ')'); + +await internalPost('/api/platform/provision', { userId: USER_ID }); +cleanupFns.push(() => { internalPost('/api/platform/destroy', { userId: USER_ID }).catch(() => {}); }); +green('Instance provisioned'); + +// Verify googleConnected is false before we start +const { json: statusBefore } = await internalGet(`/api/platform/status?userId=${USER_ID}`); +if (statusBefore?.googleConnected !== false) { + red('Expected googleConnected=false before test, got: ' + statusBefore?.googleConnected); + cleanup(); + process.exit(1); +} +green('googleConnected=false (baseline)'); + +const jwt = await new SignJWT({ + kiloUserId: USER_ID, + apiTokenPepper: null, + version: 3, + env: 'development', +}) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('30m') + .setIssuedAt() + .sign(new TextEncoder().encode(NEXTAUTH_SECRET)); +green('JWT generated'); + +// --------------------------------------------------------------------------- +// Build Docker image +// --------------------------------------------------------------------------- + +bold('Building Docker image'); + +try { + execSync(`docker build -t ${DOCKER_IMAGE} ${DOCKER_CONTEXT}`, { stdio: 'inherit' }); + green('Image built'); +} catch { + red('Docker build failed'); + cleanup(); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Run Docker container (interactive — user completes OAuth flow) +// --------------------------------------------------------------------------- + +bold('Running Google Setup'); +console.log(' Complete the OAuth flow in your browser.'); +console.log(' The container will exit when done.\n'); + +const dockerArgs = [ + 'run', '--rm', '-it', + '--network', 'host', + DOCKER_IMAGE, + `--api-key=${jwt}`, + `--worker-url=${DOCKER_WORKER_URL}`, +]; + +const exitCode = await new Promise((resolve) => { + const child = spawn('docker', dockerArgs, { stdio: 'inherit' }); + child.on('close', resolve); + child.on('error', (err) => { + red('Failed to start Docker: ' + err.message); + resolve(1); + }); +}); + +if (exitCode !== 0) { + red(`Docker container exited with code ${exitCode}`); + cleanup(); + process.exit(1); +} + +green('Container exited successfully'); + +// --------------------------------------------------------------------------- +// Verify: googleConnected should now be true +// --------------------------------------------------------------------------- + +bold('Verification'); + +const { json: statusAfter } = await internalGet(`/api/platform/status?userId=${USER_ID}`); + +if (statusAfter?.googleConnected === true) { + green('googleConnected=true — Google credentials stored successfully!'); +} else { + red('Expected googleConnected=true, got: ' + statusAfter?.googleConnected); + cleanup(); + process.exit(1); +} + +// Also verify via debug-status +const { json: debugAfter } = await internalGet(`/api/platform/debug-status?userId=${USER_ID}`); +if (debugAfter?.googleConnected === true) { + green('debug-status confirms googleConnected=true'); +} else { + red('debug-status shows googleConnected=' + debugAfter?.googleConnected); +} + +// --------------------------------------------------------------------------- +// Optional: clear credentials to leave clean state +// --------------------------------------------------------------------------- + +await internalDelete(`/api/platform/google-credentials?userId=${USER_ID}`); +green('Credentials cleared (clean state)'); + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +cleanup(); + +bold('Result: All checks passed!'); From 06a1b00298d1117406d2335d095846407bc1c544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 10 Mar 2026 15:58:57 +0100 Subject: [PATCH 12/87] feat(claw): add Google Account section to Settings tab Show docker setup command with copy button when not connected, and a disconnect button when connected. Adds connectGoogle and disconnectGoogle mutations to useKiloClawMutations hook. --- src/app/(app)/claw/components/SettingsTab.tsx | 90 +++++++++++++++++++ src/hooks/useKiloClaw.ts | 6 ++ 2 files changed, 96 insertions(+) diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 21535cef2..a7fe63aeb 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -3,6 +3,8 @@ import { AlertCircle, AlertTriangle, + Check, + Copy, Hash, Package, RotateCcw, @@ -216,6 +218,90 @@ function ChannelSection({ ); } +const DOCKER_COMMAND = `docker run -it --network host kilocode/google-setup \\ + --api-key="YOUR_API_KEY"`; + +function GoogleAccountSection({ + connected, + mutations, +}: { + connected: boolean; + mutations: ClawMutations; +}) { + const [copied, setCopied] = useState(false); + const isDisconnecting = mutations.disconnectGoogle.isPending; + + function handleCopy() { + navigator.clipboard.writeText(DOCKER_COMMAND.replace(/\\\n\s+/g, ' ')); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+

Google Account

+

+ Connect your Google account to give your bot access to Gmail, Calendar, and Docs. +

+ +
+
+ + {connected ? 'Connected' : 'Not connected'} + +
+ + {!connected && ( +
+

+ Run this command to connect your Google account. Replace{' '} + YOUR_API_KEY with your API key from the{' '} + + Profile page + + . +

+
+
+                {DOCKER_COMMAND}
+              
+ +
+
+ )} + + {connected && ( + + )} +
+
+ ); +} + export function SettingsTab({ status, mutations, @@ -442,6 +528,10 @@ export function SettingsTab({ + + + + diff --git a/src/hooks/useKiloClaw.ts b/src/hooks/useKiloClaw.ts index 27c70dbd9..33c23d0c9 100644 --- a/src/hooks/useKiloClaw.ts +++ b/src/hooks/useKiloClaw.ts @@ -197,6 +197,12 @@ export function useKiloClawMutations() { }, }) ), + connectGoogle: useMutation( + trpc.kiloclaw.connectGoogle.mutationOptions({ onSuccess: invalidateStatus }) + ), + disconnectGoogle: useMutation( + trpc.kiloclaw.disconnectGoogle.mutationOptions({ onSuccess: invalidateStatus }) + ), }; } From a5949a41768139eccea55d723ae294cd57601b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 10 Mar 2026 16:00:55 +0100 Subject: [PATCH 13/87] feat(claw): auto-fill API key in Google setup command Add getGoogleSetupCommand tRPC query that generates the docker command with the user's API key pre-filled, so they can just copy and paste without visiting the Profile page. --- src/app/(app)/claw/components/SettingsTab.tsx | 24 +++++++++---------- src/routers/kiloclaw-router.ts | 7 ++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index a7fe63aeb..b96e94ef2 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -13,6 +13,7 @@ import { X, } from 'lucide-react'; import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { usePostHog } from 'posthog-js/react'; import { toast } from 'sonner'; import { useOpenRouterModels } from '@/app/api/openrouter/hooks'; @@ -20,6 +21,7 @@ import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombob import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types'; import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; import { useControllerVersion, useKiloClawConfig, useKiloClawMyPin } from '@/hooks/useKiloClaw'; +import { useTRPC } from '@/lib/trpc/utils'; import { useDefaultModelSelection } from '../hooks/useDefaultModelSelection'; import { Badge } from '@/components/ui/badge'; @@ -218,9 +220,6 @@ function ChannelSection({ ); } -const DOCKER_COMMAND = `docker run -it --network host kilocode/google-setup \\ - --api-key="YOUR_API_KEY"`; - function GoogleAccountSection({ connected, mutations, @@ -228,11 +227,17 @@ function GoogleAccountSection({ connected: boolean; mutations: ClawMutations; }) { + const trpc = useTRPC(); + const { data: setupData } = useQuery( + trpc.kiloclaw.getGoogleSetupCommand.queryOptions(undefined, { enabled: !connected }) + ); const [copied, setCopied] = useState(false); const isDisconnecting = mutations.disconnectGoogle.isPending; + const command = setupData?.command; function handleCopy() { - navigator.clipboard.writeText(DOCKER_COMMAND.replace(/\\\n\s+/g, ' ')); + if (!command) return; + navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); } @@ -251,19 +256,14 @@ function GoogleAccountSection({ - {!connected && ( + {!connected && command && (

- Run this command to connect your Google account. Replace{' '} - YOUR_API_KEY with your API key from the{' '} - - Profile page - - . + Run this command in your terminal to connect your Google account:

-                {DOCKER_COMMAND}
+                {command}
               
+ )}
{!connected && command && ( @@ -280,23 +296,6 @@ function GoogleAccountSection({
)} - - {connected && ( - - )} ); From 17fe88bafde865911e9a0f9f5e9b4d2a88d96b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 10 Mar 2026 21:25:25 +0100 Subject: [PATCH 25/87] fix(kiloclaw): harden Google credentials feature from code review - Fix API key parsing truncating keys containing '=' (use substring) - Scope getGoogleSetupCommand token to 30 days instead of default 5 years - Add GOOGLE_CLIENT_SECRET_JSON/GOOGLE_CREDENTIALS_JSON to SENSITIVE_KEYS for defense-in-depth classification - Cache derived public key PEM to avoid re-deriving on every request - Fix auth check in setup.mjs to use google-credentials endpoint - Handle non-OAuth requests in callback server (return 404) - Remove unused 'open' dependency from google-setup package.json - Remove misleading EXPOSE 8080 from google-setup Dockerfile - Document expect script's gws CLI version dependency - Update integration test to match new auth check endpoint --- kiloclaw/google-setup/Dockerfile | 2 -- kiloclaw/google-setup/package.json | 3 --- kiloclaw/google-setup/setup.mjs | 21 +++++++++++++++---- kiloclaw/src/gateway/env.test.ts | 15 +++++++++++++ kiloclaw/src/gateway/env.ts | 2 ++ kiloclaw/src/routes/public.ts | 13 ++++++++++++ .../test/google-credentials-integration.mjs | 7 ++++--- src/routers/kiloclaw-router.ts | 5 ++++- 8 files changed, 55 insertions(+), 13 deletions(-) diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index fe295e321..2ebdb6200 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -26,6 +26,4 @@ COPY package.json ./ RUN npm install --production COPY setup.mjs ./ -EXPOSE 8080 - ENTRYPOINT ["node", "setup.mjs"] diff --git a/kiloclaw/google-setup/package.json b/kiloclaw/google-setup/package.json index d911ee670..b1a287dbe 100644 --- a/kiloclaw/google-setup/package.json +++ b/kiloclaw/google-setup/package.json @@ -5,8 +5,5 @@ "type": "module", "scripts": { "start": "node setup.mjs" - }, - "dependencies": { - "open": "^10.0.0" } } diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index b846bc8b0..89ee4e511 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -27,10 +27,12 @@ import http from 'node:http'; const args = process.argv.slice(2); const apiKeyArg = args.find(a => a.startsWith('--api-key=')); -const apiKey = apiKeyArg?.split('=')[1]; +const apiKey = apiKeyArg?.substring(apiKeyArg.indexOf('=') + 1); -const workerUrl = - args.find(a => a.startsWith('--worker-url='))?.split('=')[1] ?? 'https://claw.kilo.ai'; +const workerUrlArg = args.find(a => a.startsWith('--worker-url=')); +const workerUrl = workerUrlArg + ? workerUrlArg.substring(workerUrlArg.indexOf('=') + 1) + : 'https://claw.kilo.ai'; if (!apiKey) { console.error( @@ -67,7 +69,10 @@ if (!validateRes.ok) { process.exit(1); } -const authCheckRes = await fetch(`${workerUrl}/api/admin/storage`, { +// Validate auth by hitting an admin endpoint — the JWT middleware runs before any handler. +// GET /api/admin/google-credentials is not defined, so it returns 404/405 after auth passes, +// or 401/403 if the key is invalid. +const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { headers: authHeaders, }); @@ -101,6 +106,9 @@ console.log('Follow the prompts to create an OAuth client in your Google Cloud p // Use `expect` to wrap `gws auth setup` in a real PTY so all interactive prompts // work normally, while auto-answering "n" to the final "Run gws auth login now?" prompt. +// The "Y/n" pattern matches gws CLI's confirmation prompt. If gws changes this prompt +// text in a future version, this interaction will need updating. +// Tested with @googleworkspace/cli (gws) as of 2026-03. // Write expect script to a temp file to avoid JS→shell→Tcl escaping issues. const expectScriptPath = '/tmp/gws-setup.exp'; fs.writeFileSync(expectScriptPath, [ @@ -171,7 +179,12 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { res.end('

Authorization successful!

You can close this tab.

'); server.close(); resolve({ code, redirectUri: `http://localhost:${callbackPort}` }); + return; } + + // Ignore non-OAuth requests (e.g. browser favicon) + res.writeHead(404); + res.end(); }); server.listen(0, () => { diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index 62b790802..d2b48c07c 100644 --- a/kiloclaw/src/gateway/env.test.ts +++ b/kiloclaw/src/gateway/env.test.ts @@ -312,6 +312,21 @@ describe('buildEnvVars', () => { expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); }); + it('classifies Google credential env var names as sensitive even when provided as plaintext', async () => { + const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey }); + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + envVars: { + GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"leak"}', + GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"leak"}', + }, + }); + + expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBe('{"client_id":"leak"}'); + expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBe('{"refresh_token":"leak"}'); + expect(result.env.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); + expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + }); + it('skips Google credential decryption when no private key configured', async () => { const env = createMockEnv(); // no AGENT_ENV_VARS_PRIVATE_KEY const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index 5d98923c9..a82bf71c6 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -46,6 +46,8 @@ export type EnvVarsBuild = { const SENSITIVE_KEYS = new Set([ 'KILOCODE_API_KEY', 'OPENCLAW_GATEWAY_TOKEN', + 'GOOGLE_CLIENT_SECRET_JSON', + 'GOOGLE_CREDENTIALS_JSON', ...ALL_SECRET_ENV_VARS, ]); diff --git a/kiloclaw/src/routes/public.ts b/kiloclaw/src/routes/public.ts index 996404dc8..1c2cd1925 100644 --- a/kiloclaw/src/routes/public.ts +++ b/kiloclaw/src/routes/public.ts @@ -2,6 +2,10 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types'; import { OPENCLAW_PORT } from '../config'; +// Cache the derived public key PEM to avoid re-deriving on every request. +let cachedPublicKeyPem: string | null = null; +let cachedForPrivateKey: string | null = null; + /** * Public routes - no authentication required * @@ -27,9 +31,18 @@ publicRoutes.get('/public-key', async c => { } try { + // Return cached public key if derived from the same private key + if (cachedPublicKeyPem && cachedForPrivateKey === privateKeyPem) { + return c.json({ publicKey: cachedPublicKeyPem }); + } + const { createPublicKey } = await import('crypto'); const publicKey = createPublicKey({ key: privateKeyPem, format: 'pem' }); const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + + cachedPublicKeyPem = publicKeyPem; + cachedForPrivateKey = privateKeyPem; + return c.json({ publicKey: publicKeyPem }); } catch (err) { console.error('[public] Failed to derive public key:', err); diff --git a/kiloclaw/test/google-credentials-integration.mjs b/kiloclaw/test/google-credentials-integration.mjs index afa885f04..d131c0076 100644 --- a/kiloclaw/test/google-credentials-integration.mjs +++ b/kiloclaw/test/google-credentials-integration.mjs @@ -260,9 +260,10 @@ if (dbConnected) { assertNotEmpty('JWT generated', JWT); - // Auth check (GET /api/admin/storage should return 410, not 401/403) - const { status: authCode } = await jwtGet('/api/admin/storage'); - assertEq('Auth check returns 410 (not 401/403)', 410, authCode); + // Auth check — GET /api/admin/google-credentials isn't a defined route, so it returns + // 404 or 405 after auth passes. The key point: it's not 401/403. + const { status: authCode } = await jwtGet('/api/admin/google-credentials'); + assertEq('Auth check returns non-401/403 (auth passed)', true, authCode !== 401 && authCode !== 403); // Store via user-facing route const { json: storeJwt } = await jwtPost('/api/admin/google-credentials', { googleCredentials: DUMMY_CREDS }); diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 961a80f2e..75a7b7780 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -463,7 +463,10 @@ export const kiloclawRouter = createTRPCRouter({ }), getGoogleSetupCommand: baseProcedure.query(({ ctx }) => { - const apiKey = generateApiToken(ctx.user); + // Short-lived token — the user should run the setup command promptly. + const apiKey = generateApiToken(ctx.user, undefined, { + expiresIn: TOKEN_EXPIRY.thirtyDays, + }); const isDev = process.env.NODE_ENV === 'development'; const workerFlag = isDev ? ' --worker-url=http://localhost:8795' : ''; return { From 7ee4c5e73656f9a1e91f169898d489e554ea49e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 12:48:32 +0100 Subject: [PATCH 26/87] fix(kiloclaw): address second-pass review findings - Clear OAuth timeout timer on success/error paths in setup.mjs - Reduce setup command token expiry from 30 days to 1 hour (add TOKEN_EXPIRY.oneHour constant) - Replace deprecated npm install --production with --omit=dev - Guard installGwsSkills with marker file to skip on subsequent starts - Add GET /api/admin/google-credentials endpoint for proper auth check and connection status; update setup.mjs and integration test to use it --- .../controller/src/gws-credentials.test.ts | 32 ++++++++++++++++++- kiloclaw/controller/src/gws-credentials.ts | 15 +++++++++ kiloclaw/google-setup/Dockerfile | 2 +- kiloclaw/google-setup/setup.mjs | 9 ++++-- kiloclaw/src/routes/api.ts | 12 +++++++ .../test/google-credentials-integration.mjs | 8 ++--- src/lib/tokens.ts | 2 ++ src/routers/kiloclaw-router.ts | 3 +- 8 files changed, 73 insertions(+), 10 deletions(-) diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts index 147d2e33b..5732242f4 100644 --- a/kiloclaw/controller/src/gws-credentials.test.ts +++ b/kiloclaw/controller/src/gws-credentials.test.ts @@ -2,10 +2,27 @@ import { describe, it, expect, vi } from 'vitest'; import path from 'node:path'; import { writeGwsCredentials, installGwsSkills, type GwsCredentialsDeps } from './gws-credentials'; +import fs from 'node:fs'; + vi.mock('node:child_process', () => ({ exec: vi.fn((_cmd: string, cb: (err: Error | null) => void) => cb(null)), })); +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + default: { + ...actual, + // Default: marker file not found (ENOENT) — so installGwsSkills proceeds + accessSync: vi.fn(() => { + throw new Error('ENOENT'); + }), + writeFileSync: vi.fn(), + }, + }; +}); + function mockDeps() { return { mkdirSync: vi.fn(), @@ -124,9 +141,12 @@ describe('writeGwsCredentials', () => { }); describe('installGwsSkills', () => { - it('runs npx skills add command', async () => { + it('runs npx skills add command when marker file is absent', async () => { const { exec } = await import('node:child_process'); (exec as unknown as ReturnType).mockClear(); + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); installGwsSkills(); @@ -135,4 +155,14 @@ describe('installGwsSkills', () => { expect.any(Function) ); }); + + it('skips install when marker file exists', async () => { + const { exec } = await import('node:child_process'); + (exec as unknown as ReturnType).mockClear(); + vi.mocked(fs.accessSync).mockImplementation(() => undefined); + + installGwsSkills(); + + expect(exec).not.toHaveBeenCalled(); + }); }); diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts index 3e5b0be0d..3dcf2da70 100644 --- a/kiloclaw/controller/src/gws-credentials.ts +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -73,8 +73,18 @@ export function writeGwsCredentials( /** * Install gws agent skills for OpenClaw via the `skills` CLI. * Runs in the background — logs outcome but never blocks startup. + * Skips if skills are already installed (marker file check). */ export function installGwsSkills(): void { + const markerFile = path.join(GWS_CONFIG_DIR, '.skills-installed'); + try { + fs.accessSync(markerFile); + console.log('[gws] Agent skills already installed, skipping'); + return; + } catch { + // Marker not found — proceed with install + } + const cmd = 'npx -y skills add https://github.com/googleworkspace/cli --yes --global'; console.log('[gws] Installing agent skills in background...'); exec(cmd, (error, _stdout, stderr) => { @@ -82,6 +92,11 @@ export function installGwsSkills(): void { console.error('[gws] Failed to install agent skills:', stderr || error.message); } else { console.log('[gws] Agent skills installed successfully'); + try { + fs.writeFileSync(markerFile, new Date().toISOString(), { mode: 0o600 }); + } catch { + // Non-fatal — will retry next startup + } } }); } diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 2ebdb6200..10dbff74e 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -23,7 +23,7 @@ RUN npm install -g @googleworkspace/cli WORKDIR /app COPY package.json ./ -RUN npm install --production +RUN npm install --omit=dev COPY setup.mjs ./ ENTRYPOINT ["node", "setup.mjs"] diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 89ee4e511..0bc1bf2c3 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -69,8 +69,7 @@ if (!validateRes.ok) { process.exit(1); } -// Validate auth by hitting an admin endpoint — the JWT middleware runs before any handler. -// GET /api/admin/google-credentials is not defined, so it returns 404/405 after auth passes, +// Validate auth by checking Google credentials status — returns 200 if auth passes, // or 401/403 if the key is invalid. const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { headers: authHeaders, @@ -167,6 +166,7 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { const error = url.searchParams.get('error'); if (error) { + clearTimeout(timer); res.writeHead(200, { 'content-type': 'text/html' }); res.end('

Authorization failed

You can close this tab.

'); server.close(); @@ -175,6 +175,7 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { } if (code) { + clearTimeout(timer); res.writeHead(200, { 'content-type': 'text/html' }); res.end('

Authorization successful!

You can close this tab.

'); server.close(); @@ -187,6 +188,8 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { res.end(); }); + let timer; + server.listen(0, () => { callbackPort = server.address().port; const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); @@ -202,7 +205,7 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { console.log(`Waiting for OAuth callback on port ${callbackPort}...`); }); - const timer = setTimeout(() => { + timer = setTimeout(() => { server.close(); reject(new Error('OAuth flow timed out (5 minutes)')); }, 5 * 60 * 1000); diff --git a/kiloclaw/src/routes/api.ts b/kiloclaw/src/routes/api.ts index 5762ab92f..912c92274 100644 --- a/kiloclaw/src/routes/api.ts +++ b/kiloclaw/src/routes/api.ts @@ -63,6 +63,18 @@ adminApi.post('/gateway/restart', async c => { } }); +// GET /api/admin/google-credentials - Check Google connection status +adminApi.get('/google-credentials', async c => { + const stub = resolveStub(c); + try { + const status = await stub.getStatus(); + return c.json({ googleConnected: status.googleConnected ?? false }, 200); + } catch (err) { + console.error('[api] google-credentials status failed:', err); + return c.json({ error: 'Failed to check Google credentials status' }, 500); + } +}); + // POST /api/admin/google-credentials - Store encrypted Google credentials adminApi.post('/google-credentials', async c => { const stub = resolveStub(c); diff --git a/kiloclaw/test/google-credentials-integration.mjs b/kiloclaw/test/google-credentials-integration.mjs index d131c0076..0c51d50be 100644 --- a/kiloclaw/test/google-credentials-integration.mjs +++ b/kiloclaw/test/google-credentials-integration.mjs @@ -260,10 +260,10 @@ if (dbConnected) { assertNotEmpty('JWT generated', JWT); - // Auth check — GET /api/admin/google-credentials isn't a defined route, so it returns - // 404 or 405 after auth passes. The key point: it's not 401/403. - const { status: authCode } = await jwtGet('/api/admin/google-credentials'); - assertEq('Auth check returns non-401/403 (auth passed)', true, authCode !== 401 && authCode !== 403); + // Auth check — GET /api/admin/google-credentials returns 200 with googleConnected status + const { status: authCode, json: authJson } = await jwtGet('/api/admin/google-credentials'); + assertEq('Auth check returns 200', 200, authCode); + assertEq('Auth check returns googleConnected field', false, authJson?.googleConnected); // Store via user-facing route const { json: storeJwt } = await jwtPost('/api/admin/google-credentials', { googleCredentials: DUMMY_CREDS }); diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts index 9b9ec9520..405589d75 100644 --- a/src/lib/tokens.ts +++ b/src/lib/tokens.ts @@ -21,11 +21,13 @@ export type JWTTokenExtraPayload = { const FIVE_YEARS_IN_SECONDS = 5 * 365 * 24 * 60 * 60; const THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60; +const ONE_HOUR_IN_SECONDS = 60 * 60; const FIVE_MINUTES_IN_SECONDS = 5 * 60; export const TOKEN_EXPIRY = { default: FIVE_YEARS_IN_SECONDS, thirtyDays: THIRTY_DAYS_IN_SECONDS, + oneHour: ONE_HOUR_IN_SECONDS, fiveMinutes: FIVE_MINUTES_IN_SECONDS, } as const; diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 75a7b7780..28b5de5c4 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -464,8 +464,9 @@ export const kiloclawRouter = createTRPCRouter({ getGoogleSetupCommand: baseProcedure.query(({ ctx }) => { // Short-lived token — the user should run the setup command promptly. + // Regenerated on each page load, so 1 hour is sufficient. const apiKey = generateApiToken(ctx.user, undefined, { - expiresIn: TOKEN_EXPIRY.thirtyDays, + expiresIn: TOKEN_EXPIRY.oneHour, }); const isDev = process.env.NODE_ENV === 'development'; const workerFlag = isDev ? ' --worker-url=http://localhost:8795' : ''; From d97e2d6a9b308a2af7a92b5b799bfd5b16215495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 12:58:05 +0100 Subject: [PATCH 27/87] fix(kiloclaw): address third-pass review findings - Reset googleCredentials in clearDestroyedState() to prevent stale credential data between destroy and next loadState() - Validate public key PEM format before using it in encryption - Add server error handler for OAuth callback port bind failures - Remove no-op npm install step from google-setup Dockerfile - Pin @googleworkspace/cli@0.11.1 in both Dockerfiles for reproducibility --- kiloclaw/Dockerfile | 2 +- kiloclaw/google-setup/Dockerfile | 4 +--- kiloclaw/google-setup/setup.mjs | 10 ++++++++++ kiloclaw/src/durable-objects/kiloclaw-instance.ts | 1 + 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/kiloclaw/Dockerfile b/kiloclaw/Dockerfile index 901c9c5f2..999fa9f85 100644 --- a/kiloclaw/Dockerfile +++ b/kiloclaw/Dockerfile @@ -55,7 +55,7 @@ RUN npm install -g mcporter@0.7.3 RUN npm install -g @steipete/summarize@0.11.1 # Install gws CLI (Google Workspace CLI) -RUN npm install -g @googleworkspace/cli +RUN npm install -g @googleworkspace/cli@0.11.1 # Install Go (available at runtime for users to `go install` additional tools) ENV GO_VERSION=1.26.0 diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 10dbff74e..75f36b17b 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -19,11 +19,9 @@ RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dea # Install gws CLI (Google Workspace CLI) # https://github.com/googleworkspace/cli -RUN npm install -g @googleworkspace/cli +RUN npm install -g @googleworkspace/cli@0.11.1 WORKDIR /app -COPY package.json ./ -RUN npm install --omit=dev COPY setup.mjs ./ ENTRYPOINT ["node", "setup.mjs"] diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 0bc1bf2c3..f19ef9d1e 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -96,6 +96,11 @@ if (!pubKeyRes.ok) { const { publicKey: publicKeyPem } = await pubKeyRes.json(); +if (!publicKeyPem || !publicKeyPem.includes('BEGIN PUBLIC KEY')) { + console.error('Invalid public key received from worker.'); + process.exit(1); +} + // --------------------------------------------------------------------------- // Step 3: Run gws auth setup (project + OAuth client only, no login) // --------------------------------------------------------------------------- @@ -190,6 +195,11 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { let timer; + server.on('error', (err) => { + clearTimeout(timer); + reject(new Error(`OAuth callback server failed: ${err.message}`)); + }); + server.listen(0, () => { callbackPort = server.address().port; const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index 823f5d27e..764bf4afd 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -2304,6 +2304,7 @@ export class KiloClawInstance extends DurableObject { this.kilocodeApiKeyExpiresAt = null; this.kilocodeDefaultModel = null; this.channels = null; + this.googleCredentials = null; this.provisionedAt = null; this.lastStartedAt = null; this.lastStoppedAt = null; From f1e5a4f7e48f3e3bf493c4d738a1be4cb41830fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 13:08:18 +0100 Subject: [PATCH 28/87] fix(kiloclaw): remove stale gws token cache on credential write The gws CLI encrypts token_cache.json with machine-specific keys, so a cache from the setup image or a previous container can't be decrypted, causing a noisy warning on every gws invocation. --- kiloclaw/controller/src/gws-credentials.test.ts | 16 ++++++++++++++++ kiloclaw/controller/src/gws-credentials.ts | 12 +++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts index 5732242f4..380fbcbc5 100644 --- a/kiloclaw/controller/src/gws-credentials.test.ts +++ b/kiloclaw/controller/src/gws-credentials.test.ts @@ -95,6 +95,7 @@ describe('writeGwsCredentials', () => { expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'client_secret.json')); expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); + expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'token_cache.json')); }); it('ignores missing files during cleanup', () => { @@ -109,6 +110,21 @@ describe('writeGwsCredentials', () => { expect(result).toBe(false); }); + it('removes stale token cache when writing fresh credentials', () => { + const deps = mockDeps(); + const dir = '/tmp/gws-test'; + writeGwsCredentials( + { + GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}', + GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}', + }, + dir, + deps + ); + + expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'token_cache.json')); + }); + it('calls installGwsSkills when credentials are written', async () => { const { exec } = await import('node:child_process'); const deps = mockDeps(); diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts index 3dcf2da70..993b6da6d 100644 --- a/kiloclaw/controller/src/gws-credentials.ts +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -15,6 +15,7 @@ import path from 'node:path'; const GWS_CONFIG_DIR = '/root/.config/gws'; const CLIENT_SECRET_FILE = 'client_secret.json'; const CREDENTIALS_FILE = 'credentials.json'; +const TOKEN_CACHE_FILE = 'token_cache.json'; export type GwsCredentialsDeps = { mkdirSync: (dir: string, opts: { recursive: boolean }) => void; @@ -42,7 +43,7 @@ export function writeGwsCredentials( if (!clientSecret || !credentials) { // Clean up stale credential files from a previous run (e.g. after disconnect) - for (const file of [CLIENT_SECRET_FILE, CREDENTIALS_FILE]) { + for (const file of [CLIENT_SECRET_FILE, CREDENTIALS_FILE, TOKEN_CACHE_FILE]) { const filePath = path.join(configDir, file); try { deps.unlinkSync(filePath); @@ -55,6 +56,15 @@ export function writeGwsCredentials( } deps.mkdirSync(configDir, { recursive: true }); + + // Remove stale token cache — gws encrypts it with machine-specific keys, so a + // cache from a previous container (or the setup image) can't be decrypted here. + try { + deps.unlinkSync(path.join(configDir, TOKEN_CACHE_FILE)); + } catch { + // File doesn't exist — nothing to clean up + } + deps.writeFileSync(path.join(configDir, CLIENT_SECRET_FILE), clientSecret, { mode: 0o600 }); deps.writeFileSync(path.join(configDir, CREDENTIALS_FILE), credentials, { mode: 0o600 }); From 1c4a1de1d2f8f1145062c0d162f5f3a42dd4487c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 13:13:08 +0100 Subject: [PATCH 29/87] fix(kiloclaw): only ignore ENOENT in gws credential cleanup The catch blocks in writeGwsCredentials swallowed all errors during unlink, hiding permission errors and I/O failures. Now only ENOENT is ignored; other errors propagate so stale credentials aren't silently left in place after disconnect. --- kiloclaw/controller/src/gws-credentials.test.ts | 2 +- kiloclaw/controller/src/gws-credentials.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts index 380fbcbc5..b063c471a 100644 --- a/kiloclaw/controller/src/gws-credentials.test.ts +++ b/kiloclaw/controller/src/gws-credentials.test.ts @@ -101,7 +101,7 @@ describe('writeGwsCredentials', () => { it('ignores missing files during cleanup', () => { const deps = mockDeps(); deps.unlinkSync.mockImplementation(() => { - throw new Error('ENOENT'); + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); const dir = '/tmp/gws-test'; diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts index 993b6da6d..3a7d8311c 100644 --- a/kiloclaw/controller/src/gws-credentials.ts +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -48,8 +48,8 @@ export function writeGwsCredentials( try { deps.unlinkSync(filePath); console.log(`[gws] Removed stale ${filePath}`); - } catch { - // File doesn't exist — nothing to clean up + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } } return false; @@ -61,8 +61,8 @@ export function writeGwsCredentials( // cache from a previous container (or the setup image) can't be decrypted here. try { deps.unlinkSync(path.join(configDir, TOKEN_CACHE_FILE)); - } catch { - // File doesn't exist — nothing to clean up + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } deps.writeFileSync(path.join(configDir, CLIENT_SECRET_FILE), clientSecret, { mode: 0o600 }); From b01436de7d68529db402cfc403cfbc38687e9159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 13:13:27 +0100 Subject: [PATCH 30/87] fix(kiloclaw): pin skills CLI version in gws agent skills install Pin skills@1.4.4 in the npx command so controller boot doesn't depend on live npm registry state. Prevents upstream releases or outages from breaking running instances. --- kiloclaw/controller/src/gws-credentials.test.ts | 4 ++-- kiloclaw/controller/src/gws-credentials.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts index b063c471a..a8689fb68 100644 --- a/kiloclaw/controller/src/gws-credentials.test.ts +++ b/kiloclaw/controller/src/gws-credentials.test.ts @@ -140,7 +140,7 @@ describe('writeGwsCredentials', () => { ); expect(exec).toHaveBeenCalledWith( - 'npx -y skills add https://github.com/googleworkspace/cli --yes --global', + 'npx -y skills@1.4.4 add https://github.com/googleworkspace/cli --yes --global', expect.any(Function) ); }); @@ -167,7 +167,7 @@ describe('installGwsSkills', () => { installGwsSkills(); expect(exec).toHaveBeenCalledWith( - 'npx -y skills add https://github.com/googleworkspace/cli --yes --global', + 'npx -y skills@1.4.4 add https://github.com/googleworkspace/cli --yes --global', expect.any(Function) ); }); diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts index 3a7d8311c..fa5cc632c 100644 --- a/kiloclaw/controller/src/gws-credentials.ts +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -95,7 +95,7 @@ export function installGwsSkills(): void { // Marker not found — proceed with install } - const cmd = 'npx -y skills add https://github.com/googleworkspace/cli --yes --global'; + const cmd = 'npx -y skills@1.4.4 add https://github.com/googleworkspace/cli --yes --global'; console.log('[gws] Installing agent skills in background...'); exec(cmd, (error, _stdout, stderr) => { if (error) { From f20513bbc9caaf1fef8a6a1488a00a3fb58c2c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 13:13:40 +0100 Subject: [PATCH 31/87] fix(kiloclaw): fail integration test when DO never becomes reachable The readiness poll loop continued silently after 30s even if the DO was never reachable, causing confusing downstream failures. Now exits with a clear error message. --- kiloclaw/test/google-credentials-integration.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kiloclaw/test/google-credentials-integration.mjs b/kiloclaw/test/google-credentials-integration.mjs index 0c51d50be..1e45cbbe8 100644 --- a/kiloclaw/test/google-credentials-integration.mjs +++ b/kiloclaw/test/google-credentials-integration.mjs @@ -200,15 +200,21 @@ const provisionPromise = fetch(`${WORKER_URL}/api/platform/provision`, { green('Provision request fired (not waiting for completion)'); // Poll status until the DO is reachable (google-credentials endpoint works once DO exists) +let doReachable = false; for (let i = 0; i < 30; i++) { const { status } = await internalGet(`/api/platform/status?userId=${USER_ID}`); if (status !== 404) { green('Instance DO reachable'); + doReachable = true; break; } await new Promise(r => setTimeout(r, 1000)); } provisionController.abort(); +if (!doReachable) { + red('Instance DO never became reachable after 30s — provision may have failed'); + process.exit(1); +} // --------------------------------------------------------------------------- // 3. Platform API: store Google credentials From 95e49543b97387f41dc3b37e27daa093d9bf63fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 13:13:55 +0100 Subject: [PATCH 32/87] fix(kiloclaw): check provision response in google-setup e2e test The test reported provisioning success without checking the HTTP status, masking 4xx/5xx failures that would only surface as confusing errors later. --- kiloclaw/test/google-setup-e2e.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kiloclaw/test/google-setup-e2e.mjs b/kiloclaw/test/google-setup-e2e.mjs index 547073f44..5be76a2c0 100644 --- a/kiloclaw/test/google-setup-e2e.mjs +++ b/kiloclaw/test/google-setup-e2e.mjs @@ -130,7 +130,12 @@ sql(`INSERT INTO kilocode_users (id, google_user_email, google_user_name, google cleanupFns.push(() => { try { sql(`DELETE FROM kilocode_users WHERE id = '${USER_ID}'`); } catch {} }); green('Test user created (id=' + USER_ID + ')'); -await internalPost('/api/platform/provision', { userId: USER_ID }); +const provisionRes = await internalPost('/api/platform/provision', { userId: USER_ID }); +if (provisionRes.status < 200 || provisionRes.status >= 300) { + red(`Provision failed with status ${provisionRes.status}: ${JSON.stringify(provisionRes.json)}`); + cleanup(); + process.exit(1); +} cleanupFns.push(() => { internalPost('/api/platform/destroy', { userId: USER_ID }).catch(() => {}); }); green('Instance provisioned'); From 93781fefd67f2665bd3405085195762c87177969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 13:53:57 +0100 Subject: [PATCH 33/87] fix(claw): remove unused AlertCircle import from SettingsTab --- src/app/(app)/claw/components/SettingsTab.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 3c0f091e9..a01e0cf81 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -1,7 +1,6 @@ 'use client'; import { - AlertCircle, AlertTriangle, Check, Copy, From 4ef1e23fbde6137c5a2f4cabbf8782f94d552f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 14:14:35 +0100 Subject: [PATCH 34/87] fix(kiloclaw): bind OAuth callback to loopback and refresh setup JWT Bind the google-setup OAuth callback server to 127.0.0.1 instead of 0.0.0.0 so only localhost can reach the callback port when running with --network host. Add refetchInterval to the setup command query so the cached JWT is refreshed before its 1-hour expiry if the settings tab stays open. --- kiloclaw/google-setup/setup.mjs | 2 +- src/app/(app)/claw/components/SettingsTab.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index f19ef9d1e..012ff8f66 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -200,7 +200,7 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { reject(new Error(`OAuth callback server failed: ${err.message}`)); }); - server.listen(0, () => { + server.listen(0, '127.0.0.1', () => { callbackPort = server.address().port; const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); authUrl.searchParams.set('client_id', client_id); diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index a01e0cf81..05ff12e94 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -90,7 +90,10 @@ function GoogleAccountSection({ }) { const trpc = useTRPC(); const { data: setupData } = useQuery( - trpc.kiloclaw.getGoogleSetupCommand.queryOptions(undefined, { enabled: !connected }) + trpc.kiloclaw.getGoogleSetupCommand.queryOptions(undefined, { + enabled: !connected, + refetchInterval: 50 * 60 * 1000, + }) ); const [copied, setCopied] = useState(false); const isDisconnecting = mutations.disconnectGoogle.isPending; From 261d102b7fc5af95d29c5a7f127bd5d3095fee01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 16:55:41 +0100 Subject: [PATCH 35/87] refactor(kiloclaw): move Google credential env vars to secret-catalog Centralizes GOOGLE_CLIENT_SECRET_JSON and GOOGLE_CREDENTIALS_JSON in the @kilocode/kiloclaw-secret-catalog package via INTERNAL_SENSITIVE_ENV_VARS instead of hardcoding them in env.ts SENSITIVE_KEYS. --- .../secret-catalog/src/__tests__/catalog.test.ts | 15 +++++++++++++++ kiloclaw/packages/secret-catalog/src/catalog.ts | 10 ++++++++++ kiloclaw/packages/secret-catalog/src/index.ts | 1 + kiloclaw/src/gateway/env.ts | 8 +++++--- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts index 143b2894c..76ddf6594 100644 --- a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts +++ b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts @@ -6,6 +6,8 @@ import { FIELD_KEY_TO_ENV_VAR, ENV_VAR_TO_FIELD_KEY, FIELD_KEY_TO_ENTRY, + ALL_SECRET_ENV_VARS, + INTERNAL_SENSITIVE_ENV_VARS, getEntriesByCategory, } from '../catalog.js'; import { validateFieldValue } from '../validation.js'; @@ -312,6 +314,19 @@ describe('Secret Catalog', () => { }); }); + describe('INTERNAL_SENSITIVE_ENV_VARS', () => { + it('contains Google credential env vars', () => { + expect(INTERNAL_SENSITIVE_ENV_VARS.has('GOOGLE_CLIENT_SECRET_JSON')).toBe(true); + expect(INTERNAL_SENSITIVE_ENV_VARS.has('GOOGLE_CREDENTIALS_JSON')).toBe(true); + }); + + it('does not overlap with catalog-derived ALL_SECRET_ENV_VARS', () => { + for (const envVar of INTERNAL_SENSITIVE_ENV_VARS) { + expect(ALL_SECRET_ENV_VARS.has(envVar)).toBe(false); + } + }); + }); + describe('maxLength contract', () => { it('all maxLength values are within the global 500 ceiling', () => { for (const entry of SECRET_CATALOG) { diff --git a/kiloclaw/packages/secret-catalog/src/catalog.ts b/kiloclaw/packages/secret-catalog/src/catalog.ts index e3921c531..232776c29 100644 --- a/kiloclaw/packages/secret-catalog/src/catalog.ts +++ b/kiloclaw/packages/secret-catalog/src/catalog.ts @@ -132,6 +132,16 @@ export const ALL_SECRET_ENV_VARS: ReadonlySet = new Set( SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.envVar)) ); +/** + * Env vars that are always sensitive but aren't part of the UI catalog. + * These are set internally by the worker (e.g. from encrypted DO state), + * not entered by users through the secret management UI. + */ +export const INTERNAL_SENSITIVE_ENV_VARS: ReadonlySet = new Set([ + 'GOOGLE_CLIENT_SECRET_JSON', + 'GOOGLE_CREDENTIALS_JSON', +]); + /** * Get all entries for a given category, sorted by order (undefined sorts last). */ diff --git a/kiloclaw/packages/secret-catalog/src/index.ts b/kiloclaw/packages/secret-catalog/src/index.ts index 545d4d509..62461d4a8 100644 --- a/kiloclaw/packages/secret-catalog/src/index.ts +++ b/kiloclaw/packages/secret-catalog/src/index.ts @@ -27,6 +27,7 @@ export { ENV_VAR_TO_FIELD_KEY, FIELD_KEY_TO_ENTRY, ALL_SECRET_ENV_VARS, + INTERNAL_SENSITIVE_ENV_VARS, getEntriesByCategory, } from './catalog'; diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index 9b4a0ef0d..85afc6b42 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -1,4 +1,7 @@ -import { ALL_SECRET_ENV_VARS } from '@kilocode/kiloclaw-secret-catalog'; +import { + ALL_SECRET_ENV_VARS, + INTERNAL_SENSITIVE_ENV_VARS, +} from '@kilocode/kiloclaw-secret-catalog'; import type { KiloClawEnv } from '../types'; import type { EncryptedEnvelope, @@ -57,9 +60,8 @@ export type EnvVarsBuild = { const SENSITIVE_KEYS = new Set([ 'KILOCODE_API_KEY', 'OPENCLAW_GATEWAY_TOKEN', - 'GOOGLE_CLIENT_SECRET_JSON', - 'GOOGLE_CREDENTIALS_JSON', ...ALL_SECRET_ENV_VARS, + ...INTERNAL_SENSITIVE_ENV_VARS, ]); /** From a5f27cc2187d11076a8a9651712c71891bef88b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 16:56:27 +0100 Subject: [PATCH 36/87] fix(kiloclaw): wrap Google credential decryption in try/catch Corrupted or mismatched credential envelopes no longer crash the entire env build. The container starts without Google access and logs a warning, matching the existing fail-open pattern for channel tokens. --- kiloclaw/src/gateway/env.test.ts | 22 ++++++++++++++++++++++ kiloclaw/src/gateway/env.ts | 24 +++++++++++++++--------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index e3c797cdc..c59118570 100644 --- a/kiloclaw/src/gateway/env.test.ts +++ b/kiloclaw/src/gateway/env.test.ts @@ -331,6 +331,28 @@ describe('buildEnvVars', () => { expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); }); + it('continues without Google access when credential decryption fails', async () => { + const env = createMockEnv({ + AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey, + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + googleCredentials: { + // Corrupted envelopes — not valid encrypted data + clientSecret: { encrypted: 'bad', key: 'bad', iv: 'bad', authTag: 'bad' }, + credentials: { encrypted: 'bad', key: 'bad', iv: 'bad', authTag: 'bad' }, + }, + }); + + expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); + expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to decrypt Google credentials, starting without Google access:', + expect.any(Error) + ); + warnSpy.mockRestore(); + }); + it('skips Google credential decryption when no private key configured', async () => { const env = createMockEnv(); // no AGENT_ENV_VARS_PRIVATE_KEY const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index 85afc6b42..266a41296 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -163,16 +163,22 @@ export async function buildEnvVars( Object.assign(sensitive, channelEnv); } - // Layer 4b: Decrypt Google credentials and pass as env vars + // Layer 4b: Decrypt Google credentials and pass as env vars. + // Wrapped in try/catch so corrupted credentials don't block container startup — + // the machine starts without Google access instead of failing entirely. if (userConfig.googleCredentials && env.AGENT_ENV_VARS_PRIVATE_KEY) { - sensitive.GOOGLE_CLIENT_SECRET_JSON = decryptWithPrivateKey( - userConfig.googleCredentials.clientSecret, - env.AGENT_ENV_VARS_PRIVATE_KEY - ); - sensitive.GOOGLE_CREDENTIALS_JSON = decryptWithPrivateKey( - userConfig.googleCredentials.credentials, - env.AGENT_ENV_VARS_PRIVATE_KEY - ); + try { + sensitive.GOOGLE_CLIENT_SECRET_JSON = decryptWithPrivateKey( + userConfig.googleCredentials.clientSecret, + env.AGENT_ENV_VARS_PRIVATE_KEY + ); + sensitive.GOOGLE_CREDENTIALS_JSON = decryptWithPrivateKey( + userConfig.googleCredentials.credentials, + env.AGENT_ENV_VARS_PRIVATE_KEY + ); + } catch (err) { + console.warn('Failed to decrypt Google credentials, starting without Google access:', err); + } } } From 343c7c2a748f7fe591206db841f635b45ab1f461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 16:57:17 +0100 Subject: [PATCH 37/87] fix(kiloclaw): deduplicate OAuth client_secret across credential envelopes The setup flow no longer stores client_id/client_secret in the credentials envelope. The worker merges them from the clientSecret envelope at decryption time, limiting blast radius if a single envelope is compromised. Backwards-compatible with existing credentials that already contain both fields. --- kiloclaw/google-setup/setup.mjs | 5 +++-- kiloclaw/src/gateway/env.test.ts | 38 ++++++++++++++++++++++++++++---- kiloclaw/src/gateway/env.ts | 18 +++++++++++++-- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 012ff8f66..2decedcff 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -245,11 +245,12 @@ if (!tokenRes.ok) { const tokens = await tokenRes.json(); // Build a credentials object similar to what gws stores +// Omit client_id/client_secret from the credentials envelope to avoid +// duplicating the OAuth client secret across both envelopes. +// The worker merges them from the clientSecret envelope at decryption time. const credentialsObj = { type: 'authorized_user', ...tokens, - client_id, - client_secret, scopes: SCOPES, }; const credentialsJson = JSON.stringify(credentialsObj); diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index c59118570..3b8356d04 100644 --- a/kiloclaw/src/gateway/env.test.ts +++ b/kiloclaw/src/gateway/env.test.ts @@ -299,23 +299,53 @@ describe('buildEnvVars', () => { // ─── Google credentials (Layer 4b) ─────────────────────────────────── - it('decrypts Google credentials into sensitive bucket', async () => { + it('decrypts Google credentials into sensitive bucket and merges client_id/client_secret', async () => { const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey, }); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { googleCredentials: { - clientSecret: encryptForTest('{"client_id":"test"}', testPublicKey), + clientSecret: encryptForTest('{"client_id":"cid","client_secret":"csec"}', testPublicKey), + // credentials envelope omits client_id/client_secret (new setup flow) credentials: encryptForTest('{"refresh_token":"rt"}', testPublicKey), }, }); - expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBe('{"client_id":"test"}'); - expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBe('{"refresh_token":"rt"}'); + expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBe( + '{"client_id":"cid","client_secret":"csec"}' + ); + // Verify client_id and client_secret were merged from clientSecret envelope + const creds = JSON.parse(result.sensitive.GOOGLE_CREDENTIALS_JSON); + expect(creds.refresh_token).toBe('rt'); + expect(creds.client_id).toBe('cid'); + expect(creds.client_secret).toBe('csec'); expect(result.env.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); }); + it('does not overwrite existing client_id/client_secret in credentials envelope', async () => { + const env = createMockEnv({ + AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey, + }); + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + googleCredentials: { + clientSecret: encryptForTest( + '{"client_id":"old","client_secret":"old_sec"}', + testPublicKey + ), + // Legacy credentials envelope that already has client_id/client_secret + credentials: encryptForTest( + '{"refresh_token":"rt","client_id":"existing","client_secret":"existing_sec"}', + testPublicKey + ), + }, + }); + + const creds = JSON.parse(result.sensitive.GOOGLE_CREDENTIALS_JSON); + expect(creds.client_id).toBe('existing'); + expect(creds.client_secret).toBe('existing_sec'); + }); + it('classifies Google credential env var names as sensitive even when provided as plaintext', async () => { const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey }); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index 266a41296..c7fa4d1fd 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -168,14 +168,28 @@ export async function buildEnvVars( // the machine starts without Google access instead of failing entirely. if (userConfig.googleCredentials && env.AGENT_ENV_VARS_PRIVATE_KEY) { try { - sensitive.GOOGLE_CLIENT_SECRET_JSON = decryptWithPrivateKey( + const clientSecretJson = decryptWithPrivateKey( userConfig.googleCredentials.clientSecret, env.AGENT_ENV_VARS_PRIVATE_KEY ); - sensitive.GOOGLE_CREDENTIALS_JSON = decryptWithPrivateKey( + sensitive.GOOGLE_CLIENT_SECRET_JSON = clientSecretJson; + + const credentialsRaw = decryptWithPrivateKey( userConfig.googleCredentials.credentials, env.AGENT_ENV_VARS_PRIVATE_KEY ); + // Merge client_id/client_secret from the clientSecret envelope into + // the credentials object. The setup flow omits them from the credentials + // envelope to avoid duplicating the OAuth client secret across envelopes. + const clientSecret = JSON.parse(clientSecretJson); + const credentials = JSON.parse(credentialsRaw); + if (!credentials.client_id && clientSecret.client_id) { + credentials.client_id = clientSecret.client_id; + } + if (!credentials.client_secret && clientSecret.client_secret) { + credentials.client_secret = clientSecret.client_secret; + } + sensitive.GOOGLE_CREDENTIALS_JSON = JSON.stringify(credentials); } catch (err) { console.warn('Failed to decrypt Google credentials, starting without Google access:', err); } From 99ea1d7e975f885696e476846ac5da1bddba2e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 16:57:55 +0100 Subject: [PATCH 38/87] fix(kiloclaw): remove unused connectGoogle mutation The connection flow uses the Docker-based setup container which POSTs encrypted credentials directly to the worker. This removes the unused Next.js-side plaintext credential path. --- src/hooks/useKiloClaw.ts | 3 --- src/routers/kiloclaw-router.ts | 17 ----------------- 2 files changed, 20 deletions(-) diff --git a/src/hooks/useKiloClaw.ts b/src/hooks/useKiloClaw.ts index 33c23d0c9..afe86c9cd 100644 --- a/src/hooks/useKiloClaw.ts +++ b/src/hooks/useKiloClaw.ts @@ -197,9 +197,6 @@ export function useKiloClawMutations() { }, }) ), - connectGoogle: useMutation( - trpc.kiloclaw.connectGoogle.mutationOptions({ onSuccess: invalidateStatus }) - ), disconnectGoogle: useMutation( trpc.kiloclaw.disconnectGoogle.mutationOptions({ onSuccess: invalidateStatus }) ), diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 04131cb47..f10ea7076 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -476,23 +476,6 @@ export const kiloclawRouter = createTRPCRouter({ }; }), - connectGoogle: baseProcedure - .input( - z.object({ - clientSecretJson: z.string().min(1), - credentialsJson: z.string().min(1), - }) - ) - .mutation(async ({ ctx, input }) => { - const client = new KiloClawInternalClient(); - return client.updateGoogleCredentials(ctx.user.id, { - googleCredentials: { - clientSecret: encryptKiloClawSecret(input.clientSecretJson), - credentials: encryptKiloClawSecret(input.credentialsJson), - }, - }); - }), - disconnectGoogle: baseProcedure.mutation(async ({ ctx }) => { const client = new KiloClawInternalClient(); return client.clearGoogleCredentials(ctx.user.id); From 74b0cce2a464784059c9cf1058146cbd90f9bc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 16:59:48 +0100 Subject: [PATCH 39/87] fix(kiloclaw): move public-key endpoint behind auth The /public-key endpoint now requires JWT auth at /api/admin/public-key. This prevents unauthenticated clients from fetching the encryption key. The setup container already has a valid JWT and now passes it when fetching the public key. --- kiloclaw/google-setup/setup.mjs | 2 +- kiloclaw/src/routes/api.ts | 32 ++++++++++++++++++++++++++++++++ kiloclaw/src/routes/public.ts | 32 -------------------------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 2decedcff..8240bcf23 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -88,7 +88,7 @@ console.log('API key verified.'); console.log('Fetching encryption public key...'); -const pubKeyRes = await fetch(`${workerUrl}/public-key`); +const pubKeyRes = await fetch(`${workerUrl}/api/admin/public-key`, { headers: authHeaders }); if (!pubKeyRes.ok) { console.error('Failed to fetch public key from worker.'); process.exit(1); diff --git a/kiloclaw/src/routes/api.ts b/kiloclaw/src/routes/api.ts index 912c92274..76daa7ae3 100644 --- a/kiloclaw/src/routes/api.ts +++ b/kiloclaw/src/routes/api.ts @@ -63,6 +63,38 @@ adminApi.post('/gateway/restart', async c => { } }); +// Cache the derived public key PEM to avoid re-deriving on every request. +let cachedPublicKeyPem: string | null = null; +let cachedForPrivateKey: string | null = null; + +// GET /api/admin/public-key - RSA public key for encrypting secrets +// The google-setup container fetches this to encrypt Google OAuth credentials. +adminApi.get('/public-key', async c => { + const privateKeyPem = c.env.AGENT_ENV_VARS_PRIVATE_KEY; + if (!privateKeyPem) { + return c.json({ error: 'Encryption not configured' }, 503); + } + + try { + // Return cached public key if derived from the same private key + if (cachedPublicKeyPem && cachedForPrivateKey === privateKeyPem) { + return c.json({ publicKey: cachedPublicKeyPem }); + } + + const { createPublicKey } = await import('crypto'); + const publicKey = createPublicKey({ key: privateKeyPem, format: 'pem' }); + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + + cachedPublicKeyPem = publicKeyPem; + cachedForPrivateKey = privateKeyPem; + + return c.json({ publicKey: publicKeyPem }); + } catch (err) { + console.error('[api] Failed to derive public key:', err); + return c.json({ error: 'Failed to derive public key' }, 500); + } +}); + // GET /api/admin/google-credentials - Check Google connection status adminApi.get('/google-credentials', async c => { const stub = resolveStub(c); diff --git a/kiloclaw/src/routes/public.ts b/kiloclaw/src/routes/public.ts index 1c2cd1925..b0476e4ef 100644 --- a/kiloclaw/src/routes/public.ts +++ b/kiloclaw/src/routes/public.ts @@ -2,10 +2,6 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types'; import { OPENCLAW_PORT } from '../config'; -// Cache the derived public key PEM to avoid re-deriving on every request. -let cachedPublicKeyPem: string | null = null; -let cachedForPrivateKey: string | null = null; - /** * Public routes - no authentication required * @@ -22,32 +18,4 @@ publicRoutes.get('/health', c => { }); }); -// GET /public-key - RSA public key for encrypting secrets -// The google-setup container fetches this to encrypt Google OAuth credentials. -publicRoutes.get('/public-key', async c => { - const privateKeyPem = c.env.AGENT_ENV_VARS_PRIVATE_KEY; - if (!privateKeyPem) { - return c.json({ error: 'Encryption not configured' }, 503); - } - - try { - // Return cached public key if derived from the same private key - if (cachedPublicKeyPem && cachedForPrivateKey === privateKeyPem) { - return c.json({ publicKey: cachedPublicKeyPem }); - } - - const { createPublicKey } = await import('crypto'); - const publicKey = createPublicKey({ key: privateKeyPem, format: 'pem' }); - const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; - - cachedPublicKeyPem = publicKeyPem; - cachedForPrivateKey = privateKeyPem; - - return c.json({ publicKey: publicKeyPem }); - } catch (err) { - console.error('[public] Failed to derive public key:', err); - return c.json({ error: 'Failed to derive public key' }, 500); - } -}); - export { publicRoutes }; From 3c92b094ad44589c4108193aa6215df884dfc4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:00:06 +0100 Subject: [PATCH 40/87] feat(kiloclaw): add changelog entry for gws CLI availability --- src/app/(app)/claw/components/changelog-data.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/(app)/claw/components/changelog-data.ts b/src/app/(app)/claw/components/changelog-data.ts index e8dd6889f..a047fb50a 100644 --- a/src/app/(app)/claw/components/changelog-data.ts +++ b/src/app/(app)/claw/components/changelog-data.ts @@ -10,6 +10,13 @@ export type ChangelogEntry = { // Newest entries first. Developers add new entries to the top of this array. export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + { + date: '2026-03-11', + description: + 'Added Google Workspace CLI (gws) to the default image. Agents can use gws for Google Workspace API access when a Google account is connected.', + category: 'feature', + deployHint: 'redeploy_required', + }, { date: '2026-03-10', description: From b5e4590279297b2f8434ae0da63100779a9227eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:00:21 +0100 Subject: [PATCH 41/87] docs(kiloclaw): document env mutation side effect in writeGwsCredentials --- kiloclaw/controller/src/gws-credentials.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts index fa5cc632c..ca007ce77 100644 --- a/kiloclaw/controller/src/gws-credentials.ts +++ b/kiloclaw/controller/src/gws-credentials.ts @@ -32,6 +32,10 @@ const defaultDeps: GwsCredentialsDeps = { /** * Write gws credential files if the corresponding env vars are set. * Returns true if credentials were written, false if skipped. + * + * Side effect: mutates the passed `env` record by setting + * GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE so gws finds the credentials + * when HOME points to the OpenClaw workspace dir. */ export function writeGwsCredentials( env: Record = process.env as Record, From 1532c9722064436fdeba4fe7383095ee4eb74bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:00:36 +0100 Subject: [PATCH 42/87] chore(kiloclaw): add engines field to google-setup package.json Requires Node >= 22 for top-level await support in setup.mjs. --- kiloclaw/google-setup/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kiloclaw/google-setup/package.json b/kiloclaw/google-setup/package.json index b1a287dbe..6d9916aa4 100644 --- a/kiloclaw/google-setup/package.json +++ b/kiloclaw/google-setup/package.json @@ -5,5 +5,8 @@ "type": "module", "scripts": { "start": "node setup.mjs" + }, + "engines": { + "node": ">=22" } } From 5fb3d953ad4c77654d0e410180bbd37a8a7c8cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:00:51 +0100 Subject: [PATCH 43/87] fix(kiloclaw): pass DATABASE_URL via env in integration test sql helper Avoids shell metacharacter injection by passing DATABASE_URL and the query string as environment variables instead of interpolating them into the shell command. --- kiloclaw/test/google-credentials-integration.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kiloclaw/test/google-credentials-integration.mjs b/kiloclaw/test/google-credentials-integration.mjs index 1e45cbbe8..4e5f0dba9 100644 --- a/kiloclaw/test/google-credentials-integration.mjs +++ b/kiloclaw/test/google-credentials-integration.mjs @@ -117,9 +117,11 @@ const DUMMY_CREDS = { // --------------------------------------------------------------------------- function sql(query) { - return execSync(`psql "${DATABASE_URL}" -tAc "${query.replace(/"/g, '\\"')}"`, { + return execSync('psql "$PGURL" -tAc "$PGQUERY"', { encoding: 'utf8', timeout: 5000, + env: { ...process.env, PGURL: DATABASE_URL, PGQUERY: query }, + shell: '/bin/sh', }).trim(); } From c749aa1e077c7c3bab25ef6b71724a5fad17b851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:01:17 +0100 Subject: [PATCH 44/87] fix(kiloclaw): restore gog binary alongside gws in container image OpenClaw docs and default skills still reference gog. Both CLIs coexist until the ecosystem fully migrates to gws. --- kiloclaw/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kiloclaw/Dockerfile b/kiloclaw/Dockerfile index ba28f9cf1..67923f3f4 100644 --- a/kiloclaw/Dockerfile +++ b/kiloclaw/Dockerfile @@ -72,7 +72,8 @@ RUN ARCH="$(dpkg --print-architecture)" \ # so user-installed tools persist across restarts and are included in snapshots. # - npm/Node (installed to /usr/local) and apt packages (/usr/bin) are unaffected. ENV PATH="/usr/local/go/bin:/root/go/bin:$PATH" -RUN GOBIN=/usr/local/bin go install github.com/steipete/goplaces/cmd/goplaces@v0.3.0 \ +RUN GOBIN=/usr/local/bin go install github.com/steipete/gogcli/cmd/gog@v0.11.0 \ + && GOBIN=/usr/local/bin go install github.com/steipete/goplaces/cmd/goplaces@v0.3.0 \ && GOBIN=/usr/local/bin go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@v0.0.2 \ && GOBIN=/usr/local/bin go install github.com/xdevplatform/xurl@v1.0.3 \ && GOBIN=/usr/local/bin go install github.com/steipete/gifgrep/cmd/gifgrep@v0.2.3 \ From ecdbb0be5354f1a8fed561e56818659fff541910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:01:34 +0100 Subject: [PATCH 45/87] fix(kiloclaw): validate --worker-url scheme in google-setup Rejects non-HTTPS worker URLs (except localhost for dev) to prevent the API key from being sent to a malicious endpoint. Prints a warning when a non-default worker URL is used. --- kiloclaw/google-setup/setup.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 8240bcf23..5cee882ff 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -41,6 +41,25 @@ if (!apiKey) { process.exit(1); } +// Validate worker URL scheme — reject non-HTTPS except for localhost dev. +try { + const parsed = new URL(workerUrl); + const isLocalhost = + parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; + if (parsed.protocol !== 'https:' && !isLocalhost) { + console.error( + `Error: --worker-url must use HTTPS (got ${parsed.protocol}). HTTP is only allowed for localhost.` + ); + process.exit(1); + } + if (workerUrl !== 'https://claw.kilo.ai') { + console.warn(`Warning: using non-default worker URL: ${workerUrl}`); + } +} catch { + console.error(`Error: invalid --worker-url: ${workerUrl}`); + process.exit(1); +} + const authHeaders = { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', From 0c33a6517f957404ba970f29de116cb2637322b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 17:01:48 +0100 Subject: [PATCH 46/87] fix(kiloclaw): fix corrupted envelope test to match EncryptedEnvelope type Use correct field names (encryptedData/encryptedDEK) and add missing vi import for console.warn spy. --- kiloclaw/src/gateway/env.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index 3b8356d04..e49a6b223 100644 --- a/kiloclaw/src/gateway/env.test.ts +++ b/kiloclaw/src/gateway/env.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, vi } from 'vitest'; import { generateKeyPairSync, publicEncrypt, randomBytes, createCipheriv, constants } from 'crypto'; import { buildEnvVars, FEATURE_TO_ENV_VAR } from './env'; import { DEFAULT_INSTANCE_FEATURES } from '../schemas/instance-config'; @@ -369,8 +369,18 @@ describe('buildEnvVars', () => { const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { googleCredentials: { // Corrupted envelopes — not valid encrypted data - clientSecret: { encrypted: 'bad', key: 'bad', iv: 'bad', authTag: 'bad' }, - credentials: { encrypted: 'bad', key: 'bad', iv: 'bad', authTag: 'bad' }, + clientSecret: { + encryptedData: 'bad', + encryptedDEK: 'bad', + algorithm: 'rsa-aes-256-gcm' as const, + version: 1 as const, + }, + credentials: { + encryptedData: 'bad', + encryptedDEK: 'bad', + algorithm: 'rsa-aes-256-gcm' as const, + version: 1 as const, + }, }, }); From d45fa5e804cbdba22d986dacd268ea464f2b14ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:14:27 +0100 Subject: [PATCH 47/87] docs(kiloclaw): add gws-to-gog migration plan 13-task implementation plan covering controller credential writer, Dockerfile changes, google-setup rewrite, and e2e test updates. --- .../plans/2026-03-11-gws-to-gog-migration.md | 1521 +++++++++++++++++ 1 file changed, 1521 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md diff --git a/docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md b/docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md new file mode 100644 index 000000000..16db45878 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md @@ -0,0 +1,1521 @@ +# gws → gog CLI Migration Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Google Workspace CLI (gws) with gogcli (gog) as the sole Google CLI in KiloClaw, and rewrite the setup flow to use gcloud + gog directly. + +**Architecture:** The controller writes gog-format credentials (plain JSON client config + JWE-encrypted keyring token) at startup. The setup container uses gcloud for project/API setup and prompts for manual OAuth client creation (the only step Google doesn't expose an API for), then runs a custom OAuth flow and stores encrypted credentials. No gws dependency anywhere. + +**Tech Stack:** Node.js, jose (JWE encryption), gcloud CLI, gog (Go CLI), Vitest + +--- + +## Context for implementers + +### Project structure + +``` +kiloclaw/ + Dockerfile # Main container image + controller/ + package.json # Dependencies (hono, will add jose) + src/ + index.ts # Controller entry — calls writeGwsCredentials() + gws-credentials.ts # Current credential writer (being replaced) + gws-credentials.test.ts # Tests (being replaced) + google-setup/ + Dockerfile # Setup image (has gws + gcloud) + setup.mjs # Setup script (uses gws auth setup) + package.json + README.md + test/ # E2E tests (being renamed to e2e/) + google-credentials-integration.mjs + google-setup-e2e.mjs + docker-image-testing.md + src/ + gateway/env.ts # Decrypts credentials → GOOGLE_CLIENT_SECRET_JSON + GOOGLE_CREDENTIALS_JSON env vars + gateway/env.test.ts # Tests for env decryption + routes/api.ts # User-facing google-credentials routes + routes/platform.ts # Internal google-credentials routes +``` + +### Key env vars (unchanged by this migration) + +- `GOOGLE_CLIENT_SECRET_JSON` — JSON with `{client_id, client_secret}` (from `installed` wrapper) +- `GOOGLE_CREDENTIALS_JSON` — JSON with `{type, refresh_token, client_id, client_secret, scopes, ...}` + +These env vars are set by `kiloclaw/src/gateway/env.ts` (the worker side). The controller reads them and writes files for whichever CLI to discover. The env var names do NOT change. + +### gog credential format + +gog expects: +1. **Client config**: `~/.config/gogcli/credentials.json` — plain JSON `{client_id, client_secret}` +2. **Refresh token**: `~/.config/gogcli/keyring/` — JWE-encrypted file + - Key name: `token:default:` → filename: `token%3Adefault%3A` + - JWE algorithm: `PBES2-HS256+A128KW` (key wrapping) + `A256GCM` (content encryption) + - Password: from `GOG_KEYRING_PASSWORD` env var (empty string is valid) + - Payload: `{RefreshToken: string, Services: string[], Scopes: string[], CreatedAt: string}` + +### gog env vars + +- `GOG_KEYRING_BACKEND=file` — use file-based keyring (not OS keychain) +- `GOG_KEYRING_PASSWORD=""` — password for file keyring encryption (empty is supported) +- `GOG_ACCOUNT=` — default account to use + +### Scopes (full set) + +```js +const SCOPES = [ + 'openid', + 'email', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.settings.basic', + 'https://www.googleapis.com/auth/gmail.settings.sharing', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/presentations', + 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/tasks', + 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/directory.readonly', + 'https://www.googleapis.com/auth/forms.body', + 'https://www.googleapis.com/auth/forms.responses.readonly', + 'https://www.googleapis.com/auth/chat.spaces', + 'https://www.googleapis.com/auth/chat.messages', + 'https://www.googleapis.com/auth/chat.memberships', + 'https://www.googleapis.com/auth/classroom.courses', + 'https://www.googleapis.com/auth/classroom.rosters', + 'https://www.googleapis.com/auth/script.projects', + 'https://www.googleapis.com/auth/script.deployments', + 'https://www.googleapis.com/auth/keep', + 'https://www.googleapis.com/auth/pubsub', +]; +``` + +### APIs to enable + +``` +gmail.googleapis.com calendar-json.googleapis.com drive.googleapis.com +docs.googleapis.com slides.googleapis.com sheets.googleapis.com +tasks.googleapis.com people.googleapis.com forms.googleapis.com +chat.googleapis.com classroom.googleapis.com script.googleapis.com +keep.googleapis.com pubsub.googleapis.com +``` + +### Commands + +- **Controller tests**: `cd kiloclaw/controller && npx vitest run` +- **Worker tests**: `cd kiloclaw && pnpm test` +- **Format changed files**: `pnpm run format:changed` (from repo root) + +--- + +## Chunk 1: Controller — gog credential writer + +### Task 1: Add jose dependency to controller + +**Files:** +- Modify: `kiloclaw/controller/package.json` + +- [ ] **Step 1: Add jose dependency** + +```json +{ + "name": "kiloclaw-controller", + "private": true, + "type": "module", + "dependencies": { + "hono": "4.12.2", + "jose": "6.0.11" + }, + "devDependencies": { + "@types/node": "22.0.0" + } +} +``` + +- [ ] **Step 2: Install dependencies** + +Run: `cd kiloclaw/controller && bun install` +Expected: bun.lock updated, jose installed + +- [ ] **Step 3: Commit** + +```bash +git add kiloclaw/controller/package.json kiloclaw/controller/bun.lock +git commit -m "chore(kiloclaw): add jose dependency to controller for JWE keyring" +``` + +### Task 2: Write failing tests for gog-credentials + +**Files:** +- Create: `kiloclaw/controller/src/gog-credentials.test.ts` + +- [ ] **Step 1: Write the test file** + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'node:path'; + +// We'll import from gog-credentials once it exists. +// For now, these tests define the expected behavior. + +// No child_process mock needed — gog-credentials doesn't shell out + +function mockDeps() { + return { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), + }; +} + +// Credentials JSON that includes email (new requirement for gog) +const CLIENT_SECRET_JSON = JSON.stringify({ + installed: { + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + }, +}); + +const CREDENTIALS_JSON = JSON.stringify({ + type: 'authorized_user', + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + refresh_token: '1//0test-refresh-token', + scopes: ['https://www.googleapis.com/auth/gmail.modify'], + email: 'user@gmail.com', +}); + +describe('writeGogCredentials', () => { + let writeGogCredentials: typeof import('./gog-credentials').writeGogCredentials; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import('./gog-credentials'); + writeGogCredentials = mod.writeGogCredentials; + }); + + it('writes client credentials and keyring when both env vars are set', async () => { + const deps = mockDeps(); + const dir = '/tmp/gogcli-test'; + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + const result = await writeGogCredentials(env, dir, deps); + + expect(result).toBe(true); + expect(deps.mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); + expect(deps.mkdirSync).toHaveBeenCalledWith(path.join(dir, 'keyring'), { recursive: true }); + + // Should write credentials.json with just client_id + client_secret + const credentialsCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => c[0] === path.join(dir, 'credentials.json') + ); + expect(credentialsCall).toBeDefined(); + const writtenCreds = JSON.parse(credentialsCall![1] as string); + expect(writtenCreds).toEqual({ + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + }); + + // Should write a keyring file with percent-encoded name + const keyringCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string).includes('keyring/') + ); + expect(keyringCall).toBeDefined(); + const keyringPath = keyringCall![0] as string; + expect(keyringPath).toContain('token%3Adefault%3Auser%40gmail.com'); + + // Keyring file should be a JWE string (starts with eyJ) + const keyringContent = keyringCall![1] as string; + expect(keyringContent).toMatch(/^eyJ/); + }); + + it('sets GOG env vars when credentials are written', async () => { + const deps = mockDeps(); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + expect(env.GOG_KEYRING_BACKEND).toBe('file'); + expect(env.GOG_KEYRING_PASSWORD).toBe(''); + expect(env.GOG_ACCOUNT).toBe('user@gmail.com'); + }); + + it('does NOT set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE', async () => { + const deps = mockDeps(); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + expect(env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE).toBeUndefined(); + }); + + it('skips when GOOGLE_CLIENT_SECRET_JSON is missing', async () => { + const deps = mockDeps(); + const result = await writeGogCredentials( + { GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON }, + '/tmp/gogcli-test', + deps + ); + expect(result).toBe(false); + expect(deps.mkdirSync).not.toHaveBeenCalled(); + }); + + it('skips when GOOGLE_CREDENTIALS_JSON is missing', async () => { + const deps = mockDeps(); + const result = await writeGogCredentials( + { GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON }, + '/tmp/gogcli-test', + deps + ); + expect(result).toBe(false); + }); + + it('removes stale credential files when env vars are absent', async () => { + const deps = mockDeps(); + const dir = '/tmp/gogcli-test'; + await writeGogCredentials({}, dir, deps); + + expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); + }); + + it('ignores missing files during cleanup', async () => { + const deps = mockDeps(); + deps.unlinkSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const result = await writeGogCredentials({}, '/tmp/gogcli-test', deps); + expect(result).toBe(false); + }); + + it('handles "web" client config wrapper', async () => { + const deps = mockDeps(); + const webClientSecret = JSON.stringify({ + web: { + client_id: 'web-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-web-secret', + }, + }); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: webClientSecret, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + const credentialsCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') + ); + const writtenCreds = JSON.parse(credentialsCall![1] as string); + expect(writtenCreds.client_id).toBe('web-client-id.apps.googleusercontent.com'); + expect(writtenCreds.client_secret).toBe('GOCSPX-web-secret'); + }); + + it('percent-encodes special characters in email for keyring filename', async () => { + const deps = mockDeps(); + const credsWithPlus = JSON.stringify({ + type: 'authorized_user', + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + refresh_token: '1//0test-refresh-token', + scopes: ['https://www.googleapis.com/auth/gmail.modify'], + email: 'user+tag@gmail.com', + }); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: credsWithPlus, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + const keyringCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string).includes('keyring/') + ); + const keyringPath = keyringCall![0] as string; + // + must be percent-encoded as %2B + expect(keyringPath).toContain('user%2Btag%40gmail.com'); + }); + + it('handles credentials without email gracefully', async () => { + const deps = mockDeps(); + const credsNoEmail = JSON.stringify({ + type: 'authorized_user', + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + refresh_token: '1//0test-refresh-token', + scopes: ['https://www.googleapis.com/auth/gmail.modify'], + }); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: credsNoEmail, + }; + + // Should still write client credentials but skip keyring (no email = can't create key name) + const result = await writeGogCredentials(env, '/tmp/gogcli-test', deps); + expect(result).toBe(true); + + // Client credentials should still be written + const credentialsCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') + ); + expect(credentialsCall).toBeDefined(); + + // Keyring file should NOT be written (no email) + const keyringCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string).includes('keyring/') + ); + expect(keyringCall).toBeUndefined(); + + // GOG_ACCOUNT should not be set + expect(env.GOG_ACCOUNT).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd kiloclaw/controller && npx vitest run src/gog-credentials.test.ts` +Expected: FAIL — module `./gog-credentials` not found + +### Task 3: Implement gog-credentials module + +**Files:** +- Create: `kiloclaw/controller/src/gog-credentials.ts` + +- [ ] **Step 1: Write the implementation** + +```typescript +/** + * Writes gogcli credential files to disk so the gog CLI picks them up + * automatically at runtime. + * + * When the container starts with GOOGLE_CLIENT_SECRET_JSON and + * GOOGLE_CREDENTIALS_JSON env vars, this module: + * 1. Writes client credentials to ~/.config/gogcli/credentials.json + * 2. Writes a JWE-encrypted keyring file with the refresh token + * 3. Sets GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, GOG_ACCOUNT env vars + */ +import { CompactEncrypt } from 'jose'; +import path from 'node:path'; + +// Use /root explicitly — OpenClaw changes HOME to the workspace dir at runtime, +// but we need credentials at a stable, absolute path that gog can always find. +const GOG_CONFIG_DIR = '/root/.config/gogcli'; +const CREDENTIALS_FILE = 'credentials.json'; +const KEYRING_DIR = 'keyring'; + +export type GogCredentialsDeps = { + mkdirSync: (dir: string, opts: { recursive: boolean }) => void; + writeFileSync: (path: string, data: string, opts: { mode: number }) => void; + unlinkSync: (path: string) => void; +}; + +/** + * Percent-encode a string for use as a keyring filename. + * gog uses Go's url.PathEscape which encodes everything except unreserved chars. + */ +function percentEncode(s: string): string { + return Array.from(new TextEncoder().encode(s)) + .map(b => { + const c = String.fromCharCode(b); + if (/[A-Za-z0-9\-_.~]/.test(c)) return c; + return '%' + b.toString(16).toUpperCase().padStart(2, '0'); + }) + .join(''); +} + +/** + * Create a JWE-encrypted keyring file matching the 99designs/keyring file backend. + * Uses PBES2-HS256+A128KW for key wrapping and A256GCM for content encryption. + */ +async function createKeyringEntry( + refreshToken: string, + scopes: string[], + password: string +): Promise { + // Map OAuth scopes to gog service names for the Services field + const services = mapScopesToServices(scopes); + + const payload = JSON.stringify({ + RefreshToken: refreshToken, + Services: services, + // gog stores only full OAuth scopes, not OIDC shorthand like 'openid'/'email' + Scopes: scopes.filter(s => s.startsWith('https://')), + CreatedAt: new Date().toISOString(), + }); + + const encoder = new TextEncoder(); + const jwe = await new CompactEncrypt(encoder.encode(payload)) + .setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' }) + .encrypt(encoder.encode(password)); + + return jwe; +} + +/** Map Google OAuth scopes to gog service names. */ +function mapScopesToServices(scopes: string[]): string[] { + const scopeToService: Record = { + 'gmail.modify': 'gmail', + 'gmail.settings.basic': 'gmail', + 'gmail.settings.sharing': 'gmail', + 'gmail.readonly': 'gmail', + calendar: 'calendar', + 'calendar.readonly': 'calendar', + drive: 'drive', + 'drive.readonly': 'drive', + 'drive.file': 'drive', + documents: 'docs', + 'documents.readonly': 'docs', + presentations: 'slides', + 'presentations.readonly': 'slides', + spreadsheets: 'sheets', + 'spreadsheets.readonly': 'sheets', + tasks: 'tasks', + 'tasks.readonly': 'tasks', + contacts: 'contacts', + 'contacts.readonly': 'contacts', + 'contacts.other.readonly': 'contacts', + 'directory.readonly': 'contacts', + 'forms.body': 'forms', + 'forms.body.readonly': 'forms', + 'forms.responses.readonly': 'forms', + 'chat.spaces': 'chat', + 'chat.messages': 'chat', + 'chat.memberships': 'chat', + 'chat.spaces.readonly': 'chat', + 'chat.messages.readonly': 'chat', + 'chat.memberships.readonly': 'chat', + 'classroom.courses': 'classroom', + 'classroom.rosters': 'classroom', + 'script.projects': 'appscript', + 'script.deployments': 'appscript', + keep: 'keep', + pubsub: 'pubsub', + }; + + const prefix = 'https://www.googleapis.com/auth/'; + const services = new Set(); + for (const scope of scopes) { + const short = scope.startsWith(prefix) ? scope.slice(prefix.length) : scope; + const service = scopeToService[short]; + if (service) services.add(service); + } + return [...services].sort(); +} + +/** + * Write gog credential files if the corresponding env vars are set. + * Returns true if credentials were written, false if skipped. + * + * Side effect: mutates the passed `env` record by setting + * GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, and GOG_ACCOUNT. + */ +export async function writeGogCredentials( + env: Record = process.env as Record, + configDir = GOG_CONFIG_DIR, + deps?: Partial +): Promise { + const fs = await import('node:fs'); + const d: GogCredentialsDeps = { + mkdirSync: deps?.mkdirSync ?? ((dir, opts) => fs.default.mkdirSync(dir, opts)), + writeFileSync: deps?.writeFileSync ?? ((p, data, opts) => fs.default.writeFileSync(p, data, opts)), + unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), + }; + + const clientSecretRaw = env.GOOGLE_CLIENT_SECRET_JSON; + const credentialsRaw = env.GOOGLE_CREDENTIALS_JSON; + + if (!clientSecretRaw || !credentialsRaw) { + // Clean up stale credential files from a previous run (e.g. after disconnect) + for (const file of [CREDENTIALS_FILE]) { + const filePath = path.join(configDir, file); + try { + d.unlinkSync(filePath); + console.log(`[gog] Removed stale ${filePath}`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + return false; + } + + d.mkdirSync(configDir, { recursive: true }); + + // Parse client_secret.json — extract client_id + client_secret from the + // "installed" or "web" wrapper, or use top-level fields if already flat. + const clientConfig = JSON.parse(clientSecretRaw); + const clientFields = clientConfig.installed ?? clientConfig.web ?? clientConfig; + const clientId = clientFields.client_id; + const clientSecret = clientFields.client_secret; + + // Write gogcli credentials.json (just client_id + client_secret) + d.writeFileSync( + path.join(configDir, CREDENTIALS_FILE), + JSON.stringify({ client_id: clientId, client_secret: clientSecret }), + { mode: 0o600 } + ); + + console.log(`[gog] Wrote client credentials to ${configDir}/${CREDENTIALS_FILE}`); + + // Parse credentials to get refresh_token, email, scopes + const credentials = JSON.parse(credentialsRaw); + const email: string | undefined = credentials.email; + const refreshToken: string | undefined = credentials.refresh_token; + const scopes: string[] = credentials.scopes ?? []; + + // Write keyring entry if we have email + refresh_token + if (email && refreshToken) { + const keyringDir = path.join(configDir, KEYRING_DIR); + d.mkdirSync(keyringDir, { recursive: true }); + + const keyName = `token:default:${email}`; + const fileName = percentEncode(keyName); + const password = ''; // Empty password is supported by gog + + const jwe = await createKeyringEntry(refreshToken, scopes, password); + d.writeFileSync(path.join(keyringDir, fileName), jwe, { mode: 0o600 }); + + console.log(`[gog] Wrote keyring entry for ${email}`); + + // Set env vars for gog discovery + env.GOG_KEYRING_BACKEND = 'file'; + env.GOG_KEYRING_PASSWORD = ''; + env.GOG_ACCOUNT = email; + } else { + if (!email) console.warn('[gog] No email in credentials — keyring entry skipped, gog may not work'); + if (!refreshToken) console.warn('[gog] No refresh_token in credentials — keyring entry skipped'); + } + + return true; +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cd kiloclaw/controller && npx vitest run src/gog-credentials.test.ts` +Expected: All tests pass + +- [ ] **Step 3: Commit** + +```bash +git add kiloclaw/controller/src/gog-credentials.ts kiloclaw/controller/src/gog-credentials.test.ts +git commit -m "feat(kiloclaw): add gog-credentials module with JWE keyring support" +``` + +### Task 4: Wire gog-credentials into controller and remove gws-credentials + +**Files:** +- Modify: `kiloclaw/controller/src/index.ts:16-18` (import + call) +- Delete: `kiloclaw/controller/src/gws-credentials.ts` +- Delete: `kiloclaw/controller/src/gws-credentials.test.ts` + +- [ ] **Step 1: Update index.ts import and call** + +In `kiloclaw/controller/src/index.ts`, replace: + +```typescript +import { writeGwsCredentials } from './gws-credentials'; +``` + +with: + +```typescript +import { writeGogCredentials } from './gog-credentials'; +``` + +And replace line 118: + +```typescript + writeGwsCredentials(env as Record); +``` + +with: + +```typescript + // writeGogCredentials is async (JWE encryption) but we don't await it — + // credential writing is best-effort and should not block controller startup. + // This is safe: the gateway process doesn't use gog credentials at startup; + // gog is only invoked later by user/bot actions, well after this completes. + writeGogCredentials(env as Record).catch(err => { + console.error('[gog] Failed to write credentials:', err); + }); +``` + +- [ ] **Step 2: Delete old gws files** + +```bash +git rm kiloclaw/controller/src/gws-credentials.ts +git rm kiloclaw/controller/src/gws-credentials.test.ts +``` + +- [ ] **Step 3: Run all controller tests** + +Run: `cd kiloclaw/controller && npx vitest run` +Expected: All tests pass (gog tests pass, no gws tests remain) + +- [ ] **Step 4: Commit** + +```bash +git add kiloclaw/controller/src/index.ts +git commit -m "refactor(kiloclaw): replace gws-credentials with gog-credentials in controller" +``` + +Note: The `git rm` in step 2 already staged the deletions. + +--- + +## Chunk 2: Dockerfile changes + +### Task 5: Remove gws from main Dockerfile, keep gog + +**Files:** +- Modify: `kiloclaw/Dockerfile:57-58` + +- [ ] **Step 1: Remove gws CLI installation** + +Delete these lines from `kiloclaw/Dockerfile` (around line 57-58): + +```dockerfile +# Install gws CLI (Google Workspace CLI) +RUN npm install -g @googleworkspace/cli@0.11.1 +``` + +- [ ] **Step 2: Verify gog is still present** + +Confirm line 75 still has: +```dockerfile +RUN GOBIN=/usr/local/bin go install github.com/steipete/gogcli/cmd/gog@v0.11.0 \ +``` + +No changes needed — gog is already installed. + +Note: The old `gws-credentials.ts` ran `installGwsSkills()` which installed gws agent skills via +`npx skills add https://github.com/googleworkspace/cli`. This is intentionally dropped — gog has +native OpenClaw support and doesn't need a separate skills installation step. + +- [ ] **Step 3: Commit** + +```bash +git add kiloclaw/Dockerfile +git commit -m "chore(kiloclaw): remove gws CLI from container image" +``` + +--- + +## Chunk 3: Google Setup rewrite + +### Task 6: Update google-setup Dockerfile + +**Files:** +- Modify: `kiloclaw/google-setup/Dockerfile` + +- [ ] **Step 1: Remove gws and expect, keep gcloud** + +Replace the full content of `kiloclaw/google-setup/Dockerfile` with: + +```dockerfile +FROM node:22-slim + +# Install dependencies for gcloud CLI + readline for interactive prompts +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + python3 \ + apt-transport-https \ + ca-certificates \ + gnupg \ + && rm -rf /var/lib/apt/lists/* + +# Install gcloud CLI +RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ + > /etc/apt/sources.list.d/google-cloud-sdk.list \ + && apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY setup.mjs ./ + +ENTRYPOINT ["node", "setup.mjs"] +``` + +- [ ] **Step 2: Commit** + +```bash +git add kiloclaw/google-setup/Dockerfile +git commit -m "chore(kiloclaw): remove gws and expect from google-setup image" +``` + +### Task 7: Rewrite setup.mjs + +**Files:** +- Modify: `kiloclaw/google-setup/setup.mjs` + +This is the largest change. The new flow: + +1. Validate API key (unchanged) +2. Fetch public key (unchanged) +3. **NEW**: Sign into gcloud, create/select project, enable APIs, configure consent screen +4. **NEW**: Prompt user to create OAuth Desktop client in Console and paste client_id + client_secret +5. Run custom OAuth flow (mostly unchanged, expanded scopes) +6. **NEW**: Fetch user email via userinfo endpoint +7. Encrypt + POST (mostly unchanged, email added to credentials) + +- [ ] **Step 1: Rewrite setup.mjs** + +Replace the full content of `kiloclaw/google-setup/setup.mjs` with the following. Key differences from the old version are commented: + +```js +#!/usr/bin/env node + +/** + * KiloClaw Google Account Setup + * + * Docker-based tool that: + * 1. Validates the user's KiloCode API key against the kiloclaw worker + * 2. Fetches the worker's RSA public key for credential encryption + * 3. Signs into gcloud, creates/selects a GCP project, enables APIs + * 4. Prompts user to create a Desktop OAuth client in Cloud Console + * 5. Runs our own OAuth flow (localhost callback) to get a refresh token + * 6. Fetches the user's email address + * 7. Encrypts the client_secret + credentials with the worker's public key + * 8. POSTs the encrypted bundle to the kiloclaw worker + * + * Usage: + * docker run -it --network host kilocode/google-setup --api-key=kilo_abc123 + */ + +import { spawn, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import http from 'node:http'; +import readline from 'node:readline'; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const apiKeyArg = args.find(a => a.startsWith('--api-key=')); +const apiKey = apiKeyArg?.substring(apiKeyArg.indexOf('=') + 1); + +const workerUrlArg = args.find(a => a.startsWith('--worker-url=')); +const workerUrl = workerUrlArg + ? workerUrlArg.substring(workerUrlArg.indexOf('=') + 1) + : 'https://claw.kilo.ai'; + +if (!apiKey) { + console.error( + 'Usage: docker run -it --network host kilocode/google-setup --api-key=' + ); + process.exit(1); +} + +// Validate worker URL scheme — reject non-HTTPS except for localhost dev. +try { + const parsed = new URL(workerUrl); + const isLocalhost = + parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; + if (parsed.protocol !== 'https:' && !isLocalhost) { + console.error( + `Error: --worker-url must use HTTPS (got ${parsed.protocol}). HTTP is only allowed for localhost.` + ); + process.exit(1); + } + if (workerUrl !== 'https://claw.kilo.ai') { + console.warn(`Warning: using non-default worker URL: ${workerUrl}`); + } +} catch { + console.error(`Error: invalid --worker-url: ${workerUrl}`); + process.exit(1); +} + +const authHeaders = { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', +}; + +// --------------------------------------------------------------------------- +// Scopes — all gog user services + pubsub +// --------------------------------------------------------------------------- + +const SCOPES = [ + 'openid', + 'email', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.settings.basic', + 'https://www.googleapis.com/auth/gmail.settings.sharing', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/presentations', + 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/tasks', + 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/directory.readonly', + 'https://www.googleapis.com/auth/forms.body', + 'https://www.googleapis.com/auth/forms.responses.readonly', + 'https://www.googleapis.com/auth/chat.spaces', + 'https://www.googleapis.com/auth/chat.messages', + 'https://www.googleapis.com/auth/chat.memberships', + 'https://www.googleapis.com/auth/classroom.courses', + 'https://www.googleapis.com/auth/classroom.rosters', + 'https://www.googleapis.com/auth/script.projects', + 'https://www.googleapis.com/auth/script.deployments', + 'https://www.googleapis.com/auth/keep', + 'https://www.googleapis.com/auth/pubsub', +]; + +// APIs to enable in the GCP project +const GCP_APIS = [ + 'gmail.googleapis.com', + 'calendar-json.googleapis.com', + 'drive.googleapis.com', + 'docs.googleapis.com', + 'slides.googleapis.com', + 'sheets.googleapis.com', + 'tasks.googleapis.com', + 'people.googleapis.com', + 'forms.googleapis.com', + 'chat.googleapis.com', + 'classroom.googleapis.com', + 'script.googleapis.com', + 'keep.googleapis.com', + 'pubsub.googleapis.com', +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function ask(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(question, answer => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +function runCommand(cmd, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'inherit', ...opts }); + child.on('close', code => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)))); + child.on('error', reject); + }); +} + +function runCommandOutput(cmd, args) { + return execSync([cmd, ...args].join(' '), { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +// --------------------------------------------------------------------------- +// Step 1: Validate API key +// --------------------------------------------------------------------------- + +console.log('Validating API key...'); + +const validateRes = await fetch(`${workerUrl}/health`); +if (!validateRes.ok) { + console.error('Cannot reach kiloclaw worker at', workerUrl); + process.exit(1); +} + +const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { + headers: authHeaders, +}); + +if (authCheckRes.status === 401 || authCheckRes.status === 403) { + console.error('Invalid API key. Check your key and try again.'); + process.exit(1); +} + +console.log('API key verified.\n'); + +// --------------------------------------------------------------------------- +// Step 2: Fetch public key for encryption +// --------------------------------------------------------------------------- + +console.log('Fetching encryption public key...'); + +const pubKeyRes = await fetch(`${workerUrl}/api/admin/public-key`, { headers: authHeaders }); +if (!pubKeyRes.ok) { + console.error('Failed to fetch public key from worker.'); + process.exit(1); +} + +const { publicKey: publicKeyPem } = await pubKeyRes.json(); + +if (!publicKeyPem || !publicKeyPem.includes('BEGIN PUBLIC KEY')) { + console.error('Invalid public key received from worker.'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Step 3: Sign into gcloud and set up GCP project + APIs +// --------------------------------------------------------------------------- + +console.log('Signing into Google Cloud...'); +console.log('A browser window will open for you to sign in.\n'); + +await runCommand('gcloud', ['auth', 'login', '--brief']); + +const gcloudAccount = runCommandOutput('gcloud', ['config', 'get-value', 'account']); +console.log(`\nSigned in as: ${gcloudAccount}\n`); + +// Project selection: create new or use existing +console.log('Google Cloud project setup:'); +console.log(' 1. Create a new project (recommended)'); +console.log(' 2. Use an existing project\n'); + +const projectChoice = await ask('Choose (1 or 2): '); +let projectId; + +if (projectChoice === '2') { + // List existing projects + console.log('\nFetching your projects...'); + try { + await runCommand('gcloud', ['projects', 'list', '--format=table(projectId,name)']); + } catch { + console.warn('Could not list projects. You can still enter a project ID manually.'); + } + projectId = await ask('\nEnter your project ID: '); +} else { + // Generate a project ID based on date + const now = new Date(); + const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; + const defaultId = `kiloclaw-${dateStr}`; + const inputId = await ask(`Project ID [${defaultId}]: `); + projectId = inputId || defaultId; + + console.log(`\nCreating project "${projectId}"...`); + try { + await runCommand('gcloud', ['projects', 'create', projectId, '--set-as-default']); + console.log('Project created.\n'); + } catch { + console.error(`Failed to create project "${projectId}". It may already exist.`); + console.error('Try a different name, or choose option 2 to use an existing project.'); + process.exit(1); + } +} + +// Set as active project +await runCommand('gcloud', ['config', 'set', 'project', projectId]); +console.log(`\nUsing project: ${projectId}`); + +// Enable APIs +console.log('\nEnabling Google APIs (this may take a minute)...'); +await runCommand('gcloud', ['services', 'enable', ...GCP_APIS, `--project=${projectId}`]); +console.log('APIs enabled.\n'); + +// Configure OAuth consent screen via REST API +console.log('Configuring OAuth consent screen...'); +const accessToken = runCommandOutput('gcloud', ['auth', 'print-access-token']); + +// Check if brand already exists +const brandsRes = await fetch( + `https://iap.googleapis.com/v1/projects/${projectId}/brands`, + { headers: { authorization: `Bearer ${accessToken}` } } +); +const brandsData = await brandsRes.json(); + +if (!brandsData.brands?.length) { + const createBrandRes = await fetch( + `https://iap.googleapis.com/v1/projects/${projectId}/brands`, + { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + applicationTitle: 'KiloClaw', + supportEmail: gcloudAccount, + }), + } + ); + if (!createBrandRes.ok) { + console.warn('Could not auto-configure consent screen. You may need to set it up manually.'); + console.warn(`Visit: https://console.cloud.google.com/apis/credentials/consent?project=${projectId}\n`); + } else { + console.log('OAuth consent screen configured.\n'); + } +} else { + console.log('OAuth consent screen already configured.\n'); +} + +// --------------------------------------------------------------------------- +// Step 4: Manual OAuth client creation +// --------------------------------------------------------------------------- + +const credentialsUrl = `https://console.cloud.google.com/apis/credentials?project=${projectId}`; + +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(' Create an OAuth client'); +console.log(''); +console.log(` 1. Open: ${credentialsUrl}`); +console.log(' 2. Click "Create Credentials" → "OAuth client ID"'); +console.log(' 3. Application type: "Desktop app"'); +console.log(' 4. Click "Create"'); +console.log(' 5. Copy the Client ID and Client Secret below'); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + +const clientId = await ask('Client ID: '); +const clientSecret = await ask('Client Secret: '); + +if (!clientId || !clientSecret) { + console.error('Client ID and Client Secret are required.'); + process.exit(1); +} + +// Build client_secret.json in the standard Google format +const clientSecretObj = { + installed: { + client_id: clientId, + project_id: projectId, + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_secret: clientSecret, + redirect_uris: ['http://localhost'], + }, +}; +const clientSecretJson = JSON.stringify(clientSecretObj); + +// --------------------------------------------------------------------------- +// Step 5: Custom OAuth flow to get refresh token +// --------------------------------------------------------------------------- + +console.log('\nStarting OAuth authorization...'); + +const { code, redirectUri } = await new Promise((resolve, reject) => { + let callbackPort; + + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + clearTimeout(timer); + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('

Authorization failed

You can close this tab.

'); + server.close(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + if (code) { + clearTimeout(timer); + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab.

'); + server.close(); + resolve({ code, redirectUri: `http://localhost:${callbackPort}` }); + return; + } + + // Ignore non-OAuth requests (e.g. browser favicon) + res.writeHead(404); + res.end(); + }); + + let timer; + + server.on('error', (err) => { + clearTimeout(timer); + reject(new Error(`OAuth callback server failed: ${err.message}`)); + }); + + server.listen(0, '127.0.0.1', () => { + callbackPort = server.address().port; + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}`); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', SCOPES.join(' ')); + authUrl.searchParams.set('access_type', 'offline'); + authUrl.searchParams.set('prompt', 'consent'); + + console.log('\nOpen this URL in your browser to authorize:\n'); + console.log(` ${authUrl.toString()}\n`); + console.log(`Waiting for OAuth callback on port ${callbackPort}...`); + }); + + timer = setTimeout(() => { + server.close(); + reject(new Error('OAuth flow timed out (5 minutes)')); + }, 5 * 60 * 1000); + timer.unref(); +}); + +// Exchange authorization code for tokens +console.log('Exchanging authorization code for tokens...'); + +const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), +}); + +if (!tokenRes.ok) { + const err = await tokenRes.text(); + console.error('Token exchange failed:', err); + process.exit(1); +} + +const tokens = await tokenRes.json(); +console.log('OAuth tokens obtained.'); + +// --------------------------------------------------------------------------- +// Step 6: Fetch user email +// --------------------------------------------------------------------------- + +console.log('Fetching account info...'); + +const userinfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { authorization: `Bearer ${tokens.access_token}` }, +}); + +let userEmail; +if (userinfoRes.ok) { + const userinfo = await userinfoRes.json(); + userEmail = userinfo.email; + console.log(`Account: ${userEmail}`); +} else { + console.warn('Could not fetch user email. gog account auto-selection will not work.'); +} + +// Build credentials object — includes email for gog keyring key naming +const credentialsObj = { + type: 'authorized_user', + ...tokens, + scopes: SCOPES, + ...(userEmail && { email: userEmail }), +}; +const credentialsJson = JSON.stringify(credentialsObj); + +// --------------------------------------------------------------------------- +// Step 7: Encrypt credentials with worker's public key +// --------------------------------------------------------------------------- + +function encryptEnvelope(plaintext, pemKey) { + const dek = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); + let encrypted = cipher.update(plaintext, 'utf8'); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const tag = cipher.getAuthTag(); + const encryptedData = Buffer.concat([iv, encrypted, tag]); + const encryptedDEK = crypto.publicEncrypt( + { key: pemKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + dek + ); + return { + encryptedData: encryptedData.toString('base64'), + encryptedDEK: encryptedDEK.toString('base64'), + algorithm: 'rsa-aes-256-gcm', + version: 1, + }; +} + +console.log('Encrypting credentials...'); + +const encryptedBundle = { + clientSecret: encryptEnvelope(clientSecretJson, publicKeyPem), + credentials: encryptEnvelope(credentialsJson, publicKeyPem), +}; + +// --------------------------------------------------------------------------- +// Step 8: POST to worker +// --------------------------------------------------------------------------- + +console.log('Sending credentials to your kiloclaw instance...'); + +const postRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ googleCredentials: encryptedBundle }), +}); + +if (!postRes.ok) { + const body = await postRes.text(); + console.error('Failed to store credentials:', body); + process.exit(1); +} + +console.log('\nGoogle account connected!'); +console.log('Credentials sent to your kiloclaw instance.'); +if (userEmail) { + console.log(`Connected account: ${userEmail}`); +} +console.log('\nYour bot can now use Gmail, Calendar, Drive, Docs, Sheets, and more.'); +console.log('Redeploy your kiloclaw instance to activate.'); +``` + +- [ ] **Step 2: Commit** + +```bash +git add kiloclaw/google-setup/setup.mjs +git commit -m "feat(kiloclaw): rewrite google-setup to use gcloud + gog instead of gws" +``` + +### Task 8: Update google-setup README + +**Files:** +- Modify: `kiloclaw/google-setup/README.md` + +- [ ] **Step 1: Update README** + +Replace the content of `kiloclaw/google-setup/README.md` with: + +```markdown +# KiloClaw Google Setup + +Docker image that guides users through connecting their Google account to KiloClaw. + +## What it does + +1. Validates the user's KiloCode API key +2. Signs into gcloud, creates/selects a GCP project, enables Google APIs +3. Guides user through creating a Desktop OAuth client in Google Cloud Console +4. Runs a local OAuth flow to obtain refresh tokens +5. Encrypts credentials with the worker's public key +6. POSTs the encrypted bundle to the KiloClaw worker + +## Usage + +```bash +docker run -it --network host ghcr.io/kilo-org/google-setup --api-key="YOUR_API_KEY" +``` + +For local development against a local worker: + +```bash +docker run -it --network host ghcr.io/kilo-org/google-setup \ + --api-key="YOUR_API_KEY" \ + --worker-url=http://localhost:8795 +``` + +## Publishing + +The image is hosted on GitHub Container Registry at `ghcr.io/kilo-org/google-setup`. + +### Prerequisites + +- Docker with buildx support +- GitHub CLI (`gh`) with `write:packages` scope + +### Steps + +```bash +# 1. Add write:packages scope (one-time) +gh auth refresh -h github.com -s write:packages + +# 2. Login to GHCR +echo $(gh auth token) | docker login ghcr.io -u $(gh api user -q .login) --password-stdin + +# 3. Create multi-arch builder (one-time) +docker buildx create --use --name multiarch + +# 4. Build and push (amd64 + arm64) +docker buildx build --platform linux/amd64,linux/arm64 \ + -t ghcr.io/kilo-org/google-setup:latest \ + --push \ + kiloclaw/google-setup/ +``` + +### Tagging a release + +```bash +docker buildx build --platform linux/amd64,linux/arm64 \ + -t ghcr.io/kilo-org/google-setup:latest \ + -t ghcr.io/kilo-org/google-setup:v2.0.0 \ + --push \ + kiloclaw/google-setup/ +``` + +## Making the package public + +By default, GHCR packages are private. To make it public: + +1. Go to https://github.com/orgs/Kilo-Org/packages/container/google-setup/settings +2. Under "Danger Zone", click "Change visibility" and select "Public" +``` + +- [ ] **Step 2: Commit** + +```bash +git add kiloclaw/google-setup/README.md +git commit -m "docs(kiloclaw): update google-setup README for gog migration" +``` + +--- + +## Chunk 4: Rename test/ → e2e/ and update tests + +### Task 9: Rename test directory to e2e + +**Files:** +- Rename: `kiloclaw/test/` → `kiloclaw/e2e/` + +- [ ] **Step 1: Rename directory** + +```bash +git mv kiloclaw/test kiloclaw/e2e +``` + +- [ ] **Step 2: Update all references to the old path** + +Search for `kiloclaw/test/` in comments and docs. Update these files: + +In `kiloclaw/e2e/google-credentials-integration.mjs`, update both usage lines (12-13): +``` + * node kiloclaw/e2e/google-credentials-integration.mjs + * DATABASE_URL=postgres://... WORKER_URL=http://localhost:9000 node kiloclaw/e2e/google-credentials-integration.mjs +``` + +In `kiloclaw/e2e/google-setup-e2e.mjs`, update the usage line (19): +``` + * node kiloclaw/e2e/google-setup-e2e.mjs +``` + +In `kiloclaw/e2e/docker-image-testing.md`, no path references to `kiloclaw/test/` exist, so no change needed. + +- [ ] **Step 3: Commit** + +```bash +git add kiloclaw/e2e kiloclaw/test +git commit -m "refactor(kiloclaw): rename test/ to e2e/" +``` + +### Task 10: Update E2E test — google-setup-e2e.mjs + +**Files:** +- Modify: `kiloclaw/e2e/google-setup-e2e.mjs` + +- [ ] **Step 1: Update gws references** + +Two changes in this file: + +1. Line 37 comment — change "gws CLI's random OAuth callback port" to "the OAuth callback port": + +```js +// We use --network host so the OAuth callback port is reachable +// from the browser. This also means localhost in the container reaches the host, +// so we don't need host.docker.internal. +``` + +2. The usage path on line 19 was already updated in Task 9 step 2. + +The test is otherwise unchanged — it builds the docker image, runs it interactively, and checks `googleConnected=true`. The setup.mjs rewrite handles the gog migration; the E2E test just validates the outcome. + +- [ ] **Step 2: Commit** + +```bash +git add kiloclaw/e2e/google-setup-e2e.mjs +git commit -m "chore(kiloclaw): update e2e test comments for gog migration" +``` + +### Task 11: Update E2E test — google-credentials-integration.mjs + +**Files:** +- Modify: `kiloclaw/e2e/google-credentials-integration.mjs` + +- [ ] **Step 1: Update usage comment path** + +Line 13 — already updated in Task 9. Verify it reads: +``` + * node kiloclaw/e2e/google-credentials-integration.mjs +``` + +This test is API-level (POST/GET/DELETE google-credentials endpoints) and doesn't reference gws or gog directly. No other changes needed. + +- [ ] **Step 2: Run the integration test to verify it still works** + +Run: `node kiloclaw/e2e/google-credentials-integration.mjs` +Expected: All tests pass (requires local Postgres + worker running) + +Note: If local services aren't running, this step can be skipped — the test doesn't touch gws/gog code paths. + +--- + +## Chunk 5: Final validation and cleanup + +### Task 12: Run all tests + +- [ ] **Step 1: Run controller unit tests** + +Run: `cd kiloclaw/controller && npx vitest run` +Expected: All tests pass + +- [ ] **Step 2: Run worker tests** + +Run: `cd kiloclaw && pnpm test` +Expected: All tests pass + +- [ ] **Step 3: Run format on changed files** + +Run: `pnpm run format:changed` (from repo root) + +- [ ] **Step 4: Run typecheck** + +Run: `pnpm run typecheck` (from repo root) +Expected: No type errors + +- [ ] **Step 5: Run linter** + +Run: `pnpm run lint` (from repo root) +Expected: No lint errors + +- [ ] **Step 6: Commit any formatting fixes** + +```bash +git add -A +git commit -m "style(kiloclaw): format changes from gog migration" +``` + +### Task 13: Verify no stale gws references remain + +- [ ] **Step 1: Search for leftover gws references** + +Run: `grep -r "gws" kiloclaw/ --include="*.ts" --include="*.mjs" --include="*.json" --include="*.md" --include="Dockerfile" -l` + +Expected: No results. If any files still reference gws, update them. + +Note: `gws` may appear in Git history or in paths like `gateway` — only actual gws CLI references need removal. + +- [ ] **Step 2: Search for leftover GOOGLE_WORKSPACE_CLI references** + +Run: `grep -r "GOOGLE_WORKSPACE_CLI" kiloclaw/ -l` + +Expected: No results. + +- [ ] **Step 3: Verify gws npm package is not referenced** + +Run: `grep -r "@googleworkspace/cli" kiloclaw/ -l` + +Expected: No results. + +- [ ] **Step 4: Commit any remaining fixes** + +If any stale references were found and fixed: +```bash +git add -A +git commit -m "chore(kiloclaw): remove remaining gws references" +``` From 72951379e7eca2a927fbcb095c12ed0952812706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:18:28 +0100 Subject: [PATCH 48/87] chore(kiloclaw): add jose dependency to controller for JWE keyring --- kiloclaw/controller/bun.lock | 3 +++ kiloclaw/controller/package.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/kiloclaw/controller/bun.lock b/kiloclaw/controller/bun.lock index c547d3ba6..f4c6838c9 100644 --- a/kiloclaw/controller/bun.lock +++ b/kiloclaw/controller/bun.lock @@ -6,6 +6,7 @@ "name": "kiloclaw-controller", "dependencies": { "hono": "4.12.2", + "jose": "6.0.11", }, "devDependencies": { "@types/node": "22.0.0", @@ -17,6 +18,8 @@ "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], + "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], + "undici-types": ["undici-types@6.11.1", "", {}, "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ=="], } } diff --git a/kiloclaw/controller/package.json b/kiloclaw/controller/package.json index 9135fc00f..9eccbf9c2 100644 --- a/kiloclaw/controller/package.json +++ b/kiloclaw/controller/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "dependencies": { - "hono": "4.12.2" + "hono": "4.12.2", + "jose": "6.0.11" }, "devDependencies": { "@types/node": "22.0.0" From e816c6a2374891085ab9543bb5d1d119fcfdba30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:20:04 +0100 Subject: [PATCH 49/87] feat(kiloclaw): add gog-credentials module with JWE keyring support --- .../controller/src/gog-credentials.test.ts | 223 ++++++++++++++++++ kiloclaw/controller/src/gog-credentials.ts | 203 ++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 kiloclaw/controller/src/gog-credentials.test.ts create mode 100644 kiloclaw/controller/src/gog-credentials.ts diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts new file mode 100644 index 000000000..445ca2c4c --- /dev/null +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'node:path'; + +// We'll import from gog-credentials once it exists. +// For now, these tests define the expected behavior. + +// No child_process mock needed — gog-credentials doesn't shell out + +function mockDeps() { + return { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), + }; +} + +// Credentials JSON that includes email (new requirement for gog) +const CLIENT_SECRET_JSON = JSON.stringify({ + installed: { + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + }, +}); + +const CREDENTIALS_JSON = JSON.stringify({ + type: 'authorized_user', + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + refresh_token: '1//0test-refresh-token', + scopes: ['https://www.googleapis.com/auth/gmail.modify'], + email: 'user@gmail.com', +}); + +describe('writeGogCredentials', () => { + let writeGogCredentials: typeof import('./gog-credentials').writeGogCredentials; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import('./gog-credentials'); + writeGogCredentials = mod.writeGogCredentials; + }); + + it('writes client credentials and keyring when both env vars are set', async () => { + const deps = mockDeps(); + const dir = '/tmp/gogcli-test'; + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + const result = await writeGogCredentials(env, dir, deps); + + expect(result).toBe(true); + expect(deps.mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); + expect(deps.mkdirSync).toHaveBeenCalledWith(path.join(dir, 'keyring'), { recursive: true }); + + // Should write credentials.json with just client_id + client_secret + const credentialsCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => c[0] === path.join(dir, 'credentials.json') + ); + expect(credentialsCall).toBeDefined(); + const writtenCreds = JSON.parse(credentialsCall![1] as string); + expect(writtenCreds).toEqual({ + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + }); + + // Should write a keyring file with percent-encoded name + const keyringCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string).includes('keyring/') + ); + expect(keyringCall).toBeDefined(); + const keyringPath = keyringCall![0] as string; + expect(keyringPath).toContain('token%3Adefault%3Auser%40gmail.com'); + + // Keyring file should be a JWE string (starts with eyJ) + const keyringContent = keyringCall![1] as string; + expect(keyringContent).toMatch(/^eyJ/); + }); + + it('sets GOG env vars when credentials are written', async () => { + const deps = mockDeps(); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + expect(env.GOG_KEYRING_BACKEND).toBe('file'); + expect(env.GOG_KEYRING_PASSWORD).toBe(''); + expect(env.GOG_ACCOUNT).toBe('user@gmail.com'); + }); + + it('does NOT set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE', async () => { + const deps = mockDeps(); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + expect(env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE).toBeUndefined(); + }); + + it('skips when GOOGLE_CLIENT_SECRET_JSON is missing', async () => { + const deps = mockDeps(); + const result = await writeGogCredentials( + { GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON }, + '/tmp/gogcli-test', + deps + ); + expect(result).toBe(false); + expect(deps.mkdirSync).not.toHaveBeenCalled(); + }); + + it('skips when GOOGLE_CREDENTIALS_JSON is missing', async () => { + const deps = mockDeps(); + const result = await writeGogCredentials( + { GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON }, + '/tmp/gogcli-test', + deps + ); + expect(result).toBe(false); + }); + + it('removes stale credential files when env vars are absent', async () => { + const deps = mockDeps(); + const dir = '/tmp/gogcli-test'; + await writeGogCredentials({}, dir, deps); + + expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); + }); + + it('ignores missing files during cleanup', async () => { + const deps = mockDeps(); + deps.unlinkSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const result = await writeGogCredentials({}, '/tmp/gogcli-test', deps); + expect(result).toBe(false); + }); + + it('handles "web" client config wrapper', async () => { + const deps = mockDeps(); + const webClientSecret = JSON.stringify({ + web: { + client_id: 'web-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-web-secret', + }, + }); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: webClientSecret, + GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + const credentialsCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') + ); + const writtenCreds = JSON.parse(credentialsCall![1] as string); + expect(writtenCreds.client_id).toBe('web-client-id.apps.googleusercontent.com'); + expect(writtenCreds.client_secret).toBe('GOCSPX-web-secret'); + }); + + it('percent-encodes special characters in email for keyring filename', async () => { + const deps = mockDeps(); + const credsWithPlus = JSON.stringify({ + type: 'authorized_user', + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + refresh_token: '1//0test-refresh-token', + scopes: ['https://www.googleapis.com/auth/gmail.modify'], + email: 'user+tag@gmail.com', + }); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: credsWithPlus, + }; + await writeGogCredentials(env, '/tmp/gogcli-test', deps); + + const keyringCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string).includes('keyring/') + ); + const keyringPath = keyringCall![0] as string; + // + must be percent-encoded as %2B + expect(keyringPath).toContain('user%2Btag%40gmail.com'); + }); + + it('handles credentials without email gracefully', async () => { + const deps = mockDeps(); + const credsNoEmail = JSON.stringify({ + type: 'authorized_user', + client_id: 'test-client-id.apps.googleusercontent.com', + client_secret: 'GOCSPX-test-secret', + refresh_token: '1//0test-refresh-token', + scopes: ['https://www.googleapis.com/auth/gmail.modify'], + }); + const env: Record = { + GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, + GOOGLE_CREDENTIALS_JSON: credsNoEmail, + }; + + // Should still write client credentials but skip keyring (no email = can't create key name) + const result = await writeGogCredentials(env, '/tmp/gogcli-test', deps); + expect(result).toBe(true); + + // Client credentials should still be written + const credentialsCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') + ); + expect(credentialsCall).toBeDefined(); + + // Keyring file should NOT be written (no email) + const keyringCall = deps.writeFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string).includes('keyring/') + ); + expect(keyringCall).toBeUndefined(); + + // GOG_ACCOUNT should not be set + expect(env.GOG_ACCOUNT).toBeUndefined(); + }); +}); diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts new file mode 100644 index 000000000..b9b6d3971 --- /dev/null +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -0,0 +1,203 @@ +/** + * Writes gogcli credential files to disk so the gog CLI picks them up + * automatically at runtime. + * + * When the container starts with GOOGLE_CLIENT_SECRET_JSON and + * GOOGLE_CREDENTIALS_JSON env vars, this module: + * 1. Writes client credentials to ~/.config/gogcli/credentials.json + * 2. Writes a JWE-encrypted keyring file with the refresh token + * 3. Sets GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, GOG_ACCOUNT env vars + */ +import { CompactEncrypt } from 'jose'; +import path from 'node:path'; + +// Use /root explicitly — OpenClaw changes HOME to the workspace dir at runtime, +// but we need credentials at a stable, absolute path that gog can always find. +const GOG_CONFIG_DIR = '/root/.config/gogcli'; +const CREDENTIALS_FILE = 'credentials.json'; +const KEYRING_DIR = 'keyring'; + +export type GogCredentialsDeps = { + mkdirSync: (dir: string, opts: { recursive: boolean }) => void; + writeFileSync: (path: string, data: string, opts: { mode: number }) => void; + unlinkSync: (path: string) => void; +}; + +/** + * Percent-encode a string for use as a keyring filename. + * gog uses Go's url.PathEscape which encodes everything except unreserved chars. + */ +function percentEncode(s: string): string { + return Array.from(new TextEncoder().encode(s)) + .map(b => { + const c = String.fromCharCode(b); + if (/[A-Za-z0-9\-_.~]/.test(c)) return c; + return '%' + b.toString(16).toUpperCase().padStart(2, '0'); + }) + .join(''); +} + +/** + * Create a JWE-encrypted keyring file matching the 99designs/keyring file backend. + * Uses PBES2-HS256+A128KW for key wrapping and A256GCM for content encryption. + */ +async function createKeyringEntry( + refreshToken: string, + scopes: string[], + password: string +): Promise { + // Map OAuth scopes to gog service names for the Services field + const services = mapScopesToServices(scopes); + + const payload = JSON.stringify({ + RefreshToken: refreshToken, + Services: services, + // gog stores only full OAuth scopes, not OIDC shorthand like 'openid'/'email' + Scopes: scopes.filter(s => s.startsWith('https://')), + CreatedAt: new Date().toISOString(), + }); + + const encoder = new TextEncoder(); + const jwe = await new CompactEncrypt(encoder.encode(payload)) + .setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' }) + .encrypt(encoder.encode(password)); + + return jwe; +} + +/** Map Google OAuth scopes to gog service names. */ +function mapScopesToServices(scopes: string[]): string[] { + const scopeToService: Record = { + 'gmail.modify': 'gmail', + 'gmail.settings.basic': 'gmail', + 'gmail.settings.sharing': 'gmail', + 'gmail.readonly': 'gmail', + calendar: 'calendar', + 'calendar.readonly': 'calendar', + drive: 'drive', + 'drive.readonly': 'drive', + 'drive.file': 'drive', + documents: 'docs', + 'documents.readonly': 'docs', + presentations: 'slides', + 'presentations.readonly': 'slides', + spreadsheets: 'sheets', + 'spreadsheets.readonly': 'sheets', + tasks: 'tasks', + 'tasks.readonly': 'tasks', + contacts: 'contacts', + 'contacts.readonly': 'contacts', + 'contacts.other.readonly': 'contacts', + 'directory.readonly': 'contacts', + 'forms.body': 'forms', + 'forms.body.readonly': 'forms', + 'forms.responses.readonly': 'forms', + 'chat.spaces': 'chat', + 'chat.messages': 'chat', + 'chat.memberships': 'chat', + 'chat.spaces.readonly': 'chat', + 'chat.messages.readonly': 'chat', + 'chat.memberships.readonly': 'chat', + 'classroom.courses': 'classroom', + 'classroom.rosters': 'classroom', + 'script.projects': 'appscript', + 'script.deployments': 'appscript', + keep: 'keep', + pubsub: 'pubsub', + }; + + const prefix = 'https://www.googleapis.com/auth/'; + const services = new Set(); + for (const scope of scopes) { + const short = scope.startsWith(prefix) ? scope.slice(prefix.length) : scope; + const service = scopeToService[short]; + if (service) services.add(service); + } + return [...services].sort(); +} + +/** + * Write gog credential files if the corresponding env vars are set. + * Returns true if credentials were written, false if skipped. + * + * Side effect: mutates the passed `env` record by setting + * GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, and GOG_ACCOUNT. + */ +export async function writeGogCredentials( + env: Record = process.env as Record, + configDir = GOG_CONFIG_DIR, + deps?: Partial +): Promise { + const fs = await import('node:fs'); + const d: GogCredentialsDeps = { + mkdirSync: deps?.mkdirSync ?? ((dir, opts) => fs.default.mkdirSync(dir, opts)), + writeFileSync: deps?.writeFileSync ?? ((p, data, opts) => fs.default.writeFileSync(p, data, opts)), + unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), + }; + + const clientSecretRaw = env.GOOGLE_CLIENT_SECRET_JSON; + const credentialsRaw = env.GOOGLE_CREDENTIALS_JSON; + + if (!clientSecretRaw || !credentialsRaw) { + // Clean up stale credential files from a previous run (e.g. after disconnect) + for (const file of [CREDENTIALS_FILE]) { + const filePath = path.join(configDir, file); + try { + d.unlinkSync(filePath); + console.log(`[gog] Removed stale ${filePath}`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + return false; + } + + d.mkdirSync(configDir, { recursive: true }); + + // Parse client_secret.json — extract client_id + client_secret from the + // "installed" or "web" wrapper, or use top-level fields if already flat. + const clientConfig = JSON.parse(clientSecretRaw); + const clientFields = clientConfig.installed ?? clientConfig.web ?? clientConfig; + const clientId = clientFields.client_id; + const clientSecret = clientFields.client_secret; + + // Write gogcli credentials.json (just client_id + client_secret) + d.writeFileSync( + path.join(configDir, CREDENTIALS_FILE), + JSON.stringify({ client_id: clientId, client_secret: clientSecret }), + { mode: 0o600 } + ); + + console.log(`[gog] Wrote client credentials to ${configDir}/${CREDENTIALS_FILE}`); + + // Parse credentials to get refresh_token, email, scopes + const credentials = JSON.parse(credentialsRaw); + const email: string | undefined = credentials.email; + const refreshToken: string | undefined = credentials.refresh_token; + const scopes: string[] = credentials.scopes ?? []; + + // Write keyring entry if we have email + refresh_token + if (email && refreshToken) { + const keyringDir = path.join(configDir, KEYRING_DIR); + d.mkdirSync(keyringDir, { recursive: true }); + + const keyName = `token:default:${email}`; + const fileName = percentEncode(keyName); + const password = ''; // Empty password is supported by gog + + const jwe = await createKeyringEntry(refreshToken, scopes, password); + d.writeFileSync(path.join(keyringDir, fileName), jwe, { mode: 0o600 }); + + console.log(`[gog] Wrote keyring entry for ${email}`); + + // Set env vars for gog discovery + env.GOG_KEYRING_BACKEND = 'file'; + env.GOG_KEYRING_PASSWORD = ''; + env.GOG_ACCOUNT = email; + } else { + if (!email) console.warn('[gog] No email in credentials — keyring entry skipped, gog may not work'); + if (!refreshToken) console.warn('[gog] No refresh_token in credentials — keyring entry skipped'); + } + + return true; +} From 4c1f0b9b438c12b12572fdd3ef14b40ab4dd99ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:20:40 +0100 Subject: [PATCH 50/87] refactor(kiloclaw): replace gws-credentials with gog-credentials in controller --- .../controller/src/gog-credentials.test.ts | 12 +- kiloclaw/controller/src/gog-credentials.ts | 9 +- .../controller/src/gws-credentials.test.ts | 184 ------------------ kiloclaw/controller/src/gws-credentials.ts | 116 ----------- kiloclaw/controller/src/index.ts | 11 +- 5 files changed, 20 insertions(+), 312 deletions(-) delete mode 100644 kiloclaw/controller/src/gws-credentials.test.ts delete mode 100644 kiloclaw/controller/src/gws-credentials.ts diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index 445ca2c4c..d5fc3539c 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -67,8 +67,8 @@ describe('writeGogCredentials', () => { }); // Should write a keyring file with percent-encoded name - const keyringCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => (c[0] as string).includes('keyring/') + const keyringCall = deps.writeFileSync.mock.calls.find((c: unknown[]) => + (c[0] as string).includes('keyring/') ); expect(keyringCall).toBeDefined(); const keyringPath = keyringCall![0] as string; @@ -179,8 +179,8 @@ describe('writeGogCredentials', () => { }; await writeGogCredentials(env, '/tmp/gogcli-test', deps); - const keyringCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => (c[0] as string).includes('keyring/') + const keyringCall = deps.writeFileSync.mock.calls.find((c: unknown[]) => + (c[0] as string).includes('keyring/') ); const keyringPath = keyringCall![0] as string; // + must be percent-encoded as %2B @@ -212,8 +212,8 @@ describe('writeGogCredentials', () => { expect(credentialsCall).toBeDefined(); // Keyring file should NOT be written (no email) - const keyringCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => (c[0] as string).includes('keyring/') + const keyringCall = deps.writeFileSync.mock.calls.find((c: unknown[]) => + (c[0] as string).includes('keyring/') ); expect(keyringCall).toBeUndefined(); diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index b9b6d3971..7707a2ef8 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -131,7 +131,8 @@ export async function writeGogCredentials( const fs = await import('node:fs'); const d: GogCredentialsDeps = { mkdirSync: deps?.mkdirSync ?? ((dir, opts) => fs.default.mkdirSync(dir, opts)), - writeFileSync: deps?.writeFileSync ?? ((p, data, opts) => fs.default.writeFileSync(p, data, opts)), + writeFileSync: + deps?.writeFileSync ?? ((p, data, opts) => fs.default.writeFileSync(p, data, opts)), unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), }; @@ -195,8 +196,10 @@ export async function writeGogCredentials( env.GOG_KEYRING_PASSWORD = ''; env.GOG_ACCOUNT = email; } else { - if (!email) console.warn('[gog] No email in credentials — keyring entry skipped, gog may not work'); - if (!refreshToken) console.warn('[gog] No refresh_token in credentials — keyring entry skipped'); + if (!email) + console.warn('[gog] No email in credentials — keyring entry skipped, gog may not work'); + if (!refreshToken) + console.warn('[gog] No refresh_token in credentials — keyring entry skipped'); } return true; diff --git a/kiloclaw/controller/src/gws-credentials.test.ts b/kiloclaw/controller/src/gws-credentials.test.ts deleted file mode 100644 index a8689fb68..000000000 --- a/kiloclaw/controller/src/gws-credentials.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import path from 'node:path'; -import { writeGwsCredentials, installGwsSkills, type GwsCredentialsDeps } from './gws-credentials'; - -import fs from 'node:fs'; - -vi.mock('node:child_process', () => ({ - exec: vi.fn((_cmd: string, cb: (err: Error | null) => void) => cb(null)), -})); - -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs'); - return { - ...actual, - default: { - ...actual, - // Default: marker file not found (ENOENT) — so installGwsSkills proceeds - accessSync: vi.fn(() => { - throw new Error('ENOENT'); - }), - writeFileSync: vi.fn(), - }, - }; -}); - -function mockDeps() { - return { - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), - } satisfies GwsCredentialsDeps; -} - -describe('writeGwsCredentials', () => { - it('writes credential files when both env vars are set', () => { - const deps = mockDeps(); - const dir = '/tmp/gws-test'; - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}', - GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}', - }; - const result = writeGwsCredentials(env, dir, deps); - - expect(result).toBe(true); - expect(deps.mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); - expect(deps.writeFileSync).toHaveBeenCalledWith( - path.join(dir, 'client_secret.json'), - '{"client_id":"test"}', - { mode: 0o600 } - ); - expect(deps.writeFileSync).toHaveBeenCalledWith( - path.join(dir, 'credentials.json'), - '{"refresh_token":"rt"}', - { mode: 0o600 } - ); - expect(env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE).toBe(path.join(dir, 'credentials.json')); - }); - - it('skips when GOOGLE_CLIENT_SECRET_JSON is missing', () => { - const deps = mockDeps(); - const result = writeGwsCredentials( - { GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}' }, - '/tmp/gws-test', - deps - ); - - expect(result).toBe(false); - expect(deps.mkdirSync).not.toHaveBeenCalled(); - expect(deps.writeFileSync).not.toHaveBeenCalled(); - }); - - it('skips when GOOGLE_CREDENTIALS_JSON is missing', () => { - const deps = mockDeps(); - const result = writeGwsCredentials( - { GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}' }, - '/tmp/gws-test', - deps - ); - - expect(result).toBe(false); - expect(deps.mkdirSync).not.toHaveBeenCalled(); - }); - - it('skips when both env vars are missing', () => { - const deps = mockDeps(); - const result = writeGwsCredentials({}, '/tmp/gws-test', deps); - - expect(result).toBe(false); - }); - - it('removes stale credential files when env vars are absent', () => { - const deps = mockDeps(); - const dir = '/tmp/gws-test'; - writeGwsCredentials({}, dir, deps); - - expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'client_secret.json')); - expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); - expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'token_cache.json')); - }); - - it('ignores missing files during cleanup', () => { - const deps = mockDeps(); - deps.unlinkSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const dir = '/tmp/gws-test'; - - // Should not throw - const result = writeGwsCredentials({}, dir, deps); - expect(result).toBe(false); - }); - - it('removes stale token cache when writing fresh credentials', () => { - const deps = mockDeps(); - const dir = '/tmp/gws-test'; - writeGwsCredentials( - { - GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}', - GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}', - }, - dir, - deps - ); - - expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'token_cache.json')); - }); - - it('calls installGwsSkills when credentials are written', async () => { - const { exec } = await import('node:child_process'); - const deps = mockDeps(); - (exec as unknown as ReturnType).mockClear(); - - writeGwsCredentials( - { - GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"test"}', - GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"rt"}', - }, - '/tmp/gws-test', - deps - ); - - expect(exec).toHaveBeenCalledWith( - 'npx -y skills@1.4.4 add https://github.com/googleworkspace/cli --yes --global', - expect.any(Function) - ); - }); - - it('does not call installGwsSkills when credentials are absent', async () => { - const { exec } = await import('node:child_process'); - const deps = mockDeps(); - (exec as unknown as ReturnType).mockClear(); - - writeGwsCredentials({}, '/tmp/gws-test', deps); - - expect(exec).not.toHaveBeenCalled(); - }); -}); - -describe('installGwsSkills', () => { - it('runs npx skills add command when marker file is absent', async () => { - const { exec } = await import('node:child_process'); - (exec as unknown as ReturnType).mockClear(); - vi.mocked(fs.accessSync).mockImplementation(() => { - throw new Error('ENOENT'); - }); - - installGwsSkills(); - - expect(exec).toHaveBeenCalledWith( - 'npx -y skills@1.4.4 add https://github.com/googleworkspace/cli --yes --global', - expect.any(Function) - ); - }); - - it('skips install when marker file exists', async () => { - const { exec } = await import('node:child_process'); - (exec as unknown as ReturnType).mockClear(); - vi.mocked(fs.accessSync).mockImplementation(() => undefined); - - installGwsSkills(); - - expect(exec).not.toHaveBeenCalled(); - }); -}); diff --git a/kiloclaw/controller/src/gws-credentials.ts b/kiloclaw/controller/src/gws-credentials.ts deleted file mode 100644 index ca007ce77..000000000 --- a/kiloclaw/controller/src/gws-credentials.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Writes Google Workspace CLI (gws) credential files to disk and - * installs gws agent skills for OpenClaw. - * - * When the container starts with GOOGLE_CLIENT_SECRET_JSON and - * GOOGLE_CREDENTIALS_JSON env vars, this module writes them to - * ~/.config/gws/ so the gws CLI picks them up automatically. - */ -import { exec } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -// Use /root explicitly — OpenClaw changes HOME to the workspace dir at runtime, -// but we need credentials at a stable, absolute path that gws can always find. -const GWS_CONFIG_DIR = '/root/.config/gws'; -const CLIENT_SECRET_FILE = 'client_secret.json'; -const CREDENTIALS_FILE = 'credentials.json'; -const TOKEN_CACHE_FILE = 'token_cache.json'; - -export type GwsCredentialsDeps = { - mkdirSync: (dir: string, opts: { recursive: boolean }) => void; - writeFileSync: (path: string, data: string, opts: { mode: number }) => void; - unlinkSync: (path: string) => void; -}; - -const defaultDeps: GwsCredentialsDeps = { - mkdirSync: (dir, opts) => fs.mkdirSync(dir, opts), - writeFileSync: (p, data, opts) => fs.writeFileSync(p, data, opts), - unlinkSync: p => fs.unlinkSync(p), -}; - -/** - * Write gws credential files if the corresponding env vars are set. - * Returns true if credentials were written, false if skipped. - * - * Side effect: mutates the passed `env` record by setting - * GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE so gws finds the credentials - * when HOME points to the OpenClaw workspace dir. - */ -export function writeGwsCredentials( - env: Record = process.env as Record, - configDir = GWS_CONFIG_DIR, - deps: GwsCredentialsDeps = defaultDeps -): boolean { - const clientSecret = env.GOOGLE_CLIENT_SECRET_JSON; - const credentials = env.GOOGLE_CREDENTIALS_JSON; - - if (!clientSecret || !credentials) { - // Clean up stale credential files from a previous run (e.g. after disconnect) - for (const file of [CLIENT_SECRET_FILE, CREDENTIALS_FILE, TOKEN_CACHE_FILE]) { - const filePath = path.join(configDir, file); - try { - deps.unlinkSync(filePath); - console.log(`[gws] Removed stale ${filePath}`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - } - return false; - } - - deps.mkdirSync(configDir, { recursive: true }); - - // Remove stale token cache — gws encrypts it with machine-specific keys, so a - // cache from a previous container (or the setup image) can't be decrypted here. - try { - deps.unlinkSync(path.join(configDir, TOKEN_CACHE_FILE)); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - - deps.writeFileSync(path.join(configDir, CLIENT_SECRET_FILE), clientSecret, { mode: 0o600 }); - deps.writeFileSync(path.join(configDir, CREDENTIALS_FILE), credentials, { mode: 0o600 }); - - // Set env var so gws finds credentials even when HOME changes (OpenClaw sets - // HOME to the workspace dir, but credentials live under /root/.config/gws/). - env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE = path.join(configDir, CREDENTIALS_FILE); - - console.log(`[gws] Wrote credentials to ${configDir}`); - - // Install gws agent skills in the background (non-blocking, best-effort) - installGwsSkills(); - - return true; -} - -/** - * Install gws agent skills for OpenClaw via the `skills` CLI. - * Runs in the background — logs outcome but never blocks startup. - * Skips if skills are already installed (marker file check). - */ -export function installGwsSkills(): void { - const markerFile = path.join(GWS_CONFIG_DIR, '.skills-installed'); - try { - fs.accessSync(markerFile); - console.log('[gws] Agent skills already installed, skipping'); - return; - } catch { - // Marker not found — proceed with install - } - - const cmd = 'npx -y skills@1.4.4 add https://github.com/googleworkspace/cli --yes --global'; - console.log('[gws] Installing agent skills in background...'); - exec(cmd, (error, _stdout, stderr) => { - if (error) { - console.error('[gws] Failed to install agent skills:', stderr || error.message); - } else { - console.log('[gws] Agent skills installed successfully'); - try { - fs.writeFileSync(markerFile, new Date().toISOString(), { mode: 0o600 }); - } catch { - // Non-fatal — will retry next startup - } - } - }); -} diff --git a/kiloclaw/controller/src/index.ts b/kiloclaw/controller/src/index.ts index 9c17dd86c..2c9cb5968 100644 --- a/kiloclaw/controller/src/index.ts +++ b/kiloclaw/controller/src/index.ts @@ -13,7 +13,7 @@ import { registerHealthRoute } from './routes/health'; import { registerGatewayRoutes } from './routes/gateway'; import { registerConfigRoutes } from './routes/config'; import { CONTROLLER_COMMIT, CONTROLLER_VERSION } from './version'; -import { writeGwsCredentials } from './gws-credentials'; +import { writeGogCredentials } from './gog-credentials'; export type RuntimeConfig = { port: number; @@ -114,8 +114,13 @@ async function handleHttpRequest( export async function startController(env: NodeJS.ProcessEnv = process.env): Promise { const config = loadRuntimeConfig(env); - // Write Google Workspace CLI credentials if available - writeGwsCredentials(env as Record); + // writeGogCredentials is async (JWE encryption) but we don't await it — + // credential writing is best-effort and should not block controller startup. + // This is safe: the gateway process doesn't use gog credentials at startup; + // gog is only invoked later by user/bot actions, well after this completes. + writeGogCredentials(env as Record).catch(err => { + console.error('[gog] Failed to write credentials:', err); + }); const supervisor = createSupervisor({ gatewayArgs: config.gatewayArgs, From 8ab25f34635f2a02cbfd7255c94116067e7f9852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:21:27 +0100 Subject: [PATCH 51/87] chore(kiloclaw): remove gws CLI from container image --- kiloclaw/Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/kiloclaw/Dockerfile b/kiloclaw/Dockerfile index 67923f3f4..8eb309dad 100644 --- a/kiloclaw/Dockerfile +++ b/kiloclaw/Dockerfile @@ -54,9 +54,6 @@ RUN npm install -g mcporter@0.7.3 # Install summarize (web page summarization CLI) RUN npm install -g @steipete/summarize@0.11.1 -# Install gws CLI (Google Workspace CLI) -RUN npm install -g @googleworkspace/cli@0.11.1 - # Install Go (available at runtime for users to `go install` additional tools) ENV GO_VERSION=1.26.0 RUN ARCH="$(dpkg --print-architecture)" \ From d52cee5bab9d4623382c51ca1223190a0e4bed47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:24:41 +0100 Subject: [PATCH 52/87] chore(kiloclaw): remove gws and expect from google-setup image --- kiloclaw/google-setup/Dockerfile | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 75f36b17b..10cfa3ff1 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -1,13 +1,12 @@ FROM node:22-slim -# Install dependencies for gcloud CLI +# Install dependencies for gcloud CLI + readline for interactive prompts RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ python3 \ apt-transport-https \ ca-certificates \ gnupg \ - expect \ && rm -rf /var/lib/apt/lists/* # Install gcloud CLI @@ -17,10 +16,6 @@ RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dea && apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \ && rm -rf /var/lib/apt/lists/* -# Install gws CLI (Google Workspace CLI) -# https://github.com/googleworkspace/cli -RUN npm install -g @googleworkspace/cli@0.11.1 - WORKDIR /app COPY setup.mjs ./ From 7d89bc439330f147bfa9d51afe1d40c37d9ae76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:24:44 +0100 Subject: [PATCH 53/87] feat(kiloclaw): rewrite google-setup to use gcloud + gog instead of gws --- kiloclaw/google-setup/setup.mjs | 300 ++++++++++++++++++++++++-------- 1 file changed, 230 insertions(+), 70 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 5cee882ff..4945e7d2c 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -6,20 +6,21 @@ * Docker-based tool that: * 1. Validates the user's KiloCode API key against the kiloclaw worker * 2. Fetches the worker's RSA public key for credential encryption - * 3. Runs `gws auth setup` to create an OAuth client in the user's Google Cloud project - * 4. Runs our own OAuth flow (localhost callback) to get a refresh token - * 5. Encrypts the client_secret + credentials with the worker's public key - * 6. POSTs the encrypted bundle to the kiloclaw worker (user-facing JWT auth) + * 3. Signs into gcloud, creates/selects a GCP project, enables APIs + * 4. Prompts user to create a Desktop OAuth client in Cloud Console + * 5. Runs our own OAuth flow (localhost callback) to get a refresh token + * 6. Fetches the user's email address + * 7. Encrypts the client_secret + credentials with the worker's public key + * 8. POSTs the encrypted bundle to the kiloclaw worker * * Usage: * docker run -it --network host kilocode/google-setup --api-key=kilo_abc123 */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; +import { spawn, execSync } from 'node:child_process'; import crypto from 'node:crypto'; import http from 'node:http'; +import readline from 'node:readline'; // --------------------------------------------------------------------------- // CLI args @@ -66,16 +67,81 @@ const authHeaders = { }; // --------------------------------------------------------------------------- -// Scopes +// Scopes — all gog user services + pubsub // --------------------------------------------------------------------------- const SCOPES = [ + 'openid', + 'email', 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.settings.basic', + 'https://www.googleapis.com/auth/gmail.settings.sharing', 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/presentations', + 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/tasks', + 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/directory.readonly', + 'https://www.googleapis.com/auth/forms.body', + 'https://www.googleapis.com/auth/forms.responses.readonly', + 'https://www.googleapis.com/auth/chat.spaces', + 'https://www.googleapis.com/auth/chat.messages', + 'https://www.googleapis.com/auth/chat.memberships', + 'https://www.googleapis.com/auth/classroom.courses', + 'https://www.googleapis.com/auth/classroom.rosters', + 'https://www.googleapis.com/auth/script.projects', + 'https://www.googleapis.com/auth/script.deployments', + 'https://www.googleapis.com/auth/keep', + 'https://www.googleapis.com/auth/pubsub', ]; +// APIs to enable in the GCP project +const GCP_APIS = [ + 'gmail.googleapis.com', + 'calendar-json.googleapis.com', + 'drive.googleapis.com', + 'docs.googleapis.com', + 'slides.googleapis.com', + 'sheets.googleapis.com', + 'tasks.googleapis.com', + 'people.googleapis.com', + 'forms.googleapis.com', + 'chat.googleapis.com', + 'classroom.googleapis.com', + 'script.googleapis.com', + 'keep.googleapis.com', + 'pubsub.googleapis.com', +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function ask(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(question, answer => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +function runCommand(cmd, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'inherit', ...opts }); + child.on('close', code => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)))); + child.on('error', reject); + }); +} + +function runCommandOutput(cmd, args) { + return execSync([cmd, ...args].join(' '), { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + // --------------------------------------------------------------------------- // Step 1: Validate API key // --------------------------------------------------------------------------- @@ -88,8 +154,6 @@ if (!validateRes.ok) { process.exit(1); } -// Validate auth by checking Google credentials status — returns 200 if auth passes, -// or 401/403 if the key is invalid. const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { headers: authHeaders, }); @@ -99,7 +163,7 @@ if (authCheckRes.status === 401 || authCheckRes.status === 403) { process.exit(1); } -console.log('API key verified.'); +console.log('API key verified.\n'); // --------------------------------------------------------------------------- // Step 2: Fetch public key for encryption @@ -121,66 +185,142 @@ if (!publicKeyPem || !publicKeyPem.includes('BEGIN PUBLIC KEY')) { } // --------------------------------------------------------------------------- -// Step 3: Run gws auth setup (project + OAuth client only, no login) +// Step 3: Sign into gcloud and set up GCP project + APIs // --------------------------------------------------------------------------- -console.log('Setting up Google OAuth client...'); -console.log('Follow the prompts to create an OAuth client in your Google Cloud project.\n'); - -// Use `expect` to wrap `gws auth setup` in a real PTY so all interactive prompts -// work normally, while auto-answering "n" to the final "Run gws auth login now?" prompt. -// The "Y/n" pattern matches gws CLI's confirmation prompt. If gws changes this prompt -// text in a future version, this interaction will need updating. -// Tested with @googleworkspace/cli (gws) as of 2026-03. -// Write expect script to a temp file to avoid JS→shell→Tcl escaping issues. -const expectScriptPath = '/tmp/gws-setup.exp'; -fs.writeFileSync(expectScriptPath, [ - '#!/usr/bin/expect -f', - 'set timeout -1', - 'spawn gws auth setup', - 'interact -o "Y/n" {', - ' send "n\\r"', - '}', - 'catch wait result', - 'exit [lindex $result 3]', - '', -].join('\n')); - -const setupExitCode = await new Promise((resolve) => { - const child = spawn('expect', [expectScriptPath], { - stdio: 'inherit', - }); - child.on('close', (code) => resolve(code)); - child.on('error', () => resolve(1)); -}); +console.log('Signing into Google Cloud...'); +console.log('A browser window will open for you to sign in.\n'); -if (setupExitCode !== 0) { - console.error('\nFailed to set up OAuth client. Please try again.'); - process.exit(1); +await runCommand('gcloud', ['auth', 'login', '--brief']); + +const gcloudAccount = runCommandOutput('gcloud', ['config', 'get-value', 'account']); +console.log(`\nSigned in as: ${gcloudAccount}\n`); + +// Project selection: create new or use existing +console.log('Google Cloud project setup:'); +console.log(' 1. Create a new project (recommended)'); +console.log(' 2. Use an existing project\n'); + +const projectChoice = await ask('Choose (1 or 2): '); +let projectId; + +if (projectChoice === '2') { + // List existing projects + console.log('\nFetching your projects...'); + try { + await runCommand('gcloud', ['projects', 'list', '--format=table(projectId,name)']); + } catch { + console.warn('Could not list projects. You can still enter a project ID manually.'); + } + projectId = await ask('\nEnter your project ID: '); +} else { + // Generate a project ID based on date + const now = new Date(); + const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; + const defaultId = `kiloclaw-${dateStr}`; + const inputId = await ask(`Project ID [${defaultId}]: `); + projectId = inputId || defaultId; + + console.log(`\nCreating project "${projectId}"...`); + try { + await runCommand('gcloud', ['projects', 'create', projectId, '--set-as-default']); + console.log('Project created.\n'); + } catch { + console.error(`Failed to create project "${projectId}". It may already exist.`); + console.error('Try a different name, or choose option 2 to use an existing project.'); + process.exit(1); + } +} + +// Set as active project +await runCommand('gcloud', ['config', 'set', 'project', projectId]); +console.log(`\nUsing project: ${projectId}`); + +// Enable APIs +console.log('\nEnabling Google APIs (this may take a minute)...'); +await runCommand('gcloud', ['services', 'enable', ...GCP_APIS, `--project=${projectId}`]); +console.log('APIs enabled.\n'); + +// Configure OAuth consent screen via REST API +console.log('Configuring OAuth consent screen...'); +const accessToken = runCommandOutput('gcloud', ['auth', 'print-access-token']); + +// Check if brand already exists +const brandsRes = await fetch( + `https://iap.googleapis.com/v1/projects/${projectId}/brands`, + { headers: { authorization: `Bearer ${accessToken}` } } +); +const brandsData = await brandsRes.json(); + +if (!brandsData.brands?.length) { + const createBrandRes = await fetch( + `https://iap.googleapis.com/v1/projects/${projectId}/brands`, + { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + applicationTitle: 'KiloClaw', + supportEmail: gcloudAccount, + }), + } + ); + if (!createBrandRes.ok) { + console.warn('Could not auto-configure consent screen. You may need to set it up manually.'); + console.warn(`Visit: https://console.cloud.google.com/apis/credentials/consent?project=${projectId}\n`); + } else { + console.log('OAuth consent screen configured.\n'); + } +} else { + console.log('OAuth consent screen already configured.\n'); } // --------------------------------------------------------------------------- -// Step 4: Read client_secret.json and run our own OAuth flow +// Step 4: Manual OAuth client creation // --------------------------------------------------------------------------- -const gwsDir = path.join(process.env.HOME ?? '/root', '.config', 'gws'); -const clientSecretPath = path.join(gwsDir, 'client_secret.json'); +const credentialsUrl = `https://console.cloud.google.com/apis/credentials?project=${projectId}`; -if (!fs.existsSync(clientSecretPath)) { - console.error('client_secret.json not found. The setup step may not have completed.'); - process.exit(1); -} +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(' Create an OAuth client'); +console.log(''); +console.log(` 1. Open: ${credentialsUrl}`); +console.log(' 2. Click "Create Credentials" → "OAuth client ID"'); +console.log(' 3. Application type: "Desktop app"'); +console.log(' 4. Click "Create"'); +console.log(' 5. Copy the Client ID and Client Secret below'); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); -const clientSecretJson = fs.readFileSync(clientSecretPath, 'utf8'); -const clientConfig = JSON.parse(clientSecretJson); -const { client_id, client_secret } = clientConfig.installed || clientConfig.web || {}; +const clientId = await ask('Client ID: '); +const clientSecret = await ask('Client Secret: '); -if (!client_id || !client_secret) { - console.error('Invalid client_secret.json format.'); +if (!clientId || !clientSecret) { + console.error('Client ID and Client Secret are required.'); process.exit(1); } -// Start a local HTTP server for the OAuth callback +// Build client_secret.json in the standard Google format +const clientSecretObj = { + installed: { + client_id: clientId, + project_id: projectId, + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_secret: clientSecret, + redirect_uris: ['http://localhost'], + }, +}; +const clientSecretJson = JSON.stringify(clientSecretObj); + +// --------------------------------------------------------------------------- +// Step 5: Custom OAuth flow to get refresh token +// --------------------------------------------------------------------------- + +console.log('\nStarting OAuth authorization...'); + const { code, redirectUri } = await new Promise((resolve, reject) => { let callbackPort; @@ -222,7 +362,7 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { server.listen(0, '127.0.0.1', () => { callbackPort = server.address().port; const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); - authUrl.searchParams.set('client_id', client_id); + authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}`); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', SCOPES.join(' ')); @@ -249,8 +389,8 @@ const tokenRes = await fetch('https://oauth2.googleapis.com/token', { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, - client_id, - client_secret, + client_id: clientId, + client_secret: clientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', }), @@ -263,21 +403,38 @@ if (!tokenRes.ok) { } const tokens = await tokenRes.json(); -// Build a credentials object similar to what gws stores -// Omit client_id/client_secret from the credentials envelope to avoid -// duplicating the OAuth client secret across both envelopes. -// The worker merges them from the clientSecret envelope at decryption time. +console.log('OAuth tokens obtained.'); + +// --------------------------------------------------------------------------- +// Step 6: Fetch user email +// --------------------------------------------------------------------------- + +console.log('Fetching account info...'); + +const userinfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { authorization: `Bearer ${tokens.access_token}` }, +}); + +let userEmail; +if (userinfoRes.ok) { + const userinfo = await userinfoRes.json(); + userEmail = userinfo.email; + console.log(`Account: ${userEmail}`); +} else { + console.warn('Could not fetch user email. gog account auto-selection will not work.'); +} + +// Build credentials object — includes email for gog keyring key naming const credentialsObj = { type: 'authorized_user', ...tokens, scopes: SCOPES, + ...(userEmail && { email: userEmail }), }; const credentialsJson = JSON.stringify(credentialsObj); -console.log('OAuth tokens obtained.'); - // --------------------------------------------------------------------------- -// Step 5: Encrypt credentials with worker's public key +// Step 7: Encrypt credentials with worker's public key // --------------------------------------------------------------------------- function encryptEnvelope(plaintext, pemKey) { @@ -308,7 +465,7 @@ const encryptedBundle = { }; // --------------------------------------------------------------------------- -// Step 6: POST to worker +// Step 8: POST to worker // --------------------------------------------------------------------------- console.log('Sending credentials to your kiloclaw instance...'); @@ -327,5 +484,8 @@ if (!postRes.ok) { console.log('\nGoogle account connected!'); console.log('Credentials sent to your kiloclaw instance.'); -console.log('\nYour bot can now use Gmail, Calendar, and Docs.'); +if (userEmail) { + console.log(`Connected account: ${userEmail}`); +} +console.log('\nYour bot can now use Gmail, Calendar, Drive, Docs, Sheets, and more.'); console.log('Redeploy your kiloclaw instance to activate.'); From aa48c6dc7e4a7d72160fe57d54c7686ea1471707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:24:47 +0100 Subject: [PATCH 54/87] docs(kiloclaw): update google-setup README for gog migration --- kiloclaw/google-setup/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/kiloclaw/google-setup/README.md b/kiloclaw/google-setup/README.md index 9c99620fa..64dfa4d8f 100644 --- a/kiloclaw/google-setup/README.md +++ b/kiloclaw/google-setup/README.md @@ -5,10 +5,11 @@ Docker image that guides users through connecting their Google account to KiloCl ## What it does 1. Validates the user's KiloCode API key -2. Runs `gws auth setup` to create an OAuth client in the user's Google Cloud project -3. Runs a local OAuth flow to obtain refresh tokens -4. Encrypts credentials with the worker's public key -5. POSTs the encrypted bundle to the KiloClaw worker +2. Signs into gcloud, creates/selects a GCP project, enables Google APIs +3. Guides user through creating a Desktop OAuth client in Google Cloud Console +4. Runs a local OAuth flow to obtain refresh tokens +5. Encrypts credentials with the worker's public key +6. POSTs the encrypted bundle to the KiloClaw worker ## Usage @@ -57,7 +58,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \ ```bash docker buildx build --platform linux/amd64,linux/arm64 \ -t ghcr.io/kilo-org/google-setup:latest \ - -t ghcr.io/kilo-org/google-setup:v1.0.0 \ + -t ghcr.io/kilo-org/google-setup:v2.0.0 \ --push \ kiloclaw/google-setup/ ``` From 1244e4786b8863650ba21c10f6d8dcb2fb91b852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:25:53 +0100 Subject: [PATCH 55/87] refactor(kiloclaw): rename test/ to e2e/ --- kiloclaw/{test => e2e}/docker-image-testing.md | 0 kiloclaw/{test => e2e}/google-credentials-integration.mjs | 4 ++-- kiloclaw/{test => e2e}/google-setup-e2e.mjs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename kiloclaw/{test => e2e}/docker-image-testing.md (100%) rename kiloclaw/{test => e2e}/google-credentials-integration.mjs (99%) rename kiloclaw/{test => e2e}/google-setup-e2e.mjs (98%) diff --git a/kiloclaw/test/docker-image-testing.md b/kiloclaw/e2e/docker-image-testing.md similarity index 100% rename from kiloclaw/test/docker-image-testing.md rename to kiloclaw/e2e/docker-image-testing.md diff --git a/kiloclaw/test/google-credentials-integration.mjs b/kiloclaw/e2e/google-credentials-integration.mjs similarity index 99% rename from kiloclaw/test/google-credentials-integration.mjs rename to kiloclaw/e2e/google-credentials-integration.mjs index 4e5f0dba9..b7d0c1296 100644 --- a/kiloclaw/test/google-credentials-integration.mjs +++ b/kiloclaw/e2e/google-credentials-integration.mjs @@ -9,8 +9,8 @@ * The test creates a temporary user in the DB for JWT auth tests, and cleans up after. * * Usage: - * node kiloclaw/test/google-credentials-integration.mjs - * DATABASE_URL=postgres://... WORKER_URL=http://localhost:9000 node kiloclaw/test/google-credentials-integration.mjs + * node kiloclaw/e2e/google-credentials-integration.mjs + * DATABASE_URL=postgres://... WORKER_URL=http://localhost:9000 node kiloclaw/e2e/google-credentials-integration.mjs */ import { SignJWT } from 'jose'; diff --git a/kiloclaw/test/google-setup-e2e.mjs b/kiloclaw/e2e/google-setup-e2e.mjs similarity index 98% rename from kiloclaw/test/google-setup-e2e.mjs rename to kiloclaw/e2e/google-setup-e2e.mjs index 5be76a2c0..63eb31c60 100644 --- a/kiloclaw/test/google-setup-e2e.mjs +++ b/kiloclaw/e2e/google-setup-e2e.mjs @@ -16,7 +16,7 @@ * 6. Cleans up * * Usage: - * node kiloclaw/test/google-setup-e2e.mjs + * node kiloclaw/e2e/google-setup-e2e.mjs */ import { SignJWT } from 'jose'; @@ -34,7 +34,7 @@ const USER_ID = `test-google-setup-${Date.now()}`; const DOCKER_IMAGE = 'kilocode/google-setup'; const DOCKER_CONTEXT = path.resolve(__dirname, '../google-setup'); -// We use --network host so the gws CLI's random OAuth callback port is reachable +// We use --network host so the OAuth callback port is reachable // from the browser. This also means localhost in the container reaches the host, // so we don't need host.docker.internal. const DOCKER_WORKER_URL = WORKER_URL; From 30944573d925ea34aa5d9b77f04c51875d72274e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:40:34 +0100 Subject: [PATCH 56/87] chore: remove gws entry from changelog --- src/app/(app)/claw/components/changelog-data.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/app/(app)/claw/components/changelog-data.ts b/src/app/(app)/claw/components/changelog-data.ts index a047fb50a..e8dd6889f 100644 --- a/src/app/(app)/claw/components/changelog-data.ts +++ b/src/app/(app)/claw/components/changelog-data.ts @@ -10,13 +10,6 @@ export type ChangelogEntry = { // Newest entries first. Developers add new entries to the top of this array. export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ - { - date: '2026-03-11', - description: - 'Added Google Workspace CLI (gws) to the default image. Agents can use gws for Google Workspace API access when a Google account is connected.', - category: 'feature', - deployHint: 'redeploy_required', - }, { date: '2026-03-10', description: From 376836138ae686bee8e96e549c5cd268dffd448d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 18:58:00 +0100 Subject: [PATCH 57/87] refactor: replace api key with token --- kiloclaw/e2e/google-setup-e2e.mjs | 2 +- kiloclaw/google-setup/README.md | 6 +-- kiloclaw/google-setup/setup.mjs | 74 +++++++++++-------------------- src/routers/kiloclaw-router.ts | 4 +- 4 files changed, 32 insertions(+), 54 deletions(-) diff --git a/kiloclaw/e2e/google-setup-e2e.mjs b/kiloclaw/e2e/google-setup-e2e.mjs index 63eb31c60..9f645aaec 100644 --- a/kiloclaw/e2e/google-setup-e2e.mjs +++ b/kiloclaw/e2e/google-setup-e2e.mjs @@ -187,7 +187,7 @@ const dockerArgs = [ 'run', '--rm', '-it', '--network', 'host', DOCKER_IMAGE, - `--api-key=${jwt}`, + `--token=${jwt}`, `--worker-url=${DOCKER_WORKER_URL}`, ]; diff --git a/kiloclaw/google-setup/README.md b/kiloclaw/google-setup/README.md index 64dfa4d8f..63311aafe 100644 --- a/kiloclaw/google-setup/README.md +++ b/kiloclaw/google-setup/README.md @@ -4,7 +4,7 @@ Docker image that guides users through connecting their Google account to KiloCl ## What it does -1. Validates the user's KiloCode API key +1. Validates the user's session token (JWT) 2. Signs into gcloud, creates/selects a GCP project, enables Google APIs 3. Guides user through creating a Desktop OAuth client in Google Cloud Console 4. Runs a local OAuth flow to obtain refresh tokens @@ -14,14 +14,14 @@ Docker image that guides users through connecting their Google account to KiloCl ## Usage ```bash -docker run -it --network host ghcr.io/kilo-org/google-setup --api-key="YOUR_API_KEY" +docker run -it --network host ghcr.io/kilo-org/google-setup --token="YOUR_SESSION_JWT" ``` For local development against a local worker: ```bash docker run -it --network host ghcr.io/kilo-org/google-setup \ - --api-key="YOUR_API_KEY" \ + --token="YOUR_SESSION_JWT" \ --worker-url=http://localhost:8795 ``` diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 4945e7d2c..24a0f5866 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -4,7 +4,7 @@ * KiloClaw Google Account Setup * * Docker-based tool that: - * 1. Validates the user's KiloCode API key against the kiloclaw worker + * 1. Validates the user's session token (JWT) against the kiloclaw worker * 2. Fetches the worker's RSA public key for credential encryption * 3. Signs into gcloud, creates/selects a GCP project, enables APIs * 4. Prompts user to create a Desktop OAuth client in Cloud Console @@ -14,7 +14,7 @@ * 8. POSTs the encrypted bundle to the kiloclaw worker * * Usage: - * docker run -it --network host kilocode/google-setup --api-key=kilo_abc123 + * docker run -it --network host kilocode/google-setup --token= */ import { spawn, execSync } from 'node:child_process'; @@ -27,17 +27,17 @@ import readline from 'node:readline'; // --------------------------------------------------------------------------- const args = process.argv.slice(2); -const apiKeyArg = args.find(a => a.startsWith('--api-key=')); -const apiKey = apiKeyArg?.substring(apiKeyArg.indexOf('=') + 1); +const tokenArg = args.find(a => a.startsWith('--token=')); +const token = tokenArg?.substring(tokenArg.indexOf('=') + 1); const workerUrlArg = args.find(a => a.startsWith('--worker-url=')); const workerUrl = workerUrlArg ? workerUrlArg.substring(workerUrlArg.indexOf('=') + 1) : 'https://claw.kilo.ai'; -if (!apiKey) { +if (!token) { console.error( - 'Usage: docker run -it --network host kilocode/google-setup --api-key=' + 'Usage: docker run -it --network host kilocode/google-setup --token=' ); process.exit(1); } @@ -62,7 +62,7 @@ try { } const authHeaders = { - authorization: `Bearer ${apiKey}`, + authorization: `Bearer ${token}`, 'content-type': 'application/json', }; @@ -143,10 +143,10 @@ function runCommandOutput(cmd, args) { } // --------------------------------------------------------------------------- -// Step 1: Validate API key +// Step 1: Validate session token // --------------------------------------------------------------------------- -console.log('Validating API key...'); +console.log('Validating session token...'); const validateRes = await fetch(`${workerUrl}/health`); if (!validateRes.ok) { @@ -159,11 +159,11 @@ const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { }); if (authCheckRes.status === 401 || authCheckRes.status === 403) { - console.error('Invalid API key. Check your key and try again.'); + console.error('Invalid or expired session token. Log in to kilo.ai and copy a fresh token.'); process.exit(1); } -console.log('API key verified.\n'); +console.log('Session token verified.\n'); // --------------------------------------------------------------------------- // Step 2: Fetch public key for encryption @@ -241,49 +241,27 @@ console.log('\nEnabling Google APIs (this may take a minute)...'); await runCommand('gcloud', ['services', 'enable', ...GCP_APIS, `--project=${projectId}`]); console.log('APIs enabled.\n'); -// Configure OAuth consent screen via REST API -console.log('Configuring OAuth consent screen...'); -const accessToken = runCommandOutput('gcloud', ['auth', 'print-access-token']); - -// Check if brand already exists -const brandsRes = await fetch( - `https://iap.googleapis.com/v1/projects/${projectId}/brands`, - { headers: { authorization: `Bearer ${accessToken}` } } -); -const brandsData = await brandsRes.json(); - -if (!brandsData.brands?.length) { - const createBrandRes = await fetch( - `https://iap.googleapis.com/v1/projects/${projectId}/brands`, - { - method: 'POST', - headers: { - authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - applicationTitle: 'KiloClaw', - supportEmail: gcloudAccount, - }), - } - ); - if (!createBrandRes.ok) { - console.warn('Could not auto-configure consent screen. You may need to set it up manually.'); - console.warn(`Visit: https://console.cloud.google.com/apis/credentials/consent?project=${projectId}\n`); - } else { - console.log('OAuth consent screen configured.\n'); - } -} else { - console.log('OAuth consent screen already configured.\n'); -} - // --------------------------------------------------------------------------- -// Step 4: Manual OAuth client creation +// Step 4: Configure OAuth consent screen + create OAuth client // --------------------------------------------------------------------------- +const consentUrl = `https://console.cloud.google.com/auth/overview?project=${projectId}`; const credentialsUrl = `https://console.cloud.google.com/apis/credentials?project=${projectId}`; console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(' Configure OAuth consent screen'); +console.log(''); +console.log(` 1. Open: ${consentUrl}`); +console.log(' 2. Click "Get started"'); +console.log(' 3. App name: "KiloClaw", User support email: your email'); +console.log(' 4. Audience: select "External"'); +console.log(' 5. Contact email: your email'); +console.log(' 6. Finish and click "Create"'); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + +await ask('Press Enter when done...'); + +console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' Create an OAuth client'); console.log(''); console.log(` 1. Open: ${credentialsUrl}`); diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index f10ea7076..ca2f7f40b 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -466,13 +466,13 @@ export const kiloclawRouter = createTRPCRouter({ getGoogleSetupCommand: baseProcedure.query(({ ctx }) => { // Short-lived token — the user should run the setup command promptly. // Regenerated on each page load, so 1 hour is sufficient. - const apiKey = generateApiToken(ctx.user, undefined, { + const token = generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.oneHour, }); const isDev = process.env.NODE_ENV === 'development'; const workerFlag = isDev ? ' --worker-url=http://localhost:8795' : ''; return { - command: `docker run -it --network host ghcr.io/kilo-org/google-setup --api-key="${apiKey}"${workerFlag}`, + command: `docker run -it --network host ghcr.io/kilo-org/google-setup --token="${token}"${workerFlag}`, }; }), From 4e3017b79b2b25f6aef6b8478830b6dd357581ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 19:00:27 +0100 Subject: [PATCH 58/87] fix(kiloclaw): remove Keep API scope (not available for desktop OAuth) --- kiloclaw/google-setup/setup.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 24a0f5866..3733be63a 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -94,7 +94,6 @@ const SCOPES = [ 'https://www.googleapis.com/auth/classroom.rosters', 'https://www.googleapis.com/auth/script.projects', 'https://www.googleapis.com/auth/script.deployments', - 'https://www.googleapis.com/auth/keep', 'https://www.googleapis.com/auth/pubsub', ]; @@ -112,7 +111,6 @@ const GCP_APIS = [ 'chat.googleapis.com', 'classroom.googleapis.com', 'script.googleapis.com', - 'keep.googleapis.com', 'pubsub.googleapis.com', ]; From 7293589c526ddbb3f8943df3cfca70cde100025c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 19:07:59 +0100 Subject: [PATCH 59/87] feat(kiloclaw): numbered project picker for existing projects in google-setup --- kiloclaw/google-setup/setup.mjs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 3733be63a..02b970285 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -203,14 +203,36 @@ const projectChoice = await ask('Choose (1 or 2): '); let projectId; if (projectChoice === '2') { - // List existing projects + // List existing projects as a numbered menu console.log('\nFetching your projects...'); + let projects = []; try { - await runCommand('gcloud', ['projects', 'list', '--format=table(projectId,name)']); + const projectsJson = runCommandOutput('gcloud', [ + 'projects', 'list', '--format=json(projectId,name)', '--sort-by=name', + ]); + projects = JSON.parse(projectsJson); } catch { + // fall through — empty list triggers manual entry + } + + if (projects.length > 0) { + console.log(''); + projects.forEach((p, i) => { + const label = p.name ? `${p.projectId} (${p.name})` : p.projectId; + console.log(` ${i + 1}. ${label}`); + }); + console.log(''); + const pick = await ask('Enter number (or project ID): '); + const idx = parseInt(pick, 10); + if (idx >= 1 && idx <= projects.length) { + projectId = projects[idx - 1].projectId; + } else { + projectId = pick; + } + } else { console.warn('Could not list projects. You can still enter a project ID manually.'); + projectId = await ask('\nEnter your project ID: '); } - projectId = await ask('\nEnter your project ID: '); } else { // Generate a project ID based on date const now = new Date(); From a6aebd25eee88089ff0e206691fd485f8c16c7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 19:34:10 +0100 Subject: [PATCH 60/87] fix(kiloclaw): use snake_case JSON fields in gog keyring entries gog's storedToken Go struct uses json tags like "refresh_token", not "RefreshToken". The PascalCase fields caused gog to see an empty refresh token after decrypting the keyring file. --- kiloclaw/controller/src/gog-credentials.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 7707a2ef8..3829fe525 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -50,11 +50,11 @@ async function createKeyringEntry( const services = mapScopesToServices(scopes); const payload = JSON.stringify({ - RefreshToken: refreshToken, - Services: services, + refresh_token: refreshToken, + services: services, // gog stores only full OAuth scopes, not OIDC shorthand like 'openid'/'email' - Scopes: scopes.filter(s => s.startsWith('https://')), - CreatedAt: new Date().toISOString(), + scopes: scopes.filter(s => s.startsWith('https://')), + created_at: new Date().toISOString(), }); const encoder = new TextEncoder(); From 7ec9787ad7eab2edefac832452c9b07c8241641e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 19:50:27 +0100 Subject: [PATCH 61/87] fix(kiloclaw): wrap gog keyring data in 99designs/keyring Item struct The file backend encrypts Item{Key, Data, Label, Description}, not raw token JSON. Data is base64-encoded (Go []byte marshaling). Without the Item wrapper, gog decrypts the file but can't unmarshal the token. --- kiloclaw/controller/src/gog-credentials.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 3829fe525..8bb8c6557 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -42,6 +42,7 @@ function percentEncode(s: string): string { * Uses PBES2-HS256+A128KW for key wrapping and A256GCM for content encryption. */ async function createKeyringEntry( + keyName: string, refreshToken: string, scopes: string[], password: string @@ -49,7 +50,8 @@ async function createKeyringEntry( // Map OAuth scopes to gog service names for the Services field const services = mapScopesToServices(scopes); - const payload = JSON.stringify({ + // The token data that gog reads from Item.Data + const tokenData = JSON.stringify({ refresh_token: refreshToken, services: services, // gog stores only full OAuth scopes, not OIDC shorthand like 'openid'/'email' @@ -57,8 +59,17 @@ async function createKeyringEntry( created_at: new Date().toISOString(), }); + // 99designs/keyring file backend wraps data in an Item struct. + // Item.Data is []byte, which Go's json.Marshal encodes as base64. + const item = JSON.stringify({ + Key: keyName, + Data: Buffer.from(tokenData).toString('base64'), + Label: '', + Description: '', + }); + const encoder = new TextEncoder(); - const jwe = await new CompactEncrypt(encoder.encode(payload)) + const jwe = await new CompactEncrypt(encoder.encode(item)) .setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' }) .encrypt(encoder.encode(password)); @@ -186,7 +197,7 @@ export async function writeGogCredentials( const fileName = percentEncode(keyName); const password = ''; // Empty password is supported by gog - const jwe = await createKeyringEntry(refreshToken, scopes, password); + const jwe = await createKeyringEntry(keyName, refreshToken, scopes, password); d.writeFileSync(path.join(keyringDir, fileName), jwe, { mode: 0o600 }); console.log(`[gog] Wrote keyring entry for ${email}`); From 585482f9155d4da944e66f878cb782f762ec4179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 20:01:37 +0100 Subject: [PATCH 62/87] fix(kiloclaw): write gog config.json with keyring_backend=file gog wasn't finding credentials because the auto keyring backend doesn't use the file backend on Linux by default. Writing config.json ensures gog uses the file backend regardless of env var propagation. --- kiloclaw/controller/src/gog-credentials.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 8bb8c6557..f15cd522f 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -14,6 +14,7 @@ import path from 'node:path'; // Use /root explicitly — OpenClaw changes HOME to the workspace dir at runtime, // but we need credentials at a stable, absolute path that gog can always find. const GOG_CONFIG_DIR = '/root/.config/gogcli'; +const CONFIG_FILE = 'config.json'; const CREDENTIALS_FILE = 'credentials.json'; const KEYRING_DIR = 'keyring'; @@ -202,7 +203,18 @@ export async function writeGogCredentials( console.log(`[gog] Wrote keyring entry for ${email}`); - // Set env vars for gog discovery + // Write config.json so gog uses the file keyring backend. + // This is more reliable than env vars since gog may be invoked as a + // grandchild process (controller → OpenClaw gateway → gog) and env + // vars set on process.env may not propagate through all layers. + d.writeFileSync( + path.join(configDir, CONFIG_FILE), + JSON.stringify({ keyring_backend: 'file' }), + { mode: 0o600 } + ); + console.log(`[gog] Wrote config to ${configDir}/${CONFIG_FILE}`); + + // Set env vars as a belt-and-suspenders fallback env.GOG_KEYRING_BACKEND = 'file'; env.GOG_KEYRING_PASSWORD = ''; env.GOG_ACCOUNT = email; From 32327366b8edff195f7bfea2cee22c296be907de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 20:09:10 +0100 Subject: [PATCH 63/87] fix(kiloclaw): export GOG_KEYRING_PASSWORD in start-openclaw.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file keyring backend needs an empty password to decrypt entries. Setting it in the startup script ensures it propagates to all child processes (controller → gateway → gog). --- kiloclaw/start-openclaw.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/kiloclaw/start-openclaw.sh b/kiloclaw/start-openclaw.sh index 9831378bc..fd7bb6e94 100644 --- a/kiloclaw/start-openclaw.sh +++ b/kiloclaw/start-openclaw.sh @@ -395,6 +395,15 @@ fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log('Configuration patched successfully'); EOFPATCH +# ============================================================ +# GOG (Google Workspace CLI) KEYRING +# ============================================================ +# gog uses 99designs/keyring with an empty password for the file backend. +# Set GOG_KEYRING_PASSWORD here so it's inherited by the gateway and all +# child processes (controller → gateway → gog). Without this, the keyring +# library prompts for a password on a missing TTY and fails. +export GOG_KEYRING_PASSWORD="" + # ============================================================ # START CONTROLLER # ============================================================ From 9858240e2b61875a76f8d39c14da29ef76b76760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 20:14:44 +0100 Subject: [PATCH 64/87] fix(google-setup): bind OAuth callback server to all interfaces bind the OAuth callback server to all interfaces by removing the explicit 127.0.0.1 binding in server.listen(0, '127.0.0.1', ...) to server.listen(0) enables remote callbacks in containerized or CI environments and applies minor formatting improvements for consistency --- kiloclaw/google-setup/setup.mjs | 36 ++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 02b970285..e83c621be 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -36,17 +36,14 @@ const workerUrl = workerUrlArg : 'https://claw.kilo.ai'; if (!token) { - console.error( - 'Usage: docker run -it --network host kilocode/google-setup --token=' - ); + console.error('Usage: docker run -it --network host kilocode/google-setup --token='); process.exit(1); } // Validate worker URL scheme — reject non-HTTPS except for localhost dev. try { const parsed = new URL(workerUrl); - const isLocalhost = - parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; + const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; if (parsed.protocol !== 'https:' && !isLocalhost) { console.error( `Error: --worker-url must use HTTPS (got ${parsed.protocol}). HTTP is only allowed for localhost.` @@ -131,13 +128,18 @@ function ask(question) { function runCommand(cmd, args, opts = {}) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: 'inherit', ...opts }); - child.on('close', code => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)))); + child.on('close', code => + code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)) + ); child.on('error', reject); }); } function runCommandOutput(cmd, args) { - return execSync([cmd, ...args].join(' '), { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + return execSync([cmd, ...args].join(' '), { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); } // --------------------------------------------------------------------------- @@ -208,7 +210,10 @@ if (projectChoice === '2') { let projects = []; try { const projectsJson = runCommandOutput('gcloud', [ - 'projects', 'list', '--format=json(projectId,name)', '--sort-by=name', + 'projects', + 'list', + '--format=json(projectId,name)', + '--sort-by=name', ]); projects = JSON.parse(projectsJson); } catch { @@ -352,12 +357,12 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { let timer; - server.on('error', (err) => { + server.on('error', err => { clearTimeout(timer); reject(new Error(`OAuth callback server failed: ${err.message}`)); }); - server.listen(0, '127.0.0.1', () => { + server.listen(0, () => { callbackPort = server.address().port; const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); authUrl.searchParams.set('client_id', clientId); @@ -372,10 +377,13 @@ const { code, redirectUri } = await new Promise((resolve, reject) => { console.log(`Waiting for OAuth callback on port ${callbackPort}...`); }); - timer = setTimeout(() => { - server.close(); - reject(new Error('OAuth flow timed out (5 minutes)')); - }, 5 * 60 * 1000); + timer = setTimeout( + () => { + server.close(); + reject(new Error('OAuth flow timed out (5 minutes)')); + }, + 5 * 60 * 1000 + ); timer.unref(); }); From 1fa8a4cd8c7f1010eade9cf62b0530c658636175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 21:25:41 +0100 Subject: [PATCH 65/87] docs: remove outdated gws-to-gog migration plan doc delete the 2026-03-11 migration plan file as it is superseded by current gog flow --- .../plans/2026-03-11-gws-to-gog-migration.md | 1521 ----------------- 1 file changed, 1521 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md diff --git a/docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md b/docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md deleted file mode 100644 index 16db45878..000000000 --- a/docs/superpowers/plans/2026-03-11-gws-to-gog-migration.md +++ /dev/null @@ -1,1521 +0,0 @@ -# gws → gog CLI Migration Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the Google Workspace CLI (gws) with gogcli (gog) as the sole Google CLI in KiloClaw, and rewrite the setup flow to use gcloud + gog directly. - -**Architecture:** The controller writes gog-format credentials (plain JSON client config + JWE-encrypted keyring token) at startup. The setup container uses gcloud for project/API setup and prompts for manual OAuth client creation (the only step Google doesn't expose an API for), then runs a custom OAuth flow and stores encrypted credentials. No gws dependency anywhere. - -**Tech Stack:** Node.js, jose (JWE encryption), gcloud CLI, gog (Go CLI), Vitest - ---- - -## Context for implementers - -### Project structure - -``` -kiloclaw/ - Dockerfile # Main container image - controller/ - package.json # Dependencies (hono, will add jose) - src/ - index.ts # Controller entry — calls writeGwsCredentials() - gws-credentials.ts # Current credential writer (being replaced) - gws-credentials.test.ts # Tests (being replaced) - google-setup/ - Dockerfile # Setup image (has gws + gcloud) - setup.mjs # Setup script (uses gws auth setup) - package.json - README.md - test/ # E2E tests (being renamed to e2e/) - google-credentials-integration.mjs - google-setup-e2e.mjs - docker-image-testing.md - src/ - gateway/env.ts # Decrypts credentials → GOOGLE_CLIENT_SECRET_JSON + GOOGLE_CREDENTIALS_JSON env vars - gateway/env.test.ts # Tests for env decryption - routes/api.ts # User-facing google-credentials routes - routes/platform.ts # Internal google-credentials routes -``` - -### Key env vars (unchanged by this migration) - -- `GOOGLE_CLIENT_SECRET_JSON` — JSON with `{client_id, client_secret}` (from `installed` wrapper) -- `GOOGLE_CREDENTIALS_JSON` — JSON with `{type, refresh_token, client_id, client_secret, scopes, ...}` - -These env vars are set by `kiloclaw/src/gateway/env.ts` (the worker side). The controller reads them and writes files for whichever CLI to discover. The env var names do NOT change. - -### gog credential format - -gog expects: -1. **Client config**: `~/.config/gogcli/credentials.json` — plain JSON `{client_id, client_secret}` -2. **Refresh token**: `~/.config/gogcli/keyring/` — JWE-encrypted file - - Key name: `token:default:` → filename: `token%3Adefault%3A` - - JWE algorithm: `PBES2-HS256+A128KW` (key wrapping) + `A256GCM` (content encryption) - - Password: from `GOG_KEYRING_PASSWORD` env var (empty string is valid) - - Payload: `{RefreshToken: string, Services: string[], Scopes: string[], CreatedAt: string}` - -### gog env vars - -- `GOG_KEYRING_BACKEND=file` — use file-based keyring (not OS keychain) -- `GOG_KEYRING_PASSWORD=""` — password for file keyring encryption (empty is supported) -- `GOG_ACCOUNT=` — default account to use - -### Scopes (full set) - -```js -const SCOPES = [ - 'openid', - 'email', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.settings.basic', - 'https://www.googleapis.com/auth/gmail.settings.sharing', - 'https://www.googleapis.com/auth/calendar', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/presentations', - 'https://www.googleapis.com/auth/spreadsheets', - 'https://www.googleapis.com/auth/tasks', - 'https://www.googleapis.com/auth/contacts', - 'https://www.googleapis.com/auth/contacts.other.readonly', - 'https://www.googleapis.com/auth/directory.readonly', - 'https://www.googleapis.com/auth/forms.body', - 'https://www.googleapis.com/auth/forms.responses.readonly', - 'https://www.googleapis.com/auth/chat.spaces', - 'https://www.googleapis.com/auth/chat.messages', - 'https://www.googleapis.com/auth/chat.memberships', - 'https://www.googleapis.com/auth/classroom.courses', - 'https://www.googleapis.com/auth/classroom.rosters', - 'https://www.googleapis.com/auth/script.projects', - 'https://www.googleapis.com/auth/script.deployments', - 'https://www.googleapis.com/auth/keep', - 'https://www.googleapis.com/auth/pubsub', -]; -``` - -### APIs to enable - -``` -gmail.googleapis.com calendar-json.googleapis.com drive.googleapis.com -docs.googleapis.com slides.googleapis.com sheets.googleapis.com -tasks.googleapis.com people.googleapis.com forms.googleapis.com -chat.googleapis.com classroom.googleapis.com script.googleapis.com -keep.googleapis.com pubsub.googleapis.com -``` - -### Commands - -- **Controller tests**: `cd kiloclaw/controller && npx vitest run` -- **Worker tests**: `cd kiloclaw && pnpm test` -- **Format changed files**: `pnpm run format:changed` (from repo root) - ---- - -## Chunk 1: Controller — gog credential writer - -### Task 1: Add jose dependency to controller - -**Files:** -- Modify: `kiloclaw/controller/package.json` - -- [ ] **Step 1: Add jose dependency** - -```json -{ - "name": "kiloclaw-controller", - "private": true, - "type": "module", - "dependencies": { - "hono": "4.12.2", - "jose": "6.0.11" - }, - "devDependencies": { - "@types/node": "22.0.0" - } -} -``` - -- [ ] **Step 2: Install dependencies** - -Run: `cd kiloclaw/controller && bun install` -Expected: bun.lock updated, jose installed - -- [ ] **Step 3: Commit** - -```bash -git add kiloclaw/controller/package.json kiloclaw/controller/bun.lock -git commit -m "chore(kiloclaw): add jose dependency to controller for JWE keyring" -``` - -### Task 2: Write failing tests for gog-credentials - -**Files:** -- Create: `kiloclaw/controller/src/gog-credentials.test.ts` - -- [ ] **Step 1: Write the test file** - -```typescript -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import path from 'node:path'; - -// We'll import from gog-credentials once it exists. -// For now, these tests define the expected behavior. - -// No child_process mock needed — gog-credentials doesn't shell out - -function mockDeps() { - return { - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), - }; -} - -// Credentials JSON that includes email (new requirement for gog) -const CLIENT_SECRET_JSON = JSON.stringify({ - installed: { - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - auth_uri: 'https://accounts.google.com/o/oauth2/auth', - token_uri: 'https://oauth2.googleapis.com/token', - }, -}); - -const CREDENTIALS_JSON = JSON.stringify({ - type: 'authorized_user', - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - refresh_token: '1//0test-refresh-token', - scopes: ['https://www.googleapis.com/auth/gmail.modify'], - email: 'user@gmail.com', -}); - -describe('writeGogCredentials', () => { - let writeGogCredentials: typeof import('./gog-credentials').writeGogCredentials; - - beforeEach(async () => { - vi.resetModules(); - const mod = await import('./gog-credentials'); - writeGogCredentials = mod.writeGogCredentials; - }); - - it('writes client credentials and keyring when both env vars are set', async () => { - const deps = mockDeps(); - const dir = '/tmp/gogcli-test'; - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, - }; - const result = await writeGogCredentials(env, dir, deps); - - expect(result).toBe(true); - expect(deps.mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); - expect(deps.mkdirSync).toHaveBeenCalledWith(path.join(dir, 'keyring'), { recursive: true }); - - // Should write credentials.json with just client_id + client_secret - const credentialsCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => c[0] === path.join(dir, 'credentials.json') - ); - expect(credentialsCall).toBeDefined(); - const writtenCreds = JSON.parse(credentialsCall![1] as string); - expect(writtenCreds).toEqual({ - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - }); - - // Should write a keyring file with percent-encoded name - const keyringCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => (c[0] as string).includes('keyring/') - ); - expect(keyringCall).toBeDefined(); - const keyringPath = keyringCall![0] as string; - expect(keyringPath).toContain('token%3Adefault%3Auser%40gmail.com'); - - // Keyring file should be a JWE string (starts with eyJ) - const keyringContent = keyringCall![1] as string; - expect(keyringContent).toMatch(/^eyJ/); - }); - - it('sets GOG env vars when credentials are written', async () => { - const deps = mockDeps(); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, - }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); - - expect(env.GOG_KEYRING_BACKEND).toBe('file'); - expect(env.GOG_KEYRING_PASSWORD).toBe(''); - expect(env.GOG_ACCOUNT).toBe('user@gmail.com'); - }); - - it('does NOT set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE', async () => { - const deps = mockDeps(); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, - }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); - - expect(env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE).toBeUndefined(); - }); - - it('skips when GOOGLE_CLIENT_SECRET_JSON is missing', async () => { - const deps = mockDeps(); - const result = await writeGogCredentials( - { GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON }, - '/tmp/gogcli-test', - deps - ); - expect(result).toBe(false); - expect(deps.mkdirSync).not.toHaveBeenCalled(); - }); - - it('skips when GOOGLE_CREDENTIALS_JSON is missing', async () => { - const deps = mockDeps(); - const result = await writeGogCredentials( - { GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON }, - '/tmp/gogcli-test', - deps - ); - expect(result).toBe(false); - }); - - it('removes stale credential files when env vars are absent', async () => { - const deps = mockDeps(); - const dir = '/tmp/gogcli-test'; - await writeGogCredentials({}, dir, deps); - - expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); - }); - - it('ignores missing files during cleanup', async () => { - const deps = mockDeps(); - deps.unlinkSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const result = await writeGogCredentials({}, '/tmp/gogcli-test', deps); - expect(result).toBe(false); - }); - - it('handles "web" client config wrapper', async () => { - const deps = mockDeps(); - const webClientSecret = JSON.stringify({ - web: { - client_id: 'web-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-web-secret', - }, - }); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: webClientSecret, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, - }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); - - const credentialsCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') - ); - const writtenCreds = JSON.parse(credentialsCall![1] as string); - expect(writtenCreds.client_id).toBe('web-client-id.apps.googleusercontent.com'); - expect(writtenCreds.client_secret).toBe('GOCSPX-web-secret'); - }); - - it('percent-encodes special characters in email for keyring filename', async () => { - const deps = mockDeps(); - const credsWithPlus = JSON.stringify({ - type: 'authorized_user', - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - refresh_token: '1//0test-refresh-token', - scopes: ['https://www.googleapis.com/auth/gmail.modify'], - email: 'user+tag@gmail.com', - }); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: credsWithPlus, - }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); - - const keyringCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => (c[0] as string).includes('keyring/') - ); - const keyringPath = keyringCall![0] as string; - // + must be percent-encoded as %2B - expect(keyringPath).toContain('user%2Btag%40gmail.com'); - }); - - it('handles credentials without email gracefully', async () => { - const deps = mockDeps(); - const credsNoEmail = JSON.stringify({ - type: 'authorized_user', - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - refresh_token: '1//0test-refresh-token', - scopes: ['https://www.googleapis.com/auth/gmail.modify'], - }); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: credsNoEmail, - }; - - // Should still write client credentials but skip keyring (no email = can't create key name) - const result = await writeGogCredentials(env, '/tmp/gogcli-test', deps); - expect(result).toBe(true); - - // Client credentials should still be written - const credentialsCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') - ); - expect(credentialsCall).toBeDefined(); - - // Keyring file should NOT be written (no email) - const keyringCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => (c[0] as string).includes('keyring/') - ); - expect(keyringCall).toBeUndefined(); - - // GOG_ACCOUNT should not be set - expect(env.GOG_ACCOUNT).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd kiloclaw/controller && npx vitest run src/gog-credentials.test.ts` -Expected: FAIL — module `./gog-credentials` not found - -### Task 3: Implement gog-credentials module - -**Files:** -- Create: `kiloclaw/controller/src/gog-credentials.ts` - -- [ ] **Step 1: Write the implementation** - -```typescript -/** - * Writes gogcli credential files to disk so the gog CLI picks them up - * automatically at runtime. - * - * When the container starts with GOOGLE_CLIENT_SECRET_JSON and - * GOOGLE_CREDENTIALS_JSON env vars, this module: - * 1. Writes client credentials to ~/.config/gogcli/credentials.json - * 2. Writes a JWE-encrypted keyring file with the refresh token - * 3. Sets GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, GOG_ACCOUNT env vars - */ -import { CompactEncrypt } from 'jose'; -import path from 'node:path'; - -// Use /root explicitly — OpenClaw changes HOME to the workspace dir at runtime, -// but we need credentials at a stable, absolute path that gog can always find. -const GOG_CONFIG_DIR = '/root/.config/gogcli'; -const CREDENTIALS_FILE = 'credentials.json'; -const KEYRING_DIR = 'keyring'; - -export type GogCredentialsDeps = { - mkdirSync: (dir: string, opts: { recursive: boolean }) => void; - writeFileSync: (path: string, data: string, opts: { mode: number }) => void; - unlinkSync: (path: string) => void; -}; - -/** - * Percent-encode a string for use as a keyring filename. - * gog uses Go's url.PathEscape which encodes everything except unreserved chars. - */ -function percentEncode(s: string): string { - return Array.from(new TextEncoder().encode(s)) - .map(b => { - const c = String.fromCharCode(b); - if (/[A-Za-z0-9\-_.~]/.test(c)) return c; - return '%' + b.toString(16).toUpperCase().padStart(2, '0'); - }) - .join(''); -} - -/** - * Create a JWE-encrypted keyring file matching the 99designs/keyring file backend. - * Uses PBES2-HS256+A128KW for key wrapping and A256GCM for content encryption. - */ -async function createKeyringEntry( - refreshToken: string, - scopes: string[], - password: string -): Promise { - // Map OAuth scopes to gog service names for the Services field - const services = mapScopesToServices(scopes); - - const payload = JSON.stringify({ - RefreshToken: refreshToken, - Services: services, - // gog stores only full OAuth scopes, not OIDC shorthand like 'openid'/'email' - Scopes: scopes.filter(s => s.startsWith('https://')), - CreatedAt: new Date().toISOString(), - }); - - const encoder = new TextEncoder(); - const jwe = await new CompactEncrypt(encoder.encode(payload)) - .setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' }) - .encrypt(encoder.encode(password)); - - return jwe; -} - -/** Map Google OAuth scopes to gog service names. */ -function mapScopesToServices(scopes: string[]): string[] { - const scopeToService: Record = { - 'gmail.modify': 'gmail', - 'gmail.settings.basic': 'gmail', - 'gmail.settings.sharing': 'gmail', - 'gmail.readonly': 'gmail', - calendar: 'calendar', - 'calendar.readonly': 'calendar', - drive: 'drive', - 'drive.readonly': 'drive', - 'drive.file': 'drive', - documents: 'docs', - 'documents.readonly': 'docs', - presentations: 'slides', - 'presentations.readonly': 'slides', - spreadsheets: 'sheets', - 'spreadsheets.readonly': 'sheets', - tasks: 'tasks', - 'tasks.readonly': 'tasks', - contacts: 'contacts', - 'contacts.readonly': 'contacts', - 'contacts.other.readonly': 'contacts', - 'directory.readonly': 'contacts', - 'forms.body': 'forms', - 'forms.body.readonly': 'forms', - 'forms.responses.readonly': 'forms', - 'chat.spaces': 'chat', - 'chat.messages': 'chat', - 'chat.memberships': 'chat', - 'chat.spaces.readonly': 'chat', - 'chat.messages.readonly': 'chat', - 'chat.memberships.readonly': 'chat', - 'classroom.courses': 'classroom', - 'classroom.rosters': 'classroom', - 'script.projects': 'appscript', - 'script.deployments': 'appscript', - keep: 'keep', - pubsub: 'pubsub', - }; - - const prefix = 'https://www.googleapis.com/auth/'; - const services = new Set(); - for (const scope of scopes) { - const short = scope.startsWith(prefix) ? scope.slice(prefix.length) : scope; - const service = scopeToService[short]; - if (service) services.add(service); - } - return [...services].sort(); -} - -/** - * Write gog credential files if the corresponding env vars are set. - * Returns true if credentials were written, false if skipped. - * - * Side effect: mutates the passed `env` record by setting - * GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, and GOG_ACCOUNT. - */ -export async function writeGogCredentials( - env: Record = process.env as Record, - configDir = GOG_CONFIG_DIR, - deps?: Partial -): Promise { - const fs = await import('node:fs'); - const d: GogCredentialsDeps = { - mkdirSync: deps?.mkdirSync ?? ((dir, opts) => fs.default.mkdirSync(dir, opts)), - writeFileSync: deps?.writeFileSync ?? ((p, data, opts) => fs.default.writeFileSync(p, data, opts)), - unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), - }; - - const clientSecretRaw = env.GOOGLE_CLIENT_SECRET_JSON; - const credentialsRaw = env.GOOGLE_CREDENTIALS_JSON; - - if (!clientSecretRaw || !credentialsRaw) { - // Clean up stale credential files from a previous run (e.g. after disconnect) - for (const file of [CREDENTIALS_FILE]) { - const filePath = path.join(configDir, file); - try { - d.unlinkSync(filePath); - console.log(`[gog] Removed stale ${filePath}`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - } - return false; - } - - d.mkdirSync(configDir, { recursive: true }); - - // Parse client_secret.json — extract client_id + client_secret from the - // "installed" or "web" wrapper, or use top-level fields if already flat. - const clientConfig = JSON.parse(clientSecretRaw); - const clientFields = clientConfig.installed ?? clientConfig.web ?? clientConfig; - const clientId = clientFields.client_id; - const clientSecret = clientFields.client_secret; - - // Write gogcli credentials.json (just client_id + client_secret) - d.writeFileSync( - path.join(configDir, CREDENTIALS_FILE), - JSON.stringify({ client_id: clientId, client_secret: clientSecret }), - { mode: 0o600 } - ); - - console.log(`[gog] Wrote client credentials to ${configDir}/${CREDENTIALS_FILE}`); - - // Parse credentials to get refresh_token, email, scopes - const credentials = JSON.parse(credentialsRaw); - const email: string | undefined = credentials.email; - const refreshToken: string | undefined = credentials.refresh_token; - const scopes: string[] = credentials.scopes ?? []; - - // Write keyring entry if we have email + refresh_token - if (email && refreshToken) { - const keyringDir = path.join(configDir, KEYRING_DIR); - d.mkdirSync(keyringDir, { recursive: true }); - - const keyName = `token:default:${email}`; - const fileName = percentEncode(keyName); - const password = ''; // Empty password is supported by gog - - const jwe = await createKeyringEntry(refreshToken, scopes, password); - d.writeFileSync(path.join(keyringDir, fileName), jwe, { mode: 0o600 }); - - console.log(`[gog] Wrote keyring entry for ${email}`); - - // Set env vars for gog discovery - env.GOG_KEYRING_BACKEND = 'file'; - env.GOG_KEYRING_PASSWORD = ''; - env.GOG_ACCOUNT = email; - } else { - if (!email) console.warn('[gog] No email in credentials — keyring entry skipped, gog may not work'); - if (!refreshToken) console.warn('[gog] No refresh_token in credentials — keyring entry skipped'); - } - - return true; -} -``` - -- [ ] **Step 2: Run tests** - -Run: `cd kiloclaw/controller && npx vitest run src/gog-credentials.test.ts` -Expected: All tests pass - -- [ ] **Step 3: Commit** - -```bash -git add kiloclaw/controller/src/gog-credentials.ts kiloclaw/controller/src/gog-credentials.test.ts -git commit -m "feat(kiloclaw): add gog-credentials module with JWE keyring support" -``` - -### Task 4: Wire gog-credentials into controller and remove gws-credentials - -**Files:** -- Modify: `kiloclaw/controller/src/index.ts:16-18` (import + call) -- Delete: `kiloclaw/controller/src/gws-credentials.ts` -- Delete: `kiloclaw/controller/src/gws-credentials.test.ts` - -- [ ] **Step 1: Update index.ts import and call** - -In `kiloclaw/controller/src/index.ts`, replace: - -```typescript -import { writeGwsCredentials } from './gws-credentials'; -``` - -with: - -```typescript -import { writeGogCredentials } from './gog-credentials'; -``` - -And replace line 118: - -```typescript - writeGwsCredentials(env as Record); -``` - -with: - -```typescript - // writeGogCredentials is async (JWE encryption) but we don't await it — - // credential writing is best-effort and should not block controller startup. - // This is safe: the gateway process doesn't use gog credentials at startup; - // gog is only invoked later by user/bot actions, well after this completes. - writeGogCredentials(env as Record).catch(err => { - console.error('[gog] Failed to write credentials:', err); - }); -``` - -- [ ] **Step 2: Delete old gws files** - -```bash -git rm kiloclaw/controller/src/gws-credentials.ts -git rm kiloclaw/controller/src/gws-credentials.test.ts -``` - -- [ ] **Step 3: Run all controller tests** - -Run: `cd kiloclaw/controller && npx vitest run` -Expected: All tests pass (gog tests pass, no gws tests remain) - -- [ ] **Step 4: Commit** - -```bash -git add kiloclaw/controller/src/index.ts -git commit -m "refactor(kiloclaw): replace gws-credentials with gog-credentials in controller" -``` - -Note: The `git rm` in step 2 already staged the deletions. - ---- - -## Chunk 2: Dockerfile changes - -### Task 5: Remove gws from main Dockerfile, keep gog - -**Files:** -- Modify: `kiloclaw/Dockerfile:57-58` - -- [ ] **Step 1: Remove gws CLI installation** - -Delete these lines from `kiloclaw/Dockerfile` (around line 57-58): - -```dockerfile -# Install gws CLI (Google Workspace CLI) -RUN npm install -g @googleworkspace/cli@0.11.1 -``` - -- [ ] **Step 2: Verify gog is still present** - -Confirm line 75 still has: -```dockerfile -RUN GOBIN=/usr/local/bin go install github.com/steipete/gogcli/cmd/gog@v0.11.0 \ -``` - -No changes needed — gog is already installed. - -Note: The old `gws-credentials.ts` ran `installGwsSkills()` which installed gws agent skills via -`npx skills add https://github.com/googleworkspace/cli`. This is intentionally dropped — gog has -native OpenClaw support and doesn't need a separate skills installation step. - -- [ ] **Step 3: Commit** - -```bash -git add kiloclaw/Dockerfile -git commit -m "chore(kiloclaw): remove gws CLI from container image" -``` - ---- - -## Chunk 3: Google Setup rewrite - -### Task 6: Update google-setup Dockerfile - -**Files:** -- Modify: `kiloclaw/google-setup/Dockerfile` - -- [ ] **Step 1: Remove gws and expect, keep gcloud** - -Replace the full content of `kiloclaw/google-setup/Dockerfile` with: - -```dockerfile -FROM node:22-slim - -# Install dependencies for gcloud CLI + readline for interactive prompts -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - python3 \ - apt-transport-https \ - ca-certificates \ - gnupg \ - && rm -rf /var/lib/apt/lists/* - -# Install gcloud CLI -RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ - > /etc/apt/sources.list.d/google-cloud-sdk.list \ - && apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -COPY setup.mjs ./ - -ENTRYPOINT ["node", "setup.mjs"] -``` - -- [ ] **Step 2: Commit** - -```bash -git add kiloclaw/google-setup/Dockerfile -git commit -m "chore(kiloclaw): remove gws and expect from google-setup image" -``` - -### Task 7: Rewrite setup.mjs - -**Files:** -- Modify: `kiloclaw/google-setup/setup.mjs` - -This is the largest change. The new flow: - -1. Validate API key (unchanged) -2. Fetch public key (unchanged) -3. **NEW**: Sign into gcloud, create/select project, enable APIs, configure consent screen -4. **NEW**: Prompt user to create OAuth Desktop client in Console and paste client_id + client_secret -5. Run custom OAuth flow (mostly unchanged, expanded scopes) -6. **NEW**: Fetch user email via userinfo endpoint -7. Encrypt + POST (mostly unchanged, email added to credentials) - -- [ ] **Step 1: Rewrite setup.mjs** - -Replace the full content of `kiloclaw/google-setup/setup.mjs` with the following. Key differences from the old version are commented: - -```js -#!/usr/bin/env node - -/** - * KiloClaw Google Account Setup - * - * Docker-based tool that: - * 1. Validates the user's KiloCode API key against the kiloclaw worker - * 2. Fetches the worker's RSA public key for credential encryption - * 3. Signs into gcloud, creates/selects a GCP project, enables APIs - * 4. Prompts user to create a Desktop OAuth client in Cloud Console - * 5. Runs our own OAuth flow (localhost callback) to get a refresh token - * 6. Fetches the user's email address - * 7. Encrypts the client_secret + credentials with the worker's public key - * 8. POSTs the encrypted bundle to the kiloclaw worker - * - * Usage: - * docker run -it --network host kilocode/google-setup --api-key=kilo_abc123 - */ - -import { spawn, execSync } from 'node:child_process'; -import fs from 'node:fs'; -import crypto from 'node:crypto'; -import http from 'node:http'; -import readline from 'node:readline'; - -// --------------------------------------------------------------------------- -// CLI args -// --------------------------------------------------------------------------- - -const args = process.argv.slice(2); -const apiKeyArg = args.find(a => a.startsWith('--api-key=')); -const apiKey = apiKeyArg?.substring(apiKeyArg.indexOf('=') + 1); - -const workerUrlArg = args.find(a => a.startsWith('--worker-url=')); -const workerUrl = workerUrlArg - ? workerUrlArg.substring(workerUrlArg.indexOf('=') + 1) - : 'https://claw.kilo.ai'; - -if (!apiKey) { - console.error( - 'Usage: docker run -it --network host kilocode/google-setup --api-key=' - ); - process.exit(1); -} - -// Validate worker URL scheme — reject non-HTTPS except for localhost dev. -try { - const parsed = new URL(workerUrl); - const isLocalhost = - parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; - if (parsed.protocol !== 'https:' && !isLocalhost) { - console.error( - `Error: --worker-url must use HTTPS (got ${parsed.protocol}). HTTP is only allowed for localhost.` - ); - process.exit(1); - } - if (workerUrl !== 'https://claw.kilo.ai') { - console.warn(`Warning: using non-default worker URL: ${workerUrl}`); - } -} catch { - console.error(`Error: invalid --worker-url: ${workerUrl}`); - process.exit(1); -} - -const authHeaders = { - authorization: `Bearer ${apiKey}`, - 'content-type': 'application/json', -}; - -// --------------------------------------------------------------------------- -// Scopes — all gog user services + pubsub -// --------------------------------------------------------------------------- - -const SCOPES = [ - 'openid', - 'email', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.settings.basic', - 'https://www.googleapis.com/auth/gmail.settings.sharing', - 'https://www.googleapis.com/auth/calendar', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/presentations', - 'https://www.googleapis.com/auth/spreadsheets', - 'https://www.googleapis.com/auth/tasks', - 'https://www.googleapis.com/auth/contacts', - 'https://www.googleapis.com/auth/contacts.other.readonly', - 'https://www.googleapis.com/auth/directory.readonly', - 'https://www.googleapis.com/auth/forms.body', - 'https://www.googleapis.com/auth/forms.responses.readonly', - 'https://www.googleapis.com/auth/chat.spaces', - 'https://www.googleapis.com/auth/chat.messages', - 'https://www.googleapis.com/auth/chat.memberships', - 'https://www.googleapis.com/auth/classroom.courses', - 'https://www.googleapis.com/auth/classroom.rosters', - 'https://www.googleapis.com/auth/script.projects', - 'https://www.googleapis.com/auth/script.deployments', - 'https://www.googleapis.com/auth/keep', - 'https://www.googleapis.com/auth/pubsub', -]; - -// APIs to enable in the GCP project -const GCP_APIS = [ - 'gmail.googleapis.com', - 'calendar-json.googleapis.com', - 'drive.googleapis.com', - 'docs.googleapis.com', - 'slides.googleapis.com', - 'sheets.googleapis.com', - 'tasks.googleapis.com', - 'people.googleapis.com', - 'forms.googleapis.com', - 'chat.googleapis.com', - 'classroom.googleapis.com', - 'script.googleapis.com', - 'keep.googleapis.com', - 'pubsub.googleapis.com', -]; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function ask(question) { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - return new Promise(resolve => { - rl.question(question, answer => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -function runCommand(cmd, args, opts = {}) { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: 'inherit', ...opts }); - child.on('close', code => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)))); - child.on('error', reject); - }); -} - -function runCommandOutput(cmd, args) { - return execSync([cmd, ...args].join(' '), { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); -} - -// --------------------------------------------------------------------------- -// Step 1: Validate API key -// --------------------------------------------------------------------------- - -console.log('Validating API key...'); - -const validateRes = await fetch(`${workerUrl}/health`); -if (!validateRes.ok) { - console.error('Cannot reach kiloclaw worker at', workerUrl); - process.exit(1); -} - -const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { - headers: authHeaders, -}); - -if (authCheckRes.status === 401 || authCheckRes.status === 403) { - console.error('Invalid API key. Check your key and try again.'); - process.exit(1); -} - -console.log('API key verified.\n'); - -// --------------------------------------------------------------------------- -// Step 2: Fetch public key for encryption -// --------------------------------------------------------------------------- - -console.log('Fetching encryption public key...'); - -const pubKeyRes = await fetch(`${workerUrl}/api/admin/public-key`, { headers: authHeaders }); -if (!pubKeyRes.ok) { - console.error('Failed to fetch public key from worker.'); - process.exit(1); -} - -const { publicKey: publicKeyPem } = await pubKeyRes.json(); - -if (!publicKeyPem || !publicKeyPem.includes('BEGIN PUBLIC KEY')) { - console.error('Invalid public key received from worker.'); - process.exit(1); -} - -// --------------------------------------------------------------------------- -// Step 3: Sign into gcloud and set up GCP project + APIs -// --------------------------------------------------------------------------- - -console.log('Signing into Google Cloud...'); -console.log('A browser window will open for you to sign in.\n'); - -await runCommand('gcloud', ['auth', 'login', '--brief']); - -const gcloudAccount = runCommandOutput('gcloud', ['config', 'get-value', 'account']); -console.log(`\nSigned in as: ${gcloudAccount}\n`); - -// Project selection: create new or use existing -console.log('Google Cloud project setup:'); -console.log(' 1. Create a new project (recommended)'); -console.log(' 2. Use an existing project\n'); - -const projectChoice = await ask('Choose (1 or 2): '); -let projectId; - -if (projectChoice === '2') { - // List existing projects - console.log('\nFetching your projects...'); - try { - await runCommand('gcloud', ['projects', 'list', '--format=table(projectId,name)']); - } catch { - console.warn('Could not list projects. You can still enter a project ID manually.'); - } - projectId = await ask('\nEnter your project ID: '); -} else { - // Generate a project ID based on date - const now = new Date(); - const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const defaultId = `kiloclaw-${dateStr}`; - const inputId = await ask(`Project ID [${defaultId}]: `); - projectId = inputId || defaultId; - - console.log(`\nCreating project "${projectId}"...`); - try { - await runCommand('gcloud', ['projects', 'create', projectId, '--set-as-default']); - console.log('Project created.\n'); - } catch { - console.error(`Failed to create project "${projectId}". It may already exist.`); - console.error('Try a different name, or choose option 2 to use an existing project.'); - process.exit(1); - } -} - -// Set as active project -await runCommand('gcloud', ['config', 'set', 'project', projectId]); -console.log(`\nUsing project: ${projectId}`); - -// Enable APIs -console.log('\nEnabling Google APIs (this may take a minute)...'); -await runCommand('gcloud', ['services', 'enable', ...GCP_APIS, `--project=${projectId}`]); -console.log('APIs enabled.\n'); - -// Configure OAuth consent screen via REST API -console.log('Configuring OAuth consent screen...'); -const accessToken = runCommandOutput('gcloud', ['auth', 'print-access-token']); - -// Check if brand already exists -const brandsRes = await fetch( - `https://iap.googleapis.com/v1/projects/${projectId}/brands`, - { headers: { authorization: `Bearer ${accessToken}` } } -); -const brandsData = await brandsRes.json(); - -if (!brandsData.brands?.length) { - const createBrandRes = await fetch( - `https://iap.googleapis.com/v1/projects/${projectId}/brands`, - { - method: 'POST', - headers: { - authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - applicationTitle: 'KiloClaw', - supportEmail: gcloudAccount, - }), - } - ); - if (!createBrandRes.ok) { - console.warn('Could not auto-configure consent screen. You may need to set it up manually.'); - console.warn(`Visit: https://console.cloud.google.com/apis/credentials/consent?project=${projectId}\n`); - } else { - console.log('OAuth consent screen configured.\n'); - } -} else { - console.log('OAuth consent screen already configured.\n'); -} - -// --------------------------------------------------------------------------- -// Step 4: Manual OAuth client creation -// --------------------------------------------------------------------------- - -const credentialsUrl = `https://console.cloud.google.com/apis/credentials?project=${projectId}`; - -console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); -console.log(' Create an OAuth client'); -console.log(''); -console.log(` 1. Open: ${credentialsUrl}`); -console.log(' 2. Click "Create Credentials" → "OAuth client ID"'); -console.log(' 3. Application type: "Desktop app"'); -console.log(' 4. Click "Create"'); -console.log(' 5. Copy the Client ID and Client Secret below'); -console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - -const clientId = await ask('Client ID: '); -const clientSecret = await ask('Client Secret: '); - -if (!clientId || !clientSecret) { - console.error('Client ID and Client Secret are required.'); - process.exit(1); -} - -// Build client_secret.json in the standard Google format -const clientSecretObj = { - installed: { - client_id: clientId, - project_id: projectId, - auth_uri: 'https://accounts.google.com/o/oauth2/auth', - token_uri: 'https://oauth2.googleapis.com/token', - auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', - client_secret: clientSecret, - redirect_uris: ['http://localhost'], - }, -}; -const clientSecretJson = JSON.stringify(clientSecretObj); - -// --------------------------------------------------------------------------- -// Step 5: Custom OAuth flow to get refresh token -// --------------------------------------------------------------------------- - -console.log('\nStarting OAuth authorization...'); - -const { code, redirectUri } = await new Promise((resolve, reject) => { - let callbackPort; - - const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://localhost`); - const code = url.searchParams.get('code'); - const error = url.searchParams.get('error'); - - if (error) { - clearTimeout(timer); - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

Authorization failed

You can close this tab.

'); - server.close(); - reject(new Error(`OAuth error: ${error}`)); - return; - } - - if (code) { - clearTimeout(timer); - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

Authorization successful!

You can close this tab.

'); - server.close(); - resolve({ code, redirectUri: `http://localhost:${callbackPort}` }); - return; - } - - // Ignore non-OAuth requests (e.g. browser favicon) - res.writeHead(404); - res.end(); - }); - - let timer; - - server.on('error', (err) => { - clearTimeout(timer); - reject(new Error(`OAuth callback server failed: ${err.message}`)); - }); - - server.listen(0, '127.0.0.1', () => { - callbackPort = server.address().port; - const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); - authUrl.searchParams.set('client_id', clientId); - authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}`); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('scope', SCOPES.join(' ')); - authUrl.searchParams.set('access_type', 'offline'); - authUrl.searchParams.set('prompt', 'consent'); - - console.log('\nOpen this URL in your browser to authorize:\n'); - console.log(` ${authUrl.toString()}\n`); - console.log(`Waiting for OAuth callback on port ${callbackPort}...`); - }); - - timer = setTimeout(() => { - server.close(); - reject(new Error('OAuth flow timed out (5 minutes)')); - }, 5 * 60 * 1000); - timer.unref(); -}); - -// Exchange authorization code for tokens -console.log('Exchanging authorization code for tokens...'); - -const tokenRes = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: clientId, - client_secret: clientSecret, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }), -}); - -if (!tokenRes.ok) { - const err = await tokenRes.text(); - console.error('Token exchange failed:', err); - process.exit(1); -} - -const tokens = await tokenRes.json(); -console.log('OAuth tokens obtained.'); - -// --------------------------------------------------------------------------- -// Step 6: Fetch user email -// --------------------------------------------------------------------------- - -console.log('Fetching account info...'); - -const userinfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { - headers: { authorization: `Bearer ${tokens.access_token}` }, -}); - -let userEmail; -if (userinfoRes.ok) { - const userinfo = await userinfoRes.json(); - userEmail = userinfo.email; - console.log(`Account: ${userEmail}`); -} else { - console.warn('Could not fetch user email. gog account auto-selection will not work.'); -} - -// Build credentials object — includes email for gog keyring key naming -const credentialsObj = { - type: 'authorized_user', - ...tokens, - scopes: SCOPES, - ...(userEmail && { email: userEmail }), -}; -const credentialsJson = JSON.stringify(credentialsObj); - -// --------------------------------------------------------------------------- -// Step 7: Encrypt credentials with worker's public key -// --------------------------------------------------------------------------- - -function encryptEnvelope(plaintext, pemKey) { - const dek = crypto.randomBytes(32); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); - let encrypted = cipher.update(plaintext, 'utf8'); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - const encryptedData = Buffer.concat([iv, encrypted, tag]); - const encryptedDEK = crypto.publicEncrypt( - { key: pemKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, - dek - ); - return { - encryptedData: encryptedData.toString('base64'), - encryptedDEK: encryptedDEK.toString('base64'), - algorithm: 'rsa-aes-256-gcm', - version: 1, - }; -} - -console.log('Encrypting credentials...'); - -const encryptedBundle = { - clientSecret: encryptEnvelope(clientSecretJson, publicKeyPem), - credentials: encryptEnvelope(credentialsJson, publicKeyPem), -}; - -// --------------------------------------------------------------------------- -// Step 8: POST to worker -// --------------------------------------------------------------------------- - -console.log('Sending credentials to your kiloclaw instance...'); - -const postRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ googleCredentials: encryptedBundle }), -}); - -if (!postRes.ok) { - const body = await postRes.text(); - console.error('Failed to store credentials:', body); - process.exit(1); -} - -console.log('\nGoogle account connected!'); -console.log('Credentials sent to your kiloclaw instance.'); -if (userEmail) { - console.log(`Connected account: ${userEmail}`); -} -console.log('\nYour bot can now use Gmail, Calendar, Drive, Docs, Sheets, and more.'); -console.log('Redeploy your kiloclaw instance to activate.'); -``` - -- [ ] **Step 2: Commit** - -```bash -git add kiloclaw/google-setup/setup.mjs -git commit -m "feat(kiloclaw): rewrite google-setup to use gcloud + gog instead of gws" -``` - -### Task 8: Update google-setup README - -**Files:** -- Modify: `kiloclaw/google-setup/README.md` - -- [ ] **Step 1: Update README** - -Replace the content of `kiloclaw/google-setup/README.md` with: - -```markdown -# KiloClaw Google Setup - -Docker image that guides users through connecting their Google account to KiloClaw. - -## What it does - -1. Validates the user's KiloCode API key -2. Signs into gcloud, creates/selects a GCP project, enables Google APIs -3. Guides user through creating a Desktop OAuth client in Google Cloud Console -4. Runs a local OAuth flow to obtain refresh tokens -5. Encrypts credentials with the worker's public key -6. POSTs the encrypted bundle to the KiloClaw worker - -## Usage - -```bash -docker run -it --network host ghcr.io/kilo-org/google-setup --api-key="YOUR_API_KEY" -``` - -For local development against a local worker: - -```bash -docker run -it --network host ghcr.io/kilo-org/google-setup \ - --api-key="YOUR_API_KEY" \ - --worker-url=http://localhost:8795 -``` - -## Publishing - -The image is hosted on GitHub Container Registry at `ghcr.io/kilo-org/google-setup`. - -### Prerequisites - -- Docker with buildx support -- GitHub CLI (`gh`) with `write:packages` scope - -### Steps - -```bash -# 1. Add write:packages scope (one-time) -gh auth refresh -h github.com -s write:packages - -# 2. Login to GHCR -echo $(gh auth token) | docker login ghcr.io -u $(gh api user -q .login) --password-stdin - -# 3. Create multi-arch builder (one-time) -docker buildx create --use --name multiarch - -# 4. Build and push (amd64 + arm64) -docker buildx build --platform linux/amd64,linux/arm64 \ - -t ghcr.io/kilo-org/google-setup:latest \ - --push \ - kiloclaw/google-setup/ -``` - -### Tagging a release - -```bash -docker buildx build --platform linux/amd64,linux/arm64 \ - -t ghcr.io/kilo-org/google-setup:latest \ - -t ghcr.io/kilo-org/google-setup:v2.0.0 \ - --push \ - kiloclaw/google-setup/ -``` - -## Making the package public - -By default, GHCR packages are private. To make it public: - -1. Go to https://github.com/orgs/Kilo-Org/packages/container/google-setup/settings -2. Under "Danger Zone", click "Change visibility" and select "Public" -``` - -- [ ] **Step 2: Commit** - -```bash -git add kiloclaw/google-setup/README.md -git commit -m "docs(kiloclaw): update google-setup README for gog migration" -``` - ---- - -## Chunk 4: Rename test/ → e2e/ and update tests - -### Task 9: Rename test directory to e2e - -**Files:** -- Rename: `kiloclaw/test/` → `kiloclaw/e2e/` - -- [ ] **Step 1: Rename directory** - -```bash -git mv kiloclaw/test kiloclaw/e2e -``` - -- [ ] **Step 2: Update all references to the old path** - -Search for `kiloclaw/test/` in comments and docs. Update these files: - -In `kiloclaw/e2e/google-credentials-integration.mjs`, update both usage lines (12-13): -``` - * node kiloclaw/e2e/google-credentials-integration.mjs - * DATABASE_URL=postgres://... WORKER_URL=http://localhost:9000 node kiloclaw/e2e/google-credentials-integration.mjs -``` - -In `kiloclaw/e2e/google-setup-e2e.mjs`, update the usage line (19): -``` - * node kiloclaw/e2e/google-setup-e2e.mjs -``` - -In `kiloclaw/e2e/docker-image-testing.md`, no path references to `kiloclaw/test/` exist, so no change needed. - -- [ ] **Step 3: Commit** - -```bash -git add kiloclaw/e2e kiloclaw/test -git commit -m "refactor(kiloclaw): rename test/ to e2e/" -``` - -### Task 10: Update E2E test — google-setup-e2e.mjs - -**Files:** -- Modify: `kiloclaw/e2e/google-setup-e2e.mjs` - -- [ ] **Step 1: Update gws references** - -Two changes in this file: - -1. Line 37 comment — change "gws CLI's random OAuth callback port" to "the OAuth callback port": - -```js -// We use --network host so the OAuth callback port is reachable -// from the browser. This also means localhost in the container reaches the host, -// so we don't need host.docker.internal. -``` - -2. The usage path on line 19 was already updated in Task 9 step 2. - -The test is otherwise unchanged — it builds the docker image, runs it interactively, and checks `googleConnected=true`. The setup.mjs rewrite handles the gog migration; the E2E test just validates the outcome. - -- [ ] **Step 2: Commit** - -```bash -git add kiloclaw/e2e/google-setup-e2e.mjs -git commit -m "chore(kiloclaw): update e2e test comments for gog migration" -``` - -### Task 11: Update E2E test — google-credentials-integration.mjs - -**Files:** -- Modify: `kiloclaw/e2e/google-credentials-integration.mjs` - -- [ ] **Step 1: Update usage comment path** - -Line 13 — already updated in Task 9. Verify it reads: -``` - * node kiloclaw/e2e/google-credentials-integration.mjs -``` - -This test is API-level (POST/GET/DELETE google-credentials endpoints) and doesn't reference gws or gog directly. No other changes needed. - -- [ ] **Step 2: Run the integration test to verify it still works** - -Run: `node kiloclaw/e2e/google-credentials-integration.mjs` -Expected: All tests pass (requires local Postgres + worker running) - -Note: If local services aren't running, this step can be skipped — the test doesn't touch gws/gog code paths. - ---- - -## Chunk 5: Final validation and cleanup - -### Task 12: Run all tests - -- [ ] **Step 1: Run controller unit tests** - -Run: `cd kiloclaw/controller && npx vitest run` -Expected: All tests pass - -- [ ] **Step 2: Run worker tests** - -Run: `cd kiloclaw && pnpm test` -Expected: All tests pass - -- [ ] **Step 3: Run format on changed files** - -Run: `pnpm run format:changed` (from repo root) - -- [ ] **Step 4: Run typecheck** - -Run: `pnpm run typecheck` (from repo root) -Expected: No type errors - -- [ ] **Step 5: Run linter** - -Run: `pnpm run lint` (from repo root) -Expected: No lint errors - -- [ ] **Step 6: Commit any formatting fixes** - -```bash -git add -A -git commit -m "style(kiloclaw): format changes from gog migration" -``` - -### Task 13: Verify no stale gws references remain - -- [ ] **Step 1: Search for leftover gws references** - -Run: `grep -r "gws" kiloclaw/ --include="*.ts" --include="*.mjs" --include="*.json" --include="*.md" --include="Dockerfile" -l` - -Expected: No results. If any files still reference gws, update them. - -Note: `gws` may appear in Git history or in paths like `gateway` — only actual gws CLI references need removal. - -- [ ] **Step 2: Search for leftover GOOGLE_WORKSPACE_CLI references** - -Run: `grep -r "GOOGLE_WORKSPACE_CLI" kiloclaw/ -l` - -Expected: No results. - -- [ ] **Step 3: Verify gws npm package is not referenced** - -Run: `grep -r "@googleworkspace/cli" kiloclaw/ -l` - -Expected: No results. - -- [ ] **Step 4: Commit any remaining fixes** - -If any stale references were found and fixed: -```bash -git add -A -git commit -m "chore(kiloclaw): remove remaining gws references" -``` From 2c879bbc38761b97be02f11e6d75d709caab5ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 21:49:55 +0100 Subject: [PATCH 66/87] feat(kiloclaw): ship gog config as tarball instead of reconstructing keyring Replace the fragile keyring reconstruction approach with a simpler flow: the google-setup container now runs gog directly (auth credentials set + auth add), tars up ~/.config/gogcli/, encrypts the tarball, and ships it to the worker. The controller just extracts the tarball on startup. - Add gog v0.11.0 binary to google-setup Dockerfile - Simplify setup.mjs: use gog auth commands instead of custom OAuth flow - Update GoogleCredentialsSchema: gogConfigTarball + email (was clientSecret + credentials) - Bump EncryptedEnvelope max to 32KB for tarball headroom - Simplify controller gog-credentials.ts to tarball extraction only - Remove machine name from Fly createMachine (avoids stale name conflicts) - Use non-empty keyring password to avoid TTY prompts in headless env --- kiloclaw/controller/bun.lock | 3 - kiloclaw/controller/package.json | 3 +- .../controller/src/gog-credentials.test.ts | 206 +++------------ kiloclaw/controller/src/gog-credentials.ts | 235 ++++------------- kiloclaw/controller/src/index.ts | 4 +- .../e2e/google-credentials-integration.mjs | 6 +- kiloclaw/google-setup/Dockerfile | 5 + kiloclaw/google-setup/setup.mjs | 249 ++++++------------ .../src/__tests__/catalog.test.ts | 3 +- .../packages/secret-catalog/src/catalog.ts | 3 +- .../kiloclaw-instance/fly-machines.ts | 1 - kiloclaw/src/gateway/env.test.ts | 74 ++---- kiloclaw/src/gateway/env.ts | 26 +- kiloclaw/src/schemas/instance-config.ts | 8 +- kiloclaw/start-openclaw.sh | 10 +- 15 files changed, 204 insertions(+), 632 deletions(-) diff --git a/kiloclaw/controller/bun.lock b/kiloclaw/controller/bun.lock index f4c6838c9..c547d3ba6 100644 --- a/kiloclaw/controller/bun.lock +++ b/kiloclaw/controller/bun.lock @@ -6,7 +6,6 @@ "name": "kiloclaw-controller", "dependencies": { "hono": "4.12.2", - "jose": "6.0.11", }, "devDependencies": { "@types/node": "22.0.0", @@ -18,8 +17,6 @@ "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], - "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], - "undici-types": ["undici-types@6.11.1", "", {}, "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ=="], } } diff --git a/kiloclaw/controller/package.json b/kiloclaw/controller/package.json index 9eccbf9c2..9135fc00f 100644 --- a/kiloclaw/controller/package.json +++ b/kiloclaw/controller/package.json @@ -3,8 +3,7 @@ "private": true, "type": "module", "dependencies": { - "hono": "4.12.2", - "jose": "6.0.11" + "hono": "4.12.2" }, "devDependencies": { "@types/node": "22.0.0" diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index d5fc3539c..f039c810d 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -1,37 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import path from 'node:path'; - -// We'll import from gog-credentials once it exists. -// For now, these tests define the expected behavior. - -// No child_process mock needed — gog-credentials doesn't shell out function mockDeps() { return { mkdirSync: vi.fn(), writeFileSync: vi.fn(), unlinkSync: vi.fn(), + rmSync: vi.fn(), + execSync: vi.fn(), }; } -// Credentials JSON that includes email (new requirement for gog) -const CLIENT_SECRET_JSON = JSON.stringify({ - installed: { - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - auth_uri: 'https://accounts.google.com/o/oauth2/auth', - token_uri: 'https://oauth2.googleapis.com/token', - }, -}); - -const CREDENTIALS_JSON = JSON.stringify({ - type: 'authorized_user', - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - refresh_token: '1//0test-refresh-token', - scopes: ['https://www.googleapis.com/auth/gmail.modify'], - email: 'user@gmail.com', -}); +// A tiny valid .tar.gz base64 — content doesn't matter for unit tests since execSync is mocked +const FAKE_TARBALL_BASE64 = Buffer.from('fake-tarball-data').toString('base64'); describe('writeGogCredentials', () => { let writeGogCredentials: typeof import('./gog-credentials').writeGogCredentials; @@ -42,182 +22,74 @@ describe('writeGogCredentials', () => { writeGogCredentials = mod.writeGogCredentials; }); - it('writes client credentials and keyring when both env vars are set', async () => { + it('extracts tarball and sets env vars when GOOGLE_GOG_CONFIG_TARBALL is set', async () => { const deps = mockDeps(); - const dir = '/tmp/gogcli-test'; + const dir = '/root/.config/gogcli'; const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, + GOOGLE_ACCOUNT_EMAIL: 'user@gmail.com', }; const result = await writeGogCredentials(env, dir, deps); expect(result).toBe(true); - expect(deps.mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); - expect(deps.mkdirSync).toHaveBeenCalledWith(path.join(dir, 'keyring'), { recursive: true }); + expect(deps.mkdirSync).toHaveBeenCalledWith('/root/.config', { recursive: true }); - // Should write credentials.json with just client_id + client_secret - const credentialsCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => c[0] === path.join(dir, 'credentials.json') + // Should write temp tarball file + expect(deps.writeFileSync).toHaveBeenCalledWith( + '/root/.config/gogcli-config.tar.gz', + Buffer.from(FAKE_TARBALL_BASE64, 'base64') ); - expect(credentialsCall).toBeDefined(); - const writtenCreds = JSON.parse(credentialsCall![1] as string); - expect(writtenCreds).toEqual({ - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - }); - // Should write a keyring file with percent-encoded name - const keyringCall = deps.writeFileSync.mock.calls.find((c: unknown[]) => - (c[0] as string).includes('keyring/') + // Should run tar extraction + expect(deps.execSync).toHaveBeenCalledWith( + 'tar xzf /root/.config/gogcli-config.tar.gz -C /root/.config' ); - expect(keyringCall).toBeDefined(); - const keyringPath = keyringCall![0] as string; - expect(keyringPath).toContain('token%3Adefault%3Auser%40gmail.com'); - // Keyring file should be a JWE string (starts with eyJ) - const keyringContent = keyringCall![1] as string; - expect(keyringContent).toMatch(/^eyJ/); - }); - - it('sets GOG env vars when credentials are written', async () => { - const deps = mockDeps(); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, - }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); + // Should clean up temp tarball + expect(deps.unlinkSync).toHaveBeenCalledWith('/root/.config/gogcli-config.tar.gz'); + // Should set gog env vars expect(env.GOG_KEYRING_BACKEND).toBe('file'); - expect(env.GOG_KEYRING_PASSWORD).toBe(''); + expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw'); expect(env.GOG_ACCOUNT).toBe('user@gmail.com'); }); - it('does NOT set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE', async () => { + it('works without GOOGLE_ACCOUNT_EMAIL', async () => { const deps = mockDeps(); const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); + const result = await writeGogCredentials(env, '/root/.config/gogcli', deps); - expect(env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE).toBeUndefined(); + expect(result).toBe(true); + expect(env.GOG_KEYRING_BACKEND).toBe('file'); + expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw'); + expect(env.GOG_ACCOUNT).toBeUndefined(); }); - it('skips when GOOGLE_CLIENT_SECRET_JSON is missing', async () => { + it('returns false and cleans up when tarball env var is absent', async () => { const deps = mockDeps(); - const result = await writeGogCredentials( - { GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON }, - '/tmp/gogcli-test', - deps - ); - expect(result).toBe(false); - expect(deps.mkdirSync).not.toHaveBeenCalled(); - }); + const dir = '/root/.config/gogcli'; + const result = await writeGogCredentials({}, dir, deps); - it('skips when GOOGLE_CREDENTIALS_JSON is missing', async () => { - const deps = mockDeps(); - const result = await writeGogCredentials( - { GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON }, - '/tmp/gogcli-test', - deps - ); - expect(result).toBe(false); - }); - - it('removes stale credential files when env vars are absent', async () => { - const deps = mockDeps(); - const dir = '/tmp/gogcli-test'; - await writeGogCredentials({}, dir, deps); - - expect(deps.unlinkSync).toHaveBeenCalledWith(path.join(dir, 'credentials.json')); - }); - - it('ignores missing files during cleanup', async () => { - const deps = mockDeps(); - deps.unlinkSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const result = await writeGogCredentials({}, '/tmp/gogcli-test', deps); expect(result).toBe(false); + expect(deps.rmSync).toHaveBeenCalledWith(dir, { recursive: true, force: true }); + expect(deps.mkdirSync).not.toHaveBeenCalled(); }); - it('handles "web" client config wrapper', async () => { + it('cleans up temp tarball even if extraction fails', async () => { const deps = mockDeps(); - const webClientSecret = JSON.stringify({ - web: { - client_id: 'web-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-web-secret', - }, + deps.execSync.mockImplementation(() => { + throw new Error('tar failed'); }); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: webClientSecret, - GOOGLE_CREDENTIALS_JSON: CREDENTIALS_JSON, - }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); - const credentialsCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') - ); - const writtenCreds = JSON.parse(credentialsCall![1] as string); - expect(writtenCreds.client_id).toBe('web-client-id.apps.googleusercontent.com'); - expect(writtenCreds.client_secret).toBe('GOCSPX-web-secret'); - }); - - it('percent-encodes special characters in email for keyring filename', async () => { - const deps = mockDeps(); - const credsWithPlus = JSON.stringify({ - type: 'authorized_user', - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - refresh_token: '1//0test-refresh-token', - scopes: ['https://www.googleapis.com/auth/gmail.modify'], - email: 'user+tag@gmail.com', - }); const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: credsWithPlus, + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, }; - await writeGogCredentials(env, '/tmp/gogcli-test', deps); - const keyringCall = deps.writeFileSync.mock.calls.find((c: unknown[]) => - (c[0] as string).includes('keyring/') + await expect(writeGogCredentials(env, '/root/.config/gogcli', deps)).rejects.toThrow( + 'tar failed' ); - const keyringPath = keyringCall![0] as string; - // + must be percent-encoded as %2B - expect(keyringPath).toContain('user%2Btag%40gmail.com'); - }); - - it('handles credentials without email gracefully', async () => { - const deps = mockDeps(); - const credsNoEmail = JSON.stringify({ - type: 'authorized_user', - client_id: 'test-client-id.apps.googleusercontent.com', - client_secret: 'GOCSPX-test-secret', - refresh_token: '1//0test-refresh-token', - scopes: ['https://www.googleapis.com/auth/gmail.modify'], - }); - const env: Record = { - GOOGLE_CLIENT_SECRET_JSON: CLIENT_SECRET_JSON, - GOOGLE_CREDENTIALS_JSON: credsNoEmail, - }; - - // Should still write client credentials but skip keyring (no email = can't create key name) - const result = await writeGogCredentials(env, '/tmp/gogcli-test', deps); - expect(result).toBe(true); - - // Client credentials should still be written - const credentialsCall = deps.writeFileSync.mock.calls.find( - (c: unknown[]) => c[0] === path.join('/tmp/gogcli-test', 'credentials.json') - ); - expect(credentialsCall).toBeDefined(); - - // Keyring file should NOT be written (no email) - const keyringCall = deps.writeFileSync.mock.calls.find((c: unknown[]) => - (c[0] as string).includes('keyring/') - ); - expect(keyringCall).toBeUndefined(); - - // GOG_ACCOUNT should not be set - expect(env.GOG_ACCOUNT).toBeUndefined(); + expect(deps.unlinkSync).toHaveBeenCalledWith('/root/.config/gogcli-config.tar.gz'); }); }); diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index f15cd522f..65949adfe 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -1,136 +1,26 @@ /** - * Writes gogcli credential files to disk so the gog CLI picks them up - * automatically at runtime. + * Sets up gogcli credentials by extracting a pre-built config tarball. * - * When the container starts with GOOGLE_CLIENT_SECRET_JSON and - * GOOGLE_CREDENTIALS_JSON env vars, this module: - * 1. Writes client credentials to ~/.config/gogcli/credentials.json - * 2. Writes a JWE-encrypted keyring file with the refresh token + * When the container starts with GOOGLE_GOG_CONFIG_TARBALL env var, this module: + * 1. Base64-decodes the tarball to a temp file + * 2. Extracts it to /root/.config/ (produces /root/.config/gogcli/) * 3. Sets GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, GOG_ACCOUNT env vars */ -import { CompactEncrypt } from 'jose'; import path from 'node:path'; -// Use /root explicitly — OpenClaw changes HOME to the workspace dir at runtime, -// but we need credentials at a stable, absolute path that gog can always find. const GOG_CONFIG_DIR = '/root/.config/gogcli'; -const CONFIG_FILE = 'config.json'; -const CREDENTIALS_FILE = 'credentials.json'; -const KEYRING_DIR = 'keyring'; export type GogCredentialsDeps = { mkdirSync: (dir: string, opts: { recursive: boolean }) => void; - writeFileSync: (path: string, data: string, opts: { mode: number }) => void; + writeFileSync: (path: string, data: Buffer) => void; unlinkSync: (path: string) => void; + rmSync: (path: string, opts: { recursive: boolean; force: boolean }) => void; + execSync: (cmd: string) => void; }; /** - * Percent-encode a string for use as a keyring filename. - * gog uses Go's url.PathEscape which encodes everything except unreserved chars. - */ -function percentEncode(s: string): string { - return Array.from(new TextEncoder().encode(s)) - .map(b => { - const c = String.fromCharCode(b); - if (/[A-Za-z0-9\-_.~]/.test(c)) return c; - return '%' + b.toString(16).toUpperCase().padStart(2, '0'); - }) - .join(''); -} - -/** - * Create a JWE-encrypted keyring file matching the 99designs/keyring file backend. - * Uses PBES2-HS256+A128KW for key wrapping and A256GCM for content encryption. - */ -async function createKeyringEntry( - keyName: string, - refreshToken: string, - scopes: string[], - password: string -): Promise { - // Map OAuth scopes to gog service names for the Services field - const services = mapScopesToServices(scopes); - - // The token data that gog reads from Item.Data - const tokenData = JSON.stringify({ - refresh_token: refreshToken, - services: services, - // gog stores only full OAuth scopes, not OIDC shorthand like 'openid'/'email' - scopes: scopes.filter(s => s.startsWith('https://')), - created_at: new Date().toISOString(), - }); - - // 99designs/keyring file backend wraps data in an Item struct. - // Item.Data is []byte, which Go's json.Marshal encodes as base64. - const item = JSON.stringify({ - Key: keyName, - Data: Buffer.from(tokenData).toString('base64'), - Label: '', - Description: '', - }); - - const encoder = new TextEncoder(); - const jwe = await new CompactEncrypt(encoder.encode(item)) - .setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' }) - .encrypt(encoder.encode(password)); - - return jwe; -} - -/** Map Google OAuth scopes to gog service names. */ -function mapScopesToServices(scopes: string[]): string[] { - const scopeToService: Record = { - 'gmail.modify': 'gmail', - 'gmail.settings.basic': 'gmail', - 'gmail.settings.sharing': 'gmail', - 'gmail.readonly': 'gmail', - calendar: 'calendar', - 'calendar.readonly': 'calendar', - drive: 'drive', - 'drive.readonly': 'drive', - 'drive.file': 'drive', - documents: 'docs', - 'documents.readonly': 'docs', - presentations: 'slides', - 'presentations.readonly': 'slides', - spreadsheets: 'sheets', - 'spreadsheets.readonly': 'sheets', - tasks: 'tasks', - 'tasks.readonly': 'tasks', - contacts: 'contacts', - 'contacts.readonly': 'contacts', - 'contacts.other.readonly': 'contacts', - 'directory.readonly': 'contacts', - 'forms.body': 'forms', - 'forms.body.readonly': 'forms', - 'forms.responses.readonly': 'forms', - 'chat.spaces': 'chat', - 'chat.messages': 'chat', - 'chat.memberships': 'chat', - 'chat.spaces.readonly': 'chat', - 'chat.messages.readonly': 'chat', - 'chat.memberships.readonly': 'chat', - 'classroom.courses': 'classroom', - 'classroom.rosters': 'classroom', - 'script.projects': 'appscript', - 'script.deployments': 'appscript', - keep: 'keep', - pubsub: 'pubsub', - }; - - const prefix = 'https://www.googleapis.com/auth/'; - const services = new Set(); - for (const scope of scopes) { - const short = scope.startsWith(prefix) ? scope.slice(prefix.length) : scope; - const service = scopeToService[short]; - if (service) services.add(service); - } - return [...services].sort(); -} - -/** - * Write gog credential files if the corresponding env vars are set. - * Returns true if credentials were written, false if skipped. + * Extract gog config tarball if the corresponding env var is set. + * Returns true if credentials were extracted, false if skipped. * * Side effect: mutates the passed `env` record by setting * GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, and GOG_ACCOUNT. @@ -141,88 +31,51 @@ export async function writeGogCredentials( deps?: Partial ): Promise { const fs = await import('node:fs'); + const cp = await import('node:child_process'); const d: GogCredentialsDeps = { mkdirSync: deps?.mkdirSync ?? ((dir, opts) => fs.default.mkdirSync(dir, opts)), - writeFileSync: - deps?.writeFileSync ?? ((p, data, opts) => fs.default.writeFileSync(p, data, opts)), + writeFileSync: deps?.writeFileSync ?? ((p, data) => fs.default.writeFileSync(p, data)), unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), + rmSync: deps?.rmSync ?? ((p, opts) => fs.default.rmSync(p, opts)), + execSync: + deps?.execSync ?? + (cmd => + cp.default.execSync(cmd, { + stdio: ['pipe', 'pipe', 'pipe'], + })), }; - const clientSecretRaw = env.GOOGLE_CLIENT_SECRET_JSON; - const credentialsRaw = env.GOOGLE_CREDENTIALS_JSON; + const tarballBase64 = env.GOOGLE_GOG_CONFIG_TARBALL; - if (!clientSecretRaw || !credentialsRaw) { - // Clean up stale credential files from a previous run (e.g. after disconnect) - for (const file of [CREDENTIALS_FILE]) { - const filePath = path.join(configDir, file); - try { - d.unlinkSync(filePath); - console.log(`[gog] Removed stale ${filePath}`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - } + if (!tarballBase64) { + // Clean up stale config from a previous run (e.g. after disconnect) + d.rmSync(configDir, { recursive: true, force: true }); return false; } - d.mkdirSync(configDir, { recursive: true }); - - // Parse client_secret.json — extract client_id + client_secret from the - // "installed" or "web" wrapper, or use top-level fields if already flat. - const clientConfig = JSON.parse(clientSecretRaw); - const clientFields = clientConfig.installed ?? clientConfig.web ?? clientConfig; - const clientId = clientFields.client_id; - const clientSecret = clientFields.client_secret; - - // Write gogcli credentials.json (just client_id + client_secret) - d.writeFileSync( - path.join(configDir, CREDENTIALS_FILE), - JSON.stringify({ client_id: clientId, client_secret: clientSecret }), - { mode: 0o600 } - ); - - console.log(`[gog] Wrote client credentials to ${configDir}/${CREDENTIALS_FILE}`); - - // Parse credentials to get refresh_token, email, scopes - const credentials = JSON.parse(credentialsRaw); - const email: string | undefined = credentials.email; - const refreshToken: string | undefined = credentials.refresh_token; - const scopes: string[] = credentials.scopes ?? []; - - // Write keyring entry if we have email + refresh_token - if (email && refreshToken) { - const keyringDir = path.join(configDir, KEYRING_DIR); - d.mkdirSync(keyringDir, { recursive: true }); - - const keyName = `token:default:${email}`; - const fileName = percentEncode(keyName); - const password = ''; // Empty password is supported by gog - - const jwe = await createKeyringEntry(keyName, refreshToken, scopes, password); - d.writeFileSync(path.join(keyringDir, fileName), jwe, { mode: 0o600 }); - - console.log(`[gog] Wrote keyring entry for ${email}`); - - // Write config.json so gog uses the file keyring backend. - // This is more reliable than env vars since gog may be invoked as a - // grandchild process (controller → OpenClaw gateway → gog) and env - // vars set on process.env may not propagate through all layers. - d.writeFileSync( - path.join(configDir, CONFIG_FILE), - JSON.stringify({ keyring_backend: 'file' }), - { mode: 0o600 } - ); - console.log(`[gog] Wrote config to ${configDir}/${CONFIG_FILE}`); + // Decode tarball and extract to /root/.config/ + const parentDir = path.dirname(configDir); + d.mkdirSync(parentDir, { recursive: true }); + + const tmpTarball = path.join(parentDir, 'gogcli-config.tar.gz'); + d.writeFileSync(tmpTarball, Buffer.from(tarballBase64, 'base64')); + + try { + d.execSync(`tar xzf ${tmpTarball} -C ${parentDir}`); + console.log(`[gog] Extracted config tarball to ${configDir}`); + } finally { + try { + d.unlinkSync(tmpTarball); + } catch { + // ignore cleanup errors + } + } - // Set env vars as a belt-and-suspenders fallback - env.GOG_KEYRING_BACKEND = 'file'; - env.GOG_KEYRING_PASSWORD = ''; - env.GOG_ACCOUNT = email; - } else { - if (!email) - console.warn('[gog] No email in credentials — keyring entry skipped, gog may not work'); - if (!refreshToken) - console.warn('[gog] No refresh_token in credentials — keyring entry skipped'); + // Set env vars for gog runtime + env.GOG_KEYRING_BACKEND = 'file'; + env.GOG_KEYRING_PASSWORD = 'kiloclaw'; + if (env.GOOGLE_ACCOUNT_EMAIL) { + env.GOG_ACCOUNT = env.GOOGLE_ACCOUNT_EMAIL; } return true; diff --git a/kiloclaw/controller/src/index.ts b/kiloclaw/controller/src/index.ts index 2c9cb5968..349882f2d 100644 --- a/kiloclaw/controller/src/index.ts +++ b/kiloclaw/controller/src/index.ts @@ -114,8 +114,8 @@ async function handleHttpRequest( export async function startController(env: NodeJS.ProcessEnv = process.env): Promise { const config = loadRuntimeConfig(env); - // writeGogCredentials is async (JWE encryption) but we don't await it — - // credential writing is best-effort and should not block controller startup. + // writeGogCredentials is async but we don't await it — + // credential extraction is best-effort and should not block controller startup. // This is safe: the gateway process doesn't use gog credentials at startup; // gog is only invoked later by user/bot actions, well after this completes. writeGogCredentials(env as Record).catch(err => { diff --git a/kiloclaw/e2e/google-credentials-integration.mjs b/kiloclaw/e2e/google-credentials-integration.mjs index b7d0c1296..8d6895b4d 100644 --- a/kiloclaw/e2e/google-credentials-integration.mjs +++ b/kiloclaw/e2e/google-credentials-integration.mjs @@ -108,8 +108,8 @@ async function jwtGet(path) { } const DUMMY_CREDS = { - clientSecret: { encryptedData: 'dGVzdA==', encryptedDEK: 'dGVzdA==', algorithm: 'rsa-aes-256-gcm', version: 1 }, - credentials: { encryptedData: 'dGVzdA==', encryptedDEK: 'dGVzdA==', algorithm: 'rsa-aes-256-gcm', version: 1 }, + gogConfigTarball: { encryptedData: 'dGVzdA==', encryptedDEK: 'dGVzdA==', algorithm: 'rsa-aes-256-gcm', version: 1 }, + email: 'test@example.com', }; // --------------------------------------------------------------------------- @@ -307,7 +307,7 @@ assertEq('Rejects missing googleCredentials (400)', 400, badCode1); // Invalid envelope schema (internal API) const { status: badCode2 } = await internalPost('/api/platform/google-credentials', { userId: USER_ID, - googleCredentials: { clientSecret: { bad: 'data' }, credentials: { bad: 'data' } }, + googleCredentials: { gogConfigTarball: { bad: 'data' } }, }); assertEq('Rejects invalid envelope schema (400)', 400, badCode2); diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 10cfa3ff1..2a717779e 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -16,6 +16,11 @@ RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dea && apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \ && rm -rf /var/lib/apt/lists/* +# Install gogcli v0.11.0 pre-built binary +RUN curl -fsSL https://github.com/steipete/gogcli/releases/download/v0.11.0/gogcli_0.11.0_linux_amd64.tar.gz \ + | tar xz -C /usr/local/bin gog \ + && chmod +x /usr/local/bin/gog + WORKDIR /app COPY setup.mjs ./ diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index e83c621be..ad601e80a 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -8,18 +8,15 @@ * 2. Fetches the worker's RSA public key for credential encryption * 3. Signs into gcloud, creates/selects a GCP project, enables APIs * 4. Prompts user to create a Desktop OAuth client in Cloud Console - * 5. Runs our own OAuth flow (localhost callback) to get a refresh token - * 6. Fetches the user's email address - * 7. Encrypts the client_secret + credentials with the worker's public key - * 8. POSTs the encrypted bundle to the kiloclaw worker + * 5. Runs gog auth (credentials set + add) to authorize all services + * 6. Tarballs the gog config, encrypts, and POSTs to the worker * * Usage: * docker run -it --network host kilocode/google-setup --token= */ -import { spawn, execSync } from 'node:child_process'; +import { spawn, execSync, execFileSync } from 'node:child_process'; import crypto from 'node:crypto'; -import http from 'node:http'; import readline from 'node:readline'; // --------------------------------------------------------------------------- @@ -63,37 +60,6 @@ const authHeaders = { 'content-type': 'application/json', }; -// --------------------------------------------------------------------------- -// Scopes — all gog user services + pubsub -// --------------------------------------------------------------------------- - -const SCOPES = [ - 'openid', - 'email', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.settings.basic', - 'https://www.googleapis.com/auth/gmail.settings.sharing', - 'https://www.googleapis.com/auth/calendar', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/presentations', - 'https://www.googleapis.com/auth/spreadsheets', - 'https://www.googleapis.com/auth/tasks', - 'https://www.googleapis.com/auth/contacts', - 'https://www.googleapis.com/auth/contacts.other.readonly', - 'https://www.googleapis.com/auth/directory.readonly', - 'https://www.googleapis.com/auth/forms.body', - 'https://www.googleapis.com/auth/forms.responses.readonly', - 'https://www.googleapis.com/auth/chat.spaces', - 'https://www.googleapis.com/auth/chat.messages', - 'https://www.googleapis.com/auth/chat.memberships', - 'https://www.googleapis.com/auth/classroom.courses', - 'https://www.googleapis.com/auth/classroom.rosters', - 'https://www.googleapis.com/auth/script.projects', - 'https://www.googleapis.com/auth/script.deployments', - 'https://www.googleapis.com/auth/pubsub', -]; - // APIs to enable in the GCP project const GCP_APIS = [ 'gmail.googleapis.com', @@ -136,7 +102,7 @@ function runCommand(cmd, args, opts = {}) { } function runCommandOutput(cmd, args) { - return execSync([cmd, ...args].join(' '), { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); @@ -304,8 +270,42 @@ if (!clientId || !clientSecret) { process.exit(1); } -// Build client_secret.json in the standard Google format -const clientSecretObj = { +// --------------------------------------------------------------------------- +// Step 5: Run gog auth to set credentials and authorize account +// --------------------------------------------------------------------------- + +import { mkdirSync, writeFileSync } from 'node:fs'; + +function encryptEnvelope(plaintext, pemKey) { + const dek = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); + let encrypted = cipher.update(plaintext, 'utf8'); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const tag = cipher.getAuthTag(); + const encryptedData = Buffer.concat([iv, encrypted, tag]); + const encryptedDEK = crypto.publicEncrypt( + { key: pemKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + dek + ); + return { + encryptedData: encryptedData.toString('base64'), + encryptedDEK: encryptedDEK.toString('base64'), + algorithm: 'rsa-aes-256-gcm', + version: 1, + }; +} + +const gogHome = '/tmp/gogcli-home'; +const gogEnv = { + ...process.env, + HOME: gogHome, + GOG_KEYRING_BACKEND: 'file', + GOG_KEYRING_PASSWORD: 'kiloclaw', +}; + +// Build client_secret.json in Google's standard format and feed it to gog +const clientSecretJson = JSON.stringify({ installed: { client_id: clientId, project_id: projectId, @@ -315,159 +315,60 @@ const clientSecretObj = { client_secret: clientSecret, redirect_uris: ['http://localhost'], }, -}; -const clientSecretJson = JSON.stringify(clientSecretObj); - -// --------------------------------------------------------------------------- -// Step 5: Custom OAuth flow to get refresh token -// --------------------------------------------------------------------------- - -console.log('\nStarting OAuth authorization...'); - -const { code, redirectUri } = await new Promise((resolve, reject) => { - let callbackPort; - - const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://localhost`); - const code = url.searchParams.get('code'); - const error = url.searchParams.get('error'); +}); - if (error) { - clearTimeout(timer); - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

Authorization failed

You can close this tab.

'); - server.close(); - reject(new Error(`OAuth error: ${error}`)); - return; - } +// Write to temp file so gog can read it +const clientSecretPath = '/tmp/client_secret.json'; +writeFileSync(clientSecretPath, clientSecretJson); - if (code) { - clearTimeout(timer); - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

Authorization successful!

You can close this tab.

'); - server.close(); - resolve({ code, redirectUri: `http://localhost:${callbackPort}` }); - return; - } +console.log('\nSetting up gog credentials...'); - // Ignore non-OAuth requests (e.g. browser favicon) - res.writeHead(404); - res.end(); +try { + await runCommand('gog', ['auth', 'credentials', 'set', clientSecretPath], { + env: gogEnv, }); +} catch (err) { + console.error('gog auth credentials set failed:', err.message); + process.exit(1); +} - let timer; - - server.on('error', err => { - clearTimeout(timer); - reject(new Error(`OAuth callback server failed: ${err.message}`)); - }); +// Use the gcloud account email for gog auth add +const userEmail = gcloudAccount; +console.log(`\nAuthorizing ${userEmail} with gog...`); +console.log('A browser window will open for you to authorize Google Workspace access.\n'); - server.listen(0, () => { - callbackPort = server.address().port; - const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); - authUrl.searchParams.set('client_id', clientId); - authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}`); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('scope', SCOPES.join(' ')); - authUrl.searchParams.set('access_type', 'offline'); - authUrl.searchParams.set('prompt', 'consent'); - - console.log('\nOpen this URL in your browser to authorize:\n'); - console.log(` ${authUrl.toString()}\n`); - console.log(`Waiting for OAuth callback on port ${callbackPort}...`); +try { + await runCommand('gog', [ + 'auth', 'add', userEmail, + '--services=all', + '--force-consent', + ], { + env: gogEnv, }); - - timer = setTimeout( - () => { - server.close(); - reject(new Error('OAuth flow timed out (5 minutes)')); - }, - 5 * 60 * 1000 - ); - timer.unref(); -}); - -// Exchange authorization code for tokens -console.log('Exchanging authorization code for tokens...'); - -const tokenRes = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: clientId, - client_secret: clientSecret, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }), -}); - -if (!tokenRes.ok) { - const err = await tokenRes.text(); - console.error('Token exchange failed:', err); +} catch (err) { + console.error('gog auth add failed:', err.message); process.exit(1); } -const tokens = await tokenRes.json(); -console.log('OAuth tokens obtained.'); +console.log(`\nAuthenticated as: ${userEmail}`); // --------------------------------------------------------------------------- -// Step 6: Fetch user email +// Step 6: Create config tarball, encrypt, and POST // --------------------------------------------------------------------------- -console.log('Fetching account info...'); - -const userinfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { - headers: { authorization: `Bearer ${tokens.access_token}` }, +console.log('Creating config tarball...'); +const tarballBuffer = execSync(`tar czf - -C ${gogHome}/.config gogcli`, { + maxBuffer: 1024 * 1024, }); +const tarballBase64 = tarballBuffer.toString('base64'); -let userEmail; -if (userinfoRes.ok) { - const userinfo = await userinfoRes.json(); - userEmail = userinfo.email; - console.log(`Account: ${userEmail}`); -} else { - console.warn('Could not fetch user email. gog account auto-selection will not work.'); -} - -// Build credentials object — includes email for gog keyring key naming -const credentialsObj = { - type: 'authorized_user', - ...tokens, - scopes: SCOPES, - ...(userEmail && { email: userEmail }), -}; -const credentialsJson = JSON.stringify(credentialsObj); - -// --------------------------------------------------------------------------- -// Step 7: Encrypt credentials with worker's public key -// --------------------------------------------------------------------------- - -function encryptEnvelope(plaintext, pemKey) { - const dek = crypto.randomBytes(32); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); - let encrypted = cipher.update(plaintext, 'utf8'); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - const encryptedData = Buffer.concat([iv, encrypted, tag]); - const encryptedDEK = crypto.publicEncrypt( - { key: pemKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, - dek - ); - return { - encryptedData: encryptedData.toString('base64'), - encryptedDEK: encryptedDEK.toString('base64'), - algorithm: 'rsa-aes-256-gcm', - version: 1, - }; -} +console.log(`Config tarball size: ${tarballBuffer.length} bytes`); -console.log('Encrypting credentials...'); +console.log('Encrypting config tarball...'); const encryptedBundle = { - clientSecret: encryptEnvelope(clientSecretJson, publicKeyPem), - credentials: encryptEnvelope(credentialsJson, publicKeyPem), + gogConfigTarball: encryptEnvelope(tarballBase64, publicKeyPem), + email: userEmail, }; // --------------------------------------------------------------------------- diff --git a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts index 76ddf6594..d89aeb291 100644 --- a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts +++ b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts @@ -316,8 +316,7 @@ describe('Secret Catalog', () => { describe('INTERNAL_SENSITIVE_ENV_VARS', () => { it('contains Google credential env vars', () => { - expect(INTERNAL_SENSITIVE_ENV_VARS.has('GOOGLE_CLIENT_SECRET_JSON')).toBe(true); - expect(INTERNAL_SENSITIVE_ENV_VARS.has('GOOGLE_CREDENTIALS_JSON')).toBe(true); + expect(INTERNAL_SENSITIVE_ENV_VARS.has('GOOGLE_GOG_CONFIG_TARBALL')).toBe(true); }); it('does not overlap with catalog-derived ALL_SECRET_ENV_VARS', () => { diff --git a/kiloclaw/packages/secret-catalog/src/catalog.ts b/kiloclaw/packages/secret-catalog/src/catalog.ts index 232776c29..d75cce93c 100644 --- a/kiloclaw/packages/secret-catalog/src/catalog.ts +++ b/kiloclaw/packages/secret-catalog/src/catalog.ts @@ -138,8 +138,7 @@ export const ALL_SECRET_ENV_VARS: ReadonlySet = new Set( * not entered by users through the secret management UI. */ export const INTERNAL_SENSITIVE_ENV_VARS: ReadonlySet = new Set([ - 'GOOGLE_CLIENT_SECRET_JSON', - 'GOOGLE_CREDENTIALS_JSON', + 'GOOGLE_GOG_CONFIG_TARBALL', ]); /** diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts index 85730305a..afc96b1c8 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts @@ -217,7 +217,6 @@ export async function createNewMachine( envFlyRegion?: string ): Promise { const machine = await fly.createMachine(flyConfig, machineConfig, { - name: state.sandboxId ?? undefined, region: state.flyRegion ?? envFlyRegion ?? undefined, minSecretsVersion, }); diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index e49a6b223..4a9a89088 100644 --- a/kiloclaw/src/gateway/env.test.ts +++ b/kiloclaw/src/gateway/env.test.ts @@ -299,66 +299,37 @@ describe('buildEnvVars', () => { // ─── Google credentials (Layer 4b) ─────────────────────────────────── - it('decrypts Google credentials into sensitive bucket and merges client_id/client_secret', async () => { + it('decrypts Google gog config tarball into sensitive bucket', async () => { const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey, }); + const tarballBase64 = Buffer.from('fake-tarball').toString('base64'); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { googleCredentials: { - clientSecret: encryptForTest('{"client_id":"cid","client_secret":"csec"}', testPublicKey), - // credentials envelope omits client_id/client_secret (new setup flow) - credentials: encryptForTest('{"refresh_token":"rt"}', testPublicKey), + gogConfigTarball: encryptForTest(tarballBase64, testPublicKey), + email: 'user@gmail.com', }, }); - expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBe( - '{"client_id":"cid","client_secret":"csec"}' - ); - // Verify client_id and client_secret were merged from clientSecret envelope - const creds = JSON.parse(result.sensitive.GOOGLE_CREDENTIALS_JSON); - expect(creds.refresh_token).toBe('rt'); - expect(creds.client_id).toBe('cid'); - expect(creds.client_secret).toBe('csec'); - expect(result.env.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); - expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + expect(result.sensitive.GOOGLE_GOG_CONFIG_TARBALL).toBe(tarballBase64); + expect(result.env.GOOGLE_ACCOUNT_EMAIL).toBe('user@gmail.com'); + // Should not leak into plaintext + expect(result.env.GOOGLE_GOG_CONFIG_TARBALL).toBeUndefined(); }); - it('does not overwrite existing client_id/client_secret in credentials envelope', async () => { + it('decrypts Google gog config tarball without email', async () => { const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey, }); + const tarballBase64 = Buffer.from('fake-tarball').toString('base64'); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { googleCredentials: { - clientSecret: encryptForTest( - '{"client_id":"old","client_secret":"old_sec"}', - testPublicKey - ), - // Legacy credentials envelope that already has client_id/client_secret - credentials: encryptForTest( - '{"refresh_token":"rt","client_id":"existing","client_secret":"existing_sec"}', - testPublicKey - ), - }, - }); - - const creds = JSON.parse(result.sensitive.GOOGLE_CREDENTIALS_JSON); - expect(creds.client_id).toBe('existing'); - expect(creds.client_secret).toBe('existing_sec'); - }); - - it('classifies Google credential env var names as sensitive even when provided as plaintext', async () => { - const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey }); - const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { - envVars: { - GOOGLE_CLIENT_SECRET_JSON: '{"client_id":"leak"}', - GOOGLE_CREDENTIALS_JSON: '{"refresh_token":"leak"}', + gogConfigTarball: encryptForTest(tarballBase64, testPublicKey), }, }); - expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBe('{"client_id":"leak"}'); - expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBe('{"refresh_token":"leak"}'); - expect(result.env.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); - expect(result.env.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + expect(result.sensitive.GOOGLE_GOG_CONFIG_TARBALL).toBe(tarballBase64); + expect(result.env.GOOGLE_ACCOUNT_EMAIL).toBeUndefined(); }); it('continues without Google access when credential decryption fails', async () => { @@ -368,14 +339,7 @@ describe('buildEnvVars', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { googleCredentials: { - // Corrupted envelopes — not valid encrypted data - clientSecret: { - encryptedData: 'bad', - encryptedDEK: 'bad', - algorithm: 'rsa-aes-256-gcm' as const, - version: 1 as const, - }, - credentials: { + gogConfigTarball: { encryptedData: 'bad', encryptedDEK: 'bad', algorithm: 'rsa-aes-256-gcm' as const, @@ -384,8 +348,7 @@ describe('buildEnvVars', () => { }, }); - expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); - expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + expect(result.sensitive.GOOGLE_GOG_CONFIG_TARBALL).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( 'Failed to decrypt Google credentials, starting without Google access:', expect.any(Error) @@ -395,15 +358,14 @@ describe('buildEnvVars', () => { it('skips Google credential decryption when no private key configured', async () => { const env = createMockEnv(); // no AGENT_ENV_VARS_PRIVATE_KEY + const tarballBase64 = Buffer.from('fake-tarball').toString('base64'); const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { googleCredentials: { - clientSecret: encryptForTest('{"client_id":"test"}', testPublicKey), - credentials: encryptForTest('{"refresh_token":"rt"}', testPublicKey), + gogConfigTarball: encryptForTest(tarballBase64, testPublicKey), }, }); - expect(result.sensitive.GOOGLE_CLIENT_SECRET_JSON).toBeUndefined(); - expect(result.sensitive.GOOGLE_CREDENTIALS_JSON).toBeUndefined(); + expect(result.sensitive.GOOGLE_GOG_CONFIG_TARBALL).toBeUndefined(); }); // ─── Catalog-derived SENSITIVE_KEYS equivalence ─────────────────────── diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index c7fa4d1fd..93c98ae0a 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -163,33 +163,19 @@ export async function buildEnvVars( Object.assign(sensitive, channelEnv); } - // Layer 4b: Decrypt Google credentials and pass as env vars. + // Layer 4b: Decrypt Google credentials (gog config tarball) and pass as env var. // Wrapped in try/catch so corrupted credentials don't block container startup — // the machine starts without Google access instead of failing entirely. if (userConfig.googleCredentials && env.AGENT_ENV_VARS_PRIVATE_KEY) { try { - const clientSecretJson = decryptWithPrivateKey( - userConfig.googleCredentials.clientSecret, + const tarballBase64 = decryptWithPrivateKey( + userConfig.googleCredentials.gogConfigTarball, env.AGENT_ENV_VARS_PRIVATE_KEY ); - sensitive.GOOGLE_CLIENT_SECRET_JSON = clientSecretJson; - - const credentialsRaw = decryptWithPrivateKey( - userConfig.googleCredentials.credentials, - env.AGENT_ENV_VARS_PRIVATE_KEY - ); - // Merge client_id/client_secret from the clientSecret envelope into - // the credentials object. The setup flow omits them from the credentials - // envelope to avoid duplicating the OAuth client secret across envelopes. - const clientSecret = JSON.parse(clientSecretJson); - const credentials = JSON.parse(credentialsRaw); - if (!credentials.client_id && clientSecret.client_id) { - credentials.client_id = clientSecret.client_id; - } - if (!credentials.client_secret && clientSecret.client_secret) { - credentials.client_secret = clientSecret.client_secret; + sensitive.GOOGLE_GOG_CONFIG_TARBALL = tarballBase64; + if (userConfig.googleCredentials.email) { + plainEnv.GOOGLE_ACCOUNT_EMAIL = userConfig.googleCredentials.email; } - sensitive.GOOGLE_CREDENTIALS_JSON = JSON.stringify(credentials); } catch (err) { console.warn('Failed to decrypt Google credentials, starting without Google access:', err); } diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 6bff9f351..3957d33d1 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -4,8 +4,8 @@ import { IMAGE_TAG_RE, IMAGE_TAG_MAX_LENGTH } from '../lib/image-tag-validation' export const EncryptedEnvelopeSchema = z.object({ // AES-256-GCM ciphertext: 16-byte IV + ciphertext + 16-byte tag, base64-encoded. - // 8 KiB is generous for token values (typical bot tokens are < 200 bytes). - encryptedData: z.string().max(8192), + // 32 KiB headroom for larger payloads like gog config tarballs. + encryptedData: z.string().max(32768), // RSA-2048 OAEP ciphertext of the 32-byte DEK, base64-encoded (~344 chars). encryptedDEK: z.string().max(1024), algorithm: z.literal('rsa-aes-256-gcm'), @@ -31,8 +31,8 @@ const envVarNameSchema = z .refine(s => !s.startsWith('KILOCLAW_'), 'Uses reserved prefix (KILOCLAW_*)'); export const GoogleCredentialsSchema = z.object({ - clientSecret: EncryptedEnvelopeSchema, // client_secret.json contents - credentials: EncryptedEnvelopeSchema, // OAuth tokens (refresh token, etc.) + gogConfigTarball: EncryptedEnvelopeSchema, // base64 tar.gz of ~/.config/gogcli/ + email: z.string().optional(), // for display ("Connected as user@...") }); export type GoogleCredentials = z.infer; diff --git a/kiloclaw/start-openclaw.sh b/kiloclaw/start-openclaw.sh index fd7bb6e94..94b491785 100644 --- a/kiloclaw/start-openclaw.sh +++ b/kiloclaw/start-openclaw.sh @@ -398,11 +398,11 @@ EOFPATCH # ============================================================ # GOG (Google Workspace CLI) KEYRING # ============================================================ -# gog uses 99designs/keyring with an empty password for the file backend. -# Set GOG_KEYRING_PASSWORD here so it's inherited by the gateway and all -# child processes (controller → gateway → gog). Without this, the keyring -# library prompts for a password on a missing TTY and fails. -export GOG_KEYRING_PASSWORD="" +# gog uses 99designs/keyring with the file backend. Set GOG_KEYRING_PASSWORD +# here so it's inherited by the gateway and all child processes +# (controller → gateway → gog). Without this, the keyring library prompts +# for a password on a missing TTY and fails. +export GOG_KEYRING_PASSWORD="kiloclaw" # ============================================================ # START CONTROLLER From f7607e135bb1867c341a27c342786e7f9589ca0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 21:58:20 +0100 Subject: [PATCH 67/87] fix: use execFileSync for tar extraction, fix stale GoogleCredentialsInput type Replace shell string interpolation in gog-credentials.ts with execFileSync array args to eliminate shell injection surface. Update GoogleCredentialsInput type to match the actual GoogleCredentialsSchema (gogConfigTarball + email). --- kiloclaw/controller/src/gog-credentials.test.ts | 13 ++++++++----- kiloclaw/controller/src/gog-credentials.ts | 12 ++++++------ src/lib/kiloclaw/types.ts | 4 ++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index f039c810d..4801dd0ba 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -6,7 +6,7 @@ function mockDeps() { writeFileSync: vi.fn(), unlinkSync: vi.fn(), rmSync: vi.fn(), - execSync: vi.fn(), + execFileSync: vi.fn(), }; } @@ -41,9 +41,12 @@ describe('writeGogCredentials', () => { ); // Should run tar extraction - expect(deps.execSync).toHaveBeenCalledWith( - 'tar xzf /root/.config/gogcli-config.tar.gz -C /root/.config' - ); + expect(deps.execFileSync).toHaveBeenCalledWith('tar', [ + 'xzf', + '/root/.config/gogcli-config.tar.gz', + '-C', + '/root/.config', + ]); // Should clean up temp tarball expect(deps.unlinkSync).toHaveBeenCalledWith('/root/.config/gogcli-config.tar.gz'); @@ -79,7 +82,7 @@ describe('writeGogCredentials', () => { it('cleans up temp tarball even if extraction fails', async () => { const deps = mockDeps(); - deps.execSync.mockImplementation(() => { + deps.execFileSync.mockImplementation(() => { throw new Error('tar failed'); }); diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 65949adfe..1ade20aa5 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -15,7 +15,7 @@ export type GogCredentialsDeps = { writeFileSync: (path: string, data: Buffer) => void; unlinkSync: (path: string) => void; rmSync: (path: string, opts: { recursive: boolean; force: boolean }) => void; - execSync: (cmd: string) => void; + execFileSync: (file: string, args: string[]) => void; }; /** @@ -37,10 +37,10 @@ export async function writeGogCredentials( writeFileSync: deps?.writeFileSync ?? ((p, data) => fs.default.writeFileSync(p, data)), unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), rmSync: deps?.rmSync ?? ((p, opts) => fs.default.rmSync(p, opts)), - execSync: - deps?.execSync ?? - (cmd => - cp.default.execSync(cmd, { + execFileSync: + deps?.execFileSync ?? + ((file, args) => + cp.default.execFileSync(file, args, { stdio: ['pipe', 'pipe', 'pipe'], })), }; @@ -61,7 +61,7 @@ export async function writeGogCredentials( d.writeFileSync(tmpTarball, Buffer.from(tarballBase64, 'base64')); try { - d.execSync(`tar xzf ${tmpTarball} -C ${parentDir}`); + d.execFileSync('tar', ['xzf', tmpTarball, '-C', parentDir]); console.log(`[gog] Extracted config tarball to ${configDir}`); } finally { try { diff --git a/src/lib/kiloclaw/types.ts b/src/lib/kiloclaw/types.ts index 0ec9806b6..186d63c16 100644 --- a/src/lib/kiloclaw/types.ts +++ b/src/lib/kiloclaw/types.ts @@ -231,8 +231,8 @@ export type ControllerVersionResponse = { /** Input to POST /api/platform/google-credentials */ export type GoogleCredentialsInput = { googleCredentials: { - clientSecret: EncryptedEnvelope; - credentials: EncryptedEnvelope; + gogConfigTarball: EncryptedEnvelope; + email?: string; }; }; From 2dead7bae3b463ea58c76048d886c6e5df9987f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:07:13 +0100 Subject: [PATCH 68/87] fix(e2e): update google-credentials integration test to match current code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read secrets from .dev.vars instead of hardcoded defaults - Fix public-key endpoint path: /public-key → /api/admin/public-key - Public-key test now uses JWT auth (endpoint is behind auth) --- .../e2e/google-credentials-integration.mjs | 77 ++++++++++++++----- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/kiloclaw/e2e/google-credentials-integration.mjs b/kiloclaw/e2e/google-credentials-integration.mjs index 8d6895b4d..50cfacadf 100644 --- a/kiloclaw/e2e/google-credentials-integration.mjs +++ b/kiloclaw/e2e/google-credentials-integration.mjs @@ -6,7 +6,7 @@ * 1. Local Postgres running (postgres://postgres:postgres@localhost:5432/postgres) * 2. kiloclaw worker running locally (pnpm start → localhost:8795) * - * The test creates a temporary user in the DB for JWT auth tests, and cleans up after. + * The test reads secrets from kiloclaw/.dev.vars so it works without manual env setup. * * Usage: * node kiloclaw/e2e/google-credentials-integration.mjs @@ -15,10 +15,36 @@ import { SignJWT } from 'jose'; import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Load secrets from .dev.vars (same file wrangler uses) +// --------------------------------------------------------------------------- + +function loadDevVars() { + const devVarsPath = path.resolve(__dirname, '../.dev.vars'); + const vars = {}; + try { + const content = fs.readFileSync(devVarsPath, 'utf8'); + for (const line of content.split('\n')) { + const match = line.match(/^(\w+)="(.*)"/); + if (match) vars[match[1]] = match[2]; + } + } catch { + console.warn('Could not read .dev.vars — using env overrides or defaults'); + } + return vars; +} + +const devVars = loadDevVars(); const WORKER_URL = process.env.WORKER_URL ?? 'http://localhost:8795'; -const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? 'dev-internal-secret'; -const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'dev-secret-change-me'; +const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? devVars.INTERNAL_API_SECRET ?? 'dev-internal-secret'; +const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? devVars.NEXTAUTH_SECRET ?? 'dev-secret-change-me'; const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/postgres'; const USER_ID = `test-google-creds-${Date.now()}`; @@ -141,6 +167,23 @@ function checkDbConnection() { } } +// --------------------------------------------------------------------------- +// Generate JWT (needed for both public-key and admin routes) +// --------------------------------------------------------------------------- + +async function generateJwt(userId) { + return new SignJWT({ + kiloUserId: userId, + apiTokenPepper: null, // matches NULL in DB + version: 3, + env: 'development', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('5m') + .setIssuedAt() + .sign(new TextEncoder().encode(NEXTAUTH_SECRET)); +} + // --------------------------------------------------------------------------- // Preflight // --------------------------------------------------------------------------- @@ -174,15 +217,21 @@ if (checkDbConnection()) { } // --------------------------------------------------------------------------- -// 1. Public key endpoint +// 1. Public key endpoint (requires JWT auth at /api/admin/public-key) // --------------------------------------------------------------------------- bold('1. Public key endpoint'); -const pubKeyRes = await fetch(`${WORKER_URL}/public-key`); -const pubKeyJson = pubKeyRes.ok ? await pubKeyRes.json() : {}; -assertNotEmpty('GET /public-key returns a key', pubKeyJson.publicKey); -assertEq('Public key is valid PEM', true, pubKeyJson.publicKey?.includes('BEGIN PUBLIC KEY')); +if (dbConnected) { + JWT = await generateJwt(USER_ID); + + const { status: pubKeyStatus, json: pubKeyJson } = await jwtGet('/api/admin/public-key'); + assertEq('GET /api/admin/public-key returns 200', 200, pubKeyStatus); + assertNotEmpty('Response contains a public key', pubKeyJson?.publicKey); + assertEq('Public key is valid PEM', true, pubKeyJson?.publicKey?.includes('BEGIN PUBLIC KEY')); +} else { + bold('1. Public key endpoint — SKIPPED (no DB for JWT)'); +} // --------------------------------------------------------------------------- // 2. Provision a test instance @@ -255,17 +304,7 @@ assertEq('GET status shows googleConnected=false', false, statusAfterClear?.goog if (dbConnected) { bold('5. User-facing routes (JWT auth)'); - JWT = await new SignJWT({ - kiloUserId: USER_ID, - apiTokenPepper: null, // matches NULL in DB - version: 3, - env: 'development', - }) - .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime('5m') - .setIssuedAt() - .sign(new TextEncoder().encode(NEXTAUTH_SECRET)); - + // JWT already generated in section 1 assertNotEmpty('JWT generated', JWT); // Auth check — GET /api/admin/google-credentials returns 200 with googleConnected status From dde26fcd787c0612412e33f32648677b2d02d6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:11:42 +0100 Subject: [PATCH 69/87] fix(e2e): update google-setup e2e test to read .dev.vars and handle slow provision - Read secrets from .dev.vars instead of hardcoded defaults - Use fire-and-forget provision with DO polling (handles Fly timeouts) - Use safer env-var-based sql() helper --- kiloclaw/e2e/google-setup-e2e.mjs | 62 +++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/kiloclaw/e2e/google-setup-e2e.mjs b/kiloclaw/e2e/google-setup-e2e.mjs index 9f645aaec..44b60f505 100644 --- a/kiloclaw/e2e/google-setup-e2e.mjs +++ b/kiloclaw/e2e/google-setup-e2e.mjs @@ -21,14 +21,36 @@ import { SignJWT } from 'jose'; import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// --------------------------------------------------------------------------- +// Load secrets from .dev.vars (same file wrangler uses) +// --------------------------------------------------------------------------- + +function loadDevVars() { + const devVarsPath = path.resolve(__dirname, '../.dev.vars'); + const vars = {}; + try { + const content = fs.readFileSync(devVarsPath, 'utf8'); + for (const line of content.split('\n')) { + const match = line.match(/^(\w+)="(.*)"/); + if (match) vars[match[1]] = match[2]; + } + } catch { + console.warn('Could not read .dev.vars — using env overrides or defaults'); + } + return vars; +} + +const devVars = loadDevVars(); + const WORKER_URL = process.env.WORKER_URL ?? 'http://localhost:8795'; -const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? 'dev-internal-secret'; -const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'dev-secret-change-me'; +const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? devVars.INTERNAL_API_SECRET ?? 'dev-internal-secret'; +const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? devVars.NEXTAUTH_SECRET ?? 'dev-secret-change-me'; const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/postgres'; const USER_ID = `test-google-setup-${Date.now()}`; const DOCKER_IMAGE = 'kilocode/google-setup'; @@ -50,9 +72,11 @@ function red(msg) { console.log(`\x1b[31m ✗ ${msg}\x1b[0m`); } function bold(msg) { console.log(`\n\x1b[1m${msg}\x1b[0m`); } function sql(query) { - return execSync(`psql "${DATABASE_URL}" -tAc "${query.replace(/"/g, '\\"')}"`, { + return execSync('psql "$PGURL" -tAc "$PGQUERY"', { encoding: 'utf8', timeout: 5000, + env: { ...process.env, PGURL: DATABASE_URL, PGQUERY: query }, + shell: '/bin/sh', }).trim(); } @@ -130,14 +154,36 @@ sql(`INSERT INTO kilocode_users (id, google_user_email, google_user_name, google cleanupFns.push(() => { try { sql(`DELETE FROM kilocode_users WHERE id = '${USER_ID}'`); } catch {} }); green('Test user created (id=' + USER_ID + ')'); -const provisionRes = await internalPost('/api/platform/provision', { userId: USER_ID }); -if (provisionRes.status < 200 || provisionRes.status >= 300) { - red(`Provision failed with status ${provisionRes.status}: ${JSON.stringify(provisionRes.json)}`); +// Fire off provision — Fly machine creation can be slow or flaky, but we only need +// the DO to exist for credential storage. The machine start may time out on first try. +cleanupFns.push(() => { internalPost('/api/platform/destroy', { userId: USER_ID }).catch(() => {}); }); + +const provisionController = new AbortController(); +const provisionPromise = fetch(`${WORKER_URL}/api/platform/provision`, { + method: 'POST', + headers: { 'x-internal-api-key': INTERNAL_SECRET, 'content-type': 'application/json' }, + body: JSON.stringify({ userId: USER_ID }), + signal: provisionController.signal, +}).catch(() => {}); +green('Provision request fired'); + +// Poll until DO is reachable (enough for credential tests) +let doReachable = false; +for (let i = 0; i < 60; i++) { + const { status } = await internalGet(`/api/platform/status?userId=${USER_ID}`); + if (status !== 404) { + doReachable = true; + break; + } + await new Promise(r => setTimeout(r, 1000)); +} +provisionController.abort(); +if (!doReachable) { + red('Instance DO never became reachable after 60s'); cleanup(); process.exit(1); } -cleanupFns.push(() => { internalPost('/api/platform/destroy', { userId: USER_ID }).catch(() => {}); }); -green('Instance provisioned'); +green('Instance DO reachable'); // Verify googleConnected is false before we start const { json: statusBefore } = await internalGet(`/api/platform/status?userId=${USER_ID}`); From 7dced6842836bcceecd0e5c299c42df1d07fbf78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:26:57 +0100 Subject: [PATCH 70/87] fix(kiloclaw): bump encryptedData max from 32 KiB to 64 KiB The gog config tarball includes OAuth tokens, client secrets, and keyring data for 13+ services. 32 KiB of encrypted base64 could get tight as gog adds more data over time. 64 KiB gives more breathing room. --- kiloclaw/src/schemas/instance-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 3957d33d1..3c23a469d 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -4,8 +4,8 @@ import { IMAGE_TAG_RE, IMAGE_TAG_MAX_LENGTH } from '../lib/image-tag-validation' export const EncryptedEnvelopeSchema = z.object({ // AES-256-GCM ciphertext: 16-byte IV + ciphertext + 16-byte tag, base64-encoded. - // 32 KiB headroom for larger payloads like gog config tarballs. - encryptedData: z.string().max(32768), + // 64 KiB headroom for larger payloads like gog config tarballs. + encryptedData: z.string().max(65536), // RSA-2048 OAEP ciphertext of the 32-byte DEK, base64-encoded (~344 chars). encryptedDEK: z.string().max(1024), algorithm: z.literal('rsa-aes-256-gcm'), From f3ddeebab0612f75671a00556bc999e9f7f55d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:27:09 +0100 Subject: [PATCH 71/87] fix(kiloclaw): clarify public key cache is isolate-level, not persistent --- kiloclaw/src/routes/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kiloclaw/src/routes/api.ts b/kiloclaw/src/routes/api.ts index 12d9fde24..7f0b39584 100644 --- a/kiloclaw/src/routes/api.ts +++ b/kiloclaw/src/routes/api.ts @@ -89,7 +89,9 @@ adminApi.post('/gateway/restart', async c => { } }); -// Cache the derived public key PEM to avoid re-deriving on every request. +// Isolate-level cache: shared across requests within the same CF Worker isolate +// but evicted when the isolate is recycled. Not persistent — just avoids +// re-deriving the public key on every request within a single isolate lifetime. let cachedPublicKeyPem: string | null = null; let cachedForPrivateKey: string | null = null; From fff5bb53abb46bf4cee67785a40dd45db9d4e30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:27:17 +0100 Subject: [PATCH 72/87] fix(google-setup): fix stale step numbering in setup.mjs --- kiloclaw/google-setup/setup.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index ad601e80a..f2396c90f 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -372,7 +372,7 @@ const encryptedBundle = { }; // --------------------------------------------------------------------------- -// Step 8: POST to worker +// POST encrypted credentials to worker // --------------------------------------------------------------------------- console.log('Sending credentials to your kiloclaw instance...'); From 3fe817130ac6b8e60027c423fc8f59284c541f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:27:41 +0100 Subject: [PATCH 73/87] fix(kiloclaw): add cross-reference comments for GOG_KEYRING_PASSWORD The same password string is hardcoded in 3 locations across different languages. Add comments cross-referencing all three so future changes don't miss one. --- kiloclaw/controller/src/gog-credentials.ts | 4 ++++ kiloclaw/google-setup/setup.mjs | 4 ++++ kiloclaw/start-openclaw.sh | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 1ade20aa5..7141e0522 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -72,6 +72,10 @@ export async function writeGogCredentials( } // Set env vars for gog runtime + // NOTE: GOG_KEYRING_PASSWORD must match in all three locations: + // - here (controller/src/gog-credentials.ts) + // - kiloclaw/start-openclaw.sh + // - kiloclaw/google-setup/setup.mjs env.GOG_KEYRING_BACKEND = 'file'; env.GOG_KEYRING_PASSWORD = 'kiloclaw'; if (env.GOOGLE_ACCOUNT_EMAIL) { diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index f2396c90f..30788ae73 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -297,6 +297,10 @@ function encryptEnvelope(plaintext, pemKey) { } const gogHome = '/tmp/gogcli-home'; +// NOTE: GOG_KEYRING_PASSWORD must match in all three locations: +// - here (google-setup/setup.mjs) +// - controller/src/gog-credentials.ts +// - start-openclaw.sh const gogEnv = { ...process.env, HOME: gogHome, diff --git a/kiloclaw/start-openclaw.sh b/kiloclaw/start-openclaw.sh index 94b491785..b16ef0ae2 100644 --- a/kiloclaw/start-openclaw.sh +++ b/kiloclaw/start-openclaw.sh @@ -402,6 +402,10 @@ EOFPATCH # here so it's inherited by the gateway and all child processes # (controller → gateway → gog). Without this, the keyring library prompts # for a password on a missing TTY and fails. +# NOTE: GOG_KEYRING_PASSWORD must match in all three locations: +# - here (start-openclaw.sh) +# - controller/src/gog-credentials.ts +# - google-setup/setup.mjs export GOG_KEYRING_PASSWORD="kiloclaw" # ============================================================ From e4a004557da8a379c36fd4d8faaed4e28a984462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:27:53 +0100 Subject: [PATCH 74/87] fix(google-setup): clarify utf8 encoding param in encryptEnvelope --- kiloclaw/google-setup/setup.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 30788ae73..35f95ddf2 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -276,6 +276,8 @@ if (!clientId || !clientSecret) { import { mkdirSync, writeFileSync } from 'node:fs'; +// plaintext is base64-encoded binary data, but cipher.update('utf8') is fine +// because base64 is a strict ASCII subset — no encoding ambiguity. function encryptEnvelope(plaintext, pemKey) { const dek = crypto.randomBytes(32); const iv = crypto.randomBytes(16); From 9c862f03201d09689a0c2d2f734fdde9c8f10ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:28:18 +0100 Subject: [PATCH 75/87] fix(claw): disable refetchOnWindowFocus for Google setup command query Each refetch generates a new 1-hour JWT. The 50-minute refetchInterval is sufficient; window focus refetches are unnecessary churn. --- src/app/(app)/claw/components/SettingsTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 7e909451a..252d6e749 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -77,6 +77,7 @@ function GoogleAccountSection({ trpc.kiloclaw.getGoogleSetupCommand.queryOptions(undefined, { enabled: !connected, refetchInterval: 50 * 60 * 1000, + refetchOnWindowFocus: false, }) ); const [copied, setCopied] = useState(false); From 5e25825fbaaf53a3430c403872dce9af727b5365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:32:26 +0100 Subject: [PATCH 76/87] fix(kiloclaw): remove stale config dir before extracting new gog tarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On reconnect (disconnect → new connect → redeploy), files from the old bundle that don't exist in the new one would linger. rmSync the config dir before unpacking to ensure a clean slate. --- kiloclaw/controller/src/gog-credentials.test.ts | 17 +++++++++++++++++ kiloclaw/controller/src/gog-credentials.ts | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index 4801dd0ba..b563d0b8e 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -32,6 +32,8 @@ describe('writeGogCredentials', () => { const result = await writeGogCredentials(env, dir, deps); expect(result).toBe(true); + // Should remove stale config before extracting + expect(deps.rmSync).toHaveBeenCalledWith(dir, { recursive: true, force: true }); expect(deps.mkdirSync).toHaveBeenCalledWith('/root/.config', { recursive: true }); // Should write temp tarball file @@ -80,6 +82,21 @@ describe('writeGogCredentials', () => { expect(deps.mkdirSync).not.toHaveBeenCalled(); }); + it('removes existing config dir before extracting new tarball', async () => { + const deps = mockDeps(); + const callOrder: string[] = []; + deps.rmSync.mockImplementation(() => callOrder.push('rmSync')); + deps.mkdirSync.mockImplementation(() => callOrder.push('mkdirSync')); + deps.execFileSync.mockImplementation(() => callOrder.push('execFileSync')); + + const env: Record = { + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, + }; + await writeGogCredentials(env, '/root/.config/gogcli', deps); + + expect(callOrder).toEqual(['rmSync', 'mkdirSync', 'execFileSync']); + }); + it('cleans up temp tarball even if extraction fails', async () => { const deps = mockDeps(); deps.execFileSync.mockImplementation(() => { diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 7141e0522..e39c70d71 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -53,6 +53,10 @@ export async function writeGogCredentials( return false; } + // Remove stale config from a previous connection before extracting the new bundle. + // Without this, files present in the old tarball but absent from the new one linger. + d.rmSync(configDir, { recursive: true, force: true }); + // Decode tarball and extract to /root/.config/ const parentDir = path.dirname(configDir); d.mkdirSync(parentDir, { recursive: true }); From 48e67b684d0e527a1b0ca63674849a587b89d86e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:32:46 +0100 Subject: [PATCH 77/87] fix(kiloclaw): harden tar extraction with --no-absolute-names Defense-in-depth against path traversal in user-supplied tarballs. The tarball is encrypted with the worker's RSA key so exploitation requires compromising that key, but the flag is cheap insurance. --- kiloclaw/controller/src/gog-credentials.test.ts | 3 ++- kiloclaw/controller/src/gog-credentials.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index b563d0b8e..19db9562b 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -42,12 +42,13 @@ describe('writeGogCredentials', () => { Buffer.from(FAKE_TARBALL_BASE64, 'base64') ); - // Should run tar extraction + // Should run tar extraction with path traversal protection expect(deps.execFileSync).toHaveBeenCalledWith('tar', [ 'xzf', '/root/.config/gogcli-config.tar.gz', '-C', '/root/.config', + '--no-absolute-names', ]); // Should clean up temp tarball diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index e39c70d71..839d66909 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -65,7 +65,7 @@ export async function writeGogCredentials( d.writeFileSync(tmpTarball, Buffer.from(tarballBase64, 'base64')); try { - d.execFileSync('tar', ['xzf', tmpTarball, '-C', parentDir]); + d.execFileSync('tar', ['xzf', tmpTarball, '-C', parentDir, '--no-absolute-names']); console.log(`[gog] Extracted config tarball to ${configDir}`); } finally { try { From 38c93e9e9615c8057a239113ca2759f4a0f89098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:33:07 +0100 Subject: [PATCH 78/87] fix(kiloclaw): clear gog env vars on disconnect cleanup --- kiloclaw/controller/src/gog-credentials.test.ts | 14 ++++++++++++++ kiloclaw/controller/src/gog-credentials.ts | 3 +++ 2 files changed, 17 insertions(+) diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index 19db9562b..4f4bb1ff7 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -83,6 +83,20 @@ describe('writeGogCredentials', () => { expect(deps.mkdirSync).not.toHaveBeenCalled(); }); + it('clears gog env vars when tarball env var is absent', async () => { + const deps = mockDeps(); + const env: Record = { + GOG_KEYRING_BACKEND: 'file', + GOG_KEYRING_PASSWORD: 'kiloclaw', + GOG_ACCOUNT: 'user@gmail.com', + }; + await writeGogCredentials(env, '/root/.config/gogcli', deps); + + expect(env.GOG_KEYRING_BACKEND).toBeUndefined(); + expect(env.GOG_KEYRING_PASSWORD).toBeUndefined(); + expect(env.GOG_ACCOUNT).toBeUndefined(); + }); + it('removes existing config dir before extracting new tarball', async () => { const deps = mockDeps(); const callOrder: string[] = []; diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 839d66909..70bc217fe 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -50,6 +50,9 @@ export async function writeGogCredentials( if (!tarballBase64) { // Clean up stale config from a previous run (e.g. after disconnect) d.rmSync(configDir, { recursive: true, force: true }); + delete env.GOG_KEYRING_BACKEND; + delete env.GOG_KEYRING_PASSWORD; + delete env.GOG_ACCOUNT; return false; } From 04409a947777899548cf40e0885c7995acb23997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:33:20 +0100 Subject: [PATCH 79/87] fix(google-setup): fail on any non-OK response in auth preflight Previously only 401/403 were rejected, so a 500/503 would fall through as "verified" and send the user through the entire OAuth flow before failing at the final POST step. --- kiloclaw/google-setup/setup.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 35f95ddf2..b7cecf7c9 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -124,8 +124,12 @@ const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, { headers: authHeaders, }); -if (authCheckRes.status === 401 || authCheckRes.status === 403) { - console.error('Invalid or expired session token. Log in to kilo.ai and copy a fresh token.'); +if (!authCheckRes.ok) { + if (authCheckRes.status === 401 || authCheckRes.status === 403) { + console.error('Invalid or expired session token. Log in to kilo.ai and copy a fresh token.'); + } else { + console.error(`Worker returned unexpected status ${authCheckRes.status} during auth check.`); + } process.exit(1); } From 94a96d8b54143ba724c2df8c54a10e49d9b499d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:43:09 +0100 Subject: [PATCH 80/87] refactor(controller): await writeGogCredentials before gateway startup make startController wait for credentials write, exposing gog env vars to the child process on first spawn; preserve best-effort error handling by logging and continuing startup --- kiloclaw/controller/src/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/kiloclaw/controller/src/index.ts b/kiloclaw/controller/src/index.ts index 349882f2d..e30259ca4 100644 --- a/kiloclaw/controller/src/index.ts +++ b/kiloclaw/controller/src/index.ts @@ -114,13 +114,13 @@ async function handleHttpRequest( export async function startController(env: NodeJS.ProcessEnv = process.env): Promise { const config = loadRuntimeConfig(env); - // writeGogCredentials is async but we don't await it — - // credential extraction is best-effort and should not block controller startup. - // This is safe: the gateway process doesn't use gog credentials at startup; - // gog is only invoked later by user/bot actions, well after this completes. - writeGogCredentials(env as Record).catch(err => { + // Write gog credentials before starting the gateway so env vars are available + // to the child process on first spawn. Best-effort: log and continue on failure. + try { + await writeGogCredentials(env as Record); + } catch (err) { console.error('[gog] Failed to write credentials:', err); - }); + } const supervisor = createSupervisor({ gatewayArgs: config.gatewayArgs, From 0a9295fe500b142f4223faee19f18b314c7e054d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:43:30 +0100 Subject: [PATCH 81/87] feat(kiloclaw/google-setup): add arch-aware gogcli install in Dockerfile --- kiloclaw/google-setup/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kiloclaw/google-setup/Dockerfile b/kiloclaw/google-setup/Dockerfile index 2a717779e..ac2158682 100644 --- a/kiloclaw/google-setup/Dockerfile +++ b/kiloclaw/google-setup/Dockerfile @@ -16,8 +16,10 @@ RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dea && apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \ && rm -rf /var/lib/apt/lists/* -# Install gogcli v0.11.0 pre-built binary -RUN curl -fsSL https://github.com/steipete/gogcli/releases/download/v0.11.0/gogcli_0.11.0_linux_amd64.tar.gz \ +# Install gogcli v0.11.0 pre-built binary (arch-aware) +ARG TARGETARCH +RUN GOARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \ + && curl -fsSL "https://github.com/steipete/gogcli/releases/download/v0.11.0/gogcli_0.11.0_linux_${GOARCH}.tar.gz" \ | tar xz -C /usr/local/bin gog \ && chmod +x /usr/local/bin/gog From a0049798281f26f30827c470abad19488d7b4d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 11 Mar 2026 22:47:52 +0100 Subject: [PATCH 82/87] fix(google-setup): verify credentials before tarballing Run `gog auth list --json` after OAuth flow to confirm the account was actually stored before creating and uploading the config tarball. --- kiloclaw/google-setup/setup.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index b7cecf7c9..f4de9e595 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -362,6 +362,27 @@ try { console.log(`\nAuthenticated as: ${userEmail}`); +// Verify the account was actually stored before tarballing +console.log('Verifying credentials...'); +try { + const authList = execFileSync('gog', ['auth', 'list', '--json'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + env: gogEnv, + }).trim(); + const accounts = JSON.parse(authList); + const found = Array.isArray(accounts) && accounts.some(a => a.email === userEmail || a.account === userEmail); + if (!found) { + throw new Error(`Account ${userEmail} not found in gog auth list`); + } + console.log('Credentials verified.\n'); +} catch (err) { + console.error('Credential verification failed — the OAuth flow may not have completed correctly.'); + console.error(err.message); + console.error('Please re-run the setup and try again.'); + process.exit(1); +} + // --------------------------------------------------------------------------- // Step 6: Create config tarball, encrypt, and POST // --------------------------------------------------------------------------- From 9c04098a7d06d6fa3b7ee6ab42459d6fbc4b2759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 12 Mar 2026 12:27:45 +0100 Subject: [PATCH 83/87] fix(controller): remove --no-absolute-names from tar extraction This flag was causing tar extraction to fail silently on the container, resulting in empty gogcli config directories and no Google credentials. --- kiloclaw/controller/src/gog-credentials.test.ts | 2 -- kiloclaw/controller/src/gog-credentials.ts | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts index 4f4bb1ff7..ad156764c 100644 --- a/kiloclaw/controller/src/gog-credentials.test.ts +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -42,13 +42,11 @@ describe('writeGogCredentials', () => { Buffer.from(FAKE_TARBALL_BASE64, 'base64') ); - // Should run tar extraction with path traversal protection expect(deps.execFileSync).toHaveBeenCalledWith('tar', [ 'xzf', '/root/.config/gogcli-config.tar.gz', '-C', '/root/.config', - '--no-absolute-names', ]); // Should clean up temp tarball diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 70bc217fe..04f01792f 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -64,11 +64,13 @@ export async function writeGogCredentials( const parentDir = path.dirname(configDir); d.mkdirSync(parentDir, { recursive: true }); + const tarballBuffer = Buffer.from(tarballBase64, 'base64'); + const tmpTarball = path.join(parentDir, 'gogcli-config.tar.gz'); - d.writeFileSync(tmpTarball, Buffer.from(tarballBase64, 'base64')); + d.writeFileSync(tmpTarball, tarballBuffer); try { - d.execFileSync('tar', ['xzf', tmpTarball, '-C', parentDir, '--no-absolute-names']); + d.execFileSync('tar', ['xzf', tmpTarball, '-C', parentDir]); console.log(`[gog] Extracted config tarball to ${configDir}`); } finally { try { From b2b22b8de7c438b647063c820296fea3464aec0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 12 Mar 2026 12:35:59 +0100 Subject: [PATCH 84/87] chore: revert random llm change --- kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts index afc96b1c8..85730305a 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/fly-machines.ts @@ -217,6 +217,7 @@ export async function createNewMachine( envFlyRegion?: string ): Promise { const machine = await fly.createMachine(flyConfig, machineConfig, { + name: state.sandboxId ?? undefined, region: state.flyRegion ?? envFlyRegion ?? undefined, minSecretsVersion, }); From 643a470082426e2f862a8eaf036bd191fd8a0100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 12 Mar 2026 12:36:41 +0100 Subject: [PATCH 85/87] chore: revert bad merge stuff --- src/app/(app)/claw/components/SettingsTab.tsx | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index d5e5b85cd..45341a0ad 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -38,33 +38,6 @@ import { VersionPinCard } from './VersionPinCard'; type ClawMutations = ReturnType; -/** - * Maps a catalog entry ID to whether the entry is "configured" based on - * the channel status from the config endpoint. The config endpoint returns - * per-field booleans (telegram, discord, slackBot, slackApp) rather than - * per-entry booleans, so we need this bridge mapping. - * - * IMPORTANT: This switch must be updated when new channel entries are added - * to the secret catalog. Unknown entry IDs silently return false ("Not configured"). - * The proper fix is to make the config endpoint return per-entry-id status - * derived from the catalog, eliminating this manual mapping. - */ -function isEntryConfigured( - entryId: string, - channelStatus: { telegram: boolean; discord: boolean; slackBot: boolean; slackApp: boolean } -): boolean { - switch (entryId) { - case 'telegram': - return channelStatus.telegram; - case 'discord': - return channelStatus.discord; - case 'slack': - return channelStatus.slackBot && channelStatus.slackApp; - default: - return false; - } -} - function GoogleAccountSection({ connected, mutations, From bf8ce1d7409076686be5157c690b0064bee6c018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 12 Mar 2026 12:58:43 +0100 Subject: [PATCH 86/87] fix(ui): add confirmation dialog before Google account disconnect Reconnecting requires re-running the full Docker OAuth setup flow, so accidental disconnects are costly. Use ConfirmActionDialog to match the pattern used by other destructive actions in SettingsTab. --- src/app/(app)/claw/components/SettingsTab.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 44e18e7d0..4901c7448 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -59,6 +59,7 @@ function GoogleAccountSection({ }) ); const [copied, setCopied] = useState(false); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); const isDisconnecting = mutations.disconnectGoogle.isPending; const command = setupData?.command; @@ -86,12 +87,7 @@ function GoogleAccountSection({ variant="outline" size="sm" disabled={isDisconnecting} - onClick={() => { - mutations.disconnectGoogle.mutate(undefined, { - onSuccess: () => toast.success('Google account disconnected. Redeploy to apply.'), - onError: err => toast.error(`Failed to disconnect: ${err.message}`), - }); - }} + onClick={() => setConfirmDisconnect(true)} > {isDisconnecting ? 'Disconnecting...' : 'Disconnect'} @@ -124,6 +120,26 @@ function GoogleAccountSection({ )} + + } + isPending={isDisconnecting} + pendingLabel="Disconnecting..." + onConfirm={() => { + mutations.disconnectGoogle.mutate(undefined, { + onSuccess: () => { + toast.success('Google account disconnected. Redeploy to apply.'); + setConfirmDisconnect(false); + }, + onError: err => toast.error(`Failed to disconnect: ${err.message}`), + }); + }} + /> ); } From 9bfac3b4bc366a2decfcea6cad07d176f9bce349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 12 Mar 2026 15:31:35 +0100 Subject: [PATCH 87/87] fix(config): unify GOG_KEYRING_PASSWORD usage across setup and runtime document that the password is not a secret and must be shared across files google-setup/setup.mjs, start-openclaw.sh, and controller/src/gog-credentials.ts --- kiloclaw/controller/src/gog-credentials.ts | 11 ++++++----- kiloclaw/google-setup/setup.mjs | 9 +++++---- kiloclaw/start-openclaw.sh | 15 +++++++-------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/kiloclaw/controller/src/gog-credentials.ts b/kiloclaw/controller/src/gog-credentials.ts index 04f01792f..e6d19cb59 100644 --- a/kiloclaw/controller/src/gog-credentials.ts +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -80,11 +80,12 @@ export async function writeGogCredentials( } } - // Set env vars for gog runtime - // NOTE: GOG_KEYRING_PASSWORD must match in all three locations: - // - here (controller/src/gog-credentials.ts) - // - kiloclaw/start-openclaw.sh - // - kiloclaw/google-setup/setup.mjs + // Set env vars for gog runtime. + // GOG_KEYRING_PASSWORD is NOT a secret. The 99designs/keyring file backend + // requires a password to operate, but gog runs inside a single-tenant VM + // with no shared access. The value is arbitrary — it just needs to be + // consistent across setup (google-setup/setup.mjs), container startup + // (start-openclaw.sh), and here. env.GOG_KEYRING_BACKEND = 'file'; env.GOG_KEYRING_PASSWORD = 'kiloclaw'; if (env.GOOGLE_ACCOUNT_EMAIL) { diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index 501c3c3f9..113241e43 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -303,10 +303,11 @@ function encryptEnvelope(plaintext, pemKey) { } const gogHome = '/tmp/gogcli-home'; -// NOTE: GOG_KEYRING_PASSWORD must match in all three locations: -// - here (google-setup/setup.mjs) -// - controller/src/gog-credentials.ts -// - start-openclaw.sh +// GOG_KEYRING_PASSWORD is NOT a secret. The 99designs/keyring file backend +// requires a password to operate, but gog runs inside a single-tenant VM +// with no shared access. The value is arbitrary — it just needs to be +// consistent across setup (here), container startup (start-openclaw.sh), +// and runtime (controller/src/gog-credentials.ts). const gogEnv = { ...process.env, HOME: gogHome, diff --git a/kiloclaw/start-openclaw.sh b/kiloclaw/start-openclaw.sh index b16ef0ae2..fbcf3fbf0 100644 --- a/kiloclaw/start-openclaw.sh +++ b/kiloclaw/start-openclaw.sh @@ -398,14 +398,13 @@ EOFPATCH # ============================================================ # GOG (Google Workspace CLI) KEYRING # ============================================================ -# gog uses 99designs/keyring with the file backend. Set GOG_KEYRING_PASSWORD -# here so it's inherited by the gateway and all child processes -# (controller → gateway → gog). Without this, the keyring library prompts -# for a password on a missing TTY and fails. -# NOTE: GOG_KEYRING_PASSWORD must match in all three locations: -# - here (start-openclaw.sh) -# - controller/src/gog-credentials.ts -# - google-setup/setup.mjs +# GOG_KEYRING_PASSWORD is NOT a secret. The 99designs/keyring file backend +# requires a password to operate, but gog runs inside a single-tenant VM +# with no shared access. The value is arbitrary — it just needs to be +# consistent across setup (google-setup/setup.mjs), container startup +# (here), and runtime (controller/src/gog-credentials.ts). Without this +# env var, the keyring library prompts for a password on a missing TTY +# and fails. export GOG_KEYRING_PASSWORD="kiloclaw" # ============================================================