Skip to content

Commit 34ced44

Browse files
Reach a paired computer through the relay sync uses (#2073)
* fix: reach a paired computer through the relay sync uses codeaf serve and chat --at fall back to the relay sync resolves, the relay client keeps the relay address's path, the carrier rides a WebSocket (with a fallback to the raw upgrade for older relays), the hosted relay gains the machine doors, and upstream failures become plain sentences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review of the machine doors A full machine answers 429 on both relays, a registration being decided is not taken over, a closed stream is not closed twice, a running serve waits out a redeploying relay, frames reuse one buffer, the door paths are one constant, per-address limits match codeaf relay, and the hosted README lists the binding, the migration and the doors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style: gofmt relay server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ce06d29 commit 34ced44

27 files changed

Lines changed: 1227 additions & 87 deletions

‎cmd/codeaf/chatv3_at.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ func openChatV3At(launch atLaunch) error {
151151
Label: pair.ThisMachineLabel(),
152152
// EVERY LINE OF THE PAIRING GOES TO STDERR, so that `--once` piped into
153153
// a file is the reply and nothing else.
154-
Say: func(line string) { fmt.Fprintln(os.Stderr, line) },
154+
Say: func(line string) { fmt.Fprintln(os.Stderr, line) },
155+
Sync: syncRelayURL(),
155156
}
156157
if stdinIsTerminal(os.Stdin) {
157158
reach.AskCode = askPairingCode
@@ -261,7 +262,7 @@ func askPairingCode(name string) (string, error) {
261262
func runServe(args []string) error {
262263
flags := commandFlags("serve")
263264
workspace := flags.String("workspace", "", "directory a connection works in when it does not name one; empty is the directory this command was run in")
264-
relayAddress := flags.String("via", "", "the sync address to be reachable through; empty reads "+pair.RelayEnv)
265+
relayAddress := flags.String("via", "", "the sync address to be reachable through; empty reads "+pair.RelayEnv+", then the address this machine syncs through")
265266
renamedFlag(flags, "relay", "via")
266267
if err := parseCommandFlags(flags, args); err != nil {
267268
return err
@@ -272,11 +273,11 @@ func runServe(args []string) error {
272273

273274
service := strings.TrimSpace(*relayAddress)
274275
if service == "" {
275-
service = pair.Relay()
276+
service = pair.Relay(syncRelayURL())
276277
}
277278
if service == "" {
278-
// THE HONEST STATE, AND THE ONE MOST PEOPLE WILL MEET. The relay is a
279-
// service and this build does not assume one exists.
279+
// Only a machine whose person turned sync off and named no relay
280+
// gets here; every other machine has sync's address.
280281
return errors.New("no sync address is set up on this machine, so there is nowhere to be reachable from — set " + pair.RelayEnv + " to your sync address, or let people in over ssh with `codeaf chat --host` from their side")
281282
}
282283

‎cmd/codeaf/chatv3_at_test.go‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ package main
33
import (
44
"strings"
55
"testing"
6+
7+
"github.com/Agent-Field/codeaf/internal/home"
8+
"github.com/Agent-Field/codeaf/internal/pair"
9+
"github.com/Agent-Field/codeaf/internal/syncsetup"
610
)
711

812
// The target is read the way the ssh door reads its own, because it is the same
@@ -58,12 +62,13 @@ func TestTheFlagsThatBuildASessionCannotTravelOverAt(t *testing.T) {
5862
}
5963

6064
// WITH NO RELAY SET UP, BOTH DOORS SAY SO IN ONE SENTENCE AND NAME THE ROAD
61-
// THAT DOES WORK. The relay is a service, this build does not assume one is
62-
// running, and a person who meets this must not be left guessing whether their
63-
// network is broken.
65+
// THAT DOES WORK. Sync falls back to the hosted relay, so the only machine
66+
// with no relay is one whose person turned sync off, and they must not be
67+
// left guessing whether their network is broken.
6468
func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) {
6569
t.Setenv("CODEAF_HOME", t.TempDir())
6670
t.Setenv("CODEAF_RELAY", "")
71+
t.Setenv(syncsetup.URLVar, "off")
6772

6873
err := openChatV3At(atLaunch{target: "otter-lamp-42"})
6974
if err == nil {
@@ -91,6 +96,39 @@ func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) {
9196
}
9297
}
9398

99+
// SERVE, --at, PAIRING AND SYNC AGREE ABOUT THE RELAY. A machine whose chats
100+
// sync is reachable through that same relay with nothing else set up, whether
101+
// sync's address came from the variable, a saved pairing or the hosted default.
102+
func TestServeAtPairingAndSyncResolveOneRelay(t *testing.T) {
103+
cases := []struct{ name, variable, saved, want string }{
104+
{"the hosted default", "", "", syncsetup.HostedRelayURL},
105+
{"the relay a pairing saved", "", "https://saved.example/fabric", "https://saved.example/fabric"},
106+
{"the variable", "https://variable.example", "https://saved.example/fabric", "https://variable.example"},
107+
}
108+
for _, c := range cases {
109+
t.Run(c.name, func(t *testing.T) {
110+
t.Setenv("CODEAF_HOME", t.TempDir())
111+
t.Setenv(pair.RelayEnv, "")
112+
t.Setenv(syncsetup.URLVar, c.variable)
113+
if c.saved != "" {
114+
if err := syncsetup.SaveRelayURL(home.Dir(), c.saved); err != nil {
115+
t.Fatal(err)
116+
}
117+
}
118+
mailbox, err := pairMailbox("")
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
sync := syncsetup.Resolve(home.Dir()).URL
123+
serve := pair.Relay(syncRelayURL())
124+
at := pair.RelayFor(pair.Known{}, false, syncRelayURL())
125+
if sync != c.want || mailbox.URL != c.want || serve != c.want || at != c.want {
126+
t.Fatalf("sync %q, pairing %q, serve %q, --at %q; want all %q", sync, mailbox.URL, serve, at, c.want)
127+
}
128+
})
129+
}
130+
}
131+
94132
// `codeaf devices` on a machine nothing has paired with is one sentence and an
95133
// invitation, not a heading over an empty table.
96134
func TestDevicesOnAFreshMachineSaysNothingIsPaired(t *testing.T) {

‎cmd/codeaf/pairroute.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ import (
2424
// wait and this client never cuts a healthy one short.
2525
const pairPollTimeout = pairbox.MaxWait + 10*time.Second
2626

27+
// syncRelayURL is the relay this computer syncs through, empty when sync is
28+
// off. `codeaf serve` and `--at` fall back to it, so a machine whose chats sync
29+
// is reachable through the same relay with nothing else set up.
30+
func syncRelayURL() string {
31+
if relay := syncsetup.Resolve(home.Dir()); relay.On() {
32+
return relay.URL
33+
}
34+
return ""
35+
}
36+
2737
// pairMailbox is the relay a pairing runs through: the --relay flag, else the
2838
// relay this computer syncs through. A relay named by flag is the one the other
2939
// device is told to pass, so it is `Shown`; the default needs no telling.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
kind: fixed
3+
title: codeaf serve and chat --at work through the relay your chats already sync through
4+
pr: 2073
5+
surface: [remote, docs]
6+
invalidates:
7+
- "codeaf serve and chat --at needed CODEAF_RELAY or a saved relay file; with neither they said no sync address was set up. They now fall back to the relay sync resolves (CODEAF_SYNC_URL, the relay a pairing saved, then the hosted relay)."
8+
- "The relay client dropped the path of a relay address, so https://host/fabric was called at https://host/v1/engine. It now calls https://host/fabric/v1/engine and /fabric/v1/dial/<name>, as every other client does."
9+
- "The machine carrier was only the raw aforge-relay/1 HTTP upgrade. It now rides a WebSocket offering that protocol and falls back to the raw upgrade when a relay answers 426; codeaf relay accepts both."
10+
- "The hosted relay had no machine doors and answered /v1/engine with 404. It now serves /v1/engine and /v1/dial/<name> through one Durable Object per machine name, which needs the v4-machines migration and the MACHINE binding in a deployment's config."
11+
- "codeaf serve said lost sync on every retry and quit when the relay still held its own name from a dropped connection. It now says it once per outage and waits the stale name out."
12+
---
13+
14+
A relay that is down, or one that cannot connect machines, is now one sentence naming the relay and what
15+
to try, never an upstream body such as `error code: 522`.

‎docs/remote-access-testing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ clear refusal. Run it anyway; the refusals are the deliverable.
375375

376376
```sh
377377
codeaf devices # who may reach this machine, and its name
378-
codeaf serve # be reachable (needs CODEAF_RELAY set)
378+
codeaf serve # be reachable (through the relay sync uses, or CODEAF_RELAY)
379379
codeaf chat --at otter-lamp-42 # reach a machine by its name
380380
```
381381

‎internal/manual/chat/reaching-this-machine-without-ssh.md‎

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,10 @@ laptop$ codeaf chat --at otter-lamp-42
3434
router setting. That machine dials *out* to a relay and stays connected, which is why
3535
firewalls and NAT stop mattering.
3636

37-
**This needs a relay service, and there is not one running yet.** Until one is switched
38-
on, `--at` prints:
39-
40-
```
41-
no service address is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a service address, or reach that machine with --host over ssh
42-
```
37+
**There is nothing to set up.** Both machines go through the relay their chats already
38+
sync through: the hosted relay, unless sync was pointed somewhere else with
39+
`CODEAF_SYNC_URL` or by pairing. `CODEAF_RELAY`, or `--via` on `codeaf serve`, picks a
40+
different relay for this door alone, such as one you run yourself.
4341

4442
`--host` works today and is not going anywhere. If you can already reach the machine that
4543
way, use that; the two doors are compared at the bottom of this page.
@@ -65,19 +63,39 @@ digits, the same every time, on any relay. You cannot pick a nicer one, and nobo
6563
can register under yours while your machine is connected.
6664

6765
`--workspace` is the directory a connection works in when it does not name one; empty
68-
means the directory you ran the command in. `--via` beats the `CODEAF_RELAY`
69-
environment variable.
66+
means the directory you ran the command in. The relay is, in order: `--via`, the
67+
`CODEAF_RELAY` environment variable, then the relay this machine syncs through.
7068

7169
Running it twice on one machine is refused, because there is only one of it:
7270

7371
```
74-
this machine is already connected to the service as otter-lamp-42 — there is only one of it, so close the other `codeaf serve`
72+
this machine is already connected to sync as otter-lamp-42 — there is only one of it, so close the other `codeaf serve`
73+
```
74+
75+
## codeaf serve when the network drops or the relay says no
76+
77+
If the network drops while it runs, it says once:
78+
79+
```
80+
lost sync — trying again
7581
```
7682

77-
With no relay set up at all it says:
83+
and keeps trying, waiting a little longer each time up to a minute, until it prints its
84+
name and code again. It does not need restarting, and devices reach it as soon as it has.
85+
86+
A relay that answers but cannot connect machines, such as an older sync service, stops
87+
it with:
7888

7989
```
80-
no service address is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to your service address, or let people in over ssh with `codeaf chat --host` from their side
90+
the service at https://service.example.com syncs chats but does not connect machines — run your own with `codeaf relay` and set CODEAF_RELAY to it, or reach that machine with --host over ssh
91+
```
92+
93+
A relay that cannot be reached when it starts stops it with the sentence in *When --at
94+
will not open*. With sync turned off (`CODEAF_SYNC_URL=off`) and no `CODEAF_RELAY`, there
95+
is no relay at all:
96+
97+
```
98+
no sync address is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to your sync address, or let people in over ssh with `codeaf chat --host` from their side
8199
```
82100

83101
Ctrl+c ends it, and the machine gives its name back on the way out:
@@ -351,9 +369,9 @@ that still has it in the list, which is what revoking removes.
351369

352370
## When --at will not open
353371

354-
There are four different reasons, and each says which one it is, in one sentence.
372+
There are five different reasons, and each says which one it is, in one sentence.
355373

356-
**No relay is set up on this machine:**
374+
**No relay is set up on this machine** (sync is off and `CODEAF_RELAY` is empty):
357375

358376
```
359377
no service address is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a service address, or reach that machine with --host over ssh
@@ -365,6 +383,12 @@ no service address is set up on this machine, so --at has nowhere to look for ot
365383
the service at https://service.example.com cannot be reached from here — check this machine's network, or reach that machine with --host over ssh
366384
```
367385

386+
**The relay answers but cannot connect machines:**
387+
388+
```
389+
the service at https://service.example.com syncs chats but does not connect machines — run your own with `codeaf relay` and set CODEAF_RELAY to it, or reach that machine with --host over ssh
390+
```
391+
368392
**The relay is fine and that machine is not connected to it:**
369393

370394
```
@@ -464,15 +488,18 @@ sitting at. They differ only in how the two halves reach each other.
464488

465489
| | `--host` | `--at` |
466490
| --- | --- | --- |
467-
| Needs | `ssh <machine>` already works | a relay service, and one pairing |
491+
| Needs | `ssh <machine>` already works | one pairing; the relay is the one sync uses |
468492
| Set up on the far machine | nothing | `codeaf serve` running |
469493
| Works through NAT and firewalls | only if ssh does | yes — that machine dials out |
470494
| Anything in the middle | nothing of ours | a relay that cannot read the conversation |
471-
| Available today | yes | not until a relay is running |
495+
| Available today | yes | yes |
472496

473497
**Prefer `--host` when ssh works.** It has nothing between the two machines, nothing to
474498
set up, and no service to depend on. `--at` is for the machine ssh cannot reach.
475499

500+
Both machines must use the same relay. If one syncs through a relay of your own and the
501+
other through the hosted one, set `CODEAF_RELAY` to the same address on both.
502+
476503
Both open the same surface with the same limits — what does and does not work over a
477504
connection is a property of the connection, not of which of these two doors opened it.
478505

‎internal/pair/dial.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ type Reach struct {
7272
AskCode func(name string) (string, error)
7373
// Say is where the pairing's own lines go. Nil says nothing.
7474
Say func(string)
75+
// Sync is the relay this device syncs through, used when nothing more
76+
// particular names one; see [Relay].
77+
Sync string
7578
// Now is the clock, swapped by tests.
7679
Now func() time.Time
7780
}
@@ -102,7 +105,7 @@ func (r Reach) Open(ctx context.Context) (*Tunnel, error) {
102105
if err != nil {
103106
return nil, err
104107
}
105-
service := RelayFor(known, found)
108+
service := RelayFor(known, found, r.Sync)
106109
if strings.TrimSpace(service) == "" {
107110
return nil, NoRelay(r.Name)
108111
}
@@ -204,6 +207,8 @@ func (r Reach) dial(ctx context.Context, service string) (io.ReadWriteCloser, er
204207
return nil, NotConnected(r.Name)
205208
case errors.Is(err, relay.ErrUnreachable):
206209
return nil, Unreachable(service)
210+
case errors.Is(err, relay.ErrNoDoor):
211+
return nil, NoDoor(service)
207212
case errors.Is(err, relay.ErrTooMany):
208213
return nil, Busy()
209214
default:

0 commit comments

Comments
 (0)