ai-or-die 0.1.87 → 0.1.88

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,6 +209,9 @@ 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,
package/mesh/main.go CHANGED
@@ -5,16 +5,28 @@
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
10
20
  // MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
11
- // MESH-ERR <msg> fatal
21
+ // MESH-ERR <msg> fatal
12
22
  package main
13
23
 
14
24
  import (
15
25
  "context"
26
+ "crypto/tls"
16
27
  "flag"
17
28
  "fmt"
29
+ "net"
18
30
  "net/http"
19
31
  "net/http/httputil"
20
32
  "net/url"
@@ -25,12 +37,33 @@ import (
25
37
  "tailscale.com/tsnet"
26
38
  )
27
39
 
40
+ // version is stamped at build time via -ldflags "-X main.version=<contentHash>".
41
+ var version = "dev"
42
+
43
+ const certWaitTimeout = 20 * time.Second
44
+
28
45
  func main() {
29
- port := flag.String("port", "7777", "local ai-or-die port to expose")
46
+ port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
47
+ backend := flag.String("backend", "", "full backend URL to proxy to (overrides --port); must be loopback")
30
48
  host := flag.String("hostname", "aiordie", "tailnet hostname")
31
49
  dir := flag.String("statedir", "./ts-state", "persistent node state dir")
50
+ showVersion := flag.Bool("version", false, "print the build version (content hash) and exit")
32
51
  flag.Parse()
33
52
 
53
+ if *showVersion {
54
+ fmt.Println(version)
55
+ return
56
+ }
57
+
58
+ // Resolve + validate the backend target. It MUST be loopback: the sidecar is
59
+ // a tailnet-facing reverse proxy, and pointing it off-host would expose an
60
+ // arbitrary origin to the whole tailnet.
61
+ target, err := resolveBackend(*backend, *port)
62
+ if err != nil {
63
+ fmt.Printf("MESH-ERR %v\n", err)
64
+ os.Exit(1)
65
+ }
66
+
34
67
  s := &tsnet.Server{Dir: *dir, Hostname: *host}
35
68
  // Surface the login URL exactly once instead of tsnet's default stderr spam.
36
69
  s.AuthKey = os.Getenv("TS_AUTHKEY")
@@ -39,27 +72,158 @@ func main() {
39
72
  // Bring the node up; report the enroll URL if it needs login.
40
73
  ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
41
74
  defer cancel()
42
- if _, err := s.Up(ctx); err != nil {
75
+ st, err := s.Up(ctx)
76
+ if err != nil {
43
77
  // Up blocks until authed; on timeout it's almost always missing/!key.
44
78
  fmt.Printf("MESH-NEEDLOGIN https://login.tailscale.com/admin/settings/keys\n")
45
79
  fmt.Printf("MESH-ERR %v\n", err)
46
80
  os.Exit(1)
47
81
  }
48
82
 
83
+ name := *host
84
+ if st != nil && st.Self != nil && st.Self.DNSName != "" {
85
+ name = strings.TrimSuffix(st.Self.DNSName, ".")
86
+ }
87
+
88
+ // One reverse proxy; the edge scheme it advertises to the backend is decided
89
+ // just below and captured by pointer so the X-Forwarded-Proto header is
90
+ // always accurate. The terminal client derives ws/wss from window.location,
91
+ // so wss works regardless; this header is hygiene for any absolute-URL or
92
+ // redirect the app generates (no mixed content).
93
+ edgeProto := "http"
94
+ rp := newEdgeProxy(target, name, &edgeProto)
95
+
96
+ // Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
97
+ // without provisioning the cert; the ACME work (and any failure) happens
98
+ // lazily inside the first TLS handshake. So we probe cert readiness up front
99
+ // and only advertise https:// when we are confident the handshake will work.
100
+ if tlsLn := tryListenTLS(ctx, s, name); tlsLn != nil {
101
+ edgeProto = "https"
102
+ // Best-effort :80 -> https redirect so a bare http:// hit is upgraded.
103
+ if httpLn, e := s.Listen("tcp", ":80"); e == nil {
104
+ go http.Serve(httpLn, redirectToHTTPS(name))
105
+ }
106
+ fmt.Printf("MESH-URL https://%s\n", name)
107
+ // http.Serve only returns on failure; exit non-zero so the manager
108
+ // (which restarts on a non-zero exit) brings the sidecar back.
109
+ err := http.Serve(tlsLn, rp)
110
+ fmt.Printf("MESH-ERR %v\n", err)
111
+ os.Exit(1)
112
+ }
113
+
114
+ // Degraded: no usable cert. Serve plain http and say so. http://<name>.ts.net
115
+ // is NOT a browser secure context, so remote mic/PWA will not work until the
116
+ // operator enables HTTPS Certificates in the tailnet admin console.
49
117
  ln, err := s.Listen("tcp", ":80")
50
118
  if err != nil {
51
119
  fmt.Printf("MESH-ERR %v\n", err)
52
120
  os.Exit(1)
53
121
  }
54
- target, _ := url.Parse("http://127.0.0.1:" + *port)
55
- rp := httputil.NewSingleHostReverseProxy(target) // handles WS upgrade via hijack
122
+ fmt.Printf("MESH-NOCERT\n")
123
+ fmt.Printf("MESH-URL http://%s\n", name)
124
+ serveErr := http.Serve(ln, rp)
125
+ fmt.Printf("MESH-ERR %v\n", serveErr)
126
+ os.Exit(1)
127
+ }
56
128
 
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, ".")
129
+ // resolveBackend builds the proxy target from --backend (preferred) or --port,
130
+ // and rejects anything that is not an http(s) loopback origin.
131
+ func resolveBackend(backend, port string) (*url.URL, error) {
132
+ raw := backend
133
+ if raw == "" {
134
+ raw = "http://127.0.0.1:" + port
61
135
  }
62
- fmt.Printf("MESH-URL https://%s\n", name)
136
+ u, err := url.Parse(raw)
137
+ if err != nil {
138
+ return nil, fmt.Errorf("invalid --backend %q: %v", raw, err)
139
+ }
140
+ if u.Scheme != "http" && u.Scheme != "https" {
141
+ return nil, fmt.Errorf("backend scheme must be http/https, got %q", u.Scheme)
142
+ }
143
+ if !isLoopbackHost(u.Hostname()) {
144
+ return nil, fmt.Errorf("backend host must be loopback (127.0.0.1/::1/localhost), got %q", u.Hostname())
145
+ }
146
+ // Pin "localhost" to a numeric loopback so the later proxy dial cannot be
147
+ // redirected off-host by a poisoned resolver / hosts file.
148
+ if u.Hostname() == "localhost" {
149
+ if p := u.Port(); p != "" {
150
+ u.Host = "127.0.0.1:" + p
151
+ } else {
152
+ u.Host = "127.0.0.1"
153
+ }
154
+ }
155
+ return u, nil
156
+ }
63
157
 
64
- _ = http.Serve(ln, rp)
158
+ func isLoopbackHost(h string) bool {
159
+ if h == "localhost" {
160
+ return true
161
+ }
162
+ ip := net.ParseIP(h)
163
+ return ip != nil && ip.IsLoopback()
164
+ }
165
+
166
+ // tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
167
+ // obtainable; otherwise nil. It first checks the tailnet's advertised cert
168
+ // domains, then proactively warms the cert with a bounded timeout so a failure
169
+ // surfaces here (returning nil) instead of mid-handshake later.
170
+ func tryListenTLS(ctx context.Context, s *tsnet.Server, name string) net.Listener {
171
+ lc, err := s.LocalClient()
172
+ if err != nil {
173
+ return nil
174
+ }
175
+ status, err := lc.Status(ctx)
176
+ if err != nil || status == nil {
177
+ return nil
178
+ }
179
+ eligible := false
180
+ for _, d := range status.CertDomains {
181
+ if d == name {
182
+ eligible = true
183
+ break
184
+ }
185
+ }
186
+ if !eligible {
187
+ return nil
188
+ }
189
+ // Proactively obtain the cert with a bounded timeout. If the tailnet says the
190
+ // domain is eligible but issuance still fails (HTTPS not actually enabled,
191
+ // ACME hiccup), we learn it now and fall back to http rather than hanging the
192
+ // first browser request.
193
+ warmCtx, cancel := context.WithTimeout(ctx, certWaitTimeout)
194
+ defer cancel()
195
+ if _, _, err := lc.CertPair(warmCtx, name); err != nil {
196
+ return nil
197
+ }
198
+ ln, err := s.ListenTLS("tcp", ":443")
199
+ if err != nil {
200
+ return nil
201
+ }
202
+ return ln
203
+ }
204
+
205
+ func redirectToHTTPS(name string) http.HandlerFunc {
206
+ return func(w http.ResponseWriter, r *http.Request) {
207
+ u := "https://" + name + r.URL.RequestURI()
208
+ http.Redirect(w, r, u, http.StatusPermanentRedirect)
209
+ }
210
+ }
211
+
212
+ // newEdgeProxy builds the reverse proxy to the loopback backend. It stamps
213
+ // X-Forwarded-Proto (read through *proto, set after the TLS decision) and
214
+ // X-Forwarded-Host so the app never emits mixed-content links. For an https
215
+ // loopback backend (a future self-signed local listener) it skips verification —
216
+ // the host is already validated as loopback, so this is not a trust boundary.
217
+ func newEdgeProxy(target *url.URL, name string, proto *string) *httputil.ReverseProxy {
218
+ rp := httputil.NewSingleHostReverseProxy(target)
219
+ orig := rp.Director
220
+ rp.Director = func(r *http.Request) {
221
+ orig(r)
222
+ r.Header.Set("X-Forwarded-Proto", *proto)
223
+ r.Header.Set("X-Forwarded-Host", name)
224
+ }
225
+ if target.Scheme == "https" {
226
+ rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
227
+ }
228
+ return rp
65
229
  }
@@ -0,0 +1,119 @@
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, and the
71
+ // pointer-captured proto must reflect the post-TLS decision.
72
+ func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
73
+ var gotProto, gotHost, 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
+ io.WriteString(w, "backend-ok")
78
+ }))
79
+ defer backend.Close()
80
+
81
+ target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
82
+ proto := "http" // default before the TLS decision
83
+ rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto)
84
+ proto = "https" // edge decided real TLS AFTER constructing the proxy
85
+
86
+ front := httptest.NewServer(rp)
87
+ defer front.Close()
88
+
89
+ res, err := http.Get(front.URL + "/app")
90
+ if err != nil {
91
+ t.Fatalf("proxy request failed: %v", err)
92
+ }
93
+ defer res.Body.Close()
94
+ b, _ := io.ReadAll(res.Body)
95
+ gotBody = string(b)
96
+
97
+ if gotProto != "https" {
98
+ t.Errorf("backend saw X-Forwarded-Proto=%q, want https (pointer-captured decision)", gotProto)
99
+ }
100
+ if gotHost != "node.tailnet.ts.net" {
101
+ t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
102
+ }
103
+ if gotBody != "backend-ok" {
104
+ t.Errorf("proxy body=%q, want backend-ok", gotBody)
105
+ }
106
+ }
107
+
108
+ func TestRedirectToHTTPS(t *testing.T) {
109
+ rec := httptest.NewRecorder()
110
+ req := httptest.NewRequest("GET", "http://anything/path?q=1", nil)
111
+ redirectToHTTPS("node.tailnet.ts.net")(rec, req)
112
+ if rec.Code != http.StatusPermanentRedirect {
113
+ t.Fatalf("status=%d, want 308", rec.Code)
114
+ }
115
+ loc := rec.Header().Get("Location")
116
+ if !strings.HasPrefix(loc, "https://node.tailnet.ts.net/path") || !strings.Contains(loc, "q=1") {
117
+ t.Fatalf("Location=%q, want https://node.tailnet.ts.net/path?q=1", loc)
118
+ }
119
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "contentHash": "fb010e2cd6a930ef7eeac0de0adaf903cdb7252815650c38eac2d72f15da6e3d",
4
+ "assets": {
5
+ "aiordie-mesh-windows-amd64.exe": "7a30a635b726e5cae694e3f48b37b329d470e8cf493ac5f025b99ea4a0fbded0",
6
+ "aiordie-mesh-windows-arm64.exe": "6d056c561538a40991a975e410b61b7ba4e076c456ee280744ae9ade65202dda",
7
+ "aiordie-mesh-linux-amd64": "2e5fd52842b67ec56647efb59ce8944bb211055b7630a19206a67b2c80b328fd",
8
+ "aiordie-mesh-linux-arm64": "7508a98a2762feb3405df54150a34c53028b5588b44eac504835c56a5df82822",
9
+ "aiordie-mesh-darwin-amd64": "d095c85d01036f3a9a529179c9ef5ca67231150bd49fc5adf1c4fb4e0d0da11b",
10
+ "aiordie-mesh-darwin-arm64": "8d115b8233697995dc0d7bab8f45f881bba2d37896c86cd70ea626965b1e4cb0"
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.88",
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": [
@@ -34,12 +34,18 @@ class MeshManager {
34
34
  ? path.join(localApp, 'ai-or-die')
35
35
  : path.join(os.homedir(), '.ai-or-die');
36
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);
37
+ // The sidecar binary is content-addressed: its path embeds the lock's
38
+ // contentHash so a new build installs alongside the old one (no overwrite of
39
+ // a running .exe). Derived from the installer's lock + path helpers.
40
+ this._installer = options._installer || require('./utils/sidecar-installer');
41
+ this.sidecar = options.sidecar || this._sidecarPathFromLock(base);
39
42
  this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
40
43
 
41
44
  this.proc = null;
42
45
  this.dnsName = null;
46
+ this.scheme = null; // 'https' (edge TLS) or 'http' (degraded)
47
+ this.backend = options.backend || `http://127.0.0.1:${this.port}`; // mesh serves plaintext loopback
48
+ this._lastError = null;
43
49
  this.stopping = false;
44
50
  this.retryCount = 0;
45
51
  this._totalRestarts = 0;
@@ -54,30 +60,40 @@ class MeshManager {
54
60
  console.log('\n Connecting mesh (Tailscale userspace)...');
55
61
  this.stopping = false;
56
62
  if (!fs.existsSync(this.sidecar)) {
57
- if (!(await this._ensureSidecar())) { this._printMissing(); return; }
63
+ if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
58
64
  }
59
65
  if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
60
66
  try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
61
67
  await this._spawn();
62
68
  }
63
69
 
70
+ /** Content-addressed sidecar path from the committed lock; safe fallback. */
71
+ _sidecarPathFromLock(base) {
72
+ try {
73
+ const lock = this._installer.loadLock();
74
+ return this._installer.sidecarPath(lock.contentHash);
75
+ } catch (_) {
76
+ const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
77
+ return path.join(base, 'bin', exe);
78
+ }
79
+ }
80
+
64
81
  /** Download + verify the sidecar from the matching release. Best-effort. */
65
82
  async _ensureSidecar() {
66
83
  try {
67
- const { ensureSidecar } = require('./utils/sidecar-installer');
68
- let version = '0.0.0';
69
- try { version = require('../package.json').version; } catch (_) {}
70
84
  console.log(' [mesh] fetching sidecar binary...');
71
- await ensureSidecar(version, this.sidecar);
85
+ await this._installer.ensureSidecar(this.sidecar);
86
+ this._lastError = null;
72
87
  return fs.existsSync(this.sidecar);
73
88
  } catch (e) {
89
+ this._lastError = e;
74
90
  if (this.dev) console.error(' [mesh] sidecar fetch failed:', e.message);
75
91
  return false;
76
92
  }
77
93
  }
78
94
 
79
95
  getStatus() {
80
- return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `https://${this.dnsName}` : null };
96
+ return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null };
81
97
  }
