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 +1 -1
- package/mesh/main.go +169 -1
- package/mesh-sidecar.lock.json +7 -7
- package/package.json +1 -1
- package/src/artifact-review.js +159 -2
- package/src/base-bridge.js +18 -0
- package/src/mesh-manager.js +86 -12
- package/src/public/components/artifact-panel.css +57 -0
- package/src/server.js +54 -1
- package/src/utils/sidecar-installer.js +33 -11
- package/mesh/main_test.go +0 -123
package/mesh/go.mod
CHANGED
package/mesh/main.go
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Command
|
|
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
|
+
}
|
package/mesh-sidecar.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"contentHash": "
|
|
3
|
+
"contentHash": "15ca95fdba8ba0d81c2e49f8f9707d2fb124f51ee3982dea3a87d11b140ab279",
|
|
4
4
|
"assets": {
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
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
package/src/artifact-review.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/base-bridge.js
CHANGED
|
@@ -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
|
package/src/mesh-manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// MeshManager — permanent reachability over a Tailscale tailnet via the
|
|
4
|
-
//
|
|
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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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.
|
|
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
|
-
/**
|
|
77
|
-
|
|
81
|
+
/** Stable, hash-free sidecar path from the installer; safe fallback. */
|
|
82
|
+
_stableSidecarPath(base) {
|
|
78
83
|
try {
|
|
79
|
-
|
|
80
|
-
return this._installer.sidecarPath(lock.contentHash);
|
|
84
|
+
return this._installer.stableSidecarPath();
|
|
81
85
|
} catch (_) {
|
|
82
|
-
const exe = process.platform === 'win32' ? '
|
|
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
|
|
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
|
-
//
|
|
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 `
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
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', `
|
|
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 ||
|
|
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,
|
|
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
|
-
}
|