Skip to content

Commit 1b98d1e

Browse files
committed
Implement exec_service gRPC server in Go
1 parent 10b1abd commit 1b98d1e

File tree

9 files changed

+875
-28
lines changed

9 files changed

+875
-28
lines changed

MODULE.bazel

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,19 @@ git_override(
6565
bazel_dep(name = "rules_cc", version = "0.2.14")
6666
bazel_dep(name = "platforms", version = "1.0.0")
6767
bazel_dep(name = "protobuf", version = "33.4")
68+
69+
# --- Go Toolchain --------------------------------------------------------
70+
71+
bazel_dep(name = "rules_go", version = "0.60.0")
72+
bazel_dep(name = "gazelle", version = "0.48.0")
73+
74+
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
75+
go_sdk.download(version = "1.24.12")
76+
77+
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
78+
go_deps.from_file(go_mod = "//:go.mod")
79+
use_repo(
80+
go_deps,
81+
"org_golang_google_grpc",
82+
"org_golang_google_protobuf",
83+
)

MODULE.bazel.lock

Lines changed: 376 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

exec_service/BUILD.bazel

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,17 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
load("@rules_go//proto:def.bzl", "go_proto_library")
16+
1517
proto_library(
1618
name = "exec_service_proto",
1719
srcs = ["exec_service.proto"],
1820
)
21+
22+
go_proto_library(
23+
name = "exec_service_go_proto",
24+
compilers = ["@rules_go//proto:go_grpc"],
25+
importpath = "github.com/google/agent-shell-tools/exec_service/execservicepb",
26+
proto = ":exec_service_proto",
27+
visibility = ["//visibility:public"],
28+
)

exec_service/exec_service.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ syntax = "proto3";
1616

1717
package exec_service;
1818

19+
option go_package = "github.com/google/agent-shell-tools/exec_service/execservicepb";
20+
1921
// A simple command execution service.
2022
service ExecService {
2123
// Executes a command and streams the output until completion.

exec_service/server/BUILD.bazel

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
load("@rules_go//go:def.bzl", "go_library", "go_test")
16+
17+
go_library(
18+
name = "server",
19+
srcs = ["server.go"],
20+
importpath = "github.com/google/agent-shell-tools/exec_service/server",
21+
visibility = ["//visibility:public"],
22+
deps = [
23+
"//exec_service:exec_service_go_proto",
24+
],
25+
)
26+
27+
go_test(
28+
name = "server_test",
29+
srcs = ["server_test.go"],
30+
deps = [
31+
":server",
32+
"//exec_service:exec_service_go_proto",
33+
"@org_golang_google_grpc//:grpc",
34+
"@org_golang_google_grpc//credentials/insecure",
35+
"@org_golang_google_grpc//test/bufconn",
36+
],
37+
)

exec_service/server/server.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package server implements the ExecService gRPC server.
16+
package server
17+
18+
import (
19+
"errors"
20+
"fmt"
21+
"os"
22+
"os/exec"
23+
"syscall"
24+
"time"
25+
26+
pb "github.com/google/agent-shell-tools/exec_service/execservicepb"
27+
)
28+
29+
// ExecServer implements the ExecService gRPC service.
30+
// It runs shell commands and streams their output.
31+
type ExecServer struct {
32+
pb.UnimplementedExecServiceServer
33+
}
34+
35+
// RunCommand executes a shell command and streams output events until the
36+
// command exits. The command_line is interpreted by sh -c. Stdout and stderr
37+
// are merged into the output stream.
38+
func (s *ExecServer) RunCommand(req *pb.StartCommandRequest, stream pb.ExecService_RunCommandServer) error {
39+
cmd := exec.Command("sh", "-c", req.GetCommandLine())
40+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
41+
42+
if wd := req.GetWorkingDir(); wd != "" {
43+
cmd.Dir = wd
44+
}
45+
46+
pr, pw, err := os.Pipe()
47+
if err != nil {
48+
return sendError(stream, fmt.Sprintf("pipe: %v", err))
49+
}
50+
defer pr.Close()
51+
cmd.Stdout = pw
52+
cmd.Stderr = pw
53+
54+
if err := cmd.Start(); err != nil {
55+
pw.Close()
56+
return sendError(stream, fmt.Sprintf("start: %v", err))
57+
}
58+
pw.Close()
59+
60+
killGroup := func() {
61+
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
62+
}
63+
64+
// Wait for the command concurrently. When it exits, expire the pipe
65+
// read deadline so the read loop drains buffered output without
66+
// blocking on background children that inherited the pipe fds.
67+
waitCh := make(chan error, 1)
68+
go func() {
69+
err := cmd.Wait()
70+
pr.SetReadDeadline(time.Now())
71+
waitCh <- err
72+
}()
73+
74+
// Kill the process group if the client disconnects.
75+
done := make(chan struct{})
76+
defer close(done)
77+
go func() {
78+
select {
79+
case <-stream.Context().Done():
80+
killGroup()
81+
case <-done:
82+
}
83+
}()
84+
85+
buf := make([]byte, 4096)
86+
for {
87+
n, err := pr.Read(buf)
88+
if n > 0 {
89+
if sendErr := stream.Send(&pb.ServerEvent{
90+
Event: &pb.ServerEvent_Output{Output: append([]byte(nil), buf[:n]...)},
91+
}); sendErr != nil {
92+
killGroup()
93+
<-waitCh
94+
return sendErr
95+
}
96+
}
97+
if err != nil {
98+
break
99+
}
100+
}
101+
102+
exitCode := int32(0)
103+
var errMsg string
104+
if err := <-waitCh; err != nil {
105+
var exitErr *exec.ExitError
106+
if errors.As(err, &exitErr) {
107+
exitCode = int32(exitErr.ExitCode())
108+
} else {
109+
exitCode = -1
110+
errMsg = err.Error()
111+
}
112+
}
113+
114+
return stream.Send(&pb.ServerEvent{
115+
Event: &pb.ServerEvent_Exited{
116+
Exited: &pb.ExitInfo{
117+
ExitCode: exitCode,
118+
ErrorMessage: errMsg,
119+
},
120+
},
121+
})
122+
}
123+
124+
func sendError(stream pb.ExecService_RunCommandServer, msg string) error {
125+
return stream.Send(&pb.ServerEvent{
126+
Event: &pb.ServerEvent_Exited{
127+
Exited: &pb.ExitInfo{
128+
ExitCode: -1,
129+
ErrorMessage: msg,
130+
},
131+
},
132+
})
133+
}

0 commit comments

Comments
 (0)