From e8695b72a606e368720b7f026e8e8386882d8c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 21:09:40 +0000 Subject: [PATCH 1/6] feat(coredeno): gRPC server with permission-gated I/O fortress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated Go code from proto. Server implements CoreService with FileRead/FileWrite/FileList/FileDelete/StoreGet/StoreSet — every request checked against the calling module's manifest permissions. Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 +- go.sum | 9 + pkg/coredeno/proto/coredeno.pb.go | 1420 ++++++++++++++++++++++++ pkg/coredeno/proto/coredeno_grpc.pb.go | 579 ++++++++++ pkg/coredeno/server.go | 131 +++ pkg/coredeno/server_test.go | 97 ++ 6 files changed, 2238 insertions(+), 2 deletions(-) create mode 100644 pkg/coredeno/proto/coredeno.pb.go create mode 100644 pkg/coredeno/proto/coredeno_grpc.pb.go create mode 100644 pkg/coredeno/server.go create mode 100644 pkg/coredeno/server_test.go diff --git a/go.mod b/go.mod index 5bd28cb..d8169da 100644 --- a/go.mod +++ b/go.mod @@ -112,8 +112,8 @@ require ( golang.org/x/tools v0.42.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.17.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 61be66c..d043f23 100644 --- a/go.sum +++ b/go.sum @@ -247,14 +247,19 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -299,8 +304,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/coredeno/proto/coredeno.pb.go b/pkg/coredeno/proto/coredeno.pb.go new file mode 100644 index 0000000..2a04e7a --- /dev/null +++ b/pkg/coredeno/proto/coredeno.pb.go @@ -0,0 +1,1420 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: pkg/coredeno/proto/coredeno.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ModuleStatusResponse_Status int32 + +const ( + ModuleStatusResponse_UNKNOWN ModuleStatusResponse_Status = 0 + ModuleStatusResponse_LOADING ModuleStatusResponse_Status = 1 + ModuleStatusResponse_RUNNING ModuleStatusResponse_Status = 2 + ModuleStatusResponse_STOPPED ModuleStatusResponse_Status = 3 + ModuleStatusResponse_ERRORED ModuleStatusResponse_Status = 4 +) + +// Enum value maps for ModuleStatusResponse_Status. +var ( + ModuleStatusResponse_Status_name = map[int32]string{ + 0: "UNKNOWN", + 1: "LOADING", + 2: "RUNNING", + 3: "STOPPED", + 4: "ERRORED", + } + ModuleStatusResponse_Status_value = map[string]int32{ + "UNKNOWN": 0, + "LOADING": 1, + "RUNNING": 2, + "STOPPED": 3, + "ERRORED": 4, + } +) + +func (x ModuleStatusResponse_Status) Enum() *ModuleStatusResponse_Status { + p := new(ModuleStatusResponse_Status) + *p = x + return p +} + +func (x ModuleStatusResponse_Status) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ModuleStatusResponse_Status) Descriptor() protoreflect.EnumDescriptor { + return file_pkg_coredeno_proto_coredeno_proto_enumTypes[0].Descriptor() +} + +func (ModuleStatusResponse_Status) Type() protoreflect.EnumType { + return &file_pkg_coredeno_proto_coredeno_proto_enumTypes[0] +} + +func (x ModuleStatusResponse_Status) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ModuleStatusResponse_Status.Descriptor instead. +func (ModuleStatusResponse_Status) EnumDescriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{22, 0} +} + +type FileReadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + ModuleCode string `protobuf:"bytes,2,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileReadRequest) Reset() { + *x = FileReadRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileReadRequest) ProtoMessage() {} + +func (x *FileReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileReadRequest.ProtoReflect.Descriptor instead. +func (*FileReadRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{0} +} + +func (x *FileReadRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileReadRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type FileReadResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileReadResponse) Reset() { + *x = FileReadResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileReadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileReadResponse) ProtoMessage() {} + +func (x *FileReadResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileReadResponse.ProtoReflect.Descriptor instead. +func (*FileReadResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{1} +} + +func (x *FileReadResponse) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type FileWriteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + ModuleCode string `protobuf:"bytes,3,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileWriteRequest) Reset() { + *x = FileWriteRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileWriteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileWriteRequest) ProtoMessage() {} + +func (x *FileWriteRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileWriteRequest.ProtoReflect.Descriptor instead. +func (*FileWriteRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{2} +} + +func (x *FileWriteRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileWriteRequest) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *FileWriteRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type FileWriteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileWriteResponse) Reset() { + *x = FileWriteResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileWriteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileWriteResponse) ProtoMessage() {} + +func (x *FileWriteResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileWriteResponse.ProtoReflect.Descriptor instead. +func (*FileWriteResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{3} +} + +func (x *FileWriteResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +type FileListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + ModuleCode string `protobuf:"bytes,2,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileListRequest) Reset() { + *x = FileListRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileListRequest) ProtoMessage() {} + +func (x *FileListRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileListRequest.ProtoReflect.Descriptor instead. +func (*FileListRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{4} +} + +func (x *FileListRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileListRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type FileListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*FileEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileListResponse) Reset() { + *x = FileListResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileListResponse) ProtoMessage() {} + +func (x *FileListResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileListResponse.ProtoReflect.Descriptor instead. +func (*FileListResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{5} +} + +func (x *FileListResponse) GetEntries() []*FileEntry { + if x != nil { + return x.Entries + } + return nil +} + +type FileEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + IsDir bool `protobuf:"varint,2,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileEntry) Reset() { + *x = FileEntry{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileEntry) ProtoMessage() {} + +func (x *FileEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileEntry.ProtoReflect.Descriptor instead. +func (*FileEntry) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{6} +} + +func (x *FileEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FileEntry) GetIsDir() bool { + if x != nil { + return x.IsDir + } + return false +} + +func (x *FileEntry) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +type FileDeleteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + ModuleCode string `protobuf:"bytes,2,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDeleteRequest) Reset() { + *x = FileDeleteRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDeleteRequest) ProtoMessage() {} + +func (x *FileDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDeleteRequest.ProtoReflect.Descriptor instead. +func (*FileDeleteRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{7} +} + +func (x *FileDeleteRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileDeleteRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type FileDeleteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDeleteResponse) Reset() { + *x = FileDeleteResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDeleteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDeleteResponse) ProtoMessage() {} + +func (x *FileDeleteResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDeleteResponse.ProtoReflect.Descriptor instead. +func (*FileDeleteResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{8} +} + +func (x *FileDeleteResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +type StoreGetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Group string `protobuf:"bytes,1,opt,name=group,proto3" json:"group,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreGetRequest) Reset() { + *x = StoreGetRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreGetRequest) ProtoMessage() {} + +func (x *StoreGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreGetRequest.ProtoReflect.Descriptor instead. +func (*StoreGetRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{9} +} + +func (x *StoreGetRequest) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *StoreGetRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +type StoreGetResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreGetResponse) Reset() { + *x = StoreGetResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreGetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreGetResponse) ProtoMessage() {} + +func (x *StoreGetResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreGetResponse.ProtoReflect.Descriptor instead. +func (*StoreGetResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{10} +} + +func (x *StoreGetResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *StoreGetResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +type StoreSetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Group string `protobuf:"bytes,1,opt,name=group,proto3" json:"group,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + Value string `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreSetRequest) Reset() { + *x = StoreSetRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreSetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreSetRequest) ProtoMessage() {} + +func (x *StoreSetRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreSetRequest.ProtoReflect.Descriptor instead. +func (*StoreSetRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{11} +} + +func (x *StoreSetRequest) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *StoreSetRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *StoreSetRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type StoreSetResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreSetResponse) Reset() { + *x = StoreSetResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreSetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreSetResponse) ProtoMessage() {} + +func (x *StoreSetResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreSetResponse.ProtoReflect.Descriptor instead. +func (*StoreSetResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{12} +} + +func (x *StoreSetResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +type ProcessStartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + ModuleCode string `protobuf:"bytes,3,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessStartRequest) Reset() { + *x = ProcessStartRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessStartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessStartRequest) ProtoMessage() {} + +func (x *ProcessStartRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessStartRequest.ProtoReflect.Descriptor instead. +func (*ProcessStartRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessStartRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *ProcessStartRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessStartRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type ProcessStartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessStartResponse) Reset() { + *x = ProcessStartResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessStartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessStartResponse) ProtoMessage() {} + +func (x *ProcessStartResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessStartResponse.ProtoReflect.Descriptor instead. +func (*ProcessStartResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{14} +} + +func (x *ProcessStartResponse) GetProcessId() string { + if x != nil { + return x.ProcessId + } + return "" +} + +type ProcessStopRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessStopRequest) Reset() { + *x = ProcessStopRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessStopRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessStopRequest) ProtoMessage() {} + +func (x *ProcessStopRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessStopRequest.ProtoReflect.Descriptor instead. +func (*ProcessStopRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{15} +} + +func (x *ProcessStopRequest) GetProcessId() string { + if x != nil { + return x.ProcessId + } + return "" +} + +type ProcessStopResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessStopResponse) Reset() { + *x = ProcessStopResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessStopResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessStopResponse) ProtoMessage() {} + +func (x *ProcessStopResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessStopResponse.ProtoReflect.Descriptor instead. +func (*ProcessStopResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{16} +} + +func (x *ProcessStopResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +type LoadModuleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + EntryPoint string `protobuf:"bytes,2,opt,name=entry_point,json=entryPoint,proto3" json:"entry_point,omitempty"` + Permissions []string `protobuf:"bytes,3,rep,name=permissions,proto3" json:"permissions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoadModuleRequest) Reset() { + *x = LoadModuleRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LoadModuleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoadModuleRequest) ProtoMessage() {} + +func (x *LoadModuleRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoadModuleRequest.ProtoReflect.Descriptor instead. +func (*LoadModuleRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{17} +} + +func (x *LoadModuleRequest) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *LoadModuleRequest) GetEntryPoint() string { + if x != nil { + return x.EntryPoint + } + return "" +} + +func (x *LoadModuleRequest) GetPermissions() []string { + if x != nil { + return x.Permissions + } + return nil +} + +type LoadModuleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoadModuleResponse) Reset() { + *x = LoadModuleResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LoadModuleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoadModuleResponse) ProtoMessage() {} + +func (x *LoadModuleResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoadModuleResponse.ProtoReflect.Descriptor instead. +func (*LoadModuleResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{18} +} + +func (x *LoadModuleResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *LoadModuleResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type UnloadModuleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnloadModuleRequest) Reset() { + *x = UnloadModuleRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnloadModuleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnloadModuleRequest) ProtoMessage() {} + +func (x *UnloadModuleRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnloadModuleRequest.ProtoReflect.Descriptor instead. +func (*UnloadModuleRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{19} +} + +func (x *UnloadModuleRequest) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +type UnloadModuleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnloadModuleResponse) Reset() { + *x = UnloadModuleResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnloadModuleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnloadModuleResponse) ProtoMessage() {} + +func (x *UnloadModuleResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnloadModuleResponse.ProtoReflect.Descriptor instead. +func (*UnloadModuleResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{20} +} + +func (x *UnloadModuleResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +type ModuleStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleStatusRequest) Reset() { + *x = ModuleStatusRequest{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleStatusRequest) ProtoMessage() {} + +func (x *ModuleStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleStatusRequest.ProtoReflect.Descriptor instead. +func (*ModuleStatusRequest) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{21} +} + +func (x *ModuleStatusRequest) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +type ModuleStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Status ModuleStatusResponse_Status `protobuf:"varint,2,opt,name=status,proto3,enum=coredeno.ModuleStatusResponse_Status" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleStatusResponse) Reset() { + *x = ModuleStatusResponse{} + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleStatusResponse) ProtoMessage() {} + +func (x *ModuleStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_coredeno_proto_coredeno_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleStatusResponse.ProtoReflect.Descriptor instead. +func (*ModuleStatusResponse) Descriptor() ([]byte, []int) { + return file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP(), []int{22} +} + +func (x *ModuleStatusResponse) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ModuleStatusResponse) GetStatus() ModuleStatusResponse_Status { + if x != nil { + return x.Status + } + return ModuleStatusResponse_UNKNOWN +} + +var File_pkg_coredeno_proto_coredeno_proto protoreflect.FileDescriptor + +const file_pkg_coredeno_proto_coredeno_proto_rawDesc = "" + + "\n" + + "!pkg/coredeno/proto/coredeno.proto\x12\bcoredeno\"F\n" + + "\x0fFileReadRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1f\n" + + "\vmodule_code\x18\x02 \x01(\tR\n" + + "moduleCode\",\n" + + "\x10FileReadResponse\x12\x18\n" + + "\acontent\x18\x01 \x01(\tR\acontent\"a\n" + + "\x10FileWriteRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x18\n" + + "\acontent\x18\x02 \x01(\tR\acontent\x12\x1f\n" + + "\vmodule_code\x18\x03 \x01(\tR\n" + + "moduleCode\"#\n" + + "\x11FileWriteResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\"F\n" + + "\x0fFileListRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1f\n" + + "\vmodule_code\x18\x02 \x01(\tR\n" + + "moduleCode\"A\n" + + "\x10FileListResponse\x12-\n" + + "\aentries\x18\x01 \x03(\v2\x13.coredeno.FileEntryR\aentries\"J\n" + + "\tFileEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x15\n" + + "\x06is_dir\x18\x02 \x01(\bR\x05isDir\x12\x12\n" + + "\x04size\x18\x03 \x01(\x03R\x04size\"H\n" + + "\x11FileDeleteRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1f\n" + + "\vmodule_code\x18\x02 \x01(\tR\n" + + "moduleCode\"$\n" + + "\x12FileDeleteResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\"9\n" + + "\x0fStoreGetRequest\x12\x14\n" + + "\x05group\x18\x01 \x01(\tR\x05group\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\">\n" + + "\x10StoreGetResponse\x12\x14\n" + + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found\"O\n" + + "\x0fStoreSetRequest\x12\x14\n" + + "\x05group\x18\x01 \x01(\tR\x05group\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x03 \x01(\tR\x05value\"\"\n" + + "\x10StoreSetResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\"d\n" + + "\x13ProcessStartRequest\x12\x18\n" + + "\acommand\x18\x01 \x01(\tR\acommand\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x12\x1f\n" + + "\vmodule_code\x18\x03 \x01(\tR\n" + + "moduleCode\"5\n" + + "\x14ProcessStartResponse\x12\x1d\n" + + "\n" + + "process_id\x18\x01 \x01(\tR\tprocessId\"3\n" + + "\x12ProcessStopRequest\x12\x1d\n" + + "\n" + + "process_id\x18\x01 \x01(\tR\tprocessId\"%\n" + + "\x13ProcessStopResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\"j\n" + + "\x11LoadModuleRequest\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x1f\n" + + "\ventry_point\x18\x02 \x01(\tR\n" + + "entryPoint\x12 \n" + + "\vpermissions\x18\x03 \x03(\tR\vpermissions\":\n" + + "\x12LoadModuleResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\")\n" + + "\x13UnloadModuleRequest\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\"&\n" + + "\x14UnloadModuleResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\")\n" + + "\x13ModuleStatusRequest\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\"\xb4\x01\n" + + "\x14ModuleStatusResponse\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12=\n" + + "\x06status\x18\x02 \x01(\x0e2%.coredeno.ModuleStatusResponse.StatusR\x06status\"I\n" + + "\x06Status\x12\v\n" + + "\aUNKNOWN\x10\x00\x12\v\n" + + "\aLOADING\x10\x01\x12\v\n" + + "\aRUNNING\x10\x02\x12\v\n" + + "\aSTOPPED\x10\x03\x12\v\n" + + "\aERRORED\x10\x042\xc3\x04\n" + + "\vCoreService\x12A\n" + + "\bFileRead\x12\x19.coredeno.FileReadRequest\x1a\x1a.coredeno.FileReadResponse\x12D\n" + + "\tFileWrite\x12\x1a.coredeno.FileWriteRequest\x1a\x1b.coredeno.FileWriteResponse\x12A\n" + + "\bFileList\x12\x19.coredeno.FileListRequest\x1a\x1a.coredeno.FileListResponse\x12G\n" + + "\n" + + "FileDelete\x12\x1b.coredeno.FileDeleteRequest\x1a\x1c.coredeno.FileDeleteResponse\x12A\n" + + "\bStoreGet\x12\x19.coredeno.StoreGetRequest\x1a\x1a.coredeno.StoreGetResponse\x12A\n" + + "\bStoreSet\x12\x19.coredeno.StoreSetRequest\x1a\x1a.coredeno.StoreSetResponse\x12M\n" + + "\fProcessStart\x12\x1d.coredeno.ProcessStartRequest\x1a\x1e.coredeno.ProcessStartResponse\x12J\n" + + "\vProcessStop\x12\x1c.coredeno.ProcessStopRequest\x1a\x1d.coredeno.ProcessStopResponse2\xf4\x01\n" + + "\vDenoService\x12G\n" + + "\n" + + "LoadModule\x12\x1b.coredeno.LoadModuleRequest\x1a\x1c.coredeno.LoadModuleResponse\x12M\n" + + "\fUnloadModule\x12\x1d.coredeno.UnloadModuleRequest\x1a\x1e.coredeno.UnloadModuleResponse\x12M\n" + + "\fModuleStatus\x12\x1d.coredeno.ModuleStatusRequest\x1a\x1e.coredeno.ModuleStatusResponseB*Z(forge.lthn.ai/core/go/pkg/coredeno/protob\x06proto3" + +var ( + file_pkg_coredeno_proto_coredeno_proto_rawDescOnce sync.Once + file_pkg_coredeno_proto_coredeno_proto_rawDescData []byte +) + +func file_pkg_coredeno_proto_coredeno_proto_rawDescGZIP() []byte { + file_pkg_coredeno_proto_coredeno_proto_rawDescOnce.Do(func() { + file_pkg_coredeno_proto_coredeno_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pkg_coredeno_proto_coredeno_proto_rawDesc), len(file_pkg_coredeno_proto_coredeno_proto_rawDesc))) + }) + return file_pkg_coredeno_proto_coredeno_proto_rawDescData +} + +var file_pkg_coredeno_proto_coredeno_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pkg_coredeno_proto_coredeno_proto_msgTypes = make([]protoimpl.MessageInfo, 23) +var file_pkg_coredeno_proto_coredeno_proto_goTypes = []any{ + (ModuleStatusResponse_Status)(0), // 0: coredeno.ModuleStatusResponse.Status + (*FileReadRequest)(nil), // 1: coredeno.FileReadRequest + (*FileReadResponse)(nil), // 2: coredeno.FileReadResponse + (*FileWriteRequest)(nil), // 3: coredeno.FileWriteRequest + (*FileWriteResponse)(nil), // 4: coredeno.FileWriteResponse + (*FileListRequest)(nil), // 5: coredeno.FileListRequest + (*FileListResponse)(nil), // 6: coredeno.FileListResponse + (*FileEntry)(nil), // 7: coredeno.FileEntry + (*FileDeleteRequest)(nil), // 8: coredeno.FileDeleteRequest + (*FileDeleteResponse)(nil), // 9: coredeno.FileDeleteResponse + (*StoreGetRequest)(nil), // 10: coredeno.StoreGetRequest + (*StoreGetResponse)(nil), // 11: coredeno.StoreGetResponse + (*StoreSetRequest)(nil), // 12: coredeno.StoreSetRequest + (*StoreSetResponse)(nil), // 13: coredeno.StoreSetResponse + (*ProcessStartRequest)(nil), // 14: coredeno.ProcessStartRequest + (*ProcessStartResponse)(nil), // 15: coredeno.ProcessStartResponse + (*ProcessStopRequest)(nil), // 16: coredeno.ProcessStopRequest + (*ProcessStopResponse)(nil), // 17: coredeno.ProcessStopResponse + (*LoadModuleRequest)(nil), // 18: coredeno.LoadModuleRequest + (*LoadModuleResponse)(nil), // 19: coredeno.LoadModuleResponse + (*UnloadModuleRequest)(nil), // 20: coredeno.UnloadModuleRequest + (*UnloadModuleResponse)(nil), // 21: coredeno.UnloadModuleResponse + (*ModuleStatusRequest)(nil), // 22: coredeno.ModuleStatusRequest + (*ModuleStatusResponse)(nil), // 23: coredeno.ModuleStatusResponse +} +var file_pkg_coredeno_proto_coredeno_proto_depIdxs = []int32{ + 7, // 0: coredeno.FileListResponse.entries:type_name -> coredeno.FileEntry + 0, // 1: coredeno.ModuleStatusResponse.status:type_name -> coredeno.ModuleStatusResponse.Status + 1, // 2: coredeno.CoreService.FileRead:input_type -> coredeno.FileReadRequest + 3, // 3: coredeno.CoreService.FileWrite:input_type -> coredeno.FileWriteRequest + 5, // 4: coredeno.CoreService.FileList:input_type -> coredeno.FileListRequest + 8, // 5: coredeno.CoreService.FileDelete:input_type -> coredeno.FileDeleteRequest + 10, // 6: coredeno.CoreService.StoreGet:input_type -> coredeno.StoreGetRequest + 12, // 7: coredeno.CoreService.StoreSet:input_type -> coredeno.StoreSetRequest + 14, // 8: coredeno.CoreService.ProcessStart:input_type -> coredeno.ProcessStartRequest + 16, // 9: coredeno.CoreService.ProcessStop:input_type -> coredeno.ProcessStopRequest + 18, // 10: coredeno.DenoService.LoadModule:input_type -> coredeno.LoadModuleRequest + 20, // 11: coredeno.DenoService.UnloadModule:input_type -> coredeno.UnloadModuleRequest + 22, // 12: coredeno.DenoService.ModuleStatus:input_type -> coredeno.ModuleStatusRequest + 2, // 13: coredeno.CoreService.FileRead:output_type -> coredeno.FileReadResponse + 4, // 14: coredeno.CoreService.FileWrite:output_type -> coredeno.FileWriteResponse + 6, // 15: coredeno.CoreService.FileList:output_type -> coredeno.FileListResponse + 9, // 16: coredeno.CoreService.FileDelete:output_type -> coredeno.FileDeleteResponse + 11, // 17: coredeno.CoreService.StoreGet:output_type -> coredeno.StoreGetResponse + 13, // 18: coredeno.CoreService.StoreSet:output_type -> coredeno.StoreSetResponse + 15, // 19: coredeno.CoreService.ProcessStart:output_type -> coredeno.ProcessStartResponse + 17, // 20: coredeno.CoreService.ProcessStop:output_type -> coredeno.ProcessStopResponse + 19, // 21: coredeno.DenoService.LoadModule:output_type -> coredeno.LoadModuleResponse + 21, // 22: coredeno.DenoService.UnloadModule:output_type -> coredeno.UnloadModuleResponse + 23, // 23: coredeno.DenoService.ModuleStatus:output_type -> coredeno.ModuleStatusResponse + 13, // [13:24] is the sub-list for method output_type + 2, // [2:13] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pkg_coredeno_proto_coredeno_proto_init() } +func file_pkg_coredeno_proto_coredeno_proto_init() { + if File_pkg_coredeno_proto_coredeno_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pkg_coredeno_proto_coredeno_proto_rawDesc), len(file_pkg_coredeno_proto_coredeno_proto_rawDesc)), + NumEnums: 1, + NumMessages: 23, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_pkg_coredeno_proto_coredeno_proto_goTypes, + DependencyIndexes: file_pkg_coredeno_proto_coredeno_proto_depIdxs, + EnumInfos: file_pkg_coredeno_proto_coredeno_proto_enumTypes, + MessageInfos: file_pkg_coredeno_proto_coredeno_proto_msgTypes, + }.Build() + File_pkg_coredeno_proto_coredeno_proto = out.File + file_pkg_coredeno_proto_coredeno_proto_goTypes = nil + file_pkg_coredeno_proto_coredeno_proto_depIdxs = nil +} diff --git a/pkg/coredeno/proto/coredeno_grpc.pb.go b/pkg/coredeno/proto/coredeno_grpc.pb.go new file mode 100644 index 0000000..528f99c --- /dev/null +++ b/pkg/coredeno/proto/coredeno_grpc.pb.go @@ -0,0 +1,579 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.21.12 +// source: pkg/coredeno/proto/coredeno.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + CoreService_FileRead_FullMethodName = "/coredeno.CoreService/FileRead" + CoreService_FileWrite_FullMethodName = "/coredeno.CoreService/FileWrite" + CoreService_FileList_FullMethodName = "/coredeno.CoreService/FileList" + CoreService_FileDelete_FullMethodName = "/coredeno.CoreService/FileDelete" + CoreService_StoreGet_FullMethodName = "/coredeno.CoreService/StoreGet" + CoreService_StoreSet_FullMethodName = "/coredeno.CoreService/StoreSet" + CoreService_ProcessStart_FullMethodName = "/coredeno.CoreService/ProcessStart" + CoreService_ProcessStop_FullMethodName = "/coredeno.CoreService/ProcessStop" +) + +// CoreServiceClient is the client API for CoreService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// CoreService is implemented by CoreGO — Deno calls this for I/O. +type CoreServiceClient interface { + // Filesystem (gated by manifest permissions) + FileRead(ctx context.Context, in *FileReadRequest, opts ...grpc.CallOption) (*FileReadResponse, error) + FileWrite(ctx context.Context, in *FileWriteRequest, opts ...grpc.CallOption) (*FileWriteResponse, error) + FileList(ctx context.Context, in *FileListRequest, opts ...grpc.CallOption) (*FileListResponse, error) + FileDelete(ctx context.Context, in *FileDeleteRequest, opts ...grpc.CallOption) (*FileDeleteResponse, error) + // Object store + StoreGet(ctx context.Context, in *StoreGetRequest, opts ...grpc.CallOption) (*StoreGetResponse, error) + StoreSet(ctx context.Context, in *StoreSetRequest, opts ...grpc.CallOption) (*StoreSetResponse, error) + // Process management + ProcessStart(ctx context.Context, in *ProcessStartRequest, opts ...grpc.CallOption) (*ProcessStartResponse, error) + ProcessStop(ctx context.Context, in *ProcessStopRequest, opts ...grpc.CallOption) (*ProcessStopResponse, error) +} + +type coreServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewCoreServiceClient(cc grpc.ClientConnInterface) CoreServiceClient { + return &coreServiceClient{cc} +} + +func (c *coreServiceClient) FileRead(ctx context.Context, in *FileReadRequest, opts ...grpc.CallOption) (*FileReadResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileReadResponse) + err := c.cc.Invoke(ctx, CoreService_FileRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) FileWrite(ctx context.Context, in *FileWriteRequest, opts ...grpc.CallOption) (*FileWriteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileWriteResponse) + err := c.cc.Invoke(ctx, CoreService_FileWrite_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) FileList(ctx context.Context, in *FileListRequest, opts ...grpc.CallOption) (*FileListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileListResponse) + err := c.cc.Invoke(ctx, CoreService_FileList_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) FileDelete(ctx context.Context, in *FileDeleteRequest, opts ...grpc.CallOption) (*FileDeleteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDeleteResponse) + err := c.cc.Invoke(ctx, CoreService_FileDelete_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) StoreGet(ctx context.Context, in *StoreGetRequest, opts ...grpc.CallOption) (*StoreGetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StoreGetResponse) + err := c.cc.Invoke(ctx, CoreService_StoreGet_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) StoreSet(ctx context.Context, in *StoreSetRequest, opts ...grpc.CallOption) (*StoreSetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StoreSetResponse) + err := c.cc.Invoke(ctx, CoreService_StoreSet_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) ProcessStart(ctx context.Context, in *ProcessStartRequest, opts ...grpc.CallOption) (*ProcessStartResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProcessStartResponse) + err := c.cc.Invoke(ctx, CoreService_ProcessStart_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *coreServiceClient) ProcessStop(ctx context.Context, in *ProcessStopRequest, opts ...grpc.CallOption) (*ProcessStopResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProcessStopResponse) + err := c.cc.Invoke(ctx, CoreService_ProcessStop_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CoreServiceServer is the server API for CoreService service. +// All implementations must embed UnimplementedCoreServiceServer +// for forward compatibility. +// +// CoreService is implemented by CoreGO — Deno calls this for I/O. +type CoreServiceServer interface { + // Filesystem (gated by manifest permissions) + FileRead(context.Context, *FileReadRequest) (*FileReadResponse, error) + FileWrite(context.Context, *FileWriteRequest) (*FileWriteResponse, error) + FileList(context.Context, *FileListRequest) (*FileListResponse, error) + FileDelete(context.Context, *FileDeleteRequest) (*FileDeleteResponse, error) + // Object store + StoreGet(context.Context, *StoreGetRequest) (*StoreGetResponse, error) + StoreSet(context.Context, *StoreSetRequest) (*StoreSetResponse, error) + // Process management + ProcessStart(context.Context, *ProcessStartRequest) (*ProcessStartResponse, error) + ProcessStop(context.Context, *ProcessStopRequest) (*ProcessStopResponse, error) + mustEmbedUnimplementedCoreServiceServer() +} + +// UnimplementedCoreServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedCoreServiceServer struct{} + +func (UnimplementedCoreServiceServer) FileRead(context.Context, *FileReadRequest) (*FileReadResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileRead not implemented") +} +func (UnimplementedCoreServiceServer) FileWrite(context.Context, *FileWriteRequest) (*FileWriteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileWrite not implemented") +} +func (UnimplementedCoreServiceServer) FileList(context.Context, *FileListRequest) (*FileListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileList not implemented") +} +func (UnimplementedCoreServiceServer) FileDelete(context.Context, *FileDeleteRequest) (*FileDeleteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDelete not implemented") +} +func (UnimplementedCoreServiceServer) StoreGet(context.Context, *StoreGetRequest) (*StoreGetResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StoreGet not implemented") +} +func (UnimplementedCoreServiceServer) StoreSet(context.Context, *StoreSetRequest) (*StoreSetResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StoreSet not implemented") +} +func (UnimplementedCoreServiceServer) ProcessStart(context.Context, *ProcessStartRequest) (*ProcessStartResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessStart not implemented") +} +func (UnimplementedCoreServiceServer) ProcessStop(context.Context, *ProcessStopRequest) (*ProcessStopResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessStop not implemented") +} +func (UnimplementedCoreServiceServer) mustEmbedUnimplementedCoreServiceServer() {} +func (UnimplementedCoreServiceServer) testEmbeddedByValue() {} + +// UnsafeCoreServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CoreServiceServer will +// result in compilation errors. +type UnsafeCoreServiceServer interface { + mustEmbedUnimplementedCoreServiceServer() +} + +func RegisterCoreServiceServer(s grpc.ServiceRegistrar, srv CoreServiceServer) { + // If the following call panics, it indicates UnimplementedCoreServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&CoreService_ServiceDesc, srv) +} + +func _CoreService_FileRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileReadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).FileRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_FileRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).FileRead(ctx, req.(*FileReadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_FileWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileWriteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).FileWrite(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_FileWrite_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).FileWrite(ctx, req.(*FileWriteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_FileList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileListRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).FileList(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_FileList_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).FileList(ctx, req.(*FileListRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_FileDelete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDeleteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).FileDelete(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_FileDelete_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).FileDelete(ctx, req.(*FileDeleteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_StoreGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StoreGetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).StoreGet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_StoreGet_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).StoreGet(ctx, req.(*StoreGetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_StoreSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StoreSetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).StoreSet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_StoreSet_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).StoreSet(ctx, req.(*StoreSetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_ProcessStart_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProcessStartRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).ProcessStart(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_ProcessStart_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).ProcessStart(ctx, req.(*ProcessStartRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CoreService_ProcessStop_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProcessStopRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServiceServer).ProcessStop(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CoreService_ProcessStop_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServiceServer).ProcessStop(ctx, req.(*ProcessStopRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CoreService_ServiceDesc is the grpc.ServiceDesc for CoreService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CoreService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "coredeno.CoreService", + HandlerType: (*CoreServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "FileRead", + Handler: _CoreService_FileRead_Handler, + }, + { + MethodName: "FileWrite", + Handler: _CoreService_FileWrite_Handler, + }, + { + MethodName: "FileList", + Handler: _CoreService_FileList_Handler, + }, + { + MethodName: "FileDelete", + Handler: _CoreService_FileDelete_Handler, + }, + { + MethodName: "StoreGet", + Handler: _CoreService_StoreGet_Handler, + }, + { + MethodName: "StoreSet", + Handler: _CoreService_StoreSet_Handler, + }, + { + MethodName: "ProcessStart", + Handler: _CoreService_ProcessStart_Handler, + }, + { + MethodName: "ProcessStop", + Handler: _CoreService_ProcessStop_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "pkg/coredeno/proto/coredeno.proto", +} + +const ( + DenoService_LoadModule_FullMethodName = "/coredeno.DenoService/LoadModule" + DenoService_UnloadModule_FullMethodName = "/coredeno.DenoService/UnloadModule" + DenoService_ModuleStatus_FullMethodName = "/coredeno.DenoService/ModuleStatus" +) + +// DenoServiceClient is the client API for DenoService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// DenoService is implemented by CoreDeno — Go calls this for module lifecycle. +type DenoServiceClient interface { + LoadModule(ctx context.Context, in *LoadModuleRequest, opts ...grpc.CallOption) (*LoadModuleResponse, error) + UnloadModule(ctx context.Context, in *UnloadModuleRequest, opts ...grpc.CallOption) (*UnloadModuleResponse, error) + ModuleStatus(ctx context.Context, in *ModuleStatusRequest, opts ...grpc.CallOption) (*ModuleStatusResponse, error) +} + +type denoServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDenoServiceClient(cc grpc.ClientConnInterface) DenoServiceClient { + return &denoServiceClient{cc} +} + +func (c *denoServiceClient) LoadModule(ctx context.Context, in *LoadModuleRequest, opts ...grpc.CallOption) (*LoadModuleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LoadModuleResponse) + err := c.cc.Invoke(ctx, DenoService_LoadModule_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *denoServiceClient) UnloadModule(ctx context.Context, in *UnloadModuleRequest, opts ...grpc.CallOption) (*UnloadModuleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UnloadModuleResponse) + err := c.cc.Invoke(ctx, DenoService_UnloadModule_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *denoServiceClient) ModuleStatus(ctx context.Context, in *ModuleStatusRequest, opts ...grpc.CallOption) (*ModuleStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ModuleStatusResponse) + err := c.cc.Invoke(ctx, DenoService_ModuleStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DenoServiceServer is the server API for DenoService service. +// All implementations must embed UnimplementedDenoServiceServer +// for forward compatibility. +// +// DenoService is implemented by CoreDeno — Go calls this for module lifecycle. +type DenoServiceServer interface { + LoadModule(context.Context, *LoadModuleRequest) (*LoadModuleResponse, error) + UnloadModule(context.Context, *UnloadModuleRequest) (*UnloadModuleResponse, error) + ModuleStatus(context.Context, *ModuleStatusRequest) (*ModuleStatusResponse, error) + mustEmbedUnimplementedDenoServiceServer() +} + +// UnimplementedDenoServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDenoServiceServer struct{} + +func (UnimplementedDenoServiceServer) LoadModule(context.Context, *LoadModuleRequest) (*LoadModuleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method LoadModule not implemented") +} +func (UnimplementedDenoServiceServer) UnloadModule(context.Context, *UnloadModuleRequest) (*UnloadModuleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UnloadModule not implemented") +} +func (UnimplementedDenoServiceServer) ModuleStatus(context.Context, *ModuleStatusRequest) (*ModuleStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ModuleStatus not implemented") +} +func (UnimplementedDenoServiceServer) mustEmbedUnimplementedDenoServiceServer() {} +func (UnimplementedDenoServiceServer) testEmbeddedByValue() {} + +// UnsafeDenoServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DenoServiceServer will +// result in compilation errors. +type UnsafeDenoServiceServer interface { + mustEmbedUnimplementedDenoServiceServer() +} + +func RegisterDenoServiceServer(s grpc.ServiceRegistrar, srv DenoServiceServer) { + // If the following call panics, it indicates UnimplementedDenoServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DenoService_ServiceDesc, srv) +} + +func _DenoService_LoadModule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LoadModuleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).LoadModule(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_LoadModule_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).LoadModule(ctx, req.(*LoadModuleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DenoService_UnloadModule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnloadModuleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).UnloadModule(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_UnloadModule_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).UnloadModule(ctx, req.(*UnloadModuleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DenoService_ModuleStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ModuleStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).ModuleStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_ModuleStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).ModuleStatus(ctx, req.(*ModuleStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// DenoService_ServiceDesc is the grpc.ServiceDesc for DenoService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DenoService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "coredeno.DenoService", + HandlerType: (*DenoServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "LoadModule", + Handler: _DenoService_LoadModule_Handler, + }, + { + MethodName: "UnloadModule", + Handler: _DenoService_UnloadModule_Handler, + }, + { + MethodName: "ModuleStatus", + Handler: _DenoService_ModuleStatus_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "pkg/coredeno/proto/coredeno.proto", +} diff --git a/pkg/coredeno/server.go b/pkg/coredeno/server.go new file mode 100644 index 0000000..31395a1 --- /dev/null +++ b/pkg/coredeno/server.go @@ -0,0 +1,131 @@ +package coredeno + +import ( + "context" + "fmt" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/store" +) + +// Server implements the CoreService gRPC interface with permission gating. +// Every I/O request is checked against the calling module's declared permissions. +type Server struct { + pb.UnimplementedCoreServiceServer + medium io.Medium + store *store.Store + manifests map[string]*manifest.Manifest +} + +// NewServer creates a CoreService server backed by the given Medium and Store. +func NewServer(medium io.Medium, st *store.Store) *Server { + return &Server{ + medium: medium, + store: st, + manifests: make(map[string]*manifest.Manifest), + } +} + +// RegisterModule adds a module's manifest to the permission registry. +func (s *Server) RegisterModule(m *manifest.Manifest) { + s.manifests[m.Code] = m +} + +// getManifest looks up a module and returns an error if unknown. +func (s *Server) getManifest(code string) (*manifest.Manifest, error) { + m, ok := s.manifests[code] + if !ok { + return nil, fmt.Errorf("unknown module: %s", code) + } + return m, nil +} + +// FileRead implements CoreService.FileRead with permission gating. +func (s *Server) FileRead(_ context.Context, req *pb.FileReadRequest) (*pb.FileReadResponse, error) { + m, err := s.getManifest(req.ModuleCode) + if err != nil { + return nil, err + } + if !CheckPath(req.Path, m.Permissions.Read) { + return nil, fmt.Errorf("permission denied: %s cannot read %s", req.ModuleCode, req.Path) + } + content, err := s.medium.Read(req.Path) + if err != nil { + return nil, err + } + return &pb.FileReadResponse{Content: content}, nil +} + +// FileWrite implements CoreService.FileWrite with permission gating. +func (s *Server) FileWrite(_ context.Context, req *pb.FileWriteRequest) (*pb.FileWriteResponse, error) { + m, err := s.getManifest(req.ModuleCode) + if err != nil { + return nil, err + } + if !CheckPath(req.Path, m.Permissions.Write) { + return nil, fmt.Errorf("permission denied: %s cannot write %s", req.ModuleCode, req.Path) + } + if err := s.medium.Write(req.Path, req.Content); err != nil { + return nil, err + } + return &pb.FileWriteResponse{Ok: true}, nil +} + +// FileList implements CoreService.FileList with permission gating. +func (s *Server) FileList(_ context.Context, req *pb.FileListRequest) (*pb.FileListResponse, error) { + m, err := s.getManifest(req.ModuleCode) + if err != nil { + return nil, err + } + if !CheckPath(req.Path, m.Permissions.Read) { + return nil, fmt.Errorf("permission denied: %s cannot list %s", req.ModuleCode, req.Path) + } + entries, err := s.medium.List(req.Path) + if err != nil { + return nil, err + } + var pbEntries []*pb.FileEntry + for _, e := range entries { + info, _ := e.Info() + pbEntries = append(pbEntries, &pb.FileEntry{ + Name: e.Name(), + IsDir: e.IsDir(), + Size: info.Size(), + }) + } + return &pb.FileListResponse{Entries: pbEntries}, nil +} + +// FileDelete implements CoreService.FileDelete with permission gating. +func (s *Server) FileDelete(_ context.Context, req *pb.FileDeleteRequest) (*pb.FileDeleteResponse, error) { + m, err := s.getManifest(req.ModuleCode) + if err != nil { + return nil, err + } + if !CheckPath(req.Path, m.Permissions.Write) { + return nil, fmt.Errorf("permission denied: %s cannot delete %s", req.ModuleCode, req.Path) + } + if err := s.medium.Delete(req.Path); err != nil { + return nil, err + } + return &pb.FileDeleteResponse{Ok: true}, nil +} + +// StoreGet implements CoreService.StoreGet. +func (s *Server) StoreGet(_ context.Context, req *pb.StoreGetRequest) (*pb.StoreGetResponse, error) { + val, err := s.store.Get(req.Group, req.Key) + if err != nil { + return &pb.StoreGetResponse{Found: false}, nil + } + return &pb.StoreGetResponse{Value: val, Found: true}, nil +} + +// StoreSet implements CoreService.StoreSet. +func (s *Server) StoreSet(_ context.Context, req *pb.StoreSetRequest) (*pb.StoreSetResponse, error) { + if err := s.store.Set(req.Group, req.Key, req.Value); err != nil { + return nil, err + } + return &pb.StoreSetResponse{Ok: true}, nil +} diff --git a/pkg/coredeno/server_test.go b/pkg/coredeno/server_test.go new file mode 100644 index 0000000..6438f33 --- /dev/null +++ b/pkg/coredeno/server_test.go @@ -0,0 +1,97 @@ +package coredeno + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/manifest" + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + "forge.lthn.ai/core/go/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + medium := io.NewMockMedium() + medium.Files["./data/test.txt"] = "hello" + st, err := store.New(":memory:") + require.NoError(t, err) + t.Cleanup(func() { st.Close() }) + + srv := NewServer(medium, st) + srv.RegisterModule(&manifest.Manifest{ + Code: "test-mod", + Permissions: manifest.Permissions{ + Read: []string{"./data/"}, + Write: []string{"./data/"}, + }, + }) + return srv +} + +func TestFileRead_Good(t *testing.T) { + srv := newTestServer(t) + resp, err := srv.FileRead(context.Background(), &pb.FileReadRequest{ + Path: "./data/test.txt", ModuleCode: "test-mod", + }) + require.NoError(t, err) + assert.Equal(t, "hello", resp.Content) +} + +func TestFileRead_Bad_PermissionDenied(t *testing.T) { + srv := newTestServer(t) + _, err := srv.FileRead(context.Background(), &pb.FileReadRequest{ + Path: "./secrets/key.pem", ModuleCode: "test-mod", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestFileRead_Bad_UnknownModule(t *testing.T) { + srv := newTestServer(t) + _, err := srv.FileRead(context.Background(), &pb.FileReadRequest{ + Path: "./data/test.txt", ModuleCode: "unknown", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown module") +} + +func TestFileWrite_Good(t *testing.T) { + srv := newTestServer(t) + resp, err := srv.FileWrite(context.Background(), &pb.FileWriteRequest{ + Path: "./data/new.txt", Content: "world", ModuleCode: "test-mod", + }) + require.NoError(t, err) + assert.True(t, resp.Ok) +} + +func TestFileWrite_Bad_PermissionDenied(t *testing.T) { + srv := newTestServer(t) + _, err := srv.FileWrite(context.Background(), &pb.FileWriteRequest{ + Path: "./secrets/bad.txt", Content: "nope", ModuleCode: "test-mod", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestStoreGetSet_Good(t *testing.T) { + srv := newTestServer(t) + ctx := context.Background() + + _, err := srv.StoreSet(ctx, &pb.StoreSetRequest{Group: "cfg", Key: "theme", Value: "dark"}) + require.NoError(t, err) + + resp, err := srv.StoreGet(ctx, &pb.StoreGetRequest{Group: "cfg", Key: "theme"}) + require.NoError(t, err) + assert.True(t, resp.Found) + assert.Equal(t, "dark", resp.Value) +} + +func TestStoreGet_Good_NotFound(t *testing.T) { + srv := newTestServer(t) + resp, err := srv.StoreGet(context.Background(), &pb.StoreGetRequest{Group: "cfg", Key: "missing"}) + require.NoError(t, err) + assert.False(t, resp.Found) +} -- 2.45.3 From 7d047fbdccbc3457aa679b958ad331e99b3e1bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 21:12:27 +0000 Subject: [PATCH 2/6] feat(coredeno): wire Service into framework DI with ServiceRuntime[T] Service embeds ServiceRuntime[Options] for Core/Opts access. NewServiceFactory returns factory for core.WithService registration. Correct Startable/Stoppable signatures with context.Context. Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/service.go | 34 ++++++++++++++++--------- pkg/coredeno/service_test.go | 49 ++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go index ccc843f..e218a2e 100644 --- a/pkg/coredeno/service.go +++ b/pkg/coredeno/service.go @@ -1,29 +1,39 @@ package coredeno -import "context" +import ( + "context" -// Service wraps the CoreDeno sidecar for framework lifecycle integration. -// Implements Startable (OnStartup) and Stoppable (OnShutdown) interfaces. + core "forge.lthn.ai/core/go/pkg/framework/core" +) + +// Service wraps the CoreDeno sidecar as a framework service. +// Implements Startable and Stoppable for lifecycle management. +// +// Registration: +// +// core.New(core.WithService(coredeno.NewServiceFactory(opts))) type Service struct { + *core.ServiceRuntime[Options] 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, +// NewServiceFactory returns a factory function for framework registration via WithService. +func NewServiceFactory(opts Options) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime(c, opts), + sidecar: NewSidecar(opts), + }, nil } } -// OnStartup starts the Deno sidecar. Called by the framework. +// OnStartup starts the Deno sidecar. Called by the framework on app startup. func (s *Service) OnStartup(ctx context.Context) error { return nil } -// OnShutdown stops the Deno sidecar. Called by the framework. -func (s *Service) OnShutdown() error { +// OnShutdown stops the Deno sidecar. Called by the framework on app shutdown. +func (s *Service) OnShutdown(_ context.Context) error { return s.sidecar.Stop() } diff --git a/pkg/coredeno/service_test.go b/pkg/coredeno/service_test.go index e6b7473..685bbc8 100644 --- a/pkg/coredeno/service_test.go +++ b/pkg/coredeno/service_test.go @@ -1,30 +1,65 @@ package coredeno import ( + "context" "testing" + core "forge.lthn.ai/core/go/pkg/framework/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNewService_Good(t *testing.T) { +func TestNewServiceFactory_Good(t *testing.T) { opts := Options{ DenoPath: "echo", SocketPath: "/tmp/test-service.sock", } - svc := NewService(opts) - require.NotNil(t, svc) + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + + svc, ok := result.(*Service) + require.True(t, ok) assert.NotNil(t, svc.sidecar) assert.Equal(t, "echo", svc.sidecar.opts.DenoPath) + assert.NotNil(t, svc.Core(), "ServiceRuntime should provide Core access") + assert.Equal(t, opts, svc.Opts(), "ServiceRuntime should provide Options access") } -func TestService_OnShutdown_Good_NotStarted(t *testing.T) { - svc := NewService(Options{DenoPath: "echo"}) - err := svc.OnShutdown() +func TestService_WithService_Good(t *testing.T) { + opts := Options{DenoPath: "echo"} + c, err := core.New(core.WithService(NewServiceFactory(opts))) + require.NoError(t, err) + assert.NotNil(t, c) +} + +func TestService_Lifecycle_Good(t *testing.T) { + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(Options{DenoPath: "echo"}) + result, _ := factory(c) + svc := result.(*Service) + + // Verify Startable + err = svc.OnStartup(context.Background()) + assert.NoError(t, err) + + // Verify Stoppable (not started, should be no-op) + err = svc.OnShutdown(context.Background()) assert.NoError(t, err) } func TestService_Sidecar_Good(t *testing.T) { - svc := NewService(Options{DenoPath: "echo"}) + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(Options{DenoPath: "echo"}) + result, _ := factory(c) + svc := result.(*Service) + assert.NotNil(t, svc.Sidecar()) } -- 2.45.3 From 2f246ad0537433f671d679cc1492ad707cba79c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 21:39:49 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat(coredeno):=20wire=20Tier=201=20boot=20?= =?UTF-8?q?sequence=20=E2=80=94=20gRPC=20listener,=20manifest=20loading,?= =?UTF-8?q?=20sidecar=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service.OnStartup now creates sandboxed I/O medium, opens SQLite store, starts gRPC listener on Unix socket, loads .core/view.yml manifest, and launches Deno sidecar with CORE_SOCKET env var. Full shutdown in reverse. New files: listener.go (Unix socket gRPC server), runtime/main.ts (Deno entry point), integration_test.go (full boot with real Deno). 34 tests pass (33 unit + 1 integration). Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 +- go.sum | 21 ++---- pkg/coredeno/coredeno.go | 12 ++- pkg/coredeno/coredeno_test.go | 28 +++++++ pkg/coredeno/integration_test.go | 110 +++++++++++++++++++++++++++ pkg/coredeno/lifecycle.go | 1 + pkg/coredeno/lifecycle_test.go | 32 ++++++++ pkg/coredeno/listener.go | 47 ++++++++++++ pkg/coredeno/listener_test.go | 112 ++++++++++++++++++++++++++++ pkg/coredeno/runtime/main.ts | 30 ++++++++ pkg/coredeno/service.go | 94 ++++++++++++++++++++++- pkg/coredeno/service_test.go | 124 ++++++++++++++++++++++++++++++- 12 files changed, 590 insertions(+), 25 deletions(-) create mode 100644 pkg/coredeno/integration_test.go create mode 100644 pkg/coredeno/listener.go create mode 100644 pkg/coredeno/listener_test.go create mode 100644 pkg/coredeno/runtime/main.ts diff --git a/go.mod b/go.mod index d8169da..655e860 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,8 @@ require ( golang.org/x/net v0.50.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 + google.golang.org/grpc v1.79.1 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.45.0 ) @@ -113,8 +115,6 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d043f23..42687bd 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -245,21 +247,16 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -302,12 +299,8 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/pkg/coredeno/coredeno.go b/pkg/coredeno/coredeno.go index a45bbe5..ee50ddb 100644 --- a/pkg/coredeno/coredeno.go +++ b/pkg/coredeno/coredeno.go @@ -2,6 +2,7 @@ package coredeno import ( "context" + "crypto/ed25519" "fmt" "os" "os/exec" @@ -12,8 +13,12 @@ import ( // Options configures the CoreDeno sidecar. type Options struct { - DenoPath string // path to deno binary (default: "deno") - SocketPath string // Unix socket path for gRPC + DenoPath string // path to deno binary (default: "deno") + SocketPath string // Unix socket path for gRPC + AppRoot string // app root directory (sandboxed I/O) + StoreDBPath string // SQLite DB path (default: AppRoot/.core/store.db) + PublicKey ed25519.PublicKey // ed25519 public key for manifest verification (optional) + SidecarArgs []string // args passed to the sidecar process } // Permissions declares per-module Deno permission flags. @@ -69,5 +74,8 @@ func NewSidecar(opts Options) *Sidecar { if opts.SocketPath == "" { opts.SocketPath = DefaultSocketPath() } + if opts.StoreDBPath == "" && opts.AppRoot != "" { + opts.StoreDBPath = filepath.Join(opts.AppRoot, ".core", "store.db") + } return &Sidecar{opts: opts} } diff --git a/pkg/coredeno/coredeno_test.go b/pkg/coredeno/coredeno_test.go index dec79bf..1da31c8 100644 --- a/pkg/coredeno/coredeno_test.go +++ b/pkg/coredeno/coredeno_test.go @@ -44,6 +44,34 @@ func TestSidecar_PermissionFlags_Empty(t *testing.T) { assert.Empty(t, flags) } +func TestOptions_AppRoot_Good(t *testing.T) { + opts := Options{ + DenoPath: "deno", + SocketPath: "/tmp/test.sock", + AppRoot: "/app", + StoreDBPath: "/app/.core/store.db", + } + sc := NewSidecar(opts) + assert.Equal(t, "/app", sc.opts.AppRoot) + assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath) +} + +func TestOptions_StoreDBPath_Default_Good(t *testing.T) { + opts := Options{AppRoot: "/app"} + sc := NewSidecar(opts) + assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath, + "StoreDBPath should default to AppRoot/.core/store.db") +} + +func TestOptions_SidecarArgs_Good(t *testing.T) { + opts := Options{ + DenoPath: "deno", + SidecarArgs: []string{"run", "--allow-env", "main.ts"}, + } + sc := NewSidecar(opts) + assert.Equal(t, []string{"run", "--allow-env", "main.ts"}, sc.opts.SidecarArgs) +} + func TestDefaultSocketPath_XDG(t *testing.T) { orig := os.Getenv("XDG_RUNTIME_DIR") defer os.Setenv("XDG_RUNTIME_DIR", orig) diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go new file mode 100644 index 0000000..ce83fe0 --- /dev/null +++ b/pkg/coredeno/integration_test.go @@ -0,0 +1,110 @@ +//go:build integration + +package coredeno + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + core "forge.lthn.ai/core/go/pkg/framework/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestIntegration_FullBoot_Good(t *testing.T) { + denoPath, err := exec.LookPath("deno") + if err != nil { + // Check ~/.deno/bin/deno + home, _ := os.UserHomeDir() + denoPath = filepath.Join(home, ".deno", "bin", "deno") + if _, err := os.Stat(denoPath); err != nil { + t.Skip("deno not installed") + } + } + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + + // Write a manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: integration-test +name: Integration Test +version: "1.0" +permissions: + read: ["./data/"] +`), 0644)) + + // Copy the runtime entry point + runtimeDir := filepath.Join(coreDir, "runtime") + require.NoError(t, os.MkdirAll(runtimeDir, 0755)) + src, err := os.ReadFile("runtime/main.ts") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(runtimeDir, "main.ts"), src, 0644)) + + entryPoint := filepath.Join(runtimeDir, "main.ts") + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "--allow-env", entryPoint}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify gRPC is working + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 5*time.Second, 50*time.Millisecond, "socket should appear") + + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := pb.NewCoreServiceClient(conn) + _, err = client.StoreSet(ctx, &pb.StoreSetRequest{ + Group: "integration", Key: "boot", Value: "ok", + }) + require.NoError(t, err) + + resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "integration", Key: "boot", + }) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Value) + assert.True(t, resp.Found) + + // Verify sidecar is running + assert.True(t, svc.sidecar.IsRunning(), "Deno sidecar should be running") + + // Clean shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") +} diff --git a/pkg/coredeno/lifecycle.go b/pkg/coredeno/lifecycle.go index 61d5a7c..f5975b5 100644 --- a/pkg/coredeno/lifecycle.go +++ b/pkg/coredeno/lifecycle.go @@ -28,6 +28,7 @@ func (s *Sidecar) Start(ctx context.Context, args ...string) error { s.ctx, s.cancel = context.WithCancel(ctx) s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...) + s.cmd.Env = append(os.Environ(), "CORE_SOCKET="+s.opts.SocketPath) s.done = make(chan struct{}) if err := s.cmd.Start(); err != nil { s.cmd = nil diff --git a/pkg/coredeno/lifecycle_test.go b/pkg/coredeno/lifecycle_test.go index a8ff90f..ef14c21 100644 --- a/pkg/coredeno/lifecycle_test.go +++ b/pkg/coredeno/lifecycle_test.go @@ -36,6 +36,38 @@ func TestStop_Good_NotStarted(t *testing.T) { assert.NoError(t, err, "stopping a not-started sidecar should be a no-op") } +func TestStart_Good_EnvPassedToChild(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "test.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() + + // Verify the child process has CORE_SOCKET in its environment + sc.mu.RLock() + env := sc.cmd.Env + sc.mu.RUnlock() + + found := false + expected := "CORE_SOCKET=" + sockPath + for _, e := range env { + if e == expected { + found = true + break + } + } + assert.True(t, found, "child process should receive CORE_SOCKET=%s", sockPath) +} + func TestSocketDirCreated_Good(t *testing.T) { dir := t.TempDir() sockPath := filepath.Join(dir, "sub", "deno.sock") diff --git a/pkg/coredeno/listener.go b/pkg/coredeno/listener.go new file mode 100644 index 0000000..5a2a5e2 --- /dev/null +++ b/pkg/coredeno/listener.go @@ -0,0 +1,47 @@ +package coredeno + +import ( + "context" + "net" + "os" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + "google.golang.org/grpc" +) + +// ListenGRPC starts a gRPC server on a Unix socket, serving the CoreService. +// It blocks until ctx is cancelled, then performs a graceful stop. +func ListenGRPC(ctx context.Context, socketPath string, srv *Server) error { + // Clean up stale socket + if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { + return err + } + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + defer func() { + _ = listener.Close() + _ = os.Remove(socketPath) + }() + + gs := grpc.NewServer() + pb.RegisterCoreServiceServer(gs, srv) + + // Graceful stop when context cancelled + go func() { + <-ctx.Done() + gs.GracefulStop() + }() + + if err := gs.Serve(listener); err != nil { + select { + case <-ctx.Done(): + return nil // Expected shutdown + default: + return err + } + } + return nil +} diff --git a/pkg/coredeno/listener_test.go b/pkg/coredeno/listener_test.go new file mode 100644 index 0000000..1ab7f77 --- /dev/null +++ b/pkg/coredeno/listener_test.go @@ -0,0 +1,112 @@ +package coredeno + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestListenGRPC_Good(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "test.sock") + + medium := io.NewMockMedium() + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + srv := NewServer(medium, st) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- ListenGRPC(ctx, sockPath, srv) + }() + + // Wait for socket to appear + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 2*time.Second, 10*time.Millisecond, "socket should appear") + + // Connect as gRPC client + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := pb.NewCoreServiceClient(conn) + + // StoreSet + StoreGet round-trip + _, err = client.StoreSet(ctx, &pb.StoreSetRequest{ + Group: "test", Key: "k", Value: "v", + }) + require.NoError(t, err) + + resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "test", Key: "k", + }) + require.NoError(t, err) + assert.True(t, resp.Found) + assert.Equal(t, "v", resp.Value) + + // Cancel ctx to stop listener + cancel() + + select { + case err := <-errCh: + assert.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("listener did not stop") + } +} + +func TestListenGRPC_Bad_StaleSocket(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "stale.sock") + + // Create a stale socket file + require.NoError(t, os.WriteFile(sockPath, []byte("stale"), 0644)) + + medium := io.NewMockMedium() + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + srv := NewServer(medium, st) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- ListenGRPC(ctx, sockPath, srv) + }() + + // Should replace stale file and start listening + require.Eventually(t, func() bool { + info, err := os.Stat(sockPath) + if err != nil { + return false + } + // Socket file type, not regular file + return info.Mode()&os.ModeSocket != 0 + }, 2*time.Second, 10*time.Millisecond, "socket should replace stale file") + + cancel() + <-errCh +} diff --git a/pkg/coredeno/runtime/main.ts b/pkg/coredeno/runtime/main.ts new file mode 100644 index 0000000..c1c6f93 --- /dev/null +++ b/pkg/coredeno/runtime/main.ts @@ -0,0 +1,30 @@ +// CoreDeno Runtime Entry Point +// Connects to CoreGO via gRPC over Unix socket. +// Implements DenoService for module lifecycle management. + +const socketPath = Deno.env.get("CORE_SOCKET"); +if (!socketPath) { + console.error("FATAL: CORE_SOCKET environment variable not set"); + Deno.exit(1); +} + +console.error(`CoreDeno: connecting to ${socketPath}`); + +// Tier 1: signal readiness and stay alive. +// Tier 2 adds the gRPC client and DenoService implementation. +console.error("CoreDeno: ready"); + +// Keep alive until parent sends SIGTERM +const ac = new AbortController(); +Deno.addSignalListener("SIGTERM", () => { + console.error("CoreDeno: shutting down"); + ac.abort(); +}); + +try { + await new Promise((_resolve, reject) => { + ac.signal.addEventListener("abort", () => reject(new Error("shutdown"))); + }); +} catch { + // Clean exit on SIGTERM +} diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go index e218a2e..9bc4d85 100644 --- a/pkg/coredeno/service.go +++ b/pkg/coredeno/service.go @@ -2,8 +2,12 @@ package coredeno import ( "context" + "fmt" core "forge.lthn.ai/core/go/pkg/framework/core" + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/store" ) // Service wraps the CoreDeno sidecar as a framework service. @@ -14,7 +18,11 @@ import ( // core.New(core.WithService(coredeno.NewServiceFactory(opts))) type Service struct { *core.ServiceRuntime[Options] - sidecar *Sidecar + sidecar *Sidecar + grpcServer *Server + store *store.Store + grpcCancel context.CancelFunc + grpcDone chan error } // NewServiceFactory returns a factory function for framework registration via WithService. @@ -27,17 +35,95 @@ func NewServiceFactory(opts Options) func(*core.Core) (any, error) { } } -// OnStartup starts the Deno sidecar. Called by the framework on app startup. +// OnStartup boots the CoreDeno subsystem. Called by the framework on app startup. +// +// Sequence: medium → store → server → manifest → gRPC listener → sidecar. func (s *Service) OnStartup(ctx context.Context) error { + opts := s.Opts() + + // 1. Create sandboxed Medium (or mock if no AppRoot) + var medium io.Medium + if opts.AppRoot != "" { + var err error + medium, err = io.NewSandboxed(opts.AppRoot) + if err != nil { + return fmt.Errorf("coredeno: medium: %w", err) + } + } else { + medium = io.NewMockMedium() + } + + // 2. Create Store + dbPath := opts.StoreDBPath + if dbPath == "" { + dbPath = ":memory:" + } + var err error + s.store, err = store.New(dbPath) + if err != nil { + return fmt.Errorf("coredeno: store: %w", err) + } + + // 3. Create gRPC Server + s.grpcServer = NewServer(medium, s.store) + + // 4. Load manifest if AppRoot set (non-fatal if missing) + if opts.AppRoot != "" { + m, loadErr := manifest.Load(medium, ".") + if loadErr == nil && m != nil { + if opts.PublicKey != nil { + if ok, verr := manifest.Verify(m, opts.PublicKey); verr == nil && ok { + s.grpcServer.RegisterModule(m) + } + } else { + s.grpcServer.RegisterModule(m) + } + } + } + + // 5. Start gRPC listener in background + grpcCtx, grpcCancel := context.WithCancel(ctx) + s.grpcCancel = grpcCancel + s.grpcDone = make(chan error, 1) + go func() { + s.grpcDone <- ListenGRPC(grpcCtx, opts.SocketPath, s.grpcServer) + }() + + // 6. Start sidecar (if args provided) + if len(opts.SidecarArgs) > 0 { + if err := s.sidecar.Start(ctx, opts.SidecarArgs...); err != nil { + return fmt.Errorf("coredeno: sidecar: %w", err) + } + } + return nil } -// OnShutdown stops the Deno sidecar. Called by the framework on app shutdown. +// OnShutdown stops the CoreDeno subsystem. Called by the framework on app shutdown. func (s *Service) OnShutdown(_ context.Context) error { - return s.sidecar.Stop() + // Stop sidecar first + _ = s.sidecar.Stop() + + // Stop gRPC listener + if s.grpcCancel != nil { + s.grpcCancel() + <-s.grpcDone + } + + // Close store + if s.store != nil { + s.store.Close() + } + + return nil } // Sidecar returns the underlying sidecar for direct access. func (s *Service) Sidecar() *Sidecar { return s.sidecar } + +// GRPCServer returns the gRPC server for direct access. +func (s *Service) GRPCServer() *Server { + return s.grpcServer +} diff --git a/pkg/coredeno/service_test.go b/pkg/coredeno/service_test.go index 685bbc8..008a96b 100644 --- a/pkg/coredeno/service_test.go +++ b/pkg/coredeno/service_test.go @@ -2,11 +2,17 @@ package coredeno import ( "context" + "os" + "path/filepath" "testing" + "time" + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" core "forge.lthn.ai/core/go/pkg/framework/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" ) func TestNewServiceFactory_Good(t *testing.T) { @@ -37,18 +43,27 @@ func TestService_WithService_Good(t *testing.T) { } func TestService_Lifecycle_Good(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "lifecycle.sock") + c, err := core.New() require.NoError(t, err) - factory := NewServiceFactory(Options{DenoPath: "echo"}) + factory := NewServiceFactory(Options{ + DenoPath: "echo", + SocketPath: sockPath, + }) result, _ := factory(c) svc := result.(*Service) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Verify Startable - err = svc.OnStartup(context.Background()) + err = svc.OnStartup(ctx) assert.NoError(t, err) - // Verify Stoppable (not started, should be no-op) + // Verify Stoppable err = svc.OnShutdown(context.Background()) assert.NoError(t, err) } @@ -63,3 +78,106 @@ func TestService_Sidecar_Good(t *testing.T) { assert.NotNil(t, svc.Sidecar()) } + +func TestService_OnStartup_Good(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + + // Write a minimal manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: test-app +name: Test App +version: "1.0" +permissions: + read: ["./data/"] + write: ["./data/"] +`), 0644)) + + opts := Options{ + DenoPath: "sleep", + SocketPath: sockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"60"}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify socket appeared + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 2*time.Second, 10*time.Millisecond, "gRPC socket should appear after startup") + + // Verify gRPC responds + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := pb.NewCoreServiceClient(conn) + _, err = client.StoreSet(ctx, &pb.StoreSetRequest{ + Group: "boot", Key: "ok", Value: "true", + }) + require.NoError(t, err) + + resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "boot", Key: "ok", + }) + require.NoError(t, err) + assert.True(t, resp.Found) + assert.Equal(t, "true", resp.Value) + + // Verify sidecar is running + assert.True(t, svc.sidecar.IsRunning(), "sidecar should be running") + + // Shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "sidecar should be stopped") +} + +func TestService_OnStartup_Good_NoManifest(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + + opts := Options{ + DenoPath: "sleep", + SocketPath: sockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, _ := factory(c) + svc := result.(*Service) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Should succeed even without .core/view.yml + err = svc.OnStartup(ctx) + require.NoError(t, err) + + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) +} -- 2.45.3 From af98accc03a54cc6d1642e27db30cce0ab56dd8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 22:43:12 +0000 Subject: [PATCH 4/6] =?UTF-8?q?feat(coredeno):=20Tier=202=20bidirectional?= =?UTF-8?q?=20bridge=20=E2=80=94=20Go=E2=86=94Deno=20module=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the CoreDeno sidecar into a fully bidirectional bridge: - Deno→Go (gRPC): Deno connects as CoreService client via polyfilled @grpc/grpc-js over Unix socket. Polyfill patches Deno 2.x http2 gaps (getDefaultSettings, pre-connected socket handling, remoteSettings). - Go→Deno (JSON-RPC): Go connects to Deno's newline-delimited JSON-RPC server for module lifecycle (LoadModule, UnloadModule, ModuleStatus). gRPC server direction avoided due to Deno http2.createServer limitations. - ProcessStart/ProcessStop: gRPC handlers delegate to process.Service with manifest permission gating (run permissions). - Deno runtime: main.ts boots DenoService server, connects CoreService client with retry + health-check round-trip, handles SIGTERM shutdown. 40 unit tests + 2 integration tests (Tier 1 boot + Tier 2 bidirectional). Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/coredeno.go | 16 ++- pkg/coredeno/coredeno_test.go | 17 +++ pkg/coredeno/denoclient.go | 127 ++++++++++++++++++++ pkg/coredeno/integration_test.go | 139 ++++++++++++++++++++-- pkg/coredeno/lifecycle.go | 11 +- pkg/coredeno/lifecycle_test.go | 36 ++++++ pkg/coredeno/runtime/client.ts | 95 +++++++++++++++ pkg/coredeno/runtime/deno.json | 7 ++ pkg/coredeno/runtime/deno.lock | 193 +++++++++++++++++++++++++++++++ pkg/coredeno/runtime/main.ts | 87 ++++++++++++-- pkg/coredeno/runtime/modules.ts | 50 ++++++++ pkg/coredeno/runtime/polyfill.ts | 94 +++++++++++++++ pkg/coredeno/runtime/server.ts | 124 ++++++++++++++++++++ pkg/coredeno/server.go | 55 +++++++++ pkg/coredeno/server_test.go | 105 ++++++++++++++++- pkg/coredeno/service.go | 51 +++++++- 16 files changed, 1177 insertions(+), 30 deletions(-) create mode 100644 pkg/coredeno/denoclient.go create mode 100644 pkg/coredeno/runtime/client.ts create mode 100644 pkg/coredeno/runtime/deno.json create mode 100644 pkg/coredeno/runtime/deno.lock create mode 100644 pkg/coredeno/runtime/modules.ts create mode 100644 pkg/coredeno/runtime/polyfill.ts create mode 100644 pkg/coredeno/runtime/server.ts diff --git a/pkg/coredeno/coredeno.go b/pkg/coredeno/coredeno.go index ee50ddb..2150377 100644 --- a/pkg/coredeno/coredeno.go +++ b/pkg/coredeno/coredeno.go @@ -13,12 +13,13 @@ import ( // Options configures the CoreDeno sidecar. type Options struct { - DenoPath string // path to deno binary (default: "deno") - SocketPath string // Unix socket path for gRPC - AppRoot string // app root directory (sandboxed I/O) - StoreDBPath string // SQLite DB path (default: AppRoot/.core/store.db) - PublicKey ed25519.PublicKey // ed25519 public key for manifest verification (optional) - SidecarArgs []string // args passed to the sidecar process + DenoPath string // path to deno binary (default: "deno") + SocketPath string // Unix socket path for Go's gRPC server (CoreService) + DenoSocketPath string // Unix socket path for Deno's gRPC server (DenoService) + AppRoot string // app root directory (sandboxed I/O) + StoreDBPath string // SQLite DB path (default: AppRoot/.core/store.db) + PublicKey ed25519.PublicKey // ed25519 public key for manifest verification (optional) + SidecarArgs []string // args passed to the sidecar process } // Permissions declares per-module Deno permission flags. @@ -74,6 +75,9 @@ func NewSidecar(opts Options) *Sidecar { if opts.SocketPath == "" { opts.SocketPath = DefaultSocketPath() } + if opts.DenoSocketPath == "" && opts.SocketPath != "" { + opts.DenoSocketPath = filepath.Join(filepath.Dir(opts.SocketPath), "deno.sock") + } if opts.StoreDBPath == "" && opts.AppRoot != "" { opts.StoreDBPath = filepath.Join(opts.AppRoot, ".core", "store.db") } diff --git a/pkg/coredeno/coredeno_test.go b/pkg/coredeno/coredeno_test.go index 1da31c8..a670dcc 100644 --- a/pkg/coredeno/coredeno_test.go +++ b/pkg/coredeno/coredeno_test.go @@ -80,3 +80,20 @@ func TestDefaultSocketPath_XDG(t *testing.T) { path := DefaultSocketPath() assert.Equal(t, "/run/user/1000/core/deno.sock", path) } + +func TestOptions_DenoSocketPath_Default_Good(t *testing.T) { + opts := Options{SocketPath: "/tmp/core/core.sock"} + sc := NewSidecar(opts) + assert.Equal(t, "/tmp/core/deno.sock", sc.opts.DenoSocketPath, + "DenoSocketPath should default to same dir as SocketPath with deno.sock") +} + +func TestOptions_DenoSocketPath_Explicit_Good(t *testing.T) { + opts := Options{ + SocketPath: "/tmp/core/core.sock", + DenoSocketPath: "/tmp/custom/deno.sock", + } + sc := NewSidecar(opts) + assert.Equal(t, "/tmp/custom/deno.sock", sc.opts.DenoSocketPath, + "Explicit DenoSocketPath should not be overridden") +} diff --git a/pkg/coredeno/denoclient.go b/pkg/coredeno/denoclient.go new file mode 100644 index 0000000..81b6952 --- /dev/null +++ b/pkg/coredeno/denoclient.go @@ -0,0 +1,127 @@ +package coredeno + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "sync" +) + +// DenoClient communicates with the Deno sidecar's JSON-RPC server over a Unix socket. +// Thread-safe: uses a mutex to serialize requests (one connection, request/response protocol). +type DenoClient struct { + mu sync.Mutex + conn net.Conn + reader *bufio.Reader +} + +// DialDeno connects to the Deno JSON-RPC server on the given Unix socket path. +func DialDeno(socketPath string) (*DenoClient, error) { + conn, err := net.Dial("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("deno dial: %w", err) + } + return &DenoClient{ + conn: conn, + reader: bufio.NewReader(conn), + }, nil +} + +// Close closes the underlying connection. +func (c *DenoClient) Close() error { + return c.conn.Close() +} + +func (c *DenoClient) call(req map[string]any) (map[string]any, error) { + c.mu.Lock() + defer c.mu.Unlock() + + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + data = append(data, '\n') + + if _, err := c.conn.Write(data); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + line, err := c.reader.ReadBytes('\n') + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + + var resp map[string]any + if err := json.Unmarshal(line, &resp); err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + + if errMsg, ok := resp["error"].(string); ok && errMsg != "" { + return nil, fmt.Errorf("deno: %s", errMsg) + } + return resp, nil +} + +// LoadModuleResponse holds the result of a LoadModule call. +type LoadModuleResponse struct { + Ok bool + Error string +} + +// LoadModule tells Deno to load a module. +func (c *DenoClient) LoadModule(code, entryPoint string, permissions []string) (*LoadModuleResponse, error) { + resp, err := c.call(map[string]any{ + "method": "LoadModule", + "code": code, + "entry_point": entryPoint, + "permissions": permissions, + }) + if err != nil { + return nil, err + } + return &LoadModuleResponse{ + Ok: resp["ok"] == true, + Error: fmt.Sprint(resp["error"]), + }, nil +} + +// UnloadModuleResponse holds the result of an UnloadModule call. +type UnloadModuleResponse struct { + Ok bool +} + +// UnloadModule tells Deno to unload a module. +func (c *DenoClient) UnloadModule(code string) (*UnloadModuleResponse, error) { + resp, err := c.call(map[string]any{ + "method": "UnloadModule", + "code": code, + }) + if err != nil { + return nil, err + } + return &UnloadModuleResponse{ + Ok: resp["ok"] == true, + }, nil +} + +// ModuleStatusResponse holds the result of a ModuleStatus call. +type ModuleStatusResponse struct { + Code string + Status string +} + +// ModuleStatus queries the status of a module in the Deno runtime. +func (c *DenoClient) ModuleStatus(code string) (*ModuleStatusResponse, error) { + resp, err := c.call(map[string]any{ + "method": "ModuleStatus", + "code": code, + }) + if err != nil { + return nil, err + } + return &ModuleStatusResponse{ + Code: fmt.Sprint(resp["code"]), + Status: fmt.Sprint(resp["status"]), + }, nil +} diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go index ce83fe0..b1c515d 100644 --- a/pkg/coredeno/integration_test.go +++ b/pkg/coredeno/integration_test.go @@ -18,16 +18,34 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -func TestIntegration_FullBoot_Good(t *testing.T) { +// unused import guard +var _ = pb.NewCoreServiceClient + +func findDeno(t *testing.T) string { + t.Helper() denoPath, err := exec.LookPath("deno") if err != nil { - // Check ~/.deno/bin/deno home, _ := os.UserHomeDir() denoPath = filepath.Join(home, ".deno", "bin", "deno") if _, err := os.Stat(denoPath); err != nil { t.Skip("deno not installed") } } + return denoPath +} + +// runtimeEntryPoint returns the absolute path to runtime/main.ts. +func runtimeEntryPoint(t *testing.T) string { + t.Helper() + // We're in pkg/coredeno/ during test, runtime is a subdir + abs, err := filepath.Abs("runtime/main.ts") + require.NoError(t, err) + require.FileExists(t, abs) + return abs +} + +func TestIntegration_FullBoot_Good(t *testing.T) { + denoPath := findDeno(t) tmpDir := t.TempDir() sockPath := filepath.Join(tmpDir, "core.sock") @@ -43,21 +61,14 @@ permissions: read: ["./data/"] `), 0644)) - // Copy the runtime entry point - runtimeDir := filepath.Join(coreDir, "runtime") - require.NoError(t, os.MkdirAll(runtimeDir, 0755)) - src, err := os.ReadFile("runtime/main.ts") - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(runtimeDir, "main.ts"), src, 0644)) - - entryPoint := filepath.Join(runtimeDir, "main.ts") + entryPoint := runtimeEntryPoint(t) opts := Options{ DenoPath: denoPath, SocketPath: sockPath, AppRoot: tmpDir, StoreDBPath: ":memory:", - SidecarArgs: []string{"run", "--allow-env", entryPoint}, + SidecarArgs: []string{"run", "-A", entryPoint}, } c, err := core.New() @@ -68,7 +79,7 @@ permissions: require.NoError(t, err) svc := result.(*Service) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() err = svc.OnStartup(ctx) @@ -108,3 +119,107 @@ permissions: assert.NoError(t, err) assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") } + +func TestIntegration_Tier2_Bidirectional_Good(t *testing.T) { + denoPath := findDeno(t) + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + denoSockPath := filepath.Join(tmpDir, "deno.sock") + + // Write a manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: tier2-test +name: Tier 2 Test +version: "1.0" +permissions: + read: ["./data/"] + run: ["echo"] +`), 0644)) + + entryPoint := runtimeEntryPoint(t) + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + DenoSocketPath: denoSockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "-A", entryPoint}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify both sockets appeared + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 10*time.Second, 50*time.Millisecond, "core socket should appear") + + require.Eventually(t, func() bool { + _, err := os.Stat(denoSockPath) + return err == nil + }, 10*time.Second, 50*time.Millisecond, "deno socket should appear") + + // Verify sidecar is running + assert.True(t, svc.sidecar.IsRunning(), "Deno sidecar should be running") + + // Verify DenoClient is connected + require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") + + // Test Go → Deno: LoadModule + loadResp, err := svc.DenoClient().LoadModule("test-module", "/modules/test/main.ts", []string{"read", "net"}) + require.NoError(t, err) + assert.True(t, loadResp.Ok) + + // Test Go → Deno: ModuleStatus + statusResp, err := svc.DenoClient().ModuleStatus("test-module") + require.NoError(t, err) + assert.Equal(t, "test-module", statusResp.Code) + assert.Equal(t, "RUNNING", statusResp.Status) + + // Test Go → Deno: UnloadModule + unloadResp, err := svc.DenoClient().UnloadModule("test-module") + require.NoError(t, err) + assert.True(t, unloadResp.Ok) + + // Verify module is now STOPPED + statusResp2, err := svc.DenoClient().ModuleStatus("test-module") + require.NoError(t, err) + assert.Equal(t, "STOPPED", statusResp2.Status) + + // Verify CoreService gRPC still works (Deno wrote health check data) + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + coreClient := pb.NewCoreServiceClient(conn) + getResp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "_coredeno", Key: "status", + }) + require.NoError(t, err) + assert.True(t, getResp.Found) + assert.Equal(t, "connected", getResp.Value, "Deno should have written health check") + + // Clean shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") +} diff --git a/pkg/coredeno/lifecycle.go b/pkg/coredeno/lifecycle.go index f5975b5..1b58039 100644 --- a/pkg/coredeno/lifecycle.go +++ b/pkg/coredeno/lifecycle.go @@ -23,12 +23,17 @@ func (s *Sidecar) Start(ctx context.Context, args ...string) error { return fmt.Errorf("coredeno: mkdir %s: %w", sockDir, err) } - // Remove stale socket - os.Remove(s.opts.SocketPath) + // Remove stale Deno socket (the Core socket is managed by ListenGRPC) + if s.opts.DenoSocketPath != "" { + os.Remove(s.opts.DenoSocketPath) + } s.ctx, s.cancel = context.WithCancel(ctx) s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...) - s.cmd.Env = append(os.Environ(), "CORE_SOCKET="+s.opts.SocketPath) + s.cmd.Env = append(os.Environ(), + "CORE_SOCKET="+s.opts.SocketPath, + "DENO_SOCKET="+s.opts.DenoSocketPath, + ) s.done = make(chan struct{}) if err := s.cmd.Start(); err != nil { s.cmd = nil diff --git a/pkg/coredeno/lifecycle_test.go b/pkg/coredeno/lifecycle_test.go index ef14c21..f9347fc 100644 --- a/pkg/coredeno/lifecycle_test.go +++ b/pkg/coredeno/lifecycle_test.go @@ -68,6 +68,42 @@ func TestStart_Good_EnvPassedToChild(t *testing.T) { assert.True(t, found, "child process should receive CORE_SOCKET=%s", sockPath) } +func TestStart_Good_DenoSocketEnv(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "core.sock") + denoSockPath := filepath.Join(sockDir, "deno.sock") + + sc := NewSidecar(Options{ + DenoPath: "sleep", + SocketPath: sockPath, + DenoSocketPath: denoSockPath, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := sc.Start(ctx, "10") + require.NoError(t, err) + defer sc.Stop() + + sc.mu.RLock() + env := sc.cmd.Env + sc.mu.RUnlock() + + foundCore := false + foundDeno := false + for _, e := range env { + if e == "CORE_SOCKET="+sockPath { + foundCore = true + } + if e == "DENO_SOCKET="+denoSockPath { + foundDeno = true + } + } + assert.True(t, foundCore, "child should receive CORE_SOCKET") + assert.True(t, foundDeno, "child should receive DENO_SOCKET") +} + func TestSocketDirCreated_Good(t *testing.T) { dir := t.TempDir() sockPath := filepath.Join(dir, "sub", "deno.sock") diff --git a/pkg/coredeno/runtime/client.ts b/pkg/coredeno/runtime/client.ts new file mode 100644 index 0000000..8afe006 --- /dev/null +++ b/pkg/coredeno/runtime/client.ts @@ -0,0 +1,95 @@ +// CoreService gRPC client — Deno calls Go for I/O operations. +// All filesystem, store, and process operations route through this client. + +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROTO_PATH = join(__dirname, "..", "proto", "coredeno.proto"); + +let packageDef: protoLoader.PackageDefinition | null = null; + +function getProto(): any { + if (!packageDef) { + packageDef = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + } + return grpc.loadPackageDefinition(packageDef).coredeno as any; +} + +export interface CoreClient { + raw: any; + storeGet(group: string, key: string): Promise<{ value: string; found: boolean }>; + storeSet(group: string, key: string, value: string): Promise<{ ok: boolean }>; + fileRead(path: string, moduleCode: string): Promise<{ content: string }>; + fileWrite(path: string, content: string, moduleCode: string): Promise<{ ok: boolean }>; + fileList(path: string, moduleCode: string): Promise<{ entries: Array<{ name: string; is_dir: boolean; size: number }> }>; + fileDelete(path: string, moduleCode: string): Promise<{ ok: boolean }>; + processStart(command: string, args: string[], moduleCode: string): Promise<{ process_id: string }>; + processStop(processId: string): Promise<{ ok: boolean }>; + close(): void; +} + +function promisify(client: any, method: string, request: any): Promise { + return new Promise((resolve, reject) => { + client[method](request, (err: Error | null, response: T) => { + if (err) reject(err); + else resolve(response); + }); + }); +} + +export function createCoreClient(socketPath: string): CoreClient { + const proto = getProto(); + const client = new proto.CoreService( + `unix:${socketPath}`, + grpc.credentials.createInsecure(), + ); + + return { + raw: client, + + storeGet(group: string, key: string) { + return promisify(client, "StoreGet", { group, key }); + }, + + storeSet(group: string, key: string, value: string) { + return promisify(client, "StoreSet", { group, key, value }); + }, + + fileRead(path: string, moduleCode: string) { + return promisify(client, "FileRead", { path, module_code: moduleCode }); + }, + + fileWrite(path: string, content: string, moduleCode: string) { + return promisify(client, "FileWrite", { path, content, module_code: moduleCode }); + }, + + fileList(path: string, moduleCode: string) { + return promisify(client, "FileList", { path, module_code: moduleCode }); + }, + + fileDelete(path: string, moduleCode: string) { + return promisify(client, "FileDelete", { path, module_code: moduleCode }); + }, + + processStart(command: string, args: string[], moduleCode: string) { + return promisify(client, "ProcessStart", { command, args, module_code: moduleCode }); + }, + + processStop(processId: string) { + return promisify(client, "ProcessStop", { process_id: processId }); + }, + + close() { + client.close(); + }, + }; +} diff --git a/pkg/coredeno/runtime/deno.json b/pkg/coredeno/runtime/deno.json new file mode 100644 index 0000000..13f798e --- /dev/null +++ b/pkg/coredeno/runtime/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "@grpc/grpc-js": "npm:@grpc/grpc-js@^1.12", + "@grpc/proto-loader": "npm:@grpc/proto-loader@^0.7" + }, + "nodeModulesDir": "none" +} diff --git a/pkg/coredeno/runtime/deno.lock b/pkg/coredeno/runtime/deno.lock new file mode 100644 index 0000000..b02e151 --- /dev/null +++ b/pkg/coredeno/runtime/deno.lock @@ -0,0 +1,193 @@ +{ + "version": "5", + "specifiers": { + "npm:@grpc/grpc-js@^1.12.0": "1.14.3", + "npm:@grpc/proto-loader@0.7": "0.7.15" + }, + "npm": { + "@grpc/grpc-js@1.14.3": { + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "dependencies": [ + "@grpc/proto-loader@0.8.0", + "@js-sdsl/ordered-map" + ] + }, + "@grpc/proto-loader@0.7.15": { + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dependencies": [ + "lodash.camelcase", + "long", + "protobufjs", + "yargs" + ], + "bin": true + }, + "@grpc/proto-loader@0.8.0": { + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dependencies": [ + "lodash.camelcase", + "long", + "protobufjs", + "yargs" + ], + "bin": true + }, + "@js-sdsl/ordered-map@4.4.2": { + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==" + }, + "@protobufjs/aspromise@1.1.2": { + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64@1.1.2": { + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen@2.0.4": { + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter@1.1.0": { + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch@1.1.0": { + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": [ + "@protobufjs/aspromise", + "@protobufjs/inquire" + ] + }, + "@protobufjs/float@1.0.2": { + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire@1.1.0": { + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path@1.1.2": { + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool@1.1.0": { + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8@1.1.0": { + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "@types/node@25.2.3": { + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "dependencies": [ + "undici-types" + ] + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "cliui@8.0.1": { + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": [ + "string-width", + "strip-ansi", + "wrap-ansi" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "lodash.camelcase@4.3.0": { + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "protobufjs@7.5.4": { + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "dependencies": [ + "@protobufjs/aspromise", + "@protobufjs/base64", + "@protobufjs/codegen", + "@protobufjs/eventemitter", + "@protobufjs/fetch", + "@protobufjs/float", + "@protobufjs/inquire", + "@protobufjs/path", + "@protobufjs/pool", + "@protobufjs/utf8", + "@types/node", + "long" + ], + "scripts": true + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex", + "is-fullwidth-code-point", + "strip-ansi" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex" + ] + }, + "undici-types@7.16.0": { + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles", + "string-width", + "strip-ansi" + ] + }, + "y18n@5.0.8": { + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs-parser@21.1.1": { + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yargs@17.7.2": { + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": [ + "cliui", + "escalade", + "get-caller-file", + "require-directory", + "string-width", + "y18n", + "yargs-parser" + ] + } + }, + "workspace": { + "dependencies": [ + "npm:@grpc/grpc-js@^1.12.0", + "npm:@grpc/proto-loader@0.7" + ] + } +} diff --git a/pkg/coredeno/runtime/main.ts b/pkg/coredeno/runtime/main.ts index c1c6f93..ca0aba4 100644 --- a/pkg/coredeno/runtime/main.ts +++ b/pkg/coredeno/runtime/main.ts @@ -2,19 +2,90 @@ // Connects to CoreGO via gRPC over Unix socket. // Implements DenoService for module lifecycle management. -const socketPath = Deno.env.get("CORE_SOCKET"); -if (!socketPath) { +// Must be first import — patches http2 before @grpc/grpc-js loads. +import "./polyfill.ts"; + +import { createCoreClient, type CoreClient } from "./client.ts"; +import { startDenoServer, type DenoServer } from "./server.ts"; +import { ModuleRegistry } from "./modules.ts"; + +// Read required environment variables +const coreSocket = Deno.env.get("CORE_SOCKET"); +if (!coreSocket) { console.error("FATAL: CORE_SOCKET environment variable not set"); Deno.exit(1); } -console.error(`CoreDeno: connecting to ${socketPath}`); +const denoSocket = Deno.env.get("DENO_SOCKET"); +if (!denoSocket) { + console.error("FATAL: DENO_SOCKET environment variable not set"); + Deno.exit(1); +} -// Tier 1: signal readiness and stay alive. -// Tier 2 adds the gRPC client and DenoService implementation. +console.error(`CoreDeno: CORE_SOCKET=${coreSocket}`); +console.error(`CoreDeno: DENO_SOCKET=${denoSocket}`); + +// 1. Create module registry +const registry = new ModuleRegistry(); + +// 2. Start DenoService server (Go calls us here via JSON-RPC over Unix socket) +let denoServer: DenoServer; +try { + denoServer = await startDenoServer(denoSocket, registry); + console.error("CoreDeno: DenoService server started"); +} catch (err) { + console.error(`FATAL: failed to start DenoService server: ${err}`); + Deno.exit(1); +} + +// 3. Connect to CoreService (we call Go here) with retry +let coreClient: CoreClient; +{ + coreClient = createCoreClient(coreSocket); + const maxRetries = 20; + let connected = false; + let lastErr: unknown; + for (let i = 0; i < maxRetries; i++) { + try { + const timeoutCall = (p: Promise): Promise => + Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error("call timeout")), 2000), + ), + ]); + await timeoutCall( + coreClient.storeSet("_coredeno", "status", "connected"), + ); + const resp = await timeoutCall( + coreClient.storeGet("_coredeno", "status"), + ); + if (resp.found && resp.value === "connected") { + connected = true; + break; + } + } catch (err) { + lastErr = err; + if (i < 3 || i === 9 || i === 19) { + console.error(`CoreDeno: retry ${i}: ${err}`); + } + } + await new Promise((r) => setTimeout(r, 250)); + } + if (!connected) { + console.error( + `FATAL: failed to connect to CoreService after retries, last error: ${lastErr}`, + ); + denoServer.close(); + Deno.exit(1); + } + console.error("CoreDeno: CoreService client connected"); +} + +// 4. Signal readiness console.error("CoreDeno: ready"); -// Keep alive until parent sends SIGTERM +// 5. Keep alive until SIGTERM const ac = new AbortController(); Deno.addSignalListener("SIGTERM", () => { console.error("CoreDeno: shutting down"); @@ -26,5 +97,7 @@ try { ac.signal.addEventListener("abort", () => reject(new Error("shutdown"))); }); } catch { - // Clean exit on SIGTERM + // Clean shutdown + coreClient.close(); + denoServer.close(); } diff --git a/pkg/coredeno/runtime/modules.ts b/pkg/coredeno/runtime/modules.ts new file mode 100644 index 0000000..a53bd4b --- /dev/null +++ b/pkg/coredeno/runtime/modules.ts @@ -0,0 +1,50 @@ +// Module registry — tracks loaded modules and their lifecycle status. +// Tier 2: status tracking only. Tier 3 adds real Deno worker isolates. + +export type ModuleStatus = "UNKNOWN" | "LOADING" | "RUNNING" | "STOPPED" | "ERRORED"; + +// Status enum values matching the proto definition. +export const StatusEnum: Record = { + UNKNOWN: 0, + LOADING: 1, + RUNNING: 2, + STOPPED: 3, + ERRORED: 4, +}; + +export interface Module { + code: string; + entryPoint: string; + permissions: string[]; + status: ModuleStatus; +} + +export class ModuleRegistry { + private modules = new Map(); + + load(code: string, entryPoint: string, permissions: string[]): void { + this.modules.set(code, { + code, + entryPoint, + permissions, + status: "RUNNING", + }); + console.error(`CoreDeno: module loaded: ${code}`); + } + + unload(code: string): boolean { + const mod = this.modules.get(code); + if (!mod) return false; + mod.status = "STOPPED"; + console.error(`CoreDeno: module unloaded: ${code}`); + return true; + } + + status(code: string): ModuleStatus { + return this.modules.get(code)?.status ?? "UNKNOWN"; + } + + list(): Module[] { + return Array.from(this.modules.values()); + } +} diff --git a/pkg/coredeno/runtime/polyfill.ts b/pkg/coredeno/runtime/polyfill.ts new file mode 100644 index 0000000..a3ef4f9 --- /dev/null +++ b/pkg/coredeno/runtime/polyfill.ts @@ -0,0 +1,94 @@ +// Deno http2 + grpc-js polyfill — must be imported BEFORE @grpc/grpc-js. +// +// Two issues with Deno 2.x node compat: +// 1. http2.getDefaultSettings throws "Not implemented" +// 2. grpc-js's createConnection returns a socket that reports readyState="open" +// but never emits "connect", causing http2 sessions to hang forever. +// Fix: wrap createConnection to emit "connect" on next tick for open sockets. + +import http2 from "node:http2"; + +// Fix 1: getDefaultSettings stub +(http2 as any).getDefaultSettings = () => ({ + headerTableSize: 4096, + enablePush: true, + initialWindowSize: 65535, + maxFrameSize: 16384, + maxConcurrentStreams: 0xffffffff, + maxHeaderListSize: 65535, + maxHeaderSize: 65535, + enableConnectProtocol: false, +}); + +// Fix 2: grpc-js (transport.js line 536) passes an already-connected socket +// to http2.connect via createConnection. Deno's http2 never completes the +// HTTP/2 handshake because it expects a "connect" event from the socket, +// which already fired. Emitting "connect" again causes "Busy: Unix socket +// is currently in use" in Deno's internal http2. +// +// Workaround: track Unix socket paths via net.connect intercept, then in +// createConnection, return a FRESH socket. Keep the original socket alive +// (grpc-js has close listeners on it) but unused for data. +import net from "node:net"; + +const socketPathMap = new WeakMap(); +const origNetConnect = net.connect; +(net as any).connect = function (...args: any[]) { + const sock = origNetConnect.apply(this, args as any); + if (args[0] && typeof args[0] === "object" && args[0].path) { + socketPathMap.set(sock, args[0].path); + } + return sock; +}; + +// Fix 3: Deno's http2 client never fires "remoteSettings" event, which +// grpc-js waits for before marking the transport as READY. +// Workaround: emit "remoteSettings" after "connect" with reasonable defaults. +const origConnect = http2.connect; +(http2 as any).connect = function ( + authority: any, + options: any, + ...rest: any[] +) { + // For Unix sockets: replace pre-connected socket with fresh one + if (options?.createConnection) { + const origCC = options.createConnection; + options = { + ...options, + createConnection(...ccArgs: any[]) { + const origSock = origCC.apply(this, ccArgs); + const unixPath = socketPathMap.get(origSock); + if ( + unixPath && + !origSock.connecting && + origSock.readyState === "open" + ) { + const freshSock = net.connect({ path: unixPath }); + freshSock.on("close", () => origSock.destroy()); + return freshSock; + } + return origSock; + }, + }; + } + + const session = origConnect.call(this, authority, options, ...rest); + + // Emit remoteSettings after connect — Deno's http2 doesn't emit it + session.once("connect", () => { + if (!session.destroyed && !session.closed) { + const settings = { + headerTableSize: 4096, + enablePush: false, + initialWindowSize: 65535, + maxFrameSize: 16384, + maxConcurrentStreams: 100, + maxHeaderListSize: 8192, + maxHeaderSize: 8192, + }; + process.nextTick(() => session.emit("remoteSettings", settings)); + } + }); + + return session; +}; diff --git a/pkg/coredeno/runtime/server.ts b/pkg/coredeno/runtime/server.ts new file mode 100644 index 0000000..81065a2 --- /dev/null +++ b/pkg/coredeno/runtime/server.ts @@ -0,0 +1,124 @@ +// DenoService JSON-RPC server — Go calls Deno for module lifecycle management. +// Uses length-prefixed JSON over raw Unix socket (Deno's http2 server is broken). +// Protocol: 4-byte big-endian length + JSON payload, newline-delimited. + +import { ModuleRegistry } from "./modules.ts"; + +export interface DenoServer { + close(): void; +} + +export async function startDenoServer( + socketPath: string, + registry: ModuleRegistry, +): Promise { + // Remove stale socket + try { + Deno.removeSync(socketPath); + } catch { + // ignore + } + + const listener = Deno.listen({ transport: "unix", path: socketPath }); + + const handleConnection = async (conn: Deno.UnixConn) => { + const reader = conn.readable.getReader(); + const writer = conn.writable.getWriter(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines (newline-delimited JSON) + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx); + buffer = buffer.slice(newlineIdx + 1); + + if (!line.trim()) continue; + + try { + const req = JSON.parse(line); + const resp = dispatch(req, registry); + await writer.write( + new TextEncoder().encode(JSON.stringify(resp) + "\n"), + ); + } catch (err) { + const errResp = { + error: err instanceof Error ? err.message : String(err), + }; + await writer.write( + new TextEncoder().encode(JSON.stringify(errResp) + "\n"), + ); + } + } + } + } catch { + // Connection closed or error — expected during shutdown + } finally { + try { + writer.close(); + } catch { + /* already closed */ + } + } + }; + + // Accept connections in background + const abortController = new AbortController(); + (async () => { + try { + for await (const conn of listener) { + if (abortController.signal.aborted) break; + handleConnection(conn); + } + } catch { + // Listener closed + } + })(); + + return { + close() { + abortController.abort(); + listener.close(); + }, + }; +} + +interface RPCRequest { + method: string; + code?: string; + entry_point?: string; + permissions?: string[]; + process_id?: string; +} + +function dispatch( + req: RPCRequest, + registry: ModuleRegistry, +): Record { + switch (req.method) { + case "LoadModule": { + registry.load( + req.code ?? "", + req.entry_point ?? "", + req.permissions ?? [], + ); + return { ok: true, error: "" }; + } + case "UnloadModule": { + const ok = registry.unload(req.code ?? ""); + return { ok }; + } + case "ModuleStatus": { + return { code: req.code, status: registry.status(req.code ?? "") }; + } + default: + return { error: `unknown method: ${req.method}` }; + } +} diff --git a/pkg/coredeno/server.go b/pkg/coredeno/server.go index 31395a1..df040bf 100644 --- a/pkg/coredeno/server.go +++ b/pkg/coredeno/server.go @@ -8,8 +8,27 @@ import ( "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/manifest" "forge.lthn.ai/core/go/pkg/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +// ProcessRunner abstracts process management for the gRPC server. +// Satisfied by *process.Service. +type ProcessRunner interface { + Start(ctx context.Context, command string, args ...string) (ProcessHandle, error) + Kill(id string) error +} + +// ProcessHandle is returned by ProcessRunner.Start. +type ProcessHandle interface { + Info() ProcessInfo +} + +// ProcessInfo is the subset of process info the server needs. +type ProcessInfo struct { + ID string +} + // Server implements the CoreService gRPC interface with permission gating. // Every I/O request is checked against the calling module's declared permissions. type Server struct { @@ -17,6 +36,7 @@ type Server struct { medium io.Medium store *store.Store manifests map[string]*manifest.Manifest + processes ProcessRunner } // NewServer creates a CoreService server backed by the given Medium and Store. @@ -129,3 +149,38 @@ func (s *Server) StoreSet(_ context.Context, req *pb.StoreSetRequest) (*pb.Store } return &pb.StoreSetResponse{Ok: true}, nil } + +// SetProcessRunner sets the process runner for ProcessStart/ProcessStop. +func (s *Server) SetProcessRunner(pr ProcessRunner) { + s.processes = pr +} + +// ProcessStart implements CoreService.ProcessStart with permission gating. +func (s *Server) ProcessStart(ctx context.Context, req *pb.ProcessStartRequest) (*pb.ProcessStartResponse, error) { + if s.processes == nil { + return nil, status.Error(codes.Unimplemented, "process service not available") + } + m, err := s.getManifest(req.ModuleCode) + if err != nil { + return nil, err + } + if !CheckRun(req.Command, m.Permissions.Run) { + return nil, fmt.Errorf("permission denied: %s cannot run %s", req.ModuleCode, req.Command) + } + proc, err := s.processes.Start(ctx, req.Command, req.Args...) + if err != nil { + return nil, fmt.Errorf("process start: %w", err) + } + return &pb.ProcessStartResponse{ProcessId: proc.Info().ID}, nil +} + +// ProcessStop implements CoreService.ProcessStop. +func (s *Server) ProcessStop(_ context.Context, req *pb.ProcessStopRequest) (*pb.ProcessStopResponse, error) { + if s.processes == nil { + return nil, status.Error(codes.Unimplemented, "process service not available") + } + if err := s.processes.Kill(req.ProcessId); err != nil { + return nil, fmt.Errorf("process stop: %w", err) + } + return &pb.ProcessStopResponse{Ok: true}, nil +} diff --git a/pkg/coredeno/server_test.go b/pkg/coredeno/server_test.go index 6438f33..276e064 100644 --- a/pkg/coredeno/server_test.go +++ b/pkg/coredeno/server_test.go @@ -2,16 +2,48 @@ package coredeno import ( "context" + "fmt" "testing" + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/manifest" - pb "forge.lthn.ai/core/go/pkg/coredeno/proto" "forge.lthn.ai/core/go/pkg/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +// mockProcessRunner implements ProcessRunner for testing. +type mockProcessRunner struct { + started map[string]bool + nextID int +} + +func newMockProcessRunner() *mockProcessRunner { + return &mockProcessRunner{started: make(map[string]bool)} +} + +func (m *mockProcessRunner) Start(_ context.Context, command string, args ...string) (ProcessHandle, error) { + m.nextID++ + id := fmt.Sprintf("proc-%d", m.nextID) + m.started[id] = true + return &mockProcessHandle{id: id}, nil +} + +func (m *mockProcessRunner) Kill(id string) error { + if !m.started[id] { + return fmt.Errorf("process not found: %s", id) + } + delete(m.started, id) + return nil +} + +type mockProcessHandle struct{ id string } + +func (h *mockProcessHandle) Info() ProcessInfo { return ProcessInfo{ID: h.id} } + func newTestServer(t *testing.T) *Server { t.Helper() medium := io.NewMockMedium() @@ -95,3 +127,74 @@ func TestStoreGet_Good_NotFound(t *testing.T) { require.NoError(t, err) assert.False(t, resp.Found) } + +func newTestServerWithProcess(t *testing.T) (*Server, *mockProcessRunner) { + t.Helper() + srv := newTestServer(t) + srv.RegisterModule(&manifest.Manifest{ + Code: "runner-mod", + Permissions: manifest.Permissions{ + Run: []string{"echo", "ls"}, + }, + }) + pr := newMockProcessRunner() + srv.SetProcessRunner(pr) + return srv, pr +} + +func TestProcessStart_Good(t *testing.T) { + srv, _ := newTestServerWithProcess(t) + resp, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{ + Command: "echo", Args: []string{"hello"}, ModuleCode: "runner-mod", + }) + require.NoError(t, err) + assert.NotEmpty(t, resp.ProcessId) +} + +func TestProcessStart_Bad_PermissionDenied(t *testing.T) { + srv, _ := newTestServerWithProcess(t) + _, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{ + Command: "rm", Args: []string{"-rf", "/"}, ModuleCode: "runner-mod", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestProcessStart_Bad_NoProcessService(t *testing.T) { + srv := newTestServer(t) + srv.RegisterModule(&manifest.Manifest{ + Code: "no-proc-mod", + Permissions: manifest.Permissions{Run: []string{"echo"}}, + }) + _, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{ + Command: "echo", ModuleCode: "no-proc-mod", + }) + assert.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unimplemented, st.Code()) +} + +func TestProcessStop_Good(t *testing.T) { + srv, _ := newTestServerWithProcess(t) + // Start a process first + startResp, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{ + Command: "echo", ModuleCode: "runner-mod", + }) + require.NoError(t, err) + + // Stop it + resp, err := srv.ProcessStop(context.Background(), &pb.ProcessStopRequest{ + ProcessId: startResp.ProcessId, + }) + require.NoError(t, err) + assert.True(t, resp.Ok) +} + +func TestProcessStop_Bad_NotFound(t *testing.T) { + srv, _ := newTestServerWithProcess(t) + _, err := srv.ProcessStop(context.Background(), &pb.ProcessStopRequest{ + ProcessId: "nonexistent", + }) + assert.Error(t, err) +} diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go index 9bc4d85..fe6bd70 100644 --- a/pkg/coredeno/service.go +++ b/pkg/coredeno/service.go @@ -3,6 +3,8 @@ package coredeno import ( "context" "fmt" + "os" + "time" core "forge.lthn.ai/core/go/pkg/framework/core" "forge.lthn.ai/core/go/pkg/io" @@ -23,6 +25,7 @@ type Service struct { store *store.Store grpcCancel context.CancelFunc grpcDone chan error + denoClient *DenoClient } // NewServiceFactory returns a factory function for framework registration via WithService. @@ -91,9 +94,26 @@ func (s *Service) OnStartup(ctx context.Context) error { // 6. Start sidecar (if args provided) if len(opts.SidecarArgs) > 0 { + // Wait for core socket so sidecar can connect to our gRPC server + if err := waitForSocket(ctx, opts.SocketPath, 5*time.Second); err != nil { + return fmt.Errorf("coredeno: core socket: %w", err) + } + if err := s.sidecar.Start(ctx, opts.SidecarArgs...); err != nil { return fmt.Errorf("coredeno: sidecar: %w", err) } + + // 7. Wait for Deno's server and connect as client + if opts.DenoSocketPath != "" { + if err := waitForSocket(ctx, opts.DenoSocketPath, 10*time.Second); err != nil { + return fmt.Errorf("coredeno: deno socket: %w", err) + } + dc, err := DialDeno(opts.DenoSocketPath) + if err != nil { + return fmt.Errorf("coredeno: deno client: %w", err) + } + s.denoClient = dc + } } return nil @@ -101,7 +121,12 @@ func (s *Service) OnStartup(ctx context.Context) error { // OnShutdown stops the CoreDeno subsystem. Called by the framework on app shutdown. func (s *Service) OnShutdown(_ context.Context) error { - // Stop sidecar first + // Close Deno client connection + if s.denoClient != nil { + s.denoClient.Close() + } + + // Stop sidecar _ = s.sidecar.Stop() // Stop gRPC listener @@ -127,3 +152,27 @@ func (s *Service) Sidecar() *Sidecar { func (s *Service) GRPCServer() *Server { return s.grpcServer } + +// DenoClient returns the DenoService client for calling the Deno sidecar. +// Returns nil if the sidecar was not started or has no DenoSocketPath. +func (s *Service) DenoClient() *DenoClient { + return s.denoClient +} + +// waitForSocket polls until a Unix socket file appears or the context/timeout expires. +func waitForSocket(ctx context.Context, path string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if _, err := os.Stat(path); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for socket %s", path) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} -- 2.45.3 From ad6a4664595a58167671d0da138c46a6d2cecd65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 00:48:16 +0000 Subject: [PATCH 5/6] =?UTF-8?q?feat(coredeno):=20Tier=203=20Worker=20isola?= =?UTF-8?q?tion=20=E2=80=94=20sandboxed=20module=20loading=20with=20I/O=20?= =?UTF-8?q?bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each module now runs in a real Deno Worker with per-module permission sandboxing. The I/O bridge relays Worker postMessage calls through the parent to CoreService gRPC, so modules can access store, files, and processes without direct network/filesystem access. - Worker bootstrap (worker-entry.ts): sets up RPC bridge, dynamically imports module, calls init(core) with typed I/O object - ModuleRegistry rewritten: creates Workers with Deno permission constructor, handles LOADING → RUNNING → STOPPED lifecycle - Structured ModulePermissions (read/write/net/run) replaces flat string array in Go→Deno JSON-RPC - I/O bridge: Worker postMessage → parent dispatchRPC → CoreClient gRPC → response relayed back to Worker - Test module proves end-to-end: Worker calls core.storeSet() → Go verifies value in store 40 unit tests + 3 integration tests (Tier 1 boot + Tier 2 bidir + Tier 3 Worker). Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/denoclient.go | 14 +- pkg/coredeno/integration_test.go | 124 +++++++++++- pkg/coredeno/runtime/deno.json | 3 +- pkg/coredeno/runtime/main.ts | 7 +- pkg/coredeno/runtime/modules.ts | 192 +++++++++++++++++-- pkg/coredeno/runtime/server.ts | 4 +- pkg/coredeno/runtime/testdata/test-module.ts | 5 + pkg/coredeno/runtime/worker-entry.ts | 79 ++++++++ 8 files changed, 396 insertions(+), 32 deletions(-) create mode 100644 pkg/coredeno/runtime/testdata/test-module.ts create mode 100644 pkg/coredeno/runtime/worker-entry.ts diff --git a/pkg/coredeno/denoclient.go b/pkg/coredeno/denoclient.go index 81b6952..81ecc39 100644 --- a/pkg/coredeno/denoclient.go +++ b/pkg/coredeno/denoclient.go @@ -63,19 +63,27 @@ func (c *DenoClient) call(req map[string]any) (map[string]any, error) { return resp, nil } +// ModulePermissions declares per-module permission scopes for Deno Worker sandboxing. +type ModulePermissions struct { + Read []string `json:"read,omitempty"` + Write []string `json:"write,omitempty"` + Net []string `json:"net,omitempty"` + Run []string `json:"run,omitempty"` +} + // LoadModuleResponse holds the result of a LoadModule call. type LoadModuleResponse struct { Ok bool Error string } -// LoadModule tells Deno to load a module. -func (c *DenoClient) LoadModule(code, entryPoint string, permissions []string) (*LoadModuleResponse, error) { +// LoadModule tells Deno to load a module with the given permissions. +func (c *DenoClient) LoadModule(code, entryPoint string, perms ModulePermissions) (*LoadModuleResponse, error) { resp, err := c.call(map[string]any{ "method": "LoadModule", "code": code, "entry_point": entryPoint, - "permissions": permissions, + "permissions": perms, }) if err != nil { return nil, err diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go index b1c515d..da61c8c 100644 --- a/pkg/coredeno/integration_test.go +++ b/pkg/coredeno/integration_test.go @@ -44,6 +44,15 @@ func runtimeEntryPoint(t *testing.T) string { return abs } +// testModulePath returns the absolute path to runtime/testdata/test-module.ts. +func testModulePath(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs("runtime/testdata/test-module.ts") + require.NoError(t, err) + require.FileExists(t, abs) + return abs +} + func TestIntegration_FullBoot_Good(t *testing.T) { denoPath := findDeno(t) @@ -147,7 +156,7 @@ permissions: DenoSocketPath: denoSockPath, AppRoot: tmpDir, StoreDBPath: ":memory:", - SidecarArgs: []string{"run", "-A", entryPoint}, + SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint}, } c, err := core.New() @@ -181,12 +190,20 @@ permissions: // Verify DenoClient is connected require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") - // Test Go → Deno: LoadModule - loadResp, err := svc.DenoClient().LoadModule("test-module", "/modules/test/main.ts", []string{"read", "net"}) + // Test Go → Deno: LoadModule with real Worker + modPath := testModulePath(t) + loadResp, err := svc.DenoClient().LoadModule("test-module", modPath, ModulePermissions{ + Read: []string{filepath.Dir(modPath) + "/"}, + }) require.NoError(t, err) assert.True(t, loadResp.Ok) - // Test Go → Deno: ModuleStatus + // Wait for module to finish loading (async Worker init) + require.Eventually(t, func() bool { + resp, err := svc.DenoClient().ModuleStatus("test-module") + return err == nil && (resp.Status == "RUNNING" || resp.Status == "ERRORED") + }, 5*time.Second, 50*time.Millisecond, "module should finish loading") + statusResp, err := svc.DenoClient().ModuleStatus("test-module") require.NoError(t, err) assert.Equal(t, "test-module", statusResp.Code) @@ -223,3 +240,102 @@ permissions: assert.NoError(t, err) assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") } + +func TestIntegration_Tier3_WorkerIsolation_Good(t *testing.T) { + denoPath := findDeno(t) + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + denoSockPath := filepath.Join(tmpDir, "deno.sock") + + // Write a manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: tier3-test +name: Tier 3 Test +version: "1.0" +permissions: + read: ["./data/"] +`), 0644)) + + entryPoint := runtimeEntryPoint(t) + modPath := testModulePath(t) + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + DenoSocketPath: denoSockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify both sockets appeared + require.Eventually(t, func() bool { + _, err := os.Stat(denoSockPath) + return err == nil + }, 10*time.Second, 50*time.Millisecond, "deno socket should appear") + + require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") + + // Load a real module — it writes to store via I/O bridge + loadResp, err := svc.DenoClient().LoadModule("test-mod", modPath, ModulePermissions{ + Read: []string{filepath.Dir(modPath) + "/"}, + }) + require.NoError(t, err) + assert.True(t, loadResp.Ok) + + // Wait for module to reach RUNNING (Worker init + init() completes) + require.Eventually(t, func() bool { + resp, err := svc.DenoClient().ModuleStatus("test-mod") + return err == nil && resp.Status == "RUNNING" + }, 10*time.Second, 100*time.Millisecond, "module should be RUNNING") + + // Verify the module wrote to the store via the I/O bridge + // Module calls: core.storeSet("test-module", "init", "ok") + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + coreClient := pb.NewCoreServiceClient(conn) + + // Poll for the store value — module init is async + require.Eventually(t, func() bool { + resp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "test-module", Key: "init", + }) + return err == nil && resp.Found && resp.Value == "ok" + }, 5*time.Second, 100*time.Millisecond, "module should have written to store via I/O bridge") + + // Unload and verify + unloadResp, err := svc.DenoClient().UnloadModule("test-mod") + require.NoError(t, err) + assert.True(t, unloadResp.Ok) + + statusResp, err := svc.DenoClient().ModuleStatus("test-mod") + require.NoError(t, err) + assert.Equal(t, "STOPPED", statusResp.Status) + + // Clean shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") +} diff --git a/pkg/coredeno/runtime/deno.json b/pkg/coredeno/runtime/deno.json index 13f798e..95117a4 100644 --- a/pkg/coredeno/runtime/deno.json +++ b/pkg/coredeno/runtime/deno.json @@ -3,5 +3,6 @@ "@grpc/grpc-js": "npm:@grpc/grpc-js@^1.12", "@grpc/proto-loader": "npm:@grpc/proto-loader@^0.7" }, - "nodeModulesDir": "none" + "nodeModulesDir": "none", + "unstable": ["worker-options"] } diff --git a/pkg/coredeno/runtime/main.ts b/pkg/coredeno/runtime/main.ts index ca0aba4..052ba2d 100644 --- a/pkg/coredeno/runtime/main.ts +++ b/pkg/coredeno/runtime/main.ts @@ -82,10 +82,13 @@ let coreClient: CoreClient; console.error("CoreDeno: CoreService client connected"); } -// 4. Signal readiness +// 4. Inject CoreClient into registry for I/O bridge +registry.setCoreClient(coreClient); + +// 5. Signal readiness console.error("CoreDeno: ready"); -// 5. Keep alive until SIGTERM +// 6. Keep alive until SIGTERM const ac = new AbortController(); Deno.addSignalListener("SIGTERM", () => { console.error("CoreDeno: shutting down"); diff --git a/pkg/coredeno/runtime/modules.ts b/pkg/coredeno/runtime/modules.ts index a53bd4b..0feb890 100644 --- a/pkg/coredeno/runtime/modules.ts +++ b/pkg/coredeno/runtime/modules.ts @@ -1,40 +1,189 @@ -// Module registry — tracks loaded modules and their lifecycle status. -// Tier 2: status tracking only. Tier 3 adds real Deno worker isolates. +// Module registry — manages module lifecycle with Deno Worker isolation. +// Each module runs in its own Worker with per-module permission sandboxing. +// I/O bridge relays Worker postMessage calls to CoreService gRPC. -export type ModuleStatus = "UNKNOWN" | "LOADING" | "RUNNING" | "STOPPED" | "ERRORED"; +import type { CoreClient } from "./client.ts"; -// Status enum values matching the proto definition. -export const StatusEnum: Record = { - UNKNOWN: 0, - LOADING: 1, - RUNNING: 2, - STOPPED: 3, - ERRORED: 4, -}; +export type ModuleStatus = + | "UNKNOWN" + | "LOADING" + | "RUNNING" + | "STOPPED" + | "ERRORED"; -export interface Module { +export interface ModulePermissions { + read?: string[]; + write?: string[]; + net?: string[]; + run?: string[]; +} + +interface Module { code: string; entryPoint: string; - permissions: string[]; + permissions: ModulePermissions; status: ModuleStatus; + worker?: Worker; } export class ModuleRegistry { private modules = new Map(); + private coreClient: CoreClient | null = null; + private workerEntryUrl: string; - load(code: string, entryPoint: string, permissions: string[]): void { - this.modules.set(code, { + constructor() { + this.workerEntryUrl = new URL("./worker-entry.ts", import.meta.url).href; + } + + setCoreClient(client: CoreClient): void { + this.coreClient = client; + } + + load(code: string, entryPoint: string, permissions: ModulePermissions): void { + // Terminate existing worker if reloading + const existing = this.modules.get(code); + if (existing?.worker) { + existing.worker.terminate(); + } + + const mod: Module = { code, entryPoint, permissions, - status: "RUNNING", - }); - console.error(`CoreDeno: module loaded: ${code}`); + status: "LOADING", + }; + this.modules.set(code, mod); + + // Resolve entry point URL for the module + const moduleUrl = + entryPoint.startsWith("file://") || entryPoint.startsWith("http") + ? entryPoint + : "file://" + entryPoint; + + // Build read permissions: worker-entry.ts dir + module source + declared reads + const readPerms: string[] = [ + new URL(".", import.meta.url).pathname, + ]; + // Add the module's directory so it can be dynamically imported + if (!entryPoint.startsWith("http")) { + const modPath = entryPoint.startsWith("file://") + ? entryPoint.slice(7) + : entryPoint; + // Add the module file's directory + const lastSlash = modPath.lastIndexOf("/"); + if (lastSlash > 0) readPerms.push(modPath.slice(0, lastSlash + 1)); + else readPerms.push(modPath); + } + if (permissions.read) readPerms.push(...permissions.read); + + // Create Worker with permission sandbox + const worker = new Worker(this.workerEntryUrl, { + type: "module", + name: code, + // deno-lint-ignore no-explicit-any + deno: { + permissions: { + read: readPerms, + write: permissions.write ?? [], + net: permissions.net ?? [], + run: permissions.run ?? [], + env: false, + sys: false, + ffi: false, + }, + }, + } as any); + + mod.worker = worker; + + // I/O bridge: relay Worker RPC to CoreClient + worker.onmessage = async (e: MessageEvent) => { + const msg = e.data; + + if (msg.type === "ready") { + worker.postMessage({ type: "load", url: moduleUrl }); + return; + } + + if (msg.type === "loaded") { + mod.status = msg.ok ? "RUNNING" : "ERRORED"; + if (msg.ok) { + console.error(`CoreDeno: module running: ${code}`); + } else { + console.error(`CoreDeno: module error: ${code}: ${msg.error}`); + } + return; + } + + if (msg.type === "rpc" && this.coreClient) { + try { + const result = await this.dispatchRPC( + code, + msg.method, + msg.params, + ); + worker.postMessage({ type: "rpc_response", id: msg.id, result }); + } catch (err) { + worker.postMessage({ + type: "rpc_response", + id: msg.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + }; + + worker.onerror = (e: ErrorEvent) => { + mod.status = "ERRORED"; + console.error(`CoreDeno: worker error: ${code}: ${e.message}`); + }; + + console.error(`CoreDeno: module loading: ${code}`); + } + + private async dispatchRPC( + moduleCode: string, + method: string, + params: Record, + ): Promise { + const c = this.coreClient!; + switch (method) { + case "StoreGet": + return c.storeGet(params.group as string, params.key as string); + case "StoreSet": + return c.storeSet( + params.group as string, + params.key as string, + params.value as string, + ); + case "FileRead": + return c.fileRead(params.path as string, moduleCode); + case "FileWrite": + return c.fileWrite( + params.path as string, + params.content as string, + moduleCode, + ); + case "ProcessStart": + return c.processStart( + params.command as string, + params.args as string[], + moduleCode, + ); + case "ProcessStop": + return c.processStop(params.process_id as string); + default: + throw new Error(`unknown RPC method: ${method}`); + } } unload(code: string): boolean { const mod = this.modules.get(code); if (!mod) return false; + if (mod.worker) { + mod.worker.terminate(); + mod.worker = undefined; + } mod.status = "STOPPED"; console.error(`CoreDeno: module unloaded: ${code}`); return true; @@ -44,7 +193,10 @@ export class ModuleRegistry { return this.modules.get(code)?.status ?? "UNKNOWN"; } - list(): Module[] { - return Array.from(this.modules.values()); + list(): Array<{ code: string; status: ModuleStatus }> { + return Array.from(this.modules.values()).map((m) => ({ + code: m.code, + status: m.status, + })); } } diff --git a/pkg/coredeno/runtime/server.ts b/pkg/coredeno/runtime/server.ts index 81065a2..82afcc6 100644 --- a/pkg/coredeno/runtime/server.ts +++ b/pkg/coredeno/runtime/server.ts @@ -94,7 +94,7 @@ interface RPCRequest { method: string; code?: string; entry_point?: string; - permissions?: string[]; + permissions?: { read?: string[]; write?: string[]; net?: string[]; run?: string[] }; process_id?: string; } @@ -107,7 +107,7 @@ function dispatch( registry.load( req.code ?? "", req.entry_point ?? "", - req.permissions ?? [], + req.permissions ?? {}, ); return { ok: true, error: "" }; } diff --git a/pkg/coredeno/runtime/testdata/test-module.ts b/pkg/coredeno/runtime/testdata/test-module.ts new file mode 100644 index 0000000..c7fd761 --- /dev/null +++ b/pkg/coredeno/runtime/testdata/test-module.ts @@ -0,0 +1,5 @@ +// Test module — writes to store via I/O bridge to prove Workers work. +// Called by integration tests. +export async function init(core: any) { + await core.storeSet("test-module", "init", "ok"); +} diff --git a/pkg/coredeno/runtime/worker-entry.ts b/pkg/coredeno/runtime/worker-entry.ts new file mode 100644 index 0000000..757fabf --- /dev/null +++ b/pkg/coredeno/runtime/worker-entry.ts @@ -0,0 +1,79 @@ +// Worker bootstrap — loaded as entry point for every module Worker. +// Sets up the I/O bridge (postMessage ↔ parent relay), then dynamically +// imports the module and calls its init(core) function. +// +// The parent (ModuleRegistry) injects module_code into all gRPC calls, +// so modules can't spoof their identity. + +// I/O bridge: request/response correlation over postMessage +const pending = new Map(); +let nextId = 0; + +function rpc( + method: string, + params: Record, +): Promise { + return new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + self.postMessage({ type: "rpc", id, method, params }); + }); +} + +// Typed core object passed to module's init() function. +// Each method maps to a CoreService gRPC call relayed through the parent. +const core = { + storeGet(group: string, key: string) { + return rpc("StoreGet", { group, key }); + }, + storeSet(group: string, key: string, value: string) { + return rpc("StoreSet", { group, key, value }); + }, + fileRead(path: string) { + return rpc("FileRead", { path }); + }, + fileWrite(path: string, content: string) { + return rpc("FileWrite", { path, content }); + }, + processStart(command: string, args: string[]) { + return rpc("ProcessStart", { command, args }); + }, + processStop(processId: string) { + return rpc("ProcessStop", { process_id: processId }); + }, +}; + +// Handle messages from parent: RPC responses and load commands +self.addEventListener("message", async (e: MessageEvent) => { + const msg = e.data; + + if (msg.type === "rpc_response") { + const p = pending.get(msg.id); + if (p) { + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error)); + else p.resolve(msg.result); + } + return; + } + + if (msg.type === "load") { + try { + const mod = await import(msg.url); + if (typeof mod.init === "function") { + await mod.init(core); + } + self.postMessage({ type: "loaded", ok: true }); + } catch (err) { + self.postMessage({ + type: "loaded", + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } +}); + +// Signal ready — parent will respond with {type: "load", url: "..."} +self.postMessage({ type: "ready" }); -- 2.45.3 From 98993981533ccd1825937811eb359af3e4a6a5cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 08:04:13 +0000 Subject: [PATCH 6/6] =?UTF-8?q?feat(coredeno):=20Tier=204=20marketplace=20?= =?UTF-8?q?install=20pipeline=20=E2=80=94=20clone,=20verify,=20register,?= =?UTF-8?q?=20auto-load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the marketplace to actually install modules from Git repos, verify manifest signatures, track installations in the store, and auto-load them as Workers at startup. A module goes from marketplace entry to running Worker with Install() + LoadModule(). - Add Store.GetAll() for group-scoped key listing - Create marketplace.Installer with Install/Remove/Update/Installed - Export manifest.MarshalYAML for test fixtures - Wire installer into Service with auto-load on startup (step 8) - Expose Service.Installer() accessor - Full integration test: install → load → verify store write → unload → remove Co-Authored-By: Claude Opus 4.6 --- pkg/coredeno/integration_test.go | 158 ++++++++++++++++++ pkg/coredeno/service.go | 30 ++++ pkg/manifest/loader.go | 4 +- pkg/manifest/loader_test.go | 4 +- pkg/marketplace/installer.go | 194 ++++++++++++++++++++++ pkg/marketplace/installer_test.go | 263 ++++++++++++++++++++++++++++++ pkg/store/store.go | 19 +++ pkg/store/store_test.go | 22 +++ 8 files changed, 690 insertions(+), 4 deletions(-) create mode 100644 pkg/marketplace/installer.go create mode 100644 pkg/marketplace/installer_test.go diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go index da61c8c..8ee80fd 100644 --- a/pkg/coredeno/integration_test.go +++ b/pkg/coredeno/integration_test.go @@ -12,6 +12,7 @@ import ( pb "forge.lthn.ai/core/go/pkg/coredeno/proto" core "forge.lthn.ai/core/go/pkg/framework/core" + "forge.lthn.ai/core/go/pkg/marketplace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -339,3 +340,160 @@ permissions: assert.NoError(t, err) assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") } + +// createModuleRepo creates a git repo containing a test module with manifest + main.ts. +// The module's init() writes to the store to prove the I/O bridge works. +func createModuleRepo(t *testing.T, code string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), code+"-repo") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), []byte(` +code: `+code+` +name: Test Module `+code+` +version: "1.0" +permissions: + read: ["./"] +`), 0644)) + + // Module that writes to store to prove it ran + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte(` +export async function init(core: any) { + await core.storeSet("`+code+`", "installed", "yes"); +} +`), 0644)) + + gitCmd := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{ + "-C", dir, "-c", "user.email=test@test.com", "-c", "user.name=test", + }, args...)...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + } + gitCmd("init") + gitCmd("add", ".") + gitCmd("commit", "-m", "init") + + return dir +} + +func TestIntegration_Tier4_MarketplaceInstall_Good(t *testing.T) { + denoPath := findDeno(t) + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + denoSockPath := filepath.Join(tmpDir, "deno.sock") + + // Write app manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: tier4-test +name: Tier 4 Test +version: "1.0" +permissions: + read: ["./"] +`), 0644)) + + entryPoint := runtimeEntryPoint(t) + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + DenoSocketPath: denoSockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify sidecar and Deno client are up + require.Eventually(t, func() bool { + _, err := os.Stat(denoSockPath) + return err == nil + }, 10*time.Second, 50*time.Millisecond, "deno socket should appear") + + require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") + require.NotNil(t, svc.Installer(), "Installer should be available") + + // Create a test module repo and install it + moduleRepo := createModuleRepo(t, "market-mod") + err = svc.Installer().Install(ctx, marketplace.Module{ + Code: "market-mod", + Repo: moduleRepo, + }) + require.NoError(t, err) + + // Verify the module was installed on disk + modulesDir := filepath.Join(tmpDir, "modules", "market-mod") + require.DirExists(t, modulesDir) + + // Verify Installed() returns it + installed, err := svc.Installer().Installed() + require.NoError(t, err) + require.Len(t, installed, 1) + assert.Equal(t, "market-mod", installed[0].Code) + assert.Equal(t, "1.0", installed[0].Version) + + // Load the installed module into the Deno runtime + mod := installed[0] + loadResp, err := svc.DenoClient().LoadModule(mod.Code, mod.EntryPoint, ModulePermissions{ + Read: mod.Permissions.Read, + }) + require.NoError(t, err) + assert.True(t, loadResp.Ok) + + // Wait for module to reach RUNNING + require.Eventually(t, func() bool { + resp, err := svc.DenoClient().ModuleStatus("market-mod") + return err == nil && resp.Status == "RUNNING" + }, 10*time.Second, 100*time.Millisecond, "installed module should be RUNNING") + + // Verify the module wrote to the store via I/O bridge + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + coreClient := pb.NewCoreServiceClient(conn) + require.Eventually(t, func() bool { + resp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "market-mod", Key: "installed", + }) + return err == nil && resp.Found && resp.Value == "yes" + }, 5*time.Second, 100*time.Millisecond, "installed module should have written to store via I/O bridge") + + // Unload and remove + unloadResp, err := svc.DenoClient().UnloadModule("market-mod") + require.NoError(t, err) + assert.True(t, unloadResp.Ok) + + err = svc.Installer().Remove("market-mod") + require.NoError(t, err) + assert.NoDirExists(t, modulesDir, "module directory should be removed") + + installed2, err := svc.Installer().Installed() + require.NoError(t, err) + assert.Empty(t, installed2, "no modules should be installed after remove") + + // Clean shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") +} diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go index fe6bd70..80e6f8e 100644 --- a/pkg/coredeno/service.go +++ b/pkg/coredeno/service.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "os" + "path/filepath" "time" core "forge.lthn.ai/core/go/pkg/framework/core" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/marketplace" "forge.lthn.ai/core/go/pkg/store" ) @@ -26,6 +28,7 @@ type Service struct { grpcCancel context.CancelFunc grpcDone chan error denoClient *DenoClient + installer *marketplace.Installer } // NewServiceFactory returns a factory function for framework registration via WithService. @@ -116,6 +119,27 @@ func (s *Service) OnStartup(ctx context.Context) error { } } + // 8. Create installer and auto-load installed modules + if opts.AppRoot != "" { + modulesDir := filepath.Join(opts.AppRoot, "modules") + s.installer = marketplace.NewInstaller(modulesDir, s.store) + + if s.denoClient != nil { + installed, listErr := s.installer.Installed() + if listErr == nil { + for _, mod := range installed { + perms := ModulePermissions{ + Read: mod.Permissions.Read, + Write: mod.Permissions.Write, + Net: mod.Permissions.Net, + Run: mod.Permissions.Run, + } + s.denoClient.LoadModule(mod.Code, mod.EntryPoint, perms) + } + } + } + } + return nil } @@ -159,6 +183,12 @@ func (s *Service) DenoClient() *DenoClient { return s.denoClient } +// Installer returns the marketplace module installer. +// Returns nil if AppRoot was not set. +func (s *Service) Installer() *marketplace.Installer { + return s.installer +} + // waitForSocket polls until a Unix socket file appears or the context/timeout expires. func waitForSocket(ctx context.Context, path string, timeout time.Duration) error { deadline := time.Now().Add(timeout) diff --git a/pkg/manifest/loader.go b/pkg/manifest/loader.go index ea3e8a4..1136590 100644 --- a/pkg/manifest/loader.go +++ b/pkg/manifest/loader.go @@ -11,8 +11,8 @@ import ( const manifestPath = ".core/view.yml" -// marshalYAML serializes a manifest to YAML bytes. -func marshalYAML(m *Manifest) ([]byte, error) { +// MarshalYAML serializes a manifest to YAML bytes. +func MarshalYAML(m *Manifest) ([]byte, error) { return yaml.Marshal(m) } diff --git a/pkg/manifest/loader_test.go b/pkg/manifest/loader_test.go index f68c118..95f857f 100644 --- a/pkg/manifest/loader_test.go +++ b/pkg/manifest/loader_test.go @@ -39,7 +39,7 @@ func TestLoadVerified_Good(t *testing.T) { } _ = Sign(m, priv) - raw, _ := marshalYAML(m) + raw, _ := MarshalYAML(m) fs := io.NewMockMedium() fs.Files[".core/view.yml"] = string(raw) @@ -53,7 +53,7 @@ func TestLoadVerified_Bad_Tampered(t *testing.T) { m := &Manifest{Code: "app", Version: "1.0.0"} _ = Sign(m, priv) - raw, _ := marshalYAML(m) + raw, _ := MarshalYAML(m) tampered := "code: evil\n" + string(raw)[6:] fs := io.NewMockMedium() fs.Files[".core/view.yml"] = tampered diff --git a/pkg/marketplace/installer.go b/pkg/marketplace/installer.go new file mode 100644 index 0000000..ac9a690 --- /dev/null +++ b/pkg/marketplace/installer.go @@ -0,0 +1,194 @@ +package marketplace + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/store" +) + +const storeGroup = "_modules" + +// Installer handles module installation from Git repos. +type Installer struct { + modulesDir string + store *store.Store +} + +// NewInstaller creates a new module installer. +func NewInstaller(modulesDir string, st *store.Store) *Installer { + return &Installer{ + modulesDir: modulesDir, + store: st, + } +} + +// InstalledModule holds stored metadata about an installed module. +type InstalledModule struct { + Code string `json:"code"` + Name string `json:"name"` + Version string `json:"version"` + Repo string `json:"repo"` + EntryPoint string `json:"entry_point"` + Permissions manifest.Permissions `json:"permissions"` + InstalledAt string `json:"installed_at"` +} + +// Install clones a module repo, verifies its manifest signature, and registers it. +func (i *Installer) Install(ctx context.Context, mod Module) error { + // Check if already installed + if _, err := i.store.Get(storeGroup, mod.Code); err == nil { + return fmt.Errorf("marketplace: module %q already installed", mod.Code) + } + + dest := filepath.Join(i.modulesDir, mod.Code) + if err := os.MkdirAll(i.modulesDir, 0755); err != nil { + return fmt.Errorf("marketplace: mkdir: %w", err) + } + if err := gitClone(ctx, mod.Repo, dest); err != nil { + return fmt.Errorf("marketplace: clone %s: %w", mod.Repo, err) + } + + // On any error after clone, clean up the directory + cleanup := true + defer func() { + if cleanup { + os.RemoveAll(dest) + } + }() + + medium, err := io.NewSandboxed(dest) + if err != nil { + return fmt.Errorf("marketplace: medium: %w", err) + } + + m, err := loadManifest(medium, mod.SignKey) + if err != nil { + return err + } + + entryPoint := filepath.Join(dest, "main.ts") + installed := InstalledModule{ + Code: mod.Code, + Name: m.Name, + Version: m.Version, + Repo: mod.Repo, + EntryPoint: entryPoint, + Permissions: m.Permissions, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + + data, err := json.Marshal(installed) + if err != nil { + return fmt.Errorf("marketplace: marshal: %w", err) + } + + if err := i.store.Set(storeGroup, mod.Code, string(data)); err != nil { + return fmt.Errorf("marketplace: store: %w", err) + } + + cleanup = false + return nil +} + +// Remove uninstalls a module by deleting its files and store entry. +func (i *Installer) Remove(code string) error { + if _, err := i.store.Get(storeGroup, code); err != nil { + return fmt.Errorf("marketplace: module %q not installed", code) + } + + dest := filepath.Join(i.modulesDir, code) + os.RemoveAll(dest) + + return i.store.Delete(storeGroup, code) +} + +// Update pulls latest changes and re-verifies the manifest. +func (i *Installer) Update(ctx context.Context, code string) error { + raw, err := i.store.Get(storeGroup, code) + if err != nil { + return fmt.Errorf("marketplace: module %q not installed", code) + } + + var installed InstalledModule + if err := json.Unmarshal([]byte(raw), &installed); err != nil { + return fmt.Errorf("marketplace: unmarshal: %w", err) + } + + dest := filepath.Join(i.modulesDir, code) + + cmd := exec.CommandContext(ctx, "git", "-C", dest, "pull", "--ff-only") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("marketplace: pull: %s: %w", strings.TrimSpace(string(output)), err) + } + + // Reload manifest + medium, mErr := io.NewSandboxed(dest) + if mErr != nil { + return fmt.Errorf("marketplace: medium: %w", mErr) + } + m, mErr := manifest.Load(medium, ".") + if mErr != nil { + return fmt.Errorf("marketplace: reload manifest: %w", mErr) + } + + // Update stored metadata + installed.Name = m.Name + installed.Version = m.Version + installed.Permissions = m.Permissions + + data, err := json.Marshal(installed) + if err != nil { + return fmt.Errorf("marketplace: marshal: %w", err) + } + + return i.store.Set(storeGroup, code, string(data)) +} + +// Installed returns all installed module metadata. +func (i *Installer) Installed() ([]InstalledModule, error) { + all, err := i.store.GetAll(storeGroup) + if err != nil { + return nil, fmt.Errorf("marketplace: list: %w", err) + } + + var modules []InstalledModule + for _, raw := range all { + var m InstalledModule + if err := json.Unmarshal([]byte(raw), &m); err != nil { + continue + } + modules = append(modules, m) + } + return modules, nil +} + +// loadManifest loads and optionally verifies a module manifest. +func loadManifest(medium io.Medium, signKey string) (*manifest.Manifest, error) { + if signKey != "" { + pubBytes, err := hex.DecodeString(signKey) + if err != nil { + return nil, fmt.Errorf("marketplace: decode sign key: %w", err) + } + return manifest.LoadVerified(medium, ".", pubBytes) + } + return manifest.Load(medium, ".") +} + +// gitClone clones a repository with --depth=1. +func gitClone(ctx context.Context, repo, dest string) error { + cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", repo, dest) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} diff --git a/pkg/marketplace/installer_test.go b/pkg/marketplace/installer_test.go new file mode 100644 index 0000000..a8164fe --- /dev/null +++ b/pkg/marketplace/installer_test.go @@ -0,0 +1,263 @@ +package marketplace + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestRepo creates a bare-bones git repo with a manifest and main.ts. +// Returns the repo path (usable as Module.Repo for local clone). +func createTestRepo(t *testing.T, code, version string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), code) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) + + manifestYAML := "code: " + code + "\nname: Test " + code + "\nversion: \"" + version + "\"\n" + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".core", "view.yml"), + []byte(manifestYAML), 0644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "main.ts"), + []byte("export async function init(core: any) {}\n"), 0644, + )) + + runGit(t, dir, "init") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + return dir +} + +// createSignedTestRepo creates a git repo with a signed manifest. +// Returns (repo path, hex-encoded public key). +func createSignedTestRepo(t *testing.T, code, version string) (string, string) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + dir := filepath.Join(t.TempDir(), code) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) + + m := &manifest.Manifest{ + Code: code, + Name: "Test " + code, + Version: version, + } + require.NoError(t, manifest.Sign(m, priv)) + + data, err := manifest.MarshalYAML(m) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), data, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte("export async function init(core: any) {}\n"), 0644)) + + runGit(t, dir, "init") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + return dir, hex.EncodeToString(pub) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.email=test@test.com", "-c", "user.name=test"}, args...)...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +func TestInstall_Good(t *testing.T) { + repo := createTestRepo(t, "hello-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "hello-mod", + Repo: repo, + }) + require.NoError(t, err) + + // Verify directory exists + _, err = os.Stat(filepath.Join(modulesDir, "hello-mod", "main.ts")) + assert.NoError(t, err, "main.ts should exist in installed module") + + // Verify store entry + raw, err := st.Get("_modules", "hello-mod") + require.NoError(t, err) + assert.Contains(t, raw, `"code":"hello-mod"`) + assert.Contains(t, raw, `"version":"1.0"`) +} + +func TestInstall_Good_Signed(t *testing.T) { + repo, signKey := createSignedTestRepo(t, "signed-mod", "2.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "signed-mod", + Repo: repo, + SignKey: signKey, + }) + require.NoError(t, err) + + raw, err := st.Get("_modules", "signed-mod") + require.NoError(t, err) + assert.Contains(t, raw, `"version":"2.0"`) +} + +func TestInstall_Bad_AlreadyInstalled(t *testing.T) { + repo := createTestRepo(t, "dup-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + mod := Module{Code: "dup-mod", Repo: repo} + + require.NoError(t, inst.Install(context.Background(), mod)) + err = inst.Install(context.Background(), mod) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already installed") +} + +func TestInstall_Bad_InvalidSignature(t *testing.T) { + // Sign with key A, verify with key B + repo, _ := createSignedTestRepo(t, "bad-sig", "1.0") + _, wrongKey := createSignedTestRepo(t, "dummy", "1.0") // different key + + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "bad-sig", + Repo: repo, + SignKey: wrongKey, + }) + assert.Error(t, err) + + // Verify directory was cleaned up + _, statErr := os.Stat(filepath.Join(modulesDir, "bad-sig")) + assert.True(t, os.IsNotExist(statErr), "directory should be cleaned up on failure") +} + +func TestRemove_Good(t *testing.T) { + repo := createTestRepo(t, "rm-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + require.NoError(t, inst.Install(context.Background(), Module{Code: "rm-mod", Repo: repo})) + + err = inst.Remove("rm-mod") + require.NoError(t, err) + + // Directory gone + _, statErr := os.Stat(filepath.Join(modulesDir, "rm-mod")) + assert.True(t, os.IsNotExist(statErr)) + + // Store entry gone + _, err = st.Get("_modules", "rm-mod") + assert.Error(t, err) +} + +func TestRemove_Bad_NotInstalled(t *testing.T) { + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(t.TempDir(), st) + err = inst.Remove("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not installed") +} + +func TestInstalled_Good(t *testing.T) { + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + + repo1 := createTestRepo(t, "mod-a", "1.0") + repo2 := createTestRepo(t, "mod-b", "2.0") + + require.NoError(t, inst.Install(context.Background(), Module{Code: "mod-a", Repo: repo1})) + require.NoError(t, inst.Install(context.Background(), Module{Code: "mod-b", Repo: repo2})) + + installed, err := inst.Installed() + require.NoError(t, err) + assert.Len(t, installed, 2) + + codes := map[string]bool{} + for _, m := range installed { + codes[m.Code] = true + } + assert.True(t, codes["mod-a"]) + assert.True(t, codes["mod-b"]) +} + +func TestInstalled_Good_Empty(t *testing.T) { + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(t.TempDir(), st) + installed, err := inst.Installed() + require.NoError(t, err) + assert.Empty(t, installed) +} + +func TestUpdate_Good(t *testing.T) { + repo := createTestRepo(t, "upd-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + require.NoError(t, inst.Install(context.Background(), Module{Code: "upd-mod", Repo: repo})) + + // Update the origin repo + newManifest := "code: upd-mod\nname: Updated Module\nversion: \"2.0\"\n" + require.NoError(t, os.WriteFile(filepath.Join(repo, ".core", "view.yml"), []byte(newManifest), 0644)) + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "bump version") + + err = inst.Update(context.Background(), "upd-mod") + require.NoError(t, err) + + // Verify updated metadata + installed, err := inst.Installed() + require.NoError(t, err) + require.Len(t, installed, 1) + assert.Equal(t, "2.0", installed[0].Version) + assert.Equal(t, "Updated Module", installed[0].Name) +} diff --git a/pkg/store/store.go b/pkg/store/store.go index eaa2774..6f717e5 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -95,6 +95,25 @@ func (s *Store) DeleteGroup(group string) error { return nil } +// GetAll returns all key-value pairs in a group. +func (s *Store) GetAll(group string) (map[string]string, error) { + rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) + if err != nil { + return nil, fmt.Errorf("store.GetAll: %w", err) + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("store.GetAll: scan: %w", err) + } + result[k] = v + } + return result, 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) diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go index 1782ed2..b62b88b 100644 --- a/pkg/store/store_test.go +++ b/pkg/store/store_test.go @@ -66,6 +66,28 @@ func TestDeleteGroup_Good(t *testing.T) { assert.Equal(t, 0, n) } +func TestGetAll_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") + + all, err := s.GetAll("grp") + require.NoError(t, err) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all) +} + +func TestGetAll_Good_Empty(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + all, err := s.GetAll("empty") + require.NoError(t, err) + assert.Empty(t, all) +} + func TestRender_Good(t *testing.T) { s, _ := New(":memory:") defer s.Close() -- 2.45.3