fix(migrate): import current Hermes state and provider contracts (#106758)

* fix(migrate): update Hermes importer contracts

* refactor(migrate): split Hermes importer modules

* fix(migrate): remove duplicate MCP helper

* docs(plugin-sdk): refresh migration API baseline

* chore(migrate): keep release note in PR
This commit is contained in:
Peter Steinberger
2026-07-13 12:53:13 -07:00
committed by GitHub
parent a3804782dc
commit c182dda402
36 changed files with 4611 additions and 616 deletions
@@ -1,2 +1,2 @@
d577fbcd06352a606edad25f1e30b52681e2c5012c89e055117157751bec88a6 plugin-sdk-api-baseline.json
1680282717c9b65db51ca8b7d060b98737efebc5715b0aeb35d0d8a0d4ed2758 plugin-sdk-api-baseline.jsonl
8879762b036d3cea0c65fec77547e5a4654b2c02b710cb42b7ff98be4438c974 plugin-sdk-api-baseline.json
57aba01dd1368f3fdd5a444f08716c33ebd1dc7763caded250190799841f162e plugin-sdk-api-baseline.jsonl
+8 -8
View File
@@ -45,7 +45,7 @@ Running `openclaw migrate <provider>` with no other flags plans, previews, and (
Build the plan and exit without changing state.
</ParamField>
<ParamField path="--from <path>" type="string">
Override the source state directory. Hermes defaults to `~/.hermes`, Codex defaults to `~/.codex` (or `$CODEX_HOME`), Claude defaults to `~/.claude`.
Override the source state directory. Hermes follows `$HERMES_HOME` and the active profile, then uses the platform default (`~/.hermes` or `%LOCALAPPDATA%\hermes`). Codex defaults to `~/.codex` (or `$CODEX_HOME`), Claude defaults to `~/.claude`.
</ParamField>
<ParamField path="--include-secrets" type="boolean">
Import supported credentials without prompting. Interactive apply asks before importing detected auth credentials, with yes selected by default; non-interactive `--yes` requires `--include-secrets` to import them.
@@ -173,28 +173,28 @@ If Codex app-server plugin inventory is unavailable during planning, migration f
## Hermes provider
The bundled Hermes provider detects state at `~/.hermes` by default. Use `--from <path>` when Hermes lives elsewhere.
The bundled Hermes provider follows `$HERMES_HOME` and the active profile, then uses the platform default (`~/.hermes` or `%LOCALAPPDATA%\hermes`). Use `--from <path>` to override discovery.
### What Hermes imports
- Default model configuration from `config.yaml`.
- Configured model providers and custom OpenAI-compatible endpoints from `providers` and `custom_providers`.
- MCP server definitions from `mcp_servers` or `mcp.servers`.
- Configured model providers and custom OpenAI-compatible endpoints from `model`, `providers`, and `custom_providers`.
- MCP server definitions from `mcp_servers` or `mcp.servers`. Exact OpenClaw mappings cover default Streamable HTTP routing, OAuth scope, boolean TLS verification, separate client certificate/key paths, and Hermes native/resource/prompt tool policy. Unsupported Hermes-only runtime or credential fields are reported for manual review.
- `SOUL.md` and `AGENTS.md` into the OpenClaw agent workspace.
- `memories/MEMORY.md` and `memories/USER.md` appended to workspace memory files.
- Memory config defaults for OpenClaw file memory, plus archive or manual-review items for external memory providers such as Honcho.
- Skills that include a `SKILL.md` file under `skills/<name>/`.
- Skills that include a `SKILL.md` file anywhere under `skills/`; nested skills are flattened into the workspace skill directory.
- Per-skill config values from `skills.config`.
- OpenCode OpenAI OAuth credentials from OpenCode `auth.json` when interactive credential migration is accepted, or when `--include-secrets` is set. Hermes `auth.json` OAuth entries are legacy state reported for manual OpenAI reauth or doctor repair.
- Current Hermes OpenAI Codex OAuth credentials and OpenCode OpenAI OAuth credentials when interactive credential migration is accepted, or when `--include-secrets` is set. Do not keep Hermes and OpenClaw using the same imported refresh grant.
- Supported API keys and tokens from Hermes `.env` and OpenCode `auth.json` when interactive credential migration is accepted, or when `--include-secrets` is set.
### Supported `.env` keys
`AI_GATEWAY_API_KEY`, `ALIBABA_API_KEY`, `ANTHROPIC_API_KEY`, `ARCEEAI_API_KEY`, `CEREBRAS_API_KEY`, `CHUTES_API_KEY`, `CLOUDFLARE_AI_GATEWAY_API_KEY`, `COPILOT_GITHUB_TOKEN`, `DASHSCOPE_API_KEY`, `DEEPINFRA_API_KEY`, `DEEPSEEK_API_KEY`, `FIREWORKS_API_KEY`, `GEMINI_API_KEY`, `GH_TOKEN`, `GITHUB_TOKEN`, `GLM_API_KEY`, `GOOGLE_API_KEY`, `GROQ_API_KEY`, `HF_TOKEN`, `HUGGINGFACE_HUB_TOKEN`, `KILOCODE_API_KEY`, `KIMICODE_API_KEY`, `KIMI_API_KEY`, `MINIMAX_API_KEY`, `MINIMAX_CODING_API_KEY`, `MISTRAL_API_KEY`, `MODELSTUDIO_API_KEY`, `MOONSHOT_API_KEY`, `NVIDIA_API_KEY`, `OPENAI_API_KEY`, `OPENCODE_API_KEY`, `OPENCODE_GO_API_KEY`, `OPENCODE_ZEN_API_KEY`, `OPENROUTER_API_KEY`, `QIANFAN_API_KEY`, `QWEN_API_KEY`, `TOGETHER_API_KEY`, `VENICE_API_KEY`, `XAI_API_KEY`, `XIAOMI_API_KEY`, `ZAI_API_KEY`, `Z_AI_API_KEY`.
`AI_GATEWAY_API_KEY`, `ALIBABA_API_KEY`, `ANTHROPIC_API_KEY`, `ARCEEAI_API_KEY`, `CEREBRAS_API_KEY`, `CHUTES_API_KEY`, `CLOUDFLARE_AI_GATEWAY_API_KEY`, `COPILOT_GITHUB_TOKEN`, `DASHSCOPE_API_KEY`, `DEEPINFRA_API_KEY`, `DEEPSEEK_API_KEY`, `FIREWORKS_API_KEY`, `GEMINI_API_KEY`, `GH_TOKEN`, `GITHUB_TOKEN`, `GLM_API_KEY`, `GOOGLE_API_KEY`, `GROQ_API_KEY`, `HF_TOKEN`, `HUGGINGFACE_HUB_TOKEN`, `KILOCODE_API_KEY`, `KIMICODE_API_KEY`, `KIMI_API_KEY`, `KIMI_CODING_API_KEY`, `MINIMAX_API_KEY`, `MINIMAX_CODING_API_KEY`, `MISTRAL_API_KEY`, `MODELSTUDIO_API_KEY`, `MOONSHOT_API_KEY`, `NVIDIA_API_KEY`, `OPENAI_API_KEY`, `OPENCODE_API_KEY`, `OPENCODE_GO_API_KEY`, `OPENCODE_ZEN_API_KEY`, `OPENROUTER_API_KEY`, `QIANFAN_API_KEY`, `QWEN_API_KEY`, `TOGETHER_API_KEY`, `VENICE_API_KEY`, `XAI_API_KEY`, `XIAOMI_API_KEY`, `ZAI_API_KEY`, `Z_AI_API_KEY`.
### Archive-only state
Hermes state that OpenClaw cannot safely interpret is copied into the migration report for manual review, but it is not loaded into live OpenClaw config or credentials. This preserves opaque or unsafe state without pretending OpenClaw can execute or trust it automatically: `plugins/`, `sessions/`, `logs/`, `cron/`, `mcp-tokens/`, `state.db`.
Hermes state that OpenClaw cannot safely interpret is copied into the migration report for manual review, but it is not loaded into live OpenClaw config or credentials. This includes `plugins/`, `sessions/`, `logs/`, `cron/`, `mcp-tokens/`, `plans/`, `workspace/`, `skins/`, `kanban/`, pairing/platform state, gateway routing/process state, and the detected Hermes SQLite databases.
### After applying
+12 -10
View File
@@ -7,7 +7,7 @@ read_when:
title: "Migrating from Hermes"
---
The bundled Hermes migration provider detects state at `~/.hermes`, previews every change before applying, redacts secrets in plans and reports, and writes a verified OpenClaw backup before it touches anything.
The bundled Hermes migration provider follows `HERMES_HOME` and the active Hermes profile, falling back to `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows. It previews every change before applying, redacts secrets in plans and reports, and writes a verified OpenClaw backup before it touches anything. An explicit `--from` path always wins.
<Note>
Imports require a fresh OpenClaw setup. If you already have local OpenClaw state, reset config, credentials, sessions, and the workspace first, or use `openclaw migrate apply hermes` directly with `--overwrite` after reviewing the plan.
@@ -17,7 +17,7 @@ Imports require a fresh OpenClaw setup. If you already have local OpenClaw state
<Tabs>
<Tab title="Onboarding wizard">
Detects Hermes at `~/.hermes` and shows a preview before applying.
Detects the active Hermes home/profile and shows a preview before applying.
```bash
openclaw onboard --flow import
@@ -38,7 +38,7 @@ Imports require a fresh OpenClaw setup. If you already have local OpenClaw state
openclaw migrate apply hermes --yes # apply with confirmation skipped
```
Add `--from <path>` when Hermes lives outside `~/.hermes`.
Add `--from <path>` to override Hermes home/profile discovery.
</Tab>
</Tabs>
@@ -48,11 +48,11 @@ Imports require a fresh OpenClaw setup. If you already have local OpenClaw state
<AccordionGroup>
<Accordion title="Model configuration">
- Default model selection from Hermes `config.yaml`.
- Configured model providers and custom OpenAI-compatible endpoints from `providers` and `custom_providers`.
- Configured model providers and custom endpoints from `model`, `providers`, and `custom_providers`, including current Hermes Chat Completions, Codex Responses, and Anthropic Messages transports.
</Accordion>
<Accordion title="MCP servers">
MCP server definitions from `mcp_servers` or `mcp.servers`.
MCP server definitions from `mcp_servers` or `mcp.servers`, including disabled state, timeouts, parallel-tool support, OAuth scope, compatible TLS fields, and native/resource/prompt tool policy. Literal environment variables and headers require credential-import consent. Hermes-only lifecycle, sampling, elicitation, preflight, keepalive, CA-bundle, password-protected client-key, and pre-registered OAuth-client settings become manual-review items instead of invalid OpenClaw config.
</Accordion>
<Accordion title="Workspace files">
- `SOUL.md` and `AGENTS.md` are copied into the OpenClaw agent workspace.
@@ -63,10 +63,10 @@ Imports require a fresh OpenClaw setup. If you already have local OpenClaw state
Memory config defaults for OpenClaw file memory. External memory providers such as Honcho are recorded as archive or manual-review items so you can move them deliberately.
</Accordion>
<Accordion title="Skills">
Skills with a `SKILL.md` file under `skills/<name>/` are copied, along with per-skill config values from `skills.config`.
Skills with a `SKILL.md` file anywhere under `skills/` are discovered recursively, flattened into the OpenClaw workspace skill directory, and copied with their support files. Per-skill config values from `skills.config` are preserved.
</Accordion>
<Accordion title="Auth credentials">
Interactive `openclaw migrate` asks before importing auth credentials, with yes selected by default. Accepting imports OpenCode OpenAI OAuth and GitHub Copilot entries from OpenCode's `auth.json`, plus the [supported Hermes `.env` keys](/cli/migrate#supported-env-keys). Hermes's own `auth.json` OAuth entries are legacy state: they surface as a manual reauth/doctor item instead of importing into live auth. Use `--include-secrets` to import credentials in a non-interactive run, `--no-auth-credentials` to skip credential import entirely, or the onboarding wizard's `--import-secrets` flag.
Interactive `openclaw migrate` asks before importing auth credentials, with yes selected by default. Accepted imports include current Hermes OpenAI Codex OAuth entries, OpenCode OpenAI OAuth and GitHub Copilot entries, and the [supported Hermes `.env` keys](/cli/migrate#supported-env-keys). Use `--include-secrets` for non-interactive import, `--no-auth-credentials` to skip credentials, or onboarding's `--import-secrets` flag. After importing Hermes OAuth, do not keep Hermes and OpenClaw using the same refresh grant; reauthenticate one side before running both.
</Accordion>
</AccordionGroup>
@@ -79,7 +79,9 @@ The provider copies these into the migration report directory for manual review,
- `logs/`
- `cron/`
- `mcp-tokens/`
- `state.db`
- `plans/`, `workspace/`, `skins/`, and `kanban/`
- `pairing/` and `platforms/` stores, plus gateway routing/process state
- `state.db`, `hermes_state.db`, `projects.db`, `response_store.db`, `memory_store.db`, `verification_evidence.db`, `kanban.db`, and `retaindb_queue.db`
OpenClaw refuses to execute or trust this state automatically because formats and trust assumptions can drift between systems. Move what you need by hand after reviewing the archive.
@@ -131,13 +133,13 @@ Rerun with `--overwrite` only when replacing the existing target is intentional.
Conflicts are unusual on a fresh install. They typically show up when you re-run the import against a setup that already has user edits.
If a conflict surfaces mid-apply (for example, an unexpected race on a config file), Hermes marks remaining dependent config items as `skipped` with reason `blocked by earlier apply conflict` instead of writing them partially. The migration report records each blocked item so you can resolve the original conflict and rerun the import.
If a conflict surfaces mid-apply (for example, an unexpected race on a config file), that item is reported as a conflict while independent files, skills, credentials, archives, and config entries continue. Resolve the conflicted item and rerun the import; identical memory imports are idempotent.
## Secrets
Interactive `openclaw migrate` asks whether to import detected auth credentials, with yes selected by default.
- Accepting imports OpenCode OpenAI OAuth and GitHub Copilot entries from OpenCode's `auth.json`, plus the [supported `.env` keys](/cli/migrate#supported-env-keys). Hermes's own `auth.json` OAuth entries are reported for manual OpenAI reauth or doctor repair instead.
- Accepting imports current Hermes OpenAI Codex OAuth entries, OpenCode OpenAI OAuth and GitHub Copilot entries, and the [supported `.env` keys](/cli/migrate#supported-env-keys).
- Use `--no-auth-credentials`, or answer no at the prompt, to import non-secret state only.
- Use `--include-secrets` to import credentials in an unattended `--yes` run.
- Use the onboarding wizard's `--import-secrets` flag to import credentials from the wizard.
@@ -0,0 +1,59 @@
// Migrate Hermes tests cover apply result identity.
import path from "node:path";
import type { MigrationPlan } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it } from "vitest";
import { applyHermesPlan } from "./apply.js";
import { cleanupTempRoots, makeContext, makeTempRoot } from "./test/provider-helpers.js";
describe("Hermes migration apply identity", () => {
afterEach(async () => {
await cleanupTempRoots();
});
it("keeps results separate when report item ids repeat", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const stateDir = path.join(root, "state");
const workspaceDir = path.join(root, "workspace");
const plan: MigrationPlan = {
providerId: "hermes",
source,
items: [
{
id: "manual:repeated",
kind: "manual",
action: "manual",
status: "planned",
reason: "first follow-up",
},
{
id: "manual:repeated",
kind: "manual",
action: "manual",
status: "planned",
reason: "second follow-up",
},
],
summary: {
total: 2,
planned: 2,
migrated: 0,
skipped: 0,
conflicts: 0,
errors: 0,
sensitive: 0,
},
};
const result = await applyHermesPlan({
ctx: makeContext({ source, stateDir, workspaceDir }),
plan,
});
expect(result.items.map((item) => item.reason)).toEqual([
"first follow-up",
"second follow-up",
]);
expect(result.items.every((item) => item.status === "skipped")).toBe(true);
});
});
+73 -29
View File
@@ -2,8 +2,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import {
@@ -22,17 +22,20 @@ import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plug
import { applyAuthItem } from "./auth.js";
import { applyConfigItem, applyManualItem } from "./config.js";
import { appendItem } from "./helpers.js";
import {
findHermesModelProviderDependency,
HERMES_REASON_MODEL_PROVIDER_CONFLICT,
readHermesModelDetails,
} from "./items.js";
import { applyModelItem } from "./model.js";
import { buildHermesPlan } from "./plan.js";
import { applySecretItem } from "./secrets.js";
import { resolveTargets } from "./targets.js";
const HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict";
const HERMES_STATE_DB_ARCHIVE_ITEM_ID = "archive:state.db";
const HERMES_STATE_DB_SNAPSHOT_PREFIX = "openclaw-migrate-hermes-state-";
const HERMES_SQLITE_SNAPSHOT_PREFIX = "openclaw-migrate-hermes-sqlite-";
async function archiveHermesItem(item: MigrationItem, reportDir: string): Promise<MigrationItem> {
if (item.id !== HERMES_STATE_DB_ARCHIVE_ITEM_ID || !item.source) {
if (!item.source || path.extname(item.source) !== ".db") {
return await archiveMigrationItem(item, reportDir);
}
const sourcePath = item.source;
@@ -51,9 +54,9 @@ async function archiveHermesItem(item: MigrationItem, reportDir: string): Promis
// A raw state.db copy can omit committed rows that still live in state.db-wal.
// Snapshot the live database into one self-contained archive artifact.
return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: HERMES_STATE_DB_SNAPSHOT_PREFIX },
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: HERMES_SQLITE_SNAPSHOT_PREFIX },
async ({ dir: tempDir }) => {
const snapshotPath = path.join(tempDir, "state.db");
const snapshotPath = path.join(tempDir, path.basename(sourcePath));
const { DatabaseSync } = await import("node:sqlite");
const source = new DatabaseSync(sourcePath, { readOnly: true });
try {
@@ -69,16 +72,48 @@ async function archiveHermesItem(item: MigrationItem, reportDir: string): Promis
);
} catch (err) {
const snapshotReason = err instanceof Error ? err.message : String(err);
const rawArchive = await archiveMigrationItem(item, reportDir);
if (rawArchive.status === "migrated") {
let recoveryArchive: MigrationItem;
try {
recoveryArchive = await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: HERMES_SQLITE_SNAPSHOT_PREFIX },
async ({ dir: tempDir }) => {
const recoveryDir = path.join(tempDir, `${path.basename(sourcePath)}-recovery`);
await fs.mkdir(recoveryDir, { recursive: true });
for (const candidate of [sourcePath, `${sourcePath}-wal`, `${sourcePath}-shm`]) {
const stat = await fs.lstat(candidate).catch(() => undefined);
if (!stat?.isFile()) {
continue;
}
try {
await fs.copyFile(candidate, path.join(recoveryDir, path.basename(candidate)));
} catch (copyError) {
const copyCode = (copyError as NodeJS.ErrnoException).code;
if (candidate !== sourcePath && copyCode === "ENOENT") {
continue;
}
throw copyError;
}
}
return await archiveMigrationItem({ ...item, source: recoveryDir }, reportDir);
},
);
} catch (recoveryError) {
const recoveryReason =
recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
return markMigrationItemError(
rawArchive,
`SQLite snapshot failed; raw state.db preserved for manual review: ${snapshotReason}`,
item,
`SQLite snapshot failed: ${snapshotReason}; recovery archive failed: ${recoveryReason}`,
);
}
if (recoveryArchive.status === "migrated") {
return markMigrationItemError(
{ ...recoveryArchive, source: sourcePath },
`SQLite snapshot failed; database recovery files preserved for manual review: ${snapshotReason}`,
);
}
return markMigrationItemError(
rawArchive,
`SQLite snapshot failed: ${snapshotReason}; raw archive failed: ${rawArchive.reason ?? rawArchive.status}`,
{ ...recoveryArchive, source: sourcePath },
`SQLite snapshot failed: ${snapshotReason}; recovery archive failed: ${recoveryArchive.reason ?? recoveryArchive.status}`,
);
}
}
@@ -91,25 +126,39 @@ export async function applyHermesPlan(params: {
const plan = params.plan ?? (await buildHermesPlan(params.ctx));
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "hermes");
const targets = resolveTargets(params.ctx);
const items: MigrationItem[] = [];
// Item ids are report labels, not unique execution keys. Preserve object identity so
// providers can report repeated source items without cross-wiring their results.
const appliedByItem = new Map<MigrationItem, MigrationItem>();
const runtime = withCachedMigrationConfigRuntime(
params.ctx.runtime ?? params.runtime,
params.ctx.config,
);
const applyCtx = { ...params.ctx, runtime };
let blockedByApplyConflict = false;
for (const item of plan.items) {
const executionItems = [
...plan.items.filter((item) => item.id.startsWith("config:model-provider:")),
...plan.items.filter((item) => !item.id.startsWith("config:model-provider:")),
];
for (const item of executionItems) {
if (item.status !== "planned") {
items.push(item);
continue;
}
if (blockedByApplyConflict) {
items.push(markMigrationItemSkipped(item, HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT));
appliedByItem.set(item, item);
continue;
}
let appliedItem: MigrationItem;
if (item.id === "config:default-model") {
appliedItem = await applyModelItem(applyCtx, item);
const model = readHermesModelDetails(item)?.model;
const dependency = model ? findHermesModelProviderDependency(plan.items, model) : undefined;
const dependencyResult = dependency ? appliedByItem.get(dependency) : undefined;
if (dependencyResult && dependencyResult.status !== "migrated") {
appliedItem =
dependencyResult.status === "conflict"
? markMigrationItemConflict(item, HERMES_REASON_MODEL_PROVIDER_CONFLICT)
: markMigrationItemError(
item,
`model provider config failed: ${dependencyResult.reason ?? dependencyResult.status}`,
);
} else {
appliedItem = await applyModelItem(applyCtx, item);
}
} else if (item.kind === "config") {
appliedItem = await applyConfigItem(applyCtx, item);
} else if (item.kind === "manual") {
@@ -127,14 +176,9 @@ export async function applyHermesPlan(params: {
overwrite: params.ctx.overwrite,
});
}
items.push(appliedItem);
if (
item.kind === "config" &&
(appliedItem.status === "conflict" || appliedItem.status === "error")
) {
blockedByApplyConflict = true;
}
appliedByItem.set(item, appliedItem);
}
const items = plan.items.map((item) => appliedByItem.get(item) ?? item);
const result: MigrationApplyResult = {
...plan,
items,
+176
View File
@@ -0,0 +1,176 @@
// Hermes-native auth discovery and reauthentication planning.
import { createMigrationManualItem } from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord, readString, readText } from "./helpers.js";
import type { HermesSource } from "./source.js";
const HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID = "openai-codex";
export type HermesCodexAuthCandidate = {
access: string;
accountId?: string;
refresh: string;
sourceKind: "hermes-auth-json" | "opencode-auth-json";
sourceSlot: "provider" | "pool" | "opencode";
sourceCredentialIndex?: number;
sourceLabel: string;
sourcePath: string;
updatedAt?: number;
};
const HERMES_REAUTH_PROVIDER_MAPPINGS = [
{ sourceProvider: "anthropic", targetProvider: "anthropic" },
{ sourceProvider: "nous", targetProvider: "nous" },
{ sourceProvider: "qwen-oauth", targetProvider: "qwen-oauth" },
{ sourceProvider: "minimax-oauth", targetProvider: "minimax-portal" },
{ sourceProvider: "xai-oauth", targetProvider: "xai" },
] as const;
const HERMES_REAUTH_SOURCE_PROVIDERS = new Set<string>(
HERMES_REAUTH_PROVIDER_MAPPINGS.map((entry) => entry.sourceProvider),
);
function readTimestamp(value: unknown): number | undefined {
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function readHermesProviderCandidate(
auth: Record<string, unknown>,
sourcePath: string,
): HermesCodexAuthCandidate | undefined {
const providers = isRecord(auth.providers) ? auth.providers : {};
const provider = isRecord(providers[HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID])
? providers[HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID]
: undefined;
const tokens = isRecord(provider?.tokens) ? provider.tokens : undefined;
const access = readString(tokens?.access_token);
const refresh = readString(tokens?.refresh_token);
if (!access || !refresh) {
return undefined;
}
return {
access,
refresh,
sourceKind: "hermes-auth-json",
sourceSlot: "provider",
sourceLabel: "Hermes active OpenAI Codex provider",
sourcePath,
updatedAt: readTimestamp(provider?.last_refresh),
};
}
function readHermesPoolCandidates(
auth: Record<string, unknown>,
sourcePath: string,
): HermesCodexAuthCandidate[] {
const pool = isRecord(auth.credential_pool) ? auth.credential_pool : {};
const entries = Array.isArray(pool[HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID])
? pool[HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID]
: [];
return entries.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const access = readString(entry.access_token);
const refresh = readString(entry.refresh_token);
if (!access || !refresh) {
return [];
}
return [
{
access,
refresh,
sourceKind: "hermes-auth-json" as const,
sourceSlot: "pool" as const,
sourceLabel: readString(entry.label) ?? "Hermes OpenAI Codex credential pool",
sourcePath,
updatedAt: readTimestamp(entry.last_refresh) ?? readTimestamp(entry.last_status_at),
},
];
});
}
export async function readHermesCodexAuthCandidates(
authPath: string | undefined,
): Promise<HermesCodexAuthCandidate[]> {
const raw = await readText(authPath);
if (!raw || !authPath) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!isRecord(parsed)) {
return [];
}
const candidates = [
readHermesProviderCandidate(parsed, authPath),
...readHermesPoolCandidates(parsed, authPath),
]
.filter((candidate): candidate is HermesCodexAuthCandidate => candidate !== undefined)
.toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0));
candidates.forEach((candidate, index) => {
candidate.sourceCredentialIndex = index;
});
return candidates;
}
async function readHermesOAuthProviderIds(authPath: string | undefined): Promise<Set<string>> {
const raw = await readText(authPath);
if (!raw) {
return new Set();
}
try {
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) {
return new Set();
}
const providers = isRecord(parsed.providers)
? Object.keys(parsed.providers).filter((provider) =>
HERMES_REAUTH_SOURCE_PROVIDERS.has(provider),
)
: [];
const pool = isRecord(parsed.credential_pool)
? Object.entries(parsed.credential_pool).flatMap(([provider, entries]) =>
Array.isArray(entries) &&
entries.some(
(entry) => isRecord(entry) && readString(entry.auth_type)?.toLowerCase() === "oauth",
)
? [provider]
: [],
)
: [];
return new Set([...providers, ...pool]);
} catch {
return new Set();
}
}
export async function buildReauthenticationItems(source: HermesSource): Promise<MigrationItem[]> {
const profileProviders = await readHermesOAuthProviderIds(source.authPath);
const globalProviders = await readHermesOAuthProviderIds(source.globalAuthPath);
return HERMES_REAUTH_PROVIDER_MAPPINGS.flatMap(({ sourceProvider, targetProvider }) => {
const sourcePath = profileProviders.has(sourceProvider)
? source.authPath
: globalProviders.has(sourceProvider)
? source.globalAuthPath
: undefined;
if (!sourcePath) {
return [];
}
return [
createMigrationManualItem({
id: `manual:auth-reauthenticate:${targetProvider}`,
source: sourcePath,
message: `Hermes ${sourceProvider} credentials cannot be reused safely by OpenClaw.`,
recommendation: `Authenticate ${targetProvider} in OpenClaw after migration.`,
}),
];
});
}
+29 -67
View File
@@ -6,7 +6,6 @@ import {
} from "openclaw/plugin-sdk/agent-runtime";
import {
createMigrationItem,
createMigrationManualItem,
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
@@ -34,6 +33,11 @@ import {
hasCurrentAuthProfileConfigConflict,
type HermesAuthProfileConfig,
} from "./auth-config.js";
import {
buildReauthenticationItems,
readHermesCodexAuthCandidates,
type HermesCodexAuthCandidate,
} from "./auth-source.js";
import { isRecord, readString, readText } from "./helpers.js";
import {
HERMES_REASON_AUTH_PROFILE_EXISTS,
@@ -55,17 +59,6 @@ type AgentDefaultModelConfigs = NonNullable<
>;
type AgentDefaultModelConfigEntry = AgentDefaultModelConfigs[string];
type HermesCodexAuthCandidate = {
access: string;
accountId?: string;
refresh: string;
sourceKind: "opencode-auth-json";
sourceCredentialIndex?: number;
sourceLabel: string;
sourcePath: string;
updatedAt?: number;
};
type HermesCodexAuthProfile = {
candidate: HermesCodexAuthCandidate;
credential: OAuthCredential;
@@ -120,6 +113,7 @@ async function readOpenCodeOpenAICandidates(
...(accountId ? { accountId } : {}),
refresh,
sourceKind: "opencode-auth-json",
sourceSlot: "opencode",
sourceCredentialIndex: 0,
sourceLabel: "OpenCode OpenAI OAuth credential",
sourcePath: authPath,
@@ -127,45 +121,6 @@ async function readOpenCodeOpenAICandidates(
];
}
async function hasLegacyHermesAuthJson(authPath: string | undefined): Promise<boolean> {
const raw = await readText(authPath);
if (!raw) {
return false;
}
try {
const parsed: unknown = JSON.parse(raw);
return (
isRecord(parsed) &&
(hasLegacyOpenAIOAuthTokenFields(parsed.providers, "providers") ||
hasLegacyOpenAIOAuthTokenFields(parsed.credential_pool, "credential_pool") ||
hasLegacyOpenAIOAuthTokenFields(parsed.tokens, "tokens"))
);
} catch {
return false;
}
}
function hasLegacyOpenAIOAuthTokenFields(value: unknown, keyHint = ""): boolean {
if (Array.isArray(value)) {
return value.some((entry) => hasLegacyOpenAIOAuthTokenFields(entry, keyHint));
}
if (!isRecord(value)) {
return false;
}
const provider = readString(value.provider)?.toLowerCase();
const normalizedKeyHint = keyHint.toLowerCase();
const isOpenAIRecord = normalizedKeyHint.includes("openai") || provider === OPENAI_PROVIDER_ID;
const hasTokenPair =
(readString(value.access) && readString(value.refresh)) ||
(readString(value.access_token) && readString(value.refresh_token));
if (isOpenAIRecord && hasTokenPair) {
return true;
}
return Object.entries(value).some(([key, entry]) =>
hasLegacyOpenAIOAuthTokenFields(entry, keyHint ? `${keyHint}.${key}` : key),
);
}
function buildAuthResult(
candidate: HermesCodexAuthCandidate,
fallbackProfileName = "hermes-import",
@@ -245,9 +200,23 @@ function authProfileDedupeKey(profile: HermesCodexAuthProfile): string {
async function readCodexAuthProfilesFromSource(
source: HermesSource,
): Promise<HermesCodexAuthProfile[]> {
const candidates = (await readOpenCodeOpenAICandidates(source.opencodeAuthPath)).toSorted(
(left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0),
const profileHermesCandidates = await readHermesCodexAuthCandidates(source.authPath);
const globalHermesCandidates = await readHermesCodexAuthCandidates(source.globalAuthPath);
const profileProvider = profileHermesCandidates.find(
(candidate) => candidate.sourceSlot === "provider",
);
const profilePool = profileHermesCandidates.filter(
(candidate) => candidate.sourceSlot === "pool",
);
const globalProvider = globalHermesCandidates.find(
(candidate) => candidate.sourceSlot === "provider",
);
const globalPool = globalHermesCandidates.filter((candidate) => candidate.sourceSlot === "pool");
const candidates = [
...(profileProvider ? [profileProvider] : globalProvider ? [globalProvider] : []),
...(profilePool.length > 0 ? profilePool : globalPool),
...(await readOpenCodeOpenAICandidates(source.opencodeAuthPath)),
].toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0));
const profiles: HermesCodexAuthProfile[] = [];
const seen = new Set<string>();
for (const [index, candidate] of candidates.entries()) {
@@ -285,7 +254,11 @@ async function readCodexAuthProfilesFromPath(params: {
...(params.sourcePath ? { opencodeAuthPath: params.sourcePath } : {}),
});
}
return [];
return await readCodexAuthProfilesFromSource({
root: "",
archivePaths: [],
...(params.sourcePath ? { authPath: params.sourcePath } : {}),
});
}
function findMatchingProfile(
@@ -361,18 +334,7 @@ export async function buildAuthItems(params: {
targets: PlannedTargets;
}): Promise<MigrationItem[]> {
const items: MigrationItem[] = [];
if (await hasLegacyHermesAuthJson(params.source.authPath)) {
items.push(
createMigrationManualItem({
id: "manual:legacy-hermes-auth-json",
source: params.source.authPath ?? "auth.json",
message:
"Hermes auth.json contains legacy OAuth credentials. OpenClaw no longer imports those into live auth during Hermes migration.",
recommendation:
"Run openclaw models auth login --provider openai after migration, or run openclaw doctor --fix for existing OpenClaw legacy auth state.",
}),
);
}
items.push(...(await buildReauthenticationItems(params.source)));
const profiles = await readCodexAuthProfilesFromSource(params.source);
if (profiles.length === 0) {
return items;
@@ -410,7 +372,7 @@ export async function buildAuthItems(params: {
? HERMES_REASON_AUTH_PROFILE_EXISTS
: undefined,
message: skipped
? "OpenAI OAuth credentials detected in OpenCode."
? `OpenAI OAuth credentials detected in ${profile.candidate.sourceKind === "hermes-auth-json" ? "Hermes" : "OpenCode"}.`
: "Import OpenAI OAuth credentials and configure OpenAI models.",
details: {
provider: OPENAI_PROVIDER_ID,
+54
View File
@@ -0,0 +1,54 @@
// Hermes environment interpolation shared by provider and MCP config.
import { isRecord } from "./helpers.js";
export const MCP_ENV_REFERENCE_RE = /\$\{([^}]+)\}/gu;
function normalizeHermesEnvReferenceName(value: string): string | undefined {
const trimmed = value.trim();
const name = trimmed.startsWith("env:") ? trimmed.slice("env:".length).trim() : trimmed;
return name || undefined;
}
export function resolveMcpEnvReferences(
value: unknown,
env: Record<string, string>,
): { unresolved: boolean; value: unknown } {
if (typeof value === "string") {
let unresolved = false;
const resolved = value.replace(MCP_ENV_REFERENCE_RE, (match, rawName: string) => {
const name = normalizeHermesEnvReferenceName(rawName);
if (!name) {
unresolved = true;
return match;
}
const replacement = env[name];
if (replacement === undefined) {
unresolved = true;
return match;
}
return replacement;
});
return { unresolved, value: resolved };
}
if (Array.isArray(value)) {
const entries = value.map((entry) => resolveMcpEnvReferences(entry, env));
return {
unresolved: entries.some((entry) => entry.unresolved),
value: entries.map((entry) => entry.value),
};
}
if (isRecord(value)) {
const entries = Object.entries(value).map(
([key, entry]) => [key, resolveMcpEnvReferences(entry, env)] as const,
);
return {
unresolved: entries.some(([, entry]) => entry.unresolved),
value: Object.fromEntries(entries.map(([key, entry]) => [key, entry.value])),
};
}
return { unresolved: false, value };
}
export function mcpValueHasEnvReferences(value: unknown): boolean {
return value !== undefined && resolveMcpEnvReferences(value, {}).unresolved;
}
+363
View File
@@ -0,0 +1,363 @@
// Hermes MCP config mapping and manual follow-up planning.
import { createMigrationManualItem } from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { mcpValueHasEnvReferences, resolveMcpEnvReferences } from "./config-env.js";
import { readPositiveNumber } from "./config-provider-contract.js";
import { isRecord, readString, sanitizeName } from "./helpers.js";
const MCP_RESOURCE_UTILITY_TOOLS = ["resources_list", "resources_read"] as const;
const MCP_PROMPT_UTILITY_TOOLS = ["prompts_list", "prompts_get"] as const;
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function readBooleanish(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) {
return true;
}
return ["false", "0", "no", "off"].includes(normalized) ? false : undefined;
}
function readPositiveNumeric(value: unknown): number | undefined {
if (typeof value === "number") {
return readPositiveNumber(value);
}
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
return readPositiveNumber(Number(value));
}
function readToolFilterList(value: unknown): string[] | undefined {
if (typeof value === "string") {
return value.trim() ? [value.trim()] : undefined;
}
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
return undefined;
}
const normalized = [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
return normalized;
}
function mapHermesToolFilter(value: Record<string, unknown>): Record<string, unknown> | undefined {
const direct = isRecord(value.toolFilter)
? value.toolFilter
: isRecord(value.tool_filter)
? value.tool_filter
: undefined;
if (direct) {
const include = readToolFilterList(direct.include);
const exclude = readToolFilterList(direct.exclude);
if (include && include.length > 0) {
return { include };
}
return exclude !== undefined && exclude.length > 0 ? { exclude } : undefined;
}
const tools = isRecord(value.tools) ? value.tools : undefined;
if (!tools) {
return undefined;
}
const include = readToolFilterList(tools.include);
const exclude = readToolFilterList(tools.exclude);
const resourcesEnabled = readBooleanish(tools.resources) !== false;
const promptsEnabled = readBooleanish(tools.prompts) !== false;
// Hermes tests set truthiness here: `include: []` means no whitelist, so native tools remain.
if (include && include.length > 0) {
return {
include: [
...include,
...(resourcesEnabled ? MCP_RESOURCE_UTILITY_TOOLS : []),
...(promptsEnabled ? MCP_PROMPT_UTILITY_TOOLS : []),
],
};
}
const translatedExclude = [
...(exclude ?? []),
...(!resourcesEnabled ? MCP_RESOURCE_UTILITY_TOOLS : []),
...(!promptsEnabled ? MCP_PROMPT_UTILITY_TOOLS : []),
];
return translatedExclude.length > 0 ? { exclude: translatedExclude } : undefined;
}
function mapHermesClientCertificate(value: Record<string, unknown>): {
clientCert?: string;
clientKey?: string;
} {
const cert = value.clientCert ?? value.client_cert;
const key = readString(value.clientKey) ?? readString(value.client_key);
if (Array.isArray(cert) && cert.length === 2) {
const certPath = readString(cert[0]);
const keyPath = readString(cert[1]);
return certPath && keyPath ? { clientCert: certPath, clientKey: keyPath } : {};
}
const certPath = readString(cert);
return certPath && key ? { clientCert: certPath, clientKey: key } : {};
}
const MCP_CONNECTION_FIELDS = [
"enabled",
"command",
"args",
"cwd",
"workingDirectory",
"url",
"connectionTimeoutMs",
"requestTimeoutMs",
"timeout",
] as const;
export function importsMcpSensitiveValues(
value: Record<string, unknown>,
includeSecrets: boolean,
): boolean {
return (
includeSecrets &&
(value.env !== undefined ||
value.headers !== undefined ||
MCP_CONNECTION_FIELDS.some((key) => mcpValueHasEnvReferences(value[key])))
);
}
function mapHermesMcpOauth(value: Record<string, unknown>): Record<string, unknown> | undefined {
const oauth = isRecord(value.oauth) ? value.oauth : undefined;
if (!oauth) {
return undefined;
}
const mapped: Record<string, unknown> = {};
for (const key of ["authProfileId", "scope", "redirectUrl", "clientMetadataUrl"]) {
const fieldValue = readString(oauth[key]);
if (fieldValue) {
mapped[key] = fieldValue;
}
}
return Object.keys(mapped).length > 0 ? mapped : undefined;
}
export function mapMcpServer(
value: Record<string, unknown>,
includeSecrets: boolean,
env: Record<string, string>,
): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const key of MCP_CONNECTION_FIELDS) {
const sourceValue = value[key];
if (sourceValue === undefined) {
continue;
}
if (!mcpValueHasEnvReferences(sourceValue)) {
next[key] = sourceValue;
continue;
}
if (includeSecrets) {
const resolved = resolveMcpEnvReferences(sourceValue, env);
if (!resolved.unresolved) {
next[key] = resolved.value;
}
}
}
const transport = readString(value.transport) ?? readString(value.type);
if (transport === "http" || transport === "streamable-http") {
next.transport = "streamable-http";
} else if (transport === "sse" || transport === "stdio") {
next.transport = transport;
} else if (!transport && readString(next.url)) {
next.transport = "streamable-http";
}
next.connectTimeout = value.connectTimeout ?? value.connect_timeout;
next.supportsParallelToolCalls = readBoolean(
value.supportsParallelToolCalls ?? value.supports_parallel_tool_calls,
);
next.sslVerify = readBoolean(value.sslVerify ?? value.ssl_verify);
next.auth = readString(value.auth) === "oauth" ? "oauth" : undefined;
next.oauth = mapHermesMcpOauth(value);
Object.assign(next, mapHermesClientCertificate(value));
const toolFilter = mapHermesToolFilter(value);
next.toolFilter = toolFilter;
if (includeSecrets) {
for (const key of ["env", "headers"]) {
if (value[key] !== undefined) {
const resolved = resolveMcpEnvReferences(value[key], env);
if (!resolved.unresolved) {
next[key] = resolved.value;
}
}
}
}
const mapped = Object.fromEntries(
Object.entries(next).filter(([, entry]) => entry !== undefined),
);
return readString(mapped.command) || readString(mapped.url) ? mapped : {};
}
export function mcpManualItems(params: {
name: string;
raw: Record<string, unknown>;
includeSecrets: boolean;
env: Record<string, string>;
source: string;
}): MigrationItem[] {
const { name, raw } = params;
const safeName = sanitizeName(name);
const items: MigrationItem[] = [];
const add = (suffix: string, message: string, recommendation: string): void => {
items.push(
createMigrationManualItem({
id: `manual:mcp-server-${suffix}:${safeName}`,
source: params.source,
message,
recommendation,
}),
);
};
const interpolatedValues = [
...MCP_CONNECTION_FIELDS.map((key) => raw[key]),
raw.env,
raw.headers,
];
if (
!params.includeSecrets &&
(raw.env !== undefined ||
raw.headers !== undefined ||
interpolatedValues.some(mcpValueHasEnvReferences))
) {
add(
"secrets",
`Hermes MCP server "${name}" has environment-backed values that were not imported without secret consent.`,
"Re-run with --include-secrets or configure these values manually.",
);
}
if (
params.includeSecrets &&
interpolatedValues.some(
(value) => value !== undefined && resolveMcpEnvReferences(value, params.env).unresolved,
)
) {
add(
"unresolved-secrets",
`Hermes MCP server "${name}" references environment values that were not found in its .env file.`,
"Define the missing values in OpenClaw's MCP server environment or headers manually.",
);
}
const cert = raw.clientCert ?? raw.client_cert;
const key = readString(raw.clientKey) ?? readString(raw.client_key);
if (Array.isArray(cert) && cert.length === 3) {
add(
"client-cert-password",
`Hermes MCP server "${name}" uses a password-protected client key, which OpenClaw cannot represent in MCP config.`,
"Configure an unencrypted protected key path or an equivalent TLS proxy manually.",
);
} else if (
(cert !== undefined || key !== undefined) &&
!(
(Array.isArray(cert) && cert.length === 2 && readString(cert[0]) && readString(cert[1])) ||
(readString(cert) && key)
)
) {
add(
"client-cert",
`Hermes MCP server "${name}" uses a combined or invalid client-certificate shape that was not imported.`,
"Configure separate OpenClaw clientCert and clientKey file paths manually.",
);
}
if (typeof (raw.sslVerify ?? raw.ssl_verify) === "string") {
add(
"tls-ca",
`Hermes MCP server "${name}" uses a CA bundle path for TLS verification, which OpenClaw MCP config cannot represent.`,
"Install the CA in the host trust store or configure an equivalent TLS proxy manually.",
);
}
const transport = readString(raw.transport) ?? readString(raw.type);
if (transport && !["http", "streamable-http", "sse", "stdio"].includes(transport)) {
add(
"transport",
`Hermes MCP server "${name}" uses unsupported transport "${transport}".`,
"Configure an equivalent OpenClaw MCP transport manually.",
);
}
const auth = readString(raw.auth);
if (auth && auth !== "oauth") {
add(
"auth",
`Hermes MCP server "${name}" uses unsupported authentication mode "${auth}".`,
"Configure an equivalent OpenClaw MCP authentication mode manually.",
);
}
const oauth = isRecord(raw.oauth) ? raw.oauth : undefined;
if (auth === "oauth" || oauth) {
add(
"oauth-login",
`Hermes MCP server "${name}" requires OAuth login in OpenClaw.`,
`Run "openclaw mcp login ${name}" after migration.`,
);
}
if (
oauth &&
Object.keys(oauth).some(
(keyName) =>
!["authProfileId", "scope", "redirectUrl", "clientMetadataUrl"].includes(keyName),
)
) {
add(
"oauth-client",
`Hermes MCP server "${name}" uses pre-registered OAuth client settings that were not copied into OpenClaw config.`,
`Run "openclaw mcp login ${name}" and configure supported OAuth metadata manually.`,
);
}
const tools = isRecord(raw.tools) ? raw.tools : undefined;
if (
tools &&
(Object.keys(tools).some(
(keyName) => !["include", "exclude", "resources", "prompts"].includes(keyName),
) ||
(tools.include !== undefined && !readToolFilterList(tools.include)) ||
(tools.exclude !== undefined && !readToolFilterList(tools.exclude)) ||
(tools.resources !== undefined && readBooleanish(tools.resources) === undefined) ||
(tools.prompts !== undefined && readBooleanish(tools.prompts) === undefined))
) {
add(
"tool-policy",
`Hermes MCP server "${name}" has a tool policy that cannot be translated exactly.`,
"Review and configure mcp.servers toolFilter manually.",
);
}
const lifecycle = isRecord(raw.lifecycle) ? raw.lifecycle : {};
const unsupported = [
["preflight", raw.skip_preflight === true],
["sampling", isRecord(raw.sampling) && raw.sampling.enabled !== false],
["elicitation", isRecord(raw.elicitation) && raw.elicitation.enabled !== false],
[
"lifecycle",
readPositiveNumeric(raw.idle_timeout_seconds ?? lifecycle.idle_timeout_seconds) !==
undefined ||
readPositiveNumeric(raw.max_lifetime_seconds ?? lifecycle.max_lifetime_seconds) !==
undefined,
],
["keepalive", readPositiveNumeric(raw.keepalive_interval) !== undefined],
] as const;
for (const [feature, configured] of unsupported) {
if (configured) {
add(
feature,
`Hermes MCP server "${name}" uses ${feature} behavior that OpenClaw MCP config does not expose.`,
"Review the server requirement and configure an equivalent deployment or runtime policy manually.",
);
}
}
return [...new Map(items.map((item) => [item.id, item])).values()];
}
@@ -0,0 +1,436 @@
import {
MCP_ENV_REFERENCE_RE,
mcpValueHasEnvReferences,
resolveMcpEnvReferences,
} from "./config-env.js";
// Hermes provider config contract parsing and normalization.
import { childRecord, isRecord, readString, readStringArray } from "./helpers.js";
import { normalizeHermesCustomProviderId, normalizeHermesProviderId } from "./model.js";
type OpenClawModelApi =
| "anthropic-messages"
| "openai-completions"
| "openai-responses"
| "openai-chatgpt-responses";
type HermesModelConfig = {
id: string;
contextWindow?: number;
maxTokens?: number;
supportsVision?: boolean;
};
export type HermesProviderConfig = {
id: string;
baseUrl: string;
api: OpenClawModelApi;
apiKeyEnv?: string;
headers?: Record<string, unknown>;
models: HermesModelConfig[];
sensitive?: boolean;
};
export const HERMES_TRANSPORTS: Record<string, OpenClawModelApi> = {
anthropic_messages: "anthropic-messages",
chat_completions: "openai-completions",
codex_responses: "openai-responses",
openai_chat: "openai-completions",
};
const HERMES_MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1";
const HERMES_MINIMAX_CN_BASE_URL = "https://api.minimaxi.com/anthropic";
const HERMES_ALIBABA_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
const HERMES_SPECIAL_BASE_URL_ENV_VARS: Record<string, readonly string[]> = {
// Hermes plain `custom` still reads OPENAI_BASE_URL in its runtime provider resolver.
custom: ["CUSTOM_BASE_URL", "OPENAI_BASE_URL"],
"openai-api": ["OPENAI_BASE_URL"],
"xai-oauth": ["HERMES_XAI_BASE_URL", "XAI_BASE_URL"],
"qwen-oauth": ["HERMES_QWEN_BASE_URL"],
"minimax-cn": ["MINIMAX_CN_BASE_URL"],
"alibaba-coding-plan": ["ALIBABA_CODING_PLAN_BASE_URL"],
};
const HERMES_BASE_URL_ENV_VARS: Record<string, readonly string[]> = {
anthropic: ["ANTHROPIC_BASE_URL"],
arcee: ["ARCEE_BASE_URL"],
"azure-foundry": ["AZURE_FOUNDRY_BASE_URL"],
deepseek: ["DEEPSEEK_BASE_URL"],
gmi: ["GMI_BASE_URL"],
google: ["GEMINI_BASE_URL"],
huggingface: ["HF_BASE_URL"],
kilocode: ["KILOCODE_BASE_URL"],
kimi: ["KIMI_BASE_URL"],
lmstudio: ["LM_BASE_URL"],
minimax: ["MINIMAX_BASE_URL"],
novita: ["NOVITA_BASE_URL"],
nvidia: ["NVIDIA_BASE_URL"],
"ollama-cloud": ["OLLAMA_BASE_URL"],
opencode: ["OPENCODE_ZEN_BASE_URL"],
"opencode-go": ["OPENCODE_GO_BASE_URL"],
openrouter: ["OPENROUTER_BASE_URL"],
qwen: ["DASHSCOPE_BASE_URL"],
stepfun: ["STEPFUN_BASE_URL"],
"tencent-tokenhub": ["TOKENHUB_BASE_URL"],
xai: ["XAI_BASE_URL"],
xiaomi: ["XIAOMI_BASE_URL"],
zai: ["GLM_BASE_URL"],
};
const HERMES_SPECIAL_API_KEY_ENV_VARS: Record<string, string> = {
custom: "OPENAI_API_KEY",
"openai-api": "OPENAI_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
"alibaba-coding-plan": "ALIBABA_CODING_PLAN_API_KEY",
};
const HERMES_API_KEY_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
arcee: "ARCEEAI_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
google: "GOOGLE_API_KEY",
huggingface: "HF_TOKEN",
kilocode: "KILOCODE_API_KEY",
kimi: "KIMI_API_KEY",
lmstudio: "LM_API_KEY",
minimax: "MINIMAX_API_KEY",
nvidia: "NVIDIA_API_KEY",
opencode: "OPENCODE_ZEN_API_KEY",
"opencode-go": "OPENCODE_GO_API_KEY",
openrouter: "OPENROUTER_API_KEY",
qwen: "DASHSCOPE_API_KEY",
stepfun: "STEPFUN_API_KEY",
xai: "XAI_API_KEY",
xiaomi: "XIAOMI_API_KEY",
zai: "ZAI_API_KEY",
};
function resolveHermesProviderEnvValue(
providerId: string | undefined,
env: Record<string, string>,
special: Record<string, readonly string[]>,
canonical: Record<string, readonly string[]>,
): string | undefined {
if (!providerId) {
return undefined;
}
const sourceProvider = normalizeHermesCustomProviderId(providerId);
const provider = normalizeHermesProviderId(sourceProvider);
const names = special[sourceProvider] ?? canonical[provider] ?? [];
for (const name of names) {
const value = env[name]?.trim();
if (value) {
return value;
}
}
return undefined;
}
export function resolveHermesProviderBaseUrlEnv(
providerId: string | undefined,
env: Record<string, string>,
): string | undefined {
return resolveHermesProviderEnvValue(
providerId,
env,
HERMES_SPECIAL_BASE_URL_ENV_VARS,
HERMES_BASE_URL_ENV_VARS,
);
}
export function resolveHermesProviderApiKeyEnv(providerId: string | undefined): string | undefined {
if (!providerId) {
return undefined;
}
const sourceProvider = normalizeHermesCustomProviderId(providerId);
const provider = normalizeHermesProviderId(sourceProvider);
return HERMES_SPECIAL_API_KEY_ENV_VARS[sourceProvider] ?? HERMES_API_KEY_ENV_VARS[provider];
}
export function resolveHermesImplicitBaseUrl(providerId: string | undefined): string | undefined {
const provider = providerId?.trim().toLowerCase();
if (provider && ["alibaba", "alibaba-cloud", "aliyun", "dashscope"].includes(provider)) {
return HERMES_ALIBABA_BASE_URL;
}
// OpenClaw's qwen default is already Hermes' coding-plan endpoint; no override needed.
if (provider && ["kimi-coding-cn", "kimi-cn", "moonshot-cn"].includes(provider)) {
return HERMES_MOONSHOT_CN_BASE_URL;
}
return provider && ["minimax-cn", "minimax-china", "minimax_cn"].includes(provider)
? HERMES_MINIMAX_CN_BASE_URL
: undefined;
}
export function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
export function resolveProviderApi(
raw: Record<string, unknown>,
providerId?: string,
): OpenClawModelApi | undefined {
const transport = readString(raw.transport) ?? readString(raw.api_mode);
const sourceProvider = providerId?.trim().toLowerCase() ?? "";
if (sourceProvider === "openai-codex") {
return "openai-chatgpt-responses";
}
if (transport && transport !== "codex_responses") {
return HERMES_TRANSPORTS[transport];
}
const provider = sourceProvider ? normalizeHermesProviderId(sourceProvider) : "";
const baseUrl =
readString(raw.base_url) ??
readString(raw.baseUrl) ??
readString(raw.url) ??
readString(raw.api);
let hostname = "";
let pathname = "";
try {
const parsed = baseUrl ? new URL(baseUrl) : undefined;
hostname = parsed?.hostname.toLowerCase() ?? "";
pathname = parsed?.pathname.toLowerCase().replace(/\/+$/u, "") ?? "";
} catch {
// Provider identity still supplies the protocol for templated endpoints.
}
// Hermes honors an explicit Responses mode for named providers. Plain
// `custom` is the exception: endpoint detection rejects stale Responses state.
if (transport === "codex_responses" && sourceProvider !== "custom") {
return "openai-responses";
}
if (
["anthropic", "minimax", "minimax-cn", "minimax-oauth"].includes(provider) ||
hostname === "api.anthropic.com" ||
(hostname === "api.kimi.com" && (pathname === "/coding" || pathname.startsWith("/coding/"))) ||
pathname.endsWith("/anthropic") ||
pathname.endsWith("/anthropic/v1")
) {
return "anthropic-messages";
}
if (hostname === "chatgpt.com" && pathname.includes("/backend-api/codex")) {
return "openai-chatgpt-responses";
}
if (sourceProvider === "openai-api") {
return "openai-responses";
}
if (transport === "codex_responses") {
return "openai-responses";
}
if (provider === "xai" || hostname === "api.x.ai" || hostname === "api.openai.com") {
return "openai-responses";
}
return "openai-completions";
}
function normalizeProviderBaseUrl(baseUrl: string, api: OpenClawModelApi): string {
if (api !== "anthropic-messages") {
return baseUrl;
}
try {
const parsed = new URL(baseUrl);
// The Anthropic SDK appends /v1/messages. Store the canonical base so
// imported proxy paths do not repeat the version segment.
parsed.pathname = parsed.pathname.replace(/\/v1\/?$/u, "");
return parsed.toString().replace(/\/$/u, "");
} catch {
return baseUrl;
}
}
export function readEnvReference(value: unknown): string | undefined {
const raw = readString(value);
const match = raw?.match(/^\$\{([^}]+)\}$/u);
return match ? normalizeHermesEnvReferenceName(match[1] ?? "") : undefined;
}
function normalizeHermesEnvReferenceName(value: string): string | undefined {
const trimmed = value.trim();
const name = trimmed.startsWith("env:") ? trimmed.slice("env:".length).trim() : trimmed;
return name || undefined;
}
export function readProviderApiKeyEnv(raw: Record<string, unknown>): string | undefined {
return (
readString(raw.key_env) ??
readString(raw.api_key_env) ??
readString(raw.apiKeyEnv) ??
readString(raw.env) ??
readEnvReference(raw.api_key)
);
}
export function resolveHermesEndpointApiKeyEnv(baseUrl: string): string | undefined {
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
return hostname === "openai.com" ||
hostname.endsWith(".openai.com") ||
hostname === "openai.azure.com" ||
hostname.endsWith(".openai.azure.com")
? "OPENAI_API_KEY"
: undefined;
} catch {
return undefined;
}
}
function readModelMetadata(raw: Record<string, unknown>): Omit<HermesModelConfig, "id"> {
const contextWindow =
readPositiveNumber(raw.context_length) ?? readPositiveNumber(raw.contextWindow);
const maxTokens =
readPositiveNumber(raw.max_tokens) ??
readPositiveNumber(raw.max_output_tokens) ??
readPositiveNumber(raw.maxTokens);
const supportsVision = raw.supports_vision ?? raw.supportsVision;
return {
...(contextWindow ? { contextWindow } : {}),
...(maxTokens ? { maxTokens } : {}),
...(typeof supportsVision === "boolean" ? { supportsVision } : {}),
};
}
export function collectProviderModels(raw: Record<string, unknown>): HermesModelConfig[] {
const models = new Map<string, HermesModelConfig>();
const rootMetadata = readModelMetadata(raw);
for (const modelId of readStringArray(raw.models)) {
models.set(modelId, { id: modelId, ...rootMetadata });
}
for (const [modelId, metadata] of Object.entries(childRecord(raw, "models"))) {
models.set(modelId, {
id: modelId,
...rootMetadata,
...(isRecord(metadata) ? readModelMetadata(metadata) : {}),
});
}
for (const modelId of [
readString(raw.default_model),
readString(raw.default),
readString(raw.model),
]) {
if (modelId && !models.has(modelId)) {
models.set(modelId, { id: modelId, ...rootMetadata });
}
}
return [...models.values()];
}
function modelDefinition(
model: HermesModelConfig,
entry: HermesProviderConfig,
): Record<string, unknown> {
const baseUrl = normalizeProviderBaseUrl(entry.baseUrl, entry.api);
return {
id: model.id,
name: model.id,
api: entry.api,
reasoning: false,
input: model.supportsVision ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.contextWindow ?? 128_000,
maxTokens: model.maxTokens ?? 8192,
baseUrl,
metadataSource: "models-add",
};
}
export function providerConfig(entry: HermesProviderConfig): Record<string, unknown> {
const models = entry.models.length > 0 ? entry.models : [{ id: "default" }];
return {
baseUrl: normalizeProviderBaseUrl(entry.baseUrl, entry.api),
api: entry.api,
...(entry.headers ? { headers: entry.headers } : {}),
models: models.map((model) => modelDefinition(model, entry)),
};
}
export function readProviderBaseUrl(
raw: Record<string, unknown>,
env: Record<string, string>,
): { baseUrl?: string; sensitive: boolean; unresolved: boolean } {
const value =
readString(raw.base_url) ??
readString(raw.baseUrl) ??
readString(raw.url) ??
readString(raw.api);
if (!value) {
return { sensitive: false, unresolved: false };
}
const sensitive = MCP_ENV_REFERENCE_RE.test(value);
MCP_ENV_REFERENCE_RE.lastIndex = 0;
if (!sensitive) {
return { baseUrl: value, sensitive: false, unresolved: false };
}
const resolved = resolveMcpEnvReferences(value, env);
return {
baseUrl:
!resolved.unresolved && typeof resolved.value === "string" ? resolved.value : undefined,
sensitive: true,
unresolved: resolved.unresolved,
};
}
export function readProviderHeaders(
raw: Record<string, unknown>,
env: Record<string, string>,
includeSecrets: boolean,
): {
blocked: boolean;
headers?: Record<string, unknown>;
invalid: boolean;
sensitive: boolean;
unresolved: boolean;
} {
const source = isRecord(raw.extra_headers) ? raw.extra_headers : undefined;
if (!source || Object.keys(source).length === 0) {
return { blocked: false, invalid: false, sensitive: false, unresolved: false };
}
const headers: Record<string, unknown> = {};
let blocked = false;
let invalid = false;
let sensitive = false;
let unresolved = false;
for (const [name, rawValue] of Object.entries(source)) {
if (rawValue === null || rawValue === undefined) {
continue;
}
if (
typeof rawValue !== "string" &&
typeof rawValue !== "number" &&
typeof rawValue !== "boolean"
) {
invalid = true;
continue;
}
const value = String(rawValue);
const envName = readEnvReference(value);
const hasReference = mcpValueHasEnvReferences(value);
if (!includeSecrets) {
blocked = true;
continue;
}
sensitive = true;
if (envName) {
const resolved = env[envName];
if (resolved === undefined) {
unresolved = true;
continue;
}
headers[name] = resolved;
continue;
}
if (hasReference) {
const resolved = resolveMcpEnvReferences(value, env);
if (resolved.unresolved || typeof resolved.value !== "string") {
unresolved = true;
continue;
}
headers[name] = resolved.value;
continue;
}
headers[name] = value;
}
return {
blocked,
headers: Object.keys(headers).length > 0 ? headers : undefined,
invalid,
sensitive,
unresolved,
};
}
@@ -0,0 +1,361 @@
// Hermes provider config collection and migration planning.
import { createMigrationManualItem } from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import {
HERMES_TRANSPORTS,
collectProviderModels,
readEnvReference,
readProviderApiKeyEnv,
readProviderBaseUrl,
readProviderHeaders,
resolveHermesEndpointApiKeyEnv,
resolveHermesImplicitBaseUrl,
resolveHermesProviderApiKeyEnv,
resolveHermesProviderBaseUrlEnv,
resolveProviderApi,
type HermesProviderConfig,
} from "./config-provider-contract.js";
import { childRecord, isRecord, readString, sanitizeName } from "./helpers.js";
import { normalizeHermesCustomProviderId, resolveHermesConfiguredProviderId } from "./model.js";
export type HermesProviderSecretBinding = {
envVar: string;
provider: string;
};
type HermesProviderSource = {
id: string;
raw: Record<string, unknown>;
source: string;
};
export function collectHermesProviders(
config: Record<string, unknown>,
env: Record<string, string> = {},
includeSecrets = false,
): HermesProviderConfig[] {
const collected: HermesProviderConfig[] = [];
const upsert = (entry: HermesProviderConfig, options?: { fallbackOnly?: boolean }): void => {
const index = collected.findIndex((candidate) => candidate.id === entry.id);
if (index < 0) {
collected.push(entry);
return;
}
const previous = collected[index]!;
collected[index] = {
...(options?.fallbackOnly ? entry : previous),
...(options?.fallbackOnly ? previous : entry),
models: [
...previous.models,
...entry.models.filter(
(model) => !previous.models.some((previousModel) => previousModel.id === model.id),
),
],
};
};
for (const [id, raw] of Object.entries(childRecord(config, "providers"))) {
if (!isRecord(raw)) {
continue;
}
const resolvedBaseUrl = readProviderBaseUrl(raw, env);
const baseUrl = resolvedBaseUrl.baseUrl ?? resolveHermesImplicitBaseUrl(id);
const api = resolveProviderApi(baseUrl ? { ...raw, base_url: baseUrl } : raw, id);
if (!baseUrl || !api) {
continue;
}
const headerConfig = readProviderHeaders(raw, env, includeSecrets);
upsert({
id: resolveHermesConfiguredProviderId(config, id, env),
baseUrl,
api,
apiKeyEnv: readProviderApiKeyEnv(raw) ?? resolveHermesEndpointApiKeyEnv(baseUrl),
headers: headerConfig.headers,
models: collectProviderModels(raw),
sensitive: resolvedBaseUrl.sensitive || headerConfig.sensitive,
});
}
const customProviders = config.custom_providers;
if (Array.isArray(customProviders)) {
for (const raw of customProviders) {
if (!isRecord(raw)) {
continue;
}
const id = readString(raw.name) ?? readString(raw.id);
if (!id) {
continue;
}
const resolvedBaseUrl = readProviderBaseUrl(raw, env);
const baseUrl = resolvedBaseUrl.baseUrl;
const api = resolveProviderApi(baseUrl ? { ...raw, base_url: baseUrl } : raw, id);
if (!baseUrl || !api) {
continue;
}
const headerConfig = readProviderHeaders(raw, env, includeSecrets);
upsert(
{
id: resolveHermesConfiguredProviderId(config, id, env),
baseUrl,
api,
apiKeyEnv: readProviderApiKeyEnv(raw) ?? resolveHermesEndpointApiKeyEnv(baseUrl),
headers: headerConfig.headers,
models: collectProviderModels(raw),
sensitive: resolvedBaseUrl.sensitive || headerConfig.sensitive,
},
{ fallbackOnly: true },
);
}
}
const model = config.model;
if (isRecord(model)) {
const rawProvider = readString(model.provider);
const resolvedBaseUrl = readProviderBaseUrl(model, env);
const envBaseUrl = resolveHermesProviderBaseUrlEnv(rawProvider, env);
const baseUrl =
resolvedBaseUrl.baseUrl ?? envBaseUrl ?? resolveHermesImplicitBaseUrl(rawProvider);
const api = resolveProviderApi(baseUrl ? { ...model, base_url: baseUrl } : model, rawProvider);
if (baseUrl && api) {
const headerConfig = readProviderHeaders(model, env, includeSecrets);
upsert({
id: rawProvider ? resolveHermesConfiguredProviderId(config, rawProvider, env) : "custom",
baseUrl,
api,
apiKeyEnv:
readProviderApiKeyEnv(model) ??
resolveHermesProviderApiKeyEnv(rawProvider) ??
resolveHermesEndpointApiKeyEnv(baseUrl),
headers: headerConfig.headers,
models: collectProviderModels(model),
sensitive: resolvedBaseUrl.sensitive || Boolean(envBaseUrl) || headerConfig.sensitive,
});
}
} else {
const rawProvider = readString(config.provider);
const baseUrl =
resolveHermesProviderBaseUrlEnv(rawProvider, env) ??
resolveHermesImplicitBaseUrl(rawProvider);
const api = resolveProviderApi(baseUrl ? { base_url: baseUrl } : {}, rawProvider);
if (rawProvider && baseUrl && api) {
upsert({
id: resolveHermesConfiguredProviderId(config, rawProvider, env),
baseUrl,
api,
apiKeyEnv:
resolveHermesProviderApiKeyEnv(rawProvider) ?? resolveHermesEndpointApiKeyEnv(baseUrl),
models: [],
sensitive: true,
});
}
}
return collected;
}
export function collectHermesProviderSecretBindings(
config: Record<string, unknown>,
env: Record<string, string> = {},
): HermesProviderSecretBinding[] {
const bindings = collectHermesProviders(config, env).flatMap((entry) =>
entry.apiKeyEnv ? [{ envVar: entry.apiKeyEnv, provider: entry.id }] : [],
);
for (const [sourceProvider, raw] of Object.entries(childRecord(config, "providers"))) {
if (!isRecord(raw)) {
continue;
}
const envVar = readProviderApiKeyEnv(raw) ?? resolveHermesProviderApiKeyEnv(sourceProvider);
if (envVar) {
bindings.push({
envVar,
provider: resolveHermesConfiguredProviderId(config, sourceProvider, env),
});
}
}
if (Array.isArray(config.custom_providers)) {
for (const raw of config.custom_providers) {
if (!isRecord(raw)) {
continue;
}
const sourceProvider = readString(raw.name) ?? readString(raw.id);
const envVar = readProviderApiKeyEnv(raw);
if (sourceProvider && envVar) {
bindings.push({
envVar,
provider: resolveHermesConfiguredProviderId(config, sourceProvider, env),
});
}
}
}
const model = isRecord(config.model) ? config.model : undefined;
const selectedProvider = readString(model?.provider) ?? readString(config.provider);
const selectedEnv =
(model ? readProviderApiKeyEnv(model) : undefined) ??
resolveHermesProviderApiKeyEnv(selectedProvider);
if (selectedProvider && selectedEnv) {
bindings.push({
envVar: selectedEnv,
provider: resolveHermesConfiguredProviderId(config, selectedProvider, env),
});
}
return [
...new Map(
bindings.map((binding) => [`${binding.provider}\0${binding.envVar}`, binding]),
).values(),
];
}
export function addSelectedModelToProvider(
providers: HermesProviderConfig[],
modelRef: string | undefined,
): void {
if (!modelRef) {
return;
}
const slash = modelRef.indexOf("/");
if (slash <= 0 || slash === modelRef.length - 1) {
return;
}
const provider = providers.find((entry) => entry.id === modelRef.slice(0, slash));
const modelId = modelRef.slice(slash + 1);
if (provider && !provider.models.some((model) => model.id === modelId)) {
provider.models.push({ id: modelId });
}
}
export function providerManualItems(
config: Record<string, unknown>,
env: Record<string, string>,
includeSecrets: boolean,
): MigrationItem[] {
const entries: HermesProviderSource[] = [];
const currentProviderIds = new Set(
Object.keys(childRecord(config, "providers")).map(normalizeHermesCustomProviderId),
);
for (const [id, raw] of Object.entries(childRecord(config, "providers"))) {
if (isRecord(raw)) {
entries.push({ id, raw, source: `config.yaml:providers.${id}` });
}
}
if (Array.isArray(config.custom_providers)) {
for (const raw of config.custom_providers) {
if (!isRecord(raw)) {
continue;
}
const id = readString(raw.name) ?? readString(raw.id);
if (id && !currentProviderIds.has(normalizeHermesCustomProviderId(id))) {
entries.push({ id, raw, source: `config.yaml:custom_providers.${id}` });
}
}
}
if (isRecord(config.model)) {
const provider = readString(config.model.provider);
const baseUrl = readString(config.model.base_url) ?? readString(config.model.baseUrl);
if (baseUrl) {
entries.push({
id: provider
? resolveHermesConfiguredProviderId(config, provider, env) || "custom"
: "custom",
raw: config.model,
source: "config.yaml:model",
});
}
}
const items: MigrationItem[] = [];
for (const { id, raw, source } of entries) {
const transport = readString(raw.transport) ?? readString(raw.api_mode);
const baseUrlConfig = readProviderBaseUrl(raw, env);
const baseUrl = baseUrlConfig.baseUrl ?? resolveHermesImplicitBaseUrl(id);
const headerConfig = readProviderHeaders(raw, env, includeSecrets);
if (transport && !HERMES_TRANSPORTS[transport]) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-transport:${sanitizeName(id)}`,
source: `${source}.transport`,
message: `Hermes provider "${id}" uses unsupported transport "${transport}".`,
recommendation:
"Configure an equivalent OpenClaw provider plugin or API adapter manually.",
}),
);
} else if (baseUrlConfig.unresolved) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-endpoint-env:${sanitizeName(id)}`,
source,
message: `Hermes provider "${id}" references an endpoint environment variable that was not present in the Hermes .env file.`,
recommendation: "Configure the provider endpoint manually after migration.",
}),
);
} else if (!baseUrl) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-endpoint:${sanitizeName(id)}`,
source,
message: `Hermes provider "${id}" has no explicit endpoint to import safely.`,
recommendation: "Configure the provider endpoint manually after migration.",
}),
);
}
if (readString(raw.api_key) && !readEnvReference(raw.api_key)) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-inline-key:${sanitizeName(id)}`,
source: `${source}.api_key`,
message: `Hermes provider "${id}" contains an inline API key that was not copied into OpenClaw config.`,
recommendation: "Move the key to an environment variable or OpenClaw secret provider.",
}),
);
}
if (headerConfig.blocked) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-headers:${sanitizeName(id)}`,
source: `${source}.extra_headers`,
message: `Hermes provider "${id}" has literal request headers that require secret migration consent.`,
recommendation: "Rerun with --include-secrets or configure the headers manually.",
}),
);
} else if (headerConfig.unresolved) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-headers-env:${sanitizeName(id)}`,
source: `${source}.extra_headers`,
message: `Hermes provider "${id}" has request header environment references that could not be resolved.`,
recommendation: "Configure the provider headers manually after migration.",
}),
);
}
if (headerConfig.invalid) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-headers-invalid:${sanitizeName(id)}`,
source: `${source}.extra_headers`,
message: `Hermes provider "${id}" has non-scalar request header values that were not imported.`,
recommendation: "Configure valid string header values manually after migration.",
}),
);
}
if (isRecord(raw.extra_body) && Object.keys(raw.extra_body).length > 0) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-extra-body:${sanitizeName(id)}`,
source: `${source}.extra_body`,
message: `Hermes provider "${id}" adds request body fields that OpenClaw cannot import generically.`,
recommendation:
"Configure an equivalent provider plugin or supported request option manually.",
}),
);
}
const apiKeyEnv = readProviderApiKeyEnv(raw);
if (apiKeyEnv && !env[apiKeyEnv]?.trim()) {
items.push(
createMigrationManualItem({
id: `manual:model-provider-key-env:${sanitizeName(id)}`,
source: `${source}.key_env`,
message: `Hermes provider "${id}" references ${apiKeyEnv}, but that value was not present in the Hermes .env file.`,
recommendation:
"Configure an OpenClaw auth profile for this provider or expose the variable to the OpenClaw runtime.",
}),
);
}
}
return [...new Map(items.map((item) => [item.id, item])).values()];
}
File diff suppressed because it is too large Load Diff
+65 -162
View File
@@ -7,148 +7,14 @@ import {
hasMigrationConfigPatchConflict,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { childRecord, isRecord, readString, readStringArray } from "./helpers.js";
type HermesProviderConfig = {
id: string;
baseUrl?: string;
apiKeyEnv?: string;
models: string[];
};
function envKeyForProvider(providerId: string): string {
return `${providerId.toUpperCase().replaceAll(/[^A-Z0-9]/gu, "_")}_API_KEY`;
}
function splitProviderModel(modelRef: string | undefined): { provider?: string; model?: string } {
if (!modelRef) {
return {};
}
const slash = modelRef.indexOf("/");
if (slash > 0 && slash < modelRef.length - 1) {
return { provider: modelRef.slice(0, slash), model: modelRef.slice(slash + 1) };
}
return { model: modelRef };
}
function modelDefinition(modelId: string, baseUrl?: string): Record<string, unknown> {
return {
id: modelId,
name: modelId,
api: baseUrl ? "openai-completions" : "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8192,
...(baseUrl ? { baseUrl } : {}),
metadataSource: "models-add",
};
}
function providerConfig(entry: HermesProviderConfig): Record<string, unknown> {
const models = entry.models.length > 0 ? entry.models : [`${entry.id}/default`];
return {
baseUrl: entry.baseUrl ?? "",
...(entry.apiKeyEnv
? { apiKey: { source: "env", provider: "default", id: entry.apiKeyEnv } }
: {}),
api: "openai-completions",
models: models.map((modelId) => modelDefinition(modelId, entry.baseUrl)),
};
}
function collectHermesProviders(
config: Record<string, unknown>,
modelRef?: string,
): HermesProviderConfig[] {
const collected: HermesProviderConfig[] = [];
for (const [id, raw] of Object.entries(childRecord(config, "providers"))) {
if (!isRecord(raw)) {
continue;
}
const baseUrl =
readString(raw.base_url) ??
readString(raw.baseUrl) ??
readString(raw.url) ??
readString(raw.api);
const apiKeyEnv =
readString(raw.api_key_env) ??
readString(raw.apiKeyEnv) ??
readString(raw.env) ??
envKeyForProvider(id);
const models = [
...readStringArray(raw.models),
...Object.keys(childRecord(raw, "models")),
readString(raw.model),
].filter((value): value is string => Boolean(value));
collected.push({ id, baseUrl, apiKeyEnv, models: uniqueStrings(models) });
}
const customProviders = config.custom_providers;
if (Array.isArray(customProviders)) {
for (const raw of customProviders) {
if (!isRecord(raw)) {
continue;
}
const id = readString(raw.name) ?? readString(raw.id);
if (!id) {
continue;
}
const baseUrl = readString(raw.base_url) ?? readString(raw.baseUrl) ?? readString(raw.url);
const apiKeyEnv = readString(raw.api_key_env) ?? readString(raw.apiKeyEnv);
const models = [
...readStringArray(raw.models),
...Object.keys(childRecord(raw, "models")),
readString(raw.model),
].filter((value): value is string => Boolean(value));
collected.push({ id, baseUrl, apiKeyEnv, models: uniqueStrings(models) });
}
}
const defaultRef = splitProviderModel(modelRef);
if (defaultRef.provider && !collected.some((entry) => entry.id === defaultRef.provider)) {
collected.push({
id: defaultRef.provider,
apiKeyEnv: envKeyForProvider(defaultRef.provider),
models: defaultRef.model ? [defaultRef.model] : [],
});
}
return collected;
}
function mapMcpServers(raw: unknown): Record<string, unknown> | undefined {
if (!isRecord(raw)) {
return undefined;
}
const mapped: Record<string, unknown> = {};
for (const [name, value] of Object.entries(raw)) {
if (!isRecord(value)) {
continue;
}
const next: Record<string, unknown> = {};
for (const key of [
"command",
"args",
"env",
"cwd",
"workingDirectory",
"url",
"transport",
"headers",
"connectionTimeoutMs",
]) {
if (value[key] !== undefined) {
next[key] = value[key];
}
}
if (Object.keys(next).length > 0) {
mapped[name] = next;
}
}
return Object.keys(mapped).length > 0 ? mapped : undefined;
}
import { importsMcpSensitiveValues, mapMcpServer, mcpManualItems } from "./config-mcp.js";
import { providerConfig } from "./config-provider-contract.js";
import {
addSelectedModelToProvider,
collectHermesProviders,
providerManualItems,
} from "./config-providers.js";
import { childRecord, isRecord, readString, sanitizeName } from "./helpers.js";
function mapSkillEntries(config: Record<string, unknown>): Record<string, unknown> | undefined {
const entries: Record<string, unknown> = {};
@@ -165,6 +31,8 @@ function mapSkillEntries(config: Record<string, unknown>): Record<string, unknow
export function buildConfigItems(params: {
ctx: MigrationProviderContext;
config: Record<string, unknown>;
env?: Record<string, string>;
runtimeEnv?: Record<string, string>;
modelRef?: string;
hasMemoryFiles?: boolean;
}): MigrationItem[] {
@@ -241,41 +109,76 @@ export function buildConfigItems(params: {
);
}
const providers = collectHermesProviders(params.config, params.modelRef);
if (providers.length > 0) {
const value = Object.fromEntries(providers.map((entry) => [entry.id, providerConfig(entry)]));
const providers = collectHermesProviders(
params.config,
params.env,
Boolean(params.ctx.includeSecrets),
);
addSelectedModelToProvider(providers, params.modelRef);
for (const provider of providers) {
const value = { [provider.id]: providerConfig(provider) };
items.push(
createMigrationConfigPatchItem({
id: "config:model-providers",
target: "models.providers",
id: `config:model-provider:${sanitizeName(provider.id)}`,
target: `models.providers.${provider.id}`,
path: ["models", "providers"],
value,
message: "Import Hermes provider and custom endpoint config.",
message: `Import Hermes provider and custom endpoint config for "${provider.id}".`,
sensitive: provider.sensitive,
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["models", "providers"], value),
}),
);
}
items.push(
...providerManualItems(params.config, params.env ?? {}, Boolean(params.ctx.includeSecrets)),
);
const mcpConfig = params.config.mcp;
const rawMcpServers =
params.config.mcp_servers ??
(isRecord(mcpConfig) && isRecord(mcpConfig.servers) ? mcpConfig.servers : mcpConfig);
const mcpServers = mapMcpServers(rawMcpServers);
if (mcpServers) {
items.push(
createMigrationConfigPatchItem({
id: "config:mcp-servers",
target: "mcp.servers",
path: ["mcp", "servers"],
value: mcpServers,
message: "Import Hermes MCP server definitions.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["mcp", "servers"], mcpServers),
}),
);
const rawMcpSource =
params.config.mcp_servers !== undefined
? "config.yaml:mcp_servers"
: isRecord(mcpConfig) && isRecord(mcpConfig.servers)
? "config.yaml:mcp.servers"
: "config.yaml:mcp";
if (isRecord(rawMcpServers)) {
// Hermes loads process env first, then lets its source .env override those values.
const mcpEnv = { ...params.runtimeEnv, ...params.env };
for (const [name, rawServer] of Object.entries(rawMcpServers)) {
if (!isRecord(rawServer)) {
continue;
}
const server = mapMcpServer(rawServer, Boolean(params.ctx.includeSecrets), mcpEnv);
if (Object.keys(server).length > 0) {
const value = { [name]: server };
items.push(
createMigrationConfigPatchItem({
id: `config:mcp-server:${sanitizeName(name)}`,
target: `mcp.servers.${name}`,
path: ["mcp", "servers"],
value,
message: `Import Hermes MCP server definition "${name}".`,
sensitive: importsMcpSensitiveValues(rawServer, Boolean(params.ctx.includeSecrets)),
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["mcp", "servers"], value),
}),
);
}
items.push(
...mcpManualItems({
name,
raw: rawServer,
includeSecrets: Boolean(params.ctx.includeSecrets),
env: mcpEnv,
source: `${rawMcpSource}.${name}`,
}),
);
}
}
const skillEntries = mapSkillEntries(params.config);
@@ -4,12 +4,16 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime";
import { MIGRATION_REASON_TARGET_EXISTS } from "openclaw/plugin-sdk/migration";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildAuthItems } from "./auth.js";
import { buildHermesMigrationProvider } from "./provider.js";
import { discoverHermesSource, resolveImplicitHermesRoot } from "./source.js";
import { resolveTargets } from "./targets.js";
import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js";
describe("Hermes migration file and skill items", () => {
afterEach(async () => {
vi.restoreAllMocks();
await cleanupTempRoots();
});
@@ -38,6 +42,215 @@ describe("Hermes migration file and skill items", () => {
return items.find((item) => item.id === id);
}
function fakeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
const hermesAccessField = ["access", "token"].join("_");
const hermesRefreshField = ["refresh", "token"].join("_");
it("discovers nested skills while pruning inactive packages", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
await writeFile(path.join(source, "skills", "coding", "review", "SKILL.md"), "# Review\n");
await writeFile(
path.join(source, "skills", "coding", "review", "subskills", "lint", "SKILL.md"),
"# Lint\n",
);
await writeFile(
path.join(source, "skills", "coding", "review", "references", "old", "SKILL.md"),
"# Old\n",
);
await writeFile(path.join(source, "skills", ".archive", "retired", "SKILL.md"), "# Old\n");
const plan = await buildHermesMigrationProvider().plan(
makeContext({ source, stateDir, workspaceDir }),
);
const skills = plan.items.filter((item) => item.kind === "skill");
expect(skills).toHaveLength(2);
expect(itemById(skills, "skill:coding:review")).toEqual(
expect.objectContaining({
id: "skill:coding:review",
source: path.join(source, "skills", "coding", "review"),
target: path.join(workspaceDir, "skills", "review"),
}),
);
expect(itemById(skills, "skill:coding:review:subskills:lint")).toEqual(
expect.objectContaining({
source: path.join(source, "skills", "coding", "review", "subskills", "lint"),
target: path.join(workspaceDir, "skills", "lint"),
}),
);
});
it("honors explicit Hermes home, active profiles, and Windows legacy state", async () => {
const root = await makeTempRoot();
const home = path.join(root, "home");
const defaultRoot = path.join(home, ".hermes");
const profileRoot = path.join(defaultRoot, "profiles", "coder");
await writeFile(path.join(defaultRoot, "active_profile"), "coder\n");
await writeFile(path.join(profileRoot, "config.yaml"), "model: openai/gpt-5.6\n");
expect(await resolveImplicitHermesRoot({ HERMES_HOME: defaultRoot }, "darwin")).toBe(
defaultRoot,
);
expect(await resolveImplicitHermesRoot({ HOME: home }, "darwin")).toBe(profileRoot);
expect((await discoverHermesSource(profileRoot)).root).toBe(profileRoot);
await writeFile(path.join(defaultRoot, "active_profile"), "../escape\n");
expect(await resolveImplicitHermesRoot({ HOME: home }, "darwin")).toBe(defaultRoot);
const windowsHome = path.join(root, "windows-home");
const localAppData = path.join(windowsHome, "AppData", "Local");
const nativeWindowsRoot = path.join(localAppData, "hermes");
const legacyWindowsRoot = path.join(windowsHome, ".hermes");
await writeFile(path.join(legacyWindowsRoot, "config.yaml"), "model: legacy\n");
expect(
await resolveImplicitHermesRoot(
{ LOCALAPPDATA: localAppData, USERPROFILE: windowsHome },
"win32",
),
).toBe(legacyWindowsRoot);
await writeFile(path.join(nativeWindowsRoot, "config.yaml"), "model: current\n");
expect(
await resolveImplicitHermesRoot(
{ LOCALAPPDATA: localAppData, USERPROFILE: windowsHome },
"win32",
),
).toBe(nativeWindowsRoot);
const personaHome = path.join(root, "persona-home");
const personaLocalAppData = path.join(personaHome, "AppData", "Local");
const personaLegacyRoot = path.join(personaHome, ".hermes");
await writeFile(path.join(personaLegacyRoot, "SOUL.md"), "Legacy persona\n");
expect(
await resolveImplicitHermesRoot(
{ LOCALAPPDATA: personaLocalAppData, USERPROFILE: personaHome },
"win32",
),
).toBe(personaLegacyRoot);
});
it("uses global Hermes auth per provider when the active profile has no local entry", async () => {
const root = await makeTempRoot();
const home = path.join(root, "home");
const hermesRoot = path.join(home, ".hermes");
const profileRoot = path.join(hermesRoot, "profiles", "coder");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const access = fakeJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct-global" },
"https://api.openai.com/profile": { email: "global@example.test" },
});
await writeFile(path.join(hermesRoot, "active_profile"), "coder\n");
await writeFile(path.join(profileRoot, "config.yaml"), "model: openai/gpt-5.6\n");
await writeFile(path.join(profileRoot, "auth.json"), JSON.stringify({ providers: {} }));
await writeFile(
path.join(hermesRoot, "auth.json"),
JSON.stringify({
providers: {
"openai-codex": {
tokens: {
[hermesAccessField]: access,
[hermesRefreshField]: "global-refresh",
},
},
},
}),
);
const source = await discoverHermesSource(undefined, {
env: { HOME: home },
platform: "darwin",
});
expect(source.root).toBe(profileRoot);
expect(source.globalAuthPath).toBe(path.join(hermesRoot, "auth.json"));
const ctx = makeContext({ source: profileRoot, stateDir, workspaceDir, includeSecrets: true });
const items = await buildAuthItems({ ctx, source, targets: resolveTargets(ctx) });
expect(items).toEqual([
expect.objectContaining({
source: path.join(hermesRoot, "auth.json"),
details: expect.objectContaining({ provider: "openai" }),
}),
]);
});
it("maps supported OAuth model providers and requests fresh OpenClaw authentication", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const xaiProvider = ["xai", "oauth"].join("-");
const minimaxProvider = ["minimax", "oauth"].join("-");
await writeFile(
path.join(source, "config.yaml"),
`model:\n provider: ${xaiProvider}\n default: grok-4.1-fast\n`,
);
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
providers: {
anthropic: {},
nous: {},
"qwen-oauth": {},
[xaiProvider]: {},
[minimaxProvider]: {},
},
}),
);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(itemById(plan.items, "config:default-model")?.details?.model).toBe("xai/grok-4.1-fast");
const reauthItems = plan.items.filter(
(item) => item.kind === "manual" && item.message?.includes("credentials cannot be reused"),
);
expect(reauthItems.map((item) => item.reason)).toEqual([
"Authenticate anthropic in OpenClaw after migration.",
"Authenticate nous in OpenClaw after migration.",
"Authenticate qwen-oauth in OpenClaw after migration.",
"Authenticate minimax-portal in OpenClaw after migration.",
"Authenticate xai in OpenClaw after migration.",
]);
});
it("requests reauthentication only for OAuth credential-pool entries", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
await writeFile(path.join(source, "config.yaml"), "{}\n");
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
credential_pool: {
anthropic: [{ auth_type: "api_key" }],
nous: [{ auth_type: "oauth" }],
},
}),
);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(
plan.items
.filter((item) => item.id.startsWith("manual:auth-reauthenticate:"))
.map((item) => item.id),
).toEqual(["manual:auth-reauthenticate:nous"]);
});
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.access(targetPath);
@@ -53,18 +266,20 @@ describe("Hermes migration file and skill items", () => {
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
await writeFile(path.join(source, "skills", "Ship It", "SKILL.md"), "# Ship It\n");
await writeFile(path.join(source, "skills", "ship-it", "SKILL.md"), "# ship-it\n");
await writeFile(path.join(source, "skills", "team-a", "review", "SKILL.md"), "# A\n");
await writeFile(path.join(source, "skills", "team-b", "review", "SKILL.md"), "# B\n");
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir }));
const skillItems = plan.items.filter((item) => item.kind === "skill");
expect(skillItems).toHaveLength(2);
const shipIt = itemById(skillItems, "skill:ship-it");
expect(shipIt?.status).toBe("conflict");
expect(shipIt?.reason).toBe('multiple Hermes skill directories normalize to "ship-it"');
expect(shipIt?.target).toBe(path.join(workspaceDir, "skills", "ship-it"));
const reviewA = itemById(skillItems, "skill:team-a:review");
const reviewB = itemById(skillItems, "skill:team-b:review");
expect(reviewA?.status).toBe("conflict");
expect(reviewA?.reason).toBe('multiple Hermes skill directories normalize to "review"');
expect(reviewA?.target).toBe(path.join(workspaceDir, "skills", "review"));
expect(reviewB?.status).toBe("conflict");
const result = await provider.apply(
makeContext({
@@ -77,7 +292,7 @@ describe("Hermes migration file and skill items", () => {
);
expect(result.summary.conflicts).toBe(2);
await expectPathMissing(path.join(workspaceDir, "skills", "ship-it"));
await expectPathMissing(path.join(workspaceDir, "skills", "review"));
});
it("reports late-created copy targets as conflicts without overwriting", async () => {
@@ -170,6 +385,42 @@ describe("Hermes migration file and skill items", () => {
}
});
it("keeps repeated memory imports byte-identical", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
await writeFile(path.join(source, "memories", "MEMORY.md"), "one memory\n");
const provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
overwrite: true,
runtime: configRuntime({}),
});
await provider.apply(ctx);
const first = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf8");
const secondResult = await provider.apply(ctx);
expect(await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf8")).toBe(first);
expect(itemById(secondResult.items, "memory:MEMORY.md")?.status).toBe("skipped");
});
it("fails planning on malformed Hermes YAML", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
await writeFile(path.join(source, "config.yaml"), "model: [unterminated\n");
await expect(
buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "ws"),
}),
),
).rejects.toThrow(`Failed to parse Hermes config at ${path.join(source, "config.yaml")}`);
});
it("archives unsupported Hermes state without copying raw auth credentials", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
@@ -177,7 +428,15 @@ describe("Hermes migration file and skill items", () => {
const stateDir = path.join(root, "state");
const reportDir = path.join(root, "report");
await writeFile(path.join(source, "logs", "session.log"), "log line\n");
await writeFile(path.join(source, "pairing", "approved.json"), '{"approved":[]}\n');
await writeFile(path.join(source, "platforms", "pairing", "telegram.json"), "{}\n");
await writeFile(path.join(source, "gateway_state.json"), '{"running":false}\n');
await writeFile(path.join(source, "channel_directory.json"), "{}\n");
await writeFile(path.join(source, "channel_aliases.json"), "{}\n");
await writeFile(path.join(source, "processes.json"), "{}\n");
await writeFile(path.join(source, "feishu_comment_pairing.json"), "{}\n");
await writeFile(path.join(source, "auth.json"), '{"token":"opaque"}\n');
new DatabaseSync(path.join(source, "retaindb_queue.db")).close();
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir, reportDir }));
@@ -186,6 +445,18 @@ describe("Hermes migration file and skill items", () => {
expect(plannedLogs?.kind).toBe("archive");
expect(plannedLogs?.action).toBe("archive");
expect(plannedLogs?.status).toBe("planned");
for (const itemId of [
"archive:pairing",
"archive:platforms",
"archive:gateway_state.json",
"archive:channel_directory.json",
"archive:channel_aliases.json",
"archive:processes.json",
"archive:feishu_comment_pairing.json",
"archive:retaindb_queue.db",
]) {
expect(itemById(plan.items, itemId)?.status).toBe("planned");
}
expect(plan.items.find((item) => item.id === "archive:auth.json")).toBeUndefined();
expect(plan.warnings).toEqual([
"Some Hermes files are archive-only. They will be copied into the migration report for manual review, not loaded into OpenClaw.",
@@ -200,6 +471,18 @@ describe("Hermes migration file and skill items", () => {
expect(await fs.readFile(path.join(reportDir, "archive", "logs", "session.log"), "utf8")).toBe(
"log line\n",
);
expect(
await fs.readFile(path.join(reportDir, "archive", "pairing", "approved.json"), "utf8"),
).toBe('{"approved":[]}\n');
expect(
await fs.readFile(
path.join(reportDir, "archive", "platforms", "pairing", "telegram.json"),
"utf8",
),
).toBe("{}\n");
await expect(
fs.access(path.join(reportDir, "archive", "retaindb_queue.db")),
).resolves.toBeUndefined();
await expectPathMissing(path.join(reportDir, "archive", "auth.json"));
await expectPathMissing(path.join(workspaceDir, "logs", "session.log"));
});
@@ -250,6 +533,24 @@ describe("Hermes migration file and skill items", () => {
}
});
it("discovers the current Hermes state database for archival", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const currentStatePath = path.join(source, "hermes_state.db");
await fs.mkdir(source, { recursive: true });
new DatabaseSync(currentStatePath).close();
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(itemById(plan.items, "archive:hermes_state.db")?.source).toBe(currentStatePath);
});
it("preserves raw Hermes state when SQLite snapshotting fails", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
@@ -267,13 +568,49 @@ describe("Hermes migration file and skill items", () => {
expect(archivedState?.status).toBe("error");
expect(archivedState?.target).toBe(archivedStatePath);
expect(archivedState?.reason).toContain(
"SQLite snapshot failed; raw state.db preserved for manual review",
"SQLite snapshot failed; database recovery files preserved for manual review",
);
expect(await fs.readFile(path.join(archivedStatePath, "state.db"), "utf8")).toBe(
"legacy non-SQLite Hermes state\n",
);
expect(await fs.readFile(archivedStatePath, "utf8")).toBe("legacy non-SQLite Hermes state\n");
expect(result.summary.errors).toBe(1);
});
it("reports legacy Hermes OpenAI auth.json OAuth state as manual reauth work", async () => {
it("tolerates a disappearing optional SQLite recovery sidecar", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const stateDbPath = path.join(source, "state.db");
const walPath = `${stateDbPath}-wal`;
const reportDir = path.join(root, "report");
await writeFile(stateDbPath, "legacy non-SQLite Hermes state\n");
await writeFile(walPath, "transient wal\n");
const copyFile = fs.copyFile.bind(fs);
vi.spyOn(fs, "copyFile").mockImplementation(async (sourcePath, targetPath, mode) => {
if (sourcePath === walPath) {
await fs.rm(walPath, { force: true });
throw Object.assign(new Error("sidecar vanished"), { code: "ENOENT" });
}
return await copyFile(sourcePath, targetPath, mode);
});
const result = await buildHermesMigrationProvider().apply(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
reportDir,
}),
);
const archivedState = itemById(result.items, "archive:state.db");
expect(archivedState?.status).toBe("error");
expect(archivedState?.reason).toContain("recovery files preserved");
expect(await fs.readFile(path.join(reportDir, "archive", "state.db", "state.db"), "utf8")).toBe(
"legacy non-SQLite Hermes state\n",
);
});
it("ignores legacy Hermes OpenAI auth.json OAuth state", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
@@ -305,13 +642,47 @@ describe("Hermes migration file and skill items", () => {
makeContext({ source, stateDir, workspaceDir, includeSecrets: true }),
);
const manualAuth = itemById(plan.items, "manual:legacy-hermes-auth-json");
expect(manualAuth?.kind).toBe("manual");
expect(manualAuth?.status).toBe("skipped");
expect(manualAuth?.message).toContain("no longer imports");
expect(plan.items.some((item) => item.kind === "auth")).toBe(false);
});
it("plans current Hermes OpenAI OAuth state for import with a cutover warning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
providers: {
"openai-codex": {
tokens: {
[hermesAccessField]: fakeJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct-hermes" },
"https://api.openai.com/profile": { email: "hermes@example.test" },
}),
[hermesRefreshField]: "placeholder",
},
},
},
}),
);
const plan = await buildHermesMigrationProvider().plan(
makeContext({ source, stateDir, workspaceDir, includeSecrets: true }),
);
expect(plan.items.find((item) => item.kind === "auth")).toEqual(
expect.objectContaining({
status: "planned",
sensitive: true,
details: expect.objectContaining({
provider: "openai",
sourceKind: "hermes-auth-json",
}),
}),
);
expect(plan.warnings).toContain(
"Some Hermes settings require manual review before they can be activated safely.",
"Hermes and OpenClaw must not keep using the same imported OpenAI OAuth refresh grant after migration; reauthenticate one side before running both.",
);
});
@@ -334,6 +705,6 @@ describe("Hermes migration file and skill items", () => {
makeContext({ source, stateDir, workspaceDir, includeSecrets: true }),
);
expect(plan.items.find((item) => item.id === "manual:legacy-hermes-auth-json")).toBeUndefined();
expect(plan.items.some((item) => item.kind === "auth")).toBe(false);
});
});
+17 -37
View File
@@ -2,8 +2,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { parse as parseDotenv } from "dotenv";
import {
markMigrationItemError,
markMigrationItemSkipped,
MIGRATION_REASON_MISSING_SOURCE_OR_TARGET,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
@@ -42,48 +44,17 @@ export async function readText(filePath: string | undefined): Promise<string | u
}
export function parseEnv(content: string | undefined): Record<string, string> {
const env: Record<string, string> = {};
if (!content) {
return env;
}
for (const line of content.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) {
continue;
}
const key = match[1];
const rawValue = match[2];
if (!key || rawValue === undefined) {
continue;
}
let value = rawValue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
return content ? parseDotenv(content) : {};
}
export function parseHermesConfig(content: string | undefined): Record<string, unknown> {
if (!content) {
return {};
}
try {
const parsed = parseYaml(content);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
const parsed = parseYaml(content);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
}
export const isRecord = sharedIsRecord;
@@ -112,10 +83,19 @@ export async function appendItem(item: MigrationItem): Promise<MigrationItem> {
try {
const content = await fs.readFile(item.source, "utf8");
const header = `\n\n<!-- Imported from Hermes: ${path.basename(item.source)} -->\n\n`;
const body = content.trimEnd();
if (!body) {
return markMigrationItemSkipped(item, "source file is empty");
}
const importBlock = `${header}${body}\n`;
const existing = await fs.readFile(item.target, "utf8").catch(() => "");
if (existing.includes(importBlock)) {
return markMigrationItemSkipped(item, "already imported from Hermes");
}
await fs.mkdir(path.dirname(item.target), { recursive: true });
await appendRegularFile({
filePath: item.target,
content: `${header}${content.trimEnd()}\n`,
content: importBlock,
rejectSymlinkParents: true,
});
return { ...item, status: "migrated" };
+26 -2
View File
@@ -6,10 +6,11 @@ import {
markMigrationItemError,
markMigrationItemSkipped,
} from "openclaw/plugin-sdk/migration";
import { readString } from "./helpers.js";
import { isRecord, readString } from "./helpers.js";
export const HERMES_REASON_ALREADY_CONFIGURED = "already configured";
export const HERMES_REASON_DEFAULT_MODEL_CONFIGURED = "default model already configured";
export const HERMES_REASON_MODEL_PROVIDER_CONFLICT = "model provider config conflict";
export const HERMES_REASON_INCLUDE_SECRETS = "auth credential migration not selected";
export const HERMES_REASON_AUTH_PROFILE_EXISTS = "auth profile exists";
export const HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable";
@@ -44,6 +45,25 @@ export function readHermesModelDetails(item: MigrationItem): { model: string } |
return model ? { model } : undefined;
}
export function findHermesModelProviderDependency(
items: MigrationItem[],
model: string,
): MigrationItem | undefined {
const separator = model.indexOf("/");
const provider = separator > 0 ? model.slice(0, separator) : "";
if (!provider) {
return undefined;
}
return items.find((item) => {
const value = item.details?.value;
return (
item.id.startsWith("config:model-provider:") &&
isRecord(value) &&
Object.hasOwn(value, provider)
);
});
}
export function createHermesSecretItem(params: {
id: string;
source?: string;
@@ -55,8 +75,9 @@ export function createHermesSecretItem(params: {
provider: string;
profileId: string;
mode?: "token";
sourceKind?: "hermes-env" | "opencode-auth-json";
sourceKind?: "hermes-auth-json" | "hermes-env" | "opencode-auth-json";
sourceProvider?: string;
sourceCredentialId?: string;
secretField?: string;
};
}): MigrationItem {
@@ -87,6 +108,7 @@ export function readHermesSecretDetails(item: MigrationItem):
mode?: "token";
sourceKind?: string;
sourceProvider?: string;
sourceCredentialId?: string;
secretField?: string;
}
| undefined {
@@ -99,6 +121,7 @@ export function readHermesSecretDetails(item: MigrationItem):
const mode = item.details?.mode === "token" ? "token" : undefined;
const sourceKind = readString(item.details?.sourceKind);
const sourceProvider = readString(item.details?.sourceProvider);
const sourceCredentialId = readString(item.details?.sourceCredentialId);
const secretField = readString(item.details?.secretField);
return {
...(envVar ? { envVar } : {}),
@@ -107,6 +130,7 @@ export function readHermesSecretDetails(item: MigrationItem):
...(mode ? { mode } : {}),
...(sourceKind ? { sourceKind } : {}),
...(sourceProvider ? { sourceProvider } : {}),
...(sourceCredentialId ? { sourceCredentialId } : {}),
...(secretField ? { secretField } : {}),
};
}
+56 -44
View File
@@ -2,7 +2,10 @@
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it } from "vitest";
import { HERMES_REASON_DEFAULT_MODEL_CONFIGURED } from "./items.js";
import {
HERMES_REASON_DEFAULT_MODEL_CONFIGURED,
HERMES_REASON_MODEL_PROVIDER_CONFLICT,
} from "./items.js";
import { buildHermesMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
@@ -12,29 +15,6 @@ import {
writeFile,
} from "./test/provider-helpers.js";
const HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict";
const openaiProviderPatchValue = {
openai: {
baseUrl: "",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
api: "openai-completions",
models: [
{
id: "gpt-5.4",
name: "gpt-5.4",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8192,
metadataSource: "models-add",
},
],
},
};
function defaultModelItem(status: "migrated" | "conflict") {
return {
id: "config:default-model",
@@ -47,23 +27,6 @@ function defaultModelItem(status: "migrated" | "conflict") {
};
}
function modelProvidersItem(status: "migrated" | "skipped") {
return {
id: "config:model-providers",
kind: "config",
action: "merge",
source: undefined,
target: "models.providers",
status,
...(status === "skipped" ? { reason: HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT } : {}),
message: "Import Hermes provider and custom endpoint config.",
details: {
path: ["models", "providers"],
value: openaiProviderPatchValue,
},
};
}
describe("Hermes migration model apply", () => {
afterEach(async () => {
await cleanupTempRoots();
@@ -109,7 +72,7 @@ describe("Hermes migration model apply", () => {
}),
);
expect(result.items).toEqual([defaultModelItem("migrated"), modelProvidersItem("migrated")]);
expect(result.items).toEqual([defaultModelItem("migrated")]);
expect(writtenConfig?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.4",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
@@ -166,7 +129,7 @@ describe("Hermes migration model apply", () => {
}),
);
expect(result.items).toEqual([defaultModelItem("migrated"), modelProvidersItem("migrated")]);
expect(result.items).toEqual([defaultModelItem("migrated")]);
expect(writtenConfig?.agents?.list?.[0]?.model).toEqual({
primary: "openai/gpt-5.4",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
@@ -200,8 +163,57 @@ describe("Hermes migration model apply", () => {
const result = await provider.apply(ctx, plan);
expect(result.items).toEqual([defaultModelItem("conflict"), modelProvidersItem("skipped")]);
expect(result.items).toEqual([defaultModelItem("conflict")]);
expect(result.summary.conflicts).toBe(1);
expect(lateConfig.agents?.defaults?.model).toBe("anthropic/claude-sonnet-4.6");
});
it("does not apply a custom default after its provider develops a late conflict", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const reportDir = path.join(root, "report");
await writeFile(
path.join(source, "config.yaml"),
[
"model:",
" provider: custom:acme",
" default: imported-model",
"providers:",
" acme:",
" base_url: https://new.example.test/v1",
" transport: openai_chat",
"",
].join("\n"),
);
const lateConfig = {
agents: { defaults: { workspace: workspaceDir } },
models: {
providers: {
acme: {
baseUrl: "https://old.example.test/v1",
api: "openai-completions",
models: [],
},
},
},
} as OpenClawConfig;
const provider = buildHermesMigrationProvider({ runtime: makeConfigRuntime(lateConfig) });
const ctx = makeContext({ source, stateDir, workspaceDir, reportDir });
const plan = await provider.plan(ctx);
const result = await provider.apply(ctx, plan);
expect(result.items.find((item) => item.id === "config:model-provider:acme")?.status).toBe(
"conflict",
);
expect(result.items.find((item) => item.id === "config:default-model")).toEqual(
expect.objectContaining({
status: "conflict",
reason: HERMES_REASON_MODEL_PROVIDER_CONFLICT,
}),
);
expect(lateConfig.agents?.defaults?.model).toBeUndefined();
});
});
+94 -40
View File
@@ -22,46 +22,6 @@ function expectedHermesModelPlanItems(params: {
model: "openai/gpt-5.4",
},
},
{
id: "config:model-providers",
kind: "config",
action: "merge",
target: "models.providers",
status: "planned",
message: "Import Hermes provider and custom endpoint config.",
details: {
path: ["models", "providers"],
value: {
openai: {
baseUrl: "",
apiKey: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
api: "openai-completions",
models: [
{
id: "gpt-5.4",
name: "gpt-5.4",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128_000,
maxTokens: 8192,
metadataSource: "models-add",
},
],
},
},
},
},
];
}
@@ -83,6 +43,100 @@ describe("Hermes migration model planning", () => {
expect(plan.items).toEqual(expectedHermesModelPlanItems({ modelStatus: "planned" }));
});
it("preserves provider routing for vendor-qualified models and normalizes aliases", async () => {
const root = await makeTempRoot();
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const provider = buildHermesMigrationProvider();
const cases = [
["openrouter", "anthropic/claude-opus-4.7", "openrouter/anthropic/claude-opus-4.7"],
["custom:local", "vendor/model", "local/vendor/model"],
["openai-codex", "gpt-5.6", "openai/gpt-5.6"],
["azure-foundry", "gpt-5.4", "microsoft-foundry/gpt-5.4"],
["bedrock", "anthropic.claude-opus-4-6-v1", "amazon-bedrock/anthropic.claude-opus-4-6-v1"],
["copilot", "gpt-5.4", "github-copilot/gpt-5.4"],
["gemini", "gemini-3.1-pro", "google/gemini-3.1-pro"],
["glm", "glm-5.2", "zai/glm-5.2"],
["kimi-for-coding", "kimi-k2.5", "moonshot/kimi-k2.5"],
["kimi-coding", "kimi-k2.5", "moonshot/kimi-k2.5"],
["kimi", "kimi-for-coding/kimi-k2.5", "moonshot/kimi-k2.5"],
["moonshot", "kimi-k2.5", "moonshot/kimi-k2.5"],
["alibaba", "qwen3.5-plus", "qwen/qwen3.5-plus"],
["dashscope", "qwen3.6-plus", "qwen/qwen3.6-plus"],
["alibaba-coding-plan", "qwen3-coder-next", "qwen/qwen3-coder-next"],
["xai-oauth", "grok-4.1-fast", "xai/grok-4.1-fast"],
["minimax-oauth", "MiniMax-M2.7", "minimax-portal/MiniMax-M2.7"],
["minimax-cn", "MiniMax-M2.7", "minimax/MiniMax-M2.7"],
["opencode-zen", "gpt-5.4", "opencode/gpt-5.4"],
["auto", "anthropic/claude-opus-4.6", "anthropic/claude-opus-4.6"],
["qwen-cli", "qwen3.5-plus", "qwen-oauth/qwen3.5-plus"],
["vertex", "gemini-3.1-pro", "google-vertex/gemini-3.1-pro"],
["custom:My Local LLM", "local-model", "my-local-llm/local-model"],
] as const;
for (const [hermesProvider, model, expected] of cases) {
const source = path.join(root, hermesProvider.replaceAll(":", "-"));
await writeFile(
path.join(source, "config.yaml"),
`model:\n provider: ${hermesProvider}\n default: ${model}\n`,
);
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir }));
expect(plan.items[0]?.details?.model).toBe(expected);
expect(plan.items.some((item) => item.id.startsWith("config:model-provider:"))).toBe(
["alibaba", "dashscope", "minimax-cn"].includes(hermesProvider),
);
if (hermesProvider === "qwen-cli") {
expect(
plan.items.some(
(item) => item.kind === "manual" && item.message?.includes("Qwen OAuth") === true,
),
).toBe(true);
}
}
});
it.each([
["sk-kimi-placeholder", "kimi/kimi-k2.5"],
["legacy-moonshot-placeholder", "moonshot/kimi-k2.5"],
])("routes kimi-coding from the effective key contract", async (apiKey, expectedModel) => {
const root = await makeTempRoot();
const source = path.join(root, expectedModel.split("/")[0]!);
await writeFile(
path.join(source, "config.yaml"),
"model:\n provider: kimi-coding\n default: kimi-k2.5\n",
);
await writeFile(path.join(source, ".env"), `KIMI_API_KEY=${apiKey}\n`);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(plan.items[0]?.details?.model).toBe(expectedModel);
});
it("routes a model-scoped custom endpoint without an explicit provider", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
await writeFile(
path.join(source, "config.yaml"),
"model:\n default: vendor/current-model\n base_url: https://models.example/v1\n",
);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(plan.items.find((item) => item.id === "config:default-model")?.details?.model).toBe(
"custom/vendor/current-model",
);
});
it("treats existing object-form default model primaries as conflicts", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
+278 -14
View File
@@ -16,31 +16,295 @@ import {
readHermesModelDetails,
} from "./items.js";
export function resolveHermesModelRef(config: Record<string, unknown>): string | undefined {
const HERMES_PROVIDER_ALIASES: Record<string, string> = {
alibaba: "qwen",
"alibaba-cloud": "qwen",
"alibaba-coding": "qwen",
"alibaba-coding-plan": "qwen",
alibaba_coding: "qwen",
alibaba_coding_plan: "qwen",
aliyun: "qwen",
"azure-foundry": "microsoft-foundry",
bedrock: "amazon-bedrock",
claude: "anthropic",
"claude-code": "anthropic",
copilot: "github-copilot",
gemini: "google",
github: "github-copilot",
"github-copilot": "github-copilot",
"github-model": "github-copilot",
"github-models": "github-copilot",
glm: "zai",
google: "google",
"google-ai-studio": "google",
"google-gemini": "google",
grok: "xai",
kilo: "kilocode",
"kilo-code": "kilocode",
"kilo-gateway": "kilocode",
kimi: "kimi",
"kimi-cn": "moonshot",
"kimi-for-coding": "kimi",
"kimi-coding": "kimi",
"kimi-coding-cn": "moonshot",
"moonshot-cn": "moonshot",
moonshot: "moonshot",
"minimax-global": "minimax-portal",
"minimax-cn": "minimax",
"minimax-oauth": "minimax-portal",
"minimax-portal": "minimax-portal",
minimax_oauth: "minimax-portal",
"opencode-zen": "opencode",
"openai-api": "openai",
"openai-codex": "openai",
dashscope: "qwen",
qwen: "qwen",
"qwen-cli": "qwen-oauth",
"qwen-oauth": "qwen-oauth",
"qwen-portal": "qwen-oauth",
"x-ai": "xai",
"x-ai-oauth": "xai",
"x.ai": "xai",
"xai-grok-oauth": "xai",
"xai-oauth": "xai",
"grok-oauth": "xai",
"z-ai": "zai",
"z.ai": "zai",
zen: "opencode",
zhipu: "zai",
vertex: "google-vertex",
};
const HERMES_CANONICAL_PROVIDER_IDS = new Set([
"alibaba",
"alibaba-coding-plan",
"anthropic",
"arcee",
"azure-foundry",
"bedrock",
"copilot",
"copilot-acp",
"deepseek",
"fireworks",
"github-copilot",
"gemini",
"gmi",
"huggingface",
"kilo",
"kilocode",
"kimi-coding",
"kimi-coding-cn",
"kimi-for-coding",
"lmstudio",
"minimax",
"minimax-cn",
"minimax-oauth",
"moa",
"nous",
"novita",
"nvidia",
"ollama-cloud",
"openai-api",
"openai-codex",
"opencode",
"opencode-go",
"opencode-zen",
"openrouter",
"qwen-oauth",
"stepfun",
"tencent-tokenhub",
"xai",
"xai-oauth",
"xiaomi",
"zai",
"vertex",
]);
const HERMES_DYNAMIC_KIMI_PROVIDER_IDS = new Set([
"kimi",
"kimi-coding",
"kimi-for-coding",
"moonshot",
]);
export function normalizeHermesProviderId(provider: string): string {
const normalized = normalizeHermesCustomProviderId(provider);
return HERMES_PROVIDER_ALIASES[normalized] ?? normalized;
}
export function normalizeHermesCustomProviderId(provider: string): string {
const normalized = provider.trim().toLowerCase();
const withoutCustomPrefix = normalized.startsWith("custom:")
? normalized.slice("custom:".length)
: normalized;
return withoutCustomPrefix.replaceAll(" ", "-");
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readBaseUrl(value: Record<string, unknown> | undefined): string | undefined {
return value
? (readString(value.base_url) ??
readString(value.baseUrl) ??
readString(value.url) ??
readString(value.api))
: undefined;
}
function readKimiBaseUrl(
config: Record<string, unknown>,
provider: string,
env: Record<string, string>,
): string | undefined {
const model = asRecord(config.model);
const selectedProvider = readString(model?.provider) ?? readString(config.provider);
if (
selectedProvider &&
HERMES_DYNAMIC_KIMI_PROVIDER_IDS.has(normalizeHermesCustomProviderId(selectedProvider))
) {
const modelBaseUrl = readBaseUrl(model);
if (modelBaseUrl) {
return modelBaseUrl;
}
}
const providers = asRecord(config.providers);
for (const [id, value] of Object.entries(providers ?? {})) {
if (
normalizeHermesCustomProviderId(id) === normalizeHermesCustomProviderId(provider) &&
asRecord(value)
) {
const providerBaseUrl = readBaseUrl(asRecord(value));
if (providerBaseUrl) {
return providerBaseUrl;
}
}
}
return env.KIMI_BASE_URL?.trim() || undefined;
}
function resolveHermesKimiProviderId(
config: Record<string, unknown>,
provider: string,
env: Record<string, string>,
): "kimi" | "moonshot" | undefined {
const sourceProvider = normalizeHermesCustomProviderId(provider);
if (!HERMES_DYNAMIC_KIMI_PROVIDER_IDS.has(sourceProvider)) {
return undefined;
}
const baseUrl = readKimiBaseUrl(config, sourceProvider, env);
if (baseUrl) {
try {
const parsed = new URL(baseUrl);
const hostname = parsed.hostname.toLowerCase();
const pathname = parsed.pathname.toLowerCase().replace(/\/+$/u, "");
if (hostname === "api.kimi.com" && (pathname === "/coding" || pathname === "/coding/v1")) {
return "kimi";
}
if (hostname === "api.moonshot.ai") {
return "moonshot";
}
} catch {
// Preserve the named route for custom or templated endpoints.
}
return normalizeHermesProviderId(sourceProvider) === "moonshot" ? "moonshot" : "kimi";
}
const apiKey = env.KIMI_API_KEY?.trim() || env.KIMI_CODING_API_KEY?.trim();
// Hermes defaults to Moonshot; only Kimi Code keys select its Anthropic endpoint.
return apiKey?.startsWith("sk-kimi-") ? "kimi" : "moonshot";
}
function hasExplicitHermesProvider(config: Record<string, unknown>, provider: string): boolean {
const normalized = normalizeHermesCustomProviderId(provider);
if (HERMES_CANONICAL_PROVIDER_IDS.has(normalized)) {
return false;
}
const providers = config.providers;
if (
providers &&
typeof providers === "object" &&
!Array.isArray(providers) &&
Object.keys(providers).some((id) => normalizeHermesCustomProviderId(id) === normalized)
) {
return true;
}
return (
Array.isArray(config.custom_providers) &&
config.custom_providers.some((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
const record = entry as Record<string, unknown>;
const id = readString(record.name) ?? readString(record.id);
return id ? normalizeHermesCustomProviderId(id) === normalized : false;
})
);
}
export function resolveHermesConfiguredProviderId(
config: Record<string, unknown>,
provider: string,
env: Record<string, string> = {},
): string {
if (hasExplicitHermesProvider(config, provider)) {
return normalizeHermesCustomProviderId(provider);
}
return resolveHermesKimiProviderId(config, provider, env) ?? normalizeHermesProviderId(provider);
}
function joinHermesProviderModel(
config: Record<string, unknown>,
provider: string | undefined,
model: string,
env: Record<string, string>,
): string {
if (!provider) {
return model;
}
if (provider.trim().toLowerCase() === "auto") {
const slash = model.indexOf("/");
return slash > 0
? `${resolveHermesConfiguredProviderId(config, model.slice(0, slash), env)}/${model.slice(slash + 1)}`
: model;
}
const explicitProvider = hasExplicitHermesProvider(config, provider);
const normalizedProvider = resolveHermesConfiguredProviderId(config, provider, env);
const slash = model.indexOf("/");
if (slash > 0) {
const normalizedModelProvider = explicitProvider
? normalizeHermesCustomProviderId(model.slice(0, slash))
: resolveHermesConfiguredProviderId(config, model.slice(0, slash), env);
if (normalizedModelProvider === normalizedProvider) {
return `${normalizedProvider}/${model.slice(slash + 1)}`;
}
}
return model.startsWith(`${normalizedProvider}/`) ? model : `${normalizedProvider}/${model}`;
}
export function resolveHermesModelRef(
config: Record<string, unknown>,
env: Record<string, string> = {},
): string | undefined {
const model = config.model;
if (typeof model === "string" && model.trim()) {
const rawModel = model.trim();
const provider = readString(config.provider);
if (provider && !rawModel.includes("/")) {
return `${provider}/${rawModel}`;
}
return rawModel;
return joinHermesProviderModel(config, provider, rawModel, env);
}
if (model && typeof model === "object" && !Array.isArray(model)) {
const modelRecord = model as Record<string, unknown>;
const rawModel = readString(modelRecord.default) ?? readString(modelRecord.model);
const provider = readString(modelRecord.provider);
if (rawModel && provider && !rawModel.includes("/")) {
return `${provider}/${rawModel}`;
}
return rawModel;
const hasCustomEndpoint = Boolean(
readString(modelRecord.base_url) ?? readString(modelRecord.baseUrl),
);
const provider = readString(modelRecord.provider) ?? (hasCustomEndpoint ? "custom" : undefined);
return rawModel ? joinHermesProviderModel(config, provider, rawModel, env) : undefined;
}
const rootModel = readString(config.default_model) ?? readString(config.model_name);
const rootProvider = readString(config.provider);
if (rootModel && rootProvider && !rootModel.includes("/")) {
return `${rootProvider}/${rootModel}`;
}
return rootModel;
return rootModel ? joinHermesProviderModel(config, rootProvider, rootModel, env) : undefined;
}
function resolveDefaultAgentModelState(config: MigrationProviderContext["config"]): {
+1
View File
@@ -5,6 +5,7 @@
"description": "Hermes to OpenClaw migration provider",
"type": "module",
"dependencies": {
"dotenv": "17.4.2",
"yaml": "2.9.0"
},
"devDependencies": {
+63 -13
View File
@@ -2,6 +2,8 @@
import path from "node:path";
import {
createMigrationItem,
createMigrationManualItem,
markMigrationItemConflict,
MIGRATION_REASON_TARGET_EXISTS,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
@@ -12,8 +14,12 @@ import type {
} from "openclaw/plugin-sdk/plugin-entry";
import { buildAuthItems } from "./auth.js";
import { buildConfigItems } from "./config.js";
import { exists, parseHermesConfig, readText } from "./helpers.js";
import { createHermesModelItem } from "./items.js";
import { exists, parseEnv, parseHermesConfig, readText } from "./helpers.js";
import {
createHermesModelItem,
findHermesModelProviderDependency,
HERMES_REASON_MODEL_PROVIDER_CONFLICT,
} from "./items.js";
import { resolveCurrentModelRef, resolveHermesModelRef } from "./model.js";
import { buildSecretItems } from "./secrets.js";
import { buildSkillItems } from "./skills.js";
@@ -54,12 +60,28 @@ export async function buildHermesPlan(ctx: MigrationProviderContext): Promise<Mi
);
}
const targets = resolveTargets(ctx);
const config = parseHermesConfig(await readText(source.configPath));
const modelRef = resolveHermesModelRef(config);
let config: Record<string, unknown>;
try {
config = parseHermesConfig(await readText(source.configPath));
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse Hermes config at ${source.configPath}: ${reason}`, {
cause: err,
});
}
const env = parseEnv(await readText(source.envPath));
const modelRef = resolveHermesModelRef(config, env);
const runtimeEnv = Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const items: MigrationItem[] = [];
let modelItemIndex: number | undefined;
if (modelRef) {
const currentModel = resolveCurrentModelRef(ctx);
modelItemIndex = items.length;
items.push(
createHermesModelItem({
model: modelRef,
@@ -67,15 +89,36 @@ export async function buildHermesPlan(ctx: MigrationProviderContext): Promise<Mi
overwrite: ctx.overwrite,
}),
);
if (modelRef.startsWith("qwen-oauth/")) {
items.push(
createMigrationManualItem({
id: "manual:auth-reauthenticate:qwen-oauth",
source: source.configPath ?? source.root,
message: "Hermes Qwen OAuth uses the external Qwen CLI credential store.",
recommendation: "Authenticate qwen-oauth in OpenClaw after migration.",
}),
);
}
}
items.push(
...buildConfigItems({
ctx,
config,
modelRef,
hasMemoryFiles: Boolean(source.memoryPath || source.userPath),
}),
);
const configItems = buildConfigItems({
ctx,
config,
env,
runtimeEnv,
modelRef,
hasMemoryFiles: Boolean(source.memoryPath || source.userPath),
});
if (modelRef && modelItemIndex !== undefined) {
const modelItem = items[modelItemIndex];
const dependency = findHermesModelProviderDependency(configItems, modelRef);
if (modelItem?.status === "planned" && dependency?.status === "conflict") {
items[modelItemIndex] = markMigrationItemConflict(
modelItem,
HERMES_REASON_MODEL_PROVIDER_CONFLICT,
);
}
}
items.push(...configItems);
await addFileItem({
items,
@@ -117,7 +160,7 @@ export async function buildHermesPlan(ctx: MigrationProviderContext): Promise<Mi
}
items.push(...(await buildSkillItems({ source, targets, overwrite: ctx.overwrite })));
items.push(...(await buildAuthItems({ ctx, source, targets })));
items.push(...(await buildSecretItems({ ctx, source, targets })));
items.push(...(await buildSecretItems({ config, ctx, source, targets })));
for (const archivePath of source.archivePaths) {
items.push(
createMigrationItem({
@@ -138,6 +181,13 @@ export async function buildHermesPlan(ctx: MigrationProviderContext): Promise<Mi
"Auth credentials were detected but skipped. Re-run interactively or pass --include-secrets to import supported credentials.",
]
: []),
...(items.some(
(item) => item.kind === "auth" && item.details?.sourceKind === "hermes-auth-json",
)
? [
"Hermes and OpenClaw must not keep using the same imported OpenAI OAuth refresh grant after migration; reauthenticate one side before running both.",
]
: []),
...(items.some((item) => item.status === "conflict")
? [
"Conflicts were found. Re-run with --overwrite to replace conflicting targets after item-level backups.",
+2 -2
View File
@@ -121,13 +121,13 @@ describe("Hermes migration provider", () => {
}),
);
expect(plan.summary.total).toBe(8);
expect(plan.summary.total).toBe(7);
expect(plan.summary.conflicts).toBe(2);
expect(plan.summary.sensitive).toBe(1);
expect(itemById(plan.items, "config:default-model")?.status).toBe("conflict");
expect(itemById(plan.items, "config:memory")?.status).toBe("planned");
expect(itemById(plan.items, "config:memory-plugin-slot")?.status).toBe("planned");
expect(itemById(plan.items, "config:model-providers")?.status).toBe("planned");
expect(plan.items.some((item) => item.id.startsWith("config:model-provider:"))).toBe(false);
expect(itemById(plan.items, "workspace:SOUL.md")?.status).toBe("conflict");
const memory = itemById(plan.items, "memory:MEMORY.md");
expect(memory?.action).toBe("append");
@@ -0,0 +1,108 @@
// Hermes environment credential catalog.
export type SecretCredentialMode = "api_key" | "token";
export type SecretMapping = {
envVar: string;
provider: string;
profileId: string;
mode?: SecretCredentialMode;
};
export const SECRET_MAPPINGS: readonly SecretMapping[] = [
{ envVar: "OPENAI_API_KEY", provider: "openai", profileId: "openai:hermes-import" },
{ envVar: "ANTHROPIC_API_KEY", provider: "anthropic", profileId: "anthropic:hermes-import" },
{ envVar: "OPENROUTER_API_KEY", provider: "openrouter", profileId: "openrouter:hermes-import" },
{ envVar: "GOOGLE_API_KEY", provider: "google", profileId: "google:hermes-import" },
{ envVar: "GEMINI_API_KEY", provider: "google", profileId: "google:hermes-import" },
{ envVar: "GROQ_API_KEY", provider: "groq", profileId: "groq:hermes-import" },
{ envVar: "XAI_API_KEY", provider: "xai", profileId: "xai:hermes-import" },
{ envVar: "MISTRAL_API_KEY", provider: "mistral", profileId: "mistral:hermes-import" },
{ envVar: "DEEPSEEK_API_KEY", provider: "deepseek", profileId: "deepseek:hermes-import" },
{ envVar: "ZAI_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "Z_AI_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "GLM_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "KIMI_API_KEY", provider: "kimi", profileId: "kimi:hermes-import" },
{ envVar: "KIMICODE_API_KEY", provider: "kimi", profileId: "kimi:hermes-import" },
{
envVar: "KIMI_CODING_API_KEY",
provider: "kimi",
profileId: "kimi:hermes-import",
},
{ envVar: "MOONSHOT_API_KEY", provider: "moonshot", profileId: "moonshot:hermes-import" },
{ envVar: "KIMI_CN_API_KEY", provider: "moonshot", profileId: "moonshot:hermes-import" },
{ envVar: "MINIMAX_API_KEY", provider: "minimax", profileId: "minimax:hermes-import" },
{ envVar: "MINIMAX_CN_API_KEY", provider: "minimax", profileId: "minimax:hermes-import" },
{
envVar: "MINIMAX_CODING_API_KEY",
provider: "minimax",
profileId: "minimax:hermes-import",
},
{ envVar: "DASHSCOPE_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "QWEN_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "MODELSTUDIO_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "KILOCODE_API_KEY", provider: "kilocode", profileId: "kilocode:hermes-import" },
{
envVar: "AI_GATEWAY_API_KEY",
provider: "vercel-ai-gateway",
profileId: "vercel-ai-gateway:hermes-import",
},
{ envVar: "HF_TOKEN", provider: "huggingface", profileId: "huggingface:hermes-import" },
{
envVar: "HUGGINGFACE_HUB_TOKEN",
provider: "huggingface",
profileId: "huggingface:hermes-import",
},
{ envVar: "TOGETHER_API_KEY", provider: "together", profileId: "together:hermes-import" },
{ envVar: "FIREWORKS_API_KEY", provider: "fireworks", profileId: "fireworks:hermes-import" },
{ envVar: "DEEPINFRA_API_KEY", provider: "deepinfra", profileId: "deepinfra:hermes-import" },
{ envVar: "CEREBRAS_API_KEY", provider: "cerebras", profileId: "cerebras:hermes-import" },
{ envVar: "NVIDIA_API_KEY", provider: "nvidia", profileId: "nvidia:hermes-import" },
{ envVar: "VENICE_API_KEY", provider: "venice", profileId: "venice:hermes-import" },
{ envVar: "XIAOMI_API_KEY", provider: "xiaomi", profileId: "xiaomi:hermes-import" },
{ envVar: "ALIBABA_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{
envVar: "ALIBABA_CODING_PLAN_API_KEY",
provider: "qwen",
profileId: "qwen:hermes-import",
},
{ envVar: "ARCEEAI_API_KEY", provider: "arcee", profileId: "arcee:hermes-import" },
{ envVar: "CHUTES_API_KEY", provider: "chutes", profileId: "chutes:hermes-import" },
{
envVar: "CLOUDFLARE_AI_GATEWAY_API_KEY",
provider: "cloudflare-ai-gateway",
profileId: "cloudflare-ai-gateway:hermes-import",
},
{ envVar: "QIANFAN_API_KEY", provider: "qianfan", profileId: "qianfan:hermes-import" },
{ envVar: "OPENCODE_API_KEY", provider: "opencode", profileId: "opencode:hermes-import" },
{ envVar: "OPENCODE_API_KEY", provider: "opencode-go", profileId: "opencode-go:hermes-import" },
{ envVar: "OPENCODE_ZEN_API_KEY", provider: "opencode", profileId: "opencode:hermes-import" },
{
envVar: "OPENCODE_ZEN_API_KEY",
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
},
{
envVar: "OPENCODE_GO_API_KEY",
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
},
{
envVar: "COPILOT_GITHUB_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
{
envVar: "GH_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
{
envVar: "GITHUB_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
] as const;
+511 -1
View File
@@ -9,7 +9,7 @@ import {
} from "openclaw/plugin-sdk/agent-runtime";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
HERMES_REASON_AUTH_PROFILE_EXISTS,
HERMES_REASON_SECRET_NO_LONGER_PRESENT,
@@ -54,8 +54,12 @@ function fakeJwt(payload: Record<string, unknown>): string {
return `${header}.${body}.signature`;
}
const HERMES_ACCESS_FIELD = ["access", "token"].join("_");
const HERMES_REFRESH_FIELD = ["refresh", "token"].join("_");
describe("Hermes migration secret items", () => {
afterEach(async () => {
vi.unstubAllEnvs();
await cleanupTempRoots();
});
@@ -133,6 +137,512 @@ describe("Hermes migration secret items", () => {
await expectMissingPath(path.join(stateDir, "agents", "custom", "agent", "auth-profiles.json"));
});
it("parses current Hermes dotenv syntax and legacy Kimi credentials", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const kimiEnv = ["KIMI", "CODING", "API", "KEY"].join("_");
const openaiEnv = ["OPENAI", "API", "KEY"].join("_");
await writeFile(
path.join(source, ".env"),
`\uFEFFexport ${kimiEnv} = placeholder\nexport ${openaiEnv}='redacted'\n`,
);
const plan = await buildHermesMigrationProvider().plan(
makeContext({ source, stateDir, workspaceDir, includeSecrets: true }),
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "secret",
details: expect.objectContaining({ provider: "moonshot" }),
}),
expect.objectContaining({
kind: "secret",
details: expect.objectContaining({ provider: "openai" }),
}),
]),
);
});
it("imports the current Hermes MiniMax China credential", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const envVar = ["MINIMAX", "CN", "API", "KEY"].join("_");
await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
includeSecrets: true,
}),
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "secret",
details: expect.objectContaining({ envVar, provider: "minimax" }),
}),
]),
);
});
it("imports the selected provider credential without an endpoint override", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const envVar = ["STEPFUN", "API", "KEY"].join("_");
await writeFile(
path.join(source, "config.yaml"),
"model:\n provider: stepfun\n default: step-3.5-flash\n",
);
await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
includeSecrets: true,
}),
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "secret",
details: expect.objectContaining({ envVar, provider: "stepfun" }),
}),
]),
);
});
it("keeps legacy Moonshot model routing and credentials aligned", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const envVar = ["MOONSHOT", "API", "KEY"].join("_");
await writeFile(
path.join(source, "config.yaml"),
"model:\n provider: moonshot\n default: kimi-k2.5\n",
);
await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
includeSecrets: true,
}),
);
expect(plan.items.find((item) => item.id === "config:default-model")?.details?.model).toBe(
"moonshot/kimi-k2.5",
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "secret",
details: expect.objectContaining({ envVar, provider: "moonshot" }),
}),
]),
);
});
it.each([
["sk-kimi-placeholder", "kimi"],
["legacy-moonshot-placeholder", "moonshot"],
])("aligns KIMI_API_KEY with its effective %s route", async (apiKey, expectedProvider) => {
const root = await makeTempRoot();
const source = path.join(root, expectedProvider);
const envVar = ["KIMI", "API", "KEY"].join("_");
await writeFile(
path.join(source, "config.yaml"),
"model:\n provider: kimi-coding\n default: kimi-k2.5\n",
);
await writeFile(path.join(source, ".env"), `${envVar}=${apiKey}\n`);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
includeSecrets: true,
}),
);
expect(plan.items.find((item) => item.id === "config:default-model")?.details?.model).toBe(
`${expectedProvider}/kimi-k2.5`,
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "secret",
details: expect.objectContaining({ envVar, provider: expectedProvider }),
}),
]),
);
});
it("imports a configured provider key_env as matching OpenClaw provider auth", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const value = ["custom", "provider", "placeholder"].join("-");
const envVar = ["ACME", "TOKEN"].join("_");
await writeFile(
path.join(source, "config.yaml"),
[
"model:",
" provider: acme",
" default: acme-chat",
"providers:",
" acme:",
" api: https://api.acme.example/v1",
` key_env: ${envVar}`,
" models: [acme-chat]",
"",
].join("\n"),
);
await writeFile(path.join(source, ".env"), `${envVar}=${value}\n`);
const config = { agents: { defaults: { workspace: workspaceDir } } } as OpenClawConfig;
const runtime = makeConfigRuntime(config);
const result = await buildHermesMigrationProvider({ runtime }).apply(
makeContext({
source,
stateDir,
workspaceDir,
config,
runtime,
includeSecrets: true,
overwrite: true,
}),
);
expect(result.summary.errors).toBe(0);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
details: expect.objectContaining({ envVar, provider: "acme" }),
status: "migrated",
}),
]),
);
const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent"));
const profile = store.profiles["acme:hermes-import"];
expect(profile).toEqual(expect.objectContaining({ provider: "acme", type: "api_key" }));
if (!profile || profile.type !== "api_key") {
throw new Error("expected imported API key profile");
}
expect(profile.key).toBe(value);
expect(config.models?.providers?.acme?.apiKey).toBeUndefined();
expect(config.auth?.profiles?.["acme:hermes-import"]).toEqual(
expect.objectContaining({ mode: "api_key", provider: "acme" }),
);
});
it("binds the host-gated OpenAI key fallback to a model-scoped endpoint", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const envVar = ["OPENAI", "API", "KEY"].join("_");
await writeFile(
path.join(source, "config.yaml"),
[
"model:",
" provider: custom",
" default: gpt-5.6",
" base_url: https://api.openai.com/v1",
"",
].join("\n"),
);
await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
includeSecrets: true,
}),
);
const secretItems = plan.items.filter((item) => item.kind === "secret");
expect(secretItems).toHaveLength(1);
expect(secretItems[0]?.details).toEqual(
expect.objectContaining({ envVar, provider: "custom" }),
);
});
it("keeps an env-backed custom endpoint and its OpenAI key on one provider", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const keyEnv = ["OPENAI", "API", "KEY"].join("_");
const baseUrlEnv = ["OPENAI", "BASE", "URL"].join("_");
await writeFile(
path.join(source, "config.yaml"),
["model:", " provider: custom", " default: private-model", ""].join("\n"),
);
await writeFile(
path.join(source, ".env"),
`${keyEnv}=placeholder\n${baseUrlEnv}=https://private.example.test/v1\n`,
);
const plan = await buildHermesMigrationProvider().plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
includeSecrets: true,
}),
);
const providers = Object.assign(
{},
...plan.items
.filter((item) => item.id.startsWith("config:model-provider:"))
.map((item) => item.details?.value),
) as Record<string, { baseUrl?: string }>;
expect(providers?.custom?.baseUrl).toBe("https://private.example.test/v1");
const secretItems = plan.items.filter((item) => item.kind === "secret");
expect(secretItems).toHaveLength(1);
expect(secretItems[0]?.details).toEqual(
expect.objectContaining({ envVar: keyEnv, provider: "custom" }),
);
});
it("imports current Hermes singleton and pooled OpenAI OAuth accounts", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const config = { agents: { defaults: { workspace: workspaceDir } } } as OpenClawConfig;
const accountOne = fakeJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct_one" },
"https://api.openai.com/profile": { email: "one@example.test" },
});
const accountTwo = fakeJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct_two" },
"https://api.openai.com/profile": { email: "two@example.test" },
});
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
providers: {
"openai-codex": {
tokens: {
[HERMES_ACCESS_FIELD]: accountOne,
[HERMES_REFRESH_FIELD]: "refresh-one",
},
last_refresh: "2026-07-13T10:00:00Z",
},
},
credential_pool: {
"openai-codex": [
{
[HERMES_ACCESS_FIELD]: accountOne,
[HERMES_REFRESH_FIELD]: "refresh-one",
last_refresh: "2026-07-13T09:00:00Z",
},
{
[HERMES_ACCESS_FIELD]: accountTwo,
[HERMES_REFRESH_FIELD]: "refresh-two",
last_refresh: "2026-07-13T08:00:00Z",
},
],
},
}),
);
const runtime = makeConfigRuntime(config);
const provider = buildHermesMigrationProvider({ runtime });
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
config,
runtime,
includeSecrets: true,
overwrite: true,
}),
);
const authItems = result.items.filter((item) => item.kind === "auth");
expect(authItems).toHaveLength(2);
expect(authItems.every((item) => item.status === "migrated")).toBe(true);
const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent"));
expect(store.profiles["openai:account-acct_one"]).toEqual(
expect.objectContaining({ provider: "openai", refresh: "refresh-one" }),
);
expect(store.profiles["openai:account-acct_two"]).toEqual(
expect.objectContaining({ provider: "openai", refresh: "refresh-two" }),
);
});
it("imports manual Hermes API-key pool entries and skips borrowed references", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const firstValue = "openrouter-one";
const secondValue = "openrouter-two";
const borrowedValue = "borrowed-value";
const geminiValue = "gemini-value";
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
credential_pool: {
openrouter: [
{
id: "key-one",
auth_type: "api_key",
source: "manual",
[HERMES_ACCESS_FIELD]: firstValue,
},
{
id: "key-two",
auth_type: "api_key",
source: "manual",
[HERMES_ACCESS_FIELD]: secondValue,
},
{
id: "borrowed",
auth_type: "api_key",
source: "env:OPENROUTER_API_KEY",
[HERMES_ACCESS_FIELD]: borrowedValue,
},
],
gemini: [
{
id: "google-key",
auth_type: "api_key",
source: "manual",
[HERMES_ACCESS_FIELD]: geminiValue,
},
],
},
}),
);
const config = { agents: { defaults: { workspace: workspaceDir } } } as OpenClawConfig;
const runtime = makeConfigRuntime(config);
const result = await buildHermesMigrationProvider({ runtime }).apply(
makeContext({
source,
stateDir,
workspaceDir,
config,
runtime,
includeSecrets: true,
overwrite: true,
}),
);
const secretItems = result.items.filter(
(item) => item.kind === "secret" && item.details?.sourceKind === "hermes-auth-json",
);
expect(secretItems).toHaveLength(3);
const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent"));
expect(store.profiles["openrouter:hermes-key-one"]).toEqual(
expect.objectContaining({ type: "api_key", key: firstValue }),
);
expect(store.profiles["openrouter:hermes-key-two"]).toEqual(
expect.objectContaining({ type: "api_key", key: secondValue }),
);
expect(store.profiles["openrouter:hermes-borrowed"]).toBeUndefined();
expect(store.profiles["google:hermes-google-key"]).toEqual(
expect.objectContaining({ type: "api_key", key: geminiValue }),
);
});
it("uses per-provider global API-key pool fallback for an active profile", async () => {
const root = await makeTempRoot();
const hermesRoot = path.join(root, ".hermes");
const source = path.join(hermesRoot, "profiles", "coder");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const globalOpenRouterValue = ["global", "openrouter", "placeholder"].join("-");
const globalGeminiValue = ["global", "gemini", "placeholder"].join("-");
const profileOpenRouterValue = ["profile", "openrouter", "placeholder"].join("-");
await writeFile(path.join(hermesRoot, "active_profile"), "coder\n");
await writeFile(path.join(source, "config.yaml"), "{}\n");
await writeFile(
path.join(hermesRoot, "auth.json"),
JSON.stringify({
credential_pool: {
openrouter: [
{
id: "global-openrouter",
auth_type: "api_key",
source: "manual",
[HERMES_ACCESS_FIELD]: globalOpenRouterValue,
},
],
gemini: [
{
id: "global-gemini",
auth_type: "api_key",
source: "manual",
[HERMES_ACCESS_FIELD]: globalGeminiValue,
},
],
},
}),
);
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
credential_pool: {
openrouter: [
{
id: "profile-openrouter",
auth_type: "api_key",
source: "manual",
[HERMES_ACCESS_FIELD]: profileOpenRouterValue,
},
],
},
}),
);
vi.stubEnv("HOME", root);
vi.stubEnv("HERMES_HOME", "");
const config = { agents: { defaults: { workspace: workspaceDir } } } as OpenClawConfig;
const runtime = makeConfigRuntime(config);
const result = await buildHermesMigrationProvider({ runtime }).apply(
makeContext({
source: "",
stateDir,
workspaceDir,
config,
runtime,
includeSecrets: true,
overwrite: true,
}),
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
source: path.join(source, "auth.json"),
details: expect.objectContaining({ provider: "openrouter" }),
}),
expect.objectContaining({
source: path.join(hermesRoot, "auth.json"),
details: expect.objectContaining({ provider: "google" }),
}),
]),
);
const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent"));
expect(store.profiles["openrouter:hermes-profile-openrouter"]).toEqual(
expect.objectContaining({ key: profileOpenRouterValue }),
);
expect(store.profiles["openrouter:hermes-global-openrouter"]).toBeUndefined();
expect(store.profiles["google:hermes-global-gemini"]).toEqual(
expect.objectContaining({ key: globalGeminiValue }),
);
});
it("reports API key import when config update fails after profile write", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
+136 -108
View File
@@ -11,7 +11,8 @@ import {
hasCurrentAuthProfileConfigConflict,
type HermesAuthProfileConfig,
} from "./auth-config.js";
import { isRecord, parseEnv, readString, readText } from "./helpers.js";
import { collectHermesProviderSecretBindings } from "./config-providers.js";
import { isRecord, parseEnv, readString, readText, sanitizeName } from "./helpers.js";
import {
createHermesSecretItem,
HERMES_REASON_AUTH_PROFILE_EXISTS,
@@ -23,104 +24,15 @@ import {
hermesItemSkipped,
readHermesSecretDetails,
} from "./items.js";
import { normalizeHermesProviderId } from "./model.js";
import {
SECRET_MAPPINGS,
type SecretCredentialMode,
type SecretMapping,
} from "./secret-mappings.js";
import type { HermesSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
type SecretCredentialMode = "api_key" | "token";
type SecretMapping = {
envVar: string;
provider: string;
profileId: string;
mode?: SecretCredentialMode;
};
const SECRET_MAPPINGS: readonly SecretMapping[] = [
{ envVar: "OPENAI_API_KEY", provider: "openai", profileId: "openai:hermes-import" },
{ envVar: "ANTHROPIC_API_KEY", provider: "anthropic", profileId: "anthropic:hermes-import" },
{ envVar: "OPENROUTER_API_KEY", provider: "openrouter", profileId: "openrouter:hermes-import" },
{ envVar: "GOOGLE_API_KEY", provider: "google", profileId: "google:hermes-import" },
{ envVar: "GEMINI_API_KEY", provider: "google", profileId: "google:hermes-import" },
{ envVar: "GROQ_API_KEY", provider: "groq", profileId: "groq:hermes-import" },
{ envVar: "XAI_API_KEY", provider: "xai", profileId: "xai:hermes-import" },
{ envVar: "MISTRAL_API_KEY", provider: "mistral", profileId: "mistral:hermes-import" },
{ envVar: "DEEPSEEK_API_KEY", provider: "deepseek", profileId: "deepseek:hermes-import" },
{ envVar: "ZAI_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "Z_AI_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "GLM_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "KIMI_API_KEY", provider: "kimi-coding", profileId: "kimi-coding:hermes-import" },
{ envVar: "KIMICODE_API_KEY", provider: "kimi-coding", profileId: "kimi-coding:hermes-import" },
{ envVar: "MOONSHOT_API_KEY", provider: "moonshot", profileId: "moonshot:hermes-import" },
{ envVar: "MINIMAX_API_KEY", provider: "minimax", profileId: "minimax:hermes-import" },
{
envVar: "MINIMAX_CODING_API_KEY",
provider: "minimax",
profileId: "minimax:hermes-import",
},
{ envVar: "DASHSCOPE_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "QWEN_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "MODELSTUDIO_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "KILOCODE_API_KEY", provider: "kilocode", profileId: "kilocode:hermes-import" },
{
envVar: "AI_GATEWAY_API_KEY",
provider: "vercel-ai-gateway",
profileId: "vercel-ai-gateway:hermes-import",
},
{ envVar: "HF_TOKEN", provider: "huggingface", profileId: "huggingface:hermes-import" },
{
envVar: "HUGGINGFACE_HUB_TOKEN",
provider: "huggingface",
profileId: "huggingface:hermes-import",
},
{ envVar: "TOGETHER_API_KEY", provider: "together", profileId: "together:hermes-import" },
{ envVar: "FIREWORKS_API_KEY", provider: "fireworks", profileId: "fireworks:hermes-import" },
{ envVar: "DEEPINFRA_API_KEY", provider: "deepinfra", profileId: "deepinfra:hermes-import" },
{ envVar: "CEREBRAS_API_KEY", provider: "cerebras", profileId: "cerebras:hermes-import" },
{ envVar: "NVIDIA_API_KEY", provider: "nvidia", profileId: "nvidia:hermes-import" },
{ envVar: "VENICE_API_KEY", provider: "venice", profileId: "venice:hermes-import" },
{ envVar: "XIAOMI_API_KEY", provider: "xiaomi", profileId: "xiaomi:hermes-import" },
{ envVar: "ALIBABA_API_KEY", provider: "alibaba", profileId: "alibaba:hermes-import" },
{ envVar: "ARCEEAI_API_KEY", provider: "arcee", profileId: "arcee:hermes-import" },
{ envVar: "CHUTES_API_KEY", provider: "chutes", profileId: "chutes:hermes-import" },
{
envVar: "CLOUDFLARE_AI_GATEWAY_API_KEY",
provider: "cloudflare-ai-gateway",
profileId: "cloudflare-ai-gateway:hermes-import",
},
{ envVar: "QIANFAN_API_KEY", provider: "qianfan", profileId: "qianfan:hermes-import" },
{ envVar: "OPENCODE_API_KEY", provider: "opencode", profileId: "opencode:hermes-import" },
{ envVar: "OPENCODE_API_KEY", provider: "opencode-go", profileId: "opencode-go:hermes-import" },
{ envVar: "OPENCODE_ZEN_API_KEY", provider: "opencode", profileId: "opencode:hermes-import" },
{
envVar: "OPENCODE_ZEN_API_KEY",
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
},
{
envVar: "OPENCODE_GO_API_KEY",
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
},
{
envVar: "COPILOT_GITHUB_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
{
envVar: "GH_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
{
envVar: "GITHUB_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
] as const;
type SecretCandidate = {
id: string;
source?: string;
@@ -128,8 +40,9 @@ type SecretCandidate = {
provider: string;
profileId: string;
mode: SecretCredentialMode;
sourceKind?: "hermes-env" | "opencode-auth-json";
sourceKind?: "hermes-auth-json" | "hermes-env" | "opencode-auth-json";
sourceProvider?: string;
sourceCredentialId?: string;
secretField?: string;
};
@@ -155,30 +68,57 @@ function secretMode(mapping: SecretMapping): SecretCredentialMode {
}
function buildEnvSecretCandidates(params: {
config: Record<string, unknown>;
env: Record<string, string>;
envPath?: string;
}): SecretCandidate[] {
return SECRET_MAPPINGS.flatMap((mapping) => {
const value = params.env[mapping.envVar]?.trim();
const configuredBindings = collectHermesProviderSecretBindings(params.config, params.env);
const claimedEnvVars = new Set(configuredBindings.map((binding) => binding.envVar));
const configured = configuredBindings.flatMap((binding) => {
const value = params.env[binding.envVar]?.trim();
if (!value) {
return [];
}
return [
{
id: `secret:${mapping.provider}`,
id: `secret:${binding.provider}`,
source: params.envPath,
envVar: binding.envVar,
provider: binding.provider,
profileId: `${binding.provider}:hermes-import`,
mode: "api_key" as const,
},
];
});
const standard = SECRET_MAPPINGS.flatMap((mapping) => {
if (claimedEnvVars.has(mapping.envVar)) {
return [];
}
const value = params.env[mapping.envVar]?.trim();
if (!value) {
return [];
}
const provider =
mapping.envVar === "KIMI_API_KEY" || mapping.envVar === "KIMI_CODING_API_KEY"
? value.startsWith("sk-kimi-")
? "kimi"
: "moonshot"
: mapping.provider;
return [
{
id: `secret:${provider}`,
source: params.envPath,
envVar: mapping.envVar,
provider: mapping.provider,
profileId: mapping.profileId,
provider,
profileId: provider === mapping.provider ? mapping.profileId : `${provider}:hermes-import`,
mode: secretMode(mapping),
},
];
});
return [...configured, ...standard];
}
async function readOpenCodeAuthJson(
authPath: string | undefined,
): Promise<Record<string, unknown>> {
async function readAuthJson(authPath: string | undefined): Promise<Record<string, unknown>> {
const raw = await readText(authPath);
if (!raw) {
return {};
@@ -197,7 +137,7 @@ async function buildOpenCodeSecretCandidates(
if (!authPath) {
return [];
}
const auth = await readOpenCodeAuthJson(authPath);
const auth = await readAuthJson(authPath);
const opencode = isRecord(auth.opencode) ? auth.opencode : {};
const opencodeGo = isRecord(auth["opencode-go"]) ? auth["opencode-go"] : {};
const githubCopilot = isRecord(auth["github-copilot"]) ? auth["github-copilot"] : {};
@@ -243,17 +183,81 @@ async function buildOpenCodeSecretCandidates(
return candidates;
}
function normalizeHermesPoolProvider(provider: string): string {
return normalizeHermesProviderId(provider);
}
async function buildHermesPoolSecretCandidates(
authPath: string | undefined,
globalAuthPath: string | undefined,
): Promise<SecretCandidate[]> {
if (!authPath && !globalAuthPath) {
return [];
}
const auth = await readAuthJson(authPath);
const globalAuth = await readAuthJson(globalAuthPath);
const pool = isRecord(auth.credential_pool) ? auth.credential_pool : {};
const globalPool = isRecord(globalAuth.credential_pool) ? globalAuth.credential_pool : {};
const candidates: SecretCandidate[] = [];
const sourceProviders = new Set([...Object.keys(pool), ...Object.keys(globalPool)]);
for (const sourceProvider of [...sourceProviders].toSorted()) {
const profileEntries = Array.isArray(pool[sourceProvider]) ? pool[sourceProvider] : [];
const globalEntries = Array.isArray(globalPool[sourceProvider])
? globalPool[sourceProvider]
: [];
const rawEntries = profileEntries.length > 0 ? profileEntries : globalEntries;
const sourcePath = profileEntries.length > 0 ? authPath : globalAuthPath;
if (sourceProvider === "openai-codex" || !sourcePath) {
continue;
}
for (const rawEntry of rawEntries) {
if (!isRecord(rawEntry)) {
continue;
}
const sourceCredentialId = readString(rawEntry.id);
const authType = readString(rawEntry.auth_type);
const source = readString(rawEntry.source);
if (
!sourceCredentialId ||
authType !== "api_key" ||
source !== "manual" ||
!readString(rawEntry.access_token)
) {
continue;
}
const provider = normalizeHermesPoolProvider(sourceProvider);
const profileSuffix = sanitizeName(sourceCredentialId);
if (!provider || !profileSuffix) {
continue;
}
candidates.push({
id: `secret:${provider}:hermes-auth-json:${profileSuffix}`,
source: sourcePath,
provider,
profileId: `${provider}:hermes-${profileSuffix}`,
mode: "api_key",
sourceKind: "hermes-auth-json",
sourceProvider,
sourceCredentialId,
secretField: "access_token",
});
}
}
return candidates;
}
async function readSecretCandidateValue(
details: {
envVar?: string;
sourceKind?: string;
sourceProvider?: string;
sourceCredentialId?: string;
secretField?: string;
},
source: string,
): Promise<string | undefined> {
if (details.sourceKind === "opencode-auth-json") {
const auth = await readOpenCodeAuthJson(source);
const auth = await readAuthJson(source);
const sourceProvider = details.sourceProvider;
const secretField = details.secretField;
if (!sourceProvider || !secretField) {
@@ -262,6 +266,18 @@ async function readSecretCandidateValue(
const provider = isRecord(auth[sourceProvider]) ? auth[sourceProvider] : {};
return readString(provider[secretField]);
}
if (details.sourceKind === "hermes-auth-json") {
const auth = await readAuthJson(source);
const pool = isRecord(auth.credential_pool) ? auth.credential_pool : {};
const entries = details.sourceProvider ? pool[details.sourceProvider] : undefined;
if (!Array.isArray(entries) || !details.sourceCredentialId) {
return undefined;
}
const entry = entries.find(
(candidate) => isRecord(candidate) && candidate.id === details.sourceCredentialId,
);
return isRecord(entry) ? readString(entry.access_token) : undefined;
}
if (!details.envVar) {
return undefined;
}
@@ -270,6 +286,7 @@ async function readSecretCandidateValue(
}
export async function buildSecretItems(params: {
config: Record<string, unknown>;
ctx: MigrationProviderContext;
source: HermesSource;
targets: PlannedTargets;
@@ -279,7 +296,15 @@ export async function buildSecretItems(params: {
const seenProfiles = new Set<string>();
const items: MigrationItem[] = [];
const candidates = [
...buildEnvSecretCandidates({ env, envPath: params.source.envPath }),
...buildEnvSecretCandidates({
config: params.config,
env,
envPath: params.source.envPath,
}),
...(await buildHermesPoolSecretCandidates(
params.source.authPath,
params.source.globalAuthPath,
)),
...(await buildOpenCodeSecretCandidates(params.source.opencodeAuthPath)),
];
for (const candidate of candidates) {
@@ -307,6 +332,9 @@ export async function buildSecretItems(params: {
...(candidate.mode === "token" ? { mode: candidate.mode } : {}),
...(candidate.sourceKind ? { sourceKind: candidate.sourceKind } : {}),
...(candidate.sourceProvider ? { sourceProvider: candidate.sourceProvider } : {}),
...(candidate.sourceCredentialId
? { sourceCredentialId: candidate.sourceCredentialId }
: {}),
...(candidate.secretField ? { secretField: candidate.secretField } : {}),
},
}),
+45 -13
View File
@@ -8,11 +8,47 @@ import type { HermesSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
type PlannedSkill = {
id: string;
name: string;
source: string;
target: string;
};
const EXCLUDED_SKILL_DIRS = new Set([
".git",
".github",
".hub",
".archive",
".venv",
"venv",
"node_modules",
"site-packages",
"__pycache__",
".tox",
".nox",
".pytest_cache",
".mypy_cache",
".ruff_cache",
]);
const SKILL_SUPPORT_DIRS = new Set(["references", "templates", "assets", "scripts"]);
async function discoverSkillRoots(root: string): Promise<string[]> {
const hasSkill = await exists(path.join(root, "SKILL.md"));
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
const roots: string[] = hasSkill ? [root] : [];
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
if (
!entry.isDirectory() ||
EXCLUDED_SKILL_DIRS.has(entry.name) ||
(hasSkill && SKILL_SUPPORT_DIRS.has(entry.name))
) {
continue;
}
roots.push(...(await discoverSkillRoots(path.join(root, entry.name))));
}
return roots;
}
export async function buildSkillItems(params: {
source: HermesSource;
targets: PlannedTargets;
@@ -21,23 +57,19 @@ export async function buildSkillItems(params: {
if (!params.source.skillsDir) {
return [];
}
const entries = await fs
.readdir(params.source.skillsDir, { withFileTypes: true })
.catch(() => []);
const plannedSkills: PlannedSkill[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const name = sanitizeName(entry.name);
for (const source of await discoverSkillRoots(params.source.skillsDir)) {
const name = sanitizeName(path.basename(source));
if (!name) {
continue;
}
const source = path.join(params.source.skillsDir, entry.name);
if (!(await exists(path.join(source, "SKILL.md")))) {
continue;
}
plannedSkills.push({
id: `skill:${path
.relative(params.source.skillsDir, source)
.split(path.sep)
.map(sanitizeName)
.filter(Boolean)
.join(":")}`,
name,
source,
target: path.join(params.targets.workspaceDir, "skills", name),
@@ -53,7 +85,7 @@ export async function buildSkillItems(params: {
const targetExists = await exists(skill.target);
items.push(
createMigrationItem({
id: `skill:${skill.name}`,
id: skill.id,
kind: "skill",
action: "copy",
source: skill.source,
+116 -7
View File
@@ -1,12 +1,13 @@
// Migrate Hermes plugin module implements source behavior.
import path from "node:path";
import { exists, isDirectory, resolveHomePath } from "./helpers.js";
import { exists, isDirectory, readText, resolveHomePath } from "./helpers.js";
export type HermesSource = {
root: string;
configPath?: string;
envPath?: string;
authPath?: string;
globalAuthPath?: string;
opencodeAuthPath?: string;
soulPath?: string;
agentsPath?: string;
@@ -22,9 +23,49 @@ type HermesArchivePath = {
relativePath: string;
};
const HERMES_ARCHIVE_DIRS = ["plugins", "sessions", "logs", "cron", "mcp-tokens"] as const;
const HERMES_ARCHIVE_FILES = ["state.db"] as const;
const HERMES_ARCHIVE_DIRS = [
"plugins",
"sessions",
"logs",
"cron",
"mcp-tokens",
"plans",
"workspace",
"skins",
"kanban",
"pairing",
"platforms",
] as const;
const HERMES_ARCHIVE_FILES = [
"state.db",
"hermes_state.db",
"projects.db",
"response_store.db",
"memory_store.db",
"verification_evidence.db",
"kanban.db",
"retaindb_queue.db",
"gateway_state.json",
"channel_directory.json",
"channel_aliases.json",
"processes.json",
"feishu_comment_pairing.json",
] as const;
const OPENCODE_AUTH_RELATIVE_PATH = path.join(".local", "share", "opencode", "auth.json");
const HERMES_PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
const HERMES_STATE_MARKERS = [
"config.yaml",
".env",
"auth.json",
"active_profile",
"SOUL.md",
"AGENTS.md",
"skills",
"memories",
...HERMES_ARCHIVE_DIRS,
...HERMES_ARCHIVE_FILES,
] as const;
function isSameOrInside(parent: string, candidate: string): boolean {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
@@ -40,9 +81,10 @@ async function discoverOpenCodeAuthPath(params: {
root: string;
includeGlobalFallback: boolean;
includeHomeFallback: boolean;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const rootParent = path.dirname(params.root);
const xdgAuthPath = resolveOpenCodeXdgAuthPath();
const xdgAuthPath = resolveOpenCodeXdgAuthPath(params.env);
const candidates = Array.from(
new Set(
[
@@ -51,7 +93,14 @@ async function discoverOpenCodeAuthPath(params: {
: []),
path.join(rootParent, OPENCODE_AUTH_RELATIVE_PATH),
...(params.includeHomeFallback
? [resolveHomePath(`~/${OPENCODE_AUTH_RELATIVE_PATH}`)]
? [
path.join(
path.resolve(
params.env.HOME?.trim() || params.env.USERPROFILE?.trim() || resolveHomePath("~"),
),
OPENCODE_AUTH_RELATIVE_PATH,
),
]
: []),
].filter((candidate): candidate is string => Boolean(candidate)),
),
@@ -64,14 +113,31 @@ async function discoverOpenCodeAuthPath(params: {
return undefined;
}
export async function discoverHermesSource(input?: string): Promise<HermesSource> {
export async function discoverHermesSource(
input?: string,
options: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
} = {},
): Promise<HermesSource> {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const explicitInput = input?.trim();
const root = resolveHomePath(explicitInput || "~/.hermes");
const root = explicitInput
? resolveHomePath(explicitInput)
: await resolveImplicitHermesRoot(env, platform);
const opencodeAuthPath = await discoverOpenCodeAuthPath({
root,
includeGlobalFallback: !explicitInput,
includeHomeFallback: !explicitInput,
env,
});
const profileParent = path.dirname(root);
const globalRoot =
!explicitInput && path.basename(profileParent) === "profiles"
? path.dirname(profileParent)
: undefined;
const globalAuthPath = globalRoot ? path.join(globalRoot, "auth.json") : undefined;
const archivePaths: HermesArchivePath[] = [];
for (const dir of HERMES_ARCHIVE_DIRS) {
const candidate = path.join(root, dir);
@@ -95,6 +161,7 @@ export async function discoverHermesSource(input?: string): Promise<HermesSource
...((await exists(path.join(root, "auth.json")))
? { authPath: path.join(root, "auth.json") }
: {}),
...(globalAuthPath && (await exists(globalAuthPath)) ? { globalAuthPath } : {}),
...(opencodeAuthPath ? { opencodeAuthPath } : {}),
...((await exists(path.join(root, "SOUL.md"))) ? { soulPath: path.join(root, "SOUL.md") } : {}),
...((await exists(path.join(root, "AGENTS.md")))
@@ -112,11 +179,53 @@ export async function discoverHermesSource(input?: string): Promise<HermesSource
};
}
export async function resolveImplicitHermesRoot(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): Promise<string> {
const configuredHome = env.HERMES_HOME?.trim();
if (configuredHome) {
return resolveHomePath(configuredHome);
}
const userHome =
(platform === "win32" ? env.USERPROFILE?.trim() : env.HOME?.trim()) || resolveHomePath("~");
let root: string;
if (platform === "win32") {
// Hermes stores both active_profile and profiles below LOCALAPPDATA on Windows.
const localAppData = env.LOCALAPPDATA?.trim() || path.join(userHome, "AppData", "Local");
const platformRoot = path.resolve(localAppData, "hermes");
const legacyRoot = path.resolve(userHome, ".hermes");
root = (await hasHermesState(platformRoot))
? platformRoot
: (await hasHermesState(legacyRoot))
? legacyRoot
: platformRoot;
} else {
root = path.resolve(userHome, ".hermes");
}
const activeProfile = (await readText(path.join(root, "active_profile")))?.trim();
if (!activeProfile || activeProfile === "default" || !HERMES_PROFILE_RE.test(activeProfile)) {
return root;
}
const profileRoot = path.join(root, "profiles", activeProfile);
return (await isDirectory(profileRoot)) ? profileRoot : root;
}
async function hasHermesState(root: string): Promise<boolean> {
for (const marker of HERMES_STATE_MARKERS) {
if (await exists(path.join(root, marker))) {
return true;
}
}
return false;
}
export function hasHermesSource(source: HermesSource): boolean {
return Boolean(
source.configPath ||
source.envPath ||
source.authPath ||
source.globalAuthPath ||
source.soulPath ||
source.agentsPath ||
source.memoryPath ||
+3
View File
@@ -1175,6 +1175,9 @@ importers:
extensions/migrate-hermes:
dependencies:
dotenv:
specifier: 17.4.2
version: 17.4.2
yaml:
specifier: 2.9.0
version: 2.9.0
@@ -260,6 +260,35 @@ describe("plugin registry install migration", () => {
expect(persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual(["openai"]);
});
it("keeps bundled migration contracts discoverable after install", async () => {
const stateDir = makeTempDir();
const migrationDir = path.join(stateDir, "plugins", "migrate-demo");
const unusedBundledDir = path.join(stateDir, "plugins", "unused-bundled");
fs.mkdirSync(migrationDir, { recursive: true });
fs.mkdirSync(unusedBundledDir, { recursive: true });
const result = await migratePluginRegistryForInstall({
stateDir,
candidates: [
createCandidate(migrationDir, "migrate-demo", "bundled", {
manifest: {
providers: [],
contracts: { migrationProviders: ["demo"] },
},
}),
createCandidate(unusedBundledDir, "unused-bundled", "bundled"),
],
readConfig: async () => ({}),
env: hermeticEnv(),
});
const current = requireMigratedIndex(result);
expect(current.plugins.map((plugin) => plugin.pluginId)).toEqual(["migrate-demo"]);
const persisted = await readPersistedInstalledPluginIndex({ stateDir });
expect(persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual(["migrate-demo"]);
});
it("keeps legacy OpenAI Codex plugin references doctor-only", async () => {
const stateDir = makeTempDir();
const openaiDir = path.join(stateDir, "plugins", "openai");
@@ -268,6 +268,9 @@ function listMigrationRelevantPluginRecords(params: {
if ((manifest?.commandAliases ?? []).some((alias) => alias.cliCommand)) {
return true;
}
if ((manifest?.contracts?.migrationProviders?.length ?? 0) > 0) {
return true;
}
if (installedPluginIds.has(plugin.pluginId) || referencedPluginIds.has(plugin.pluginId)) {
return true;
}
+36 -2
View File
@@ -1,6 +1,40 @@
// Migration provider tests cover provider-specific option shaping.
import { describe, expect, it } from "vitest";
import { buildMigrationProviderOptions } from "./providers.js";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { MigrationProviderPlugin } from "../../plugins/types.js";
const migrationRuntimeMocks = vi.hoisted(() => ({
ensureLoaded: vi.fn(),
resolveProvider: vi.fn(),
resolveProviders: vi.fn(() => []),
}));
vi.mock("../../plugins/migration-provider-runtime.js", () => ({
ensureStandaloneMigrationProviderRegistryLoaded: migrationRuntimeMocks.ensureLoaded,
resolvePluginMigrationProvider: migrationRuntimeMocks.resolveProvider,
resolvePluginMigrationProviders: migrationRuntimeMocks.resolveProviders,
}));
import { buildMigrationProviderOptions, resolveMigrationProvider } from "./providers.js";
describe("resolveMigrationProvider", () => {
it("loads the requested bundled provider before resolving it", () => {
const config = {} as OpenClawConfig;
const provider = {
id: "hermes",
label: "Hermes",
plan: vi.fn(),
apply: vi.fn(),
} satisfies MigrationProviderPlugin;
migrationRuntimeMocks.resolveProvider.mockReturnValueOnce(provider);
expect(resolveMigrationProvider("hermes", config)).toBe(provider);
expect(migrationRuntimeMocks.ensureLoaded).toHaveBeenCalledWith({
cfg: config,
providerId: "hermes",
});
});
});
describe("buildMigrationProviderOptions", () => {
it("uses the resolved provider id for Codex options", () => {
+4 -1
View File
@@ -15,7 +15,10 @@ export function resolveMigrationProvider(
providerId: string,
config = getRuntimeConfig(),
): MigrationProviderPlugin {
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: config });
ensureStandaloneMigrationProviderRegistryLoaded({
cfg: config,
providerId,
});
const provider = resolvePluginMigrationProvider({ providerId, cfg: config });
if (!provider) {
const available = resolvePluginMigrationProviders({ cfg: config }).map((entry) => entry.id);
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
createMigrationConfigPatchItem,
redactMigrationPlan,
summarizeMigrationItems,
} from "./migration.js";
describe("migration sensitive value redaction", () => {
it("masks positional values inside sensitive config items", () => {
const item = createMigrationConfigPatchItem({
id: "config:mcp-positional-value",
target: "mcp.servers.example",
path: ["mcp", "servers"],
value: { example: { command: "server", args: ["--value", "opaque-positional-placeholder"] } },
message: "Import MCP server.",
sensitive: true,
});
const redacted = redactMigrationPlan({
providerId: "hermes",
source: "fixture",
summary: summarizeMigrationItems([item]),
items: [item],
});
expect(JSON.stringify(redacted)).not.toContain("opaque-positional-placeholder");
expect(redacted.items[0]?.details?.value).toBe("[redacted]");
});
});
+13 -1
View File
@@ -190,6 +190,7 @@ export function createMigrationConfigPatchItem(params: {
conflict?: boolean;
reason?: string;
source?: string;
sensitive?: boolean;
details?: Record<string, unknown>;
}): MigrationItem {
return createMigrationItem({
@@ -201,6 +202,7 @@ export function createMigrationConfigPatchItem(params: {
status: params.conflict ? "conflict" : "planned",
reason: params.conflict ? (params.reason ?? MIGRATION_REASON_TARGET_EXISTS) : undefined,
message: params.message,
sensitive: params.sensitive,
details: { ...params.details, path: params.path, value: params.value },
});
}
@@ -318,8 +320,18 @@ function redactMigrationValueInternal(value: unknown, seen: WeakSet<object>): un
return REDACTED_MIGRATION_VALUE;
}
seen.add(value);
const record = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
const redactSensitiveDetailsValue =
record.sensitive === true && isRecord(record.details) && Object.hasOwn(record.details, "value");
for (const [key, entry] of Object.entries(record)) {
if (key === "details" && redactSensitiveDetailsValue && isRecord(entry)) {
const details = redactMigrationValueInternal(entry, seen);
next[key] = isRecord(details)
? { ...details, value: REDACTED_MIGRATION_VALUE }
: REDACTED_MIGRATION_VALUE;
continue;
}
if (isSecretKey(key) && !isSecretReferenceLike(entry)) {
next[key] = REDACTED_MIGRATION_VALUE;
continue;
@@ -43,6 +43,7 @@ const mocks = vi.hoisted(() => ({
diagnostics: [],
})),
ensureStandaloneRuntimePluginRegistryLoaded: vi.fn(),
listBundledPluginMetadata: vi.fn(() => []),
}));
vi.mock("./loader.js", () => ({
@@ -74,6 +75,10 @@ vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({
ensureStandaloneRuntimePluginRegistryLoaded: mocks.ensureStandaloneRuntimePluginRegistryLoaded,
}));
vi.mock("./bundled-plugin-metadata.js", () => ({
listBundledPluginMetadata: mocks.listBundledPluginMetadata,
}));
let ensureStandaloneMigrationProviderRegistryLoaded: typeof import("./migration-provider-runtime.js").ensureStandaloneMigrationProviderRegistryLoaded;
let resolvePluginMigrationProvider: typeof import("./migration-provider-runtime.js").resolvePluginMigrationProvider;
let resolvePluginMigrationProviders: typeof import("./migration-provider-runtime.js").resolvePluginMigrationProviders;
@@ -105,6 +110,7 @@ describe("migration provider runtime", () => {
mocks.resolveRuntimePluginRegistry.mockReturnValue(createEmptyPluginRegistry());
mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry());
mocks.loadPluginRegistrySnapshot.mockReturnValue(createMockPluginIndex([]));
mocks.listBundledPluginMetadata.mockReturnValue([]);
mocks.loadPluginRegistrySnapshotWithMetadata.mockImplementation(
(params?: { index?: MockPluginIndex }) => ({
source: params?.index ? "provided" : "derived",
@@ -166,6 +172,28 @@ describe("migration provider runtime", () => {
});
});
it("discovers bundled migration contracts missing from a pruned persisted index", () => {
mocks.listBundledPluginMetadata.mockReturnValue([
{
manifest: {
id: "migrate-hermes",
contracts: { migrationProviders: ["hermes"] },
},
},
] as never);
ensureStandaloneMigrationProviderRegistryLoaded({ providerId: "hermes" });
const standaloneParams = requireMockCallArg(
mocks.ensureStandaloneRuntimePluginRegistryLoaded,
"ensureStandaloneRuntimePluginRegistryLoaded",
);
expect(standaloneParams.requiredPluginIds).toEqual(["migrate-hermes"]);
expect(
(standaloneParams.loadOptions as { onlyPluginIds?: unknown } | undefined)?.onlyPluginIds,
).toEqual(["migrate-hermes"]);
});
it("loads configured external migration-provider plugins from manifest contracts", () => {
const cfg = {
plugins: { entries: { "external-migration": { enabled: true } } },
+44 -12
View File
@@ -5,10 +5,16 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
import { resolveManifestContractRuntimePluginResolution } from "./manifest-contract-runtime.js";
import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js";
import type { MigrationProviderPlugin } from "./types.js";
type MigrationProviderPluginResolution = {
pluginIds: string[];
bundledCompatPluginIds: string[];
};
function findMigrationProviderById(
entries: ReadonlyArray<{ provider: MigrationProviderPlugin }>,
providerId: string,
@@ -37,6 +43,40 @@ function resolveMigrationProviderRegistry(params: { pluginIds: string[] }) {
});
}
function resolveMigrationProviderPluginResolution(params: {
cfg?: OpenClawConfig;
providerId?: string;
}): MigrationProviderPluginResolution {
const resolution = resolveManifestContractRuntimePluginResolution({
cfg: params.cfg,
contract: "migrationProviders",
...(params.providerId ? { value: params.providerId } : {}),
});
const pluginIds = new Set(resolution.pluginIds);
const bundledCompatPluginIds = new Set(resolution.bundledCompatPluginIds);
// Install migration can persist a deliberately pruned bundled-plugin index.
// Migration contracts still need manifest discovery to repair older indexes.
for (const plugin of listBundledPluginMetadata({ includeChannelConfigs: false })) {
const providerIds = plugin.manifest.contracts?.migrationProviders ?? [];
if (
providerIds.length === 0 ||
(params.providerId && !providerIds.includes(params.providerId))
) {
continue;
}
pluginIds.add(plugin.manifest.id);
bundledCompatPluginIds.add(plugin.manifest.id);
}
return {
pluginIds: [...pluginIds].toSorted((left, right) => left.localeCompare(right)),
bundledCompatPluginIds: [...bundledCompatPluginIds].toSorted((left, right) =>
left.localeCompare(right),
),
};
}
function mergeMigrationProviders(
left: ReadonlyArray<{ provider: MigrationProviderPlugin }>,
right: ReadonlyArray<{ provider: MigrationProviderPlugin }>,
@@ -56,11 +96,7 @@ export function ensureStandaloneMigrationProviderRegistryLoaded(
providerId?: string;
} = {},
): void {
const resolution = resolveManifestContractRuntimePluginResolution({
cfg: params.cfg,
contract: "migrationProviders",
...(params.providerId ? { value: params.providerId } : {}),
});
const resolution = resolveMigrationProviderPluginResolution(params);
if (resolution.pluginIds.length === 0) {
return;
}
@@ -92,10 +128,9 @@ export function resolvePluginMigrationProvider(params: {
return activeProvider;
}
const resolution = resolveManifestContractRuntimePluginResolution({
const resolution = resolveMigrationProviderPluginResolution({
cfg: params.cfg,
contract: "migrationProviders",
value: params.providerId,
providerId: params.providerId,
});
const pluginIds = resolution.pluginIds;
if (pluginIds.length === 0) {
@@ -114,10 +149,7 @@ export function resolvePluginMigrationProviders(
): MigrationProviderPlugin[] {
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeProviders = activeRegistry?.migrationProviders ?? [];
const resolution = resolveManifestContractRuntimePluginResolution({
cfg: params.cfg,
contract: "migrationProviders",
});
const resolution = resolveMigrationProviderPluginResolution({ cfg: params.cfg });
const pluginIds = resolution.pluginIds;
if (pluginIds.length === 0) {
return mergeMigrationProviders(activeProviders, []);