Skip to content

Commit b2592ea

Browse files
committed
Add exec_client CLI for exec_service
1 parent cf2c92d commit b2592ea

3 files changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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_binary", "go_library", "go_test")
16+
17+
go_library(
18+
name = "exec_client_lib",
19+
srcs = ["main.go"],
20+
importpath = "github.com/google/agent-shell-tools/exec_service/cmd/exec_client",
21+
deps = [
22+
"//exec_service:exec_service_go_proto",
23+
"@org_golang_google_grpc//:grpc",
24+
"@org_golang_google_grpc//credentials/insecure",
25+
],
26+
)
27+
28+
go_binary(
29+
name = "exec_client",
30+
embed = [":exec_client_lib"],
31+
visibility = ["//visibility:public"],
32+
)
33+
34+
go_test(
35+
name = "exec_client_test",
36+
srcs = ["exec_client_test.go"],
37+
embed = [":exec_client_lib"],
38+
tags = ["local"],
39+
deps = [
40+
"//exec_service:exec_service_go_proto",
41+
"//exec_service/server",
42+
"@org_golang_google_grpc//:grpc",
43+
],
44+
)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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 main
16+
17+
import (
18+
"bytes"
19+
"net"
20+
"path/filepath"
21+
"strings"
22+
"testing"
23+
24+
pb "github.com/google/agent-shell-tools/exec_service/execservicepb"
25+
"github.com/google/agent-shell-tools/exec_service/server"
26+
"google.golang.org/grpc"
27+
)
28+
29+
// startTestServer starts an ExecService gRPC server on a temporary Unix socket
30+
// and returns the socket path. The server is stopped on test cleanup.
31+
func startTestServer(t *testing.T) string {
32+
t.Helper()
33+
sock := filepath.Join(t.TempDir(), "test.sock")
34+
35+
lis, err := net.Listen("unix", sock)
36+
if err != nil {
37+
t.Fatalf("listen: %v", err)
38+
}
39+
40+
srv := grpc.NewServer()
41+
pb.RegisterExecServiceServer(srv, &server.ExecServer{})
42+
go srv.Serve(lis)
43+
t.Cleanup(srv.GracefulStop)
44+
45+
return sock
46+
}
47+
48+
func TestBasicCommand(t *testing.T) {
49+
sock := startTestServer(t)
50+
51+
var stdout, stderr bytes.Buffer
52+
code := run([]string{"-addr", sock, "echo", "hello"}, &stdout, &stderr)
53+
54+
if code != 0 {
55+
t.Errorf("exit code = %d, want 0; stderr: %s", code, stderr.String())
56+
}
57+
if got := strings.TrimSpace(stdout.String()); got != "hello" {
58+
t.Errorf("stdout = %q, want %q", got, "hello")
59+
}
60+
}
61+
62+
func TestExitCode(t *testing.T) {
63+
sock := startTestServer(t)
64+
65+
var stdout, stderr bytes.Buffer
66+
code := run([]string{"-addr", sock, "exit 42"}, &stdout, &stderr)
67+
68+
if code != 42 {
69+
t.Errorf("exit code = %d, want 42", code)
70+
}
71+
}
72+
73+
func TestWorkingDir(t *testing.T) {
74+
sock := startTestServer(t)
75+
dir := t.TempDir()
76+
77+
var stdout, stderr bytes.Buffer
78+
code := run([]string{"-addr", sock, "-dir", dir, "pwd"}, &stdout, &stderr)
79+
80+
if code != 0 {
81+
t.Errorf("exit code = %d, want 0; stderr: %s", code, stderr.String())
82+
}
83+
if got := strings.TrimSpace(stdout.String()); got != dir {
84+
t.Errorf("pwd = %q, want %q", got, dir)
85+
}
86+
}
87+
88+
func TestMissingAddr(t *testing.T) {
89+
var stdout, stderr bytes.Buffer
90+
code := run([]string{"echo", "hello"}, &stdout, &stderr)
91+
92+
if code != 2 {
93+
t.Errorf("exit code = %d, want 2", code)
94+
}
95+
if !strings.Contains(stderr.String(), "-addr is required") {
96+
t.Errorf("stderr = %q, want it to contain %q", stderr.String(), "-addr is required")
97+
}
98+
}
99+
100+
func TestMissingCommand(t *testing.T) {
101+
var stdout, stderr bytes.Buffer
102+
code := run([]string{"-addr", "/tmp/fake.sock"}, &stdout, &stderr)
103+
104+
if code != 2 {
105+
t.Errorf("exit code = %d, want 2", code)
106+
}
107+
if !strings.Contains(stderr.String(), "command is required") {
108+
t.Errorf("stderr = %q, want it to contain %q", stderr.String(), "command is required")
109+
}
110+
}
111+
112+
func TestStderrOutput(t *testing.T) {
113+
sock := startTestServer(t)
114+
115+
var stdout, stderr bytes.Buffer
116+
code := run([]string{"-addr", sock, "echo error >&2"}, &stdout, &stderr)
117+
118+
if code != 0 {
119+
t.Errorf("exit code = %d, want 0", code)
120+
}
121+
// Server merges stderr into stdout stream.
122+
if got := strings.TrimSpace(stdout.String()); got != "error" {
123+
t.Errorf("stdout = %q, want %q", got, "error")
124+
}
125+
}
126+
127+
func TestArgQuoting(t *testing.T) {
128+
sock := startTestServer(t)
129+
dir := t.TempDir()
130+
131+
// "touch" with a filename containing a space should create one file, not two.
132+
var stdout, stderr bytes.Buffer
133+
code := run([]string{"-addr", sock, "-dir", dir, "touch", "a b"}, &stdout, &stderr)
134+
if code != 0 {
135+
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
136+
}
137+
138+
// Verify exactly one file named "a b" was created.
139+
var check bytes.Buffer
140+
code = run([]string{"-addr", sock, "-dir", dir, "ls"}, &check, &stderr)
141+
if code != 0 {
142+
t.Fatalf("ls exit code = %d; stderr: %s", code, stderr.String())
143+
}
144+
if got := strings.TrimSpace(check.String()); got != "a b" {
145+
t.Errorf("ls = %q, want %q", got, "a b")
146+
}
147+
}
148+
149+
func TestCommandNotFound(t *testing.T) {
150+
sock := startTestServer(t)
151+
152+
var stdout, stderr bytes.Buffer
153+
code := run([]string{"-addr", sock, "nonexistent_command_xyz"}, &stdout, &stderr)
154+
155+
if code == 0 {
156+
t.Error("exit code = 0, want non-zero for command not found")
157+
}
158+
}
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+
// Binary exec_client is a command line client for the ExecService gRPC server.
16+
// It connects to the server over a Unix socket, sends a command, streams
17+
// output to stdout, and exits with the command's exit code.
18+
package main
19+
20+
import (
21+
"context"
22+
"flag"
23+
"fmt"
24+
"io"
25+
"os"
26+
"path/filepath"
27+
"strings"
28+
29+
pb "github.com/google/agent-shell-tools/exec_service/execservicepb"
30+
"google.golang.org/grpc"
31+
"google.golang.org/grpc/credentials/insecure"
32+
)
33+
34+
func run(args []string, stdout, stderr io.Writer) int {
35+
fs := flag.NewFlagSet("exec_client", flag.ContinueOnError)
36+
fs.SetOutput(stderr)
37+
addr := fs.String("addr", "", "Unix socket path to connect to (required)")
38+
dir := fs.String("dir", "", "Working directory for the command")
39+
40+
if err := fs.Parse(args); err != nil {
41+
return 2
42+
}
43+
44+
if *addr == "" {
45+
fmt.Fprintln(stderr, "-addr is required")
46+
return 2
47+
}
48+
49+
cmdArgs := fs.Args()
50+
if len(cmdArgs) == 0 {
51+
fmt.Fprintln(stderr, "command is required")
52+
return 2
53+
}
54+
55+
// When the caller passes a single argument it is treated as a raw shell
56+
// command (e.g. exec_client -addr s "echo hello && ls"). Multiple
57+
// arguments are shell-quoted so that spaces and metacharacters in
58+
// individual args are preserved (e.g. exec_client -addr s touch "a b").
59+
var cmdLine string
60+
if len(cmdArgs) == 1 {
61+
cmdLine = cmdArgs[0]
62+
} else {
63+
cmdLine = shellJoin(cmdArgs)
64+
}
65+
66+
sockPath, err := filepath.Abs(*addr)
67+
if err != nil {
68+
fmt.Fprintf(stderr, "resolve socket path: %v\n", err)
69+
return 1
70+
}
71+
72+
conn, err := grpc.NewClient("unix://"+sockPath,
73+
grpc.WithTransportCredentials(insecure.NewCredentials()),
74+
)
75+
if err != nil {
76+
fmt.Fprintf(stderr, "dial: %v\n", err)
77+
return 1
78+
}
79+
defer conn.Close()
80+
81+
client := pb.NewExecServiceClient(conn)
82+
stream, err := client.RunCommand(context.Background(), &pb.StartCommandRequest{
83+
CommandLine: cmdLine,
84+
WorkingDir: *dir,
85+
})
86+
if err != nil {
87+
fmt.Fprintf(stderr, "RunCommand: %v\n", err)
88+
return 1
89+
}
90+
91+
for {
92+
ev, err := stream.Recv()
93+
if err == io.EOF {
94+
break
95+
}
96+
if err != nil {
97+
fmt.Fprintf(stderr, "recv: %v\n", err)
98+
return 1
99+
}
100+
switch e := ev.Event.(type) {
101+
case *pb.ServerEvent_Output:
102+
stdout.Write(e.Output)
103+
case *pb.ServerEvent_Exited:
104+
if msg := e.Exited.GetErrorMessage(); msg != "" {
105+
fmt.Fprintf(stderr, "error: %s\n", msg)
106+
}
107+
return int(e.Exited.GetExitCode())
108+
}
109+
}
110+
return 0
111+
}
112+
113+
// shellQuote wraps s in single quotes, escaping any embedded single quotes.
114+
func shellQuote(s string) string {
115+
if s == "" {
116+
return "''"
117+
}
118+
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
119+
}
120+
121+
// shellJoin quotes each argument and joins them with spaces, producing a
122+
// string safe for interpretation by sh -c.
123+
func shellJoin(args []string) string {
124+
quoted := make([]string, len(args))
125+
for i, a := range args {
126+
quoted[i] = shellQuote(a)
127+
}
128+
return strings.Join(quoted, " ")
129+
}
130+
131+
func main() {
132+
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
133+
}

0 commit comments

Comments
 (0)