diff --git a/gen/fjbellows/agent/v1/agent.pb.go b/gen/fjbellows/agent/v1/agent.pb.go index a62be3c..bb44f06 100644 --- a/gen/fjbellows/agent/v1/agent.pb.go +++ b/gen/fjbellows/agent/v1/agent.pb.go @@ -137,6 +137,781 @@ func (x *HealthResponse) GetHostname() string { return "" } +// ShellMsg is the client→server frame on the Exec bidi stream. Exactly one +// ExecOpen must be the first message; subsequent messages may interleave +// Stdin / Resize / Signal / CloseStdin in any order until the server closes +// the stream after sending Exit. +type ShellMsg struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Kind: + // + // *ShellMsg_Open + // *ShellMsg_Stdin + // *ShellMsg_Resize + // *ShellMsg_Signal + // *ShellMsg_CloseStdin + Kind isShellMsg_Kind `protobuf_oneof:"kind"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ShellMsg) Reset() { + *x = ShellMsg{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ShellMsg) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ShellMsg) ProtoMessage() {} + +func (x *ShellMsg) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ShellMsg.ProtoReflect.Descriptor instead. +func (*ShellMsg) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *ShellMsg) GetKind() isShellMsg_Kind { + if x != nil { + return x.Kind + } + return nil +} + +func (x *ShellMsg) GetOpen() *ExecOpen { + if x != nil { + if x, ok := x.Kind.(*ShellMsg_Open); ok { + return x.Open + } + } + return nil +} + +func (x *ShellMsg) GetStdin() *ExecStdin { + if x != nil { + if x, ok := x.Kind.(*ShellMsg_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ShellMsg) GetResize() *ExecResize { + if x != nil { + if x, ok := x.Kind.(*ShellMsg_Resize); ok { + return x.Resize + } + } + return nil +} + +func (x *ShellMsg) GetSignal() *ExecSignal { + if x != nil { + if x, ok := x.Kind.(*ShellMsg_Signal); ok { + return x.Signal + } + } + return nil +} + +func (x *ShellMsg) GetCloseStdin() *ExecCloseStdin { + if x != nil { + if x, ok := x.Kind.(*ShellMsg_CloseStdin); ok { + return x.CloseStdin + } + } + return nil +} + +type isShellMsg_Kind interface { + isShellMsg_Kind() +} + +type ShellMsg_Open struct { + Open *ExecOpen `protobuf:"bytes,1,opt,name=open,proto3,oneof"` +} + +type ShellMsg_Stdin struct { + Stdin *ExecStdin `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"` +} + +type ShellMsg_Resize struct { + Resize *ExecResize `protobuf:"bytes,3,opt,name=resize,proto3,oneof"` +} + +type ShellMsg_Signal struct { + Signal *ExecSignal `protobuf:"bytes,4,opt,name=signal,proto3,oneof"` +} + +type ShellMsg_CloseStdin struct { + CloseStdin *ExecCloseStdin `protobuf:"bytes,5,opt,name=close_stdin,json=closeStdin,proto3,oneof"` +} + +func (*ShellMsg_Open) isShellMsg_Kind() {} + +func (*ShellMsg_Stdin) isShellMsg_Kind() {} + +func (*ShellMsg_Resize) isShellMsg_Kind() {} + +func (*ShellMsg_Signal) isShellMsg_Kind() {} + +func (*ShellMsg_CloseStdin) isShellMsg_Kind() {} + +// ShellEvent is the server→client frame. Opened arrives first (sentinel, +// matches the StreamEvents convention so the open call returns +// immediately even before fork+exec lands). Stdout and Stderr interleave +// freely. Exit is always last and ends the stream. +type ShellEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Kind: + // + // *ShellEvent_Opened + // *ShellEvent_Stdout + // *ShellEvent_Stderr + // *ShellEvent_Exit + Kind isShellEvent_Kind `protobuf_oneof:"kind"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ShellEvent) Reset() { + *x = ShellEvent{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ShellEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ShellEvent) ProtoMessage() {} + +func (x *ShellEvent) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ShellEvent.ProtoReflect.Descriptor instead. +func (*ShellEvent) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *ShellEvent) GetKind() isShellEvent_Kind { + if x != nil { + return x.Kind + } + return nil +} + +func (x *ShellEvent) GetOpened() *ExecOpened { + if x != nil { + if x, ok := x.Kind.(*ShellEvent_Opened); ok { + return x.Opened + } + } + return nil +} + +func (x *ShellEvent) GetStdout() *ExecStdout { + if x != nil { + if x, ok := x.Kind.(*ShellEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ShellEvent) GetStderr() *ExecStderr { + if x != nil { + if x, ok := x.Kind.(*ShellEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ShellEvent) GetExit() *ExecExit { + if x != nil { + if x, ok := x.Kind.(*ShellEvent_Exit); ok { + return x.Exit + } + } + return nil +} + +type isShellEvent_Kind interface { + isShellEvent_Kind() +} + +type ShellEvent_Opened struct { + Opened *ExecOpened `protobuf:"bytes,1,opt,name=opened,proto3,oneof"` +} + +type ShellEvent_Stdout struct { + Stdout *ExecStdout `protobuf:"bytes,2,opt,name=stdout,proto3,oneof"` +} + +type ShellEvent_Stderr struct { + Stderr *ExecStderr `protobuf:"bytes,3,opt,name=stderr,proto3,oneof"` +} + +type ShellEvent_Exit struct { + Exit *ExecExit `protobuf:"bytes,4,opt,name=exit,proto3,oneof"` +} + +func (*ShellEvent_Opened) isShellEvent_Kind() {} + +func (*ShellEvent_Stdout) isShellEvent_Kind() {} + +func (*ShellEvent_Stderr) isShellEvent_Kind() {} + +func (*ShellEvent_Exit) isShellEvent_Kind() {} + +// ExecOpen is the first ShellMsg on every Exec stream. argv[0] is the +// program to run; an empty argv asks for a login shell (deferred to a +// later phase). env passes additional environment vars beyond the agent +// process's own; $TERM is set from `term` when `tty` is true. +type ExecOpen struct { + state protoimpl.MessageState `protogen:"open.v1"` + // target identifies which worker/cache to run on. This field is only + // meaningful on the fjbctl→orchestrator hop; the orchestrator strips + // it before forwarding the Open to the agent. Empty on the agent hop. + Target string `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` + // argv is the program + args. argv[0] is exec'd directly; no shell + // interpolation. Pass `sh -c "..."` for shell semantics. + Argv []string `protobuf:"bytes,2,rep,name=argv,proto3" json:"argv,omitempty"` + // env is a map of additional environment variables. Empty by default; + // the agent does NOT pass the orchestrator's environment through. + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // tty asks the agent to allocate a PTY for the child and wire stdin/ + // stdout (no separate stderr under PTY) to the PTY master. Phase A + // returns CodeUnimplemented if true; PTY support lands in Phase C. + Tty bool `protobuf:"varint,4,opt,name=tty,proto3" json:"tty,omitempty"` + // term is the $TERM value to set on the child when tty=true. Ignored + // when tty=false. + Term string `protobuf:"bytes,5,opt,name=term,proto3" json:"term,omitempty"` + // winsize is the initial PTY window size when tty=true. Ignored + // otherwise. + Winsize *Winsize `protobuf:"bytes,6,opt,name=winsize,proto3" json:"winsize,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecOpen) Reset() { + *x = ExecOpen{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecOpen) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecOpen) ProtoMessage() {} + +func (x *ExecOpen) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecOpen.ProtoReflect.Descriptor instead. +func (*ExecOpen) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ExecOpen) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ExecOpen) GetArgv() []string { + if x != nil { + return x.Argv + } + return nil +} + +func (x *ExecOpen) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + +func (x *ExecOpen) GetTty() bool { + if x != nil { + return x.Tty + } + return false +} + +func (x *ExecOpen) GetTerm() string { + if x != nil { + return x.Term + } + return "" +} + +func (x *ExecOpen) GetWinsize() *Winsize { + if x != nil { + return x.Winsize + } + return nil +} + +// Winsize carries terminal columns and rows. +type Winsize struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Winsize) Reset() { + *x = Winsize{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Winsize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Winsize) ProtoMessage() {} + +func (x *Winsize) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 Winsize.ProtoReflect.Descriptor instead. +func (*Winsize) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *Winsize) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *Winsize) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +// ExecStdin pumps bytes to the child's stdin. Streaming; the client may +// send any number of these. CloseStdin signals EOF. +type ExecStdin struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStdin) Reset() { + *x = ExecStdin{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStdin) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStdin) ProtoMessage() {} + +func (x *ExecStdin) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_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 ExecStdin.ProtoReflect.Descriptor instead. +func (*ExecStdin) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *ExecStdin) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// ExecStdout / ExecStderr carry bytes from the child's stdout / stderr. +// Frame size is at the server's discretion (default 32 KiB chunks); the +// receiver should not assume line boundaries. +type ExecStdout struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStdout) Reset() { + *x = ExecStdout{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStdout) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStdout) ProtoMessage() {} + +func (x *ExecStdout) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecStdout.ProtoReflect.Descriptor instead. +func (*ExecStdout) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *ExecStdout) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type ExecStderr struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStderr) Reset() { + *x = ExecStderr{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStderr) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStderr) ProtoMessage() {} + +func (x *ExecStderr) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecStderr.ProtoReflect.Descriptor instead. +func (*ExecStderr) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *ExecStderr) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// ExecResize updates the PTY window size mid-session. Only meaningful +// when the Open had tty=true. +type ExecResize struct { + state protoimpl.MessageState `protogen:"open.v1"` + Winsize *Winsize `protobuf:"bytes,1,opt,name=winsize,proto3" json:"winsize,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecResize) Reset() { + *x = ExecResize{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecResize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResize) ProtoMessage() {} + +func (x *ExecResize) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecResize.ProtoReflect.Descriptor instead. +func (*ExecResize) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *ExecResize) GetWinsize() *Winsize { + if x != nil { + return x.Winsize + } + return nil +} + +// ExecSignal forwards a signal to the child's process group. Valid +// names: SIGINT, SIGTERM, SIGQUIT, SIGHUP. Unknown names are ignored +// (logged on the agent side). +type ExecSignal struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSignal) Reset() { + *x = ExecSignal{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSignal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSignal) ProtoMessage() {} + +func (x *ExecSignal) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_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 ExecSignal.ProtoReflect.Descriptor instead. +func (*ExecSignal) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *ExecSignal) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// ExecCloseStdin closes the child's stdin (EOF on the read side). The +// stream stays open for stdout/stderr and the terminal Exit. +type ExecCloseStdin struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecCloseStdin) Reset() { + *x = ExecCloseStdin{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecCloseStdin) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecCloseStdin) ProtoMessage() {} + +func (x *ExecCloseStdin) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecCloseStdin.ProtoReflect.Descriptor instead. +func (*ExecCloseStdin) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{11} +} + +// ExecOpened is the sentinel server→client frame that fires immediately +// after the agent accepts the Open and begins setting up the child. The +// existence of this frame is what lets the client's stream-open call +// return without waiting for fork+exec to complete. +type ExecOpened struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecOpened) Reset() { + *x = ExecOpened{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecOpened) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecOpened) ProtoMessage() {} + +func (x *ExecOpened) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecOpened.ProtoReflect.Descriptor instead. +func (*ExecOpened) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{12} +} + +// ExecExit is the terminal server→client frame. Exactly one of +// (code, signal) is meaningful: code is the program's exit status if it +// exited normally; signal is the signal name (e.g. "terminated", +// "killed") if it was killed by a signal. The agent closes the stream +// immediately after sending this. +type ExecExit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // code is the child's exit status when it exited normally. + Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` + // signal is the signal name when the child was killed by a signal. + // Empty when code is meaningful. + Signal string `protobuf:"bytes,2,opt,name=signal,proto3" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecExit) Reset() { + *x = ExecExit{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecExit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecExit) ProtoMessage() {} + +func (x *ExecExit) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_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 ExecExit.ProtoReflect.Descriptor instead. +func (*ExecExit) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *ExecExit) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *ExecExit) GetSignal() string { + if x != nil { + return x.Signal + } + return "" +} + var File_fjbellows_agent_v1_agent_proto protoreflect.FileDescriptor const file_fjbellows_agent_v1_agent_proto_rawDesc = "" + @@ -148,9 +923,58 @@ const file_fjbellows_agent_v1_agent_proto_rawDesc = "" + "\rbuild_version\x18\x02 \x01(\tR\fbuildVersion\x129\n" + "\n" + "started_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12\x1a\n" + - "\bhostname\x18\x04 \x01(\tR\bhostname2_\n" + + "\bhostname\x18\x04 \x01(\tR\bhostname\"\xb8\x02\n" + + "\bShellMsg\x122\n" + + "\x04open\x18\x01 \x01(\v2\x1c.fjbellows.agent.v1.ExecOpenH\x00R\x04open\x125\n" + + "\x05stdin\x18\x02 \x01(\v2\x1d.fjbellows.agent.v1.ExecStdinH\x00R\x05stdin\x128\n" + + "\x06resize\x18\x03 \x01(\v2\x1e.fjbellows.agent.v1.ExecResizeH\x00R\x06resize\x128\n" + + "\x06signal\x18\x04 \x01(\v2\x1e.fjbellows.agent.v1.ExecSignalH\x00R\x06signal\x12E\n" + + "\vclose_stdin\x18\x05 \x01(\v2\".fjbellows.agent.v1.ExecCloseStdinH\x00R\n" + + "closeStdinB\x06\n" + + "\x04kind\"\xf6\x01\n" + + "\n" + + "ShellEvent\x128\n" + + "\x06opened\x18\x01 \x01(\v2\x1e.fjbellows.agent.v1.ExecOpenedH\x00R\x06opened\x128\n" + + "\x06stdout\x18\x02 \x01(\v2\x1e.fjbellows.agent.v1.ExecStdoutH\x00R\x06stdout\x128\n" + + "\x06stderr\x18\x03 \x01(\v2\x1e.fjbellows.agent.v1.ExecStderrH\x00R\x06stderr\x122\n" + + "\x04exit\x18\x04 \x01(\v2\x1c.fjbellows.agent.v1.ExecExitH\x00R\x04exitB\x06\n" + + "\x04kind\"\x84\x02\n" + + "\bExecOpen\x12\x16\n" + + "\x06target\x18\x01 \x01(\tR\x06target\x12\x12\n" + + "\x04argv\x18\x02 \x03(\tR\x04argv\x127\n" + + "\x03env\x18\x03 \x03(\v2%.fjbellows.agent.v1.ExecOpen.EnvEntryR\x03env\x12\x10\n" + + "\x03tty\x18\x04 \x01(\bR\x03tty\x12\x12\n" + + "\x04term\x18\x05 \x01(\tR\x04term\x125\n" + + "\awinsize\x18\x06 \x01(\v2\x1b.fjbellows.agent.v1.WinsizeR\awinsize\x1a6\n" + + "\bEnvEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"1\n" + + "\aWinsize\x12\x12\n" + + "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x02 \x01(\rR\x04rows\"\x1f\n" + + "\tExecStdin\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\" \n" + + "\n" + + "ExecStdout\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\" \n" + + "\n" + + "ExecStderr\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"C\n" + + "\n" + + "ExecResize\x125\n" + + "\awinsize\x18\x01 \x01(\v2\x1b.fjbellows.agent.v1.WinsizeR\awinsize\" \n" + + "\n" + + "ExecSignal\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x10\n" + + "\x0eExecCloseStdin\"\f\n" + + "\n" + + "ExecOpened\"6\n" + + "\bExecExit\x12\x12\n" + + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x16\n" + + "\x06signal\x18\x02 \x01(\tR\x06signal2\xa9\x01\n" + "\fAgentService\x12O\n" + - "\x06Health\x12!.fjbellows.agent.v1.HealthRequest\x1a\".fjbellows.agent.v1.HealthResponseB\xcb\x01\n" + + "\x06Health\x12!.fjbellows.agent.v1.HealthRequest\x1a\".fjbellows.agent.v1.HealthResponse\x12H\n" + + "\x04Exec\x12\x1c.fjbellows.agent.v1.ShellMsg\x1a\x1e.fjbellows.agent.v1.ShellEvent(\x010\x01B\xcb\x01\n" + "\x16com.fjbellows.agent.v1B\n" + "AgentProtoP\x01Z;github.com/hstern/fj-bellows/gen/fjbellows/agent/v1;agentv1\xa2\x02\x03FAX\xaa\x02\x12Fjbellows.Agent.V1\xca\x02\x12Fjbellows\\Agent\\V1\xe2\x02\x1eFjbellows\\Agent\\V1\\GPBMetadata\xea\x02\x14Fjbellows::Agent::V1b\x06proto3" @@ -166,21 +990,48 @@ func file_fjbellows_agent_v1_agent_proto_rawDescGZIP() []byte { return file_fjbellows_agent_v1_agent_proto_rawDescData } -var file_fjbellows_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_fjbellows_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_fjbellows_agent_v1_agent_proto_goTypes = []any{ (*HealthRequest)(nil), // 0: fjbellows.agent.v1.HealthRequest (*HealthResponse)(nil), // 1: fjbellows.agent.v1.HealthResponse - (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp + (*ShellMsg)(nil), // 2: fjbellows.agent.v1.ShellMsg + (*ShellEvent)(nil), // 3: fjbellows.agent.v1.ShellEvent + (*ExecOpen)(nil), // 4: fjbellows.agent.v1.ExecOpen + (*Winsize)(nil), // 5: fjbellows.agent.v1.Winsize + (*ExecStdin)(nil), // 6: fjbellows.agent.v1.ExecStdin + (*ExecStdout)(nil), // 7: fjbellows.agent.v1.ExecStdout + (*ExecStderr)(nil), // 8: fjbellows.agent.v1.ExecStderr + (*ExecResize)(nil), // 9: fjbellows.agent.v1.ExecResize + (*ExecSignal)(nil), // 10: fjbellows.agent.v1.ExecSignal + (*ExecCloseStdin)(nil), // 11: fjbellows.agent.v1.ExecCloseStdin + (*ExecOpened)(nil), // 12: fjbellows.agent.v1.ExecOpened + (*ExecExit)(nil), // 13: fjbellows.agent.v1.ExecExit + nil, // 14: fjbellows.agent.v1.ExecOpen.EnvEntry + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp } var file_fjbellows_agent_v1_agent_proto_depIdxs = []int32{ - 2, // 0: fjbellows.agent.v1.HealthResponse.started_at:type_name -> google.protobuf.Timestamp - 0, // 1: fjbellows.agent.v1.AgentService.Health:input_type -> fjbellows.agent.v1.HealthRequest - 1, // 2: fjbellows.agent.v1.AgentService.Health:output_type -> fjbellows.agent.v1.HealthResponse - 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 + 15, // 0: fjbellows.agent.v1.HealthResponse.started_at:type_name -> google.protobuf.Timestamp + 4, // 1: fjbellows.agent.v1.ShellMsg.open:type_name -> fjbellows.agent.v1.ExecOpen + 6, // 2: fjbellows.agent.v1.ShellMsg.stdin:type_name -> fjbellows.agent.v1.ExecStdin + 9, // 3: fjbellows.agent.v1.ShellMsg.resize:type_name -> fjbellows.agent.v1.ExecResize + 10, // 4: fjbellows.agent.v1.ShellMsg.signal:type_name -> fjbellows.agent.v1.ExecSignal + 11, // 5: fjbellows.agent.v1.ShellMsg.close_stdin:type_name -> fjbellows.agent.v1.ExecCloseStdin + 12, // 6: fjbellows.agent.v1.ShellEvent.opened:type_name -> fjbellows.agent.v1.ExecOpened + 7, // 7: fjbellows.agent.v1.ShellEvent.stdout:type_name -> fjbellows.agent.v1.ExecStdout + 8, // 8: fjbellows.agent.v1.ShellEvent.stderr:type_name -> fjbellows.agent.v1.ExecStderr + 13, // 9: fjbellows.agent.v1.ShellEvent.exit:type_name -> fjbellows.agent.v1.ExecExit + 14, // 10: fjbellows.agent.v1.ExecOpen.env:type_name -> fjbellows.agent.v1.ExecOpen.EnvEntry + 5, // 11: fjbellows.agent.v1.ExecOpen.winsize:type_name -> fjbellows.agent.v1.Winsize + 5, // 12: fjbellows.agent.v1.ExecResize.winsize:type_name -> fjbellows.agent.v1.Winsize + 0, // 13: fjbellows.agent.v1.AgentService.Health:input_type -> fjbellows.agent.v1.HealthRequest + 2, // 14: fjbellows.agent.v1.AgentService.Exec:input_type -> fjbellows.agent.v1.ShellMsg + 1, // 15: fjbellows.agent.v1.AgentService.Health:output_type -> fjbellows.agent.v1.HealthResponse + 3, // 16: fjbellows.agent.v1.AgentService.Exec:output_type -> fjbellows.agent.v1.ShellEvent + 15, // [15:17] is the sub-list for method output_type + 13, // [13:15] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_fjbellows_agent_v1_agent_proto_init() } @@ -188,13 +1039,26 @@ func file_fjbellows_agent_v1_agent_proto_init() { if File_fjbellows_agent_v1_agent_proto != nil { return } + file_fjbellows_agent_v1_agent_proto_msgTypes[2].OneofWrappers = []any{ + (*ShellMsg_Open)(nil), + (*ShellMsg_Stdin)(nil), + (*ShellMsg_Resize)(nil), + (*ShellMsg_Signal)(nil), + (*ShellMsg_CloseStdin)(nil), + } + file_fjbellows_agent_v1_agent_proto_msgTypes[3].OneofWrappers = []any{ + (*ShellEvent_Opened)(nil), + (*ShellEvent_Stdout)(nil), + (*ShellEvent_Stderr)(nil), + (*ShellEvent_Exit)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_fjbellows_agent_v1_agent_proto_rawDesc), len(file_fjbellows_agent_v1_agent_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 15, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go b/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go index c13e1fd..c62c4a4 100644 --- a/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go +++ b/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go @@ -35,6 +35,8 @@ const ( const ( // AgentServiceHealthProcedure is the fully-qualified name of the AgentService's Health RPC. AgentServiceHealthProcedure = "/fjbellows.agent.v1.AgentService/Health" + // AgentServiceExecProcedure is the fully-qualified name of the AgentService's Exec RPC. + AgentServiceExecProcedure = "/fjbellows.agent.v1.AgentService/Exec" ) // AgentServiceClient is a client for the fjbellows.agent.v1.AgentService service. @@ -45,6 +47,17 @@ type AgentServiceClient interface { // when the call reaches the handler — the field exists so callers can // grep one boolean instead of inspecting the absence of an error. Health(context.Context, *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) + // Exec runs a single program on the target. Bidi streaming carries + // stdin/stdout/stderr/exit-code + signals in the framed envelope below. + // The orchestrator opens a matching bidi stream to fjbctl and copies + // frames between the two without reinterpretation; see FJB-93 design + // doc (docs/designs/remote-shell.md) for the end-to-end picture. + // + // Phase A (FJB-93 Phase A) of this RPC is **pipe-mode only**: the + // handler refuses ExecOpen.tty=true with CodeUnimplemented. PTY + + // window resize + interactive shell come in Phase C, on top of the + // same envelope. + Exec(context.Context) *connect.BidiStreamForClient[v1.ShellMsg, v1.ShellEvent] } // NewAgentServiceClient constructs a client for the fjbellows.agent.v1.AgentService service. By @@ -64,12 +77,19 @@ func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(agentServiceMethods.ByName("Health")), connect.WithClientOptions(opts...), ), + exec: connect.NewClient[v1.ShellMsg, v1.ShellEvent]( + httpClient, + baseURL+AgentServiceExecProcedure, + connect.WithSchema(agentServiceMethods.ByName("Exec")), + connect.WithClientOptions(opts...), + ), } } // agentServiceClient implements AgentServiceClient. type agentServiceClient struct { health *connect.Client[v1.HealthRequest, v1.HealthResponse] + exec *connect.Client[v1.ShellMsg, v1.ShellEvent] } // Health calls fjbellows.agent.v1.AgentService.Health. @@ -77,6 +97,11 @@ func (c *agentServiceClient) Health(ctx context.Context, req *connect.Request[v1 return c.health.CallUnary(ctx, req) } +// Exec calls fjbellows.agent.v1.AgentService.Exec. +func (c *agentServiceClient) Exec(ctx context.Context) *connect.BidiStreamForClient[v1.ShellMsg, v1.ShellEvent] { + return c.exec.CallBidiStream(ctx) +} + // AgentServiceHandler is an implementation of the fjbellows.agent.v1.AgentService service. type AgentServiceHandler interface { // Health returns a readiness snapshot of the agent process. Used by the @@ -85,6 +110,17 @@ type AgentServiceHandler interface { // when the call reaches the handler — the field exists so callers can // grep one boolean instead of inspecting the absence of an error. Health(context.Context, *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) + // Exec runs a single program on the target. Bidi streaming carries + // stdin/stdout/stderr/exit-code + signals in the framed envelope below. + // The orchestrator opens a matching bidi stream to fjbctl and copies + // frames between the two without reinterpretation; see FJB-93 design + // doc (docs/designs/remote-shell.md) for the end-to-end picture. + // + // Phase A (FJB-93 Phase A) of this RPC is **pipe-mode only**: the + // handler refuses ExecOpen.tty=true with CodeUnimplemented. PTY + + // window resize + interactive shell come in Phase C, on top of the + // same envelope. + Exec(context.Context, *connect.BidiStream[v1.ShellMsg, v1.ShellEvent]) error } // NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -100,10 +136,18 @@ func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(agentServiceMethods.ByName("Health")), connect.WithHandlerOptions(opts...), ) + agentServiceExecHandler := connect.NewBidiStreamHandler( + AgentServiceExecProcedure, + svc.Exec, + connect.WithSchema(agentServiceMethods.ByName("Exec")), + connect.WithHandlerOptions(opts...), + ) return "/fjbellows.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AgentServiceHealthProcedure: agentServiceHealthHandler.ServeHTTP(w, r) + case AgentServiceExecProcedure: + agentServiceExecHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -116,3 +160,7 @@ type UnimplementedAgentServiceHandler struct{} func (UnimplementedAgentServiceHandler) Health(context.Context, *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("fjbellows.agent.v1.AgentService.Health is not implemented")) } + +func (UnimplementedAgentServiceHandler) Exec(context.Context, *connect.BidiStream[v1.ShellMsg, v1.ShellEvent]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("fjbellows.agent.v1.AgentService.Exec is not implemented")) +} diff --git a/internal/agent/exec.go b/internal/agent/exec.go new file mode 100644 index 0000000..c2d4792 --- /dev/null +++ b/internal/agent/exec.go @@ -0,0 +1,307 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os/exec" + "syscall" + + "connectrpc.com/connect" + + agentv1 "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1" +) + +// Exec implements AgentService.Exec for the agent. Phase A (FJB-93) +// supports **pipe mode only** — ExecOpen.tty=true is rejected with +// CodeUnimplemented. PTY allocation and window-resize handling land in +// FJB-93 Phase C. +// +// Concurrency: the handler launches three goroutines that share the +// stream's Send side (stdout pump, stderr pump, terminal Exit emit) and +// one that owns Receive. Send-side serialization goes through a buffered +// channel + single sender goroutine; that avoids manual mutexing of +// connect.BidiStream.Send and gives us bounded backpressure for free. +func (h *Handler) Exec(ctx context.Context, stream *connect.BidiStream[agentv1.ShellMsg, agentv1.ShellEvent]) error { + open, err := receiveOpen(stream) + if err != nil { + return err + } + + if open.GetTty() { + return connect.NewError(connect.CodeUnimplemented, errors.New("PTY mode is not yet implemented (FJB-93 Phase C)")) + } + if len(open.GetArgv()) == 0 { + return connect.NewError(connect.CodeInvalidArgument, errors.New("argv must be non-empty")) + } + + // Single send goroutine owns the stream's send side. Buffer is sized + // so a brief stall on the network doesn't immediately backpressure + // the stdout/stderr pumps inside the agent. + sendBuf := make(chan *agentv1.ShellEvent, 64) + sendDone := make(chan struct{}) + sendErr := make(chan error, 1) + go runSender(stream, sendBuf, sendDone, sendErr) + + // Sentinel frame matches the StreamEvents convention — open call + // returns immediately even before fork+exec completes. + sendBuf <- &agentv1.ShellEvent{ + Kind: &agentv1.ShellEvent_Opened{Opened: &agentv1.ExecOpened{}}, + } + + // Build environment: agent process env is NOT inherited; only the + // explicit map in Open is set. Matches the design doc's "no implicit + // env passthrough" rule. + env := make([]string, 0, len(open.GetEnv())) + for k, v := range open.GetEnv() { + env = append(env, k+"="+v) + } + + //nolint:gosec // G204: argv is supplied by an authenticated orchestrator caller; that's the whole point of this RPC. + cmd := exec.CommandContext(ctx, open.GetArgv()[0], open.GetArgv()[1:]...) + cmd.Env = env + // New process group so signals can be delivered to the whole tree + // (sh -c "..." spawning children, for example). + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + // Wire stdout/stderr via custom Writers rather than StdoutPipe / + // StderrPipe. exec.Cmd documents that Wait closes pipes it created, + // which races our pump goroutines on fast-exiting commands; the + // Writer path uses exec's internal copy goroutines instead, which + // Wait blocks on before returning. That makes the "all output + // flushed before Exit" ordering structural rather than timing-based. + cmd.Stdout = &streamWriter{ch: sendBuf, wrap: asStdout} + cmd.Stderr = &streamWriter{ch: sendBuf, wrap: asStderr} + + stdinW, err := cmd.StdinPipe() + if err != nil { + close(sendBuf) + <-sendDone + return connect.NewError(connect.CodeInternal, fmt.Errorf("stdin pipe: %w", err)) + } + + if err := cmd.Start(); err != nil { + // Mirror sh's convention: command-not-found is exit 127. + _ = stdinW.Close() + sendBuf <- exitEvent(127, "") + close(sendBuf) + <-sendDone + // The RPC itself succeeded; the child's failure is on-stream. + //nolint:nilerr // start failure is reported as on-stream Exit{127}, not as an RPC error + return nil + } + + // Client-side reader: stdin frames, signals, CloseStdin. Runs until + // the client closes its send side OR the child exits. Returns the + // receive error so the outer scope can decide whether to log it. + recvDone := make(chan struct{}) + go func() { + defer close(recvDone) + runReceiver(stream, cmd, stdinW) + }() + + // Wait for child to exit. cmd.Wait blocks until both the child has + // exited AND exec's internal stdout/stderr copy goroutines have + // drained — so all output is in the sendBuf channel before this + // returns. The CommandContext-bound cancellation covers the "RPC + // ctx canceled" case: the kernel sends SIGKILL to the process group + // and Wait returns shortly after. + waitErr := cmd.Wait() + + sendBuf <- buildExitEvent(waitErr) + close(sendBuf) + + // The receiver goroutine may still be blocked on stream.Receive(). + // On the agent side we don't have a way to forcibly close the + // receive half from a handler; rely on the client to close its send + // side or on the transport to tear down when we return. Either way, + // the goroutine exits when Receive returns an error. + _ = recvDone + + <-sendDone + if err := drainSendErr(sendErr); err != nil { + slog.Default().Debug("agent exec send error", "err", err) + } + return nil +} + +// receiveOpen reads the first frame and asserts it is an ExecOpen. The +// design doc says Open must be the first message exactly once; anything +// else here is a client bug we surface as InvalidArgument. +func receiveOpen(stream *connect.BidiStream[agentv1.ShellMsg, agentv1.ShellEvent]) (*agentv1.ExecOpen, error) { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stream closed before ExecOpen")) + } + return nil, err + } + open, ok := msg.GetKind().(*agentv1.ShellMsg_Open) + if !ok || open.Open == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("first frame must be ExecOpen")) + } + return open.Open, nil +} + +// runSender owns the stream's send side. Reads events from sendBuf and +// forwards each to stream.Send. Records the first send error and exits +// when sendBuf is closed. +func runSender(stream *connect.BidiStream[agentv1.ShellMsg, agentv1.ShellEvent], sendBuf <-chan *agentv1.ShellEvent, done chan<- struct{}, errCh chan<- error) { + defer close(done) + for ev := range sendBuf { + if err := stream.Send(ev); err != nil { + select { + case errCh <- err: + default: + } + // Drain the channel so producers don't block, but don't + // send anything else — the stream is dead. + for range sendBuf { //nolint:revive // intentional drain + } + return + } + } +} + +// streamWriter is an io.Writer that emits each Write as a single +// ShellEvent into sendBuf. exec.Cmd's internal copy loop calls Write +// with chunks up to ~32 KiB by default, which lines up with the design +// doc's frame budget. The buffer cannot be reused across Writes since +// Connect's marshaller may reference it asynchronously, so we copy. +type streamWriter struct { + ch chan<- *agentv1.ShellEvent + wrap func([]byte) *agentv1.ShellEvent +} + +func (w *streamWriter) Write(p []byte) (int, error) { + chunk := make([]byte, len(p)) + copy(chunk, p) + w.ch <- w.wrap(chunk) + return len(p), nil +} + +func asStdout(data []byte) *agentv1.ShellEvent { + return &agentv1.ShellEvent{Kind: &agentv1.ShellEvent_Stdout{Stdout: &agentv1.ExecStdout{Data: data}}} +} + +func asStderr(data []byte) *agentv1.ShellEvent { + return &agentv1.ShellEvent{Kind: &agentv1.ShellEvent_Stderr{Stderr: &agentv1.ExecStderr{Data: data}}} +} + +// runReceiver processes client→server frames after Open: Stdin (write to +// child stdin), Signal (deliver to process group), CloseStdin (close +// child stdin EOF), Resize (logged + ignored in pipe mode). +func runReceiver(stream *connect.BidiStream[agentv1.ShellMsg, agentv1.ShellEvent], cmd *exec.Cmd, stdinW io.WriteCloser) { + for { + msg, err := stream.Receive() + if err != nil { + // Either the client closed send-side cleanly (io.EOF) or + // the transport died. Close stdin so the child sees EOF + // in either case; if the child already exited this is a + // no-op on an already-closed pipe. + _ = stdinW.Close() + return + } + switch kind := msg.GetKind().(type) { + case *agentv1.ShellMsg_Stdin: + if kind.Stdin == nil { + continue + } + if _, werr := stdinW.Write(kind.Stdin.GetData()); werr != nil { + // Child closed stdin (probably exited). Drop further + // stdin frames silently. + _ = stdinW.Close() + } + case *agentv1.ShellMsg_CloseStdin: + _ = stdinW.Close() + case *agentv1.ShellMsg_Signal: + deliverSignal(cmd, kind.Signal.GetName()) + case *agentv1.ShellMsg_Resize: + // Pipe mode: Resize is a no-op. Log at debug so a curious + // operator can confirm the frame arrived. + slog.Default().Debug("agent exec: ignoring Resize in pipe mode") + case *agentv1.ShellMsg_Open: + // A second Open is a protocol violation; close stdin to + // nudge the child to exit and let cmd.Wait clean up. + slog.Default().Warn("agent exec: duplicate ExecOpen frame; closing stdin") + _ = stdinW.Close() + return + } + } +} + +// deliverSignal sends a signal to the child's process group. Unknown +// names are dropped (logged). The Setpgid setup in Exec means the +// negative-pid kill targets every descendant. +func deliverSignal(cmd *exec.Cmd, name string) { + sig := signalByName(name) + if sig == 0 { + slog.Default().Debug("agent exec: unknown signal name", "name", name) + return + } + if cmd.Process == nil { + return + } + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err != nil { + // Fall back to single-process kill. + _ = cmd.Process.Signal(sig) + return + } + _ = syscall.Kill(-pgid, sig) +} + +func signalByName(name string) syscall.Signal { + switch name { + case "SIGINT": + return syscall.SIGINT + case "SIGTERM": + return syscall.SIGTERM + case "SIGQUIT": + return syscall.SIGQUIT + case "SIGHUP": + return syscall.SIGHUP + default: + return 0 + } +} + +func exitEvent(code int32, sig string) *agentv1.ShellEvent { + return &agentv1.ShellEvent{ + Kind: &agentv1.ShellEvent_Exit{Exit: &agentv1.ExecExit{Code: code, Signal: sig}}, + } +} + +// buildExitEvent inspects cmd.Wait's error and produces the terminal +// ExecExit frame. Three cases: clean exit, signal kill, other error. +func buildExitEvent(waitErr error) *agentv1.ShellEvent { + if waitErr == nil { + return exitEvent(0, "") + } + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + ws, ok := exitErr.Sys().(syscall.WaitStatus) + if ok { + if ws.Signaled() { + return exitEvent(int32(128+int(ws.Signal())), ws.Signal().String()) //nolint:gosec // signum is 1..31, no overflow + } + return exitEvent(int32(ws.ExitStatus()), "") //nolint:gosec // exit code is 0..255 on Linux + } + return exitEvent(int32(exitErr.ExitCode()), "") //nolint:gosec // ExitCode is 0..255 on POSIX + } + // Unknown error from Wait — surface as a non-zero exit so the + // client doesn't conclude success. + return exitEvent(-1, "") +} + +// drainSendErr returns the first send error (if any) without blocking. +func drainSendErr(errCh <-chan error) error { + select { + case err := <-errCh: + return err + default: + return nil + } +} diff --git a/internal/agent/exec_test.go b/internal/agent/exec_test.go new file mode 100644 index 0000000..ee557c9 --- /dev/null +++ b/internal/agent/exec_test.go @@ -0,0 +1,315 @@ +package agent + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + + agentv1 "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1" + "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1/agentv1connect" +) + +// execTestSetup spins up an in-process agent server reachable via H2C +// (Connect bidi streaming needs HTTP/2). Returns a connected client and +// a teardown. +func execTestSetup(t *testing.T) (agentv1connect.AgentServiceClient, func()) { + t.Helper() + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skipf("exec tests need a POSIX-y OS, not %s", runtime.GOOS) + } + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil) + + ts := httptest.NewUnstartedServer(srv.Handler()) + ts.EnableHTTP2 = true + ts.StartTLS() // bidi streaming requires HTTP/2; httptest's H2 is over TLS + + client := agentv1connect.NewAgentServiceClient(ts.Client(), ts.URL) + return client, ts.Close +} + +// runExec opens an Exec stream, sends Open, then optionally drives the +// stream via `drive`, then drains events until Exit. Returns the +// captured stdout, stderr, exit code, signal name, and any RPC error. +func runExec(t *testing.T, client agentv1connect.AgentServiceClient, open *agentv1.ExecOpen, drive func(*connect.BidiStreamForClient[agentv1.ShellMsg, agentv1.ShellEvent])) (stdout, stderr string, code int32, sig string, rpcErr error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + stream := client.Exec(ctx) + defer func() { _ = stream.CloseRequest() }() + + if err := stream.Send(&agentv1.ShellMsg{Kind: &agentv1.ShellMsg_Open{Open: open}}); err != nil { + return "", "", 0, "", err + } + + if drive != nil { + drive(stream) + } + + var outBuf, errBuf strings.Builder + for { + ev, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return outBuf.String(), errBuf.String(), code, sig, err + } + switch k := ev.GetKind().(type) { + case *agentv1.ShellEvent_Opened: + // sentinel; ignore. + case *agentv1.ShellEvent_Stdout: + outBuf.Write(k.Stdout.GetData()) + case *agentv1.ShellEvent_Stderr: + errBuf.Write(k.Stderr.GetData()) + case *agentv1.ShellEvent_Exit: + code = k.Exit.GetCode() + sig = k.Exit.GetSignal() + return outBuf.String(), errBuf.String(), code, sig, nil + } + } + return outBuf.String(), errBuf.String(), code, sig, nil +} + +func TestExec_Echo(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + out, errOut, code, sig, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"echo", "hello"}}, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if out != "hello\n" { + t.Errorf("stdout = %q, want %q", out, "hello\n") + } + if errOut != "" { + t.Errorf("stderr = %q, want empty", errOut) + } + if code != 0 || sig != "" { + t.Errorf("exit = %d / %q, want 0 / empty", code, sig) + } +} + +func TestExec_StdinThenClose(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + drive := func(s *connect.BidiStreamForClient[agentv1.ShellMsg, agentv1.ShellEvent]) { + _ = s.Send(&agentv1.ShellMsg{Kind: &agentv1.ShellMsg_Stdin{ + Stdin: &agentv1.ExecStdin{Data: []byte("hi cat\n")}, + }}) + _ = s.Send(&agentv1.ShellMsg{Kind: &agentv1.ShellMsg_CloseStdin{ + CloseStdin: &agentv1.ExecCloseStdin{}, + }}) + } + out, _, code, sig, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"cat"}}, drive) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if out != "hi cat\n" { + t.Errorf("stdout = %q, want %q", out, "hi cat\n") + } + if code != 0 || sig != "" { + t.Errorf("exit = %d / %q, want 0 / empty", code, sig) + } +} + +func TestExec_NonZeroExit(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + _, _, code, sig, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"sh", "-c", "exit 7"}}, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if code != 7 || sig != "" { + t.Errorf("exit = %d / %q, want 7 / empty", code, sig) + } +} + +func TestExec_Stderr(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + out, errOut, code, _, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"sh", "-c", "echo to-err >&2"}}, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if out != "" { + t.Errorf("stdout = %q, want empty", out) + } + if errOut != "to-err\n" { + t.Errorf("stderr = %q, want %q", errOut, "to-err\n") + } + if code != 0 { + t.Errorf("exit = %d, want 0", code) + } +} + +func TestExec_EnvPassthrough(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + out, _, code, _, err := runExec(t, client, + &agentv1.ExecOpen{ + Argv: []string{"sh", "-c", "echo FOO=$FOO; echo PATH_LEN=${#PATH}"}, + Env: map[string]string{"FOO": "bar", "PATH": "/usr/bin:/bin"}, + }, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if !strings.Contains(out, "FOO=bar") { + t.Errorf("stdout = %q, want it to contain FOO=bar", out) + } + if code != 0 { + t.Errorf("exit = %d, want 0", code) + } +} + +func TestExec_RejectsTTY(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + _, _, _, _, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"echo"}, Tty: true}, nil) + if err == nil { + t.Fatal("Exec with tty=true succeeded, want CodeUnimplemented") + } + if connect.CodeOf(err) != connect.CodeUnimplemented { + t.Errorf("code = %v, want CodeUnimplemented; err = %v", connect.CodeOf(err), err) + } +} + +func TestExec_RejectsEmptyArgv(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + _, _, _, _, err := runExec(t, client, + &agentv1.ExecOpen{Argv: nil}, nil) + if err == nil { + t.Fatal("Exec with empty argv succeeded, want CodeInvalidArgument") + } + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("code = %v, want CodeInvalidArgument; err = %v", connect.CodeOf(err), err) + } +} + +func TestExec_CommandNotFound(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + _, _, code, sig, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"/no/such/binary/fjb-test-not-real"}}, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if code != 127 || sig != "" { + t.Errorf("exit = %d / %q, want 127 / empty", code, sig) + } +} + +func TestExec_SignalKill(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + drive := func(s *connect.BidiStreamForClient[agentv1.ShellMsg, agentv1.ShellEvent]) { + // Give the child a moment to actually be exec'd before we + // signal it; otherwise the signal might race fork+exec and + // land on a process that hasn't yet replaced itself with + // sleep. + time.Sleep(200 * time.Millisecond) + _ = s.Send(&agentv1.ShellMsg{Kind: &agentv1.ShellMsg_Signal{ + Signal: &agentv1.ExecSignal{Name: "SIGTERM"}, + }}) + } + _, _, code, sig, err := runExec(t, client, + &agentv1.ExecOpen{Argv: []string{"sleep", "30"}}, drive) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if sig == "" { + t.Errorf("signal = %q, want non-empty (process should have been killed); code = %d", sig, code) + } + // Exit code convention for signal kill: 128 + signum. SIGTERM is 15. + if code != 128+15 { + t.Logf("exit code = %d (informational; convention is 128+signum)", code) + } +} + +func TestExec_FirstFrameMustBeOpen(t *testing.T) { + t.Parallel() + client, teardown := execTestSetup(t) + defer teardown() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stream := client.Exec(ctx) + defer func() { _ = stream.CloseRequest() }() + + // Send Stdin before Open — protocol violation. + if err := stream.Send(&agentv1.ShellMsg{Kind: &agentv1.ShellMsg_Stdin{ + Stdin: &agentv1.ExecStdin{Data: []byte("nope")}, + }}); err != nil { + t.Fatalf("Send Stdin: %v", err) + } + _, err := stream.Receive() + if err == nil { + t.Fatal("Receive succeeded after pre-Open Stdin, want CodeInvalidArgument") + } + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("code = %v, want CodeInvalidArgument; err = %v", connect.CodeOf(err), err) + } +} + +// TestExec_BearerAuth confirms the Exec RPC is gated by the same bearer +// middleware as Health (the wrap is shared at the http.Handler layer). +func TestExec_BearerAuth(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil, WithBearerToken("s3cret")) + + ts := httptest.NewUnstartedServer(srv.Handler()) + ts.EnableHTTP2 = true + ts.StartTLS() + defer ts.Close() + + client := agentv1connect.NewAgentServiceClient(ts.Client(), ts.URL) + stream := client.Exec(context.Background()) + defer func() { _ = stream.CloseRequest() }() + _ = stream.Send(&agentv1.ShellMsg{Kind: &agentv1.ShellMsg_Open{ + Open: &agentv1.ExecOpen{Argv: []string{"echo"}}, + }}) + _, err := stream.Receive() + if err == nil { + t.Fatal("Exec succeeded without token, want CodeUnauthenticated") + } + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Errorf("code = %v, want CodeUnauthenticated", connect.CodeOf(err)) + } +} + +// Confirm we're not importing http blindly. +var _ = http.NoBody diff --git a/proto/fjbellows/agent/v1/agent.proto b/proto/fjbellows/agent/v1/agent.proto index bb15ce0..3ae137d 100644 --- a/proto/fjbellows/agent/v1/agent.proto +++ b/proto/fjbellows/agent/v1/agent.proto @@ -26,6 +26,18 @@ service AgentService { // when the call reaches the handler — the field exists so callers can // grep one boolean instead of inspecting the absence of an error. rpc Health(HealthRequest) returns (HealthResponse); + + // Exec runs a single program on the target. Bidi streaming carries + // stdin/stdout/stderr/exit-code + signals in the framed envelope below. + // The orchestrator opens a matching bidi stream to fjbctl and copies + // frames between the two without reinterpretation; see FJB-93 design + // doc (docs/designs/remote-shell.md) for the end-to-end picture. + // + // Phase A (FJB-93 Phase A) of this RPC is **pipe-mode only**: the + // handler refuses ExecOpen.tty=true with CodeUnimplemented. PTY + + // window resize + interactive shell come in Phase C, on top of the + // same envelope. + rpc Exec(stream ShellMsg) returns (stream ShellEvent); } message HealthRequest {} @@ -50,3 +62,121 @@ message HealthResponse { // VM actually thinks it is. string hostname = 4; } + +// ShellMsg is the client→server frame on the Exec bidi stream. Exactly one +// ExecOpen must be the first message; subsequent messages may interleave +// Stdin / Resize / Signal / CloseStdin in any order until the server closes +// the stream after sending Exit. +message ShellMsg { + oneof kind { + ExecOpen open = 1; + ExecStdin stdin = 2; + ExecResize resize = 3; + ExecSignal signal = 4; + ExecCloseStdin close_stdin = 5; + } +} + +// ShellEvent is the server→client frame. Opened arrives first (sentinel, +// matches the StreamEvents convention so the open call returns +// immediately even before fork+exec lands). Stdout and Stderr interleave +// freely. Exit is always last and ends the stream. +message ShellEvent { + oneof kind { + ExecOpened opened = 1; + ExecStdout stdout = 2; + ExecStderr stderr = 3; + ExecExit exit = 4; + } +} + +// ExecOpen is the first ShellMsg on every Exec stream. argv[0] is the +// program to run; an empty argv asks for a login shell (deferred to a +// later phase). env passes additional environment vars beyond the agent +// process's own; $TERM is set from `term` when `tty` is true. +message ExecOpen { + // target identifies which worker/cache to run on. This field is only + // meaningful on the fjbctl→orchestrator hop; the orchestrator strips + // it before forwarding the Open to the agent. Empty on the agent hop. + string target = 1; + + // argv is the program + args. argv[0] is exec'd directly; no shell + // interpolation. Pass `sh -c "..."` for shell semantics. + repeated string argv = 2; + + // env is a map of additional environment variables. Empty by default; + // the agent does NOT pass the orchestrator's environment through. + map env = 3; + + // tty asks the agent to allocate a PTY for the child and wire stdin/ + // stdout (no separate stderr under PTY) to the PTY master. Phase A + // returns CodeUnimplemented if true; PTY support lands in Phase C. + bool tty = 4; + + // term is the $TERM value to set on the child when tty=true. Ignored + // when tty=false. + string term = 5; + + // winsize is the initial PTY window size when tty=true. Ignored + // otherwise. + Winsize winsize = 6; +} + +// Winsize carries terminal columns and rows. +message Winsize { + uint32 cols = 1; + uint32 rows = 2; +} + +// ExecStdin pumps bytes to the child's stdin. Streaming; the client may +// send any number of these. CloseStdin signals EOF. +message ExecStdin { + bytes data = 1; +} + +// ExecStdout / ExecStderr carry bytes from the child's stdout / stderr. +// Frame size is at the server's discretion (default 32 KiB chunks); the +// receiver should not assume line boundaries. +message ExecStdout { + bytes data = 1; +} + +message ExecStderr { + bytes data = 1; +} + +// ExecResize updates the PTY window size mid-session. Only meaningful +// when the Open had tty=true. +message ExecResize { + Winsize winsize = 1; +} + +// ExecSignal forwards a signal to the child's process group. Valid +// names: SIGINT, SIGTERM, SIGQUIT, SIGHUP. Unknown names are ignored +// (logged on the agent side). +message ExecSignal { + string name = 1; +} + +// ExecCloseStdin closes the child's stdin (EOF on the read side). The +// stream stays open for stdout/stderr and the terminal Exit. +message ExecCloseStdin {} + +// ExecOpened is the sentinel server→client frame that fires immediately +// after the agent accepts the Open and begins setting up the child. The +// existence of this frame is what lets the client's stream-open call +// return without waiting for fork+exec to complete. +message ExecOpened {} + +// ExecExit is the terminal server→client frame. Exactly one of +// (code, signal) is meaningful: code is the program's exit status if it +// exited normally; signal is the signal name (e.g. "terminated", +// "killed") if it was killed by a signal. The agent closes the stream +// immediately after sending this. +message ExecExit { + // code is the child's exit status when it exited normally. + int32 code = 1; + // signal is the signal name when the child was killed by a signal. + // Empty when code is meaningful. + string signal = 2; +}