ai-or-die 0.1.90 → 0.1.91

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/mesh/go.mod CHANGED
@@ -1,4 +1,4 @@
1
- module aiordie-mesh
1
+ module ai-or-die-mesh
2
2
 
3
3
  go 1.23
4
4
 
package/mesh/main.go CHANGED
@@ -1,4 +1,4 @@
1
- // Command aiordie-mesh is the userspace mesh sidecar for ai-or-die. It joins a
1
+ // Command ai-or-die-mesh is the userspace mesh sidecar for ai-or-die. It joins a
2
2
  // Tailscale tailnet entirely in userspace via tsnet (no kernel TUN, no admin,
3
3
  // no system service) and reverse-proxies the tailnet listener to ai-or-die's
4
4
  // local HTTP/WebSocket port. Only this one port is exposed on the mesh; the
@@ -18,16 +18,24 @@
18
18
  // MESH-URL http://<name> node is up, but TLS certs unavailable (degraded)
19
19
  // MESH-NOCERT hint: enable HTTPS Certificates in the tailnet
20
20
  // MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
21
+ // MESH-EGRESS http://127.0.0.1:<port> <token> loopback CONNECT proxy for a
22
+ // same-box conductor to reach tagged tailnet peers
23
+ // MESH-UNTAGGED <selfTagged> <total> <tagged> tailnet peers exist but none are
24
+ // tagged tag:aiordie — discovery will stay empty
21
25
  // MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
22
26
  // MESH-ERR <msg> fatal
23
27
  package main
24
28
 
