diff --git a/.golangci.yml b/.golangci.yml index e3e0dfd88..5c84d0173 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -58,6 +58,12 @@ linters: paths: # Exclude third-party vendored Go code bundled under ui/node_modules from analysis entirely - ui/node_modules + # Exclude protobuf-generated files. protoc-gen-go emits unsafe.Slice/ + # unsafe.StringData (gosec G103) and protoc-gen-go-grpc emits embedded + # ClientStream selectors (staticcheck QF1008); neither is actionable on + # generated code, and gosec ignores the golangci `generated:` setting. + # The single \.pb\.go$ entry also covers *_grpc.pb.go. + - \.pb\.go$ presets: - std-error-handling diff --git a/plugin/external/proto/sandbox_exec.pb.go b/plugin/external/proto/sandbox_exec.pb.go new file mode 100644 index 000000000..ec083570f --- /dev/null +++ b/plugin/external/proto/sandbox_exec.pb.go @@ -0,0 +1,306 @@ +// sandbox_exec.proto — typed gRPC contract for remote sandbox execution. +// +// Design: docs/decisions/0019-remote-sandbox-agent.md (ADR 0019) +// Plan: docs/plans/infra-p3b-proto-client (Task 13) +// +// Hard invariants: +// - NO loose Struct/Any wrapper types. +// - Free-form payloads (env values, stdout/stderr bytes) are typed. +// - env VALUES may carry unresolved secret:// refs — the agent resolves them. +// The client MUST NOT resolve secret:// refs; pass them through verbatim. +// - profile is the requested security profile; the agent clamps it server-side. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: sandbox_exec.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) +) + +// SandboxExecRequest describes a single command execution in the remote sandbox. +// env VALUES may be unresolved secret:// refs — the remote agent resolves them +// before launching the command (ADR 0017). The client passes them verbatim. +// profile is the requested security profile; the agent clamps it to its +// configured maximum-allowed profile (PR8). +type SandboxExecRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // profile is the requested sandbox security profile (e.g. "default", "strict"). + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // image is the OCI image reference to run the command in. + Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` + // command is the argv-style command to execute inside the sandbox. + Command []string `protobuf:"bytes,3,rep,name=command,proto3" json:"command,omitempty"` + // env carries the process environment. Values may be unresolved secret:// + // references; the agent resolves them before exec (ADR 0017). + Env map[string]string `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // workdir is the working directory inside the container. Empty = image default. + Workdir string `protobuf:"bytes,5,opt,name=workdir,proto3" json:"workdir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxExecRequest) Reset() { + *x = SandboxExecRequest{} + mi := &file_sandbox_exec_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxExecRequest) ProtoMessage() {} + +func (x *SandboxExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_exec_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 SandboxExecRequest.ProtoReflect.Descriptor instead. +func (*SandboxExecRequest) Descriptor() ([]byte, []int) { + return file_sandbox_exec_proto_rawDescGZIP(), []int{0} +} + +func (x *SandboxExecRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SandboxExecRequest) GetImage() string { + if x != nil { + return x.Image + } + return "" +} + +func (x *SandboxExecRequest) GetCommand() []string { + if x != nil { + return x.Command + } + return nil +} + +func (x *SandboxExecRequest) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + +func (x *SandboxExecRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +// SandboxExecChunk is one streamed unit of command output. The server sends +// zero or more stdout/stderr chunks followed by exactly one exit_code chunk +// which terminates the stream. +type SandboxExecChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Chunk: + // + // *SandboxExecChunk_Stdout + // *SandboxExecChunk_Stderr + // *SandboxExecChunk_ExitCode + Chunk isSandboxExecChunk_Chunk `protobuf_oneof:"chunk"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxExecChunk) Reset() { + *x = SandboxExecChunk{} + mi := &file_sandbox_exec_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxExecChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxExecChunk) ProtoMessage() {} + +func (x *SandboxExecChunk) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_exec_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 SandboxExecChunk.ProtoReflect.Descriptor instead. +func (*SandboxExecChunk) Descriptor() ([]byte, []int) { + return file_sandbox_exec_proto_rawDescGZIP(), []int{1} +} + +func (x *SandboxExecChunk) GetChunk() isSandboxExecChunk_Chunk { + if x != nil { + return x.Chunk + } + return nil +} + +func (x *SandboxExecChunk) GetStdout() []byte { + if x != nil { + if x, ok := x.Chunk.(*SandboxExecChunk_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *SandboxExecChunk) GetStderr() []byte { + if x != nil { + if x, ok := x.Chunk.(*SandboxExecChunk_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *SandboxExecChunk) GetExitCode() int32 { + if x != nil { + if x, ok := x.Chunk.(*SandboxExecChunk_ExitCode); ok { + return x.ExitCode + } + } + return 0 +} + +type isSandboxExecChunk_Chunk interface { + isSandboxExecChunk_Chunk() +} + +type SandboxExecChunk_Stdout struct { + // stdout carries a raw bytes fragment of the process stdout. + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type SandboxExecChunk_Stderr struct { + // stderr carries a raw bytes fragment of the process stderr. + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type SandboxExecChunk_ExitCode struct { + // exit_code is the terminal chunk; signals the command has finished. + // The client MUST treat the first exit_code chunk as stream end. + ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3,oneof"` +} + +func (*SandboxExecChunk_Stdout) isSandboxExecChunk_Chunk() {} + +func (*SandboxExecChunk_Stderr) isSandboxExecChunk_Chunk() {} + +func (*SandboxExecChunk_ExitCode) isSandboxExecChunk_Chunk() {} + +var File_sandbox_exec_proto protoreflect.FileDescriptor + +const file_sandbox_exec_proto_rawDesc = "" + + "\n" + + "\x12sandbox_exec.proto\x12 workflow.plugin.external.sandbox\"\x81\x02\n" + + "\x12SandboxExecRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\x12\x14\n" + + "\x05image\x18\x02 \x01(\tR\x05image\x12\x18\n" + + "\acommand\x18\x03 \x03(\tR\acommand\x12O\n" + + "\x03env\x18\x04 \x03(\v2=.workflow.plugin.external.sandbox.SandboxExecRequest.EnvEntryR\x03env\x12\x18\n" + + "\aworkdir\x18\x05 \x01(\tR\aworkdir\x1a6\n" + + "\bEnvEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"n\n" + + "\x10SandboxExecChunk\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x1d\n" + + "\texit_code\x18\x03 \x01(\x05H\x00R\bexitCodeB\a\n" + + "\x05chunk2\x88\x01\n" + + "\x12SandboxExecService\x12r\n" + + "\x04Exec\x124.workflow.plugin.external.sandbox.SandboxExecRequest\x1a2.workflow.plugin.external.sandbox.SandboxExecChunk0\x01B=Z;github.com/GoCodeAlone/workflow/plugin/external/proto;protob\x06proto3" + +var ( + file_sandbox_exec_proto_rawDescOnce sync.Once + file_sandbox_exec_proto_rawDescData []byte +) + +func file_sandbox_exec_proto_rawDescGZIP() []byte { + file_sandbox_exec_proto_rawDescOnce.Do(func() { + file_sandbox_exec_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sandbox_exec_proto_rawDesc), len(file_sandbox_exec_proto_rawDesc))) + }) + return file_sandbox_exec_proto_rawDescData +} + +var file_sandbox_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_sandbox_exec_proto_goTypes = []any{ + (*SandboxExecRequest)(nil), // 0: workflow.plugin.external.sandbox.SandboxExecRequest + (*SandboxExecChunk)(nil), // 1: workflow.plugin.external.sandbox.SandboxExecChunk + nil, // 2: workflow.plugin.external.sandbox.SandboxExecRequest.EnvEntry +} +var file_sandbox_exec_proto_depIdxs = []int32{ + 2, // 0: workflow.plugin.external.sandbox.SandboxExecRequest.env:type_name -> workflow.plugin.external.sandbox.SandboxExecRequest.EnvEntry + 0, // 1: workflow.plugin.external.sandbox.SandboxExecService.Exec:input_type -> workflow.plugin.external.sandbox.SandboxExecRequest + 1, // 2: workflow.plugin.external.sandbox.SandboxExecService.Exec:output_type -> workflow.plugin.external.sandbox.SandboxExecChunk + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_sandbox_exec_proto_init() } +func file_sandbox_exec_proto_init() { + if File_sandbox_exec_proto != nil { + return + } + file_sandbox_exec_proto_msgTypes[1].OneofWrappers = []any{ + (*SandboxExecChunk_Stdout)(nil), + (*SandboxExecChunk_Stderr)(nil), + (*SandboxExecChunk_ExitCode)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_exec_proto_rawDesc), len(file_sandbox_exec_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_sandbox_exec_proto_goTypes, + DependencyIndexes: file_sandbox_exec_proto_depIdxs, + MessageInfos: file_sandbox_exec_proto_msgTypes, + }.Build() + File_sandbox_exec_proto = out.File + file_sandbox_exec_proto_goTypes = nil + file_sandbox_exec_proto_depIdxs = nil +} diff --git a/plugin/external/proto/sandbox_exec.proto b/plugin/external/proto/sandbox_exec.proto new file mode 100644 index 000000000..feeed5bbe --- /dev/null +++ b/plugin/external/proto/sandbox_exec.proto @@ -0,0 +1,57 @@ +// sandbox_exec.proto — typed gRPC contract for remote sandbox execution. +// +// Design: docs/decisions/0019-remote-sandbox-agent.md (ADR 0019) +// Plan: docs/plans/infra-p3b-proto-client (Task 13) +// +// Hard invariants: +// - NO loose Struct/Any wrapper types. +// - Free-form payloads (env values, stdout/stderr bytes) are typed. +// - env VALUES may carry unresolved secret:// refs — the agent resolves them. +// The client MUST NOT resolve secret:// refs; pass them through verbatim. +// - profile is the requested security profile; the agent clamps it server-side. +syntax = "proto3"; + +package workflow.plugin.external.sandbox; + +option go_package = "github.com/GoCodeAlone/workflow/plugin/external/proto;proto"; + +// SandboxExecService is served by the remote sandbox agent (engine → agent). +// Callers open a streaming Exec RPC; the server streams back stdout/stderr +// chunks followed by a terminal exit_code chunk. +service SandboxExecService { + rpc Exec(SandboxExecRequest) returns (stream SandboxExecChunk); +} + +// SandboxExecRequest describes a single command execution in the remote sandbox. +// env VALUES may be unresolved secret:// refs — the remote agent resolves them +// before launching the command (ADR 0017). The client passes them verbatim. +// profile is the requested security profile; the agent clamps it to its +// configured maximum-allowed profile (PR8). +message SandboxExecRequest { + // profile is the requested sandbox security profile (e.g. "default", "strict"). + string profile = 1; + // image is the OCI image reference to run the command in. + string image = 2; + // command is the argv-style command to execute inside the sandbox. + repeated string command = 3; + // env carries the process environment. Values may be unresolved secret:// + // references; the agent resolves them before exec (ADR 0017). + map env = 4; + // workdir is the working directory inside the container. Empty = image default. + string workdir = 5; +} + +// SandboxExecChunk is one streamed unit of command output. The server sends +// zero or more stdout/stderr chunks followed by exactly one exit_code chunk +// which terminates the stream. +message SandboxExecChunk { + oneof chunk { + // stdout carries a raw bytes fragment of the process stdout. + bytes stdout = 1; + // stderr carries a raw bytes fragment of the process stderr. + bytes stderr = 2; + // exit_code is the terminal chunk; signals the command has finished. + // The client MUST treat the first exit_code chunk as stream end. + int32 exit_code = 3; + } +} diff --git a/plugin/external/proto/sandbox_exec_grpc.pb.go b/plugin/external/proto/sandbox_exec_grpc.pb.go new file mode 100644 index 000000000..9fcddab48 --- /dev/null +++ b/plugin/external/proto/sandbox_exec_grpc.pb.go @@ -0,0 +1,144 @@ +// sandbox_exec.proto — typed gRPC contract for remote sandbox execution. +// +// Design: docs/decisions/0019-remote-sandbox-agent.md (ADR 0019) +// Plan: docs/plans/infra-p3b-proto-client (Task 13) +// +// Hard invariants: +// - NO loose Struct/Any wrapper types. +// - Free-form payloads (env values, stdout/stderr bytes) are typed. +// - env VALUES may carry unresolved secret:// refs — the agent resolves them. +// The client MUST NOT resolve secret:// refs; pass them through verbatim. +// - profile is the requested security profile; the agent clamps it server-side. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: sandbox_exec.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 ( + SandboxExecService_Exec_FullMethodName = "/workflow.plugin.external.sandbox.SandboxExecService/Exec" +) + +// SandboxExecServiceClient is the client API for SandboxExecService 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. +// +// SandboxExecService is served by the remote sandbox agent (engine → agent). +// Callers open a streaming Exec RPC; the server streams back stdout/stderr +// chunks followed by a terminal exit_code chunk. +type SandboxExecServiceClient interface { + Exec(ctx context.Context, in *SandboxExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxExecChunk], error) +} + +type sandboxExecServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSandboxExecServiceClient(cc grpc.ClientConnInterface) SandboxExecServiceClient { + return &sandboxExecServiceClient{cc} +} + +func (c *sandboxExecServiceClient) Exec(ctx context.Context, in *SandboxExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxExecChunk], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &SandboxExecService_ServiceDesc.Streams[0], SandboxExecService_Exec_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SandboxExecRequest, SandboxExecChunk]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type SandboxExecService_ExecClient = grpc.ServerStreamingClient[SandboxExecChunk] + +// SandboxExecServiceServer is the server API for SandboxExecService service. +// All implementations must embed UnimplementedSandboxExecServiceServer +// for forward compatibility. +// +// SandboxExecService is served by the remote sandbox agent (engine → agent). +// Callers open a streaming Exec RPC; the server streams back stdout/stderr +// chunks followed by a terminal exit_code chunk. +type SandboxExecServiceServer interface { + Exec(*SandboxExecRequest, grpc.ServerStreamingServer[SandboxExecChunk]) error + mustEmbedUnimplementedSandboxExecServiceServer() +} + +// UnimplementedSandboxExecServiceServer 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 UnimplementedSandboxExecServiceServer struct{} + +func (UnimplementedSandboxExecServiceServer) Exec(*SandboxExecRequest, grpc.ServerStreamingServer[SandboxExecChunk]) error { + return status.Error(codes.Unimplemented, "method Exec not implemented") +} +func (UnimplementedSandboxExecServiceServer) mustEmbedUnimplementedSandboxExecServiceServer() {} +func (UnimplementedSandboxExecServiceServer) testEmbeddedByValue() {} + +// UnsafeSandboxExecServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SandboxExecServiceServer will +// result in compilation errors. +type UnsafeSandboxExecServiceServer interface { + mustEmbedUnimplementedSandboxExecServiceServer() +} + +func RegisterSandboxExecServiceServer(s grpc.ServiceRegistrar, srv SandboxExecServiceServer) { + // If the following call panics, it indicates UnimplementedSandboxExecServiceServer 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(&SandboxExecService_ServiceDesc, srv) +} + +func _SandboxExecService_Exec_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SandboxExecRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(SandboxExecServiceServer).Exec(m, &grpc.GenericServerStream[SandboxExecRequest, SandboxExecChunk]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type SandboxExecService_ExecServer = grpc.ServerStreamingServer[SandboxExecChunk] + +// SandboxExecService_ServiceDesc is the grpc.ServiceDesc for SandboxExecService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SandboxExecService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "workflow.plugin.external.sandbox.SandboxExecService", + HandlerType: (*SandboxExecServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Exec", + Handler: _SandboxExecService_Exec_Handler, + ServerStreams: true, + }, + }, + Metadata: "sandbox_exec.proto", +} diff --git a/sandbox/remote/runner.go b/sandbox/remote/runner.go new file mode 100644 index 000000000..748dc8d65 --- /dev/null +++ b/sandbox/remote/runner.go @@ -0,0 +1,241 @@ +// Package remote provides a RemoteRunner that implements sandbox.SandboxRunner +// by dialing a remote sandbox agent over gRPC (mTLS + bearer token auth). +// +// The remote agent binary and config wiring land in PR8. This package ships +// the client only (ADR 0019). +// +// Secret-ref invariant (ADR 0017): env values may carry unresolved secret:// +// references. RemoteRunner passes them verbatim to the agent — it MUST NOT +// attempt to resolve them. The agent resolves secret:// refs server-side. +package remote + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "sync" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/GoCodeAlone/workflow/sandbox" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// RemoteRunnerConfig carries the dial-time and per-exec identity of a remote +// sandbox agent. Profile, Image, Env, and WorkDir are sent on every +// SandboxExecRequest; command-specific overrides are applied by Exec. +type RemoteRunnerConfig struct { + // Address is the gRPC target of the remote sandbox agent (host:port). + Address string + + // Token is the bearer token sent in the "authorization" metadata header on + // every RPC. Empty string means no bearer token is sent. + Token string + + // TLS is the TLS configuration for mTLS dial. nil means insecure (useful + // for unit tests; production always supplies a tls.Config with client certs). + TLS *tls.Config + + // AllowInsecure permits a non-empty Token to be sent over an insecure + // (non-TLS) connection. This is an explicit opt-in for tests and local + // development ONLY. Without it, NewRemoteRunner rejects Token != "" && + // TLS == nil to prevent leaking the bearer token in cleartext. + AllowInsecure bool + + // Profile is the requested sandbox security profile (e.g. "default", + // "strict"). The agent clamps the effective profile to its configured + // maximum-allowed value (PR8). + Profile string + + // Image is the OCI image reference to use for command execution. + Image string + + // Env is the base process environment sent to the agent. Values may be + // unresolved secret:// references — the agent resolves them (ADR 0017). + // RemoteRunner passes them verbatim; it MUST NOT resolve them. + Env map[string]string + + // WorkDir is the working directory inside the container. Empty = image default. + WorkDir string +} + +// RemoteRunner implements sandbox.SandboxRunner by streaming commands to a +// remote sandbox agent over gRPC. +type RemoteRunner struct { + cfg RemoteRunnerConfig + mu sync.Mutex + conn *grpc.ClientConn +} + +// Compile-time assertion: *RemoteRunner satisfies sandbox.SandboxRunner. +var _ sandbox.SandboxRunner = (*RemoteRunner)(nil) + +// NewRemoteRunner dials the remote sandbox agent and returns a RemoteRunner. +// The connection is lazy-cached; subsequent Exec calls reuse it. +// +// If a bearer Token is supplied without TLS, NewRemoteRunner returns an error +// unless AllowInsecure is set — sending a token over a cleartext connection +// would leak the credential (gRPC does not reject it because the runner's +// PerRPCCredentials.RequireTransportSecurity is intentionally false to allow +// the explicit local-dev/test opt-in). +func NewRemoteRunner(cfg RemoteRunnerConfig) (*RemoteRunner, error) { + if cfg.Address == "" { + return nil, fmt.Errorf("remote: address is required") + } + if cfg.Token != "" && cfg.TLS == nil && !cfg.AllowInsecure { + return nil, fmt.Errorf("remote: refusing to send bearer token over insecure connection: set TLS, or set AllowInsecure for local/dev only") + } + r := &RemoteRunner{cfg: cfg} + if _, err := r.connect(); err != nil { + return nil, err + } + return r, nil +} + +// connect opens (or reuses) the gRPC connection to the remote agent and +// returns it. It returns the connection rather than relying on the caller to +// re-read r.conn, so a concurrent Close() (which sets r.conn = nil under r.mu) +// cannot nil the connection out from under an in-flight Exec. +// Caller must NOT hold r.mu. +func (r *RemoteRunner) connect() (*grpc.ClientConn, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.conn != nil { + return r.conn, nil + } + + var dialOpts []grpc.DialOption + + if r.cfg.TLS != nil { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(r.cfg.TLS))) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + if r.cfg.Token != "" { + dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(&bearerToken{token: r.cfg.Token})) + } + + conn, err := grpc.NewClient(r.cfg.Address, dialOpts...) + if err != nil { + return nil, fmt.Errorf("remote: dial %s: %w", r.cfg.Address, err) + } + r.conn = conn + return conn, nil +} + +// Exec runs cmd inside the remote sandbox and returns the combined result. +// env VALUES that contain secret:// references are passed verbatim to the +// agent — RemoteRunner does NOT resolve them (ADR 0017). +func (r *RemoteRunner) Exec(ctx context.Context, cmd []string) (*sandbox.ExecResult, error) { + // Reject an empty argv, matching the local DockerSandbox runner — an empty + // command is a caller/config error, not a valid remote exec. + if len(cmd) == 0 { + return nil, fmt.Errorf("remote sandbox: empty command") + } + conn, err := r.connect() + if err != nil { + return nil, err + } + + client := pb.NewSandboxExecServiceClient(conn) + + req := &pb.SandboxExecRequest{ + Profile: r.cfg.Profile, + Image: r.cfg.Image, + Command: cmd, + Env: r.cfg.Env, + Workdir: r.cfg.WorkDir, + } + + stream, err := client.Exec(ctx, req) + if err != nil { + return nil, fmt.Errorf("remote: Exec RPC: %w", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := 0 + exitCodeSeen := false + + for { + chunk, recvErr := stream.Recv() + if recvErr != nil { + if errors.Is(recvErr, io.EOF) { + break + } + return nil, fmt.Errorf("remote: stream recv: %w", recvErr) + } + + switch v := chunk.Chunk.(type) { + case *pb.SandboxExecChunk_Stdout: + stdout.Write(v.Stdout) + case *pb.SandboxExecChunk_Stderr: + stderr.Write(v.Stderr) + case *pb.SandboxExecChunk_ExitCode: + exitCode = int(v.ExitCode) + exitCodeSeen = true + } + // exit_code is the terminal chunk per the proto contract. Stop reading as + // soon as it arrives rather than waiting for io.EOF — a server that sends + // exit_code but forgets to close (or sends trailing chunks) must not hang + // the client. + if exitCodeSeen { + break + } + } + + // A stream that ends (io.EOF) without ever delivering an exit_code chunk is + // a truncated stream (agent crash or protocol violation). Returning + // ExecResult{ExitCode: 0} here would be a silent false success — a caller + // checking ExitCode != 0 would treat a crashed remote command as passing. + if !exitCodeSeen { + return nil, fmt.Errorf("remote sandbox: stream closed without exit_code (agent crash or protocol violation)") + } + + return &sandbox.ExecResult{ + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + }, nil +} + +// Close releases the underlying gRPC connection held by the runner. +func (r *RemoteRunner) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + if r.conn == nil { + return nil + } + err := r.conn.Close() + r.conn = nil + return err +} + +// bearerToken implements grpc.PerRPCCredentials for bearer token auth. +// It attaches "authorization: Bearer " to every RPC call. +type bearerToken struct { + token string +} + +func (b *bearerToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + b.token, + }, nil +} + +// RequireTransportSecurity returns false so the token path can be exercised +// over insecure transport (bufconn tests, AllowInsecure local-dev). The +// cleartext-leak guard lives in NewRemoteRunner (Token+no-TLS is rejected +// unless AllowInsecure is set), so this flag stays false to permit the +// explicit opt-in. In production, cfg.TLS is non-nil and the connection +// itself provides transport security. +func (b *bearerToken) RequireTransportSecurity() bool { + return false +} diff --git a/sandbox/remote/runner_test.go b/sandbox/remote/runner_test.go new file mode 100644 index 000000000..f9e91d8a8 --- /dev/null +++ b/sandbox/remote/runner_test.go @@ -0,0 +1,394 @@ +package remote + +import ( + "context" + "net" + "strings" + "sync" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// bufConnSize is the in-memory pipe buffer used by the bufconn listener. +// Matches the fixtureBufSize used in cmd/wfctl/iac_typed_fixture_test.go. +const bufConnSize = 1024 * 1024 + +// ─── test stub server ──────────────────────────────────────────────────────── + +// stubExecServer is an in-process SandboxExecServiceServer for unit tests. +// It streams back a configurable sequence of stdout/stderr chunks followed by +// an exit_code, and records every received SandboxExecRequest for assertion. +// +// The mutex makes lastRequest safe to read from a test goroutine while Exec +// runs on a server goroutine (and safe when concurrent Exec RPCs land in the +// -race concurrency test). +type stubExecServer struct { + pb.UnimplementedSandboxExecServiceServer + + // config + stdoutData []byte + stderrData []byte + exitCode int32 + // omitExitCode, when true, makes Exec return WITHOUT sending the terminal + // exit_code chunk — simulating an agent crash / truncated stream so the + // client's missing-exit-code guard can be exercised. + omitExitCode bool + + mu sync.Mutex + // recorded state (set during Exec; read in assertions) + lastRequest *pb.SandboxExecRequest +} + +func (s *stubExecServer) Exec(req *pb.SandboxExecRequest, stream grpc.ServerStreamingServer[pb.SandboxExecChunk]) error { + s.mu.Lock() + s.lastRequest = req + s.mu.Unlock() + + if len(s.stdoutData) > 0 { + if err := stream.Send(&pb.SandboxExecChunk{Chunk: &pb.SandboxExecChunk_Stdout{Stdout: s.stdoutData}}); err != nil { + return err + } + } + if len(s.stderrData) > 0 { + if err := stream.Send(&pb.SandboxExecChunk{Chunk: &pb.SandboxExecChunk_Stderr{Stderr: s.stderrData}}); err != nil { + return err + } + } + if s.omitExitCode { + // Return without the exit_code chunk: the stream closes (io.EOF on the + // client) with no terminal exit code, simulating a crashed agent. + return nil + } + return stream.Send(&pb.SandboxExecChunk{Chunk: &pb.SandboxExecChunk_ExitCode{ExitCode: s.exitCode}}) +} + +// getLastRequest returns the most recently received request under the mutex. +func (s *stubExecServer) getLastRequest() *pb.SandboxExecRequest { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastRequest +} + +// ─── test helpers ──────────────────────────────────────────────────────────── + +// startBufconnServer starts an in-process gRPC server backed by a bufconn +// listener. It registers the provided SandboxExecServiceServer and returns +// a dialing function suitable for grpc.WithContextDialer. +// All cleanup is registered with t.Cleanup. +// +// mTLS note: the unit test uses insecure transport (no TLS certificates) — +// mTLS is exercised end-to-end in the scenario suite (PR11). The comment +// here documents the deliberate choice so future readers don't mistake the +// lack of TLS for an oversight. +func startBufconnServer(t *testing.T, srv pb.SandboxExecServiceServer) func(context.Context, string) (net.Conn, error) { + t.Helper() + l := bufconn.Listen(bufConnSize) + t.Cleanup(func() { _ = l.Close() }) + + s := grpc.NewServer() + pb.RegisterSandboxExecServiceServer(s, srv) + go func() { _ = s.Serve(l) }() + t.Cleanup(s.Stop) + + return func(ctx context.Context, _ string) (net.Conn, error) { + return l.DialContext(ctx) + } +} + +// newTestRunner creates a RemoteRunner wired to the given bufconn dialer. +// It uses insecure transport (matching the test server) and no bearer token +// so unit tests are self-contained without certificates. +func newTestRunner(t *testing.T, dialer func(context.Context, string) (net.Conn, error), cfg RemoteRunnerConfig) *RemoteRunner { + t.Helper() + // Override address to a placeholder; we replace the dialer via DialOption. + cfg.Address = "passthrough:///bufnet" + cfg.TLS = nil // ensure insecure path in connect() + + r := &RemoteRunner{cfg: cfg} + + conn, err := grpc.NewClient(cfg.Address, + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("newTestRunner: grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + r.conn = conn + return r +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +// TestRemoteRunner_ExecReturnsExpectedResult verifies that RemoteRunner.Exec +// accumulates stdout/stderr chunks and the exit_code from the streaming +// SandboxExecService and returns a correctly populated sandbox.ExecResult. +func TestRemoteRunner_ExecReturnsExpectedResult(t *testing.T) { + stub := &stubExecServer{ + stdoutData: []byte("hello stdout"), + stderrData: []byte("hello stderr"), + exitCode: 42, + } + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{ + Profile: "default", + Image: "alpine:3.19", + }) + + result, err := runner.Exec(context.Background(), []string{"echo", "hello"}) + if err != nil { + t.Fatalf("Exec returned error: %v", err) + } + + if result.Stdout != "hello stdout" { + t.Errorf("stdout: want %q, got %q", "hello stdout", result.Stdout) + } + if result.Stderr != "hello stderr" { + t.Errorf("stderr: want %q, got %q", "hello stderr", result.Stderr) + } + if result.ExitCode != 42 { + t.Errorf("exit_code: want 42, got %d", result.ExitCode) + } +} + +// TestRemoteRunner_ExecPassesCommandToServer verifies that the command slice +// supplied to Exec is forwarded verbatim in the SandboxExecRequest. +func TestRemoteRunner_ExecPassesCommandToServer(t *testing.T) { + stub := &stubExecServer{exitCode: 0} + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{ + Profile: "strict", + Image: "busybox:1.36", + }) + + cmd := []string{"sh", "-c", "echo hi"} + result, err := runner.Exec(context.Background(), cmd) + if err != nil { + t.Fatalf("Exec error: %v", err) + } + // Assert the zero exit code is reported faithfully (not just the 42 case). + if result.ExitCode != 0 { + t.Errorf("exit_code: want 0, got %d", result.ExitCode) + } + + req := stub.getLastRequest() + if req == nil { + t.Fatal("stub: no request received") + } + if len(req.Command) != len(cmd) { + t.Fatalf("command len: want %d, got %d", len(cmd), len(req.Command)) + } + for i, arg := range cmd { + if req.Command[i] != arg { + t.Errorf("command[%d]: want %q, got %q", i, arg, req.Command[i]) + } + } + if req.Profile != "strict" { + t.Errorf("profile: want %q, got %q", "strict", req.Profile) + } + if req.Image != "busybox:1.36" { + t.Errorf("image: want %q, got %q", "busybox:1.36", req.Image) + } +} + +// TestRemoteRunner_ExecPassesSecretRefUnresolved asserts the ADR 0017 invariant: +// env values that contain secret:// references MUST arrive at the agent +// unmodified — RemoteRunner MUST NOT resolve them. +// +// The stub captures the received SandboxExecRequest and the test asserts that +// the raw "secret://vault/x" string is present in env, proving the client +// passed it verbatim. +func TestRemoteRunner_ExecPassesSecretRefUnresolved(t *testing.T) { + const secretRef = "secret://vault/my-key" + + stub := &stubExecServer{exitCode: 0} + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{ + Image: "alpine:3.19", + Env: map[string]string{ + "DB_PASSWORD": secretRef, + "PORT": "8080", + }, + }) + + _, err := runner.Exec(context.Background(), []string{"env"}) + if err != nil { + t.Fatalf("Exec error: %v", err) + } + + req := stub.getLastRequest() + if req == nil { + t.Fatal("stub: no request received") + } + got, ok := req.Env["DB_PASSWORD"] + if !ok { + t.Fatal("env: DB_PASSWORD key missing in received request") + } + if got != secretRef { + t.Errorf("env[DB_PASSWORD]: want unresolved %q, got %q (client must NOT resolve secret:// refs)", secretRef, got) + } +} + +// TestRemoteRunner_Close verifies that Close does not panic and can be called +// multiple times safely. +func TestRemoteRunner_Close(t *testing.T) { + stub := &stubExecServer{exitCode: 0} + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{Image: "alpine:3.19"}) + + if err := runner.Close(); err != nil { + t.Errorf("first Close: unexpected error: %v", err) + } + // Second Close must be a no-op (conn is nil after first Close). + if err := runner.Close(); err != nil { + t.Errorf("second Close: unexpected error: %v", err) + } +} + +// TestNewRemoteRunner_RequiresAddress verifies that NewRemoteRunner returns an +// error when Address is empty. +func TestNewRemoteRunner_RequiresAddress(t *testing.T) { + _, err := NewRemoteRunner(RemoteRunnerConfig{Image: "alpine:3.19"}) + if err == nil { + t.Fatal("expected error for empty address, got nil") + } +} + +// TestRemoteRunner_ExecMissingExitCodeIsError asserts that a stream which ends +// (io.EOF) WITHOUT delivering an exit_code chunk — an agent crash or truncated +// stream — surfaces as an error rather than a silent ExecResult{ExitCode: 0} +// false success. +func TestRemoteRunner_ExecMissingExitCodeIsError(t *testing.T) { + stub := &stubExecServer{ + stdoutData: []byte("partial output before crash"), + omitExitCode: true, + } + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{Image: "alpine:3.19"}) + + result, err := runner.Exec(context.Background(), []string{"do-something"}) + if err == nil { + t.Fatalf("expected error when stream closes without exit_code, got result=%+v", result) + } + if result != nil { + t.Errorf("expected nil result on missing exit_code, got %+v", result) + } + if !strings.Contains(err.Error(), "exit_code") { + t.Errorf("error should mention missing exit_code; got %q", err.Error()) + } +} + +// TestRemoteRunner_ConcurrentExecAndClose drives Exec from many goroutines +// while Close races against them. Under -race this proves connect() returning +// the *grpc.ClientConn (rather than Exec re-reading r.conn unlocked) closes the +// data race / nil-deref window: a concurrent Close() setting r.conn = nil +// cannot nil the connection out from under an in-flight Exec. +// +// Exec calls after Close may legitimately fail (the conn is closed / re-dialed +// or the RPC errors); the test only asserts no panic and no data race — it +// tolerates per-call errors. +func TestRemoteRunner_ConcurrentExecAndClose(t *testing.T) { + stub := &stubExecServer{stdoutData: []byte("ok"), exitCode: 0} + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{Image: "alpine:3.19"}) + + const workers = 16 + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + // Errors are tolerated; the point is no race / no nil panic. + _, _ = runner.Exec(context.Background(), []string{"echo", "hi"}) + }() + } + + // Race a Close against the in-flight Exec calls. + _ = runner.Close() + wg.Wait() +} + +// TestRemoteRunner_ConcurrentExec drives Exec from many goroutines with no +// Close to prove the happy-path connect()/Exec read of the connection is also +// race-free under -race. +func TestRemoteRunner_ConcurrentExec(t *testing.T) { + stub := &stubExecServer{stdoutData: []byte("ok"), exitCode: 0} + dialer := startBufconnServer(t, stub) + + runner := newTestRunner(t, dialer, RemoteRunnerConfig{Image: "alpine:3.19"}) + + const workers = 16 + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + if _, err := runner.Exec(context.Background(), []string{"echo", "hi"}); err != nil { + t.Errorf("concurrent Exec: unexpected error: %v", err) + } + }() + } + wg.Wait() +} + +// TestNewRemoteRunner_TokenWithoutTLSRejected asserts the credential-leak guard: +// a non-empty Token with no TLS and no AllowInsecure must be rejected so the +// bearer token is never sent in cleartext. +func TestNewRemoteRunner_TokenWithoutTLSRejected(t *testing.T) { + _, err := NewRemoteRunner(RemoteRunnerConfig{ + Address: "127.0.0.1:1234", + Token: "secret-token", + // TLS nil, AllowInsecure false + }) + if err == nil { + t.Fatal("expected error for token over insecure connection, got nil") + } + if !strings.Contains(err.Error(), "insecure") { + t.Errorf("error should mention insecure connection; got %q", err.Error()) + } +} + +// TestNewRemoteRunner_TokenWithoutTLSAllowedWithOptIn verifies the explicit +// AllowInsecure escape hatch lets a token-over-insecure runner construct (for +// local-dev / tests). +func TestNewRemoteRunner_TokenWithoutTLSAllowedWithOptIn(t *testing.T) { + r, err := NewRemoteRunner(RemoteRunnerConfig{ + Address: "127.0.0.1:1234", + Token: "secret-token", + AllowInsecure: true, + }) + if err != nil { + t.Fatalf("AllowInsecure should permit token over insecure: %v", err) + } + if r == nil { + t.Fatal("expected non-nil runner") + } + t.Cleanup(func() { _ = r.Close() }) +} + +// TestRemoteRunner_ExecEmptyCommandRejected verifies an empty argv is rejected +// before any RPC (matches the local DockerSandbox runner). +func TestRemoteRunner_ExecEmptyCommandRejected(t *testing.T) { + r, err := NewRemoteRunner(RemoteRunnerConfig{Address: "127.0.0.1:1", AllowInsecure: true}) + if err != nil { + t.Fatalf("NewRemoteRunner: %v", err) + } + defer func() { _ = r.Close() }() + if _, err := r.Exec(context.Background(), nil); err == nil { + t.Error("expected error for nil command") + } + if _, err := r.Exec(context.Background(), []string{}); err == nil { + t.Error("expected error for empty command") + } +}