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")
+ }
+}
ax sshport-forwards atenet-router and dials localhost. The outgoingate-target-actormetadata 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 -- pwdThe 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 testpasses.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.