25
29
  import (
26
30
  "context"
31
+ "crypto/rand"
32
+ "crypto/subtle"
27
33
  "crypto/tls"
34
+ "encoding/hex"
28
35
  "encoding/json"
29
36
  "flag"
30
37
  "fmt"
38
+ "io"
31
39
  "net"
32
40
  "net/http"
33
41
  "net/http/httputil"
@@ -35,6 +43,7 @@ import (
35
43
  "os"
36
44
  "sort"
37
45
  "strings"
46
+ "sync"
38
47
  "time"
39
48
 
40
49
  "tailscale.com/ipn/ipnstate"
@@ -93,6 +102,10 @@ func main() {
93
102
 
94
103
  if lc, err := s.LocalClient(); err == nil {
95
104
  go emitMeshPeersLoop(context.Background(), lc)
105
+ // Loopback egress: lets a same-box conductor (github-router) reach tagged
106
+ // tailnet peers over WireGuard without the host OS joining the tailnet.
107
+ // Best-effort — a failure here never blocks serving.
108
+ startEgressProxy(s, lc)
96
109
  }
97
110
 
98
111
  name := *host
@@ -223,6 +236,29 @@ func emitMeshPeers(ctx context.Context, lc statusClient) {
223
236
  return
224
237
  }
225
238
  fmt.Printf("MESH-PEERS %s\n", b)
239
+
240
+ // Diagnostic: tailnet peers exist but none are tagged tag:aiordie, so the
241
+ // snapshot above is empty and fleet discovery will surface nothing. Emit a
242
+ // machine-parseable line the manager turns into an actionable hint — the
243
+ // silent-empty-peers state is the exact failure this whole path guards against.
244
+ total, tagged := peerCounts(status)
245
+ if total > 0 && tagged == 0 {
246
+ selfTagged := status.Self != nil && peerHasTag(status.Self, meshPeerTag)
247
+ fmt.Printf("MESH-UNTAGGED %t %d %d\n", selfTagged, total, tagged)
248
+ }
249
+ }
250
+
251
+ func peerCounts(status *ipnstate.Status) (total, tagged int) {
252
+ for _, ps := range status.Peer {
253
+ if ps == nil {
254
+ continue
255
+ }
256
+ total++
257
+ if peerHasTag(ps, meshPeerTag) {
258
+ tagged++
259
+ }
260
+ }
261
+ return total, tagged
226
262
  }
227
263
 
228
264
  func meshPeersFromStatus(status *ipnstate.Status) meshPeersSnapshot {
@@ -339,3 +375,135 @@ func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer strin
339
375
  }
340
376
  return rp
341
377
  }
378
+
379
+ // startEgressProxy stands up a loopback HTTP CONNECT proxy so a same-box conductor
380
+ // process (github-router) can reach TAGGED tailnet peers over the sidecar's
381
+ // in-process WireGuard — the host OS never joins the tailnet. It is bound to
382
+ // loopback, gated by a random per-process bearer, and restricted to tagged
383
+ // .ts.net peers on the served ports, so a stray local process can neither turn it
384
+ // into a generic tailnet egress nor learn the token off the wire. The endpoint +
385
+ // token are announced via MESH-EGRESS for the manager to persist (0600).
386
+ // Best-effort: any failure here never blocks serving.
387
+ func startEgressProxy(s *tsnet.Server, lc statusClient) {
388
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
389
+ if err != nil {
390
+ return
391
+ }
392
+ token, err := randToken()
393
+ if err != nil {
394
+ _ = ln.Close()
395
+ return
396
+ }
397
+ fmt.Printf("MESH-EGRESS http://%s %s\n", ln.Addr().String(), token)
398
+ srv := &http.Server{Handler: egressHandler(s.Dial, lc, token)}
399
+ go func() { _ = srv.Serve(ln) }()
400
+ }
401
+
402
+ // dialFunc dials a tailnet address (tsnet.Server.Dial); injectable for tests.
403
+ type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
404
+
405
+ func egressHandler(dial dialFunc, lc statusClient, token string) http.Handler {
406
+ want := "Bearer " + token
407
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
408
+ if r.Method != http.MethodConnect {
409
+ http.Error(w, "only CONNECT is supported", http.StatusMethodNotAllowed)
410
+ return
411
+ }
412
+ // Constant-time bearer check on the proxy hop so a stray local process
413
+ // cannot use the tailnet egress without the per-process token.
414
+ got := r.Header.Get("Proxy-Authorization")
415
+ if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
416
+ w.Header().Set("Proxy-Authenticate", "Bearer")
417
+ http.Error(w, "proxy authorization required", http.StatusProxyAuthRequired)
418
+ return
419
+ }
420
+ host, port, err := net.SplitHostPort(r.Host)
421
+ if err != nil {
422
+ http.Error(w, "bad CONNECT target", http.StatusBadRequest)
423
+ return
424
+ }
425
+ if !egressTargetAllowed(lc, host, port) {
426
+ http.Error(w, "forbidden CONNECT target", http.StatusForbidden)
427
+ return
428
+ }
429
+ upstream, err := dial(r.Context(), "tcp", net.JoinHostPort(host, port))
430
+ if err != nil {
431
+ http.Error(w, "upstream dial failed", http.StatusBadGateway)
432
+ return
433
+ }
434
+ hj, ok := w.(http.Hijacker)
435
+ if !ok {
436
+ _ = upstream.Close()
437
+ http.Error(w, "hijack unsupported", http.StatusInternalServerError)
438
+ return
439
+ }
440
+ clientConn, _, err := hj.Hijack()
441
+ if err != nil {
442
+ _ = upstream.Close()
443
+ return
444
+ }
445
+ _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
446
+ // Splice both directions. Half-close each write end on its own EOF (so a
447
+ // one-way FIN — e.g. an HTTP/1.1 request with Connection: close — does not
448
+ // truncate the reply mid-flight), then fully close once BOTH directions
449
+ // have drained. net.Pipe and other non-TCP conns lack CloseWrite; the type
450
+ // assertion simply skips the half-close for them.
451
+ var wg sync.WaitGroup
452
+ wg.Add(2)
453
+ go func() {
454
+ defer wg.Done()
455
+ _, _ = io.Copy(upstream, clientConn)
456
+ if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
457
+ _ = cw.CloseWrite()
458
+ }
459
+ }()
460
+ go func() {
461
+ defer wg.Done()
462
+ _, _ = io.Copy(clientConn, upstream)
463
+ if cw, ok := clientConn.(interface{ CloseWrite() error }); ok {
464
+ _ = cw.CloseWrite()
465
+ }
466
+ }()
467
+ wg.Wait()
468
+ _ = clientConn.Close()
469
+ _ = upstream.Close()
470
+ })
471
+ }
472
+
473
+ // egressTargetAllowed permits CONNECT only to a TAGGED same-tailnet .ts.net peer
474
+ // (exact DNSName match, never a suffix) on a served port, and rejects IP-literal
475
+ // targets — so the loopback proxy can never be turned into a generic tailnet
476
+ // egress, preserving the ACL's instance-isolation posture.
477
+ func egressTargetAllowed(lc statusClient, host, port string) bool {
478
+ if port != "443" && port != "7777" {
479
+ return false
480
+ }
481
+ if net.ParseIP(host) != nil {
482
+ return false // no raw IPs — only MagicDNS names bound to a known tagged peer
483
+ }
484
+ want := normalizeDNSName(host)
485
+ if want == "" {
486
+ return false
487
+ }
488
+ status, err := lc.Status(context.Background())
489
+ if err != nil || status == nil {
490
+ return false
491
+ }
492
+ for _, ps := range status.Peer {
493
+ if ps == nil || !peerHasTag(ps, meshPeerTag) {
494
+ continue
495
+ }
496
+ if normalizeDNSName(ps.DNSName) == want {
497
+ return true
498
+ }
499
+ }
500
+ return false
501
+ }
502
+
503
+ func randToken() (string, error) {
504
+ b := make([]byte, 32)
505
+ if _, err := rand.Read(b); err != nil {
506
+ return "", err
507
+ }
508
+ return hex.EncodeToString(b), nil
509
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
3
+ "contentHash": "15ca95fdba8ba0d81c2e49f8f9707d2fb124f51ee3982dea3a87d11b140ab279",
4
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"
5
+ "ai-or-die-mesh-windows-amd64.exe": "8b194d82257e7d99e9e0f770830b2baf1ccc17f82c8aad6097fa9c7594ddc89b",
6
+ "ai-or-die-mesh-windows-arm64.exe": "7dc1fe73117a95b9e4889457b9f97f8a59bb547e53c2f64499e998cf2a0016e2",
7
+ "ai-or-die-mesh-linux-amd64": "abf133b731dd2c0dcdefaf157cb0f026d741554e1f0332d329fb2bac542d0645",
8
+ "ai-or-die-mesh-linux-arm64": "d63cd407521ef7e7c72dac90b5cf5ac18c92aa8243046ba7dc7b8c4da58fc468",
9
+ "ai-or-die-mesh-darwin-amd64": "669e36de903fedb5e5d2dc0152971c828582bf25622310f559eefb226f4221de",
10
+ "ai-or-die-mesh-darwin-arm64": "7f0f3fc766a87e110ecc5e908e6b86333e56f3d0517b81ce4dc582963dadad11"
11
11
  }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // MeshManager — permanent reachability over a Tailscale tailnet via the
