Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
// Migrate Hermes plugin module implements apply behavior.
import fs from "node:fs/promises";
import path from "node:path";
import {
markMigrationItemError,
markMigrationItemSkipped,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
withCachedMigrationConfigRuntime,
writeMigrationReport,
} from "openclaw/plugin-sdk/migration-runtime";
import type {
MigrationApplyResult,
MigrationItem,
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { applyAuthItem } from "./auth.js";
import { applyConfigItem, applyManualItem } from "./config.js";
import { appendItem } from "./helpers.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-";
async function archiveHermesItem(item: MigrationItem, reportDir: string): Promise<MigrationItem> {
if (item.id !== HERMES_STATE_DB_ARCHIVE_ITEM_ID || !item.source) {
return await archiveMigrationItem(item, reportDir);
}
const sourcePath = item.source;
let sourceStat: import("node:fs").Stats;
try {
sourceStat = await fs.lstat(sourcePath);
} catch {
return await archiveMigrationItem(item, reportDir);
}
if (!sourceStat.isFile()) {
return await archiveMigrationItem(item, reportDir);
}
try {
// 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 },
async ({ dir: tempDir }) => {
const snapshotPath = path.join(tempDir, "state.db");
const { DatabaseSync } = await import("node:sqlite");
const source = new DatabaseSync(sourcePath, { readOnly: true });
try {
source.exec("PRAGMA busy_timeout = 30000;");
source.prepare("VACUUM INTO ?").run(snapshotPath);
} finally {
source.close();
}
await fs.chmod(snapshotPath, 0o600);
const archived = await archiveMigrationItem({ ...item, source: snapshotPath }, reportDir);
return { ...archived, source: sourcePath };
},
);
} catch (err) {
const snapshotReason = err instanceof Error ? err.message : String(err);
const rawArchive = await archiveMigrationItem(item, reportDir);
if (rawArchive.status === "migrated") {
return markMigrationItemError(
rawArchive,
`SQLite snapshot failed; raw state.db preserved for manual review: ${snapshotReason}`,
);
}
return markMigrationItemError(
rawArchive,
`SQLite snapshot failed: ${snapshotReason}; raw archive failed: ${rawArchive.reason ?? rawArchive.status}`,
);
}
}
export async function applyHermesPlan(params: {
ctx: MigrationProviderContext;
plan?: MigrationPlan;
runtime?: MigrationProviderContext["runtime"];
}): Promise<MigrationApplyResult> {
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[] = [];
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) {
if (item.status !== "planned") {
items.push(item);
continue;
}
if (blockedByApplyConflict) {
items.push(markMigrationItemSkipped(item, HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT));
continue;
}
let appliedItem: MigrationItem;
if (item.id === "config:default-model") {
appliedItem = await applyModelItem(applyCtx, item);
} else if (item.kind === "config") {
appliedItem = await applyConfigItem(applyCtx, item);
} else if (item.kind === "manual") {
appliedItem = applyManualItem(item);
} else if (item.action === "archive") {
appliedItem = await archiveHermesItem(item, reportDir);
} else if (item.kind === "auth") {
appliedItem = await applyAuthItem(applyCtx, item, targets);
} else if (item.kind === "secret") {
appliedItem = await applySecretItem(applyCtx, item, targets);
} else if (item.action === "append") {
appliedItem = await appendItem(item);
} else {
appliedItem = await copyMigrationFileItem(item, reportDir, {
overwrite: params.ctx.overwrite,
});
}
items.push(appliedItem);
if (
item.kind === "config" &&
(appliedItem.status === "conflict" || appliedItem.status === "error")
) {
blockedByApplyConflict = true;
}
}
const result: MigrationApplyResult = {
...plan,
items,
summary: summarizeMigrationItems(items),
backupPath: params.ctx.backupPath,
reportDir,
};
await writeMigrationReport(result, { title: "Hermes Migration Report" });
return result;
}

View File

@@ -0,0 +1,98 @@
// Migrate Hermes helper module supports auth config behavior.
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { applyAuthProfileConfig, type OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
export type HermesAuthProfileConfig = {
profileId: string;
provider: string;
mode: "api_key" | "oauth" | "token";
email?: string;
displayName?: string;
};
export type HermesAuthConfigApplyResult = "configured" | "conflict" | "unavailable";
class HermesAuthConfigConflict extends Error {}
function existingProfileIsCompatible(
existing: NonNullable<NonNullable<OpenClawConfig["auth"]>["profiles"]>[string],
profile: HermesAuthProfileConfig,
): boolean {
if (existing.provider !== profile.provider || existing.mode !== profile.mode) {
return false;
}
if (existing.email && profile.email && existing.email !== profile.email) {
return false;
}
return true;
}
export function hasAuthProfileConfigConflict(
config: OpenClawConfig,
profile: HermesAuthProfileConfig,
overwrite: boolean,
): boolean {
if (overwrite) {
return false;
}
const existing = config.auth?.profiles?.[profile.profileId];
return Boolean(existing && !existingProfileIsCompatible(existing, profile));
}
function replaceConfigDraft(draft: OpenClawConfig, next: OpenClawConfig): void {
for (const key of Object.keys(draft) as Array<keyof OpenClawConfig>) {
delete draft[key];
}
Object.assign(draft, next);
}
export function hasCurrentAuthProfileConfigConflict(
ctx: MigrationProviderContext,
profile: HermesAuthProfileConfig,
): boolean {
let config = ctx.config;
try {
config = (ctx.runtime?.config?.current?.() as OpenClawConfig | undefined) ?? config;
} catch {
// Fall back to the planning snapshot; apply still rechecks inside mutate.
}
return hasAuthProfileConfigConflict(config, profile, Boolean(ctx.overwrite));
}
export async function applyAuthProfileConfigWithConflictCheck(params: {
ctx: MigrationProviderContext;
profile: HermesAuthProfileConfig;
applyConfigPatch?: (config: OpenClawConfig) => OpenClawConfig;
}): Promise<HermesAuthConfigApplyResult> {
const configApi = params.ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
return "unavailable";
}
try {
await configApi.mutateConfigFile({
base: "runtime",
afterWrite: { mode: "auto" },
mutate(draft) {
let next = draft;
if (params.applyConfigPatch) {
next = params.applyConfigPatch(next);
}
if (hasAuthProfileConfigConflict(next, params.profile, Boolean(params.ctx.overwrite))) {
throw new HermesAuthConfigConflict();
}
next = applyAuthProfileConfig(next, {
profileId: params.profile.profileId,
provider: params.profile.provider,
mode: params.profile.mode,
...(params.profile.email ? { email: params.profile.email } : {}),
...(params.profile.displayName ? { displayName: params.profile.displayName } : {}),
preferProfileFirst: false,
});
replaceConfigDraft(draft, next);
},
});
return "configured";
} catch (error) {
return error instanceof HermesAuthConfigConflict ? "conflict" : "unavailable";
}
}

View File

