Skip to content

ax ssh sends localhost as the gRPC authority #388

Description

@gambtho

ax ssh port-forwards atenet-router and dials localhost. The outgoing ate-target-actor metadata identifies the task, but the router also requires the actor DNS name as gRPC :authority; it rejects localhost.

On a running task with spec.debug: true:

make build
./bin/ax ssh task123 -- pwd

The patch derives the gRPC authority from <atespace>/<actor> and keeps the metadata interceptors. The regression test sees localhost authority before the fix and the actor DNS authority after it; make test passes.

Branch: https://github.com/gambtho/ax/tree/fix/ssh-actor-authority

This branch includes the readiness fix above; the diff here is only the gRPC follow-up, against that first commit.

diff --git a/docs/networking.md b/docs/networking.md
index 368c55c..84876fd 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -1,6 +1,6 @@
 # Networking
 
-Tasks do not get a Kubernetes Service or Ingress of their own. Every request to a task goes through Agent Substrate's **atenet router**, the `atenet-router` Service in the `ate-system` namespace. The router requires the actor's stable DNS authority and accepts `ate-target-actor` as the explicit target identifier; it resumes the actor first if it was suspended, then proxies the request there.
+Tasks do not get a Kubernetes Service or Ingress of their own. Every request to a task goes through Agent Substrate's **atenet router**, the `atenet-router` Service in the `ate-system` namespace. The router requires the actor's stable DNS authority (`Host` for HTTP or `:authority` for gRPC) and accepts `ate-target-actor` as the explicit target identifier; it resumes the actor first if it was suspended, then proxies the request there.
 
 The actor authority is `<task>.<atespace>.actors.resources.substrate.ate.dev`, and the target header is `<atespace>/<task>`. The controller always names a task's actor after the task, so both values below identify the task `task123` in the `default` atespace.
 
@@ -26,9 +26,14 @@ curl -H "Host: task123.default.actors.resources.substrate.ate.dev" \
 
 ## gRPC request routing
 
-Send the header as outgoing metadata under the lowercase key. This is what `ax ssh` does to reach the guest services.
+Set the actor DNS name as the gRPC authority and send the target header as outgoing metadata. This is what `ax ssh` does to reach the guest services, including through a localhost port-forward.
 
 ```go
+conn, err := grpc.NewClient(
+    routerAddress,
+    grpc.WithAuthority("task123.default.actors.resources.substrate.ate.dev"),
+    // Add transport credentials and the metadata interceptors used by the client.
+)
 ctx = metadata.AppendToOutgoingContext(ctx, "ate-target-actor", "default/task123")
 resp, err := client.SomeMethod(ctx, req)
 ```
diff --git a/internal/guest/client.go b/internal/guest/client.go
index 959e1fe..f3428be 100644
--- a/internal/guest/client.go
+++ b/internal/guest/client.go
@@ -48,7 +48,12 @@ func DialTarget(target string, targetActor string) (*Client, error) {
 
 	dialOpts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
 	if targetActor != "" {
+		atespace, actor, ok := strings.Cut(targetActor, "/")
+		if !ok || atespace == "" || actor == "" || strings.Contains(actor, "/") {
+			return nil, fmt.Errorf("invalid target actor %q (want atespace/actor)", targetActor)
+		}
 		dialOpts = append(dialOpts,
+			grpc.WithAuthority(fmt.Sprintf("%s.%s.actors.resources.substrate.ate.dev", actor, atespace)),
 			grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
 				ctx = metadata.AppendToOutgoingContext(ctx, "ate-target-actor", targetActor)
 				return invoker(ctx, method, req, reply, cc, opts...)
diff --git a/internal/guest/client_test.go b/internal/guest/client_test.go
new file mode 100644
index 0000000..07c40b8
--- /dev/null
+++ b/internal/guest/client_test.go
@@ -0,0 +1,74 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package guest
+
+import (
+	"context"
+	"net"
+	"testing"
+	"time"
+
+	ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/metadata"
+)
+
+type authorityProcessServer struct {
+	ateenvv1alpha.UnimplementedProcessServiceServer
+	authority chan string
+}
+
+func (s *authorityProcessServer) StartProcess(ctx context.Context, _ *ateenvv1alpha.StartProcessRequest) (*ateenvv1alpha.Process, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	values := md.Get(":authority")
+	if len(values) > 0 {
+		s.authority <- values[0]
+	}
+	return &ateenvv1alpha.Process{ProcessId: "test"}, nil
+}
+
+func TestDialTargetUsesActorDNSAuthority(t *testing.T) {
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer lis.Close()
+
+	srv := grpc.NewServer()
+	process := &authorityProcessServer{authority: make(chan string, 1)}
+	ateenvv1alpha.RegisterProcessServiceServer(srv, process)
+	go srv.Serve(lis)
+	defer srv.Stop()
+
+	client, err := DialTarget(lis.Addr().String(), "hello/hello-fixed-greeting")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer client.Close()
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	if _, err := client.process.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{}); err != nil {
+		t.Fatal(err)
+	}
+
+	select {
+	case got := <-process.authority:
+		if want := "hello-fixed-greeting.hello.actors.resources.substrate.ate.dev"; got != want {
+			t.Fatalf("authority = %q, want %q", got, want)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("server did not receive gRPC authority")
+	}
+}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions