diff --git a/kiloclaw/Dockerfile b/kiloclaw/Dockerfile index 42529ab41..8eb309dad 100644 --- a/kiloclaw/Dockerfile +++ b/kiloclaw/Dockerfile @@ -54,7 +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 Go (available at runtime for users to `go install` additional tools) ENV GO_VERSION=1.26.0 RUN ARCH="$(dpkg --print-architecture)" \ diff --git a/kiloclaw/controller/src/gog-credentials.test.ts b/kiloclaw/controller/src/gog-credentials.test.ts new file mode 100644 index 000000000..ad156764c --- /dev/null +++ b/kiloclaw/controller/src/gog-credentials.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +function mockDeps() { + return { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), + rmSync: vi.fn(), + execFileSync: vi.fn(), + }; +} + +// 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; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import('./gog-credentials'); + writeGogCredentials = mod.writeGogCredentials; + }); + + it('extracts tarball and sets env vars when GOOGLE_GOG_CONFIG_TARBALL is set', async () => { + const deps = mockDeps(); + const dir = '/root/.config/gogcli'; + const env: Record = { + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, + GOOGLE_ACCOUNT_EMAIL: 'user@gmail.com', + }; + 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 + expect(deps.writeFileSync).toHaveBeenCalledWith( + '/root/.config/gogcli-config.tar.gz', + Buffer.from(FAKE_TARBALL_BASE64, 'base64') + ); + + 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'); + + // Should set gog env vars + expect(env.GOG_KEYRING_BACKEND).toBe('file'); + expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw'); + expect(env.GOG_ACCOUNT).toBe('user@gmail.com'); + }); + + it('works without GOOGLE_ACCOUNT_EMAIL', async () => { + const deps = mockDeps(); + const env: Record = { + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, + }; + const result = await writeGogCredentials(env, '/root/.config/gogcli', deps); + + expect(result).toBe(true); + expect(env.GOG_KEYRING_BACKEND).toBe('file'); + expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw'); + expect(env.GOG_ACCOUNT).toBeUndefined(); + }); + + it('returns false and cleans up when tarball env var is absent', async () => { + const deps = mockDeps(); + const dir = '/root/.config/gogcli'; + const result = await writeGogCredentials({}, dir, deps); + + expect(result).toBe(false); + expect(deps.rmSync).toHaveBeenCalledWith(dir, { recursive: true, force: true }); + 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[] = []; + 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(() => { + throw new Error('tar failed'); + }); + + const env: Record = { + GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, + }; + + await expect(writeGogCredentials(env, '/root/.config/gogcli', deps)).rejects.toThrow( + 'tar failed' + ); + 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 new file mode 100644 index 000000000..e6d19cb59 --- /dev/null +++ b/kiloclaw/controller/src/gog-credentials.ts @@ -0,0 +1,96 @@ +/** + * Sets up gogcli credentials by extracting a pre-built config tarball. + * + * 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 path from 'node:path'; + +const GOG_CONFIG_DIR = '/root/.config/gogcli'; + +export type GogCredentialsDeps = { + mkdirSync: (dir: string, opts: { recursive: boolean }) => void; + writeFileSync: (path: string, data: Buffer) => void; + unlinkSync: (path: string) => void; + rmSync: (path: string, opts: { recursive: boolean; force: boolean }) => void; + execFileSync: (file: string, args: string[]) => void; +}; + +/** + * 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. + */ +export async function writeGogCredentials( + env: Record = process.env as Record, + configDir = GOG_CONFIG_DIR, + 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) => fs.default.writeFileSync(p, data)), + unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), + rmSync: deps?.rmSync ?? ((p, opts) => fs.default.rmSync(p, opts)), + execFileSync: + deps?.execFileSync ?? + ((file, args) => + cp.default.execFileSync(file, args, { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }; + + const tarballBase64 = env.GOOGLE_GOG_CONFIG_TARBALL; + + 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; + } + + // 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 }); + + const tarballBuffer = Buffer.from(tarballBase64, 'base64'); + + const tmpTarball = path.join(parentDir, 'gogcli-config.tar.gz'); + d.writeFileSync(tmpTarball, tarballBuffer); + + try { + d.execFileSync('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 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) { + env.GOG_ACCOUNT = env.GOOGLE_ACCOUNT_EMAIL; + } + + return true; +} diff --git a/kiloclaw/controller/src/index.ts b/kiloclaw/controller/src/index.ts index 8283acaf0..e30259ca4 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 { writeGogCredentials } from './gog-credentials'; export type RuntimeConfig = { port: number; @@ -112,6 +113,15 @@ async function handleHttpRequest( export async function startController(env: NodeJS.ProcessEnv = process.env): Promise { const config = loadRuntimeConfig(env); + + // 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, }); 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/e2e/google-credentials-integration.mjs b/kiloclaw/e2e/google-credentials-integration.mjs new file mode 100644 index 000000000..50cfacadf --- /dev/null +++ b/kiloclaw/e2e/google-credentials-integration.mjs @@ -0,0 +1,420 @@ +#!/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 reads secrets from kiloclaw/.dev.vars so it works without manual env setup. + * + * Usage: + * 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'; +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 ?? 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()}`; + +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 = { + gogConfigTarball: { encryptedData: 'dGVzdA==', encryptedDEK: 'dGVzdA==', algorithm: 'rsa-aes-256-gcm', version: 1 }, + email: 'test@example.com', +}; + +// --------------------------------------------------------------------------- +// DB: create/remove test user via psql +// --------------------------------------------------------------------------- + +function sql(query) { + return execSync('psql "$PGURL" -tAc "$PGQUERY"', { + encoding: 'utf8', + timeout: 5000, + env: { ...process.env, PGURL: DATABASE_URL, PGQUERY: query }, + shell: '/bin/sh', + }).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; + } +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +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 (requires JWT auth at /api/admin/public-key) +// --------------------------------------------------------------------------- + +bold('1. Public key endpoint'); + +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 +// --------------------------------------------------------------------------- + +bold(`2. Provision test instance (userId=${USER_ID})`); + +// Fire off provision without waiting — it can take 60s+ to create Fly app + machine. +// We only need the DO to exist for google-credentials tests, so poll status instead. +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 (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 +// --------------------------------------------------------------------------- + +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 already generated in section 1 + assertNotEmpty('JWT generated', JWT); + + // 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 }); + 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: { gogConfigTarball: { 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!'); +} diff --git a/kiloclaw/e2e/google-setup-e2e.mjs b/kiloclaw/e2e/google-setup-e2e.mjs new file mode 100644 index 000000000..44b60f505 --- /dev/null +++ b/kiloclaw/e2e/google-setup-e2e.mjs @@ -0,0 +1,294 @@ +#!/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/e2e/google-setup-e2e.mjs + */ + +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 ?? 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'; +const DOCKER_CONTEXT = path.resolve(__dirname, '../google-setup'); + +// 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; + +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 "$PGURL" -tAc "$PGQUERY"', { + encoding: 'utf8', + timeout: 5000, + env: { ...process.env, PGURL: DATABASE_URL, PGQUERY: query }, + shell: '/bin/sh', + }).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 + ')'); + +// 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); +} +green('Instance DO reachable'); + +// 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, + `--token=${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!'); 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 new file mode 100644 index 000000000..ac2158682 --- /dev/null +++ b/kiloclaw/google-setup/Dockerfile @@ -0,0 +1,29 @@ +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/* + +# 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 + +WORKDIR /app +COPY setup.mjs ./ + +ENTRYPOINT ["node", "setup.mjs"] diff --git a/kiloclaw/google-setup/README.md b/kiloclaw/google-setup/README.md new file mode 100644 index 000000000..63311aafe --- /dev/null +++ b/kiloclaw/google-setup/README.md @@ -0,0 +1,71 @@ +# KiloClaw Google Setup + +Docker image that guides users through connecting their Google account to KiloClaw. + +## What it does + +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 +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 --token="YOUR_SESSION_JWT" +``` + +For local development against a local worker: + +```bash +docker run -it --network host ghcr.io/kilo-org/google-setup \ + --token="YOUR_SESSION_JWT" \ + --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" diff --git a/kiloclaw/google-setup/package.json b/kiloclaw/google-setup/package.json new file mode 100644 index 000000000..6d9916aa4 --- /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" + }, + "engines": { + "node": ">=22" + } +} diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs new file mode 100644 index 000000000..113241e43 --- /dev/null +++ b/kiloclaw/google-setup/setup.mjs @@ -0,0 +1,431 @@ +#!/usr/bin/env node + +/** + * KiloClaw Google Account Setup + * + * Docker-based tool that: + * 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 + * 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, execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import readline from 'node:readline'; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +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 (!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'; + 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 ${token}`, + 'content-type': 'application/json', +}; + +// 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', + '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 execFileSync(cmd, args, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); +} + +// --------------------------------------------------------------------------- +// Step 1: Validate session token +// --------------------------------------------------------------------------- + +console.log('Validating session token...'); + +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.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); +} + +console.log('Session token 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 as a numbered menu + console.log('\nFetching your projects...'); + let projects = []; + try { + 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: '); + } +} 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'); + +// --------------------------------------------------------------------------- +// 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}`); +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); +} + +// --------------------------------------------------------------------------- +// Step 5: Run gog auth to set credentials and authorize account +// --------------------------------------------------------------------------- + +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); + 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'; +// 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, + 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, + 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'], + }, +}); + +// Write to temp file so gog can read it +const clientSecretPath = '/tmp/client_secret.json'; +writeFileSync(clientSecretPath, clientSecretJson); + +console.log('\nSetting up gog credentials...'); + +try { + await runCommand('gog', ['auth', 'credentials', 'set', clientSecretPath], { + env: gogEnv, + }); +} catch (err) { + console.error('gog auth credentials set failed:', err.message); + process.exit(1); +} + +// 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'); + +try { + await runCommand('gog', [ + 'auth', 'add', userEmail, + '--services=all', + '--force-consent', + ], { + env: gogEnv, + }); +} catch (err) { + console.error('gog auth add failed:', err.message); + process.exit(1); +} + +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 parsed = JSON.parse(authList); + const accounts = Array.isArray(parsed) ? parsed : parsed.accounts ?? []; + const found = 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 +// --------------------------------------------------------------------------- + +console.log('Creating config tarball...'); +const tarballBuffer = execSync(`tar czf - -C ${gogHome}/.config gogcli`, { + maxBuffer: 1024 * 1024, +}); +const tarballBase64 = tarballBuffer.toString('base64'); + +console.log(`Config tarball size: ${tarballBuffer.length} bytes`); + +console.log('Encrypting config tarball...'); + +const encryptedBundle = { + gogConfigTarball: encryptEnvelope(tarballBase64, publicKeyPem), + email: userEmail, +}; + +// --------------------------------------------------------------------------- +// POST encrypted credentials 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.'); diff --git a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts index 90c01cb7f..0d90747d4 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, getFieldKeysByCategory, } from '../catalog.js'; @@ -329,6 +331,18 @@ describe('Secret Catalog', () => { }); }); + describe('INTERNAL_SENSITIVE_ENV_VARS', () => { + it('contains Google credential env vars', () => { + expect(INTERNAL_SENSITIVE_ENV_VARS.has('GOOGLE_GOG_CONFIG_TARBALL')).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 157be5bf9..a44cb782f 100644 --- a/kiloclaw/packages/secret-catalog/src/catalog.ts +++ b/kiloclaw/packages/secret-catalog/src/catalog.ts @@ -132,6 +132,15 @@ 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_GOG_CONFIG_TARBALL', +]); + /** * 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 cfae16498..49a1dc5de 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, getFieldKeysByCategory, } from './catalog'; diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts index 2c75da4f4..22ae8b49d 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts @@ -139,6 +139,7 @@ export async function buildUserEnvVars( kilocodeApiKey, kilocodeDefaultModel: state.kilocodeDefaultModel ?? undefined, channels: state.channels ?? undefined, + googleCredentials: state.googleCredentials ?? undefined, instanceFeatures: state.instanceFeatures, } ); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index bb07872be..b44e0233e 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -14,6 +14,7 @@ import type { InstanceConfig, PersistedState, EncryptedEnvelope, + GoogleCredentials, MachineSize, } from '../../schemas/instance-config'; import { DEFAULT_INSTANCE_FEATURES } from '../../schemas/instance-config'; @@ -457,6 +458,34 @@ export class KiloClawInstance extends DurableObject { return { configured }; } + /** + * 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.s.googleCredentials = credentials; + await this.ctx.storage.put({ googleCredentials: this.s.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.s.googleCredentials = null; + await this.ctx.storage.put({ googleCredentials: null }); + + return { googleConnected: false }; + } + // ── Pairing ───────────────────────────────────────────────────────── async listPairingRequests(forceRefresh = false) { @@ -764,6 +793,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: string | null; trackedImageTag: string | null; trackedImageDigest: string | null; + googleConnected: boolean; }> { await this.loadState(); @@ -798,6 +828,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: this.s.imageVariant, trackedImageTag: this.s.trackedImageTag, trackedImageDigest: this.s.trackedImageDigest, + googleConnected: this.s.googleCredentials !== null, }; } @@ -820,6 +851,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; @@ -855,6 +887,7 @@ export class KiloClawInstance extends DurableObject { imageVariant: this.s.imageVariant, trackedImageTag: this.s.trackedImageTag, trackedImageDigest: this.s.trackedImageDigest, + googleConnected: this.s.googleCredentials !== null, pendingDestroyMachineId: this.s.pendingDestroyMachineId, pendingDestroyVolumeId: this.s.pendingDestroyVolumeId, pendingPostgresMarkOnFinalize: this.s.pendingPostgresMarkOnFinalize, diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts index c9e4b2da2..7974f9fae 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts @@ -36,6 +36,7 @@ export async function loadState(ctx: DurableObjectState, s: InstanceMutableState s.kilocodeApiKeyExpiresAt = d.kilocodeApiKeyExpiresAt; s.kilocodeDefaultModel = d.kilocodeDefaultModel; s.channels = d.channels; + s.googleCredentials = d.googleCredentials; s.provisionedAt = d.provisionedAt; s.lastStartedAt = d.lastStartedAt; s.lastStoppedAt = d.lastStoppedAt; @@ -85,6 +86,7 @@ export function resetMutableState(s: InstanceMutableState): void { s.kilocodeApiKeyExpiresAt = null; s.kilocodeDefaultModel = null; s.channels = null; + s.googleCredentials = null; s.provisionedAt = null; s.lastStartedAt = null; s.lastStoppedAt = null; @@ -127,6 +129,7 @@ export function createMutableState(): InstanceMutableState { kilocodeApiKeyExpiresAt: null, kilocodeDefaultModel: null, channels: null, + googleCredentials: null, provisionedAt: null, lastStartedAt: null, lastStoppedAt: null, diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts index 40c5e6b76..de662103f 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts @@ -1,5 +1,5 @@ import type { KiloClawEnv } from '../../types'; -import type { PersistedState, MachineSize } from '../../schemas/instance-config'; +import type { GoogleCredentials, PersistedState, MachineSize } from '../../schemas/instance-config'; import type { FlyClientConfig } from '../../fly/client'; /** @@ -48,6 +48,7 @@ export type InstanceMutableState = { kilocodeApiKeyExpiresAt: PersistedState['kilocodeApiKeyExpiresAt']; kilocodeDefaultModel: PersistedState['kilocodeDefaultModel']; channels: PersistedState['channels']; + googleCredentials: GoogleCredentials | null; provisionedAt: number | null; lastStartedAt: number | null; lastStoppedAt: number | null; diff --git a/kiloclaw/src/gateway/env.test.ts b/kiloclaw/src/gateway/env.test.ts index 594d1e5d2..4a9a89088 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'; @@ -297,6 +297,77 @@ describe('buildEnvVars', () => { expect(result.env.GOOD_VAR).toBe('good'); }); + // ─── Google credentials (Layer 4b) ─────────────────────────────────── + + 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: { + gogConfigTarball: encryptForTest(tarballBase64, testPublicKey), + email: 'user@gmail.com', + }, + }); + + 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('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: { + gogConfigTarball: encryptForTest(tarballBase64, testPublicKey), + }, + }); + + 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 () => { + 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: { + gogConfigTarball: { + encryptedData: 'bad', + encryptedDEK: 'bad', + algorithm: 'rsa-aes-256-gcm' as const, + version: 1 as const, + }, + }, + }); + + expect(result.sensitive.GOOGLE_GOG_CONFIG_TARBALL).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 tarballBase64 = Buffer.from('fake-tarball').toString('base64'); + const result = await buildEnvVars(env, SANDBOX_ID, SECRET, { + googleCredentials: { + gogConfigTarball: encryptForTest(tarballBase64, testPublicKey), + }, + }); + + expect(result.sensitive.GOOGLE_GOG_CONFIG_TARBALL).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 190619d05..93c98ae0a 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -1,8 +1,19 @@ -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, 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 +26,7 @@ export type UserConfig = { kilocodeApiKey?: string | null; kilocodeDefaultModel?: string | null; channels?: EncryptedChannelTokens; + googleCredentials?: GoogleCredentials; instanceFeatures?: string[]; }; @@ -49,6 +61,7 @@ const SENSITIVE_KEYS = new Set([ 'KILOCODE_API_KEY', 'OPENCLAW_GATEWAY_TOKEN', ...ALL_SECRET_ENV_VARS, + ...INTERNAL_SENSITIVE_ENV_VARS, ]); /** @@ -149,6 +162,24 @@ export async function buildEnvVars( // All channel tokens are sensitive Object.assign(sensitive, channelEnv); } + + // 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 tarballBase64 = decryptWithPrivateKey( + userConfig.googleCredentials.gogConfigTarball, + env.AGENT_ENV_VARS_PRIVATE_KEY + ); + sensitive.GOOGLE_GOG_CONFIG_TARBALL = tarballBase64; + if (userConfig.googleCredentials.email) { + plainEnv.GOOGLE_ACCOUNT_EMAIL = userConfig.googleCredentials.email; + } + } catch (err) { + console.warn('Failed to decrypt Google credentials, starting without Google access:', err); + } + } } // Worker-level passthrough (non-sensitive) diff --git a/kiloclaw/src/routes/api.ts b/kiloclaw/src/routes/api.ts index 950043cfb..7f0b39584 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 @@ -88,6 +89,92 @@ adminApi.post('/gateway/restart', async c => { } }); +// 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; + +// 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); + 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); + 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 e60f1b86a..70c64104b 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -13,6 +13,7 @@ import { UserIdRequestSchema, DestroyRequestSchema, ChannelsPatchSchema, + GoogleCredentialsSchema, SecretsPatchSchema, } from '../schemas/instance-config'; import { @@ -214,6 +215,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); + } +}); + // PATCH /api/platform/secrets platform.patch('/secrets', async c => { const result = await parseBody(c, SecretsPatchSchema); diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 8f6630be6..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. - // 8 KiB is generous for token values (typical bot tokens are < 200 bytes). - encryptedData: z.string().max(8192), + // 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'), @@ -30,6 +30,13 @@ const envVarNameSchema = z .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Must be a valid shell identifier') .refine(s => !s.startsWith('KILOCLAW_'), 'Uses reserved prefix (KILOCLAW_*)'); +export const GoogleCredentialsSchema = z.object({ + gogConfigTarball: EncryptedEnvelopeSchema, // base64 tar.gz of ~/.config/gogcli/ + email: z.string().optional(), // for display ("Connected as user@...") +}); + +export type GoogleCredentials = z.infer; + export const InstanceConfigSchema = z.object({ envVars: z.record(envVarNameSchema, z.string()).optional(), encryptedSecrets: z.record(envVarNameSchema, EncryptedEnvelopeSchema).optional(), @@ -44,6 +51,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). @@ -78,7 +86,7 @@ export const SecretsPatchSchema = z.object({ export const ProvisionRequestSchema = z.object({ userId: z.string().min(1), - ...InstanceConfigSchema.shape, + ...InstanceConfigSchema.omit({ googleCredentials: true }).shape, }); export type ProvisionRequest = z.infer; @@ -117,6 +125,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), diff --git a/kiloclaw/start-openclaw.sh b/kiloclaw/start-openclaw.sh index 9831378bc..fbcf3fbf0 100644 --- a/kiloclaw/start-openclaw.sh +++ b/kiloclaw/start-openclaw.sh @@ -395,6 +395,18 @@ fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log('Configuration patched successfully'); EOFPATCH +# ============================================================ +# GOG (Google Workspace CLI) KEYRING +# ============================================================ +# 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" + # ============================================================ # START CONTROLLER # ============================================================ diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 4e0281409..4901c7448 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -1,7 +1,18 @@ 'use client'; -import { AlertTriangle, Hash, Package, RotateCcw, Save, Square } from 'lucide-react'; +import { + AlertTriangle, + Check, + Copy, + Hash, + Package, + RotateCcw, + Save, + Square, + 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'; @@ -15,6 +26,7 @@ import { useKiloClawLatestVersion, useKiloClawMyPin, } from '@/hooks/useKiloClaw'; +import { useTRPC } from '@/lib/trpc/utils'; import { useDefaultModelSelection } from '../hooks/useDefaultModelSelection'; import { getSettingsModelOptions } from './modelSupport'; @@ -31,6 +43,107 @@ import { VersionPinCard } from './VersionPinCard'; type ClawMutations = ReturnType; +function GoogleAccountSection({ + connected, + mutations, +}: { + connected: boolean; + mutations: ClawMutations; +}) { + const trpc = useTRPC(); + const { data: setupData } = useQuery( + trpc.kiloclaw.getGoogleSetupCommand.queryOptions(undefined, { + enabled: !connected, + refetchInterval: 50 * 60 * 1000, + refetchOnWindowFocus: false, + }) + ); + const [copied, setCopied] = useState(false); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + const isDisconnecting = mutations.disconnectGoogle.isPending; + const command = setupData?.command; + + function handleCopy() { + if (!command) return; + void navigator.clipboard.writeText(command); + 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 && ( + + )} +
+ + {!connected && command && ( +
+

+ Run this command in your terminal to connect your Google account: +

+
+
+                {command}
+              
+ +
+
+ )} +
+ + } + 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}`), + }); + }} + /> +
+ ); +} + export function SettingsTab({ status, mutations, @@ -336,6 +449,10 @@ export function SettingsTab({ + + + + 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/hooks/useKiloClaw.ts b/src/hooks/useKiloClaw.ts index ad85db1ce..6cbc1df56 100644 --- a/src/hooks/useKiloClaw.ts +++ b/src/hooks/useKiloClaw.ts @@ -197,6 +197,9 @@ export function useKiloClawMutations() { }, }) ), + disconnectGoogle: useMutation( + trpc.kiloclaw.disconnectGoogle.mutationOptions({ onSuccess: invalidateStatus }) + ), }; } diff --git a/src/lib/kiloclaw/kiloclaw-internal-client.ts b/src/lib/kiloclaw/kiloclaw-internal-client.ts index f83fcf52e..8ee713f50 100644 --- a/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -22,6 +22,8 @@ import type { GatewayProcessActionResponse, ConfigRestoreResponse, ControllerVersionResponse, + GoogleCredentialsInput, + GoogleCredentialsResponse, } from './types'; /** @@ -245,4 +247,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 9c6faa72e..42dae63a8 100644 --- a/src/lib/kiloclaw/types.ts +++ b/src/lib/kiloclaw/types.ts @@ -135,6 +135,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). */ @@ -223,6 +224,19 @@ export type ControllerVersionResponse = { openclawCommit?: string | null; }; +/** Input to POST /api/platform/google-credentials */ +export type GoogleCredentialsInput = { + googleCredentials: { + gogConfigTarball: EncryptedEnvelope; + email?: string; + }; +}; + +/** 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/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 a360fb7bd..93f3b60db 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -468,6 +468,24 @@ export const kiloclawRouter = createTRPCRouter({ return client.restoreConfig(ctx.user.id); }), + 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 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 --token="${token}"${workerFlag}`, + }; + }), + + 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 }) => {