ai-or-die 0.1.89 → 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.89",
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": {
@@ -9,6 +9,9 @@ const { EventEmitter } = require('events');
9
9
  const DEFAULT_POLL_HOLD_MS = 25000;
10
10
  const DEFAULT_POLL_HEARTBEAT_MS = 5000;
11
11
  const DEFAULT_SSE_HEARTBEAT_MS = 15000;
12
+ // Bound the artifact-push await so a stalled PTY write can't hold the /prompts
13
+ // HTTP response open. On timeout we treat the push as failed and re-queue.
14
+ const PUSH_TIMEOUT_MS = 4000;
12
15
 
13
16
  function realpathOrResolve(file) {
14
17
  let resolved = path.resolve(file);
@@ -81,6 +84,65 @@ function feedbackHasData(snapshot) {
81
84
  );
82
85
  }
83
86
 
87
+ // Render queued human prompts into a single message suitable for injecting into
88
+ // the CLI as a new user turn (the artifact-push path). Pure + exported so the
89
+ // gate decision and wording are unit-testable without a live PTY. Accepts both
90
+ // the panel's prompt objects ({prompt, text, sourceLine}) and bare-string prompts
91
+ // (curl/legacy). Returns '' when there is nothing actionable to push (caller
92
+ // skips injection on empty).
93
+ function normalizeFeedbackPrompt(p) {
94
+ if (typeof p === 'string') {
95
+ return p.trim() ? { prompt: p.trim() } : null;
96
+ }
97
+ if (p && typeof p.prompt === 'string' && p.prompt.trim()) {
98
+ return {
99
+ prompt: p.prompt.trim(),
100
+ text: typeof p.text === 'string' ? p.text : '',
101
+ sourceLine: typeof p.sourceLine === 'number' ? p.sourceLine : undefined,
102
+ };
103
+ }
104
+ return null;
105
+ }
106
+ function formatFeedbackForAgent(prompts) {
107
+ const items = (Array.isArray(prompts) ? prompts : [])
108
+ .map(normalizeFeedbackPrompt)
109
+ .filter(Boolean);
110
+ if (items.length === 0) return '';
111
+ const lines = items.map((p, i) => {
112
+ const quoted = p.text && p.text.trim()
113
+ ? ' (re: "' + p.text.trim().slice(0, 160) + '")'
114
+ : '';
115
+ const where = typeof p.sourceLine === 'number' ? ' [line ' + p.sourceLine + ']' : '';
116
+ return (i + 1) + '.' + where + quoted + ' ' + p.prompt;
117
+ });
118
+ return (
119
+ 'Human review feedback from the artifact panel (you were idle, so this was '
120
+ + 'delivered as a new turn instead of through artifact_poll). Address these in '
121
+ + 'the open artifact, then reply with the artifact_reply tool:\n'
122
+ + lines.join('\n')
123
+ );
124
+ }
125
+
126
+ // Build the raw bytes to inject into the CLI PTY for an artifact push. Pure +
127
+ // exported so the security-sensitive sanitization is unit-testable. Strips ALL
128
+ // C0 control bytes and DEL except TAB and LF (this removes ESC, so the bracketed-
129
+ // paste markers and any other escape/CSI sequence in the human text cannot
130
+ // survive), which also drops CR so the only submit is the trailing CR we add.
131
+ // Wraps in a bracketed paste so multi-line feedback enters the composer
132
+ // atomically; size-capped to bound a single injection.
133
+ function buildArtifactPushPayload(text) {
134
+ if (!text || typeof text !== 'string') return '';
135
+ const safe = text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ').slice(0, 4000);
136
+ return '\x1b[200~' + safe + '\x1b[201~\r';
137
+ }
138
+
139
+ // Whether the artifact-push feature is enabled from its env var. Default ON:
140
+ // only an explicit falsy value (0 / false / off / no) disables it. Pure +
141
+ // exported so the default-on / opt-out contract is unit-tested.
142
+ function artifactPushEnabledFromEnv(raw) {
143
+ return !/^(0|false|off|no)$/i.test(String(raw == null ? '' : raw).trim());
144
+ }
145
+
84
146
  class ArtifactReviewStore extends EventEmitter {
85
147
  constructor() {
86
148
  super();
@@ -214,6 +276,25 @@ class ArtifactReviewStore extends EventEmitter {
214
276
  return review;
215
277
  }
216
278
 
279
+ // Restore prompts that were claimed (acked) for a push that then failed, back to
280
+ // the FRONT of the queue so order is preserved (FIFO) ahead of any prompts that
281
+ // arrived during the push window. Re-emits 'feedback' so an in-flight poll picks
282
+ // them up. Used only by the artifact-push re-queue path.
283
+ restoreClaimedFeedback(aiSessionId, snapshot) {
284
+ const review = this._reviews.get(aiSessionId);
285
+ if (!review || !snapshot) return null;
286
+ const prompts = cloneArray(snapshot.prompts);
287
+ if (prompts.length > 0) {
288
+ review.queuedPrompts.unshift(...prompts);
289
+ if (snapshot.dom_snapshot !== undefined && review.domSnapshot == null) {
290
+ review.domSnapshot = snapshot.dom_snapshot;
291
+ }
292
+ review.updatedAt = nowIso();
293
+ this.emit('feedback', { aiSessionId, kind: 'prompts', review });
294
+ }
295
+ return review;
296
+ }
297
+
217
298
  addAgentReply(aiSessionId, text) {
218
299
  const review = this._reviews.get(aiSessionId);
219
300
  if (!review) return null;
@@ -567,6 +648,27 @@ function createArtifactReviewRouter(options) {
567
648
  const sseHeartbeatMs = typeof options.sseHeartbeatMs === 'number'
568
649
  ? options.sseHeartbeatMs
569
650
  : DEFAULT_SSE_HEARTBEAT_MS;
651
+ // Optional artifact-push hook (default off). When provided, panel feedback that
652
+ // arrives while NO agent poll is in flight is pushed into the CLI as a new turn
653
+ // (so an idle agent reacts without the human switching to the terminal). The
654
+ // server supplies this only when AIORDIE_ARTIFACT_PUSH is enabled; it returns a
655
+ // truthy value when it actually injected, so we can consume the queued prompts.
656
+ const pushToAgent = typeof options.pushToAgent === 'function'
657
+ ? options.pushToAgent
658
+ : null;
659
+ const pushTimeoutMs = typeof options.pushTimeoutMs === 'number' && options.pushTimeoutMs > 0
660
+ ? options.pushTimeoutMs
661
+ : PUSH_TIMEOUT_MS;
662
+
663
+ // Count of in-flight long-poll requests per session. Non-zero means the agent
664
+ // is actively waiting on artifact_poll, so a queued prompt is delivered by that
665
+ // poll and must NOT be injected (injecting mid-turn would race the CLI's TUI).
666
+ const activePolls = new Map();
667
+ function pollDelta(sessionId, delta) {
668
+ const next = (activePolls.get(sessionId) || 0) + delta;
669
+ if (next > 0) activePolls.set(sessionId, next);
670
+ else activePolls.delete(sessionId);
671
+ }
570
672
 
571
673
  if (!store) throw new TypeError('Artifact review store is required');
572
674
  if (typeof validatePath !== 'function') throw new TypeError('validatePath is required');
@@ -731,15 +833,60 @@ function createArtifactReviewRouter(options) {
731
833
  res.sendFile(resolved.path);
732
834
  });
733
835
 
734
- router.post('/:sessionId/prompts', (req, res) => {
836
+ router.post('/:sessionId/prompts', async (req, res) => {
735
837
  const sessionId = req.params.sessionId;
736
838
  if (!store.get(sessionId)) return res.status(404).json({ error: 'artifact review not found' });
737
839
 
738
840
  const prompts = req.body && req.body.prompts;
739
841
  if (!Array.isArray(prompts)) return res.status(400).json({ error: 'prompts must be an array' });
740
842
 
843
+ // Snapshot whether a poll is in flight BEFORE queuing: queuePrompts emits
844
+ // 'feedback' synchronously, which makes an in-flight poll deliver + tear down
845
+ // (decrementing the count to 0) before we could observe it. Reading the count
846
+ // first is the correct "was the agent waiting when this arrived?" signal.
847
+ const hadActivePoll = (activePolls.get(sessionId) || 0) > 0;
848
+
741
849
  const review = store.queuePrompts(sessionId, prompts, req.body ? req.body.domSnapshot : undefined);
742
- res.json({ ok: true, queued: review ? review.queuedPrompts.length : 0 });
850
+
851
+ // Artifact push (default off; pushToAgent is null unless enabled). If the
852
+ // agent was NOT waiting on a poll, the queued feedback would otherwise sit
853
+ // until the agent next polls. Push it into the CLI as a new turn so an idle
854
+ // agent reacts. When a poll WAS in flight the queue path already delivers it,
855
+ // so we never inject then (injecting mid-turn would race the TUI).
856
+ //
857
+ // We CLAIM the feedback (ackFeedback) synchronously BEFORE the await, so a
858
+ // poll that arrives during the push window sees an empty queue and cannot
859
+ // also deliver the same feedback (no double-delivery). If the push then fails
860
+ // or times out we re-queue exactly what we claimed, so feedback is never lost
861
+ // and the poll path still works. The server hook applies its own PTY-quiet
862
+ // idle check and may decline.
863
+ let pushed = false;
864
+ if (pushToAgent && !hadActivePoll) {
865
+ const snapshot = store.peekFeedback(sessionId);
866
+ const text = feedbackHasData(snapshot) ? formatFeedbackForAgent(snapshot.prompts) : '';
867
+ if (text) {
868
+ store.ackFeedback(sessionId, snapshot); // claim before await
869
+ try {
870
+ pushed = !!(await Promise.race([
871
+ Promise.resolve(pushToAgent(sessionId, text)),
872
+ new Promise((resolve) => setTimeout(() => resolve(false), pushTimeoutMs)),
873
+ ]));
874
+ } catch (_) {
875
+ pushed = false;
876
+ }
877
+ if (!pushed) {
878
+ // Re-queue what we claimed (to the FRONT, preserving order) so the poll
879
+ // path still delivers it.
880
+ store.restoreClaimedFeedback(sessionId, snapshot);
881
+ }
882
+ }
883
+ }
884
+
885
+ res.json({
886
+ ok: true,
887
+ pushed,
888
+ queued: pushed ? 0 : (review ? review.queuedPrompts.length : 0),
889
+ });
743
890
  });
744
891
 
745
892
  router.post('/:sessionId/layout-warnings', (req, res) => {
@@ -849,11 +996,18 @@ function createArtifactReviewRouter(options) {
849
996
  res.setHeader('X-Accel-Buffering', 'no');
850
997
  if (typeof res.flushHeaders === 'function') res.flushHeaders();
851
998
 
999
+ // This request is now an in-flight poll; mark the agent as actively waiting
1000
+ // so concurrent /prompts feedback is delivered HERE (not injected into the
1001
+ // PTY). Decremented exactly once on teardown.
1002
+ let pollCounted = true;
1003
+ pollDelta(sessionId, 1);
1004
+
852
1005
  let done = false;
853
1006
  let snapshotToAck = null;
854
1007
  let timeout = null;
855
1008
  let heartbeat = null;
856
1009
  function cleanup() {
1010
+ if (pollCounted) { pollCounted = false; pollDelta(sessionId, -1); }
857
1011
  if (timeout) clearTimeout(timeout);
858
1012
  if (heartbeat) clearInterval(heartbeat);
859
1013
  store.removeListener('feedback', onFeedback);
@@ -924,6 +1078,9 @@ module.exports = {
924
1078
  createAssetTokenSigner,
925
1079
  createArtifactReviewRouter,
926
1080
  feedbackHasData,
1081
+ formatFeedbackForAgent,
1082
+ buildArtifactPushPayload,
1083
+ artifactPushEnabledFromEnv,
927
1084
  injectLavishSdk,
928
1085
  isMarkdownFile,
929
1086
  markdownArtifactShell,
@@ -320,6 +320,10 @@ class BaseBridge {
320
320
  workingDir,
321
321
  created: new Date(),
322
322
  active: true,
323
+ // Epoch ms of the most recent PTY output, updated in the onData handler.
324
+ // Used by the artifact-push idle gate (msSinceLastOutput) to avoid
325
+ // injecting input while the CLI is actively rendering. Seeded at spawn.
326
+ lastOutputAt: Date.now(),
323
327
  killTimeout: null,
324
328
  writeQueue: Promise.resolve(),
325
329
  // PTY listener handles registered against ptyProcess.{onData,onExit,on('error')}.
@@ -382,6 +386,7 @@ class BaseBridge {
382
386
  receivedLifeSign = true;
383
387
  clearTimeout(spawnWatchdog);
384
388
  }
389
+ session.lastOutputAt = Date.now();
385
390
 
386
391
  if (process.env.DEBUG) {
387
392
  console.log(`${this.toolName} session ${sessionId} output:`, data);
@@ -558,6 +563,19 @@ class BaseBridge {
558
563
  return session.writeQueue;
559
564
  }
560
565
 
566
+ /**
567
+ * Milliseconds since this session's PTY last emitted output, or null if there
568
+ * is no active session. Used by the artifact-push idle gate to skip injecting
569
+ * input while the CLI is actively rendering (a proxy for "not mid-turn").
570
+ * @param {string} sessionId
571
+ * @returns {number|null}
572
+ */
573
+ msSinceLastOutput(sessionId) {
574
+ const session = this.sessions.get(sessionId);
575
+ if (!session || !session.active) return null;
576
+ return Date.now() - (session.lastOutputAt || 0);
577
+ }
578
+
561
579
  /**
562
580
  * Write data to the PTY in chunks to prevent kernel buffer overflow.
563
581
  * @private
@@ -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 = {
@@ -212,3 +212,60 @@
212
212
  linear-gradient(135deg, transparent 0 50%, var(--border-hover, #484f58) 50% 60%, transparent 60% 70%, var(--border-hover, #484f58) 70% 80%, transparent 80%);
213
213
  }
214
214
  .artifact-panel__resize[hidden] { display: none; }
215
+
216
+ /* Maximized layout: horizontal split. Artifact content is the large flexible
217
+ pane; chat is a fixed 360px right rail (the lavish-axi / mainstream artifact-UI
218
+ model). Maximizing is a request for content WIDTH, so spend the reclaimed
219
+ space on the artifact, not on a wide chat. CSS grid (not a JS reparent) places
220
+ pillbar / chat / chatbar into the rail in source order: pills at the rail top,
221
+ chat filling the middle, the composer at the bottom. Reverts to the default
222
+ vertical stack under 1024px, where a narrow window reads better stacked. */
223
+ .artifact-panel--maximized .artifact-panel__body {
224
+ display: grid;
225
+ grid-template-columns: minmax(0, 1fr) 360px;
226
+ grid-template-rows: auto minmax(0, 1fr) auto;
227
+ }
228
+ .artifact-panel--maximized .artifact-panel__frame {
229
+ grid-column: 1;
230
+ grid-row: 1 / 4; /* content spans the full height on the left */
231
+ /* The frame is an <iframe> (a replaced element): it uses its intrinsic size and
232
+ does NOT stretch to fill a grid cell like a normal block, so it needs explicit
233
+ 100% dimensions (the old flex layout filled it via `flex: 1`). */
234
+ width: 100%;
235
+ height: 100%;
236
+ }
237
+ .artifact-panel--maximized .artifact-panel__pillbar {
238
+ grid-column: 2;
239
+ grid-row: 1;
240
+ border-left: 1px solid var(--panel-border, #33363f);
241
+ }
242
+ .artifact-panel--maximized .artifact-panel__chat {
243
+ grid-column: 2;
244
+ grid-row: 2;
245
+ max-height: none; /* fill the rail middle row instead of the 30% cap */
246
+ border-left: 1px solid var(--panel-border, #33363f);
247
+ }
248
+ .artifact-panel--maximized .artifact-panel__chatbar {
249
+ grid-column: 2;
250
+ grid-row: 3;
251
+ border-left: 1px solid var(--panel-border, #33363f);
252
+ }
253
+
254
+ /* Narrow viewport: revert the maximized panel to the vertical stack so the
255
+ content is not squeezed into a sliver beside a 360px rail. */
256
+ @media (max-width: 1024px) {
257
+ .artifact-panel--maximized .artifact-panel__body {
258
+ display: flex;
259
+ flex-direction: column;
260
+ }
261
+ .artifact-panel--maximized .artifact-panel__frame,
262
+ .artifact-panel--maximized .artifact-panel__pillbar,
263
+ .artifact-panel--maximized .artifact-panel__chat,
264
+ .artifact-panel--maximized .artifact-panel__chatbar {
265
+ grid-column: auto;
266
+ grid-row: auto;
267
+ border-left: none;
268
+ }
269
+ .artifact-panel--maximized .artifact-panel__frame { width: 100%; }
270
+ .artifact-panel--maximized .artifact-panel__chat { max-height: 30%; }
271
+ }
package/src/server.js CHANGED
@@ -37,7 +37,7 @@ const KeepaliveManager = require('./keepalive-manager');
37
37
  const { ControlEventBus, EVENT_KINDS: CONTROL_EVENT_KINDS } = require('./control/event-bus');
38
38
  const TranscriptBuffer = require('./sticky-note-transcript');
39
39
  const { createControlRouter } = require('./control/routes');
40
- const { ArtifactReviewStore, createArtifactReviewRouter, createAssetTokenSigner } = require('./artifact-review');
40
+ const { ArtifactReviewStore, createArtifactReviewRouter, createAssetTokenSigner, buildArtifactPushPayload, artifactPushEnabledFromEnv } = require('./artifact-review');
41
41
  const { deriveStatus, awaitingKindForPendingTool, awaitingFromScreen, TRUST_PROMPT_REGEX, DEFAULT_UNBOUND_QUIET_MS } = require('./control/session-status');
42
42
  const { detectAwaiting } = require('./control/jsonl-awaiting');
43
43
 
@@ -166,6 +166,14 @@ class ClaudeCodeWebServer {
166
166
  this.artifactPollHoldMs = options.artifactPollHoldMs || 25000;
167
167
  this.artifactPollHeartbeatMs = options.artifactPollHeartbeatMs || 5000;
168
168
  this.artifactSseHeartbeatMs = options.artifactSseHeartbeatMs || 15000;
169
+ // Artifact push (default ON): panel feedback that arrives while the agent is
170
+ // idle (no in-flight poll + PTY quiet) is injected into the CLI as a new turn,
171
+ // so the composer works without the human switching to the terminal or the
172
+ // agent having to poll. Opt OUT with AIORDIE_ARTIFACT_PUSH=0 (or false/off/no).
173
+ // The idle-gate + residual TUI-timing risk are recorded in docs/adrs/0035.
174
+ this._artifactPushEnabled = artifactPushEnabledFromEnv(process.env.AIORDIE_ARTIFACT_PUSH);
175
+ const quietRaw = Number(process.env.AIORDIE_ARTIFACT_PUSH_QUIET_MS);
176
+ this._artifactPushQuietMs = Number.isFinite(quietRaw) && quietRaw > 0 ? quietRaw : 1500;
169
177
  // PROC-04: min-heap of {id, lastActivity} pairs keyed by lastActivity.
170
178
  // Used by _evictStaleSessions to find the oldest session in O(log n)
171
179
  // rather than scanning the full Map every 5 min. Lazy-tombstone protocol
@@ -1270,6 +1278,9 @@ class ClaudeCodeWebServer {
1270
1278
  pollHoldMs: this.artifactPollHoldMs,
1271
1279
  pollHeartbeatMs: this.artifactPollHeartbeatMs,
1272
1280
  sseHeartbeatMs: this.artifactSseHeartbeatMs,
1281
+ pushToAgent: this._artifactPushEnabled
1282
+ ? (sessionId, text) => this._pushArtifactFeedbackToAgent(sessionId, text)
1283
+ : null,
1273
1284
  }));
1274
1285
 
1275
1286
  // Commands API removed
@@ -4101,6 +4112,48 @@ class ClaudeCodeWebServer {
4101
4112
  return bridges[agentType] || null;
4102
4113
  }
4103
4114
 
4115
+ // Return the bridge that currently owns a live PTY for `sessionId`, or null.
4116
+ // Uses msSinceLastOutput (null when the session is absent/inactive) so we
4117
+ // don't reach into a bridge's private session map.
4118
+ _bridgeForSession(sessionId) {
4119
+ const bridges = [
4120
+ this.claudeBridge, this.terminalBridge,
4121
+ this.codexBridge, this.copilotBridge, this.geminiBridge,
4122
+ ];
4123
+ for (const bridge of bridges) {
4124
+ if (bridge && typeof bridge.msSinceLastOutput === 'function'
4125
+ && bridge.msSinceLastOutput(sessionId) !== null) {
4126
+ return bridge;
4127
+ }
4128
+ }
4129
+ return null;
4130
+ }
4131
+
4132
+ // Artifact-push hook (wired only when AIORDIE_ARTIFACT_PUSH is enabled). Inject
4133
+ // panel feedback into the idle CLI as a NEW turn. Returns true only if it
4134
+ // actually wrote to the PTY (the caller then consumes the queued prompts).
4135
+ // Idle gate: decline when the session's PTY emitted output within the quiet
4136
+ // window (likely mid-render / mid-turn), a heuristic, hence opt-in. Bracketed
4137
+ // paste keeps multi-line feedback atomic; a trailing CR submits the turn.
4138
+ async _pushArtifactFeedbackToAgent(sessionId, text) {
4139
+ if (!text || typeof text !== 'string') return false;
4140
+ const bridge = this._bridgeForSession(sessionId);
4141
+ if (!bridge) return false;
4142
+ const quietMs = bridge.msSinceLastOutput(sessionId);
4143
+ if (quietMs === null || quietMs < this._artifactPushQuietMs) return false;
4144
+ // Sanitize + bracketed-paste-wrap the human text (pure helper, unit-tested in
4145
+ // artifact-review): strips ESC/control bytes so nothing can break out of the
4146
+ // paste envelope, and a trailing CR submits the turn.
4147
+ const payload = buildArtifactPushPayload(text);
4148
+ if (!payload) return false;
4149
+ try {
4150
+ await bridge.sendInput(sessionId, payload);
4151
+ return true;
4152
+ } catch (_) {
4153
+ return false;
4154
+ }
4155
+ }
4156
+
4104
4157
  _buildControlDeps() {
4105
4158
  return {
4106
4159
  sessions: this.claudeSessions,
@@ -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
- }