4
- // aiordie-mesh sidecar (tsnet in USERSPACE: no kernel TUN, no admin, no system
4
+ // ai-or-die-mesh sidecar (tsnet in USERSPACE: no kernel TUN, no admin, no system
5
5
  // service). Only ai-or-die's own port joins the mesh; the rest of the machine
6
6
  // stays off it. Mirrors TunnelManager's lifecycle (detect → start → backoff →
7
7
  // stop) so bin/ai-or-die.js supervises it like the dev tunnel. Verified E2E:
@@ -11,6 +11,7 @@ const { spawn } = require('child_process');
11
11
  const os = require('os');
12
12
  const path = require('path');
13
13
  const fs = require('fs');
14
+ const crypto = require('crypto');
14
15
 
15
16
  const URL_TIMEOUT_MS = 60000; // enroll + name can take ~30s on first run
16
17
  const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
@@ -38,11 +39,11 @@ class MeshManager {
38
39
  ? path.join(localApp, 'ai-or-die')
39
40
  : path.join(os.homedir(), '.ai-or-die');
40
41
  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.
42
+ // The sidecar is LAUNCHED from a stable, hash-free path (ai-or-die-mesh[.exe])
43
+ // so a single-file WDAC/AppLocker allow-list rule matches the executed image
44
+ // across versions (ADR-0036). The installer verifies + replaces it in place.
44
45
  this._installer = options._installer || require('./utils/sidecar-installer');
45
- this.sidecar = options.sidecar || this._sidecarPathFromLock(this._appBase);
46
+ this.sidecar = options.sidecar || this._stableSidecarPath(this._appBase);
46
47
  this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
47
48
 
48
49
  this.proc = null;
@@ -59,12 +60,16 @@ class MeshManager {
59
60
  this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
60
61
  this._stdoutBuffer = '';
61
62
  this.peers = null;
63
+ this._untaggedWarned = false;
62
64
  }
63
65
 
64
66
  /** Never throws — degrades to localhost/devtunnel on any failure. */
65
67
  async start() {
66
68
  console.log('\n Connecting mesh (Tailscale userspace)...');
67
69
  this.stopping = false;
70
+ // Clear any egress.json left by a crashed/kill -9'd prior run BEFORE (re)spawn,
71
+ // so a consumer can't route to a freed loopback port a local process squatted.
72
+ this._deleteEgressFile();
68
73
  if (!fs.existsSync(this.sidecar)) {
69
74
  if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
70
75
  }
@@ -73,13 +78,12 @@ class MeshManager {
73
78
  await this._spawn();
74
79
  }
75
80
 
76
- /** Content-addressed sidecar path from the committed lock; safe fallback. */
77
- _sidecarPathFromLock(base) {
81
+ /** Stable, hash-free sidecar path from the installer; safe fallback. */
82
+ _stableSidecarPath(base) {
78
83
  try {
79
- const lock = this._installer.loadLock();
80
- return this._installer.sidecarPath(lock.contentHash);
84
+ return this._installer.stableSidecarPath();
81
85
  } catch (_) {
82
- const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
86
+ const exe = process.platform === 'win32' ? 'ai-or-die-mesh.exe' : 'ai-or-die-mesh';
83
87
  return path.join(base, 'bin', exe);
84
88
  }
85
89
  }
@@ -113,6 +117,7 @@ class MeshManager {
113
117
  async stop() {
114
118
  this.stopping = true;
115
119
  this._deletePeersFile();
120
+ this._deleteEgressFile();
116
121
  this._clearStabilityTimer();
117
122
  clearTimeout(this._restartDelayTimer);
118
123
  if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
@@ -156,6 +161,7 @@ class MeshManager {
156
161
  }
157
162
  if (/^MESH-NEEDLOGIN\b/.test(line)) {
158
163
  this._deletePeersFile();
164
+ this._deleteEgressFile();
159
165
  this._printNotEnrolled();
160
166
  if (!done) { done = true; clearTimeout(timer); resolve(); }
161
167
  }
@@ -164,7 +170,7 @@ class MeshManager {
164
170
  this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
165
171
  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(); } });
166
172
  this.proc.on('exit', (code) => {
167
- this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
173
+ this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile(); this._deleteEgressFile();
168
174
  if (!this.stopping && code !== 0) this._restart();
169
175
  });
170
176
  });
@@ -189,7 +195,9 @@ class MeshManager {
189
195
 
190
196
  _handleStdoutLine(line) {
191
197
  try {
192
- if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
198
+ if (/^MESH-NEEDLOGIN\b/.test(line)) { this._deletePeersFile(); this._deleteEgressFile(); return; }
199
+ if (/^MESH-EGRESS\b/.test(line)) { this._handleEgressLine(line); return; }
200
+ if (/^MESH-UNTAGGED\b/.test(line)) { this._handleUntaggedLine(line); return; }
193
201
  const match = /^MESH-PEERS\s+(.+)$/.exec(line);
194
202
  if (!match) return;
195
203
  const raw = match[1];
@@ -206,6 +214,8 @@ class MeshManager {
206
214
  const normalized = this._validatePeersPayload(parsed);
207
215
  if (!normalized) return;
208
216
  this.peers = normalized;
217
+ // Recovered: tagged peers are visible again, so re-arm the untagged warning.
218
+ if (normalized.peers.length > 0) this._untaggedWarned = false;
209
219
  this._writePeersFile(normalized);
210
220
  } catch (_) {}
211
221
  }
@@ -249,6 +259,70 @@ class MeshManager {
249
259
  try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
250
260
  }
251
261
 
262
+ egressFilePath() {
263
+ return path.join(this._appBase, 'mesh', 'egress.json');
264
+ }
265
+
266
+ // Parse `MESH-EGRESS <url> <token>` and persist the loopback CONNECT-proxy
267
+ // endpoint + local secret for a same-box conductor (github-router) to consume.
268
+ // Only a loopback http URL is ever accepted; the token is a local credential.
269
+ _handleEgressLine(line) {
270
+ const m = /^MESH-EGRESS\s+(\S+)\s+(\S+)\s*$/.exec(line);
271
+ if (!m) return;
272
+ const url = m[1];
273
+ const token = m[2];
274
+ let u;
275
+ try { u = new URL(url); } catch (_) { return; }
276
+ if (u.protocol !== 'http:') return;
277
+ const host = u.hostname.replace(/^\[/, '').replace(/\]$/, '');
278
+ // Only a numeric loopback literal — never `localhost`, whose resolution a
279
+ // consumer's DNS/hosts config could rebind off-host.
280
+ if (host !== '127.0.0.1' && host !== '::1') return;
281
+ if (!token || /\s/.test(token)) return;
282
+ this._writeEgressFile(url, token);
283
+ }
284
+
285
+ _writeEgressFile(url, token) {
286
+ const file = this.egressFilePath();
287
+ const dir = path.dirname(file);
288
+ // Random, exclusive-create tmp so a pre-planted symlink at a predictable path
289
+ // can't redirect the write (TOCTOU / symlink clobber).
290
+ const tmp = path.join(dir, `egress.json.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
291
+ try {
292
+ fs.mkdirSync(dir, { recursive: true });
293
+ fs.writeFileSync(tmp, JSON.stringify({
294
+ version: 1,
295
+ pid: this.proc ? this.proc.pid : process.pid,
296
+ updatedAt: Date.now(),
297
+ url,
298
+ token,
299
+ }), { mode: 0o600, flag: 'wx' });
300
+ fs.chmodSync(tmp, 0o600);
301
+ fs.renameSync(tmp, file);
302
+ } catch (_) {
303
+ try { fs.rmSync(tmp, { force: true }); } catch (_) {}
304
+ }
305
+ }
306
+
307
+ _deleteEgressFile() {
308
+ try { fs.rmSync(this.egressFilePath(), { force: true }); } catch (_) {}
309
+ }
310
+
311
+ // Actionable one-shot hint: tailnet peers exist but none carry tag:aiordie, so
312
+ // fleet discovery will surface nothing until they are tagged. The exact failure
313
+ // this whole path guards against — surfaced, not silent.
314
+ _handleUntaggedLine(line) {
315
+ if (this._untaggedWarned) return;
316
+ const m = /^MESH-UNTAGGED\s+(\S+)\s+(\d+)\s+(\d+)\s*$/.exec(line);
317
+ if (!m) return;
318
+ this._untaggedWarned = true;
319
+ const total = m[2];
320
+ console.log(`\n \x1b[33mMesh: ${total} tailnet device(s) visible but \x1b[1m0 tagged tag:aiordie\x1b[0m\x1b[33m.\x1b[0m`);
321
+ console.log(' Fleet discovery stays EMPTY until instances carry the tag:');
322
+ console.log(' • enroll with a REUSABLE + TAGGED key (tag:aiordie), or retag each device in the admin console;');
323
+ console.log(' • ensure the ACL allows your conductor → tag:aiordie on the served ports (docs/mesh-acl.example.hujson).');
324
+ }
325
+
252
326
  _printMissing(err) {
253
327
  const code = err && err.code;
254
328
  const map = {
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Sidecar installer — fetches the prebuilt aiordie-mesh tsnet binary for the
3
+ // Sidecar installer — fetches the prebuilt ai-or-die-mesh tsnet binary for the
4
4
  // current platform and verifies it against a SHA-256 that ships INSIDE this npm
5
5
  // package (mesh-sidecar.lock.json). Trust is therefore anchored by the signed
6
6
  // npm artifact, not by a checksums file fetched from the same mutable GitHub
@@ -12,7 +12,7 @@
12
12
  // CI only rebuilds when the source — and thus the hash — changes.
13
13
  //
14
14
  // Asset convention (published by .github/workflows/release-on-main.yml):
15
- // aiordie-mesh-<plat>-<arch>[.exe] plat: windows|linux|darwin arch: amd64|arm64
15
+ // ai-or-die-mesh-<plat>-<arch>[.exe] plat: windows|linux|darwin arch: amd64|arm64
16
16
 
17
17
  const fs = require('fs');
18
18
  const fsp = require('fs').promises;
@@ -44,7 +44,7 @@ function assetName() {
44
44
  const plat = PLAT[process.platform];
45
45
  const arch = ARCH[process.arch];
46
46
  if (!plat || !arch) return null;
47
- return `aiordie-mesh-${plat}-${arch}${process.platform === 'win32' ? '.exe' : ''}`;
47
+ return `ai-or-die-mesh-${plat}-${arch}${process.platform === 'win32' ? '.exe' : ''}`;
48
48
  }
49
49
 
50
50
  function baseDir() {
@@ -52,11 +52,32 @@ function baseDir() {
52
52
  return process.platform === 'win32' ? path.join(localApp, 'ai-or-die') : path.join(os.homedir(), '.ai-or-die');
53
53
  }
54
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) {
55
+ // Stable runnable path (NO content hash): the manager LAUNCHES this path, so a
56
+ // single-file WDAC/AppLocker allow-list rule matches the executed image across
57
+ // every version. This trades ADR-0035's install-alongside for that match; it is
58
+ // safe because the stable file is free at process start (the prior ai-or-die and
59
+ // its sidecar have exited), and the MOTW/quarantine mark is stripped after the
60
+ // SHA-256 verify so a re-download is not re-gated. See ADR-0036.
61
+ function stableSidecarPath() {
58
62
  const ext = process.platform === 'win32' ? '.exe' : '';
59
- return path.join(baseDir(), 'bin', `aiordie-mesh-${contentHash}${ext}`);
63
+ return path.join(baseDir(), 'bin', `ai-or-die-mesh${ext}`);
64
+ }
65
+
66
+ // Best-effort provenance strip AFTER the SHA-256 verify: remove Windows
67
+ // mark-of-the-web (Zone.Identifier ADS) / macOS Gatekeeper quarantine so the
68
+ // freshly-verified binary is not re-gated by SmartScreen/Gatekeeper on each new
69
+ // version. We verified the bytes against the checksum shipped in the signed npm
70
+ // package, so this de-gates only a binary we already trust. Never throws.
71
+ function stripProvenance(file) {
72
+ try {
73
+ if (process.platform === 'win32') {
74
+ fs.rmSync(`${file}:Zone.Identifier`, { force: true });
75
+ } else if (process.platform === 'darwin') {
76
+ try {
77
+ require('child_process').execFileSync('xattr', ['-d', 'com.apple.quarantine', file], { stdio: 'ignore' });
78
+ } catch (_) { /* xattr absent or attribute not set */ }
79
+ }
80
+ } catch (_) { /* best-effort */ }
60
81
  }
61
82
 
62
83
  // The release ref to fetch from. Defaults to mesh-<contentHash>; an explicit
@@ -127,11 +148,11 @@ async function ensureSidecar(dest, opts = {}) {
127
148
  throw new SidecarError('lock-unfinalized', `mesh-sidecar.lock.json has no checksum for ${name} (built without the mesh asset pipeline?)`);
128
149
  }
129
150
 
130
- dest = dest || sidecarPath(lock.contentHash);
151
+ dest = dest || stableSidecarPath();
131
152
 
132
153
  // Existing file: trust only if it matches; otherwise replace it.
133
154
  if (fs.existsSync(dest)) {
134
- if ((await _sha256(dest)).toLowerCase() === want) return dest;
155
+ if ((await _sha256(dest)).toLowerCase() === want) { stripProvenance(dest); return dest; }
135
156
  try { await fsp.unlink(dest); }
136
157
  catch (e) {
137
158
  if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
@@ -157,7 +178,7 @@ async function ensureSidecar(dest, opts = {}) {
157
178
  } catch (e) {
158
179
  // Lost a race to a concurrent installer (or the file is locked). If the
159
180
  // destination is now present and valid, accept it; otherwise surface it.
160
- if (fs.existsSync(dest) && (await _sha256(dest)).toLowerCase() === want) return dest;
181
+ if (fs.existsSync(dest) && (await _sha256(dest)).toLowerCase() === want) { stripProvenance(dest); return dest; }
161
182
  if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
162
183
  throw new SidecarError('locked-binary', `cannot install sidecar at ${dest} (${e.code})`);
163
184
  }
@@ -166,7 +187,8 @@ async function ensureSidecar(dest, opts = {}) {
166
187
  } finally {
167
188
  try { await fsp.unlink(tmp); } catch (_) {}
168
189
  }
190
+ stripProvenance(dest);
169
191
  return dest;
170
192
  }
171
193
 
172
- module.exports = { ensureSidecar, sidecarPath, assetName, loadLock, SidecarError };
194
+ module.exports = { ensureSidecar, stableSidecarPath, assetName, loadLock, SidecarError };
package/mesh/main_test.go DELETED
@@ -1,123 +0,0 @@
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
- }