feat(migrate-v1): auto-resolve missing v2 channel keys via adapter APIs

`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 <DISCORD_BOT_TOKEN>
    → { 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
This commit is contained in:
gabi-simons
2026-04-23 12:30:27 +00:00
parent 3ee7d2147e
commit e1c8876a72
2 changed files with 95 additions and 2 deletions

View File

@@ -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<void> {
// 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<string, string>();
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<string, string>();
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<void> {
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 ?? '',

View File

@@ -518,6 +518,50 @@ export const CHANNEL_AUTH_REGISTRY: Record<string, ChannelAuthSpec> = {
},
};
/**
* 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<Record<string, string>> {
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<string, string> = {};
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-<x>.sh`
* script, if one exists. `null` means no v2 skill is available yet — the