Repository navigation
Expand file tree
/
Copy pathchatv3_at.go
More file actions
373 lines (354 loc) · 13.9 KB
/
Copy pathchatv3_at.go
File metadata and controls
373 lines (354 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
package main
// ── `codeaf serve`, `codeaf chat --at otter-lamp-42`, `codeaf devices` ───────
//
// The three doors of reaching a machine WITHOUT ssh. One machine holds an
// outbound connection open and shows a pairing code; a device pairs with that
// code once and afterwards just opens; and the machine that owns the work lists
// and stops the devices it has let in.
//
// big-machine$ codeaf serve
// this machine is reachable as otter-lamp-42
// let a device use this machine with code 42-715-302 (valid 10 minutes)
//
// laptop$ codeaf chat --at otter-lamp-42
// pairing with otter-lamp-42 — enter the code shown there: ______
// paired. this device is now a key to otter-lamp-42. [chat opens]
//
// THIS IS A SECOND DOOR AND NEVER A REPLACEMENT ONE. `--host` over ssh is the
// floor and stays the floor: zero infrastructure, nothing of ours to trust, and
// a debugging story that cannot be beaten. `--at` is for the machine ssh cannot
// reach — behind a home router, on a work network, on a phone — and everything
// it needs beyond the two commands is a relay service that this build does not
// assume exists. When there is none, `--at` says so in one sentence and names
// the road that does work.
//
// THE PROMPT LAW, INHERITED FROM chatv3_host.go AND FOR THE SAME REASON:
// EVERYTHING THAT MIGHT ASK THE PERSON A QUESTION HAPPENS BEFORE THE SURFACE
// TAKES THE SCREEN. The pairing code is typed on a plain terminal, at a plain
// prompt, while stderr is still the terminal's. A TUI that came up first would
// draw a frame over the top of the one question this door has to ask.
//
// AND THE ENGINE IS THE MACHINE'S OWN `codeaf engine`, started as a child, one
// per connection — the same shape ssh gives it, so the far half of this door is
// code that has been running in production since the ssh door shipped. What is
// new here is the pipe, and only the pipe.
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"github.com/Agent-Field/codeaf/internal/devname"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/identity"
"github.com/Agent-Field/codeaf/internal/pair"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/taskdoor"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)
// ── the surface: codeaf chat --at <name> ────────────────────────────────────
// atLaunch is one `--at` launch as the flag parser saw it. It is deliberately
// the same shape as [hostLaunch], because it is the same launch down a
// different pipe.
type atLaunch struct {
// target is the flag as typed: a machine name, or name:path.
target string
// session is --session, passed through as the engine's own --session.
session string
// model and level are --model and --reasoning, carried in the hello.
model string
level string
// once is --once: one message, printed, no terminal ownership.
once string
// pick is `codeaf resume`: the same surface, opened on the session picker.
pick bool
// noCompact and yolo are refused rather than ignored, exactly as they are
// over --host: they are properties of a session that is built over there.
noCompact bool
yolo bool
// budget is --max-hours / --max-cost, and it is refused for yolo's reason
// and one more, exactly as it is over --host: what it bounds is a goal
// owner that lives in the session (internal/session's principal.go), and
// the session is on the far machine. A ceiling accepted here would bound
// nothing at all.
budget bool
// restart is filled by the surface and read after this door has closed the
// relay tunnel and all conversation connections.
restart *codeupdate.Plan
}
// check refuses the flags this door cannot honour, in the same words the ssh
// door uses, because it is the same fact: the session is built on the other
// machine and a flag typed here has nowhere to land.
func (l atLaunch) check() error {
var named []string
if l.noCompact {
named = append(named, "--no-compact")
}
if l.yolo {
named = append(named, "--yolo")
}
if l.budget {
named = append(named, "--max-hours/--max-cost")
}
if len(named) == 0 {
return nil
}
name, _, _ := parseAtTarget(l.target)
return fmt.Errorf("%s cannot travel over --at: the session is built on %s, so set it there — open the settings panel on that machine, or run `codeaf chat %s` on it",
strings.Join(named, " and "), name, strings.Join(named, " "))
}
// parseAtTarget splits `name` or `name:path` the way the ssh door splits its
// own target: everything before the FIRST colon is the machine.
//
// THE PATH IS NOT RESOLVED HERE. It is the other machine's path, resolved over
// there, exactly as it is over ssh — making it absolute here would resolve it
// against this machine's working directory and send somebody else's filesystem.
func parseAtTarget(raw string) (name, workspace string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errors.New("--at needs a machine name: --at otter-lamp-42, or --at otter-lamp-42:code/app — `codeaf serve` prints the name of a machine")
}
name = raw
if at := strings.Index(raw, ":"); at >= 0 {
name = strings.TrimSpace(raw[:at])
workspace = strings.TrimSpace(raw[at+1:])
}
if name == "" {
return "", "", fmt.Errorf("--at %q has no machine in front of the colon", raw)
}
return name, workspace, nil
}
// openChatV3At is the launch.
func openChatV3At(launch atLaunch) error {
if err := launch.check(); err != nil {
return err
}
name, workspace, err := parseAtTarget(launch.target)
if err != nil {
return err
}
if launch.pick && launch.once != "" {
return fmt.Errorf(`codeaf resume opens the session picker; for one headless message use: codeaf chat --at %s --once "text"`, name)
}
device, err := pair.ThisDevice(pair.OpenKeeper())
if err != nil {
return err
}
reach := pair.Reach{
WeightOnApprover: true,
Name: name,
Device: device,
Machines: pair.MachineBook(),
Label: pair.ThisMachineLabel(),
// EVERY LINE OF THE PAIRING GOES TO STDERR, so that `--once` piped into
// a file is the reply and nothing else.
Say: func(line string) { fmt.Fprintln(os.Stderr, line) },
Sync: syncRelayURL(),
}
if stdinIsTerminal(os.Stdin) {
reach.AskCode = askPairingCode
}
tunnel, err := reach.Open(context.Background())
if err != nil {
return err
}
closeLink := func() {
_ = tunnel.Close()
}
defer func() { closeLink() }()
client, err := remote.Dial(tunnel, name, remote.Hello{
Headless: launch.once != "",
Workspace: workspace,
Session: launch.session,
Model: launch.model,
Level: launch.level,
// A WINDOW IS A WINDOW OPEN ON THE FAR MACHINE, said on this boot
// connection alone, exactly as the ssh door says it
// (chatv3_host.go's [openChatV3Host]): the far machine's automations
// clock runs while it is attached. A headless `--once` is not a window.
Window: launch.once == "",
})
if err != nil {
return err
}
bootTunnel := tunnel
shutBoot := func() error {
err := client.Close()
if shutErr := bootTunnel.Close(); err == nil {
err = shutErr
}
return err
}
closeLink = func() { _ = shutBoot() }
agent := client.Agent()
if launch.once != "" {
return runHostOnce(agent, launch.once)
}
// ANOTHER CONVERSATION IS ANOTHER TUNNEL THROUGH THE SAME RELAY, and it never
// asks for a pairing code: this device is already paired by the time the
// first one is open, so the second reach is the same reach with nobody to
// question (a prompt raised behind a running surface would be a question on a
// terminal the surface owns).
if shown := client.Welcome().Machine; shown != "" {
name = devname.Shown(shown)
client.SetDisplayName(name)
}
quiet := reach
quiet.AskCode = nil
fleet := newEngineFleet(name, client, shutBoot, func(ask engineAsk) (*engineConn, error) {
next, err := quiet.Open(context.Background())
if err != nil {
return nil, err
}
// NO [remote.Hello.Window] HERE, for [besideHello]'s reason: this is the
// same window on another tunnel, and the boot connection already holds
// the far machine's presence for it.
beside, err := remote.Dial(next, name, remote.Hello{
Workspace: ask.workspace,
Session: ask.session,
New: ask.mint,
Model: launch.model,
Level: launch.level,
})
if err != nil {
_ = next.Close()
return nil, err
}
return &engineConn{client: beside, shut: func() error {
err := beside.Close()
if shutErr := next.Close(); err == nil {
err = shutErr
}
return err
}}, nil
})
closeLink = fleet.closeAll
// THE SAME SURFACE THE ssh DOOR OPENS, assembled by the same function. What
// belongs to the far machine comes off the welcome and what belongs to this
// one is resolved here; the only thing that differs between the two doors is
// the pipe, so anything that differed in the surface would be a bug.
options, _ := hostOptions(fleet, client.Welcome(), launch.pick)
options.Restart = launch.restart
return runSurface(context.Background(), options)
}
// askPairingCode is the one question this door asks, on a plain terminal.
func askPairingCode(name string) (string, error) {
fmt.Fprint(os.Stderr, pair.PairingPrompt(name))
reader := bufio.NewReader(os.Stdin)
typed, err := reader.ReadString('\n')
if err != nil && strings.TrimSpace(typed) == "" {
return "", errors.New("no pairing code was typed, so nothing was paired")
}
return strings.TrimSpace(typed), nil
}
// ── the machine: codeaf serve ───────────────────────────────────────────────
// runServe holds this machine's outbound connection open and answers the
// devices that arrive on it.
//
// IT LISTENS FOR NOTHING AND OPENS NOTHING. There is no port here, no inbound
// rule, and nothing for a person to configure on their router — the machine
// walks out to the relay and stays there.
func runServe(args []string) error {
flags := commandFlags("serve")
takeTasks := flags.Bool(taskdoor.TakeTasksFlag, false, "take tasks from your paired computers")
workspace := flags.String("workspace", "", "directory a connection works in when it does not name one; empty is the directory this command was run in")
relayAddress := flags.String("via", "", "the sync service to be reachable through; empty reads "+pair.RelayEnv+", ~/.codeaf/v3/remote/relay, then the address this machine syncs through")
renamedFlag(flags, "relay", "via")
if err := parseCommandFlags(flags, args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("usage: codeaf serve [--workspace path] [--via https://…] [--" + taskdoor.TakeTasksFlag + "]")
}
service := strings.TrimSpace(*relayAddress)
if service == "" {
service = pair.Relay(syncRelayURL())
}
if service == "" {
// Only a machine whose person turned sync off and named no relay
// gets here; every other machine has sync's address.
return errors.New("no sync service is set up on this machine, so there is nowhere to be reachable from — set " + pair.RelayEnv + " to your sync service")
}
here := strings.TrimSpace(*workspace)
if here == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
here = cwd
}
device, err := pair.ThisDevice(pair.OpenKeeper())
if err != nil {
return err
}
// ctrl+c is how this command ends, so it is caught rather than left to kill
// the process mid-registration: the machine gives its name up on the way
// out instead of leaving the relay to notice.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
defer startServeKeeping(ctx, service)()
if *takeTasks {
defer serveTasks(ctx, service)()
}
shown := ""
if _, err := identity.Load(home.Dir()); err == nil && !identity.Solo(home.Dir()) {
shown = devname.Name(home.Dir())
}
host := &pair.Host{
DisplayName: shown,
Service: service,
Device: device,
Devices: pair.DeviceBook(),
Desk: &pair.Desk{},
Approve: serveApprover(ctx, os.Stdin, os.Stdout),
Say: func(line string) { fmt.Fprintln(os.Stdout, line) },
OpenNamed: func(_ string, tunnel io.ReadWriteCloser) error { return serveOneConnection(tunnel, here) },
}
if err := host.Run(ctx); err != nil {
return err
}
fmt.Fprintln(os.Stdout, "this machine is no longer reachable")
return nil
}
// serveOneConnection runs `codeaf engine` for one connection, with the tunnel
// as its pipes.
//
// A CHILD PROCESS RATHER THAN A FUNCTION CALL, and that is the whole reason
// this door was cheap to build. The engine is a program that reads frames on
// stdin and writes them on stdout (engine.go); ssh gives it those pipes from a
// remote shell and this gives it those pipes from a tunnel. One conversation
// per process is also the lifetime the engine was written for — it chdirs into
// its workspace and opens one session — so two connections are two processes
// and never two conversations fighting over one.
func serveOneConnection(tunnel io.ReadWriteCloser, workspace string) error {
self, err := os.Executable()
if err != nil {
return errors.New("this computer could not start the chat")
}
engine := exec.Command(self, "engine", "--workspace", workspace)
engine.Stdin = tunnel
engine.Stdout = tunnel
// Child diagnostics belong to a local log, never the serving terminal or
// the tunnel, whose stdout carries only protocol frames.
logPath := filepath.Join(home.Dir(), "logs", "serve.log")
var diagnostics io.Writer = io.Discard
if err := os.MkdirAll(filepath.Dir(logPath), 0700); err == nil {
if file, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600); err == nil {
defer file.Close()
diagnostics = file
}
}
engine.Stderr = diagnostics
if err := engine.Run(); err != nil && !errors.Is(err, context.Canceled) {
log.New(diagnostics, "", log.LstdFlags).Print(err)
return err
}
return nil
}