@@ -0,0 +1,525 @@
// Migrate Hermes plugin module implements auth behavior.
import { createHash } from "node:crypto";
import {
loadAuthProfileStoreWithoutExternalProfiles,
resolveAuthStorePathForDisplay,
} from "openclaw/plugin-sdk/agent-runtime";
import {
createMigrationItem,
createMigrationManualItem,
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import {
buildOpenAICodexCredentialExtra,
buildOauthProviderAuthResult,
resolveOpenAICodexAccessTokenExpiry,
resolveOpenAICodexAuthIdentity,
resolveOpenAICodexImportProfileName,
updateAuthProfileStoreWithLock,
type AuthProfileStore,
type OAuthCredential,
type OpenClawConfig,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import {
applyAuthProfileConfigWithConflictCheck,
hasAuthProfileConfigConflict,
hasCurrentAuthProfileConfigConflict,
type HermesAuthProfileConfig,
} from "./auth-config.js";
import { isRecord, readString, readText } from "./helpers.js";
import {
HERMES_REASON_AUTH_PROFILE_EXISTS,
HERMES_REASON_AUTH_PROFILE_WRITE_FAILED,
HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE,
HERMES_REASON_INCLUDE_SECRETS,
HERMES_REASON_MISSING_SECRET_METADATA,
HERMES_REASON_SECRET_NO_LONGER_PRESENT,
} from "./items.js";
import type { HermesSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
const OPENAI_PROVIDER_ID = "openai";
const OPENAI_DEFAULT_MODEL = "openai/gpt-5.5";
const HERMES_AUTH_DISPLAY_NAME = "Hermes import";
type AgentDefaultModelConfigs = NonNullable<
NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["models"]
>;
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;
result: ProviderAuthResult;
sourceProfileId: string;
};
function authProfileTarget(agentDir: string, profileId: string): string {
return `${resolveAuthStorePathForDisplay(agentDir)}#${profileId}`;
}
function sourceCredentialFingerprint(candidate: HermesCodexAuthCandidate): string {
const hash = createHash("sha256");
for (const part of [
candidate.sourceKind,
candidate.accountId ?? "",
candidate.access,
candidate.refresh,
]) {
hash.update(part);
hash.update("\0");
}
return hash.digest("hex");
}
async function readOpenCodeOpenAICandidates(
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 openai = isRecord(parsed.openai) ? parsed.openai : undefined;
const access = readString(openai?.access);
const accountId = readString(openai?.accountId);
const refresh = readString(openai?.refresh);
if (!access || !refresh) {
return [];
}
return [
{
access,
...(accountId ? { accountId } : {}),
refresh,
sourceKind: "opencode-auth-json",
sourceCredentialIndex: 0,
sourceLabel: "OpenCode OpenAI OAuth credential",
sourcePath: authPath,
},
];
}
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",
): ProviderAuthResult {
const identity = resolveOpenAICodexAuthIdentity({
access: candidate.access,
accountId: candidate.accountId,
});
return buildOauthProviderAuthResult({
providerId: OPENAI_PROVIDER_ID,
defaultModel: OPENAI_DEFAULT_MODEL,
access: candidate.access,
refresh: candidate.refresh,
expires: resolveOpenAICodexAccessTokenExpiry(candidate.access),
email: identity.email,
profileName: resolveOpenAICodexImportProfileName(identity, fallbackProfileName),
displayName: HERMES_AUTH_DISPLAY_NAME,
credentialExtra: buildOpenAICodexCredentialExtra(identity),
});
}
function readProviderAuthModelConfigs(result: ProviderAuthResult): AgentDefaultModelConfigs {
const models = result.configPatch?.agents?.defaults?.models;
if (isRecord(models)) {
return { ...models };
}
const defaultModel = readString(result.defaultModel) ?? OPENAI_DEFAULT_MODEL;
return { [defaultModel]: {} };
}
function mergeModelConfigEntry(
existing: AgentDefaultModelConfigEntry | undefined,
patch: AgentDefaultModelConfigEntry,
): AgentDefaultModelConfigEntry {
if (existing && isRecord(existing) && isRecord(patch)) {
return { ...existing, ...patch } as AgentDefaultModelConfigEntry;
}
return existing ?? patch;
}
function applyOAuthModelConfigsToConfig(
cfg: OpenClawConfig,
result: ProviderAuthResult,
): OpenClawConfig {
const patchModels = readProviderAuthModelConfigs(result);
const existingModels = cfg.agents?.defaults?.models ?? {};
const models: AgentDefaultModelConfigs = result.replaceDefaultModels
? { ...patchModels }
: { ...existingModels };
if (!result.replaceDefaultModels) {
for (const [modelRef, modelConfig] of Object.entries(patchModels)) {
models[modelRef] = mergeModelConfigEntry(models[modelRef], modelConfig);
}
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
function authProfileDedupeKey(profile: HermesCodexAuthProfile): string {
if (profile.credential.accountId) {
return `${profile.credential.provider}:account:${profile.credential.accountId}`;
}
if (profile.credential.email) {
return `${profile.credential.provider}:email:${profile.credential.email}`;
}
return `${profile.credential.provider}:profile:${profile.sourceProfileId}`;
}
async function readCodexAuthProfilesFromSource(
source: HermesSource,
): Promise<HermesCodexAuthProfile[]> {
const candidates = (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()) {
const fallbackProfileName =
candidates.length === 1 ? "hermes-import" : `hermes-import-${index + 1}`;
const result = buildAuthResult(candidate, fallbackProfileName);
const profile = result.profiles[0];
if (!profile || profile.credential.type !== "oauth") {
continue;
}
const entry = {
candidate,
credential: profile.credential,
result,
sourceProfileId: profile.profileId,
};
const dedupeKey = authProfileDedupeKey(entry);
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
profiles.push(entry);
}
return profiles;
}
async function readCodexAuthProfilesFromPath(params: {
sourcePath: string | undefined;
sourceKind: unknown;
}): Promise<HermesCodexAuthProfile[]> {
if (params.sourceKind === "opencode-auth-json") {
return await readCodexAuthProfilesFromSource({
root: "",
archivePaths: [],
...(params.sourcePath ? { opencodeAuthPath: params.sourcePath } : {}),
});
}
return [];
}
function findMatchingProfile(
store: AuthProfileStore,
credential: OAuthCredential,
): string | undefined {
for (const [profileId, existing] of Object.entries(store.profiles)) {
if (existing.type !== "oauth" || existing.provider !== credential.provider) {
continue;
}
if (credential.accountId && existing.accountId === credential.accountId) {
return profileId;
}
const canMatchByEmail = !credential.accountId || !existing.accountId;
if (canMatchByEmail && credential.email && existing.email === credential.email) {
return profileId;
}
}
return undefined;
}
function oauthAuthProfileConfig(
profileId: string,
credential: OAuthCredential,
): HermesAuthProfileConfig {
return {
profileId,
provider: credential.provider,
mode: "oauth",
...(credential.email ? { email: credential.email } : {}),
...(credential.displayName ? { displayName: credential.displayName } : {}),
};
}
function matchesSourceCredentialFingerprint(
profile: HermesCodexAuthProfile,
fingerprint: string,
): boolean {
return sourceCredentialFingerprint(profile.candidate) === fingerprint;
}
function findPlannedAuthProfile(params: {
profiles: HermesCodexAuthProfile[];
sourceProfileId: string;
sourceCredentialIndex?: number;
sourceCredentialFingerprint?: string;
}): HermesCodexAuthProfile | undefined {
const bySourceProfileId = params.profiles.find(
(entry) => entry.sourceProfileId === params.sourceProfileId,
);
const fingerprint = params.sourceCredentialFingerprint;
if (!fingerprint) {
return bySourceProfileId;
}
if (bySourceProfileId && matchesSourceCredentialFingerprint(bySourceProfileId, fingerprint)) {
return bySourceProfileId;
}
const byIndex =
params.sourceCredentialIndex === undefined
? undefined
: params.profiles.find(
(entry) => entry.candidate.sourceCredentialIndex === params.sourceCredentialIndex,
);
if (byIndex && matchesSourceCredentialFingerprint(byIndex, fingerprint)) {
return byIndex;
}
return params.profiles.find((entry) => matchesSourceCredentialFingerprint(entry, fingerprint));
}
export async function buildAuthItems(params: {
ctx: MigrationProviderContext;
source: HermesSource;
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.",
}),
);
}
const profiles = await readCodexAuthProfilesFromSource(params.source);
if (profiles.length === 0) {
return items;
}
const store = loadAuthProfileStoreWithoutExternalProfiles(params.targets.agentDir);
items.push(
...profiles.map((profile) => {
const matchedProfileId = findMatchingProfile(store, profile.credential);
const profileId = matchedProfileId ?? profile.sourceProfileId;
const targetExists = Boolean(store.profiles[profileId]);
const skipped = !params.ctx.includeSecrets;
const configConflict = hasAuthProfileConfigConflict(
params.ctx.config,
oauthAuthProfileConfig(profileId, profile.credential),
Boolean(params.ctx.overwrite),
);
const conflict =
((targetExists && !matchedProfileId && !params.ctx.overwrite) || configConflict) &&
!skipped;
const itemId =
profiles.length === 1
? `auth:${OPENAI_PROVIDER_ID}`
: `auth:${OPENAI_PROVIDER_ID}:${profile.sourceProfileId}`;
return createMigrationItem({
id: itemId,
kind: "auth",
action: skipped ? "skip" : "create",
source: profile.candidate.sourcePath,
target: authProfileTarget(params.targets.agentDir, profileId),
status: skipped ? "skipped" : conflict ? "conflict" : "planned",
sensitive: true,
reason: skipped
? HERMES_REASON_INCLUDE_SECRETS
: conflict
? HERMES_REASON_AUTH_PROFILE_EXISTS
: undefined,
message: skipped
? "OpenAI OAuth credentials detected in OpenCode."
: "Import OpenAI OAuth credentials and configure OpenAI models.",
details: {
provider: OPENAI_PROVIDER_ID,
profileId,
...(typeof profile.candidate.sourceCredentialIndex === "number"
? { sourceCredentialIndex: profile.candidate.sourceCredentialIndex }
: {}),
sourceCredentialFingerprint: sourceCredentialFingerprint(profile.candidate),
sourceProfileId: profile.sourceProfileId,
sourceKind: profile.candidate.sourceKind,
sourceLabel: profile.candidate.sourceLabel,
},
});
}),
);
return items;
}
export async function applyAuthItem(
ctx: MigrationProviderContext,
item: MigrationItem,
targets: PlannedTargets,
): Promise<MigrationItem> {
if (item.status !== "planned") {
return item;
}
const source = item.source;
const profileId = typeof item.details?.profileId === "string" ? item.details.profileId : "";
const sourceProfileId =
typeof item.details?.sourceProfileId === "string" ? item.details.sourceProfileId : profileId;
const sourceCredentialIndex =
typeof item.details?.sourceCredentialIndex === "number"
? item.details.sourceCredentialIndex
: undefined;
const sourceCredentialFingerprintLocal =
typeof item.details?.sourceCredentialFingerprint === "string"
? item.details.sourceCredentialFingerprint
: undefined;
if (!source || !profileId) {
return markMigrationItemError(item, HERMES_REASON_MISSING_SECRET_METADATA);
}
const profiles = await readCodexAuthProfilesFromPath({
sourcePath: source,
sourceKind: item.details?.sourceKind,
});
const profile = findPlannedAuthProfile({
profiles,
sourceProfileId,
...(sourceCredentialIndex === undefined ? {} : { sourceCredentialIndex }),
...(sourceCredentialFingerprintLocal
? { sourceCredentialFingerprint: sourceCredentialFingerprintLocal }
: {}),
});
if (!profile) {
return markMigrationItemSkipped(item, HERMES_REASON_SECRET_NO_LONGER_PRESENT);
}
let conflicted = false;
let wrote = false;
const credential = {
...profile.credential,
displayName:
"displayName" in profile.credential && profile.credential.displayName
? profile.credential.displayName
: HERMES_AUTH_DISPLAY_NAME,
};
const configProfile = oauthAuthProfileConfig(profileId, credential);
if (hasCurrentAuthProfileConfigConflict(ctx, configProfile)) {
return markMigrationItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
const store = await updateAuthProfileStoreWithLock({
agentDir: targets.agentDir,
updater: (freshStore) => {
const existing = freshStore.profiles[profileId];
if (!ctx.overwrite && existing) {
const matchedProfileId = findMatchingProfile(freshStore, credential);
if (matchedProfileId !== profileId) {
conflicted = true;
return false;
}
return false;
}
freshStore.profiles[profileId] = credential;
wrote = true;
return true;
},
});
if (conflicted) {
return markMigrationItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
if (!store?.profiles[profileId]) {
return markMigrationItemError(item, HERMES_REASON_AUTH_PROFILE_WRITE_FAILED);
}
const configResult = await applyAuthProfileConfigWithConflictCheck({
ctx,
profile: configProfile,
applyConfigPatch(config) {
return applyOAuthModelConfigsToConfig(config, profile.result);
},
});
if (configResult === "conflict") {
return markMigrationItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
return {
...item,
status: "migrated",
message:
configResult === "configured"
? item.message
: `${item.message ?? "Imported auth profile."} ${HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE}.`,
details: {
...item.details,
wroteAuthProfile: wrote,
configUpdated: configResult === "configured",
},
};
}

View File

@@ -0,0 +1,184 @@
// Migrate Hermes tests cover config plugin behavior.
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it } from "vitest";
import { buildHermesMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
makeConfigRuntime,
makeContext,
makeTempRoot,
writeFile,
} from "./test/provider-helpers.js";
function itemById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
describe("Hermes migration config mapping", () => {
afterEach(async () => {
await cleanupTempRoots();
});
it("plans provider, MCP, skill, and memory plugin config as plugin-owned items", 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, "config.yaml"),
[
"model:",
" provider: openai",
" model: gpt-5.4",
"providers:",
" openai:",
" base_url: https://api.openai.example/v1",
" api_key_env: OPENAI_API_KEY",
" models: [gpt-5.4]",
"custom_providers:",
" - name: local-llm",
" base_url: http://127.0.0.1:11434/v1",
" models: [local-model]",
"memory:",
" provider: honcho",
" honcho:",
" project: hermes",
"skills:",
" config:",
" ship-it:",
" mode: fast",
"mcp_servers:",
" time:",
" command: npx",
" args: ['-y', 'mcp-server-time']",
"",
].join("\n"),
);
await writeFile(path.join(source, "memories", "MEMORY.md"), "memory line\n");
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir }));
const memoryPlugin = itemById(plan.items, "config:memory-plugin:honcho");
expect(memoryPlugin?.kind).toBe("config");
expect(memoryPlugin?.action).toBe("merge");
expect(memoryPlugin?.target).toBe("plugins.entries.honcho");
const manualMemory = itemById(plan.items, "manual:memory-provider:honcho");
expect(manualMemory?.kind).toBe("manual");
expect(manualMemory?.status).toBe("skipped");
const modelProviders = itemById(plan.items, "config:model-providers");
const modelProviderValue = modelProviders?.details?.value as
| {
openai?: { baseUrl?: string; apiKey?: unknown };
"local-llm"?: { baseUrl?: string };
}
| undefined;
expect(modelProviderValue?.openai?.baseUrl).toBe("https://api.openai.example/v1");
expect(modelProviderValue?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
expect(modelProviderValue?.["local-llm"]?.baseUrl).toBe("http://127.0.0.1:11434/v1");
const mcpServers = itemById(plan.items, "config:mcp-servers");
expect(mcpServers?.details?.value).toEqual({
time: {
command: "npx",
args: ["-y", "mcp-server-time"],
},
});
const skillEntries = itemById(plan.items, "config:skill-entries");
expect(skillEntries?.details?.value).toEqual({
"ship-it": {
config: {
mode: "fast",
},
},
});
expect(plan.warnings).toEqual([
"Some Hermes settings require manual review before they can be activated safely.",
]);
});
it("applies mapped config items through the migration runtime config writer", 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;
await writeFile(
path.join(source, "config.yaml"),
[
"providers:",
" openai:",
" api_key_env: OPENAI_API_KEY",
" models: [gpt-5.4]",
"mcp_servers:",
" time:",
" command: npx",
"skills:",
" config:",
" ship-it:",
" mode: fast",
"",
].join("\n"),
);
const provider = buildHermesMigrationProvider();
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
runtime: makeConfigRuntime(config),
}),
);
expect(result.summary.errors).toBe(0);
expect(config.models?.providers?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
expect(config.mcp?.servers?.time?.command).toBe("npx");
expect(config.skills?.entries?.["ship-it"]?.config?.mode).toBe("fast");
});
it("uses the provider runtime for CLI-applied config items", 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: Record<string, unknown> = {
agents: { defaults: { workspace: workspaceDir } },
};
await writeFile(
path.join(source, "config.yaml"),
[
"mcp_servers:",
" time:",
" command: npx",
" env:",
" OPENAI_API_KEY: short-dev-key",
"",
].join("\n"),
);
const provider = buildHermesMigrationProvider({ runtime: makeConfigRuntime(config) });
const result = await provider.apply(makeContext({ source, stateDir, workspaceDir }));
expect(result.summary.errors).toBe(0);
const mcp = config.mcp as
| { servers?: { time?: { command?: unknown; env?: { OPENAI_API_KEY?: unknown } } } }
| undefined;
expect(mcp?.servers?.time?.command).toBe("npx");
expect(mcp?.servers?.time?.env?.OPENAI_API_KEY).toBe("short-dev-key");
});
});

