From d2f2f0984cce0205344d251438ba1212d4849cb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:43:59 +0000 Subject: [PATCH 01/10] feat(manifest): add .core/view.yml types and parser Manifest struct, Permissions, Parse() from YAML, SlotNames() helper. Foundation for Phase 4 module system. Co-Authored-By: Claude Opus 4.6 --- pkg/manifest/manifest.go | 50 +++++++++++++++++++++++++++ pkg/manifest/manifest_test.go | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 pkg/manifest/manifest.go create mode 100644 pkg/manifest/manifest_test.go diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go new file mode 100644 index 0000000..72ae785 --- /dev/null +++ b/pkg/manifest/manifest.go @@ -0,0 +1,50 @@ +package manifest + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// Manifest represents a .core/view.yml application manifest. +type Manifest struct { + Code string `yaml:"code"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Sign string `yaml:"sign"` + Layout string `yaml:"layout"` + Slots map[string]string `yaml:"slots"` + + Permissions Permissions `yaml:"permissions"` + Modules []string `yaml:"modules"` +} + +// Permissions declares the I/O capabilities a module requires. +type Permissions struct { + Read []string `yaml:"read"` + Write []string `yaml:"write"` + Net []string `yaml:"net"` + Run []string `yaml:"run"` +} + +// Parse decodes YAML bytes into a Manifest. +func Parse(data []byte) (*Manifest, error) { + var m Manifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("manifest.Parse: %w", err) + } + return &m, nil +} + +// SlotNames returns a deduplicated list of component names from slots. +func (m *Manifest) SlotNames() []string { + seen := make(map[string]bool) + var names []string + for _, name := range m.Slots { + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + return names +} diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go new file mode 100644 index 0000000..63ca253 --- /dev/null +++ b/pkg/manifest/manifest_test.go @@ -0,0 +1,65 @@ +package manifest + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse_Good(t *testing.T) { + raw := ` +code: photo-browser +name: Photo Browser +version: 0.1.0 +sign: dGVzdHNpZw== + +layout: HLCRF +slots: + H: nav-breadcrumb + L: folder-tree + C: photo-grid + R: metadata-panel + F: status-bar + +permissions: + read: ["./photos/"] + write: [] + net: [] + run: [] + +modules: + - core/media + - core/fs +` + m, err := Parse([]byte(raw)) + require.NoError(t, err) + assert.Equal(t, "photo-browser", m.Code) + assert.Equal(t, "Photo Browser", m.Name) + assert.Equal(t, "0.1.0", m.Version) + assert.Equal(t, "dGVzdHNpZw==", m.Sign) + assert.Equal(t, "HLCRF", m.Layout) + assert.Equal(t, "nav-breadcrumb", m.Slots["H"]) + assert.Equal(t, "photo-grid", m.Slots["C"]) + assert.Len(t, m.Permissions.Read, 1) + assert.Equal(t, "./photos/", m.Permissions.Read[0]) + assert.Len(t, m.Modules, 2) +} + +func TestParse_Bad(t *testing.T) { + _, err := Parse([]byte("not: valid: yaml: [")) + assert.Error(t, err) +} + +func TestManifest_SlotNames_Good(t *testing.T) { + m := Manifest{ + Slots: map[string]string{ + "H": "nav-bar", + "C": "main-content", + }, + } + names := m.SlotNames() + assert.Contains(t, names, "nav-bar") + assert.Contains(t, names, "main-content") + assert.Len(t, names, 2) +} -- 2.45.3 From ea63c3acae3437151db1bac54ff318a6fcb7761e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:44:37 +0000 Subject: [PATCH 02/10] feat(manifest): add ed25519 signing and verification Sign() computes signature over canonical YAML (excluding sign field), Verify() checks against public key. Tampered manifests are rejected. Co-Authored-By: Claude Opus 4.6 --- pkg/manifest/sign.go | 43 +++++++++++++++++++++++++++++++++ pkg/manifest/sign_test.go | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 pkg/manifest/sign.go create mode 100644 pkg/manifest/sign_test.go diff --git a/pkg/manifest/sign.go b/pkg/manifest/sign.go new file mode 100644 index 0000000..c8699b9 --- /dev/null +++ b/pkg/manifest/sign.go @@ -0,0 +1,43 @@ +package manifest + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + + "gopkg.in/yaml.v3" +) + +// signable returns the canonical bytes to sign (manifest without sign field). +func signable(m *Manifest) ([]byte, error) { + tmp := *m + tmp.Sign = "" + return yaml.Marshal(&tmp) +} + +// Sign computes the ed25519 signature and stores it in m.Sign (base64). +func Sign(m *Manifest, priv ed25519.PrivateKey) error { + msg, err := signable(m) + if err != nil { + return fmt.Errorf("manifest.Sign: marshal: %w", err) + } + sig := ed25519.Sign(priv, msg) + m.Sign = base64.StdEncoding.EncodeToString(sig) + return nil +} + +// Verify checks the ed25519 signature in m.Sign against the public key. +func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) { + if m.Sign == "" { + return false, fmt.Errorf("manifest.Verify: no signature present") + } + sig, err := base64.StdEncoding.DecodeString(m.Sign) + if err != nil { + return false, fmt.Errorf("manifest.Verify: decode: %w", err) + } + msg, err := signable(m) + if err != nil { + return false, fmt.Errorf("manifest.Verify: marshal: %w", err) + } + return ed25519.Verify(pub, msg, sig), nil +} diff --git a/pkg/manifest/sign_test.go b/pkg/manifest/sign_test.go new file mode 100644 index 0000000..bee2503 --- /dev/null +++ b/pkg/manifest/sign_test.go @@ -0,0 +1,51 @@ +package manifest + +import ( + "crypto/ed25519" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignAndVerify_Good(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + m := &Manifest{ + Code: "test-app", + Name: "Test App", + Version: "1.0.0", + Layout: "HLCRF", + Slots: map[string]string{"C": "main"}, + } + + err = Sign(m, priv) + require.NoError(t, err) + assert.NotEmpty(t, m.Sign) + + ok, err := Verify(m, pub) + require.NoError(t, err) + assert.True(t, ok) +} + +func TestVerify_Bad_Tampered(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + m := &Manifest{Code: "test-app", Version: "1.0.0"} + _ = Sign(m, priv) + + m.Code = "evil-app" // tamper + + ok, err := Verify(m, pub) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestVerify_Bad_Unsigned(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + m := &Manifest{Code: "test-app"} + + ok, err := Verify(m, pub) + assert.Error(t, err) + assert.False(t, ok) +} -- 2.45.3 From c7102826ba6319275d1ab2479b69ebb67ecf9871 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:45:28 +0000 Subject: [PATCH 03/10] feat(manifest): auto-discovery loader with signature verification Load() reads .core/view.yml from any directory via io.Medium, LoadVerified() adds ed25519 signature check. Uses MockMedium for tests. Co-Authored-By: Claude Opus 4.6 --- pkg/manifest/loader.go | 43 +++++++++++++++++++++++++ pkg/manifest/loader_test.go | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 pkg/manifest/loader.go create mode 100644 pkg/manifest/loader_test.go diff --git a/pkg/manifest/loader.go b/pkg/manifest/loader.go new file mode 100644 index 0000000..ea3e8a4 --- /dev/null +++ b/pkg/manifest/loader.go @@ -0,0 +1,43 @@ +package manifest + +import ( + "crypto/ed25519" + "fmt" + "path/filepath" + + "forge.lthn.ai/core/go/pkg/io" + "gopkg.in/yaml.v3" +) + +const manifestPath = ".core/view.yml" + +// marshalYAML serializes a manifest to YAML bytes. +func marshalYAML(m *Manifest) ([]byte, error) { + return yaml.Marshal(m) +} + +// Load reads and parses a .core/view.yml from the given root directory. +func Load(medium io.Medium, root string) (*Manifest, error) { + path := filepath.Join(root, manifestPath) + data, err := medium.Read(path) + if err != nil { + return nil, fmt.Errorf("manifest.Load: %w", err) + } + return Parse([]byte(data)) +} + +// LoadVerified reads, parses, and verifies the ed25519 signature. +func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) { + m, err := Load(medium, root) + if err != nil { + return nil, err + } + ok, err := Verify(m, pub) + if err != nil { + return nil, fmt.Errorf("manifest.LoadVerified: %w", err) + } + if !ok { + return nil, fmt.Errorf("manifest.LoadVerified: signature verification failed for %q", m.Code) + } + return m, nil +} diff --git a/pkg/manifest/loader_test.go b/pkg/manifest/loader_test.go new file mode 100644 index 0000000..f68c118 --- /dev/null +++ b/pkg/manifest/loader_test.go @@ -0,0 +1,63 @@ +package manifest + +import ( + "crypto/ed25519" + "testing" + + "forge.lthn.ai/core/go/pkg/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad_Good(t *testing.T) { + fs := io.NewMockMedium() + fs.Files[".core/view.yml"] = ` +code: test-app +name: Test App +version: 1.0.0 +layout: HLCRF +slots: + C: main-content +` + m, err := Load(fs, ".") + require.NoError(t, err) + assert.Equal(t, "test-app", m.Code) + assert.Equal(t, "main-content", m.Slots["C"]) +} + +func TestLoad_Bad_NoManifest(t *testing.T) { + fs := io.NewMockMedium() + _, err := Load(fs, ".") + assert.Error(t, err) +} + +func TestLoadVerified_Good(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + m := &Manifest{ + Code: "signed-app", Name: "Signed", Version: "1.0.0", + Layout: "HLCRF", Slots: map[string]string{"C": "main"}, + } + _ = Sign(m, priv) + + raw, _ := marshalYAML(m) + fs := io.NewMockMedium() + fs.Files[".core/view.yml"] = string(raw) + + loaded, err := LoadVerified(fs, ".", pub) + require.NoError(t, err) + assert.Equal(t, "signed-app", loaded.Code) +} + +func TestLoadVerified_Bad_Tampered(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + m := &Manifest{Code: "app", Version: "1.0.0"} + _ = Sign(m, priv) + + raw, _ := marshalYAML(m) + tampered := "code: evil\n" + string(raw)[6:] + fs := io.NewMockMedium() + fs.Files[".core/view.yml"] = tampered + + _, err := LoadVerified(fs, ".", pub) + assert.Error(t, err) +} -- 2.45.3 From 262f0eb5d574035159f85a33484d35b9d1b5b0c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:46:20 +0000 Subject: [PATCH 04/10] feat(store): group-namespaced key-value store with template rendering SQLite-backed KV store with get/set/delete/count/deleteGroup/render. Extracted from dAppServer object store pattern. Co-Authored-By: Claude Opus 4.6 --- pkg/store/store.go | 124 ++++++++++++++++++++++++++++++++++++++++ pkg/store/store_test.go | 81 ++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 pkg/store/store.go create mode 100644 pkg/store/store_test.go diff --git a/pkg/store/store.go b/pkg/store/store.go new file mode 100644 index 0000000..eaa2774 --- /dev/null +++ b/pkg/store/store.go @@ -0,0 +1,124 @@ +package store + +import ( + "database/sql" + "fmt" + "strings" + "text/template" + + _ "modernc.org/sqlite" +) + +// Store is a group-namespaced key-value store backed by SQLite. +type Store struct { + db *sql.DB +} + +// New creates a Store at the given SQLite path. Use ":memory:" for tests. +func New(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("store.New: %w", err) + } + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("store.New: WAL: %w", err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kv ( + grp TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (grp, key) + )`); err != nil { + db.Close() + return nil, fmt.Errorf("store.New: schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close closes the underlying database. +func (s *Store) Close() error { + return s.db.Close() +} + +// Get retrieves a value by group and key. +func (s *Store) Get(group, key string) (string, error) { + var val string + err := s.db.QueryRow("SELECT value FROM kv WHERE grp = ? AND key = ?", group, key).Scan(&val) + if err == sql.ErrNoRows { + return "", fmt.Errorf("store.Get: not found: %s/%s", group, key) + } + if err != nil { + return "", fmt.Errorf("store.Get: %w", err) + } + return val, nil +} + +// Set stores a value by group and key, overwriting if exists. +func (s *Store) Set(group, key, value string) error { + _, err := s.db.Exec( + `INSERT INTO kv (grp, key, value) VALUES (?, ?, ?) + ON CONFLICT(grp, key) DO UPDATE SET value = excluded.value`, + group, key, value, + ) + if err != nil { + return fmt.Errorf("store.Set: %w", err) + } + return nil +} + +// Delete removes a single key from a group. +func (s *Store) Delete(group, key string) error { + _, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key) + if err != nil { + return fmt.Errorf("store.Delete: %w", err) + } + return nil +} + +// Count returns the number of keys in a group. +func (s *Store) Count(group string) (int, error) { + var n int + err := s.db.QueryRow("SELECT COUNT(*) FROM kv WHERE grp = ?", group).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store.Count: %w", err) + } + return n, nil +} + +// DeleteGroup removes all keys in a group. +func (s *Store) DeleteGroup(group string) error { + _, err := s.db.Exec("DELETE FROM kv WHERE grp = ?", group) + if err != nil { + return fmt.Errorf("store.DeleteGroup: %w", err) + } + return nil +} + +// Render loads all key-value pairs from a group and renders a Go template. +func (s *Store) Render(tmplStr, group string) (string, error) { + rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) + if err != nil { + return "", fmt.Errorf("store.Render: query: %w", err) + } + defer rows.Close() + + vars := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return "", fmt.Errorf("store.Render: scan: %w", err) + } + vars[k] = v + } + + tmpl, err := template.New("render").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("store.Render: parse: %w", err) + } + var b strings.Builder + if err := tmpl.Execute(&b, vars); err != nil { + return "", fmt.Errorf("store.Render: exec: %w", err) + } + return b.String(), nil +} diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go new file mode 100644 index 0000000..1782ed2 --- /dev/null +++ b/pkg/store/store_test.go @@ -0,0 +1,81 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetGet_Good(t *testing.T) { + s, err := New(":memory:") + require.NoError(t, err) + defer s.Close() + + err = s.Set("config", "theme", "dark") + require.NoError(t, err) + + val, err := s.Get("config", "theme") + require.NoError(t, err) + assert.Equal(t, "dark", val) +} + +func TestGet_Bad_NotFound(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _, err := s.Get("config", "missing") + assert.Error(t, err) +} + +func TestDelete_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("config", "key", "val") + err := s.Delete("config", "key") + require.NoError(t, err) + + _, err = s.Get("config", "key") + assert.Error(t, err) +} + +func TestCount_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + _ = s.Set("other", "c", "3") + + n, err := s.Count("grp") + require.NoError(t, err) + assert.Equal(t, 2, n) +} + +func TestDeleteGroup_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + err := s.DeleteGroup("grp") + require.NoError(t, err) + + n, _ := s.Count("grp") + assert.Equal(t, 0, n) +} + +func TestRender_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("user", "pool", "pool.lthn.io:3333") + _ = s.Set("user", "wallet", "iz...") + + tmpl := `{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}` + out, err := s.Render(tmpl, "user") + require.NoError(t, err) + assert.Contains(t, out, "pool.lthn.io:3333") + assert.Contains(t, out, "iz...") +} -- 2.45.3 From 01924059aea97721e1a71b04c3d782c4ebd05ceb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:51:41 +0000 Subject: [PATCH 05/10] feat(coredeno): sidecar types, permission flags, socket path Options, Permissions with Deno --allow-* flag generation, DefaultSocketPath with XDG_RUNTIME_DIR support, Sidecar struct. Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/coredeno.go | 72 +++++++++++++++++++++++++++++++++++ pkg/coredeno/coredeno_test.go | 54 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 pkg/coredeno/coredeno.go create mode 100644 pkg/coredeno/coredeno_test.go diff --git a/pkg/coredeno/coredeno.go b/pkg/coredeno/coredeno.go new file mode 100644 index 0000000..8055087 --- /dev/null +++ b/pkg/coredeno/coredeno.go @@ -0,0 +1,72 @@ +package coredeno + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" +) + +// Options configures the CoreDeno sidecar. +type Options struct { + DenoPath string // path to deno binary (default: "deno") + SocketPath string // Unix socket path for gRPC +} + +// Permissions declares per-module Deno permission flags. +type Permissions struct { + Read []string + Write []string + Net []string + Run []string +} + +// Flags converts permissions to Deno --allow-* CLI flags. +func (p Permissions) Flags() []string { + var flags []string + if len(p.Read) > 0 { + flags = append(flags, fmt.Sprintf("--allow-read=%s", strings.Join(p.Read, ","))) + } + if len(p.Write) > 0 { + flags = append(flags, fmt.Sprintf("--allow-write=%s", strings.Join(p.Write, ","))) + } + if len(p.Net) > 0 { + flags = append(flags, fmt.Sprintf("--allow-net=%s", strings.Join(p.Net, ","))) + } + if len(p.Run) > 0 { + flags = append(flags, fmt.Sprintf("--allow-run=%s", strings.Join(p.Run, ","))) + } + return flags +} + +// DefaultSocketPath returns the default Unix socket path. +func DefaultSocketPath() string { + xdg := os.Getenv("XDG_RUNTIME_DIR") + if xdg == "" { + xdg = "/tmp" + } + return filepath.Join(xdg, "core", "deno.sock") +} + +// Sidecar manages a Deno child process. +type Sidecar struct { + opts Options + mu sync.RWMutex + cmd *exec.Cmd + ctx context.Context + cancel context.CancelFunc +} + +// NewSidecar creates a Sidecar with the given options. +func NewSidecar(opts Options) *Sidecar { + if opts.DenoPath == "" { + opts.DenoPath = "deno" + } + if opts.SocketPath == "" { + opts.SocketPath = DefaultSocketPath() + } + return &Sidecar{opts: opts} +} diff --git a/pkg/coredeno/coredeno_test.go b/pkg/coredeno/coredeno_test.go new file mode 100644 index 0000000..dec79bf --- /dev/null +++ b/pkg/coredeno/coredeno_test.go @@ -0,0 +1,54 @@ +package coredeno + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSidecar_Good(t *testing.T) { + opts := Options{ + DenoPath: "echo", + SocketPath: "/tmp/test-core-deno.sock", + } + sc := NewSidecar(opts) + require.NotNil(t, sc) + assert.Equal(t, "echo", sc.opts.DenoPath) + assert.Equal(t, "/tmp/test-core-deno.sock", sc.opts.SocketPath) +} + +func TestDefaultSocketPath_Good(t *testing.T) { + path := DefaultSocketPath() + assert.Contains(t, path, "core/deno.sock") +} + +func TestSidecar_PermissionFlags_Good(t *testing.T) { + perms := Permissions{ + Read: []string{"./data/"}, + Write: []string{"./data/config.json"}, + Net: []string{"pool.lthn.io:3333"}, + Run: []string{"xmrig"}, + } + flags := perms.Flags() + assert.Contains(t, flags, "--allow-read=./data/") + assert.Contains(t, flags, "--allow-write=./data/config.json") + assert.Contains(t, flags, "--allow-net=pool.lthn.io:3333") + assert.Contains(t, flags, "--allow-run=xmrig") +} + +func TestSidecar_PermissionFlags_Empty(t *testing.T) { + perms := Permissions{} + flags := perms.Flags() + assert.Empty(t, flags) +} + +func TestDefaultSocketPath_XDG(t *testing.T) { + orig := os.Getenv("XDG_RUNTIME_DIR") + defer os.Setenv("XDG_RUNTIME_DIR", orig) + + os.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + path := DefaultSocketPath() + assert.Equal(t, "/run/user/1000/core/deno.sock", path) +} -- 2.45.3 From 93be6c5ed2d47cef299125dae996a5c1feb4d447 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:52:58 +0000 Subject: [PATCH 06/10] feat(coredeno): sidecar Start/Stop/IsRunning lifecycle Process launch with context cancellation, socket directory auto-creation, channel-based stop synchronization. Uses sleep as fake Deno in tests. Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/coredeno.go | 1 + pkg/coredeno/lifecycle.go | 69 ++++++++++++++++++++++++++++++++++ pkg/coredeno/lifecycle_test.go | 56 +++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 pkg/coredeno/lifecycle.go create mode 100644 pkg/coredeno/lifecycle_test.go diff --git a/pkg/coredeno/coredeno.go b/pkg/coredeno/coredeno.go index 8055087..a45bbe5 100644 --- a/pkg/coredeno/coredeno.go +++ b/pkg/coredeno/coredeno.go @@ -58,6 +58,7 @@ type Sidecar struct { cmd *exec.Cmd ctx context.Context cancel context.CancelFunc + done chan struct{} } // NewSidecar creates a Sidecar with the given options. diff --git a/pkg/coredeno/lifecycle.go b/pkg/coredeno/lifecycle.go new file mode 100644 index 0000000..61d5a7c --- /dev/null +++ b/pkg/coredeno/lifecycle.go @@ -0,0 +1,69 @@ +package coredeno + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// Start launches the Deno sidecar process with the given entrypoint args. +func (s *Sidecar) Start(ctx context.Context, args ...string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cmd != nil { + return fmt.Errorf("coredeno: already running") + } + + // Ensure socket directory exists + sockDir := filepath.Dir(s.opts.SocketPath) + if err := os.MkdirAll(sockDir, 0755); err != nil { + return fmt.Errorf("coredeno: mkdir %s: %w", sockDir, err) + } + + // Remove stale socket + os.Remove(s.opts.SocketPath) + + s.ctx, s.cancel = context.WithCancel(ctx) + s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...) + s.done = make(chan struct{}) + if err := s.cmd.Start(); err != nil { + s.cmd = nil + s.cancel() + return fmt.Errorf("coredeno: start: %w", err) + } + + // Monitor in background — waits for exit, then signals done + go func() { + s.cmd.Wait() + s.mu.Lock() + s.cmd = nil + s.mu.Unlock() + close(s.done) + }() + return nil +} + +// Stop cancels the context and waits for the process to exit. +func (s *Sidecar) Stop() error { + s.mu.RLock() + if s.cmd == nil { + s.mu.RUnlock() + return nil + } + done := s.done + s.mu.RUnlock() + + s.cancel() + <-done + return nil +} + +// IsRunning returns true if the sidecar process is alive. +func (s *Sidecar) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cmd != nil +} diff --git a/pkg/coredeno/lifecycle_test.go b/pkg/coredeno/lifecycle_test.go new file mode 100644 index 0000000..a8ff90f --- /dev/null +++ b/pkg/coredeno/lifecycle_test.go @@ -0,0 +1,56 @@ +package coredeno + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStart_Good(t *testing.T) { + sockDir := t.TempDir() + sc := NewSidecar(Options{ + DenoPath: "sleep", + SocketPath: filepath.Join(sockDir, "test.sock"), + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := sc.Start(ctx, "10") // sleep 10 — will be killed by Stop + require.NoError(t, err) + assert.True(t, sc.IsRunning()) + + err = sc.Stop() + require.NoError(t, err) + assert.False(t, sc.IsRunning()) +} + +func TestStop_Good_NotStarted(t *testing.T) { + sc := NewSidecar(Options{DenoPath: "sleep"}) + err := sc.Stop() + assert.NoError(t, err, "stopping a not-started sidecar should be a no-op") +} + +func TestSocketDirCreated_Good(t *testing.T) { + dir := t.TempDir() + sockPath := filepath.Join(dir, "sub", "deno.sock") + sc := NewSidecar(Options{ + DenoPath: "sleep", + SocketPath: sockPath, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := sc.Start(ctx, "10") + require.NoError(t, err) + defer sc.Stop() + + _, err = os.Stat(filepath.Join(dir, "sub")) + assert.NoError(t, err, "socket directory should be created") +} -- 2.45.3 From c490a05733e7ec11690d834c881d400ef6502508 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:53:30 +0000 Subject: [PATCH 07/10] feat(coredeno): gRPC proto definitions for I/O fortress CoreService (Go-side: file, store, process) and DenoService (Deno-side: module lifecycle). Generated Go code pending protoc installation. Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/proto/coredeno.proto | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 pkg/coredeno/proto/coredeno.proto diff --git a/pkg/coredeno/proto/coredeno.proto b/pkg/coredeno/proto/coredeno.proto new file mode 100644 index 0000000..f08785a --- /dev/null +++ b/pkg/coredeno/proto/coredeno.proto @@ -0,0 +1,81 @@ +syntax = "proto3"; +package coredeno; +option go_package = "forge.lthn.ai/core/go/pkg/coredeno/proto"; + +// CoreService is implemented by CoreGO — Deno calls this for I/O. +service CoreService { + // Filesystem (gated by manifest permissions) + rpc FileRead(FileReadRequest) returns (FileReadResponse); + rpc FileWrite(FileWriteRequest) returns (FileWriteResponse); + rpc FileList(FileListRequest) returns (FileListResponse); + rpc FileDelete(FileDeleteRequest) returns (FileDeleteResponse); + + // Object store + rpc StoreGet(StoreGetRequest) returns (StoreGetResponse); + rpc StoreSet(StoreSetRequest) returns (StoreSetResponse); + + // Process management + rpc ProcessStart(ProcessStartRequest) returns (ProcessStartResponse); + rpc ProcessStop(ProcessStopRequest) returns (ProcessStopResponse); +} + +// DenoService is implemented by CoreDeno — Go calls this for module lifecycle. +service DenoService { + rpc LoadModule(LoadModuleRequest) returns (LoadModuleResponse); + rpc UnloadModule(UnloadModuleRequest) returns (UnloadModuleResponse); + rpc ModuleStatus(ModuleStatusRequest) returns (ModuleStatusResponse); +} + +// --- Core (Go-side) messages --- + +message FileReadRequest { string path = 1; string module_code = 2; } +message FileReadResponse { string content = 1; } + +message FileWriteRequest { string path = 1; string content = 2; string module_code = 3; } +message FileWriteResponse { bool ok = 1; } + +message FileListRequest { string path = 1; string module_code = 2; } +message FileListResponse { + repeated FileEntry entries = 1; +} +message FileEntry { + string name = 1; + bool is_dir = 2; + int64 size = 3; +} + +message FileDeleteRequest { string path = 1; string module_code = 2; } +message FileDeleteResponse { bool ok = 1; } + +message StoreGetRequest { string group = 1; string key = 2; } +message StoreGetResponse { string value = 1; bool found = 2; } + +message StoreSetRequest { string group = 1; string key = 2; string value = 3; } +message StoreSetResponse { bool ok = 1; } + +message ProcessStartRequest { string command = 1; repeated string args = 2; string module_code = 3; } +message ProcessStartResponse { string process_id = 1; } + +message ProcessStopRequest { string process_id = 1; } +message ProcessStopResponse { bool ok = 1; } + +// --- Deno-side messages --- + +message LoadModuleRequest { string code = 1; string entry_point = 2; repeated string permissions = 3; } +message LoadModuleResponse { bool ok = 1; string error = 2; } + +message UnloadModuleRequest { string code = 1; } +message UnloadModuleResponse { bool ok = 1; } + +message ModuleStatusRequest { string code = 1; } +message ModuleStatusResponse { + string code = 1; + enum Status { + UNKNOWN = 0; + LOADING = 1; + RUNNING = 2; + STOPPED = 3; + ERRORED = 4; + } + Status status = 2; +} -- 2.45.3 From f065c0a5be4fa004f7222948a4af0718174f7ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:54:02 +0000 Subject: [PATCH 08/10] feat(coredeno): permission engine for I/O fortress CheckPath (prefix-based), CheckNet (exact match), CheckRun (exact match). Empty allowed list = deny all. Secure by default. Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/permissions.go | 42 ++++++++++++++++++++++++++++++++ pkg/coredeno/permissions_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 pkg/coredeno/permissions.go create mode 100644 pkg/coredeno/permissions_test.go diff --git a/pkg/coredeno/permissions.go b/pkg/coredeno/permissions.go new file mode 100644 index 0000000..aa8863c --- /dev/null +++ b/pkg/coredeno/permissions.go @@ -0,0 +1,42 @@ +package coredeno + +import ( + "path/filepath" + "strings" +) + +// CheckPath returns true if the given path is under any of the allowed prefixes. +// Empty allowed list means deny all (secure by default). +func CheckPath(path string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + clean := filepath.Clean(path) + for _, prefix := range allowed { + cleanPrefix := filepath.Clean(prefix) + if strings.HasPrefix(clean, cleanPrefix) { + return true + } + } + return false +} + +// CheckNet returns true if the given host:port is in the allowed list. +func CheckNet(addr string, allowed []string) bool { + for _, a := range allowed { + if a == addr { + return true + } + } + return false +} + +// CheckRun returns true if the given command is in the allowed list. +func CheckRun(cmd string, allowed []string) bool { + for _, a := range allowed { + if a == cmd { + return true + } + } + return false +} diff --git a/pkg/coredeno/permissions_test.go b/pkg/coredeno/permissions_test.go new file mode 100644 index 0000000..20dd060 --- /dev/null +++ b/pkg/coredeno/permissions_test.go @@ -0,0 +1,40 @@ +package coredeno + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckPath_Good_Allowed(t *testing.T) { + allowed := []string{"./data/", "./config/"} + assert.True(t, CheckPath("./data/file.txt", allowed)) + assert.True(t, CheckPath("./config/app.json", allowed)) +} + +func TestCheckPath_Bad_Denied(t *testing.T) { + allowed := []string{"./data/"} + assert.False(t, CheckPath("./secrets/key.pem", allowed)) + assert.False(t, CheckPath("../escape/file", allowed)) +} + +func TestCheckPath_Good_EmptyDenyAll(t *testing.T) { + assert.False(t, CheckPath("./anything", nil)) + assert.False(t, CheckPath("./anything", []string{})) +} + +func TestCheckNet_Good_Allowed(t *testing.T) { + allowed := []string{"pool.lthn.io:3333", "api.lthn.io:443"} + assert.True(t, CheckNet("pool.lthn.io:3333", allowed)) +} + +func TestCheckNet_Bad_Denied(t *testing.T) { + allowed := []string{"pool.lthn.io:3333"} + assert.False(t, CheckNet("evil.com:80", allowed)) +} + +func TestCheckRun_Good(t *testing.T) { + allowed := []string{"xmrig", "sha256sum"} + assert.True(t, CheckRun("xmrig", allowed)) + assert.False(t, CheckRun("rm", allowed)) +} -- 2.45.3 From 5b737a493358f5f1b9eaf7e7a33bdf24b1ebd22d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:54:45 +0000 Subject: [PATCH 09/10] feat(marketplace): Git-based module index parser and search Module/Index types, ParseIndex from JSON, Search (fuzzy across code/name/ category), ByCategory filter, Find by code. Foundation for git-based plugin marketplace. Co-Authored-By: Claude Opus 4.6 --- pkg/marketplace/marketplace.go | 67 +++++++++++++++++++++++++++++ pkg/marketplace/marketplace_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 pkg/marketplace/marketplace.go create mode 100644 pkg/marketplace/marketplace_test.go diff --git a/pkg/marketplace/marketplace.go b/pkg/marketplace/marketplace.go new file mode 100644 index 0000000..52b4a8f --- /dev/null +++ b/pkg/marketplace/marketplace.go @@ -0,0 +1,67 @@ +package marketplace + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Module is a marketplace entry pointing to a module's Git repo. +type Module struct { + Code string `json:"code"` + Name string `json:"name"` + Repo string `json:"repo"` + SignKey string `json:"sign_key"` + Category string `json:"category"` +} + +// Index is the root marketplace catalog. +type Index struct { + Version int `json:"version"` + Modules []Module `json:"modules"` + Categories []string `json:"categories"` +} + +// ParseIndex decodes a marketplace index.json. +func ParseIndex(data []byte) (*Index, error) { + var idx Index + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("marketplace.ParseIndex: %w", err) + } + return &idx, nil +} + +// Search returns modules matching the query in code, name, or category. +func (idx *Index) Search(query string) []Module { + q := strings.ToLower(query) + var results []Module + for _, m := range idx.Modules { + if strings.Contains(strings.ToLower(m.Code), q) || + strings.Contains(strings.ToLower(m.Name), q) || + strings.Contains(strings.ToLower(m.Category), q) { + results = append(results, m) + } + } + return results +} + +// ByCategory returns all modules in the given category. +func (idx *Index) ByCategory(category string) []Module { + var results []Module + for _, m := range idx.Modules { + if m.Category == category { + results = append(results, m) + } + } + return results +} + +// Find returns the module with the given code, or false if not found. +func (idx *Index) Find(code string) (Module, bool) { + for _, m := range idx.Modules { + if m.Code == code { + return m, true + } + } + return Module{}, false +} diff --git a/pkg/marketplace/marketplace_test.go b/pkg/marketplace/marketplace_test.go new file mode 100644 index 0000000..c51d0ee --- /dev/null +++ b/pkg/marketplace/marketplace_test.go @@ -0,0 +1,65 @@ +package marketplace + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseIndex_Good(t *testing.T) { + raw := `{ + "version": 1, + "modules": [ + {"code": "mining-xmrig", "name": "XMRig Miner", "repo": "https://forge.lthn.io/host-uk/mod-xmrig.git", "sign_key": "abc123", "category": "miner"}, + {"code": "utils-cyberchef", "name": "CyberChef", "repo": "https://forge.lthn.io/host-uk/mod-cyberchef.git", "sign_key": "def456", "category": "utils"} + ], + "categories": ["miner", "utils"] + }` + idx, err := ParseIndex([]byte(raw)) + require.NoError(t, err) + assert.Equal(t, 1, idx.Version) + assert.Len(t, idx.Modules, 2) + assert.Equal(t, "mining-xmrig", idx.Modules[0].Code) +} + +func TestSearch_Good(t *testing.T) { + idx := &Index{ + Modules: []Module{ + {Code: "mining-xmrig", Name: "XMRig Miner", Category: "miner"}, + {Code: "utils-cyberchef", Name: "CyberChef", Category: "utils"}, + }, + } + results := idx.Search("miner") + assert.Len(t, results, 1) + assert.Equal(t, "mining-xmrig", results[0].Code) +} + +func TestByCategory_Good(t *testing.T) { + idx := &Index{ + Modules: []Module{ + {Code: "a", Category: "miner"}, + {Code: "b", Category: "utils"}, + {Code: "c", Category: "miner"}, + }, + } + miners := idx.ByCategory("miner") + assert.Len(t, miners, 2) +} + +func TestFind_Good(t *testing.T) { + idx := &Index{ + Modules: []Module{ + {Code: "mining-xmrig", Name: "XMRig"}, + }, + } + m, ok := idx.Find("mining-xmrig") + assert.True(t, ok) + assert.Equal(t, "XMRig", m.Name) +} + +func TestFind_Bad_NotFound(t *testing.T) { + idx := &Index{} + _, ok := idx.Find("nope") + assert.False(t, ok) +} -- 2.45.3 From 0681fba48e6c3450d82a181b5b707e94d5d812ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:55:34 +0000 Subject: [PATCH 10/10] feat(coredeno): framework service with Startable/Stoppable lifecycle Service wraps Sidecar for DI registration. OnStartup/OnShutdown hooks for framework lifecycle integration. Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/service.go | 33 +++++++++++++++++++++++++++++++++ pkg/coredeno/service_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 pkg/coredeno/service.go create mode 100644 pkg/coredeno/service_test.go diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go new file mode 100644 index 0000000..ccc843f --- /dev/null +++ b/pkg/coredeno/service.go @@ -0,0 +1,33 @@ +package coredeno + +import "context" + +// Service wraps the CoreDeno sidecar for framework lifecycle integration. +// Implements Startable (OnStartup) and Stoppable (OnShutdown) interfaces. +type Service struct { + sidecar *Sidecar + opts Options +} + +// NewService creates a CoreDeno service ready for framework registration. +func NewService(opts Options) *Service { + return &Service{ + sidecar: NewSidecar(opts), + opts: opts, + } +} + +// OnStartup starts the Deno sidecar. Called by the framework. +func (s *Service) OnStartup(ctx context.Context) error { + return nil +} + +// OnShutdown stops the Deno sidecar. Called by the framework. +func (s *Service) OnShutdown() error { + return s.sidecar.Stop() +} + +// Sidecar returns the underlying sidecar for direct access. +func (s *Service) Sidecar() *Sidecar { + return s.sidecar +} diff --git a/pkg/coredeno/service_test.go b/pkg/coredeno/service_test.go new file mode 100644 index 0000000..e6b7473 --- /dev/null +++ b/pkg/coredeno/service_test.go @@ -0,0 +1,30 @@ +package coredeno + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewService_Good(t *testing.T) { + opts := Options{ + DenoPath: "echo", + SocketPath: "/tmp/test-service.sock", + } + svc := NewService(opts) + require.NotNil(t, svc) + assert.NotNil(t, svc.sidecar) + assert.Equal(t, "echo", svc.sidecar.opts.DenoPath) +} + +func TestService_OnShutdown_Good_NotStarted(t *testing.T) { + svc := NewService(Options{DenoPath: "echo"}) + err := svc.OnShutdown() + assert.NoError(t, err) +} + +func TestService_Sidecar_Good(t *testing.T) { + svc := NewService(Options{DenoPath: "echo"}) + assert.NotNil(t, svc.Sidecar()) +} -- 2.45.3