From e1c8876a728493a7c940ff63d7c0321340f60a90 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 23 Apr 2026 12:30:27 +0000 Subject: [PATCH] feat(migrate-v1): auto-resolve missing v2 channel keys via adapter APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate-channel-auth` now tries to derive v2-required keys that v1 never stored by calling the channel's API with the credential v1 did have. When the gap can be closed automatically, the keys land in v2 `.env` before the missing-required check, and the step reports `success` instead of `partial`. When it can't, the existing followup fires unchanged. ## Discord v1 used raw `discord.js` (bot token only). v2's Chat SDK needs `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`. Both can be fetched with the bot token via: GET /oauth2/applications/@me Authorization: Bot → { id, verify_key, … } For a stock v1 Discord user, this means `bash nanoclaw.sh` now produces a fully working v2 Discord adapter with zero manual key-setting — just stop v1, and v2 takes over. ## Surface - `autoResolveV2Keys(channelType, lookup)` in `setup/migrate-v1/shared.ts` — pluggable per-channel resolver, returns a `{key: value}` map. Never throws; returns `{}` on any failure (network, auth, unexpected shape). Logs keys resolved, never values. - `migrate-channel-auth` wiring: build a lookup over v1 + v2 .env, call the resolver, append resolved keys to v2 .env (never overwriting), sync to `data/env/env`, then re-check `requiredV2Keys` to compute the real gap. Sidecar annotation `(auto-resolved)` on `env_keys_copied` in the handoff so the skill can tell which came from v1 vs derived. ## Extending to other channels Slack has `/auth.test` (bot token → team/app info), Telegram has `/getMe`, Matrix has `/whoami`. Most don't cover the full required-key set v2 needs (e.g. Slack's `SLACK_SIGNING_SECRET` lives only in app config and has no API equivalent). Add resolvers case-by-case when the API supports it; the registry's `requiredV2Keys` + followup fallback covers the rest. ## Testing - Stripped `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY` from v2 `.env` - Re-ran migration (wired-only, 301 groups): resolver populated both keys via the API; `migrate-channel-auth: success` (was `partial`); `overall_status: success` - Restarted v2: Discord adapter booted clean, Gateway connected, `GUILD_CREATE` received - v1 stopped, v2 handling Discord traffic --- setup/migrate-v1/channel-auth.ts | 53 ++++++++++++++++++++++++++++++-- setup/migrate-v1/shared.ts | 44 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/setup/migrate-v1/channel-auth.ts b/setup/migrate-v1/channel-auth.ts index 1ef2bc4..8c8a415 100644 --- a/setup/migrate-v1/channel-auth.ts +++ b/setup/migrate-v1/channel-auth.ts @@ -17,6 +17,7 @@ import path from 'path'; import { emitStatus } from '../status.js'; import { CHANNEL_AUTH_REGISTRY, + autoResolveV2Keys, readHandoff, recordStep, v1PathsFor, @@ -126,8 +127,56 @@ export async function run(_args: string[]): Promise { // Check v2's .env for required keys the v2 adapter needs to boot. v1 // may not have had all of them (e.g. v1's Discord used discord.js // directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK - // requires). Surface missing ones as actionable followups. + // requires). Try to auto-resolve the gap by calling the channel's API + // with the v1 credential; fall through to a followup for anything we + // can't resolve. const v2EnvPath = path.join(process.cwd(), '.env'); + const v1EnvMap = new Map(); + for (const line of v1Env.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + v1EnvMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + + // Also let the resolver reach into v2's .env (migrate-env already merged + // v1 keys into v2). Either source is fine for derivation inputs. + const v2EnvPre = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2EnvPreMap = new Map(); + for (const line of v2EnvPre.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + v2EnvPreMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + + const resolved = await autoResolveV2Keys( + ch.channel_type, + (key) => v1EnvMap.get(key) ?? v2EnvPreMap.get(key), + ); + const resolvedKeys = Object.keys(resolved); + if (resolvedKeys.length > 0) { + // Append to v2 .env (never overwriting existing values) + sync the + // container-side copy. Log keys, never values. + let text = v2EnvPre; + if (text && !text.endsWith('\n')) text += '\n'; + for (const [key, value] of Object.entries(resolved)) { + if (v2EnvPreMap.has(key)) continue; + text += `${key}=${value}\n`; + } + fs.writeFileSync(v2EnvPath, text); + try { + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { + // Best-effort; service restart rehydrates it if needed. + } + } + + // Re-read v2 .env after possible resolution to compute the real gap. const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; const v2EnvKeys = new Set( v2Env @@ -179,7 +228,7 @@ export async function run(_args: string[]): Promise { results.push({ channel_type: ch.channel_type, - env_keys_copied: envKeysPresentInV1, + env_keys_copied: [...envKeysPresentInV1, ...resolvedKeys.map((k) => `${k} (auto-resolved)`)], files_copied: filesCopied, files_missing: filesMissing, notes: spec.note ?? '', diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts index f9f03bc..bc5d3dd 100644 --- a/setup/migrate-v1/shared.ts +++ b/setup/migrate-v1/shared.ts @@ -518,6 +518,50 @@ export const CHANNEL_AUTH_REGISTRY: Record = { }, }; +/** + * For channels where v2's adapter needs keys v1 never stored (e.g. Discord's + * Chat SDK wants DISCORD_APPLICATION_ID + DISCORD_PUBLIC_KEY, but v1 used + * raw discord.js with just the bot token), try to derive the missing keys + * from the v1 creds we already have by calling the channel's API. + * + * Returns a map of key → value for what we successfully resolved. + * Never throws; returns `{}` on any failure (network, auth, unexpected + * shape). The caller writes the resolved keys to v2 .env, then re-checks + * `requiredV2Keys` so the step reports `success` instead of `partial` when + * auto-resolution covered the gap. + * + * Adding a new channel resolver: pull the needed values from an endpoint + * that accepts only the v1-side credential (bot token, API key). Don't + * prompt, don't log values. If the endpoint has rate limits, keep this + * best-effort and fail silently. + */ +export async function autoResolveV2Keys( + channelType: string, + v1EnvLookup: (key: string) => string | undefined, +): Promise> { + if (channelType === 'discord') { + const token = v1EnvLookup('DISCORD_BOT_TOKEN'); + if (!token) return {}; + try { + const resp = await fetch('https://discord.com/api/v10/oauth2/applications/@me', { + headers: { Authorization: `Bot ${token}` }, + }); + if (!resp.ok) return {}; + const data = (await resp.json()) as { id?: string; verify_key?: string }; + const out: Record = {}; + if (typeof data.id === 'string' && data.id) out.DISCORD_APPLICATION_ID = data.id; + if (typeof data.verify_key === 'string' && data.verify_key) { + out.DISCORD_PUBLIC_KEY = data.verify_key; + } + return out; + } catch { + return {}; + } + } + + return {}; +} + /** * Map a v2 `channel_type` name to the corresponding `setup/install-.sh` * script, if one exists. `null` means no v2 skill is available yet — the