View File

@@ -0,0 +1,309 @@
// Migrate Hermes helper module supports config behavior.
import {
applyMigrationConfigPatchItem,
applyMigrationManualItem,
createMigrationConfigPatchItem,
createMigrationManualItem,
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;
}
function mapSkillEntries(config: Record<string, unknown>): Record<string, unknown> | undefined {
const entries: Record<string, unknown> = {};
for (const [skillKey, value] of Object.entries(
childRecord(childRecord(config, "skills"), "config"),
)) {
if (isRecord(value)) {
entries[skillKey] = { config: value };
}
}
return Object.keys(entries).length > 0 ? entries : undefined;
}
export function buildConfigItems(params: {
ctx: MigrationProviderContext;
config: Record<string, unknown>;
modelRef?: string;
hasMemoryFiles?: boolean;
}): MigrationItem[] {
const items: MigrationItem[] = [];
const memory = childRecord(params.config, "memory");
const memoryProvider = readString(memory.provider);
if (params.hasMemoryFiles || memoryProvider) {
items.push(
createMigrationConfigPatchItem({
id: "config:memory",
target: "memory",
path: ["memory"],
value: { backend: "builtin" },
message: "Use OpenClaw built-in file memory for imported Hermes memory files.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["memory"], { backend: true }),
}),
);
items.push(
createMigrationConfigPatchItem({
id: "config:memory-plugin-slot",
target: "plugins.slots",
path: ["plugins", "slots"],
value: { memory: "memory-core" },
message: "Select the default OpenClaw memory plugin for imported file memory.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["plugins", "slots"], {
memory: true,
}),
}),
);
}
if (memoryProvider === "honcho") {
const value = {
honcho: {
enabled: true,
config: childRecord(memory, "honcho"),
},
};
items.push(
createMigrationConfigPatchItem({
id: "config:memory-plugin:honcho",
target: "plugins.entries.honcho",
path: ["plugins", "entries"],
value,
message: "Preserve Hermes Honcho memory settings as a plugin entry for manual activation.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["plugins", "entries"], value),
}),
);
items.push(
createMigrationManualItem({
id: "manual:memory-provider:honcho",
source: "config.yaml:memory.provider",
message:
"Hermes used Honcho memory. OpenClaw keeps built-in memory selected until the matching plugin is installed and reviewed.",
recommendation:
"Install or review the Honcho memory plugin before selecting it for plugins.slots.memory.",
}),
);
} else if (memoryProvider && !["builtin", "file", "files"].includes(memoryProvider)) {
items.push(
createMigrationManualItem({
id: `manual:memory-provider:${memoryProvider}`,
source: "config.yaml:memory.provider",
message: `Hermes memory provider "${memoryProvider}" does not have a known OpenClaw mapping.`,
recommendation: "Install or configure an equivalent OpenClaw memory plugin manually.",
}),
);
}
const providers = collectHermesProviders(params.config, params.modelRef);
if (providers.length > 0) {
const value = Object.fromEntries(providers.map((entry) => [entry.id, providerConfig(entry)]));
items.push(
createMigrationConfigPatchItem({
id: "config:model-providers",
target: "models.providers",
path: ["models", "providers"],
value,
message: "Import Hermes provider and custom endpoint config.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["models", "providers"], value),
}),
);
}
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 skillEntries = mapSkillEntries(params.config);
if (skillEntries) {
items.push(
createMigrationConfigPatchItem({
id: "config:skill-entries",
target: "skills.entries",
path: ["skills", "entries"],
value: skillEntries,
message: "Import Hermes skill config values.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(params.ctx.config, ["skills", "entries"], skillEntries),
}),
);
}
return items;
}
export async function applyConfigItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
return applyMigrationConfigPatchItem(ctx, item);
}
export function applyManualItem(item: MigrationItem): MigrationItem {
return applyMigrationManualItem(item);
}

View File

