ai-or-die 0.1.87 → 0.1.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ai-or-die.js CHANGED
@@ -106,7 +106,12 @@ async function main() {
106
106
  port,
107
107
  auth: authToken,
108
108
  noAuth: noAuth,
109
- https: options.https,
109
+ // Mesh terminates real TLS at the tailnet edge (the sidecar serves the
110
+ // <host>.ts.net cert) and reverse-proxies to a loopback backend, so in
111
+ // mesh mode the local server stays plain HTTP on 127.0.0.1 regardless of
112
+ // --https. (Mesh forces a loopback-only bind anyway, so --https's LAN
113
+ // secure-context role does not apply.) --https WITHOUT --mesh is unchanged.
114
+ https: options.mesh ? false : options.https,
110
115
  cert: options.cert,
111
116
  key: options.key,
112
117
  dev: options.dev,
@@ -158,7 +163,7 @@ async function main() {
158
163
  const app = new ClaudeCodeWebServer(serverOptions);
159
164
  await app.start();
160
165
 
161
- const protocol = options.https ? 'https' : 'http';
166
+ const protocol = serverOptions.https ? 'https' : 'http';
162
167
  const baseUrl = `${protocol}://localhost:${port}`;
163
168
  // For localhost with auth, embed token in URL so user can just click it
164
169
  const url = authToken ? `${baseUrl}?token=${authToken}` : baseUrl;
@@ -168,8 +173,10 @@ async function main() {
168
173
  console.log(` Auth token: \x1b[1m\x1b[33m${authToken}\x1b[0m`);
169
174
  }
170
175
 
171
- // Warn if STT is enabled without HTTPS or tunnel
172
- if ((serverOptions.stt || serverOptions.sttEndpoint) && !options.https && !options.tunnel) {
176
+ // Warn if STT is enabled without HTTPS or tunnel. Skip in mesh mode: it
177
+ // binds loopback-only (no LAN), the mic works on http://localhost locally,
178
+ // and remotely via the mesh edge's real TLS.
179
+ if ((serverOptions.stt || serverOptions.sttEndpoint) && !options.https && !options.tunnel && !options.mesh) {
173
180
  console.log('\n\x1b[33m⚠ STT enabled over plain HTTP \u2014 microphone only works on localhost.\x1b[0m');
174
181
  console.log(' For LAN access, restart with \x1b[1m--https\x1b[0m or \x1b[1m--tunnel\x1b[0m.');
175
182
  }
@@ -202,10 +209,14 @@ async function main() {
202
209
  // Mesh: permanent Tailscale reachability. Coexists with --tunnel (mesh for
203
210
  // owned devices, tunnel fallback for borrowed ones). Auth token stays ON.
204
211
  if (options.mesh) {
212
+ if (options.https) {
213
+ console.log('\n \x1b[33mNote: --https is handled by the mesh edge (real .ts.net TLS); the local server stays HTTP on loopback.\x1b[0m');
214
+ }
205
215
  const { MeshManager } = require('../src/mesh-manager');
206
216
  const mesh = new MeshManager({
207
217
  port,
208
218
  dev: options.dev,
219
+ authToken,
209
220
  onUrl: (meshUrl) => {
210
221
  const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
211
222
  console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
package/mesh/main.go CHANGED
@@ -5,32 +5,76 @@
5
5
  // rest of the machine never joins. Enrollment is one-time via TS_AUTHKEY; the
6
6
  // node identity persists in --statedir so the key is never needed again.
7
7
  //
8
+ // TLS: when the tailnet has HTTPS certificates enabled, the sidecar terminates
9
+ // real `<host>.ts.net` TLS at the edge (so the advertised https:// URL is a
10
+ // genuine browser secure context — remote mic/PWA work) and reverse-proxies to
11
+ // the loopback backend. When certs are unavailable it degrades to plain http on
12
+ // :80 and says so. The scheme is decided BEFORE the URL is advertised, never
13
+ // after, because tsnet provisions the cert lazily inside the TLS handshake — a
14
+ // post-advertise failure would otherwise hang the browser.
15
+ //
8
16
  // stdout protocol (parsed by src/mesh-manager.js):
9
- // MESH-URL https://<name> node is up and serving
17
+ // MESH-URL https://<name> node is up, serving real TLS at the edge
18
+ // MESH-URL http://<name> node is up, but TLS certs unavailable (degraded)
19
+ // MESH-NOCERT hint: enable HTTPS Certificates in the tailnet
20
+ // MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
10
21
  // MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
11
- // MESH-ERR <msg> fatal
22
+ // MESH-ERR <msg> fatal
12
23
  package main
13
24
 
14
25
  import (
15
26
  "context"
27
+ "crypto/tls"
28
+ "encoding/json"
16
29
  "flag"
17
30
  "fmt"
31
+ "net"
18
32
  "net/http"
19
33
  "net/http/httputil"
20
34
  "net/url"
21
35
  "os"
36
+ "sort"
22
37
  "strings"
23
38
  "time"
24
39
 
40
+ "tailscale.com/ipn/ipnstate"
25
41
  "tailscale.com/tsnet"
26
42
  )
27
43
 
44
+ // version is stamped at build time via -ldflags "-X main.version=<contentHash>".
45
+ var version = "dev"
46
+
47
+ const (
48
+ certWaitTimeout = 20 * time.Second
49
+ meshPeersInterval = 20 * time.Second
50
+ meshPeersMaxPeers = 512
51
+ meshPeerTag = "tag:aiordie"
52
+ )
53
+
28
54
  func main() {
29
- port := flag.String("port", "7777", "local ai-or-die port to expose")
55
+ port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
56
+ backend := flag.String("backend", "", "full backend URL to proxy to (overrides --port); must be loopback")
30
57
  host := flag.String("hostname", "aiordie", "tailnet hostname")
31
58
  dir := flag.String("statedir", "./ts-state", "persistent node state dir")
59
+ showVersion := flag.Bool("version", false, "print the build version (content hash) and exit")
32
60
  flag.Parse()
33
61
 
62
+ if *showVersion {
63
+ fmt.Println(version)
64
+ return
65
+ }
66
+
67
+ proxyBearer := os.Getenv("AIORDIE_PROXY_BEARER")
68
+
69
+ // Resolve + validate the backend target. It MUST be loopback: the sidecar is
70
+ // a tailnet-facing reverse proxy, and pointing it off-host would expose an
71
+ // arbitrary origin to the whole tailnet.
72
+ target, err := resolveBackend(*backend, *port)
73
+ if err != nil {
74
+ fmt.Printf("MESH-ERR %v\n", err)
75
+ os.Exit(1)
76
+ }
77
+
34
78
  s := &tsnet.Server{Dir: *dir, Hostname: *host}
35
79
  // Surface the login URL exactly once instead of tsnet's default stderr spam.
36
80
  s.AuthKey = os.Getenv("TS_AUTHKEY")
@@ -39,27 +83,259 @@ func main() {
39
83
  // Bring the node up; report the enroll URL if it needs login.
40
84
  ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
41
85
  defer cancel()
42
- if _, err := s.Up(ctx); err != nil {
86
+ st, err := s.Up(ctx)
87
+ if err != nil {
43
88
  // Up blocks until authed; on timeout it's almost always missing/!key.
44
89
  fmt.Printf("MESH-NEEDLOGIN https://login.tailscale.com/admin/settings/keys\n")
45
90
  fmt.Printf("MESH-ERR %v\n", err)
46
91
  os.Exit(1)
47
92
  }
48
93
 
94
+ if lc, err := s.LocalClient(); err == nil {
95
+ go emitMeshPeersLoop(context.Background(), lc)
96
+ }
97
+
98
+ name := *host
99
+ if st != nil && st.Self != nil && st.Self.DNSName != "" {
100
+ name = strings.TrimSuffix(st.Self.DNSName, ".")
101
+ }
102
+
103
+ // One reverse proxy; the edge scheme it advertises to the backend is decided
104
+ // just below and captured by pointer so the X-Forwarded-Proto header is
105
+ // always accurate. The terminal client derives ws/wss from window.location,
106
+ // so wss works regardless; this header is hygiene for any absolute-URL or
107
+ // redirect the app generates (no mixed content).
108
+ edgeProto := "http"
109
+ rp := newEdgeProxy(target, name, &edgeProto, proxyBearer)
110
+
111
+ // Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
112
+ // without provisioning the cert; the ACME work (and any failure) happens
113
+ // lazily inside the first TLS handshake. So we probe cert readiness up front
114
+ // and only advertise https:// when we are confident the handshake will work.
115
+ if tlsLn := tryListenTLS(ctx, s, name); tlsLn != nil {
116
+ edgeProto = "https"
117
+ // Best-effort :80 -> https redirect so a bare http:// hit is upgraded.
118
+ if httpLn, e := s.Listen("tcp", ":80"); e == nil {
119
+ go http.Serve(httpLn, redirectToHTTPS(name))
120
+ }
121
+ fmt.Printf("MESH-URL https://%s\n", name)
122
+ // http.Serve only returns on failure; exit non-zero so the manager
123
+ // (which restarts on a non-zero exit) brings the sidecar back.
124
+ err := http.Serve(tlsLn, rp)
125
+ fmt.Printf("MESH-ERR %v\n", err)
126
+ os.Exit(1)
127
+ }
128
+
129
+ // Degraded: no usable cert. Serve plain http and say so. http://<name>.ts.net
130
+ // is NOT a browser secure context, so remote mic/PWA will not work until the
131
+ // operator enables HTTPS Certificates in the tailnet admin console.
49
132
  ln, err := s.Listen("tcp", ":80")
50
133
  if err != nil {
51
134
  fmt.Printf("MESH-ERR %v\n", err)
52
135
  os.Exit(1)
53
136
  }
54
- target, _ := url.Parse("http://127.0.0.1:" + *port)
55
- rp := httputil.NewSingleHostReverseProxy(target) // handles WS upgrade via hijack
137
+ fmt.Printf("MESH-NOCERT\n")
138
+ fmt.Printf("MESH-URL http://%s\n", name)
139
+ serveErr := http.Serve(ln, rp)
140
+ fmt.Printf("MESH-ERR %v\n", serveErr)
141
+ os.Exit(1)
142
+ }
56
143
 
57
- st, _ := s.Up(ctx)
58
- name := *host
59
- if st != nil && st.Self != nil && st.Self.DNSName != "" {
60
- name = strings.TrimSuffix(st.Self.DNSName, ".")
144
+ // resolveBackend builds the proxy target from --backend (preferred) or --port,
145
+ // and rejects anything that is not an http(s) loopback origin.
146
+ func resolveBackend(backend, port string) (*url.URL, error) {
147
+ raw := backend
148
+ if raw == "" {
149
+ raw = "http://127.0.0.1:" + port
150
+ }
151
+ u, err := url.Parse(raw)
152
+ if err != nil {
153
+ return nil, fmt.Errorf("invalid --backend %q: %v", raw, err)
154
+ }
155
+ if u.Scheme != "http" && u.Scheme != "https" {
156
+ return nil, fmt.Errorf("backend scheme must be http/https, got %q", u.Scheme)
157
+ }
158
+ if !isLoopbackHost(u.Hostname()) {
159
+ return nil, fmt.Errorf("backend host must be loopback (127.0.0.1/::1/localhost), got %q", u.Hostname())
160
+ }
161
+ // Pin "localhost" to a numeric loopback so the later proxy dial cannot be
162
+ // redirected off-host by a poisoned resolver / hosts file.
163
+ if u.Hostname() == "localhost" {
164
+ if p := u.Port(); p != "" {
165
+ u.Host = "127.0.0.1:" + p
166
+ } else {
167
+ u.Host = "127.0.0.1"
168
+ }
169
+ }
170
+ return u, nil
171
+ }
172
+
173
+ func isLoopbackHost(h string) bool {
174
+ if h == "localhost" {
175
+ return true
176
+ }
177
+ ip := net.ParseIP(h)
178
+ return ip != nil && ip.IsLoopback()
179
+ }
180
+
181
+ type statusClient interface {
182
+ Status(context.Context) (*ipnstate.Status, error)
183
+ }
184
+
185
+ type meshPeersSnapshot struct {
186
+ Self meshPeerSelf `json:"self"`
187
+ Peers []meshPeer `json:"peers"`
188
+ }
189
+
190
+ type meshPeerSelf struct {
191
+ Hostname string `json:"hostname"`
192
+ DNSName string `json:"dnsName"`
193
+ }
194
+
195
+ type meshPeer struct {
196
+ Hostname string `json:"hostname"`
197
+ DNSName string `json:"dnsName"`
198
+ Online bool `json:"online"`
199
+ }
200
+
201
+ func emitMeshPeersLoop(ctx context.Context, lc statusClient) {
202
+ emitMeshPeers(ctx, lc)
203
+ ticker := time.NewTicker(meshPeersInterval)
204
+ defer ticker.Stop()
205
+ for {
206
+ select {
207
+ case <-ctx.Done():
208
+ return
209
+ case <-ticker.C:
210
+ emitMeshPeers(ctx, lc)
211
+ }
61
212
  }
62
- fmt.Printf("MESH-URL https://%s\n", name)
213
+ }
63
214
 
64
- _ = http.Serve(ln, rp)
215
+ func emitMeshPeers(ctx context.Context, lc statusClient) {
216
+ status, err := lc.Status(ctx)
217
+ if err != nil || status == nil {
218
+ return
219
+ }
220
+ line := meshPeersFromStatus(status)
221
+ b, err := json.Marshal(line)
222
+ if err != nil {
223
+ return
224
+ }
225
+ fmt.Printf("MESH-PEERS %s\n", b)
226
+ }
227
+
228
+ func meshPeersFromStatus(status *ipnstate.Status) meshPeersSnapshot {
229
+ var self meshPeerSelf
230
+ if status.Self != nil {
231
+ self = meshPeerSelf{Hostname: status.Self.HostName, DNSName: normalizeDNSName(status.Self.DNSName)}
232
+ }
233
+
234
+ peers := make([]meshPeer, 0)
235
+ for _, ps := range status.Peer {
236
+ if ps == nil || !peerHasTag(ps, meshPeerTag) {
237
+ continue
238
+ }
239
+ peers = append(peers, meshPeer{Hostname: ps.HostName, DNSName: normalizeDNSName(ps.DNSName), Online: ps.Online})
240
+ }
241
+ sort.Slice(peers, func(i, j int) bool {
242
+ if peers[i].DNSName != peers[j].DNSName {
243
+ return peers[i].DNSName < peers[j].DNSName
244
+ }
245
+ return peers[i].Hostname < peers[j].Hostname
246
+ })
247
+ if len(peers) > meshPeersMaxPeers {
248
+ peers = peers[:meshPeersMaxPeers]
249
+ }
250
+ return meshPeersSnapshot{Self: self, Peers: peers}
251
+ }
252
+
253
+ func peerHasTag(ps *ipnstate.PeerStatus, tag string) bool {
254
+ if ps.Tags == nil {
255
+ return false
256
+ }
257
+ for i := 0; i < ps.Tags.Len(); i++ {
258
+ if ps.Tags.At(i) == tag {
259
+ return true
260
+ }
261
+ }
262
+ return false
263
+ }
264
+
265
+ func normalizeDNSName(s string) string {
266
+ return strings.ToLower(strings.TrimSuffix(s, "."))
267
+ }
268
+
269
+ // tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
270
+ // obtainable; otherwise nil. It first checks the tailnet's advertised cert
271
+ // domains, then proactively warms the cert with a bounded timeout so a failure
272
+ // surfaces here (returning nil) instead of mid-handshake later.
273
+ func tryListenTLS(ctx context.Context, s *tsnet.Server, name string) net.Listener {
274
+ lc, err := s.LocalClient()
275
+ if err != nil {
276
+ return nil
277
+ }
278
+ status, err := lc.Status(ctx)
279
+ if err != nil || status == nil {
280
+ return nil
281
+ }
282
+ eligible := false
283
+ for _, d := range status.CertDomains {
284
+ if d == name {
285
+ eligible = true
286
+ break
287
+ }
288
+ }
289
+ if !eligible {
290
+ return nil
291
+ }
292
+ // Proactively obtain the cert with a bounded timeout. If the tailnet says the
293
+ // domain is eligible but issuance still fails (HTTPS not actually enabled,
294
+ // ACME hiccup), we learn it now and fall back to http rather than hanging the
295
+ // first browser request.
296
+ warmCtx, cancel := context.WithTimeout(ctx, certWaitTimeout)
297
+ defer cancel()
298
+ if _, _, err := lc.CertPair(warmCtx, name); err != nil {
299
+ return nil
300
+ }
301
+ ln, err := s.ListenTLS("tcp", ":443")
302
+ if err != nil {
303
+ return nil
304
+ }
305
+ return ln
306
+ }
307
+
308
+ func redirectToHTTPS(name string) http.HandlerFunc {
309
+ return func(w http.ResponseWriter, r *http.Request) {
310
+ u := "https://" + name + r.URL.RequestURI()
311
+ http.Redirect(w, r, u, http.StatusPermanentRedirect)
312
+ }
313
+ }
314
+
315
+ // newEdgeProxy builds the reverse proxy to the loopback backend. It stamps
316
+ // X-Forwarded-Proto (read through *proto, set after the TLS decision) and
317
+ // X-Forwarded-Host so the app never emits mixed-content links. For an https
318
+ // loopback backend (a future self-signed local listener) it skips verification —
319
+ // the host is already validated as loopback, so this is not a trust boundary.
320
+ func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer string) *httputil.ReverseProxy {
321
+ rp := httputil.NewSingleHostReverseProxy(target)
322
+ orig := rp.Director
323
+ rp.Director = func(r *http.Request) {
324
+ orig(r)
325
+ r.Header.Set("X-Forwarded-Proto", *proto)
326
+ r.Header.Set("X-Forwarded-Host", name)
327
+ // Authenticate tailnet->loopback traffic to the app's bearer middleware.
328
+ // Set REPLACES any client-supplied Authorization (no spoofing). When no
329
+ // token is configured, DELETE a client-supplied Authorization so a tailnet
330
+ // caller can never smuggle one through to the loopback app.
331
+ if proxyBearer != "" {
332
+ r.Header.Set("Authorization", "Bearer "+proxyBearer)
333
+ } else {
334
+ r.Header.Del("Authorization")
335
+ }
336
+ }
337
+ if target.Scheme == "https" {
338
+ rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
339
+ }
340
+ return rp
65
341
  }
@@ -0,0 +1,123 @@
1
+ package main
2
+
3
+ import (
4
+ "io"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "net/url"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestResolveBackend(t *testing.T) {
13
+ cases := []struct {
14
+ name string
15
+ backend string
16
+ port string
17
+ wantErr bool
18
+ wantHost string // expected resolved u.Host when no error
19
+ }{
20
+ {"default port → loopback", "", "7777", false, "127.0.0.1:7777"},
21
+ {"explicit loopback v4", "http://127.0.0.1:8080", "0", false, "127.0.0.1:8080"},
22
+ {"loopback v6", "http://[::1]:8080", "0", false, "[::1]:8080"},
23
+ {"ipv4-mapped loopback", "http://[::ffff:127.0.0.1]:8080", "0", false, "[::ffff:127.0.0.1]:8080"},
24
+ {"localhost normalized to 127.0.0.1", "http://localhost:9000", "0", false, "127.0.0.1:9000"},
25
+ {"https loopback allowed", "https://127.0.0.1:8443", "0", false, "127.0.0.1:8443"},
26
+ {"off-host private ip rejected", "http://10.0.0.5:80", "0", true, ""},
27
+ {"0.0.0.0 rejected", "http://0.0.0.0:80", "0", true, ""},
28
+ {"public ip rejected", "http://93.184.216.34:80", "0", true, ""},
29
+ {"arbitrary dns rejected", "http://evil.example.com:80", "0", true, ""},
30
+ {"non-loopback v6 rejected", "http://[2001:db8::1]:80", "0", true, ""},
31
+ {"bad scheme rejected", "ftp://127.0.0.1:80", "0", true, ""},
32
+ {"file scheme rejected", "file:///etc/passwd", "0", true, ""},
33
+ {"garbage rejected", "://nope", "0", true, ""},
34
+ }
35
+ for _, c := range cases {
36
+ t.Run(c.name, func(t *testing.T) {
37
+ u, err := resolveBackend(c.backend, c.port)
38
+ if c.wantErr {
39
+ if err == nil {
40
+ t.Fatalf("expected error for backend=%q, got host=%q", c.backend, u.Host)
41
+ }
42
+ return
43
+ }
44
+ if err != nil {
45
+ t.Fatalf("unexpected error for backend=%q: %v", c.backend, err)
46
+ }
47
+ if u.Host != c.wantHost {
48
+ t.Fatalf("backend=%q → host=%q, want %q", c.backend, u.Host, c.wantHost)
49
+ }
50
+ })
51
+ }
52
+ }
53
+
54
+ func TestIsLoopbackHost(t *testing.T) {
55
+ loop := []string{"localhost", "127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"}
56
+ notLoop := []string{"0.0.0.0", "10.0.0.1", "192.168.1.1", "93.184.216.34", "2001:db8::1", "example.com", ""}
57
+ for _, h := range loop {
58
+ if !isLoopbackHost(h) {
59
+ t.Errorf("isLoopbackHost(%q) = false, want true", h)
60
+ }
61
+ }
62
+ for _, h := range notLoop {
63
+ if isLoopbackHost(h) {
64
+ t.Errorf("isLoopbackHost(%q) = true, want false", h)
65
+ }
66
+ }
67
+ }
68
+
69
+ // End-to-end through the actual reverse proxy (no tailnet): a real loopback
70
+ // backend must receive the X-Forwarded-Proto/Host the edge stamps, the app
71
+ // bearer, and the pointer-captured proto must reflect the post-TLS decision.
72
+ func TestEdgeProxyForwardsProtoHostAndAuth(t *testing.T) {
73
+ var gotProto, gotHost, gotAuth, gotBody string
74
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75
+ gotProto = r.Header.Get("X-Forwarded-Proto")
76
+ gotHost = r.Header.Get("X-Forwarded-Host")
77
+ gotAuth = r.Header.Get("Authorization")
78
+ io.WriteString(w, "backend-ok")
79
+ }))
80
+ defer backend.Close()
81
+
82
+ target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
83
+ proto := "http" // default before the TLS decision
84
+ rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto, "app-token")
85
+ proto = "https" // edge decided real TLS AFTER constructing the proxy
86
+
87
+ front := httptest.NewServer(rp)
88
+ defer front.Close()
89
+
90
+ res, err := http.Get(front.URL + "/app")
91
+ if err != nil {
92
+ t.Fatalf("proxy request failed: %v", err)
93
+ }
94
+ defer res.Body.Close()
95
+ b, _ := io.ReadAll(res.Body)
96
+ gotBody = string(b)
97
+
98
+ if gotProto != "https" {
99
+ t.Errorf("backend saw X-Forwarded-Proto=%q, want https (pointer-captured decision)", gotProto)
100
+ }
101
+ if gotHost != "node.tailnet.ts.net" {
102
+ t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
103
+ }
104
+ if gotAuth != "Bearer app-token" {
105
+ t.Errorf("backend saw Authorization=%q, want Bearer app-token", gotAuth)
106
+ }
107
+ if gotBody != "backend-ok" {
108
+ t.Errorf("proxy body=%q, want backend-ok", gotBody)
109
+ }
110
+ }
111
+
112
+ func TestRedirectToHTTPS(t *testing.T) {
113
+ rec := httptest.NewRecorder()
114
+ req := httptest.NewRequest("GET", "http://anything/path?q=1", nil)
115
+ redirectToHTTPS("node.tailnet.ts.net")(rec, req)
116
+ if rec.Code != http.StatusPermanentRedirect {
117
+ t.Fatalf("status=%d, want 308", rec.Code)
118
+ }
119
+ loc := rec.Header().Get("Location")
120
+ if !strings.HasPrefix(loc, "https://node.tailnet.ts.net/path") || !strings.Contains(loc, "q=1") {
121
+ t.Fatalf("Location=%q, want https://node.tailnet.ts.net/path?q=1", loc)
122
+ }
123
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
4
+ "assets": {
5
+ "aiordie-mesh-windows-amd64.exe": "3be314f27ddf69560cb34b40b70302609f28fe6feba7600580e68ce04a811a71",
6
+ "aiordie-mesh-windows-arm64.exe": "3169f41dc835522c42dc4766dcd9a608a65430fb3797120bcd1ae9e58014942c",
7
+ "aiordie-mesh-linux-amd64": "7fb0d9c624c85759fb20e789886d1f4e0e2b5621f8ee6105634c7e73fd44d049",
8
+ "aiordie-mesh-linux-arm64": "7d23eb2d8c4c9632f37ad8f11f8c8523ce7d6ce58d0607b4c73fb9ccbc261239",
9
+ "aiordie-mesh-darwin-amd64": "69336be9137a98d8a3409ea87702502fa82d573875041111ea46f65b2fd22442",
10
+ "aiordie-mesh-darwin-arm64": "f73bcb5a6107ac3c0da8a39f06af6620e2db6e66b4e3c135740d9081703fd938"
11
+ }
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -22,6 +22,8 @@
22
22
  "test:longevity": "npm run test:longevity:server && npm run test:longevity:browser",
23
23
  "build:bundle": "node scripts/build-sea.js bundle",
24
24
  "build:sea": "node scripts/build-sea.js",
25
+ "mesh:lock": "node scripts/mesh-lock.js",
26
+ "mesh:lock:check": "node scripts/mesh-lock.js --check",
25
27
  "release:pr": "bash scripts/release-pr.sh"
26
28
  },
27
29
  "keywords": [
@@ -187,6 +187,10 @@ function createControlRouter(deps) {
187
187
  }
188
188
  });
189
189
 
190
+ router.get('/mesh/peers', (req, res) => {
191
+ res.json({ peers: deps.getMeshPeers ? deps.getMeshPeers() : [] });
192
+ });
193
+
190
194
  // GET /snapshot — F15 atomic batch resync. Returns every session's derived
191
195
  // status PLUS the event cursor, captured atomically (cursor first, then
192
196
  // statuses), so after a gap/overflow the controller resyncs in ONE call and
@@ -17,29 +17,39 @@ const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
17
17
  const MIN_RESTART_DELAY_MS = 1000;
18
18
  const MAX_RESTART_DELAY_MS = 30000;
19
19
  const MAX_RETRIES = 10;
20
+ const MESH_PEERS_JSON_MAX = 256 * 1024;
20
21
 
21
22
  class MeshManager {
22
23
  constructor(options = {}) {
23
24
  this.port = options.port || 7777; // the ai-or-die port to expose
24
25
  this.dev = options.dev || false;
25
26
  this.onUrl = options.onUrl || (() => {});
26
- // Consume the key once and scrub it everywhere it could leak.
27
+ this._proxyBearer = options.authToken || null;
28
+ // Consume the key once and scrub secrets everywhere they could leak.
27
29
  this._authKey = options.authKey || process.env.AIORDIE_TS_AUTHKEY || null;
28
30
  delete process.env.AIORDIE_TS_AUTHKEY;
31
+ delete process.env.AIORDIE_PROXY_BEARER;
29
32
  this._childEnv = { ...process.env };
30
33
  delete this._childEnv.AIORDIE_TS_AUTHKEY;
34
+ delete this._childEnv.AIORDIE_PROXY_BEARER;
31
35
 
32
36
  const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
33
- const base = process.platform === 'win32'
37
+ this._appBase = process.platform === 'win32'
34
38
  ? path.join(localApp, 'ai-or-die')
35
39
  : path.join(os.homedir(), '.ai-or-die');
36
- this.stateDir = options.stateDir || path.join(base, 'ts-state');
37
- const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
38
- this.sidecar = options.sidecar || path.join(base, 'bin', exe);
40
+ this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
41
+ // The sidecar binary is content-addressed: its path embeds the lock's
42
+ // contentHash so a new build installs alongside the old one (no overwrite of
43
+ // a running .exe). Derived from the installer's lock + path helpers.
44
+ this._installer = options._installer || require('./utils/sidecar-installer');
45
+ this.sidecar = options.sidecar || this._sidecarPathFromLock(this._appBase);
39
46
  this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
40
47
 
41
48
  this.proc = null;
42
49
  this.dnsName = null;
50
+ this.scheme = null; // 'https' (edge TLS) or 'http' (degraded)
51
+ this.backend = options.backend || `http://127.0.0.1:${this.port}`; // mesh serves plaintext loopback
52
+ this._lastError = null;
43
53
  this.stopping = false;
44
54
  this.retryCount = 0;
45
55
  this._totalRestarts = 0;
@@ -47,6 +57,8 @@ class MeshManager {
47
57
  this._restartDelayTimer = null;
48
58
  this._restartDelayResolve = null;
49
59
  this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
60
+ this._stdoutBuffer = '';
61
+ this.peers = null;
50
62
  }
51
63
 
52
64
  /** Never throws — degrades to localhost/devtunnel on any failure. */
@@ -54,34 +66,53 @@ class MeshManager {
54
66
  console.log('\n Connecting mesh (Tailscale userspace)...');
55
67
  this.stopping = false;
56
68
  if (!fs.existsSync(this.sidecar)) {
57
- if (!(await this._ensureSidecar())) { this._printMissing(); return; }
69
+ if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
58
70
  }
59
71
  if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
60
72
  try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
61
73
  await this._spawn();
62
74
  }
63
75
 
76
+ /** Content-addressed sidecar path from the committed lock; safe fallback. */
77
+ _sidecarPathFromLock(base) {
78
+ try {
79
+ const lock = this._installer.loadLock();
80
+ return this._installer.sidecarPath(lock.contentHash);
81
+ } catch (_) {
82
+ const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
83
+ return path.join(base, 'bin', exe);
84
+ }
85
+ }
86
+
64
87
  /** Download + verify the sidecar from the matching release. Best-effort. */
65
88
  async _ensureSidecar() {
66
89
  try {
67
- const { ensureSidecar } = require('./utils/sidecar-installer');
68
- let version = '0.0.0';
69
- try { version = require('../package.json').version; } catch (_) {}
70
90
  console.log(' [mesh] fetching sidecar binary...');
71
- await ensureSidecar(version, this.sidecar);
91
+ await this._installer.ensureSidecar(this.sidecar);
92
+ this._lastError = null;
72
93
  return fs.existsSync(this.sidecar);
73
94
  } catch (e) {
95
+ this._lastError = e;
74
96
  if (this.dev) console.error(' [mesh] sidecar fetch failed:', e.message);
75
97
  return false;
76
98
  }
77
99
  }
78
100
 
79
101
  getStatus() {
80
- return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `https://${this.dnsName}` : null };
102
+ return {
103
+ running: this.proc !== null && !this.stopping && !!this.dnsName,
104
+ publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null,
105
+ peers: this.peers ? this.peers.peers : [],
106
+ };
107
+ }
108
+
109
+ peersFilePath() {
110
+ return path.join(this._appBase, 'mesh', 'peers.json');
81
111
  }
82
112
 
83
113
  async stop() {
84
114
  this.stopping = true;
115
+ this._deletePeersFile();
85
116
  this._clearStabilityTimer();
86
117
  clearTimeout(this._restartDelayTimer);
87
118
  if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
@@ -100,40 +131,145 @@ class MeshManager {
100
131
 
101
132
  _spawn() {
102
133
  return new Promise((resolve) => {
103
- const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
104
- // Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
105
- // stripped from this process + base child env, so it reaches only this child.
106
- const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
134
+ const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
135
+ // Pass secrets only to the sidecar child; both are stripped from this
136
+ // process + base child env before spawning.
137
+ const env = { ...this._childEnv };
138
+ if (this._authKey) env.TS_AUTHKEY = this._authKey;
139
+ if (this._proxyBearer) env.AIORDIE_PROXY_BEARER = this._proxyBearer;
140
+ this._stdoutBuffer = '';
107
141
  this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
108
142
  let done = false;
109
143
  const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
110
144
  this.proc.stdout.on('data', (d) => {
111
- const out = d.toString();
112
- if (this.dev) process.stdout.write(` [mesh] ${out}`);
113
- const url = out.match(/MESH-URL (https:\/\/\S+)/);
114
- if (url && !this.dnsName) {
115
- this.dnsName = url[1].replace(/^https:\/\//, '');
116
- this._authKey = null;
117
- this._startStabilityTimer();
118
- this.onUrl(`https://${this.dnsName}`);
119
- if (!done) { done = true; clearTimeout(timer); resolve(); }
120
- }
121
- if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
145
+ if (this.dev) process.stdout.write(` [mesh] ${d}`);
146
+ this._handleStdoutData(d, (line) => {
147
+ if (/^MESH-NOCERT\b/.test(line)) this._printNoCert();
148
+ const url = line.match(/^MESH-URL (https?):\/\/(\S+)/);
149
+ if (url && !this.dnsName) {
150
+ this.scheme = url[1];
151
+ this.dnsName = url[2];
152
+ this._authKey = null;
153
+ this._startStabilityTimer();
154
+ this.onUrl(`${this.scheme}://${this.dnsName}`);
155
+ if (!done) { done = true; clearTimeout(timer); resolve(); }
156
+ }
157
+ if (/^MESH-NEEDLOGIN\b/.test(line)) {
158
+ this._deletePeersFile();
159
+ this._printNotEnrolled();
160
+ if (!done) { done = true; clearTimeout(timer); resolve(); }
161
+ }
162
+ });
122
163
  });
123
164
  this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
124
165
  this.proc.on('error', (e) => { console.error(` \x1b[31mmesh sidecar failed: ${e.message}\x1b[0m`); this.proc = null; if (!done) { done = true; clearTimeout(timer); resolve(); } });
125
166
  this.proc.on('exit', (code) => {
126
- this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
167
+ this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
127
168
  if (!this.stopping && code !== 0) this._restart();
128
169
  });
129
170
  });
130
171
  }
131
172
 
132
- _printMissing() {
133
- console.log('\n \x1b[33mMesh: sidecar not installed.\x1b[0m Fetched on next release build; see docs/specs/mesh.md.');
173
+ _handleStdoutData(chunk, onLine) {
174
+ try {
175
+ this._stdoutBuffer += chunk.toString();
176
+ const lines = this._stdoutBuffer.split('\n');
177
+ this._stdoutBuffer = lines.pop() || '';
178
+ // Bound the carry-over: a newline-less run longer than any legitimate line
179
+ // (MESH-PEERS is capped well under this) is junk/log-spam — drop it so a
180
+ // misbehaving child can't grow the buffer without limit.
181
+ if (this._stdoutBuffer.length > MESH_PEERS_JSON_MAX) this._stdoutBuffer = '';
182
+ for (const rawLine of lines) {
183
+ const line = rawLine.replace(/\r$/, '');
184
+ this._handleStdoutLine(line);
185
+ if (onLine) onLine(line);
186
+ }
187
+ } catch (_) {}
188
+ }
189
+
190
+ _handleStdoutLine(line) {
191
+ try {
192
+ if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
193
+ const match = /^MESH-PEERS\s+(.+)$/.exec(line);
194
+ if (!match) return;
195
+ const raw = match[1];
196
+ if (Buffer.byteLength(raw, 'utf8') > MESH_PEERS_JSON_MAX) {
197
+ if (this.dev) console.debug(' [mesh] ignoring oversized MESH-PEERS payload');
198
+ return;
199
+ }
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(raw);
203
+ } catch (_) {
204
+ return;
205
+ }
206
+ const normalized = this._validatePeersPayload(parsed);
207
+ if (!normalized) return;
208
+ this.peers = normalized;
209
+ this._writePeersFile(normalized);
210
+ } catch (_) {}
211
+ }
212
+
213
+ _validatePeersPayload(payload) {
214
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
215
+ const self = payload.self;
216
+ if (!self || typeof self !== 'object' || Array.isArray(self)) return null;
217
+ if (typeof self.hostname !== 'string' || typeof self.dnsName !== 'string') return null;
218
+ if (!Array.isArray(payload.peers)) return null;
219
+ const peers = [];
220
+ for (const p of payload.peers) {
221
+ if (!p || typeof p !== 'object' || Array.isArray(p)) continue;
222
+ if (typeof p.hostname !== 'string' || typeof p.dnsName !== 'string' || typeof p.online !== 'boolean') continue;
223
+ peers.push({ hostname: p.hostname, dnsName: p.dnsName, online: p.online });
224
+ }
225
+ return { self: { hostname: self.hostname, dnsName: self.dnsName }, peers };
226
+ }
227
+
228
+ _writePeersFile(snapshot) {
229
+ const file = this.peersFilePath();
230
+ const dir = path.dirname(file);
231
+ const tmp = path.join(dir, `peers.json.${process.pid}.tmp`);
232
+ try {
233
+ fs.mkdirSync(dir, { recursive: true });
234
+ fs.writeFileSync(tmp, JSON.stringify({
235
+ version: 1,
236
+ updatedAt: Date.now(),
237
+ self: snapshot.self,
238
+ peers: snapshot.peers,
239
+ }), { mode: 0o600 });
240
+ fs.chmodSync(tmp, 0o600);
241
+ fs.renameSync(tmp, file);
242
+ } catch (_) {
243
+ try { fs.rmSync(tmp, { force: true }); } catch (_) {}
244
+ }
245
+ }
246
+
247
+ _deletePeersFile() {
248
+ this.peers = null;
249
+ try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
250
+ }
251
+
252
+ _printMissing(err) {
253
+ const code = err && err.code;
254
+ const map = {
255
+ 'unsupported-platform': `Mesh: no sidecar build for this platform (${err && err.message}).`,
256
+ 'lock-unfinalized': 'Mesh: sidecar checksum missing from this build — upgrade ai-or-die.',
257
+ 'assets-missing': 'Mesh: sidecar binaries for this build are not published yet — try again shortly.',
258
+ 'network': `Mesh: could not download the sidecar — ${err && err.message}.`,
259
+ 'checksum-mismatch': 'Mesh: sidecar checksum mismatch — refusing to run an unverified binary.',
260
+ 'locked-binary': `Mesh: ${err && err.message} — stop any running ai-or-die mesh and retry.`,
261
+ };
262
+ const line = (code && map[code]) || 'Mesh: sidecar not installed.';
263
+ console.log(`\n \x1b[33m${line}\x1b[0m See docs/specs/mesh.md.`);
134
264
  console.log(' Continuing on localhost/devtunnel.\n');
135
265
  }
136
266
 
267
+ _printNoCert() {
268
+ console.log('\n \x1b[33mMesh: tailnet HTTPS certificates are not enabled — serving plain http.\x1b[0m');
269
+ console.log(' Remote microphone (voice) and PWA install need a secure context (https).');
270
+ console.log(' Enable it once: \x1b[1mhttps://login.tailscale.com/admin/dns\x1b[0m → HTTPS Certificates.');
271
+ }
272
+
137
273
  _printNotEnrolled() {
138
274
  const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
139
275
  console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
package/src/server.js CHANGED
@@ -4105,6 +4105,7 @@ class ClaudeCodeWebServer {
4105
4105
  return {
4106
4106
  sessions: this.claudeSessions,
4107
4107
  eventBus: this.controlEventBus,
4108
+ getMeshPeers: () => this.meshManager ? this.meshManager.getStatus().peers : [],
4108
4109
  getStatusSignal: (id) => this._controlStatusSignal(id),
4109
4110
  readTail: async (id, lines) => this._controlReadTail(id, lines),
4110
4111
  createSession: async (opts) => this._controlCreateSession(opts),
@@ -1,13 +1,18 @@
1
1
  'use strict';
2
2
 
3
3
  // Sidecar installer — fetches the prebuilt aiordie-mesh tsnet binary for the
4
- // current platform from the matching GitHub release and verifies it against the
5
- // release's published SHA-256 checksums before use. Mirrors the download/verify
6
- // shape of gguf-model-manager (resumable not needed the binary is ~20MB).
4
+ // current platform and verifies it against a SHA-256 that ships INSIDE this npm
5
+ // package (mesh-sidecar.lock.json). Trust is therefore anchored by the signed
6
+ // npm artifact, not by a checksums file fetched from the same mutable GitHub
7
+ // release (which a tampered release could swap alongside the binary).
8
+ //
9
+ // Identity is content-addressed: the binary lives at the release tag
10
+ // `mesh-<contentHash>` where contentHash is derived from the sidecar source
11
+ // (see scripts/mesh-lock.js). Many ai-or-die versions can pin the same sidecar;
12
+ // CI only rebuilds when the source — and thus the hash — changes.
7
13
  //
8
14
  // Asset convention (published by .github/workflows/release-on-main.yml):
9
15
  // aiordie-mesh-<plat>-<arch>[.exe] plat: windows|linux|darwin arch: amd64|arm64
10
- // aiordie-mesh-checksums.txt "<sha256> <assetname>" per line
11
16
 
12
17
  const fs = require('fs');
13
18
  const fsp = require('fs').promises;
@@ -19,6 +24,22 @@ const REPO = 'animeshkundu/ai-or-die';
19
24
  const PLAT = { win32: 'windows', linux: 'linux', darwin: 'darwin' };
20
25
  const ARCH = { x64: 'amd64', arm64: 'arm64' };
21
26
 
27
+ // Typed failure so the manager can print an accurate cause instead of a blanket
28
+ // "fetched on next release build".
29
+ class SidecarError extends Error {
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = 'SidecarError';
33
+ this.code = code; // unsupported-platform | lock-unfinalized | assets-missing | network | checksum-mismatch | locked-binary
34
+ }
35
+ }
36
+
37
+ function loadLock() {
38
+ // The lock ships at the package root, two levels up from src/utils/.
39
+ const p = path.join(__dirname, '..', '..', 'mesh-sidecar.lock.json');
40
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
41
+ }
42
+
22
43
  function assetName() {
23
44
  const plat = PLAT[process.platform];
24
45
  const arch = ARCH[process.arch];
@@ -26,10 +47,33 @@ function assetName() {
26
47
  return `aiordie-mesh-${plat}-${arch}${process.platform === 'win32' ? '.exe' : ''}`;
27
48
  }
28
49
 
29
- function sidecarPath() {
50
+ function baseDir() {
30
51
  const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
31
- const base = process.platform === 'win32' ? path.join(localApp, 'ai-or-die') : path.join(os.homedir(), '.ai-or-die');
32
- return path.join(base, 'bin', `aiordie-mesh${process.platform === 'win32' ? '.exe' : ''}`);
52
+ return process.platform === 'win32' ? path.join(localApp, 'ai-or-die') : path.join(os.homedir(), '.ai-or-die');
53
+ }
54
+
55
+ // Versioned path: a new contentHash installs alongside the old one, so we never
56
+ // rename over a binary that a running sidecar still has open (Windows EPERM).
57
+ function sidecarPath(contentHash) {
58
+ const ext = process.platform === 'win32' ? '.exe' : '';
59
+ return path.join(baseDir(), 'bin', `aiordie-mesh-${contentHash}${ext}`);
60
+ }
61
+
62
+ // The release ref to fetch from. Defaults to mesh-<contentHash>; an explicit
63
+ // override is a dev/testing escape hatch and is announced loudly.
64
+ function meshRef(lock) {
65
+ const override = process.env.AIORDIE_MESH_REF || process.env.AIORDIE_MESH_VERSION;
66
+ if (override) {
67
+ // Constrain to a safe tag shape — no path segments / traversal — even though
68
+ // the host is fixed. This is a dev/testing escape hatch, announced loudly.
69
+ const ref = /^mesh-/.test(override) ? override : `mesh-${override}`;
70
+ if (!/^mesh-[A-Za-z0-9._-]+$/.test(ref)) {
71
+ throw new SidecarError('unsupported-platform', `invalid AIORDIE_MESH_REF ${JSON.stringify(override)}`);
72
+ }
73
+ console.warn(` \x1b[33m[mesh] overriding sidecar ref → ${ref} (AIORDIE_MESH_REF/VERSION)\x1b[0m`);
74
+ return ref;
75
+ }
76
+ return `mesh-${lock.contentHash}`;
33
77
  }
34
78
 
35
79
  async function _sha256(file) {
@@ -42,68 +86,87 @@ async function _sha256(file) {
42
86
  });
43
87
  }
44
88
 
45
- async function _fetchText(url, ms = 15000) {
89
+ async function _download(url, tmp, ms = 120000) {
46
90
  const ac = new AbortController();
47
91
  const t = setTimeout(() => ac.abort(), ms);
92
+ let r;
48
93
  try {
49
- const r = await fetch(url, { signal: ac.signal });
50
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
51
- return await r.text();
52
- } finally { clearTimeout(t); }
94
+ r = await fetch(url, { signal: ac.signal });
95
+ } catch (e) {
96
+ throw new SidecarError('network', `could not reach ${hostOf(url)} (${e.message})`);
97
+ } finally {
98
+ clearTimeout(t);
99
+ }
100
+ if (r.status === 404) throw new SidecarError('assets-missing', `HTTP 404 for ${url}`);
101
+ if (!r.ok || !r.body) throw new SidecarError('network', `HTTP ${r.status} for ${url}`);
102
+ // Exclusive create — never follow/overwrite a pre-planted file or symlink.
103
+ const out = fs.createWriteStream(tmp, { flags: 'wx' });
104
+ const reader = r.body.getReader();
105
+ for (;;) {
106
+ const { done, value } = await reader.read();
107
+ if (done) break;
108
+ await new Promise((res, rej) => out.write(value, (e) => (e ? rej(e) : res())));
109
+ }
110
+ await new Promise((res) => out.end(res));
53
111
  }
54
112
 
55
- async function _download(url, tmp, ms = 120000) {
56
- const ac = new AbortController();
57
- const t = setTimeout(() => ac.abort(), ms);
58
- try {
59
- const r = await fetch(url, { signal: ac.signal });
60
- if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
61
- // Exclusive create — never follow/overwrite a pre-planted file or symlink.
62
- const out = fs.createWriteStream(tmp, { flags: 'wx' });
63
- const reader = r.body.getReader();
64
- for (;;) {
65
- const { done, value } = await reader.read();
66
- if (done) break;
67
- await new Promise((res, rej) => out.write(value, (e) => (e ? rej(e) : res())));
68
- }
69
- await new Promise((res) => out.end(res));
70
- } finally { clearTimeout(t); }
113
+ function hostOf(url) {
114
+ try { return new URL(url).host; } catch (_) { return 'github.com'; }
71
115
  }
72
116
 
73
- // Ensure the sidecar binary exists locally AND matches the release checksum;
74
- // download + verify if missing or stale. Returns the path; throws on failure.
75
- async function ensureSidecar(version, dest = sidecarPath()) {
117
+ // Ensure the sidecar binary exists locally AND matches the lock's checksum;
118
+ // download + verify if missing. Returns the path; throws SidecarError on failure.
119
+ // `opts.lock` injects a lock object (tests); production reads the committed lock.
120
+ async function ensureSidecar(dest, opts = {}) {
121
+ const lock = opts.lock || loadLock();
76
122
  const name = assetName();
77
- if (!name) throw new Error(`unsupported platform ${process.platform}/${process.arch}`);
78
-
79
- // Resolve the expected hash first so an existing file is also verified.
80
- const baseUrl = `https://github.com/${REPO}/releases/download/v${version}`;
81
- const sums = await _fetchText(`${baseUrl}/aiordie-mesh-checksums.txt`);
82
- const line = sums.split('\n').find((l) => {
83
- const f = l.trim().split(/\s+/)[1]; // exact filename column, not endsWith
84
- return f === name;
85
- });
86
- if (!line) throw new Error(`no checksum for ${name} in release v${version}`);
87
- const want = line.trim().split(/\s+/)[0].toLowerCase();
123
+ if (!name) throw new SidecarError('unsupported-platform', `unsupported platform ${process.platform}/${process.arch}`);
124
+
125
+ const want = (lock.assets && lock.assets[name] || '').toLowerCase();
126
+ if (!want) {
127
+ throw new SidecarError('lock-unfinalized', `mesh-sidecar.lock.json has no checksum for ${name} (built without the mesh asset pipeline?)`);
128
+ }
129
+
130
+ dest = dest || sidecarPath(lock.contentHash);
88
131
 
89
132
  // Existing file: trust only if it matches; otherwise replace it.
90
133
  if (fs.existsSync(dest)) {
91
134
  if ((await _sha256(dest)).toLowerCase() === want) return dest;
92
- try { await fsp.unlink(dest); } catch (_) {}
135
+ try { await fsp.unlink(dest); }
136
+ catch (e) {
137
+ if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
138
+ throw new SidecarError('locked-binary', `cannot replace in-use sidecar at ${dest} (${e.code})`);
139
+ }
140
+ throw e; // surface real filesystem faults instead of masking them
141
+ }
93
142
  }
94
143
 
95
144
  await fsp.mkdir(path.dirname(dest), { recursive: true });
145
+ const ref = meshRef(lock);
146
+ const url = `https://github.com/${REPO}/releases/download/${ref}/${name}`;
96
147
  const tmp = `${dest}.${crypto.randomBytes(8).toString('hex')}.incomplete`;
97
148
  try {
98
- await _download(`${baseUrl}/${name}`, tmp);
149
+ await _download(url, tmp);
99
150
  // Verify BEFORE the bytes ever reach the runnable path (no TOCTOU window).
100
- if ((await _sha256(tmp)).toLowerCase() !== want) throw new Error(`checksum mismatch for ${name}`);
151
+ if ((await _sha256(tmp)).toLowerCase() !== want) {
152
+ throw new SidecarError('checksum-mismatch', `checksum mismatch for ${name} from ${ref}`);
153
+ }
101
154
  if (process.platform !== 'win32') { try { await fsp.chmod(tmp, 0o755); } catch (_) {} }
102
- await fsp.rename(tmp, dest);
155
+ try {
156
+ await fsp.rename(tmp, dest);
157
+ } catch (e) {
158
+ // Lost a race to a concurrent installer (or the file is locked). If the
159
+ // destination is now present and valid, accept it; otherwise surface it.
160
+ if (fs.existsSync(dest) && (await _sha256(dest)).toLowerCase() === want) return dest;
161
+ if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
162
+ throw new SidecarError('locked-binary', `cannot install sidecar at ${dest} (${e.code})`);
163
+ }
164
+ throw e;
165
+ }
103
166
  } finally {
104
167
  try { await fsp.unlink(tmp); } catch (_) {}
105
168
  }
106
169
  return dest;
107
170
  }
108
171
 
109
- module.exports = { ensureSidecar, sidecarPath, assetName };
172
+ module.exports = { ensureSidecar, sidecarPath, assetName, loadLock, SidecarError };