Skip to content

feat(import): bring your Hermes setup home — MCP integrations, skills, persona, provider keys - #42

Merged
localrivet merged 10 commits into
mainfrom
claude/multi-agent-commands-ftybu2
Aug 1, 2026
Merged

feat(import): bring your Hermes setup home — MCP integrations, skills, persona, provider keys#42
localrivet merged 10 commits into
mainfrom
claude/multi-agent-commands-ftybu2

Conversation

@localrivet

Copy link
Copy Markdown
Contributor

Adds a Hermes import surface: REST endpoints + Settings → Import page that migrate MCP integrations, skills, the employee persona, and provider keys from a Hermes install into Nebo, with a generated API client (genapi overrides included).

Security-relevant: imports provider credentials — review focus on key handling, storage pathway (keyring/encryption), and endpoint auth.

🤖 Generated with Claude Code

https://claude.ai/code/session_015oqdbhhgyktgKu9iqKb3V2

claude added 9 commits August 1, 2026 16:59
First slice of the "point Nebo at an existing agent install and adopt it"
feature. Introduces a read-only importer under crates/server/src/import/:

- detect(): fingerprints an install directory as Hermes or OpenClaw
- scan(): walks a Hermes install (~/.hermes) into an ImportManifest
  describing what was found and what each piece becomes in Nebo, without
  writing to Nebo or touching the source directory
- ImportManifest / ImportItem / TrustTier: the dry-run data model that the
  "here's what I'll import" confirmation renders

Coverage for Hermes: config.yaml (mcp_servers), SOUL.md persona,
memories/*.md, skills/**/SKILL.md, cron/jobs.json, .env + auth.json
credentials, and state.db history. MCP entries are normalized into the
canonical config block and parsed by the existing parse_mcp_servers_block,
so the importer and paste-import share one parser.

Trust tiers mirror paste-import: stdio MCP servers and script-bundling
skills are Code tier (need confirm); remote MCP, personas, memory, and
schedules are Content (auto). Credential scanning surfaces key names only,
never secret values.

Applying a manifest and the OpenClaw walker are follow-up slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
Hermes spells its MCP auth field `auth:` with values `oauth` | `header`;
Nebo's mcp_integrations vocabulary is `none` | `oauth` | `api_key`. The
normalization previously copied the value verbatim, so `auth: header`
would have flowed into create_mcp_integration as an invalid auth_type
when the apply slice lands.

Extracts the Hermes-dialect normalization into normalize_mcp_block() so
the mapping is directly testable through parse_mcp_servers_block, adds a
header-auth server to the fixture, and widens the secret-leak assertion
to cover header tokens across all manifest items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
…vider keys

Second slice of the migration importer — the apply half. apply_hermes()
turns a scanned Hermes install into real Nebo artifacts through the
existing canonical write paths:

- mcp_servers → create_mcp_integration rows (shared parse_mcp_servers_block)
- skills/**/SKILL.md → user/skills/, copied wholesale so bundled resources
  survive; Hermes category nesting flattens to Nebo's flat user tier
- SOUL.md → one employee: create_agent row + AGENT.md/agent.json/
  manifest.json under user/agents/, mirroring the user-created pathway
- known LLM provider keys in .env → auth_profiles, matching the existing
  provider-settings pathway's storage semantics

The async apply() wrapper runs the canonical post-install steps: skill
loader reload, finalize_agent_install (same finalizer as a marketplace
hire), and the MCP bridge connect pass (sync_bridge widened to pub(crate)).

Idempotent by skip: existing integration names, skill dirs, the agent dir,
and provider profiles are left untouched and recorded in skipped, so
re-running an import never duplicates or clobbers. Deferred pieces
(memory, history, cron, channel tokens) are named in skipped rather than
silently dropped. A test asserts the source directory is byte-identical
after apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
Wires the migration importer to its first user-facing surface.

Backend:
- GET  /api/v1/import/detect — probe default install locations
  (HERMES_HOME / ~/.hermes, OPENCLAW_HOME / OPENCLAW_STATE_DIR /
  ~/.openclaw), fingerprinted via import::detect, not just dir-exists.
  OpenClaw reports importable=false until its walker ships.
- POST /api/v1/import/scan — read-only dry-run, returns the manifest.
- POST /api/v1/import/apply — performs the import via import::apply.
- Manifest/outcome JSON is camelCase to match the API surface.

