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
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:
147
extensions/migrate-hermes/apply.ts
Normal file
147
extensions/migrate-hermes/apply.ts
Normal 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;
|
||||
}
|
||||
98
extensions/migrate-hermes/auth-config.ts
Normal file
98
extensions/migrate-hermes/auth-config.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
525
extensions/migrate-hermes/auth.ts
Normal file
525
extensions/migrate-hermes/auth.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
184
extensions/migrate-hermes/config.test.ts
Normal file
184
extensions/migrate-hermes/config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
309
extensions/migrate-hermes/config.ts
Normal file
309
extensions/migrate-hermes/config.ts
Normal 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);
|
||||
}
|
||||
339
extensions/migrate-hermes/files-and-skills.test.ts
Normal file
339
extensions/migrate-hermes/files-and-skills.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
121
extensions/migrate-hermes/helpers.ts
Normal file
121
extensions/migrate-hermes/helpers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
12
extensions/migrate-hermes/index.ts
Normal file
12
extensions/migrate-hermes/index.ts
Normal 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 }));
|
||||
},
|
||||
});
|
||||
124
extensions/migrate-hermes/items.ts
Normal file
124
extensions/migrate-hermes/items.ts
Normal 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);
|
||||
}
|
||||
207
extensions/migrate-hermes/model.apply.test.ts
Normal file
207
extensions/migrate-hermes/model.apply.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
152
extensions/migrate-hermes/model.plan.test.ts
Normal file
152
extensions/migrate-hermes/model.plan.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
117
extensions/migrate-hermes/model.ts
Normal file
117
extensions/migrate-hermes/model.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
extensions/migrate-hermes/openclaw.plugin.json
Normal file
16
extensions/migrate-hermes/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
27
extensions/migrate-hermes/package.json
Normal file
27
extensions/migrate-hermes/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
165
extensions/migrate-hermes/plan.ts
Normal file
165
extensions/migrate-hermes/plan.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
183
extensions/migrate-hermes/provider.secret-failure.test.ts
Normal file
183
extensions/migrate-hermes/provider.secret-failure.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
extensions/migrate-hermes/provider.test.ts
Normal file
145
extensions/migrate-hermes/provider.test.ts
Normal 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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
36
extensions/migrate-hermes/provider.ts
Normal file
36
extensions/migrate-hermes/provider.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
1030
extensions/migrate-hermes/secrets.test.ts
Normal file
1030
extensions/migrate-hermes/secrets.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
390
extensions/migrate-hermes/secrets.ts
Normal file
390
extensions/migrate-hermes/secrets.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
71
extensions/migrate-hermes/skills.ts
Normal file
71
extensions/migrate-hermes/skills.ts
Normal 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;
|
||||
}
|
||||
127
extensions/migrate-hermes/source.ts
Normal file
127
extensions/migrate-hermes/source.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
5
extensions/migrate-hermes/targets.ts
Normal file
5
extensions/migrate-hermes/targets.ts
Normal 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";
|
||||
115
extensions/migrate-hermes/test/provider-helpers.ts
Normal file
115
extensions/migrate-hermes/test/provider-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user