mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
9ceabf68c4
Replace the host bind-mount + containerd exec approach with a per-bot
in-container gRPC server (ContainerService, port 9090). All file I/O,
exec, and MCP stdio sessions now go through gRPC instead of running
shell commands or reading host-mounted directories.
Architecture changes:
- cmd/mcp: rewritten as a gRPC server (ContainerService) with full
file and exec API (ReadFile, WriteFile, ListDir, ReadRaw, WriteRaw,
Exec, Stat, Mkdir, Rename, DeleteFile)
- internal/mcp/mcpcontainer: protobuf definitions and generated stubs
- internal/mcp/mcpclient: gRPC client wrapper with connection pool
(Pool) and Provider interface for dependency injection
- mcp.Manager: add per-bot IP cache, gRPC connection pool, and
SetContainerIP/MCPClient methods; remove DataDir/Exec helpers
- containerd.Service: remove ExecTask/ExecTaskStreaming; network setup
now returns NetworkResult{IP} for pool routing
- internal/fs/service.go: deleted (replaced by mcpclient)
- handlers/fs.go: deleted; MCP stdio session logic moved to mcp_stdio.go
- container provider Executor: all tools (read/write/list/edit/exec)
now call gRPC client instead of running shell via exec
- storefs, containerfs, media, skills, memory: all I/O ported to
mcpclient.Provider
Database:
- migration 0022: drop host_path column from containers table
One-time data migration:
- migrateBindMountData: on first Start() after upgrade, copies old
bind-mount data into the container via gRPC, then renames src dir
to prevent re-migration; runs in background goroutine
Bug fixes:
- mcp_stdio: callRaw now returns full JSON-RPC envelope
{"jsonrpc","id","result"|"error"} matching protocol spec;
explicit "initialize" call now advances session init state to
prevent duplicate handshake on next non-initialize call
- mcpclient Pool: properly evict stale gRPC connection after snapshot
replace (container process recreated); use SetContainerIP instead
of direct map write so IP changes always evict pool entry
- migrateBindMountData: walkErr on directories now counted as failure
so partially-walked trees don't get incorrectly marked as migrated
- cmd/mcp/Dockerfile: removed dead file (docker/Dockerfile.mcp is the
canonical production build)
Tests:
- provider_test.go: restored with bufconn in-process gRPC mock
(fakeContainerService + staticProvider), 14 cases covering all 5
tools plus edge cases
- mcp_session_test.go: new, covers JSON-RPC envelope, init state
machine, pending cleanup on cancel/close, readLoop cancel
- storefs/service_test.go: restored (pure function roundtrip tests)
76 lines
1.7 KiB
Go
76 lines
1.7 KiB
Go
package containerfs
|
|
|
|
import "testing"
|
|
|
|
func TestParseRoutingKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
key string
|
|
wantErr bool
|
|
}{
|
|
{key: "bot-1/image/ab12/ab12cd.png", wantErr: false},
|
|
{key: "/absolute/path", wantErr: true},
|
|
{key: "../escape", wantErr: true},
|
|
{key: "nosubpath", wantErr: true},
|
|
{key: "", wantErr: true},
|
|
}
|
|
for _, tt := range tests {
|
|
_, _, err := parseRoutingKey(tt.key)
|
|
if tt.wantErr && err == nil {
|
|
t.Errorf("parseRoutingKey(%q) expected error", tt.key)
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("parseRoutingKey(%q) unexpected error: %v", tt.key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProvider_AccessPath(t *testing.T) {
|
|
t.Parallel()
|
|
p := &Provider{}
|
|
|
|
tests := []struct {
|
|
key string
|
|
want string
|
|
}{
|
|
{key: "bot-1/image/ab12/ab12cd.png", want: "/data/media/image/ab12/ab12cd.png"},
|
|
{key: "bot-1/file/xx/doc.pdf", want: "/data/media/file/xx/doc.pdf"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := p.AccessPath(tt.key)
|
|
if got != tt.want {
|
|
t.Errorf("AccessPath(%q) = %q, want %q", tt.key, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseRoutingKey_PathTraversal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bad := []string{
|
|
"../etc/passwd",
|
|
"/absolute/key",
|
|
"bot-1/../../escape",
|
|
}
|
|
for _, key := range bad {
|
|
if _, _, err := parseRoutingKey(key); err == nil {
|
|
t.Errorf("parseRoutingKey(%q) should reject traversal", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitRoutingKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
botID, sub := splitRoutingKey("bot-1/image/test.png")
|
|
if botID != "bot-1" || sub != "image/test.png" {
|
|
t.Errorf("splitRoutingKey: got (%q, %q)", botID, sub)
|
|
}
|
|
|
|
botID2, sub2 := splitRoutingKey("nosubpath")
|
|
if botID2 != "" || sub2 != "nosubpath" {
|
|
t.Errorf("splitRoutingKey single: got (%q, %q)", botID2, sub2)
|
|
}
|
|
}
|