Frontend:
- Settings → Import page: detected installs with one-tap Scan, manual
  path entry, grouped manifest preview with "runs code" badges on
  Code-tier items, import receipt including the skipped list.
- Nav entry after MCP; typed API helpers in lib/api/import.ts
  (hand-written tier, nebo.ts is generated); en locale keys (other
  locales fall back to en).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
Replaces the hand-written lib/api/import.ts with the generated client as
the single pathway to the import endpoints:

- handlers/import.rs now returns typed response structs
  (DetectInstallsResponse / ScanInstallResponse / ApplyInstallResponse)
  instead of ad-hoc json! blobs, per the "generated TS interface is the
  single source of truth" convention in handlers/skills.rs
- genapi overrides.go gains ImportItem / ImportManifest / ImportOutcome
  extraInterfaces (their Rust structs live in crates/server/src/import/,
  outside the generator's struct-scan dirs, and carry enum fields the
  parser doesn't model)
- make gen: nebo.ts + neboComponents.ts pick up detectInstalls /
  scanInstall / applyInstall fully typed
- Settings → Import page switched to the generated client;
  lib/api/import.ts deleted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
…mbed

Third slice of the migration importer. Hermes memory files stop being
deferred and become native, searchable Nebo memories:

- memories/*.md parsed into discrete entries (section-sign delimited,
  paragraph fallback, headings dropped) by one shared parser, so the
  dry-run counts exactly what the apply writes
- each entry lands via the canonical upsert_memory pathway, scoped to the
  imported employee (ensure_local_user_id + agent_memory_scope), with
  deterministic content-hash keys (idempotent re-import), provenance
  tags/metadata, USER.md → tacit/preferences, MEMORY.md → tacit/general
- vectors come from the SAME boot backfill native memories use:
  AppState gains the boot embedding_provider, and apply() spawns
  backfill_missing_embeddings after a memory-bearing import — FTS recall
  works immediately, vector recall as soon as the backfill runs
- receipt plumbing: ImportOutcome.memories through overrides.go, make
  gen, the Settings page, and the en locale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
…stamps

Fourth slice of the migration importer. Hermes conversations stop being
deferred and become real threads of the imported employee:

- read_history() opens state.db read-only (rusqlite) and derives
  conversations from the messages table itself — grouped by session_id in
  insertion order — rather than Hermes's version-varying sessions table,
  with a minimal-column fallback and tolerant epoch parsing (int / float /
  string timestamps)
- each conversation becomes a chat named agent:{id}:thread:{chat_id} —
  the same session-name shape native threads use, so imported history
  lists like any other conversation of that employee
- original timestamps survive via new importer-specific inserts in the
  db crate (create_chat_imported / create_chat_message_imported, with
  day_marker derived from the original epoch); titles come from the
  first user message
- roles outside the chat_messages constraint (Hermes-internal
  compression markers etc.) are filtered and counted in the receipt;
  tool_calls JSON carries across; deterministic hermes-{session} chat
  ids make re-import a no-op
- dry-run now shows real conversation/message counts from the same
  reader the apply uses; receipt plumbed through overrides.go, make gen,
  the Settings page, and the en locale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
Fifth slice of the migration importer — OpenClaw installs now import
end-to-end, and the apply layer is restructured into the one funnel both
walkers feed:

- apply.rs: shared importers (import_mcp_block, import_skill_dirs,
  import_agent, import_memory_files, import_conversations,
  import_provider_env) with normalized SourceAgent / SourceConversation
  types; apply_hermes and apply_openclaw are thin per-source assemblies,
  and apply() dispatches on detect and finalizes EVERY created employee
- openclaw.rs: JSON5 config (json5 crate — comments, trailing commas,
  unquoted keys), mcp.servers dialect normalization (streamable-http →
  http, auth oauth / Authorization-header → api_key, disabled servers
  skipped with a note), multi-agent enumeration (default workspace agent
  + agents.entries[] with ~/relative/absolute workspace resolution),
  SOUL.md → soul + AGENTS.md → rules (dedicated agent columns),
  per-agent memory (MEMORY.md / USER.md / memory/*.md daily notes),
  and sessions/*.jsonl transcripts parsed tolerantly ({role,content}
  top-level or under message, Anthropic content blocks flattened,
  non-message lines counted and skipped)
- provider keys import from BOTH .env and the config env block
- import/parse.rs: shared memory splitting / titles / epoch coercion so
  identical content imports identically from either system
- detect endpoint now reports OpenClaw importable; scan dispatches to
  the OpenClaw walker (Settings page needs no changes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
Final surface of the migration importer: onboarding offers the one-tap
"we found your setup" moment, built so it can never slow onboarding down.

Speed:
- detection stays stat-calls only; onboarding fires the probe at mount
  and races it against an 800ms timeout — no result in time simply means
  no card (the import remains available in Settings → Import)
- Hermes scan counts history via SQL COUNT instead of reading every
  message row; OpenClaw scan counts session logs by file and line
  instead of JSON-parsing transcripts (full parsing stays apply-side)

Card (Done step, shown only when an importable install was detected):
found → "Bring it over" runs the dry-run scan and shows the summary
("2 employees · 27 skills · 5 MCP servers · 3 memory files · 2 run
locally") → "Import everything" applies → success line. "Not now"
dismisses; failures point to Settings → Import. Skippable, invisible
when nothing is detected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu
@localrivet

Copy link
Copy Markdown
Contributor Author

Deep review — full diff read, claims verified against code

The good — and it's substantial: one shared funnel with per-source walkers; reuses the canonical pathways end-to-end (parse_mcp_servers_block, finalize_agent_install same as a marketplace hire, sync_bridge, upsert_memory + the boot embedding backfill); idempotent-by-skip with honest skipped receipts; imported agents land as watcher-legible on-disk trios with napp_path; original timestamps preserved via dedicated *_imported DB variants; tests cover idempotency, source-immutability (snapshot before/after), and secret-leak assertions on the manifest. The fixtures are realistic (JSON5 with comments, Anthropic content blocks, junk lines).

Findings, ranked

1. Manifest/apply contract drift (blocking — violates the module's own invariant). mod.rs and the walker docs promise "the dry-run can never promise a different import than the apply performs," but the scan manifest lists:

  • cron jobs → "Scheduled event" (apply defers them),
  • every .env key → "Encrypted credential / secret → encrypted store" (apply imports only the 5 provider keys — and into auth_profiles.api_key, which is plaintext SQLite, not an encrypted store),
  • auth.json → "full OAuth records → encrypted store" — and apply_hermes never touches auth.json and never records it in skipped. That's a silent-empty: the user approves a manifest that says their OAuth records will come over, and they vanish without a receipt.

Fix direction: tier the manifest rows honestly (target: "Deferred — scheduling slice" etc. or a deferred: true flag the UI renders), fix the "encrypted store" wording to match what create_auth_profile actually does, and give auth.json an explicit skip line in apply.

2. OpenClaw cross-agent chat-id collision (real data-loss bug). chat_id = "openclaw-" + <file stem>, but session files live per-agent (agents/<id>/sessions/*.jsonl) — two agents with a same-named session file (fixture pattern m1.jsonl/s1.jsonl avoids this, real installs won't) collide; the second agent's conversation is misreported as "already imported" and dropped. Include the agent id in the chat id (openclaw-<agent>-<stem>).

3. Timestamp coercion misses ms and ISO. value_to_epoch/read_epoch treat any number as epoch seconds and only parse numeric strings. A source shipping milliseconds (1.7e12) produces year-55k dates; ISO-8601 strings fall to None → all history collapses onto import-time+index. Cheap hardening: if n > 100_000_000_000 { n / 1000 } plus an RFC3339 parse attempt.

4. Attach-by-name to pre-existing agents. When <user>/agents/<name> already exists, memories + history attach to whatever DB agent has that name. An unrelated employee named "Scout" inherits a foreign install's conversations. Consider attaching only when the existing agent was itself created by this importer (e.g., manifest.json provenance marker), else skip with a receipt.

5. No per-conversation transaction. A failure mid-create_chat_message_imported leaves a partial chat that every re-run then skips as "already imported." Wrap chat+messages per conversation, or insert the chat row last.

6. Tunnel exposure (ties to the known denylist-drift issue). The import routes merge into the same no-auth local block as the rest of the API. detect() gates arbitrary-dir walking (good), but scan/apply are owner-machine operations that make no sense over the tunnel — worth adding /api/v1/import/* to the tunnel denylist(s) while the drift issue is open.

Nits: handlers take Json<serde_json::Value> where the codebase convention is typed request structs (genapi contract discipline); new i18n keys are en-only (fallback covers it, but the locale batch should pick them up).

Verdict: architecture is right, pathways are canonical, tests are real. #1 and #2 should land before merge; #3#5 are small and worth doing in the same pass. (Merge hold remains until Alma lifts it.)

Addresses all six findings from the PR #42 review:

1. Dry-run over-promise (blocking): the manifest now lists ONLY what
   apply performs. Credential items are limited to known LLM provider
   keys with honest wording ("→ LLM provider profile" — auth_profiles
   stores keys as-given, so no more "encrypted store" claim); other
   .env keys, auth.json OAuth records, and cron jobs become manifest
   NOTES naming their future slice, and apply now emits a matching
   skipped receipt for auth.json instead of dropping it silently.
   provider_for_env_key is shared by scan and apply so they can't drift.

2. Chat-id collision (blocking, data loss): chat ids now carry the
   source agent's slug (openclaw-main-s1 vs openclaw-scout-s1), so two
   agents' same-named session files can never collide. The OpenClaw
   fixture deliberately gives both agents an s1.jsonl and asserts both
   conversations survive.

3. Timestamps: normalize_epoch maps ms/µs/ns magnitudes down to seconds
   (OpenClaw is Node — Date.now() is ms) and ISO-8601 strings parse via
   chrono; applied in both the SQLite reader and the JSONL reader.

4. Attach-by-name: follow-up memory/history now attach only to agent
   dirs carrying the importer's own "importedFrom" manifest marker,
   verified against the DB row id — a pre-existing user-created agent
   sharing the name inherits nothing (regression test included).

5. Partial-chat poisoning: each conversation is all-or-nothing — on a
   message insert failure the stub chat is deleted (messages follow via
   FK cascade), the failure lands in the receipt, and the rest of the
   import continues; re-runs can retry (regression test included).

6. Tunnel denylist: /api/v1/import/ joins is_blocked_path — the import
   endpoints read arbitrary local directories and copy credentials, an
   owner-machine operation never legitimate through the tunnel. Also
   repairs the pre-existing ConnectPayload test drift (missing
   hostname/platform) that kept nebo-comm's test suite from compiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFTXd9CWAcd9rHSZN4iPJu

Copy link
Copy Markdown
Contributor Author

All six review findings addressed in 958de6b (branch agent here — the session that authored the import commits).

1. Dry-run over-promise (blocking) — fixed. The manifest now lists only what apply performs. Credential items are limited to the known LLM provider keys with honest wording (→ LLM provider profile; the "encrypted store" claim is gone — auth_profiles stores keys as-given, matching the existing provider-settings pathway). All other .env keys, auth.json, and cron jobs are manifest notes naming their future slice, and apply emits a matching skipped receipt for auth.json instead of dropping it silently. provider_for_env_key is now shared by scan and apply so the two can't drift again.

2. Chat-id collision (blocking) — fixed. Chat ids carry the source agent's slug (openclaw-main-s1 / openclaw-scout-s1). The fixture now deliberately gives both agents an s1.jsonl and asserts both conversations survive.

3. Timestamps — fixed. normalize_epoch maps ms/µs/ns magnitudes to seconds (OpenClaw is Node, so Date.now() ms was the likely case, not an edge case) and ISO-8601 parses via chrono — applied in both the SQLite and JSONL readers, with unit tests.

4. Attach-by-name — fixed. Follow-up memory/history attach only to agent dirs carrying the importer's own importedFrom marker in manifest.json, verified against the DB row. Note: id-existence alone wasn't sufficient — the user-created agent pathway also writes manifest.json with an id — hence the explicit marker. Regression test: a pre-existing user-created "Hermes" inherits nothing.

5. Partial-chat poisoning — fixed. Each conversation is all-or-nothing: on a message-insert failure the stub chat is deleted (messages follow via FK cascade), the failure lands in the receipt, and the rest of the import continues. Regression test poisons a message id and asserts no stub remains and the other conversation still imports.

6. Tunnel denylist — done. /api/v1/import/ joined is_blocked_path (these endpoints read arbitrary local directories and copy credentials — owner-machine only). While verifying: nebo-comm's test suite didn't compile at all (pre-existing ConnectPayload test drift, missing hostname/platform) — repaired so the denylist tests actually run.

74/74 server lib tests and the full nebo-comm suite pass; pnpm check clean.


Generated by Claude Code

@localrivet
localrivet merged commit 9025abd into main Aug 1, 2026
2 checks passed
@localrivet
localrivet deleted the claude/multi-agent-commands-ftybu2 branch August 1, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants