mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
fix(execenv): isolate Hermes SQLite state (#5560)
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -122,7 +122,7 @@ ACP-protocol agent, primarily aimed at the Chinese market. MCP config is passed
|
||||
|
||||
### Hermes (Nous Research)
|
||||
|
||||
ACP-protocol agent (shares the transport with Kimi). Session resumption works, and MCP config is passed through ACP `mcpServers`. Hermes discovers skills only from its home (`~/.hermes/skills/`), so when an agent has skills assigned Multica points `HERMES_HOME` at a per-task overlay of your `~/.hermes/` (real home mirrored by symlink; a derived `config.yaml` references your existing skills as external roots) and writes the assigned skills into its `skills/` directory for native discovery. Tasks with no assigned skills run against your real home unchanged.
|
||||
ACP-protocol agent (shares the transport with Kimi). Session resumption works, and MCP config is passed through ACP `mcpServers`. Hermes discovers skills only from its home (`~/.hermes/skills/`), so when an agent has skills assigned Multica points `HERMES_HOME` at a per-task overlay of your `~/.hermes/` (real home mirrored by symlink; a derived `config.yaml` references your existing skills as external roots) and writes the assigned skills into its `skills/` directory for native discovery. The SQLite `state.db` session store and its journal files remain task-local, so host conversation history is neither linked nor copied and live Windows WAL locks cannot block setup. Tasks with no assigned skills run against your real home unchanged.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -58,7 +58,7 @@ Anysphere が提供し、Cursor エディターに対応する CLI です。**
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。Hermes はスキルを自身のホームディレクトリ(`~/.hermes/skills/`)と設定済みの `skills.external_dirs` からのみ探索し、ワークスペース相対の探索は行いません。そのため、**エージェントにスキルが割り当てられている場合のみ**、Multica は `HERMES_HOME` をユーザーの `~/.hermes/` のタスクごとオーバーレイに向けます。実ホームはシンボリックリンクでミラーされ(auth/シークレットも含むためトークン更新が伝播します)、派生した `config.yaml` が既存スキルを読み取り専用の external root として参照し、割り当てられたスキルだけがその `skills/` ディレクトリに書き込まれて優先されます。グローバルスキルはそのまま機能します。Multica は共有の `~/.hermes/` を直接書き換えず、読み取ってリンクするだけですが、Hermes 自身がミラーされたシンボリックリンク経由で書き込む場合(トークン更新など)は伝播します。`custom_args` の `-p`/`--profile` 指定は尊重されます。オーバーレイはその profile のホームからシードされ、フラグは起動コマンドから消費されるため、オーバーレイを迂回できません。タスクの記憶はタスクごとに隔離されます。新しい `memories/` ディレクトリに加え、派生設定で外部 `memory.provider` バックエンドを無効化するので、ディスク上のメモも共有の Supermemory/Hindsight 的バンクもタスク間で共有されません(マネージドで agent スコープの記憶バックエンドは今後の別課題です)。スキルが割り当てられていない Hermes タスクは実ホームのまま変わりません。なお home のプラットフォーム既定はネイティブ Windows では `%LOCALAPPDATA%\hermes` です。
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。Hermes はスキルを自身のホームディレクトリ(`~/.hermes/skills/`)と設定済みの `skills.external_dirs` からのみ探索し、ワークスペース相対の探索は行いません。そのため、**エージェントにスキルが割り当てられている場合のみ**、Multica は `HERMES_HOME` をユーザーの `~/.hermes/` のタスクごとオーバーレイに向けます。実ホームはシンボリックリンクでミラーされ(auth/シークレットも含むためトークン更新が伝播します)、派生した `config.yaml` が既存スキルを読み取り専用の external root として参照し、割り当てられたスキルだけがその `skills/` ディレクトリに書き込まれて優先されます。グローバルスキルはそのまま機能します。Multica は共有の `~/.hermes/` を直接書き換えず、読み取ってリンクするだけですが、Hermes 自身がミラーされたシンボリックリンク経由で書き込む場合(トークン更新など)は伝播します。`custom_args` の `-p`/`--profile` 指定は尊重されます。オーバーレイはその profile のホームからシードされ、フラグは起動コマンドから消費されるため、オーバーレイを迂回できません。タスクの記憶はタスクごとに隔離されます。新しい `memories/` ディレクトリに加え、派生設定で外部 `memory.provider` バックエンドを無効化するので、ディスク上のメモも共有の Supermemory/Hindsight 的バンクもタスク間で共有されません(マネージドで agent スコープの記憶バックエンドは今後の別課題です)。SQLite の `state.db` セッションストアとジャーナルファイルもタスクローカルで、Hermes がオーバーレイ内に作成します。ホストの会話履歴はリンクもコピーもされず、Windows で稼働中の WAL ロックがセットアップを妨げることもありません。スキルが割り当てられていない Hermes タスクは実ホームのまま変わりません。なお home のプラットフォーム既定はネイティブ Windows では `%LOCALAPPDATA%\hermes` です。
|
||||
|
||||
**Hermes profile を選択する。** 特定の profile で Hermes を起動するには、エージェントの `custom_args` に profile フラグと profile 名を 2 つの独立したエントリとして設定します。たとえば `research` という profile を使う場合:
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research에서 제공합니다. ACP 프로토콜을 사용합니다(Kimi와 전송 계층을 공유합니다). 세션 재개가 동작하고, MCP 구성은 ACP `mcpServers`로 전달됩니다. Hermes는 스킬을 자신의 홈 디렉터리(`~/.hermes/skills/`)와 설정된 `skills.external_dirs`에서만 탐색하며, 워크스페이스 상대 탐색은 하지 않습니다. 그래서 **에이전트에 스킬이 할당된 경우에만** Multica는 `HERMES_HOME`을 사용자 `~/.hermes/`의 작업별 오버레이로 향하게 합니다. 실제 홈은 심링크로 미러되고(auth/시크릿 포함, 토큰 갱신이 전파됨), 파생된 `config.yaml`이 기존 스킬을 읽기 전용 external root로 참조하며, 할당된 스킬만 그 `skills/` 디렉터리에 기록되어 우선합니다. 전역 스킬은 그대로 동작합니다. Multica는 공유 `~/.hermes/`를 직접 수정하지 않고 읽어서 링크만 하지만, Hermes가 미러된 심링크를 통해 쓰는 경우(토큰 갱신 등)는 전파됩니다. `custom_args`의 `-p`/`--profile` 선택은 존중됩니다. 오버레이는 그 profile의 홈에서 시드되고, 플래그는 실행 명령에서 소비되어 오버레이를 우회할 수 없습니다. 작업 메모리는 작업별로 격리됩니다. 새 `memories/` 디렉터리에 더해 파생 설정에서 외부 `memory.provider` 백엔드를 비활성화하므로, 디스크상의 메모도 공유 Supermemory/Hindsight 류의 뱅크도 작업 간에 공유되지 않습니다(관리형 agent 범위 메모리 백엔드는 향후 별도 과제입니다). 스킬이 할당되지 않은 Hermes 작업은 실제 홈 그대로 실행됩니다. 참고로 홈의 플랫폼 기본값은 네이티브 Windows에서 `%LOCALAPPDATA%\hermes`입니다.
|
||||
Nous Research에서 제공합니다. ACP 프로토콜을 사용합니다(Kimi와 전송 계층을 공유합니다). 세션 재개가 동작하고, MCP 구성은 ACP `mcpServers`로 전달됩니다. Hermes는 스킬을 자신의 홈 디렉터리(`~/.hermes/skills/`)와 설정된 `skills.external_dirs`에서만 탐색하며, 워크스페이스 상대 탐색은 하지 않습니다. 그래서 **에이전트에 스킬이 할당된 경우에만** Multica는 `HERMES_HOME`을 사용자 `~/.hermes/`의 작업별 오버레이로 향하게 합니다. 실제 홈은 심링크로 미러되고(auth/시크릿 포함, 토큰 갱신이 전파됨), 파생된 `config.yaml`이 기존 스킬을 읽기 전용 external root로 참조하며, 할당된 스킬만 그 `skills/` 디렉터리에 기록되어 우선합니다. 전역 스킬은 그대로 동작합니다. Multica는 공유 `~/.hermes/`를 직접 수정하지 않고 읽어서 링크만 하지만, Hermes가 미러된 심링크를 통해 쓰는 경우(토큰 갱신 등)는 전파됩니다. `custom_args`의 `-p`/`--profile` 선택은 존중됩니다. 오버레이는 그 profile의 홈에서 시드되고, 플래그는 실행 명령에서 소비되어 오버레이를 우회할 수 없습니다. 작업 메모리는 작업별로 격리됩니다. 새 `memories/` 디렉터리에 더해 파생 설정에서 외부 `memory.provider` 백엔드를 비활성화하므로, 디스크상의 메모도 공유 Supermemory/Hindsight 류의 뱅크도 작업 간에 공유되지 않습니다(관리형 agent 범위 메모리 백엔드는 향후 별도 과제입니다). `state.db` SQLite 세션 저장소와 저널 파일도 작업 로컬이며 Hermes가 오버레이 안에서 생성합니다. 호스트 대화 기록은 링크되거나 복사되지 않고, Windows에서 활성 WAL 잠금이 환경 준비를 막지 않습니다. 스킬이 할당되지 않은 Hermes 작업은 실제 홈 그대로 실행됩니다. 참고로 홈의 플랫폼 기본값은 네이티브 Windows에서 `%LOCALAPPDATA%\hermes`입니다.
|
||||
|
||||
**Hermes profile 선택.** 특정 profile로 Hermes를 실행하려면 에이전트의 `custom_args`에 profile 플래그와 profile 이름을 두 개의 독립된 항목으로 설정하세요. 예를 들어 `research`라는 profile을 사용하려면:
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption w
|
||||
|
||||
### Hermes
|
||||
|
||||
From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Session resumption works, and MCP config is passed through ACP `mcpServers`. Hermes discovers skills only from its home directory (`~/.hermes/skills/`, or `%LOCALAPPDATA%\hermes\skills\` on native Windows) and configured `skills.external_dirs` — it has no workspace-relative discovery. So **when an agent has skills assigned**, Multica points `HERMES_HOME` at a per-task overlay of your Hermes home: your real home is mirrored by symlink (auth/secrets included, so token refreshes propagate), a derived `config.yaml` references your existing skills as read-only external roots, and only the assigned skills are written into its `skills/` directory — where they take precedence, so your global skills keep working. A `-p`/`--profile` selection in `custom_args` is honored: the overlay is seeded from that profile's home, and the flag is consumed from the launch command so it can't slip past the overlay. Multica never writes to your shared home directly (it only reads it and links into it), though writes Hermes itself makes through a mirrored symlink — a token refresh, say — do propagate. Task memory is isolated per task: a fresh `memories/` directory, plus the external `memory.provider` backend is disabled in the derived config, so neither on-disk notes nor a shared Supermemory/Hindsight-style bank crosses between tasks (a managed, agent-scoped memory backend is a separate future decision). A Hermes task with no assigned skills runs against your real home unchanged.
|
||||
From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Session resumption works, and MCP config is passed through ACP `mcpServers`. Hermes discovers skills only from its home directory (`~/.hermes/skills/`, or `%LOCALAPPDATA%\hermes\skills\` on native Windows) and configured `skills.external_dirs` — it has no workspace-relative discovery. So **when an agent has skills assigned**, Multica points `HERMES_HOME` at a per-task overlay of your Hermes home: your real home is mirrored by symlink (auth/secrets included, so token refreshes propagate), a derived `config.yaml` references your existing skills as read-only external roots, and only the assigned skills are written into its `skills/` directory — where they take precedence, so your global skills keep working. A `-p`/`--profile` selection in `custom_args` is honored: the overlay is seeded from that profile's home, and the flag is consumed from the launch command so it can't slip past the overlay. Multica never writes to your shared home directly (it only reads it and links into it), though writes Hermes itself makes through a mirrored symlink — a token refresh, say — do propagate. Task memory is isolated per task: a fresh `memories/` directory, plus the external `memory.provider` backend is disabled in the derived config, so neither on-disk notes nor a shared Supermemory/Hindsight-style bank crosses between tasks (a managed, agent-scoped memory backend is a separate future decision). The SQLite `state.db` session store and its journal files are task-local too: Hermes creates them inside the overlay, so host conversation history is neither linked nor copied and a live Windows WAL lock cannot block setup. A Hermes task with no assigned skills runs against your real home unchanged.
|
||||
|
||||
**Selecting a Hermes profile.** To launch Hermes under a specific profile, set the agent's `custom_args` to the profile flag and the profile name as two separate entries — for example, for a profile named `research`:
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复可用**—
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层)。会话恢复真用,MCP 配置通过 ACP `mcpServers` 传入。Hermes 只从自己的 home 目录(`~/.hermes/skills/`)和配置的 `skills.external_dirs` 发现 skill,没有工作区相对发现。因此,**只有当智能体分配了 skill 时**,Multica 才会把 `HERMES_HOME` 指向你 `~/.hermes/` 的单次任务 overlay:真实 home 用软链镜像(含 auth/密钥,令牌刷新可传播),派生的 `config.yaml` 把你已有的 skill 作为只读 external root 引用,只有被分配的 skill 写入它的 `skills/` 目录并具更高优先级。你的全局 skill 照常可用。Multica 不会直接写你的共享 `~/.hermes/`(只读取并链接进去),但 Hermes 自己通过被镜像的软链写入时(如刷新令牌)会传播。`custom_args` 里的 `-p`/`--profile` 选择会被尊重:overlay 从该 profile 的 home 播种,且该标志会从启动命令里消费掉,无法绕过 overlay。任务记忆按任务隔离:一个全新的 `memories/` 目录,外加在派生配置里禁用外部 `memory.provider` 后端,所以磁盘上的笔记和共享的 Supermemory/Hindsight 之类记忆库都不会在任务间串用(受管、按 agent 隔离的记忆后端是后续单独决策)。没有分配 skill 的 Hermes 任务按真实 home 原样运行。另外,home 的平台默认值在原生 Windows 上是 `%LOCALAPPDATA%\hermes`。
|
||||
Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层)。会话恢复真用,MCP 配置通过 ACP `mcpServers` 传入。Hermes 只从自己的 home 目录(`~/.hermes/skills/`)和配置的 `skills.external_dirs` 发现 skill,没有工作区相对发现。因此,**只有当智能体分配了 skill 时**,Multica 才会把 `HERMES_HOME` 指向你 `~/.hermes/` 的单次任务 overlay:真实 home 用软链镜像(含 auth/密钥,令牌刷新可传播),派生的 `config.yaml` 把你已有的 skill 作为只读 external root 引用,只有被分配的 skill 写入它的 `skills/` 目录并具更高优先级。你的全局 skill 照常可用。Multica 不会直接写你的共享 `~/.hermes/`(只读取并链接进去),但 Hermes 自己通过被镜像的软链写入时(如刷新令牌)会传播。`custom_args` 里的 `-p`/`--profile` 选择会被尊重:overlay 从该 profile 的 home 播种,且该标志会从启动命令里消费掉,无法绕过 overlay。任务记忆按任务隔离:一个全新的 `memories/` 目录,外加在派生配置里禁用外部 `memory.provider` 后端,所以磁盘上的笔记和共享的 Supermemory/Hindsight 之类记忆库都不会在任务间串用(受管、按 agent 隔离的记忆后端是后续单独决策)。`state.db` SQLite 会话库及其日志文件也按任务隔离,由 Hermes 在 overlay 内创建;宿主会话历史不会被链接或复制,Windows 上运行中的 WAL 锁也不会阻塞环境准备。没有分配 skill 的 Hermes 任务按真实 home 原样运行。另外,home 的平台默认值在原生 Windows 上是 `%LOCALAPPDATA%\hermes`。
|
||||
|
||||
**指定 Hermes profile。** 要让 Hermes 使用某个 profile 启动,把智能体的 `custom_args` 设成 profile flag 和 profile 名两个独立条目。例如使用名为 `research` 的 profile:
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ import (
|
||||
// the shared home: Hermes loads and writes back MEMORY.md/USER.md there, and
|
||||
// per-agent memory is a Multica product concern — the host's local Hermes
|
||||
// memory must not bleed into a task, nor task memory back out to the host;
|
||||
// - keeps the state.db SQLite session store and its journal sidecars
|
||||
// overlay-owned too: Hermes creates them lazily, Reuse preserves them for
|
||||
// the task, and host conversation history is never linked or copied;
|
||||
// - disables the external `memory.provider` in the derived config so a
|
||||
// host-configured Supermemory/Hindsight/etc. backend isn't shared across
|
||||
// tasks. This is the on-disk + external-backend memory isolation; a managed,
|
||||
@@ -54,13 +57,17 @@ import (
|
||||
// runtime (e.g. refreshing token state under a mirrored auth/OAuth path) that
|
||||
// write does reach the shared home — that propagation is intentional.
|
||||
|
||||
// hermesOverriddenEntries are the top-level entries of the shared ~/.hermes/
|
||||
// that the overlay supplies its own task-local version of, so they are NOT
|
||||
// mirrored from the shared home and are preserved across reuse reconciliation:
|
||||
// hermesOverriddenEntries are the fixed top-level entries of the shared
|
||||
// ~/.hermes/ that the overlay supplies its own task-local version of, so they
|
||||
// are NOT mirrored from the shared home and are preserved across reuse
|
||||
// reconciliation:
|
||||
// - skills/ task-local, only the bound skills
|
||||
// - config.yaml derived config with absolutized external_dirs
|
||||
// - memories/ fresh per-task dir, isolated from the host's memory
|
||||
// - marker below records that legacy shared SQLite state was detached
|
||||
//
|
||||
// The state.db SQLite family is classified dynamically by
|
||||
// isHermesOverlayOwnedEntry so every journal sidecar stays task-local too.
|
||||
// Everything else in the shared home is mirrored generically.
|
||||
//
|
||||
// active_profile and profiles are also overlay-owned (never mirrored): Hermes
|
||||
@@ -80,13 +87,32 @@ import (
|
||||
// HERMES_HOME to the overlay, and is written even when the source has none so
|
||||
// Hermes' project-.env fallback (loaded with override=True only when no user
|
||||
// .env loaded) can't relocate the home either.
|
||||
const hermesTaskLocalStateMarker = ".multica-task-local-state-v1"
|
||||
|
||||
var hermesOverriddenEntries = map[string]struct{}{
|
||||
"skills": {},
|
||||
"config.yaml": {},
|
||||
"memories": {},
|
||||
"active_profile": {},
|
||||
"profiles": {},
|
||||
".env": {},
|
||||
"skills": {},
|
||||
"config.yaml": {},
|
||||
"memories": {},
|
||||
"active_profile": {},
|
||||
"profiles": {},
|
||||
".env": {},
|
||||
hermesTaskLocalStateMarker: {},
|
||||
}
|
||||
|
||||
// isHermesOverlayOwnedEntry reports whether name belongs to the per-task
|
||||
// overlay rather than the shared Hermes home. Hermes' state.db is the canonical
|
||||
// session store and uses WAL mode; mirroring its main file and journal sidecars
|
||||
// separately can produce an inconsistent snapshot, exposes host conversation
|
||||
// history, and fails on Windows when SQLite byte-range-locks state.db-shm.
|
||||
func isHermesOverlayOwnedEntry(name string) bool {
|
||||
if _, owned := hermesOverriddenEntries[name]; owned {
|
||||
return true
|
||||
}
|
||||
return isHermesTaskLocalStateEntry(name)
|
||||
}
|
||||
|
||||
func isHermesTaskLocalStateEntry(name string) bool {
|
||||
return name == "state.db" || strings.HasPrefix(name, "state.db-")
|
||||
}
|
||||
|
||||
// platformDefaultHermesHome returns Hermes' platform-native default home:
|
||||
@@ -360,6 +386,9 @@ func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, work
|
||||
if err := os.Chmod(hermesHome, 0o700); err != nil {
|
||||
return fmt.Errorf("chmod hermes-home dir: %w", err)
|
||||
}
|
||||
if err := prepareHermesTaskLocalState(hermesHome); err != nil {
|
||||
return fmt.Errorf("prepare task-local state: %w", err)
|
||||
}
|
||||
// Fresh, isolated per-task memories dir (idempotent — preserved across reuse
|
||||
// so the task/issue lifecycle keeps its own memory).
|
||||
if err := os.MkdirAll(filepath.Join(hermesHome, "memories"), 0o700); err != nil {
|
||||
@@ -469,7 +498,7 @@ func mirrorSharedHermesHome(sharedHome, hermesHome string, logger *slog.Logger)
|
||||
mirrored := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if _, overridden := hermesOverriddenEntries[name]; overridden {
|
||||
if isHermesOverlayOwnedEntry(name) {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(sharedHome, name)
|
||||
@@ -492,7 +521,7 @@ func reconcileMirroredEntries(hermesHome string, mirrored map[string]struct{}) e
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if _, owned := hermesOverriddenEntries[name]; owned {
|
||||
if isHermesOverlayOwnedEntry(name) {
|
||||
continue
|
||||
}
|
||||
if _, keep := mirrored[name]; keep {
|
||||
@@ -505,6 +534,39 @@ func reconcileMirroredEntries(hermesHome string, mirrored map[string]struct{}) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareHermesTaskLocalState migrates an overlay built by an older daemon away
|
||||
// from the shared Hermes SQLite session store. Without the marker, state.db and
|
||||
// its sidecars may be symlinks or independently copied files; neither is safe to
|
||||
// reuse as task-local state. Remove only those entries inside the generated
|
||||
// overlay, then record the migration atomically. Hermes lazily creates a fresh
|
||||
// database, and later prepares preserve it because the marker is present.
|
||||
func prepareHermesTaskLocalState(hermesHome string) error {
|
||||
marker := filepath.Join(hermesHome, hermesTaskLocalStateMarker)
|
||||
if fi, err := os.Lstat(marker); err == nil {
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("state marker is not a regular file: %s", marker)
|
||||
}
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat state marker: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(hermesHome)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read overlay home: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !isHermesTaskLocalStateEntry(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(hermesHome, entry.Name())
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return fmt.Errorf("remove legacy task state %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return writeFileAtomic(marker, []byte("task-local Hermes state\n"), 0o600)
|
||||
}
|
||||
|
||||
// linkSharedHermesEntry symlinks dst → src, idempotent across Reuse: an existing
|
||||
// link already pointing at src is left alone; anything else is removed and
|
||||
// recreated so the overlay never drifts from the shared home. A dangling source
|
||||
|
||||
@@ -264,6 +264,99 @@ func TestHermesOverlayIsolatesMemories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHermesOverlayKeepsSessionDatabaseTaskLocal verifies the live SQLite
|
||||
// session store is never mirrored from the host. Hermes creates it lazily in
|
||||
// the task home, and reuse must preserve that task-local database instead of
|
||||
// replacing it with a fresh host snapshot.
|
||||
func TestHermesOverlayKeepsSessionDatabaseTaskLocal(t *testing.T) {
|
||||
t.Parallel()
|
||||
sharedHome := t.TempDir()
|
||||
mustWrite(t, filepath.Join(sharedHome, "config.yaml"), "model: hermes-4\n")
|
||||
stateFiles := []string{"state.db", "state.db-wal", "state.db-shm", "state.db-journal"}
|
||||
for _, name := range stateFiles {
|
||||
mustWrite(t, filepath.Join(sharedHome, name), "HOST "+name)
|
||||
}
|
||||
|
||||
hermesHome := filepath.Join(t.TempDir(), "hermes-home")
|
||||
skills := []SkillContextForEnv{{Name: "Review Helper", Content: "x"}}
|
||||
if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil {
|
||||
t.Fatalf("prepareHermesHome failed: %v", err)
|
||||
}
|
||||
for _, name := range stateFiles {
|
||||
if _, err := os.Lstat(filepath.Join(hermesHome, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host %s must not be mirrored into a fresh task overlay: %v", name, err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(hermesHome, name), "TASK "+name)
|
||||
mustWrite(t, filepath.Join(sharedHome, name), "UPDATED HOST "+name)
|
||||
}
|
||||
|
||||
if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil {
|
||||
t.Fatalf("prepareHermesHome (reuse) failed: %v", err)
|
||||
}
|
||||
for _, name := range stateFiles {
|
||||
data, err := os.ReadFile(filepath.Join(hermesHome, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read task-local %s after reuse: %v", name, err)
|
||||
}
|
||||
if got, want := string(data), "TASK "+name; got != want {
|
||||
t.Errorf("task-local %s after reuse = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHermesOverlayMigratesLegacySessionDatabase verifies an overlay created by
|
||||
// an older daemon drops potentially inconsistent copied SQLite files once,
|
||||
// without touching the host, then preserves the database Hermes creates locally.
|
||||
func TestHermesOverlayMigratesLegacySessionDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
sharedHome := t.TempDir()
|
||||
mustWrite(t, filepath.Join(sharedHome, "config.yaml"), "model: hermes-4\n")
|
||||
stateFiles := []string{"state.db", "state.db-wal", "state.db-shm", "state.db-journal"}
|
||||
for _, name := range stateFiles {
|
||||
mustWrite(t, filepath.Join(sharedHome, name), "HOST "+name)
|
||||
}
|
||||
|
||||
hermesHome := filepath.Join(t.TempDir(), "hermes-home")
|
||||
for _, name := range stateFiles {
|
||||
mustWrite(t, filepath.Join(hermesHome, name), "LEGACY COPY "+name)
|
||||
}
|
||||
// POSIX overlays used symlinks while Windows without symlink privilege used
|
||||
// copies. Exercise the symlink migration when the host permits creating one.
|
||||
walPath := filepath.Join(hermesHome, "state.db-wal")
|
||||
if err := os.Remove(walPath); err != nil {
|
||||
t.Fatalf("remove copied WAL before symlink setup: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(sharedHome, "state.db-wal"), walPath); err != nil {
|
||||
mustWrite(t, walPath, "LEGACY COPY state.db-wal")
|
||||
}
|
||||
skills := []SkillContextForEnv{{Name: "Review Helper", Content: "x"}}
|
||||
if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil {
|
||||
t.Fatalf("prepareHermesHome failed: %v", err)
|
||||
}
|
||||
for _, name := range stateFiles {
|
||||
if _, err := os.Lstat(filepath.Join(hermesHome, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("legacy overlay %s should be removed during migration: %v", name, err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(sharedHome, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read host %s after migration: %v", name, err)
|
||||
}
|
||||
if got, want := string(data), "HOST "+name; got != want {
|
||||
t.Errorf("host %s changed during migration: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
mustWrite(t, filepath.Join(hermesHome, "state.db"), "TASK DB")
|
||||
if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil {
|
||||
t.Fatalf("prepareHermesHome (reuse) failed: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(hermesHome, "state.db")); err != nil {
|
||||
t.Fatalf("read migrated task-local state.db after reuse: %v", err)
|
||||
} else if got := string(data); got != "TASK DB" {
|
||||
t.Errorf("migrated task-local state.db after reuse = %q, want TASK DB", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHermesOverlayPermissions asserts the task home is 0700 and the derived
|
||||
// config (which can hold inline api_key secrets) is 0600.
|
||||
func TestHermesOverlayPermissions(t *testing.T) {
|
||||
@@ -287,6 +380,11 @@ func TestHermesOverlayPermissions(t *testing.T) {
|
||||
} else if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("derived config perms = %o, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(hermesHome, hermesTaskLocalStateMarker)); err != nil {
|
||||
t.Fatalf("stat task-local state marker: %v", err)
|
||||
} else if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("task-local state marker perms = %o, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHermesOverlayReconcilesDeletedSharedEntry asserts a top-level entry
|
||||
|
||||
Reference in New Issue
Block a user