Emit an explicit "Started" message and support sending signals (#56)

* Emit an explicit "Started" message and support sending signals

Co-authored-by: Fedor Korotkov <fkorotkov@openai.com>

* Run exec commands in dedicated process groups

* Disable wsl_v5 linter

* Clean up exec processes on stream failures

* Fix gosec linter error

* Wrap exec signals in a message

---------

Co-authored-by: Fedor Korotkov <fkorotkov@openai.com>
This commit is contained in:
edi-oai 2026-08-10 21:08:27 +01:00 committed by GitHub
parent df114a194a
commit d4ebcbde41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 684 additions and 60 deletions

View File

@ -32,6 +32,7 @@ linters:
# Style linters that are total nuts. # Style linters that are total nuts.
- wsl - wsl
- wsl_v5
- funlen - funlen
# Enough parallelism for now. # Enough parallelism for now.
@ -61,6 +62,15 @@ linters:
# Not all errors need to be checked # Not all errors need to be checked
- errcheck - errcheck
# It's OK to not initialize some struct fields
- exhaustruct
# This is not a library, so it's OK to use dynamic errors
- err113
# Inline error handling keeps assignment and checking together
- noinlineerr
issues: issues:
# Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely. # Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
max-issues-per-linter: 0 max-issues-per-linter: 0

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.6 // protoc-gen-go v1.36.6
// protoc (unknown) // protoc v7.35.1
// source: rpc/agent.proto // source: rpc/agent.proto
package rpc package rpc
@ -22,6 +22,55 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
) )
type ExecRequest_SendSignal_Signal int32
const (
ExecRequest_SendSignal_SIGNAL_UNSPECIFIED ExecRequest_SendSignal_Signal = 0
ExecRequest_SendSignal_SIGNAL_SIGTERM ExecRequest_SendSignal_Signal = 1
ExecRequest_SendSignal_SIGNAL_SIGKILL ExecRequest_SendSignal_Signal = 2
)
// Enum value maps for ExecRequest_SendSignal_Signal.
var (
ExecRequest_SendSignal_Signal_name = map[int32]string{
0: "SIGNAL_UNSPECIFIED",
1: "SIGNAL_SIGTERM",
2: "SIGNAL_SIGKILL",
}
ExecRequest_SendSignal_Signal_value = map[string]int32{
"SIGNAL_UNSPECIFIED": 0,
"SIGNAL_SIGTERM": 1,
"SIGNAL_SIGKILL": 2,
}
)
func (x ExecRequest_SendSignal_Signal) Enum() *ExecRequest_SendSignal_Signal {
p := new(ExecRequest_SendSignal_Signal)
*p = x
return p
}
func (x ExecRequest_SendSignal_Signal) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (ExecRequest_SendSignal_Signal) Descriptor() protoreflect.EnumDescriptor {
return file_rpc_agent_proto_enumTypes[0].Descriptor()
}
func (ExecRequest_SendSignal_Signal) Type() protoreflect.EnumType {
return &file_rpc_agent_proto_enumTypes[0]
}
func (x ExecRequest_SendSignal_Signal) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use ExecRequest_SendSignal_Signal.Descriptor instead.
func (ExecRequest_SendSignal_Signal) EnumDescriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{0, 0, 0}
}
type ExecRequest struct { type ExecRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Type: // Types that are valid to be assigned to Type:
@ -29,6 +78,7 @@ type ExecRequest struct {
// *ExecRequest_Command_ // *ExecRequest_Command_
// *ExecRequest_StandardInput // *ExecRequest_StandardInput
// *ExecRequest_TerminalResize // *ExecRequest_TerminalResize
// *ExecRequest_SendSignal_
Type isExecRequest_Type `protobuf_oneof:"type"` Type isExecRequest_Type `protobuf_oneof:"type"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@ -98,6 +148,15 @@ func (x *ExecRequest) GetTerminalResize() *TerminalSize {
return nil return nil
} }
func (x *ExecRequest) GetSendSignal() *ExecRequest_SendSignal {
if x != nil {
if x, ok := x.Type.(*ExecRequest_SendSignal_); ok {
return x.SendSignal
}
}
return nil
}
type isExecRequest_Type interface { type isExecRequest_Type interface {
isExecRequest_Type() isExecRequest_Type()
} }
@ -114,12 +173,18 @@ type ExecRequest_TerminalResize struct {
TerminalResize *TerminalSize `protobuf:"bytes,3,opt,name=terminal_resize,json=terminalResize,proto3,oneof"` TerminalResize *TerminalSize `protobuf:"bytes,3,opt,name=terminal_resize,json=terminalResize,proto3,oneof"`
} }
type ExecRequest_SendSignal_ struct {
SendSignal *ExecRequest_SendSignal `protobuf:"bytes,4,opt,name=send_signal,json=sendSignal,proto3,oneof"`
}
func (*ExecRequest_Command_) isExecRequest_Type() {} func (*ExecRequest_Command_) isExecRequest_Type() {}
func (*ExecRequest_StandardInput) isExecRequest_Type() {} func (*ExecRequest_StandardInput) isExecRequest_Type() {}
func (*ExecRequest_TerminalResize) isExecRequest_Type() {} func (*ExecRequest_TerminalResize) isExecRequest_Type() {}
func (*ExecRequest_SendSignal_) isExecRequest_Type() {}
type ExecResponse struct { type ExecResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Type: // Types that are valid to be assigned to Type:
@ -127,6 +192,7 @@ type ExecResponse struct {
// *ExecResponse_Exit_ // *ExecResponse_Exit_
// *ExecResponse_StandardOutput // *ExecResponse_StandardOutput
// *ExecResponse_StandardError // *ExecResponse_StandardError
// *ExecResponse_Started_
Type isExecResponse_Type `protobuf_oneof:"type"` Type isExecResponse_Type `protobuf_oneof:"type"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@ -196,6 +262,15 @@ func (x *ExecResponse) GetStandardError() *IOChunk {
return nil return nil
} }
func (x *ExecResponse) GetStarted() *ExecResponse_Started {
if x != nil {
if x, ok := x.Type.(*ExecResponse_Started_); ok {
return x.Started
}
}
return nil
}
type isExecResponse_Type interface { type isExecResponse_Type interface {
isExecResponse_Type() isExecResponse_Type()
} }
@ -212,12 +287,18 @@ type ExecResponse_StandardError struct {
StandardError *IOChunk `protobuf:"bytes,3,opt,name=standard_error,json=standardError,proto3,oneof"` StandardError *IOChunk `protobuf:"bytes,3,opt,name=standard_error,json=standardError,proto3,oneof"`
} }
type ExecResponse_Started_ struct {
Started *ExecResponse_Started `protobuf:"bytes,4,opt,name=started,proto3,oneof"`
}
func (*ExecResponse_Exit_) isExecResponse_Type() {} func (*ExecResponse_Exit_) isExecResponse_Type() {}
func (*ExecResponse_StandardOutput) isExecResponse_Type() {} func (*ExecResponse_StandardOutput) isExecResponse_Type() {}
func (*ExecResponse_StandardError) isExecResponse_Type() {} func (*ExecResponse_StandardError) isExecResponse_Type() {}
func (*ExecResponse_Started_) isExecResponse_Type() {}
type TerminalSize struct { type TerminalSize struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Rows uint32 `protobuf:"varint,1,opt,name=rows,proto3" json:"rows,omitempty"` Rows uint32 `protobuf:"varint,1,opt,name=rows,proto3" json:"rows,omitempty"`
@ -394,6 +475,50 @@ func (x *ResolveIPResponse) GetIp() string {
return "" return ""
} }
type ExecRequest_SendSignal struct {
state protoimpl.MessageState `protogen:"open.v1"`
Signal ExecRequest_SendSignal_Signal `protobuf:"varint,1,opt,name=signal,proto3,enum=ExecRequest_SendSignal_Signal" json:"signal,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecRequest_SendSignal) Reset() {
*x = ExecRequest_SendSignal{}
mi := &file_rpc_agent_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecRequest_SendSignal) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecRequest_SendSignal) ProtoMessage() {}
func (x *ExecRequest_SendSignal) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_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 ExecRequest_SendSignal.ProtoReflect.Descriptor instead.
func (*ExecRequest_SendSignal) Descriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{0, 0}
}
func (x *ExecRequest_SendSignal) GetSignal() ExecRequest_SendSignal_Signal {
if x != nil {
return x.Signal
}
return ExecRequest_SendSignal_SIGNAL_UNSPECIFIED
}
type ExecRequest_Command struct { type ExecRequest_Command struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@ -410,7 +535,7 @@ type ExecRequest_Command struct {
func (x *ExecRequest_Command) Reset() { func (x *ExecRequest_Command) Reset() {
*x = ExecRequest_Command{} *x = ExecRequest_Command{}
mi := &file_rpc_agent_proto_msgTypes[6] mi := &file_rpc_agent_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -422,7 +547,7 @@ func (x *ExecRequest_Command) String() string {
func (*ExecRequest_Command) ProtoMessage() {} func (*ExecRequest_Command) ProtoMessage() {}
func (x *ExecRequest_Command) ProtoReflect() protoreflect.Message { func (x *ExecRequest_Command) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_proto_msgTypes[6] mi := &file_rpc_agent_proto_msgTypes[7]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -435,7 +560,7 @@ func (x *ExecRequest_Command) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExecRequest_Command.ProtoReflect.Descriptor instead. // Deprecated: Use ExecRequest_Command.ProtoReflect.Descriptor instead.
func (*ExecRequest_Command) Descriptor() ([]byte, []int) { func (*ExecRequest_Command) Descriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{0, 0} return file_rpc_agent_proto_rawDescGZIP(), []int{0, 1}
} }
func (x *ExecRequest_Command) GetName() string { func (x *ExecRequest_Command) GetName() string {
@ -503,7 +628,7 @@ type ExecResponse_Exit struct {
func (x *ExecResponse_Exit) Reset() { func (x *ExecResponse_Exit) Reset() {
*x = ExecResponse_Exit{} *x = ExecResponse_Exit{}
mi := &file_rpc_agent_proto_msgTypes[8] mi := &file_rpc_agent_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -515,7 +640,7 @@ func (x *ExecResponse_Exit) String() string {
func (*ExecResponse_Exit) ProtoMessage() {} func (*ExecResponse_Exit) ProtoMessage() {}
func (x *ExecResponse_Exit) ProtoReflect() protoreflect.Message { func (x *ExecResponse_Exit) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_proto_msgTypes[8] mi := &file_rpc_agent_proto_msgTypes[9]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -538,15 +663,60 @@ func (x *ExecResponse_Exit) GetCode() int32 {
return 0 return 0
} }
type ExecResponse_Started struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecResponse_Started) Reset() {
*x = ExecResponse_Started{}
mi := &file_rpc_agent_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecResponse_Started) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecResponse_Started) ProtoMessage() {}
func (x *ExecResponse_Started) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_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 ExecResponse_Started.ProtoReflect.Descriptor instead.
func (*ExecResponse_Started) Descriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{1, 1}
}
var File_rpc_agent_proto protoreflect.FileDescriptor var File_rpc_agent_proto protoreflect.FileDescriptor
const file_rpc_agent_proto_rawDesc = "" + const file_rpc_agent_proto_rawDesc = "" +
"\n" + "\n" +
"\x0frpc/agent.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xeb\x03\n" + "\x0frpc/agent.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xb8\x05\n" +
"\vExecRequest\x120\n" + "\vExecRequest\x120\n" +
"\acommand\x18\x01 \x01(\v2\x14.ExecRequest.CommandH\x00R\acommand\x121\n" + "\acommand\x18\x01 \x01(\v2\x14.ExecRequest.CommandH\x00R\acommand\x121\n" +
"\x0estandard_input\x18\x02 \x01(\v2\b.IOChunkH\x00R\rstandardInput\x128\n" + "\x0estandard_input\x18\x02 \x01(\v2\b.IOChunkH\x00R\rstandardInput\x128\n" +
"\x0fterminal_resize\x18\x03 \x01(\v2\r.TerminalSizeH\x00R\x0eterminalResize\x1a\xb4\x02\n" + "\x0fterminal_resize\x18\x03 \x01(\v2\r.TerminalSizeH\x00R\x0eterminalResize\x12:\n" +
"\vsend_signal\x18\x04 \x01(\v2\x17.ExecRequest.SendSignalH\x00R\n" +
"sendSignal\x1a\x8e\x01\n" +
"\n" +
"SendSignal\x126\n" +
"\x06signal\x18\x01 \x01(\x0e2\x1e.ExecRequest.SendSignal.SignalR\x06signal\"H\n" +
"\x06Signal\x12\x16\n" +
"\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" +
"\x0eSIGNAL_SIGTERM\x10\x01\x12\x12\n" +
"\x0eSIGNAL_SIGKILL\x10\x02\x1a\xb4\x02\n" +
"\aCommand\x12\x12\n" + "\aCommand\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04args\x18\x02 \x03(\tR\x04args\x12 \n" + "\x04args\x18\x02 \x03(\tR\x04args\x12 \n" +
@ -559,13 +729,15 @@ const file_rpc_agent_proto_rawDesc = "" +
"\bEnvEntry\x12\x10\n" + "\bEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" +
"\x04type\"\xc4\x01\n" + "\x04type\"\x82\x02\n" +
"\fExecResponse\x12(\n" + "\fExecResponse\x12(\n" +
"\x04exit\x18\x01 \x01(\v2\x12.ExecResponse.ExitH\x00R\x04exit\x123\n" + "\x04exit\x18\x01 \x01(\v2\x12.ExecResponse.ExitH\x00R\x04exit\x123\n" +
"\x0fstandard_output\x18\x02 \x01(\v2\b.IOChunkH\x00R\x0estandardOutput\x121\n" + "\x0fstandard_output\x18\x02 \x01(\v2\b.IOChunkH\x00R\x0estandardOutput\x121\n" +
"\x0estandard_error\x18\x03 \x01(\v2\b.IOChunkH\x00R\rstandardError\x1a\x1a\n" + "\x0estandard_error\x18\x03 \x01(\v2\b.IOChunkH\x00R\rstandardError\x121\n" +
"\astarted\x18\x04 \x01(\v2\x15.ExecResponse.StartedH\x00R\astarted\x1a\x1a\n" +
"\x04Exit\x12\x12\n" + "\x04Exit\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04codeB\x06\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x1a\t\n" +
"\aStartedB\x06\n" +
"\x04type\"6\n" + "\x04type\"6\n" +
"\fTerminalSize\x12\x12\n" + "\fTerminalSize\x12\x12\n" +
"\x04rows\x18\x01 \x01(\rR\x04rows\x12\x12\n" + "\x04rows\x18\x01 \x01(\rR\x04rows\x12\x12\n" +
@ -591,36 +763,43 @@ func file_rpc_agent_proto_rawDescGZIP() []byte {
return file_rpc_agent_proto_rawDescData return file_rpc_agent_proto_rawDescData
} }
var file_rpc_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_rpc_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_rpc_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_rpc_agent_proto_goTypes = []any{ var file_rpc_agent_proto_goTypes = []any{
(*ExecRequest)(nil), // 0: ExecRequest (ExecRequest_SendSignal_Signal)(0), // 0: ExecRequest.SendSignal.Signal
(*ExecResponse)(nil), // 1: ExecResponse (*ExecRequest)(nil), // 1: ExecRequest
(*TerminalSize)(nil), // 2: TerminalSize (*ExecResponse)(nil), // 2: ExecResponse
(*IOChunk)(nil), // 3: IOChunk (*TerminalSize)(nil), // 3: TerminalSize
(*ResolveIPRequest)(nil), // 4: ResolveIPRequest (*IOChunk)(nil), // 4: IOChunk
(*ResolveIPResponse)(nil), // 5: ResolveIPResponse (*ResolveIPRequest)(nil), // 5: ResolveIPRequest
(*ExecRequest_Command)(nil), // 6: ExecRequest.Command (*ResolveIPResponse)(nil), // 6: ResolveIPResponse
nil, // 7: ExecRequest.Command.EnvEntry (*ExecRequest_SendSignal)(nil), // 7: ExecRequest.SendSignal
(*ExecResponse_Exit)(nil), // 8: ExecResponse.Exit (*ExecRequest_Command)(nil), // 8: ExecRequest.Command
nil, // 9: ExecRequest.Command.EnvEntry
(*ExecResponse_Exit)(nil), // 10: ExecResponse.Exit
(*ExecResponse_Started)(nil), // 11: ExecResponse.Started
} }
var file_rpc_agent_proto_depIdxs = []int32{ var file_rpc_agent_proto_depIdxs = []int32{
6, // 0: ExecRequest.command:type_name -> ExecRequest.Command 8, // 0: ExecRequest.command:type_name -> ExecRequest.Command
3, // 1: ExecRequest.standard_input:type_name -> IOChunk 4, // 1: ExecRequest.standard_input:type_name -> IOChunk
2, // 2: ExecRequest.terminal_resize:type_name -> TerminalSize 3, // 2: ExecRequest.terminal_resize:type_name -> TerminalSize
8, // 3: ExecResponse.exit:type_name -> ExecResponse.Exit 7, // 3: ExecRequest.send_signal:type_name -> ExecRequest.SendSignal
3, // 4: ExecResponse.standard_output:type_name -> IOChunk 10, // 4: ExecResponse.exit:type_name -> ExecResponse.Exit
3, // 5: ExecResponse.standard_error:type_name -> IOChunk 4, // 5: ExecResponse.standard_output:type_name -> IOChunk
2, // 6: ExecRequest.Command.terminal_size:type_name -> TerminalSize 4, // 6: ExecResponse.standard_error:type_name -> IOChunk
7, // 7: ExecRequest.Command.env:type_name -> ExecRequest.Command.EnvEntry 11, // 7: ExecResponse.started:type_name -> ExecResponse.Started
0, // 8: Agent.Exec:input_type -> ExecRequest 0, // 8: ExecRequest.SendSignal.signal:type_name -> ExecRequest.SendSignal.Signal
4, // 9: Agent.ResolveIP:input_type -> ResolveIPRequest 3, // 9: ExecRequest.Command.terminal_size:type_name -> TerminalSize
1, // 10: Agent.Exec:output_type -> ExecResponse 9, // 10: ExecRequest.Command.env:type_name -> ExecRequest.Command.EnvEntry
5, // 11: Agent.ResolveIP:output_type -> ResolveIPResponse 1, // 11: Agent.Exec:input_type -> ExecRequest
10, // [10:12] is the sub-list for method output_type 5, // 12: Agent.ResolveIP:input_type -> ResolveIPRequest
8, // [8:10] is the sub-list for method input_type 2, // 13: Agent.Exec:output_type -> ExecResponse
8, // [8:8] is the sub-list for extension type_name 6, // 14: Agent.ResolveIP:output_type -> ResolveIPResponse
8, // [8:8] is the sub-list for extension extendee 13, // [13:15] is the sub-list for method output_type
0, // [0:8] is the sub-list for field type_name 11, // [11:13] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
} }
func init() { file_rpc_agent_proto_init() } func init() { file_rpc_agent_proto_init() }
@ -632,24 +811,27 @@ func file_rpc_agent_proto_init() {
(*ExecRequest_Command_)(nil), (*ExecRequest_Command_)(nil),
(*ExecRequest_StandardInput)(nil), (*ExecRequest_StandardInput)(nil),
(*ExecRequest_TerminalResize)(nil), (*ExecRequest_TerminalResize)(nil),
(*ExecRequest_SendSignal_)(nil),
} }
file_rpc_agent_proto_msgTypes[1].OneofWrappers = []any{ file_rpc_agent_proto_msgTypes[1].OneofWrappers = []any{
(*ExecResponse_Exit_)(nil), (*ExecResponse_Exit_)(nil),
(*ExecResponse_StandardOutput)(nil), (*ExecResponse_StandardOutput)(nil),
(*ExecResponse_StandardError)(nil), (*ExecResponse_StandardError)(nil),
(*ExecResponse_Started_)(nil),
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_agent_proto_rawDesc), len(file_rpc_agent_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_agent_proto_rawDesc), len(file_rpc_agent_proto_rawDesc)),
NumEnums: 0, NumEnums: 1,
NumMessages: 9, NumMessages: 11,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
GoTypes: file_rpc_agent_proto_goTypes, GoTypes: file_rpc_agent_proto_goTypes,
DependencyIndexes: file_rpc_agent_proto_depIdxs, DependencyIndexes: file_rpc_agent_proto_depIdxs,
EnumInfos: file_rpc_agent_proto_enumTypes,
MessageInfos: file_rpc_agent_proto_msgTypes, MessageInfos: file_rpc_agent_proto_msgTypes,
}.Build() }.Build()
File_rpc_agent_proto = out.File File_rpc_agent_proto = out.File

View File

@ -4,23 +4,30 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/creack/pty"
"github.com/samber/lo"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"strings" "strings"
"sync"
"syscall" "syscall"
"github.com/creack/pty"
"github.com/samber/lo"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
) )
const ( const (
standardStreamsBufferSize = 4096 standardStreamsBufferSize = 4096
eofChar = 0x04 eofChar = 0x04
// execRuntimeFailureExitCode matches Docker's exit code for runtime failures before a process starts.
execRuntimeFailureExitCode = 125
// signalExitCodeOffset is the base for shell-style exit codes of processes terminated by signals.
signalExitCodeOffset = 128
) )
func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error { func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
@ -58,12 +65,21 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
zap.S().Warnf("failed to start %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
firstExecRequestCommand.Command.GetArgs()), err)
return sendStartFailure(stream)
}
// Release ownership before sending responses so failures do not leak the process handle
if err := cmd.Process.Release(); err != nil {
return err return err
} }
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil { // Explicitly notify the client that the process was started
return err err = sendStartSuccess(stream)
} if err != nil {
return err
} }
if err := stream.Send(&ExecResponse{ if err := stream.Send(&ExecResponse{
@ -79,6 +95,11 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return nil return nil
} }
// Kill the whole process group when the exec stream is canceled
cmd.Cancel = func() error {
return signalProcessGroup(cmd.Process, syscall.SIGKILL)
}
var stdin io.WriteCloser var stdin io.WriteCloser
var stdout, stderr io.ReadCloser var stdout, stderr io.ReadCloser
var ptmx *os.File var ptmx *os.File
@ -95,6 +116,9 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
stdout = ptmx stdout = ptmx
stderr = ptmx stderr = ptmx
} else { } else {
// Start the command in its own process group so signals reach all descendants
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if firstExecRequestCommand.Command.Interactive { if firstExecRequestCommand.Command.Interactive {
stdin, err = cmd.StdinPipe() stdin, err = cmd.StdinPipe()
if err != nil { if err != nil {
@ -114,22 +138,47 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
err = cmd.Start() err = cmd.Start()
} }
if err != nil { if err != nil {
return err zap.S().Warnf("failed to start %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
firstExecRequestCommand.Command.GetArgs()), err)
return sendStartFailure(stream)
} }
// Ensure the PTY is closed if sending the Started response fails
if ptmx != nil { if ptmx != nil {
defer ptmx.Close() defer ptmx.Close()
} }
// Explicitly notify the client that the process was started
err = sendStartSuccess(stream)
if err != nil {
// Output readers have not started yet, so cancel and reap directly
_ = cmd.Cancel()
_ = cmd.Wait()
return err
}
// Handle standard input and terminal resize events from the client // Handle standard input and terminal resize events from the client
fromClientErrCh := make(chan error, 1) fromClientErrCh := make(chan error, 1)
reportClientError := func(err error) {
fromClientErrCh <- err
_ = cmd.Cancel()
}
go func() { go func() {
for { for {
request, err := stream.Recv() request, err := stream.Recv()
if err != nil { if err != nil {
// Allow the client to close its sending side while continuing to receive responses
if errors.Is(err, io.EOF) {
return
}
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
fromClientErrCh <- err reportClientError(err)
} }
return return
@ -156,7 +205,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
} else { } else {
// Close the standard input // Close the standard input
if err := stdin.Close(); err != nil { if err := stdin.Close(); err != nil {
fromClientErrCh <- err reportClientError(err)
return return
} }
@ -166,7 +215,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
} }
if _, err := stdin.Write(dataToWrite); err != nil { if _, err := stdin.Write(dataToWrite); err != nil {
fromClientErrCh <- err reportClientError(err)
return return
} }
@ -181,7 +230,26 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
Rows: uint16(typedAction.TerminalResize.GetRows()), Rows: uint16(typedAction.TerminalResize.GetRows()),
Cols: uint16(typedAction.TerminalResize.GetCols()), Cols: uint16(typedAction.TerminalResize.GetCols()),
}); err != nil { }); err != nil {
fromClientErrCh <- err reportClientError(err)
return
}
case *ExecRequest_SendSignal_:
var signal syscall.Signal
switch typedAction.SendSignal.GetSignal() {
case ExecRequest_SendSignal_SIGNAL_SIGTERM:
signal = syscall.SIGTERM
case ExecRequest_SendSignal_SIGNAL_SIGKILL:
signal = syscall.SIGKILL
default:
reportClientError(fmt.Errorf("unsupported exec signal %q", typedAction.SendSignal.GetSignal().String()))
return
}
if err := signalProcessGroup(cmd.Process, signal); err != nil && !errors.Is(err, os.ErrProcessDone) {
reportClientError(err)
return return
} }
@ -189,6 +257,16 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
} }
}() }()
// Serialize responses from the stdout and stderr goroutines
var sendMutex sync.Mutex
sendResponse := func(response *ExecResponse) error {
sendMutex.Lock()
defer sendMutex.Unlock()
return stream.Send(response)
}
group, _ := errgroup.WithContext(stream.Context()) group, _ := errgroup.WithContext(stream.Context())
// Handle standard output from the command // Handle standard output from the command
@ -210,7 +288,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return err return err
} }
if err := stream.Send(&ExecResponse{ if err := sendResponse(&ExecResponse{
Type: &ExecResponse_StandardOutput{ Type: &ExecResponse_StandardOutput{
StandardOutput: &IOChunk{ StandardOutput: &IOChunk{
Data: slices.Clone(buf[:n]), Data: slices.Clone(buf[:n]),
@ -240,7 +318,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return err return err
} }
if err := stream.Send(&ExecResponse{ if err := sendResponse(&ExecResponse{
Type: &ExecResponse_StandardError{ Type: &ExecResponse_StandardError{
StandardError: &IOChunk{ StandardError: &IOChunk{
Data: slices.Clone(buf[:n]), Data: slices.Clone(buf[:n]),
@ -258,15 +336,29 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
} }
// Wait for the command to finish // Wait for the command to finish
err = cmd.Wait()
// Prefer a client error over the command exit result
select {
case err := <-fromClientErrCh:
return err
default:
}
exitCode := 0 exitCode := 0
if err := cmd.Wait(); err != nil { if err != nil {
var exitError *exec.ExitError var exitError *exec.ExitError
if errors.As(err, &exitError) { if !errors.As(err, &exitError) {
exitCode = exitError.ExitCode()
} else {
return err return err
} }
// ExitCode returns -1 for signals; report the containerd-compatible 128 + signal instead
exitCode = exitError.ExitCode()
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok && waitStatus.Signaled() {
exitCode = signalExitCodeOffset + int(waitStatus.Signal())
}
} }
return stream.Send(&ExecResponse{ return stream.Send(&ExecResponse{
@ -278,6 +370,37 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
}) })
} }
func signalProcessGroup(process *os.Process, signal syscall.Signal) error {
if err := syscall.Kill(-process.Pid, signal); err != nil {
// Translate a missing process group into the process-finished error expected by os/exec
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
return nil
}
func sendStartSuccess(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
return stream.Send(&ExecResponse{
Type: &ExecResponse_Started_{
Started: &ExecResponse_Started{},
},
})
}
func sendStartFailure(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
return stream.Send(&ExecResponse{
Type: &ExecResponse_Exit_{
Exit: &ExecResponse_Exit{
Code: execRuntimeFailureExitCode,
},
},
})
}
func applyExecOverrides(cmd *exec.Cmd, command *ExecRequest_Command) { func applyExecOverrides(cmd *exec.Cmd, command *ExecRequest_Command) {
if command.Workdir != "" { if command.Workdir != "" {
cmd.Dir = command.Workdir cmd.Dir = command.Workdir

293
internal/rpc/exec_test.go Normal file
View File

@ -0,0 +1,293 @@
// In-process stream scaffolding intentionally favors direct test construction.
//
//nolint:containedctx,testpackage,wsl_v5
package rpc
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
const (
execTestShell = "/bin/sh"
execTestTimeout = 5 * time.Second
)
type execTestStream struct {
grpc.ServerStream
ctx context.Context
requests chan *ExecRequest
responses chan *ExecResponse
sendHook func(*ExecResponse) error
}
var _ grpc.BidiStreamingServer[ExecRequest, ExecResponse] = (*execTestStream)(nil)
func newExecTestStream(ctx context.Context) *execTestStream {
return &execTestStream{
ctx: ctx,
requests: make(chan *ExecRequest, 8),
responses: make(chan *ExecResponse, 8),
}
}
func (stream *execTestStream) Send(response *ExecResponse) error {
if stream.sendHook != nil {
if err := stream.sendHook(response); err != nil {
return err
}
}
select {
case stream.responses <- response:
return nil
case <-stream.ctx.Done():
return stream.ctx.Err()
}
}
func (stream *execTestStream) Recv() (*ExecRequest, error) {
select {
case request, ok := <-stream.requests:
if !ok {
return nil, io.EOF
}
return request, nil
case <-stream.ctx.Done():
return nil, stream.ctx.Err()
}
}
func (stream *execTestStream) Context() context.Context { return stream.ctx }
func TestExecSendsStartedBeforeOutputAndExit(t *testing.T) {
stream, result := startExecTest(t, &ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", "printf hello"},
})
first := receiveExecResponse(t, stream)
require.NotNil(t, first.GetStarted())
var output []byte
for {
response := receiveExecResponse(t, stream)
switch response := response.GetType().(type) {
case *ExecResponse_StandardOutput:
output = append(output, response.StandardOutput.GetData()...)
case *ExecResponse_Exit_:
require.EqualValues(t, 0, response.Exit.GetCode())
require.Equal(t, []byte("hello"), output)
require.NoError(t, receiveExecResult(t, result))
return
default:
t.Fatalf("unexpected exec response %T", response)
}
}
}
func TestExecReportsStartFailureBeforeStarted(t *testing.T) {
tests := []struct {
name string
command *ExecRequest_Command
}{
{
name: "missing executable",
command: &ExecRequest_Command{
Name: "/definitely/missing/tart-guest-agent-test-command",
},
},
{
name: "missing workdir",
command: &ExecRequest_Command{
Name: execTestShell,
Workdir: "/definitely/missing/tart-guest-agent-test-workdir",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stream, result := startExecTest(t, test.command)
response := receiveExecResponse(t, stream)
require.Nil(t, response.GetStarted())
require.EqualValues(t, execRuntimeFailureExitCode, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
})
}
}
func TestExecSignalsProcess(t *testing.T) {
tests := []struct {
name string
signal ExecRequest_SendSignal_Signal
code int32
err string
}{
{
name: "SIGTERM",
signal: ExecRequest_SendSignal_SIGNAL_SIGTERM,
code: int32(signalExitCodeOffset + syscall.SIGTERM),
},
{
name: "SIGKILL",
signal: ExecRequest_SendSignal_SIGNAL_SIGKILL,
code: int32(signalExitCodeOffset + syscall.SIGKILL),
},
{
name: "unsupported",
signal: ExecRequest_SendSignal_SIGNAL_UNSPECIFIED,
err: `unsupported exec signal "SIGNAL_UNSPECIFIED"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/sleep",
Args: []string{"30"},
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
stream.requests <- &ExecRequest{
Type: &ExecRequest_SendSignal_{
SendSignal: &ExecRequest_SendSignal{Signal: test.signal},
},
}
if test.err != "" {
require.EqualError(t, receiveExecResult(t, result), test.err)
return
}
response := receiveExecResponse(t, stream)
require.NotNil(t, response.GetExit())
require.Equal(t, test.code, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
})
}
}
func TestExecSignalsProcessGroup(t *testing.T) {
stream, result := startExecTest(t, &ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", "sleep 30 & printf ready; wait"},
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
require.Equal(t, []byte("ready"), receiveExecResponse(t, stream).GetStandardOutput().GetData())
stream.requests <- &ExecRequest{
Type: &ExecRequest_SendSignal_{
SendSignal: &ExecRequest_SendSignal{Signal: ExecRequest_SendSignal_SIGNAL_SIGTERM},
},
}
response := receiveExecResponse(t, stream)
require.EqualValues(t, signalExitCodeOffset+syscall.SIGTERM, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecReapsProcessWhenStartedCannotBeSent(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "pid")
sendErr := errors.New("failed to send Started")
var processPID int
_, result := startExecTest(t, &ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", `printf %d "$$" > "$PID_FILE"; exec sleep 30`},
Env: map[string]string{"PID_FILE": pidPath},
}, func(stream *execTestStream) {
stream.sendHook = func(response *ExecResponse) error {
if response.GetStarted() == nil {
return nil
}
var err error
processPID, err = waitForExecTestPID(pidPath)
if err != nil {
return err
}
return sendErr
}
})
require.ErrorIs(t, receiveExecResult(t, result), sendErr)
require.ErrorIs(t, syscall.Kill(processPID, 0), syscall.ESRCH)
}
func startExecTest(
t *testing.T,
command *ExecRequest_Command,
configure ...func(*execTestStream),
) (*execTestStream, <-chan error) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
stream := newExecTestStream(ctx)
for _, configureStream := range configure {
configureStream(stream)
}
result := make(chan error, 1)
go func() {
result <- (&RPC{}).Exec(stream)
}()
stream.requests <- &ExecRequest{
Type: &ExecRequest_Command_{Command: command},
}
return stream, result
}
func receiveExecResponse(t *testing.T, stream *execTestStream) *ExecResponse {
t.Helper()
select {
case response := <-stream.responses:
return response
case <-time.After(execTestTimeout):
t.Fatal("timed out waiting for exec response")
return nil
}
}
func receiveExecResult(t *testing.T, result <-chan error) error {
t.Helper()
select {
case err := <-result:
return err
case <-time.After(execTestTimeout):
t.Fatal("timed out waiting for Exec to return")
return nil
}
}
func waitForExecTestPID(path string) (int, error) {
deadline := time.Now().Add(execTestTimeout)
for time.Now().Before(deadline) {
//nolint:gosec // path is created under t.TempDir by the test
data, err := os.ReadFile(path)
if err == nil {
return strconv.Atoi(string(data))
}
if !errors.Is(err, os.ErrNotExist) {
return 0, err
}
time.Sleep(10 * time.Millisecond)
}
return 0, context.DeadlineExceeded
}

View File

@ -10,6 +10,16 @@ service Agent {
} }
message ExecRequest { message ExecRequest {
message SendSignal {
enum Signal {
SIGNAL_UNSPECIFIED = 0;
SIGNAL_SIGTERM = 1;
SIGNAL_SIGKILL = 2;
}
Signal signal = 1;
}
message Command { message Command {
string name = 1; string name = 1;
repeated string args = 2; repeated string args = 2;
@ -25,6 +35,7 @@ message ExecRequest {
Command command = 1; Command command = 1;
IOChunk standard_input = 2; IOChunk standard_input = 2;
TerminalSize terminal_resize = 3; TerminalSize terminal_resize = 3;
SendSignal send_signal = 4;
} }
} }
@ -33,10 +44,15 @@ message ExecResponse {
int32 code = 1; int32 code = 1;
} }
message Started {
// nothing for now
}
oneof type { oneof type {
Exit exit = 1; Exit exit = 1;
IOChunk standard_output = 2; IOChunk standard_output = 2;
IOChunk standard_error = 3; IOChunk standard_error = 3;
Started started = 4;
} }
} }