82
98
 
83
99
  async stop() {
@@ -100,7 +116,7 @@ class MeshManager {
100
116
 
101
117
  _spawn() {
102
118
  return new Promise((resolve) => {
103
- const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
119
+ const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
104
120
  // Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
105
121
  // stripped from this process + base child env, so it reaches only this child.
106
122
  const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
@@ -110,12 +126,14 @@ class MeshManager {
110
126
  this.proc.stdout.on('data', (d) => {
111
127
  const out = d.toString();
112
128
  if (this.dev) process.stdout.write(` [mesh] ${out}`);
113
- const url = out.match(/MESH-URL (https:\/\/\S+)/);
129
+ if (/MESH-NOCERT/.test(out)) this._printNoCert();
130
+ const url = out.match(/MESH-URL (https?):\/\/(\S+)/);
114
131
  if (url && !this.dnsName) {
115
- this.dnsName = url[1].replace(/^https:\/\//, '');
132
+ this.scheme = url[1];
133
+ this.dnsName = url[2];
116
134
  this._authKey = null;
117
135
  this._startStabilityTimer();
118
- this.onUrl(`https://${this.dnsName}`);
136
+ this.onUrl(`${this.scheme}://${this.dnsName}`);
119
137
  if (!done) { done = true; clearTimeout(timer); resolve(); }
120
138
  }
121
139
  if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
@@ -129,11 +147,27 @@ class MeshManager {
129
147
  });
130
148
  }
131
149
 
132
- _printMissing() {
133
- console.log('\n \x1b[33mMesh: sidecar not installed.\x1b[0m Fetched on next release build; see docs/specs/mesh.md.');
150
+ _printMissing(err) {
151
+ const code = err && err.code;
152
+ const map = {
153
+ 'unsupported-platform': `Mesh: no sidecar build for this platform (${err && err.message}).`,
154
+ 'lock-unfinalized': 'Mesh: sidecar checksum missing from this build — upgrade ai-or-die.',
155
+ 'assets-missing': 'Mesh: sidecar binaries for this build are not published yet — try again shortly.',
156
+ 'network': `Mesh: could not download the sidecar — ${err && err.message}.`,
157
+ 'checksum-mismatch': 'Mesh: sidecar checksum mismatch — refusing to run an unverified binary.',
158
+ 'locked-binary': `Mesh: ${err && err.message} — stop any running ai-or-die mesh and retry.`,
159
+ };
160
+ const line = (code && map[code]) || 'Mesh: sidecar not installed.';
161
+ console.log(`\n \x1b[33m${line}\x1b[0m See docs/specs/mesh.md.`);
134
162
  console.log(' Continuing on localhost/devtunnel.\n');
135
163
  }
136
164
 
165
+ _printNoCert() {
166
+ console.log('\n \x1b[33mMesh: tailnet HTTPS certificates are not enabled — serving plain http.\x1b[0m');
167
+ console.log(' Remote microphone (voice) and PWA install need a secure context (https).');
168
+ console.log(' Enable it once: \x1b[1mhttps://login.tailscale.com/admin/dns\x1b[0m → HTTPS Certificates.');
169
+ }
170
+
137
171
  _printNotEnrolled() {
138
172
  const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
139
173
  console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
@@ -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 };