@@ -0,0 +1,339 @@
// Migrate Hermes tests cover files and skills plugin behavior.
import fs from "node:fs/promises";
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 { buildHermesMigrationProvider } from "./provider.js";
import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js";
describe("Hermes migration file and skill items", () => {
afterEach(async () => {
await cleanupTempRoots();
});
function configRuntime(config: Record<string, unknown>) {
return {
config: {
current: () => config,
mutateConfigFile: async ({
mutate,
}: {
mutate: (draft: Record<string, unknown>) => void | Promise<void>;
}) => {
const next = structuredClone(config);
await mutate(next);
Object.keys(config).forEach((key) => {
delete config[key];
});
Object.assign(config, next);
return { nextConfig: next };
},
},
} as never;
}
function itemById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.access(targetPath);
} catch (error) {
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
return;
}
throw new Error(`Expected path to be missing: ${targetPath}`);
}
it("reports normalized skill-name collisions instead of overwriting during apply", 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", "Ship It", "SKILL.md"), "# Ship It\n");
await writeFile(path.join(source, "skills", "ship-it", "SKILL.md"), "# ship-it\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 result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
overwrite: true,
reportDir: path.join(root, "report"),
}),
);
expect(result.summary.conflicts).toBe(2);
await expectPathMissing(path.join(workspaceDir, "skills", "ship-it"));
});
it("reports late-created copy targets as conflicts without overwriting", 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, "AGENTS.md"), "# Hermes agents\n");
const provider = buildHermesMigrationProvider();
const ctx = makeContext({ source, stateDir, workspaceDir, reportDir });
const plan = await provider.plan(ctx);
await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Late agents\n");
const result = await provider.apply(ctx, plan);
const agents = itemById(result.items, "workspace:AGENTS.md");
expect(agents?.status).toBe("conflict");
expect(agents?.reason).toBe(MIGRATION_REASON_TARGET_EXISTS);
expect(result.summary.conflicts).toBe(1);
expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toBe("# Late agents\n");
});
it("applies files, appended memories, item backups, reports, and opt-in API keys", 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, ".env"), "OPENAI_API_KEY=sk-hermes\n");
await writeFile(path.join(source, "AGENTS.md"), "# Hermes agents\n");
await writeFile(path.join(source, "memories", "MEMORY.md"), "memory line\n");
await writeFile(path.join(source, "skills", "Ship It", "SKILL.md"), "# Ship It\n");
await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Existing agents\n");
const provider = buildHermesMigrationProvider();
const config: Record<string, unknown> = {};
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
includeSecrets: true,
overwrite: true,
reportDir,
runtime: configRuntime(config),
}),
);
expect(result.summary.errors).toBe(0);
expect(result.summary.conflicts).toBe(0);
expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toBe(
"# Hermes agents\n",
);
expect(
await fs.readFile(path.join(workspaceDir, "skills", "ship-it", "SKILL.md"), "utf8"),
).toBe("# Ship It\n");
await expect(fs.access(path.join(reportDir, "summary.md"))).resolves.toBeUndefined();
expect(await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf8")).toContain(
"Imported from Hermes",
);
const copiedAgentsItem = result.items.find((item) => item.id === "workspace:AGENTS.md");
expect(String(copiedAgentsItem?.details?.backupPath)).toContain("AGENTS.md");
const agentDir = path.join(stateDir, "agents", "main", "agent");
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_AGENT_DIR = agentDir;
try {
const authStore = loadAuthProfileStoreWithoutExternalProfiles(agentDir);
expect(authStore.profiles?.["openai:hermes-import"]).toEqual(
expect.objectContaining({
type: "api_key",
provider: "openai",
key: "sk-hermes",
}),
);
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
if (previousAgentDir === undefined) {
delete process.env.OPENCLAW_AGENT_DIR;
} else {
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
}
}
});
it("archives unsupported Hermes state without copying raw auth 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 reportDir = path.join(root, "report");
await writeFile(path.join(source, "logs", "session.log"), "log line\n");
await writeFile(path.join(source, "auth.json"), '{"token":"opaque"}\n');
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir, reportDir }));
const plannedLogs = itemById(plan.items, "archive:logs");
expect(plannedLogs?.kind).toBe("archive");
expect(plannedLogs?.action).toBe("archive");
expect(plannedLogs?.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.",
]);
const result = await provider.apply(makeContext({ source, stateDir, workspaceDir, reportDir }));
expect(result.summary.errors).toBe(0);
const migratedLogs = itemById(result.items, "archive:logs");
expect(migratedLogs?.status).toBe("migrated");
expect(migratedLogs?.target).toBe(path.join(reportDir, "archive", "logs"));
expect(await fs.readFile(path.join(reportDir, "archive", "logs", "session.log"), "utf8")).toBe(
"log line\n",
);
await expectPathMissing(path.join(reportDir, "archive", "auth.json"));
await expectPathMissing(path.join(workspaceDir, "logs", "session.log"));
});
it("archives committed Hermes SQLite WAL state", 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");
const stateDbPath = path.join(source, "state.db");
await fs.mkdir(source, { recursive: true });
const sourceDb = new DatabaseSync(stateDbPath);
try {
sourceDb.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE marker(value TEXT NOT NULL);
PRAGMA wal_checkpoint(TRUNCATE);
`);
sourceDb.prepare("INSERT INTO marker(value) VALUES (?)").run("committed-only-in-wal");
expect((await fs.stat(`${stateDbPath}-wal`)).size).toBeGreaterThan(0);
const provider = buildHermesMigrationProvider();
const result = await provider.apply(
makeContext({ source, stateDir, workspaceDir, reportDir }),
);
const archivedState = itemById(result.items, "archive:state.db");
const archivedStatePath = path.join(reportDir, "archive", "state.db");
expect(archivedState?.status).toBe("migrated");
expect(archivedState?.source).toBe(stateDbPath);
expect(archivedState?.target).toBe(archivedStatePath);
const archivedDb = new DatabaseSync(archivedStatePath, { readOnly: true });
try {
expect(archivedDb.prepare("SELECT value FROM marker").all()).toEqual([
{ value: "committed-only-in-wal" },
]);
expect(archivedDb.prepare("PRAGMA integrity_check").get()).toEqual({
integrity_check: "ok",
});
} finally {
archivedDb.close();
}
} finally {
sourceDb.close();
}
});
it("preserves raw Hermes state when SQLite snapshotting fails", 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");
const stateDbPath = path.join(source, "state.db");
const archivedStatePath = path.join(reportDir, "archive", "state.db");
await writeFile(stateDbPath, "legacy non-SQLite Hermes state\n");
const provider = buildHermesMigrationProvider();
const result = await provider.apply(makeContext({ source, stateDir, workspaceDir, reportDir }));
const archivedState = itemById(result.items, "archive:state.db");
expect(archivedState?.status).toBe("error");
expect(archivedState?.target).toBe(archivedStatePath);
expect(archivedState?.reason).toContain(
"SQLite snapshot failed; raw state.db preserved for manual review",
);
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 () => {
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: {
tokens: {
access_token: "old-access",
refresh_token: "old-refresh",
},
},
},
credential_pool: {
openai: [
{
access_token: "pool-access",
refresh_token: "pool-refresh",
},
],
},
}),
);
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(
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);
expect(plan.warnings).toContain(
"Some Hermes settings require manual review before they can be activated safely.",
);
});
it("ignores empty Hermes auth.json credential containers", 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: {},
credential_pool: {},
tokens: { anthropic: { access: "other-access", refresh: "other-refresh" } },
}),
);
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(
makeContext({ source, stateDir, workspaceDir, includeSecrets: true }),
);
expect(plan.items.find((item) => item.id === "manual:legacy-hermes-auth-json")).toBeUndefined();
});
});

View File

@@ -0,0 +1,121 @@
// Migrate Hermes helper module supports helpers behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
markMigrationItemError,
MIGRATION_REASON_MISSING_SOURCE_OR_TARGET,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime";
import {
isRecord as sharedIsRecord,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { parse as parseYaml } from "yaml";
const HOME_SHORTHAND_RE = /^~(?=$|[\\/])/u;
const UNSAFE_NAME_CHARS_RE = /[^a-z0-9._-]+/g;
const EDGE_DASHES_RE = /^-+|-+$/g;
export function resolveHomePath(input: string): string {
const value = input.trim();
return value ? path.resolve(value.replace(HOME_SHORTHAND_RE, os.homedir())) : value;
}
export async function exists(filePath: string): Promise<boolean> {
return await pathExists(filePath);
}
export async function isDirectory(dirPath: string): Promise<boolean> {
const stat = await fs.stat(dirPath).catch(() => undefined);
return stat?.isDirectory() === true;
}
export function sanitizeName(name: string): string {
const normalized = name.trim().toLowerCase().replaceAll(UNSAFE_NAME_CHARS_RE, "-");
return normalized.replaceAll(EDGE_DASHES_RE, "");
}
export async function readText(filePath: string | undefined): Promise<string | undefined> {
return filePath ? await fs.readFile(filePath, "utf8").catch(() => undefined) : undefined;
}
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];
let value = match[2] ?? "";
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
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 {};
}
}
export const isRecord = sharedIsRecord;
export function childRecord(
root: Record<string, unknown> | undefined,
key: string,
): Record<string, unknown> {
const value = root?.[key];
return isRecord(value) ? value : {};
}
export const readString = normalizeOptionalString;
export function readStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
}
export async function appendItem(item: MigrationItem): Promise<MigrationItem> {
if (!item.source || !item.target) {
return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET);
}
try {
const content = await fs.readFile(item.source, "utf8");
const header = `\n\n<!-- Imported from Hermes: ${path.basename(item.source)} -->\n\n`;
await fs.mkdir(path.dirname(item.target), { recursive: true });
await appendRegularFile({
filePath: item.target,
content: `${header}${content.trimEnd()}\n`,
rejectSymlinkParents: true,
});
return { ...item, status: "migrated" };
} catch (err) {
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
}
}

View File

@@ -0,0 +1,12 @@
// Migrate Hermes plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildHermesMigrationProvider } from "./provider.js";
export default definePluginEntry({
id: "migrate-hermes",
name: "Hermes Migration",
description: "Imports Hermes state into OpenClaw.",
register(api) {
api.registerMigrationProvider(buildHermesMigrationProvider({ runtime: api.runtime }));
},
});

View File

@@ -0,0 +1,124 @@
// Migrate Hermes plugin module implements items behavior.
import type { MigrationItem } from "openclaw/plugin-sdk/migration";
import {
createMigrationItem,
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
} from "openclaw/plugin-sdk/migration";
import { 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_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";
export const HERMES_REASON_MISSING_SECRET_METADATA = "missing secret metadata";
export const HERMES_REASON_SECRET_NO_LONGER_PRESENT = "secret no longer present";
export const HERMES_REASON_AUTH_PROFILE_WRITE_FAILED = "failed to write auth profile";
export function createHermesModelItem(params: {
model: string;
currentModel?: string;
overwrite?: boolean;
}): MigrationItem {
const alreadyConfigured = params.currentModel === params.model;
const conflict = Boolean(params.currentModel && !params.overwrite && !alreadyConfigured);
return createMigrationItem({
id: "config:default-model",
kind: "config",
action: alreadyConfigured ? "skip" : "update",
target: "agents.defaults.model",
status: alreadyConfigured ? "skipped" : conflict ? "conflict" : "planned",
reason: alreadyConfigured
? HERMES_REASON_ALREADY_CONFIGURED
: conflict
? HERMES_REASON_DEFAULT_MODEL_CONFIGURED
: undefined,
details: { model: params.model },
});
}
export function readHermesModelDetails(item: MigrationItem): { model: string } | undefined {
const model = readString(item.details?.model);
return model ? { model } : undefined;
}
export function createHermesSecretItem(params: {
id: string;
source?: string;
target: string;
includeSecrets?: boolean;
existsAlready?: boolean;
details: {
envVar?: string;
provider: string;
profileId: string;
mode?: "token";
sourceKind?: "hermes-env" | "opencode-auth-json";
sourceProvider?: string;
secretField?: string;
};
}): MigrationItem {
const skipped = !params.includeSecrets;
const conflict = Boolean(params.existsAlready && !skipped);
return createMigrationItem({
id: params.id,
kind: "secret",
action: skipped ? "skip" : "create",
source: params.source,
target: params.target,
status: skipped ? "skipped" : conflict ? "conflict" : "planned",
sensitive: true,
reason: skipped
? HERMES_REASON_INCLUDE_SECRETS
: conflict
? HERMES_REASON_AUTH_PROFILE_EXISTS
: undefined,
details: params.details,
});
}
export function readHermesSecretDetails(item: MigrationItem):
| {
envVar?: string;
provider: string;
profileId: string;
mode?: "token";
sourceKind?: string;
sourceProvider?: string;
secretField?: string;
}
| undefined {
const envVar = readString(item.details?.envVar);
const provider = readString(item.details?.provider);
const profileId = readString(item.details?.profileId);
if (!provider || !profileId) {
return undefined;
}
const mode = item.details?.mode === "token" ? "token" : undefined;
const sourceKind = readString(item.details?.sourceKind);
const sourceProvider = readString(item.details?.sourceProvider);
const secretField = readString(item.details?.secretField);
return {
...(envVar ? { envVar } : {}),
provider,
profileId,
...(mode ? { mode } : {}),
...(sourceKind ? { sourceKind } : {}),
...(sourceProvider ? { sourceProvider } : {}),
...(secretField ? { secretField } : {}),
};
}
export function hermesItemConflict(item: MigrationItem, reason: string): MigrationItem {
return markMigrationItemConflict(item, reason);
}
export function hermesItemError(item: MigrationItem, reason: string): MigrationItem {
return markMigrationItemError(item, reason);
}
export function hermesItemSkipped(item: MigrationItem, reason: string): MigrationItem {
return markMigrationItemSkipped(item, reason);
}

View File

@@ -0,0 +1,207 @@
// Migrate Hermes tests cover model.apply plugin behavior.
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 { buildHermesMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
makeConfigRuntime,
makeContext,
makeTempRoot,
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",
kind: "config",
action: "update",
target: "agents.defaults.model",
status,
...(status === "conflict" ? { reason: HERMES_REASON_DEFAULT_MODEL_CONFIGURED } : {}),
details: { model: "openai/gpt-5.4" },
};
}
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();
});
it("updates only the primary model when applying over object-form model config", 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:\n provider: openai\n model: gpt-5.4\n",
);
const existingConfig = {
agents: {
defaults: {
workspace: workspaceDir,
model: {
primary: "anthropic/claude-sonnet-4.6",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
timeoutMs: 120_000,
},
},
},
} as OpenClawConfig;
let writtenConfig: OpenClawConfig | undefined;
const provider = buildHermesMigrationProvider({
runtime: makeConfigRuntime(existingConfig, (next) => {
writtenConfig = next;
}),
});
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
overwrite: true,
model: existingConfig.agents?.defaults?.model,
reportDir,
}),
);
expect(result.items).toEqual([defaultModelItem("migrated"), modelProvidersItem("migrated")]);
expect(writtenConfig?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.4",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
timeoutMs: 120_000,
});
});
it("updates the default-agent model override when applying with overwrite", 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:\n provider: openai\n model: gpt-5.4\n",
);
const existingConfig = {
agents: {
defaults: {
workspace: workspaceDir,
model: {
primary: "google/gemini-3-pro",
fallbacks: ["openai/gpt-5.4"],
},
},
list: [
{
id: "main",
default: true,
model: {
primary: "anthropic/claude-sonnet-4.6",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
},
},
],
},
} as OpenClawConfig;
let writtenConfig: OpenClawConfig | undefined;
const provider = buildHermesMigrationProvider({
runtime: makeConfigRuntime(existingConfig, (next) => {
writtenConfig = next;
}),
});
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
config: existingConfig,
overwrite: true,
reportDir,
}),
);
expect(result.items).toEqual([defaultModelItem("migrated"), modelProvidersItem("migrated")]);
expect(writtenConfig?.agents?.list?.[0]?.model).toEqual({
primary: "openai/gpt-5.4",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
});
expect(writtenConfig?.agents?.defaults?.model).toEqual(existingConfig.agents?.defaults?.model);
});
it("reports late-created default models as conflicts without overwriting", 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:\n provider: openai\n model: gpt-5.4\n",
);
const lateConfig = {
agents: {
defaults: {
workspace: workspaceDir,
model: "anthropic/claude-sonnet-4.6",
},
},
} 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).toEqual([defaultModelItem("conflict"), modelProvidersItem("skipped")]);
expect(result.summary.conflicts).toBe(1);
expect(lateConfig.agents?.defaults?.model).toBe("anthropic/claude-sonnet-4.6");
});
});

View File

@@ -0,0 +1,152 @@
// Migrate Hermes tests cover model.plan plugin behavior.
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 { buildHermesMigrationProvider } from "./provider.js";
import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js";
function expectedHermesModelPlanItems(params: {
modelStatus: "planned" | "conflict";
modelReason?: string;
}) {
return [
{
id: "config:default-model",
kind: "config",
action: "update",
target: "agents.defaults.model",
status: params.modelStatus,
...(params.modelReason ? { reason: params.modelReason } : {}),
details: {
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",
},
],
},
},
},
},
];
}
describe("Hermes migration model planning", () => {
afterEach(async () => {
await cleanupTempRoots();
});
it("preserves the provider for top-level string model refs", 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, "config.yaml"), "provider: openai\nmodel: gpt-5.4\n");
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir }));
expect(plan.items).toEqual(expectedHermesModelPlanItems({ modelStatus: "planned" }));
});
it("treats existing object-form default model primaries as conflicts", 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, "config.yaml"),
"model:\n provider: openai\n model: gpt-5.4\n",
);
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(
makeContext({
source,
stateDir,
workspaceDir,
model: {
primary: "anthropic/claude-sonnet-4.6",
fallbacks: ["openai/gpt-5.4"],
},
}),
);
expect(plan.items).toEqual(
expectedHermesModelPlanItems({
modelStatus: "conflict",
modelReason: HERMES_REASON_DEFAULT_MODEL_CONFIGURED,
}),
);
});
it("treats default-agent model overrides as conflicts", 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, "config.yaml"),
"model:\n provider: openai\n model: gpt-5.4\n",
);
const config = {
agents: {
defaults: {
workspace: workspaceDir,
model: "openai/gpt-5.4",
},
list: [
{
id: "main",
default: true,
model: "anthropic/claude-sonnet-4.6",
},
],
},
} as OpenClawConfig;
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir, config }));
expect(plan.items).toEqual(
expectedHermesModelPlanItems({
modelStatus: "conflict",
modelReason: HERMES_REASON_DEFAULT_MODEL_CONFIGURED,
}),
);
});
});

View File

@@ -0,0 +1,117 @@
// Migrate Hermes plugin module implements model behavior.
import {
resolveAgentEffectiveModelPrimary,
resolveDefaultAgentId,
setAgentEffectiveModelPrimary,
} from "openclaw/plugin-sdk/agent-runtime";
import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { readString } from "./helpers.js";
import {
HERMES_REASON_ALREADY_CONFIGURED,
HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE,
HERMES_REASON_DEFAULT_MODEL_CONFIGURED,
hermesItemConflict,
hermesItemError,
hermesItemSkipped,
readHermesModelDetails,
} from "./items.js";
export function resolveHermesModelRef(config: Record<string, unknown>): 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;
}
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 rootModel = readString(config.default_model) ?? readString(config.model_name);
const rootProvider = readString(config.provider);
if (rootModel && rootProvider && !rootModel.includes("/")) {
return `${rootProvider}/${rootModel}`;
}
return rootModel;
}
function resolveDefaultAgentModelState(config: MigrationProviderContext["config"]): {
agentId: string;
effectivePrimary?: string;
} {
const agentId = resolveDefaultAgentId(config);
const effectivePrimary = resolveAgentEffectiveModelPrimary(config, agentId);
return {
agentId,
effectivePrimary,
};
}
export function resolveCurrentModelRef(ctx: MigrationProviderContext): string | undefined {
return resolveDefaultAgentModelState(ctx.config).effectivePrimary;
}
class ModelApplyAbortError extends Error {
constructor(
readonly status: "conflict" | "skipped",
readonly reason: string,
) {
super(reason);
this.name = "ModelApplyAbortError";
}
}
export async function applyModelItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
const details = readHermesModelDetails(item);
if (!details || item.status !== "planned") {
return item;
}
try {
const configApi = ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
return hermesItemError(item, HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE);
}
const currentState = resolveDefaultAgentModelState(
configApi.current() as MigrationProviderContext["config"],
);
if (currentState.effectivePrimary === details.model) {
return hermesItemSkipped(item, HERMES_REASON_ALREADY_CONFIGURED);
}
if (currentState.effectivePrimary && !ctx.overwrite) {
return hermesItemConflict(item, HERMES_REASON_DEFAULT_MODEL_CONFIGURED);
}
await configApi.mutateConfigFile({
base: "runtime",
afterWrite: { mode: "auto" },
mutate(draft) {
const mutationState = resolveDefaultAgentModelState(draft);
if (mutationState.effectivePrimary === details.model) {
throw new ModelApplyAbortError("skipped", HERMES_REASON_ALREADY_CONFIGURED);
}
if (mutationState.effectivePrimary && !ctx.overwrite) {
throw new ModelApplyAbortError("conflict", HERMES_REASON_DEFAULT_MODEL_CONFIGURED);
}
setAgentEffectiveModelPrimary(draft, mutationState.agentId, details.model);
},
});
return { ...item, status: "migrated" };
} catch (err) {
if (err instanceof ModelApplyAbortError) {
return err.status === "conflict"
? hermesItemConflict(item, err.reason)
: hermesItemSkipped(item, err.reason);
}
return hermesItemError(item, err instanceof Error ? err.message : String(err));
}
}

View File

@@ -0,0 +1,16 @@
{
"id": "migrate-hermes",
"activation": {
"onStartup": false
},
"name": "Hermes Migration",
"description": "Imports Hermes configuration, memories, skills, and supported credentials into OpenClaw.",
"contracts": {
"migrationProviders": ["hermes"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "@openclaw/migrate-hermes",
"version": "2026.6.11",
"private": true,
"description": "Hermes to OpenClaw migration provider",
"type": "module",
"dependencies": {
"yaml": "2.9.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,165 @@
// Migrate Hermes plugin module implements plan behavior.
import path from "node:path";
import {
createMigrationItem,
MIGRATION_REASON_TARGET_EXISTS,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import type {
MigrationItem,
MigrationPlan,
MigrationProviderContext,
} 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 { resolveCurrentModelRef, resolveHermesModelRef } from "./model.js";
import { buildSecretItems } from "./secrets.js";
import { buildSkillItems } from "./skills.js";
import { discoverHermesSource, hasHermesSource } from "./source.js";
import { resolveTargets } from "./targets.js";
async function addFileItem(params: {
items: MigrationItem[];
id: string;
source?: string;
target: string;
kind?: MigrationItem["kind"];
action?: MigrationItem["action"];
overwrite?: boolean;
}): Promise<void> {
if (!params.source) {
return;
}
const targetExists = await exists(params.target);
params.items.push(
createMigrationItem({
id: params.id,
kind: params.kind ?? "file",
action: params.action ?? "copy",
source: params.source,
target: params.target,
status: targetExists && !params.overwrite ? "conflict" : "planned",
reason: targetExists && !params.overwrite ? MIGRATION_REASON_TARGET_EXISTS : undefined,
}),
);
}
export async function buildHermesPlan(ctx: MigrationProviderContext): Promise<MigrationPlan> {
const source = await discoverHermesSource(ctx.source);
if (!hasHermesSource(source)) {
throw new Error(
`Hermes state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
);
}
const targets = resolveTargets(ctx);
const config = parseHermesConfig(await readText(source.configPath));
const modelRef = resolveHermesModelRef(config);
const items: MigrationItem[] = [];
if (modelRef) {
const currentModel = resolveCurrentModelRef(ctx);
items.push(
createHermesModelItem({
model: modelRef,
currentModel,
overwrite: ctx.overwrite,
}),
);
}
items.push(
...buildConfigItems({
ctx,
config,
modelRef,
hasMemoryFiles: Boolean(source.memoryPath || source.userPath),
}),
);
await addFileItem({
items,
id: "workspace:SOUL.md",
kind: "workspace",
source: source.soulPath,
target: path.join(targets.workspaceDir, "SOUL.md"),
overwrite: ctx.overwrite,
});
await addFileItem({
items,
id: "workspace:AGENTS.md",
kind: "workspace",
source: source.agentsPath,
target: path.join(targets.workspaceDir, "AGENTS.md"),
overwrite: ctx.overwrite,
});
if (source.memoryPath) {
items.push(
createMigrationItem({
id: "memory:MEMORY.md",
kind: "memory",
action: "append",
source: source.memoryPath,
target: path.join(targets.workspaceDir, "MEMORY.md"),
}),
);
}
if (source.userPath) {
items.push(
createMigrationItem({
id: "memory:USER.md",
kind: "memory",
action: "append",
source: source.userPath,
target: path.join(targets.workspaceDir, "USER.md"),
}),
);
}
items.push(...(await buildSkillItems({ source, targets, overwrite: ctx.overwrite })));
items.push(...(await buildAuthItems({ ctx, source, targets })));
items.push(...(await buildSecretItems({ ctx, source, targets })));
for (const archivePath of source.archivePaths) {
items.push(
createMigrationItem({
id: archivePath.id,
kind: "archive",
action: "archive",
source: archivePath.path,
message:
"Archived in the migration report for manual review; not imported into live config.",
details: { archiveRelativePath: archivePath.relativePath },
}),
);
}
const warnings = [
...(!ctx.includeSecrets && items.some((item) => item.kind === "secret" || item.kind === "auth")
? [
"Auth credentials were detected but skipped. Re-run interactively or pass --include-secrets to import supported credentials.",
]
: []),
...(items.some((item) => item.status === "conflict")
? [
"Conflicts were found. Re-run with --overwrite to replace conflicting targets after item-level backups.",
]
: []),
...(source.archivePaths.length > 0
? [
"Some Hermes files are archive-only. They will be copied into the migration report for manual review, not loaded into OpenClaw.",
]
: []),
...(items.some((item) => item.kind === "manual")
? ["Some Hermes settings require manual review before they can be activated safely."]
: []),
];
return {
providerId: "hermes",
source: source.root,
target: targets.workspaceDir,
summary: summarizeMigrationItems(items),
items,
warnings,
nextSteps: ["Run openclaw doctor after applying the migration."],
metadata: { agentDir: targets.agentDir },
};
}

View File

@@ -0,0 +1,183 @@
// Migrate Hermes tests cover provider.secret failure plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveAuthStorePathForDisplay } 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, vi } from "vitest";
import { HERMES_REASON_AUTH_PROFILE_WRITE_FAILED } from "./items.js";
const mocks = vi.hoisted(() => ({
updateAuthProfileStoreWithLock: vi.fn(async () => null),
}));
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>()),
updateAuthProfileStoreWithLock: mocks.updateAuthProfileStoreWithLock,
}));
const { buildHermesMigrationProvider } = await import("./provider.js");
const tempRoots = new Set<string>();
const logger = {
info() {},
warn() {},
error() {},
debug() {},
};
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hermes-secret-failure-"));
tempRoots.add(root);
return root;
}
async function writeFile(filePath: string, content: string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
function makeContext(params: {
source: string;
stateDir: string;
workspaceDir: string;
reportDir: string;
}): MigrationProviderContext {
return {
config: {
agents: {
defaults: {
workspace: params.workspaceDir,
},
},
} as OpenClawConfig,
stateDir: params.stateDir,
source: params.source,
includeSecrets: true,
overwrite: true,
reportDir: params.reportDir,
logger,
};
}
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`;
}
function authProfileTarget(agentDir: string, profileId: string): string {
return `${resolveAuthStorePathForDisplay(agentDir)}#${profileId}`;
}
describe("Hermes migration provider secret write failures", () => {
afterEach(async () => {
for (const root of tempRoots) {
await fs.rm(root, { force: true, recursive: true });
}
tempRoots.clear();
mocks.updateAuthProfileStoreWithLock.mockClear();
});
it("reports an error when a secret auth-profile write fails", 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, ".env"), "OPENAI_API_KEY=sk-hermes\n");
const provider = buildHermesMigrationProvider();
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
reportDir: path.join(root, "report"),
}),
);
expect(result.items).toEqual([
{
id: "secret:openai",
kind: "secret",
action: "create",
source: path.join(source, ".env"),
target: authProfileTarget(
path.join(stateDir, "agents", "main", "agent"),
"openai:hermes-import",
),
status: "error",
sensitive: true,
reason: HERMES_REASON_AUTH_PROFILE_WRITE_FAILED,
details: {
envVar: "OPENAI_API_KEY",
provider: "openai",
profileId: "openai:hermes-import",
},
},
]);
expect(result.summary.errors).toBe(1);
expect(result.summary.migrated).toBe(0);
});
it("reports an error when an OAuth auth-profile write fails", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const accessToken = fakeJwt({
exp: Math.floor(Date.now() / 1000) + 3600,
"https://api.openai.com/profile": { email: "codex@example.test" },
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_fail",
chatgpt_plan_type: "plus",
},
});
await writeFile(path.join(source, "auth.json"), "{}");
const opencodeAuthPath = path.join(root, ".local", "share", "opencode", "auth.json");
await writeFile(
opencodeAuthPath,
JSON.stringify({
openai: {
type: "oauth",
access: accessToken,
refresh: "refresh-fail-token",
},
}),
);
const provider = buildHermesMigrationProvider();
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
reportDir: path.join(root, "report"),
}),
);
expect(result.items).toEqual([
expect.objectContaining({
id: "auth:openai",
kind: "auth",
action: "create",
source: opencodeAuthPath,
target: authProfileTarget(
path.join(stateDir, "agents", "main", "agent"),
"openai:account-acct_fail",
),
status: "error",
sensitive: true,
reason: HERMES_REASON_AUTH_PROFILE_WRITE_FAILED,
details: expect.objectContaining({
provider: "openai",
profileId: "openai:account-acct_fail",
sourceProfileId: "openai:account-acct_fail",
}),
}),
]);
expect(result.summary.errors).toBe(1);
expect(result.summary.migrated).toBe(0);
});
});

View File

@@ -0,0 +1,145 @@
// Migrate Hermes tests cover provider plugin behavior.
import os from "node:os";
import path from "node:path";
import { createCapturedPluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { resolveHomePath } from "./helpers.js";
import pluginEntry from "./index.js";
import { HERMES_REASON_INCLUDE_SECRETS } from "./items.js";
import { buildHermesMigrationProvider } from "./provider.js";
import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js";
function itemById(
items: Array<{ id: string; [key: string]: unknown }>,
id: string,
): { id: string; [key: string]: unknown } | undefined {
return items.find((item) => item.id === id);
}
describe("Hermes migration provider", () => {
afterEach(async () => {
await cleanupTempRoots();
});
it("registers the Hermes migration provider through the plugin entry", () => {
const captured = createCapturedPluginRegistration();
pluginEntry.register(captured.api);
expect(captured.migrationProviders.map((provider) => provider.id)).toEqual(["hermes"]);
});
it("resolves tilde source paths against the OS home when OPENCLAW_HOME is set", () => {
const previous = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = path.join(path.sep, "tmp", "openclaw-home");
try {
expect(resolveHomePath("~/.hermes")).toBe(path.join(os.homedir(), ".hermes"));
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previous;
}
}
});
it("detects Hermes sources supported by planning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
await writeFile(path.join(source, "SOUL.md"), "# Hermes soul\n");
const provider = buildHermesMigrationProvider();
const detected = await provider.detect?.(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(detected?.found).toBe(true);
expect(detected?.source).toBe(source);
expect(detected?.confidence).toBe("high");
});
it("detects archive-only Hermes sources", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
await writeFile(path.join(source, "logs", "run.log"), "log line\n");
const provider = buildHermesMigrationProvider();
const detected = await provider.detect?.(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
);
expect(detected?.found).toBe(true);
expect(detected?.source).toBe(source);
expect(detected?.confidence).toBe("high");
});
it("rejects missing Hermes sources before planning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "missing-hermes");
const provider = buildHermesMigrationProvider();
await expect(
provider.plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
}),
),
).rejects.toThrow(`Hermes state was not found at ${source}`);
});
it("plans model, workspace, memory, skill, and secret items without importing secrets by default", 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, "config.yaml"),
"model:\n provider: openai\n model: gpt-5.4\n",
);
await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n");
await writeFile(path.join(source, "SOUL.md"), "# Hermes soul\n");
await writeFile(path.join(source, "memories", "MEMORY.md"), "remember this\n");
await writeFile(path.join(source, "skills", "Ship It", "SKILL.md"), "# Ship It\n");
await writeFile(path.join(workspaceDir, "SOUL.md"), "# Existing soul\n");
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(
makeContext({
source,
stateDir,
workspaceDir,
model: "anthropic/claude-sonnet-4.6",
}),
);
expect(plan.summary.total).toBe(8);
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(itemById(plan.items, "workspace:SOUL.md")?.status).toBe("conflict");
const memory = itemById(plan.items, "memory:MEMORY.md");
expect(memory?.action).toBe("append");
expect(memory?.status).toBe("planned");
expect(itemById(plan.items, "skill:ship-it")?.status).toBe("planned");
const secret = itemById(plan.items, "secret:openai");
expect(secret?.sensitive).toBe(true);
expect(secret?.status).toBe("skipped");
expect(secret?.reason).toBe(HERMES_REASON_INCLUDE_SECRETS);
expect(plan.warnings).toEqual([
"Auth credentials were detected but skipped. Re-run interactively or pass --include-secrets to import supported credentials.",
"Conflicts were found. Re-run with --overwrite to replace conflicting targets after item-level backups.",
]);
});
});

View File

@@ -0,0 +1,36 @@
// Migrate Hermes provider module implements model/runtime integration.
import type {
MigrationPlan,
MigrationProviderContext,
MigrationProviderPlugin,
} from "openclaw/plugin-sdk/plugin-entry";
import { applyHermesPlan } from "./apply.js";
import { buildHermesPlan } from "./plan.js";
import { discoverHermesSource, hasHermesSource } from "./source.js";
export function buildHermesMigrationProvider(
params: {
runtime?: MigrationProviderContext["runtime"];
} = {},
): MigrationProviderPlugin {
return {
id: "hermes",
label: "Hermes",
description: "Import Hermes config, memories, skills, and supported credentials.",
async detect(ctx) {
const source = await discoverHermesSource(ctx.source);
const found = hasHermesSource(source);
return {
found,
source: source.root,
label: "Hermes",
confidence: found ? "high" : "low",
message: found ? "Hermes state found." : "Hermes state not found.",
};
},
plan: buildHermesPlan,
async apply(ctx, plan?: MigrationPlan) {
return await applyHermesPlan({ ctx, plan, runtime: params.runtime });
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,390 @@
// Migrate Hermes plugin module implements secrets behavior.
import {
loadAuthProfileStoreWithoutExternalProfiles,
resolveAuthStorePathForDisplay,
} from "openclaw/plugin-sdk/agent-runtime";
import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth";
import {
applyAuthProfileConfigWithConflictCheck,
hasAuthProfileConfigConflict,
hasCurrentAuthProfileConfigConflict,
type HermesAuthProfileConfig,
} from "./auth-config.js";
import { isRecord, parseEnv, readString, readText } from "./helpers.js";
import {
createHermesSecretItem,
HERMES_REASON_AUTH_PROFILE_EXISTS,
HERMES_REASON_AUTH_PROFILE_WRITE_FAILED,
HERMES_REASON_MISSING_SECRET_METADATA,
HERMES_REASON_SECRET_NO_LONGER_PRESENT,
hermesItemConflict,
hermesItemError,
hermesItemSkipped,
readHermesSecretDetails,
} from "./items.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;
envVar?: string;
provider: string;
profileId: string;
mode: SecretCredentialMode;
sourceKind?: "hermes-env" | "opencode-auth-json";
sourceProvider?: string;
secretField?: string;
};
function authProfileTarget(agentDir: string, profileId: string): string {
return `${resolveAuthStorePathForDisplay(agentDir)}#${profileId}`;
}
function secretAuthProfileConfig(details: {
provider: string;
profileId: string;
mode?: SecretCredentialMode;
}): HermesAuthProfileConfig {
return {
profileId: details.profileId,
provider: details.provider,
mode: details.mode ?? "api_key",
displayName: "Hermes import",
};
}
function secretMode(mapping: SecretMapping): SecretCredentialMode {
return mapping.mode ?? "api_key";
}
function buildEnvSecretCandidates(params: {
env: Record<string, string>;
envPath?: string;
}): SecretCandidate[] {
return SECRET_MAPPINGS.flatMap((mapping) => {
const value = params.env[mapping.envVar]?.trim();
if (!value) {
return [];
}
return [
{
id: `secret:${mapping.provider}`,
source: params.envPath,
envVar: mapping.envVar,
provider: mapping.provider,
profileId: mapping.profileId,
mode: secretMode(mapping),
},
];
});
}
async function readOpenCodeAuthJson(
authPath: string | undefined,
): Promise<Record<string, unknown>> {
const raw = await readText(authPath);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
async function buildOpenCodeSecretCandidates(
authPath: string | undefined,
): Promise<SecretCandidate[]> {
if (!authPath) {
return [];
}
const auth = await readOpenCodeAuthJson(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"] : {};
const githubCopilotEnterpriseUrl = readString(githubCopilot.enterpriseUrl);
const candidates: SecretCandidate[] = [];
if (readString(opencode.key)) {
candidates.push({
id: "secret:opencode:opencode-auth-json",
source: authPath,
provider: "opencode",
profileId: "opencode:hermes-import",
mode: "api_key",
sourceKind: "opencode-auth-json",
sourceProvider: "opencode",
secretField: "key",
});
}
if (readString(opencodeGo.key)) {
candidates.push({
id: "secret:opencode-go:opencode-auth-json",
source: authPath,
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
mode: "api_key",
sourceKind: "opencode-auth-json",
sourceProvider: "opencode-go",
secretField: "key",
});
}
// OpenClaw's Copilot token profile cannot preserve OpenCode enterprise routing yet.
if (readString(githubCopilot.refresh) && !githubCopilotEnterpriseUrl) {
candidates.push({
id: "secret:github-copilot:opencode-auth-json",
source: authPath,
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
sourceKind: "opencode-auth-json",
sourceProvider: "github-copilot",
secretField: "refresh",
});
}
return candidates;
}
async function readSecretCandidateValue(
details: {
envVar?: string;
sourceKind?: string;
sourceProvider?: string;
secretField?: string;
},
source: string,
): Promise<string | undefined> {
if (details.sourceKind === "opencode-auth-json") {
const auth = await readOpenCodeAuthJson(source);
const sourceProvider = details.sourceProvider;
const secretField = details.secretField;
if (!sourceProvider || !secretField) {
return undefined;
}
const provider = isRecord(auth[sourceProvider]) ? auth[sourceProvider] : {};
return readString(provider[secretField]);
}
if (!details.envVar) {
return undefined;
}
const env = parseEnv(await readText(source));
return env[details.envVar]?.trim() || undefined;
}
export async function buildSecretItems(params: {
ctx: MigrationProviderContext;
source: HermesSource;
targets: PlannedTargets;
}): Promise<MigrationItem[]> {
const env = parseEnv(await readText(params.source.envPath));
const store = loadAuthProfileStoreWithoutExternalProfiles(params.targets.agentDir);
const seenProfiles = new Set<string>();
const items: MigrationItem[] = [];
const candidates = [
...buildEnvSecretCandidates({ env, envPath: params.source.envPath }),
...(await buildOpenCodeSecretCandidates(params.source.opencodeAuthPath)),
];
for (const candidate of candidates) {
if (seenProfiles.has(candidate.profileId)) {
continue;
}
seenProfiles.add(candidate.profileId);
const existsAlready = Boolean(store.profiles[candidate.profileId]);
const configConflict = hasAuthProfileConfigConflict(
params.ctx.config,
secretAuthProfileConfig(candidate),
Boolean(params.ctx.overwrite),
);
items.push(
createHermesSecretItem({
id: candidate.id,
source: candidate.source,
target: authProfileTarget(params.targets.agentDir, candidate.profileId),
includeSecrets: params.ctx.includeSecrets,
existsAlready: (existsAlready && !params.ctx.overwrite) || configConflict,
details: {
...(candidate.envVar ? { envVar: candidate.envVar } : {}),
provider: candidate.provider,
profileId: candidate.profileId,
...(candidate.mode === "token" ? { mode: candidate.mode } : {}),
...(candidate.sourceKind ? { sourceKind: candidate.sourceKind } : {}),
...(candidate.sourceProvider ? { sourceProvider: candidate.sourceProvider } : {}),
...(candidate.secretField ? { secretField: candidate.secretField } : {}),
},
}),
);
}
return items;
}
export async function applySecretItem(
ctx: MigrationProviderContext,
item: MigrationItem,
targets: PlannedTargets,
): Promise<MigrationItem> {
if (item.status !== "planned") {
return item;
}
const details = readHermesSecretDetails(item);
const source = item.source;
if (!details || !source) {
return hermesItemError(item, HERMES_REASON_MISSING_SECRET_METADATA);
}
const key = await readSecretCandidateValue(details, source);
if (!key) {
return hermesItemSkipped(item, HERMES_REASON_SECRET_NO_LONGER_PRESENT);
}
const configProfile = secretAuthProfileConfig(details);
if (hasCurrentAuthProfileConfigConflict(ctx, configProfile)) {
return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
let conflicted = false;
let wrote = false;
const store = await updateAuthProfileStoreWithLock({
agentDir: targets.agentDir,
updater: (freshStore) => {
if (!ctx.overwrite && freshStore.profiles[details.profileId]) {
conflicted = true;
return false;
}
freshStore.profiles[details.profileId] =
details.mode === "token"
? {
type: "token",
provider: details.provider,
token: key,
displayName: "Hermes import",
}
: {
type: "api_key",
provider: details.provider,
key,
displayName: "Hermes import",
};
wrote = true;
return true;
},
});
if (conflicted) {
return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
if (!store?.profiles[details.profileId]) {
return hermesItemError(item, HERMES_REASON_AUTH_PROFILE_WRITE_FAILED);
}
if (!wrote && !ctx.overwrite) {
return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
const configResult = await applyAuthProfileConfigWithConflictCheck({
ctx,
profile: configProfile,
});
if (configResult === "conflict") {
return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS);
}
return {
...item,
status: "migrated",
details: {
...item.details,
configUpdated: configResult === "configured",
},
};
}

View File

@@ -0,0 +1,71 @@
// Migrate Hermes plugin module implements skills behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { createMigrationItem, MIGRATION_REASON_TARGET_EXISTS } from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { exists, sanitizeName } from "./helpers.js";
import type { HermesSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
type PlannedSkill = {
name: string;
source: string;
target: string;
};
export async function buildSkillItems(params: {
source: HermesSource;
targets: PlannedTargets;
overwrite?: boolean;
}): Promise<MigrationItem[]> {
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);
if (!name) {
continue;
}
const source = path.join(params.source.skillsDir, entry.name);
if (!(await exists(path.join(source, "SKILL.md")))) {
continue;
}
plannedSkills.push({
name,
source,
target: path.join(params.targets.workspaceDir, "skills", name),
});
}
const counts = new Map<string, number>();
for (const skill of plannedSkills) {
counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1);
}
const items: MigrationItem[] = [];
for (const skill of plannedSkills) {
const collides = (counts.get(skill.name) ?? 0) > 1;
const targetExists = await exists(skill.target);
items.push(
createMigrationItem({
id: `skill:${skill.name}`,
kind: "skill",
action: "copy",
source: skill.source,
target: skill.target,
status: collides ? "conflict" : targetExists && !params.overwrite ? "conflict" : "planned",
reason: collides
? `multiple Hermes skill directories normalize to "${skill.name}"`
: targetExists && !params.overwrite
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
}),
);
}
return items;
}

View File

@@ -0,0 +1,127 @@
// Migrate Hermes plugin module implements source behavior.
import path from "node:path";
import { exists, isDirectory, resolveHomePath } from "./helpers.js";
export type HermesSource = {
root: string;
configPath?: string;
envPath?: string;
authPath?: string;
opencodeAuthPath?: string;
soulPath?: string;
agentsPath?: string;
memoryPath?: string;
userPath?: string;
skillsDir?: string;
archivePaths: HermesArchivePath[];
};
type HermesArchivePath = {
id: string;
path: string;
relativePath: string;
};
const HERMES_ARCHIVE_DIRS = ["plugins", "sessions", "logs", "cron", "mcp-tokens"] as const;
const HERMES_ARCHIVE_FILES = ["state.db"] as const;
const OPENCODE_AUTH_RELATIVE_PATH = path.join(".local", "share", "opencode", "auth.json");
function isSameOrInside(parent: string, candidate: string): boolean {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveOpenCodeXdgAuthPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
const xdgDataHome = env.XDG_DATA_HOME?.trim();
return xdgDataHome ? path.join(resolveHomePath(xdgDataHome), "opencode", "auth.json") : undefined;
}
async function discoverOpenCodeAuthPath(params: {
root: string;
includeGlobalFallback: boolean;
includeHomeFallback: boolean;
}): Promise<string | undefined> {
const rootParent = path.dirname(params.root);
const xdgAuthPath = resolveOpenCodeXdgAuthPath();
const candidates = Array.from(
new Set(
[
...(xdgAuthPath && (params.includeGlobalFallback || isSameOrInside(rootParent, xdgAuthPath))
? [xdgAuthPath]
: []),
path.join(rootParent, OPENCODE_AUTH_RELATIVE_PATH),
...(params.includeHomeFallback
? [resolveHomePath(`~/${OPENCODE_AUTH_RELATIVE_PATH}`)]
: []),
].filter((candidate): candidate is string => Boolean(candidate)),
),
);
for (const candidate of candidates) {
if (await exists(candidate)) {
return candidate;
}
}
return undefined;
}
export async function discoverHermesSource(input?: string): Promise<HermesSource> {
const explicitInput = input?.trim();
const root = resolveHomePath(explicitInput || "~/.hermes");
const opencodeAuthPath = await discoverOpenCodeAuthPath({
root,
includeGlobalFallback: !explicitInput,
includeHomeFallback: !explicitInput,
});
const archivePaths: HermesArchivePath[] = [];
for (const dir of HERMES_ARCHIVE_DIRS) {
const candidate = path.join(root, dir);
if (await isDirectory(candidate)) {
archivePaths.push({ id: `archive:${dir}`, path: candidate, relativePath: dir });
}
}
for (const file of HERMES_ARCHIVE_FILES) {
const candidate = path.join(root, file);
if (await exists(candidate)) {
archivePaths.push({ id: `archive:${file}`, path: candidate, relativePath: file });
}
}
return {
root,
archivePaths,
...((await exists(path.join(root, "config.yaml")))
? { configPath: path.join(root, "config.yaml") }
: {}),
...((await exists(path.join(root, ".env"))) ? { envPath: path.join(root, ".env") } : {}),
...((await exists(path.join(root, "auth.json")))
? { authPath: path.join(root, "auth.json") }
: {}),
...(opencodeAuthPath ? { opencodeAuthPath } : {}),
...((await exists(path.join(root, "SOUL.md"))) ? { soulPath: path.join(root, "SOUL.md") } : {}),
...((await exists(path.join(root, "AGENTS.md")))
? { agentsPath: path.join(root, "AGENTS.md") }
: {}),
...((await exists(path.join(root, "memories", "MEMORY.md")))
? { memoryPath: path.join(root, "memories", "MEMORY.md") }
: {}),
...((await exists(path.join(root, "memories", "USER.md")))
? { userPath: path.join(root, "memories", "USER.md") }
: {}),
...((await isDirectory(path.join(root, "skills")))
? { skillsDir: path.join(root, "skills") }
: {}),
};
}
export function hasHermesSource(source: HermesSource): boolean {
return Boolean(
source.configPath ||
source.envPath ||
source.authPath ||
source.soulPath ||
source.agentsPath ||
source.memoryPath ||
source.userPath ||
source.skillsDir ||
source.archivePaths.length > 0,
);
}

View File

@@ -0,0 +1,5 @@
// Migrate Hermes plugin re-exports the shared migration target resolution.
export {
resolvePlannedMigrationTargets as resolveTargets,
type PlannedMigrationTargets as PlannedTargets,
} from "openclaw/plugin-sdk/migration-runtime";

View File

@@ -0,0 +1,115 @@
// Migrate Hermes provider module implements model/runtime integration.
import fs from "node:fs/promises";
import path from "node:path";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
const tempRoots = new Set<string>();
const TEMP_ROOT_PREFIX = "openclaw-migrate-hermes-";
function noop() {}
const logger: MigrationProviderContext["logger"] = {
debug: noop,
error: noop,
info: noop,
warn: noop,
};
export async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), TEMP_ROOT_PREFIX));
tempRoots.add(root);
return root;
}
export async function cleanupTempRoots() {
await Promise.all([...tempRoots].map((root) => fs.rm(root, { force: true, recursive: true })));
tempRoots.clear();
}
export async function writeFile(filePath: string, content: string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
export function makeConfigRuntime(
config: OpenClawConfig,
onWrite?: (next: OpenClawConfig) => void,
): NonNullable<MigrationProviderContext["runtime"]> {
const commitConfig = (next: OpenClawConfig) => {
(Object.keys(config) as Array<keyof OpenClawConfig>).forEach((key) => delete config[key]);
Object.assign(config, next);
onWrite?.(next);
};
return {
config: {
current: () => config,
mutateConfigFile: async ({
afterWrite,
mutate,
}: {
afterWrite?: unknown;
mutate: (draft: OpenClawConfig, context: unknown) => Promise<unknown> | void;
}) => {
const next = structuredClone(config);
const result = await mutate(next, {
previousHash: null,
persistedHash: null,
snapshot: { config, raw: "", hash: null },
});
commitConfig(next);
return {
afterWrite,
followUp: { mode: "auto", requiresRestart: false },
nextConfig: next,
result,
};
},
replaceConfigFile: async ({
afterWrite,
nextConfig,
}: {
afterWrite?: unknown;
nextConfig: OpenClawConfig;
}) => {
commitConfig(nextConfig);
return { afterWrite, followUp: { mode: "auto", requiresRestart: false }, nextConfig };
},
},
} as NonNullable<MigrationProviderContext["runtime"]>;
}
export function makeContext(params: {
source: string;
stateDir: string;
workspaceDir: string;
config?: OpenClawConfig;
includeSecrets?: boolean;
overwrite?: boolean;
model?: NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
reportDir?: string;
runtime?: MigrationProviderContext["runtime"];
}): MigrationProviderContext {
const config =
params.config ??
({
agents: {
defaults: {
workspace: params.workspaceDir,
...(params.model !== undefined ? { model: params.model } : {}),
},
},
} as OpenClawConfig);
return {
config,
stateDir: params.stateDir,
source: params.source,
includeSecrets: params.includeSecrets,
overwrite: params.overwrite,
reportDir: params.reportDir,
runtime: params.runtime,
logger,
};
}