ai-or-die 0.1.86 → 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.86",
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:');