ai-or-die 0.1.83 → 0.1.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ai-or-die.js +23 -2
- package/mesh/go.mod +5 -0
- package/mesh/main.go +65 -0
- package/package.json +1 -1
- package/src/mesh-manager.js +147 -0
- package/src/server.js +24 -2
package/bin/ai-or-die.js
CHANGED
|
@@ -36,6 +36,7 @@ program
|
|
|
36
36
|
.option('--terminal-alias <name>', 'display alias for Terminal (default: env TERMINAL_ALIAS or "Terminal")')
|
|
37
37
|
.option('--tunnel', 'enable dev tunnel (requires devtunnel CLI installed)')
|
|
38
38
|
.option('--tunnel-allow-anonymous', 'allow anonymous access to dev tunnel')
|
|
39
|
+
.option('--mesh', 'expose this instance over a permanent Tailscale mesh (userspace; requires tailscale installed; set AIORDIE_TS_AUTHKEY to enroll)')
|
|
39
40
|
.option('--no-stt', 'disable local speech-to-text (on by default; downloads ~670MB Parakeet V3 model on first use)')
|
|
40
41
|
.option('--stt-endpoint <url>', 'use external STT endpoint (OpenAI-compatible)')
|
|
41
42
|
.option('--stt-model-dir <path>', 'custom directory for STT model files')
|
|
@@ -88,9 +89,10 @@ async function main() {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Handle authentication logic
|
|
91
|
-
// Tunnel mode disables auth — the tunnel
|
|
92
|
+
// Tunnel mode disables auth — the tunnel controls access. Mesh KEEPS auth
|
|
93
|
+
// on (ACL + Bearer layered); --mesh wins over --tunnel here.
|
|
92
94
|
let authToken = null;
|
|
93
|
-
let noAuth = options.disableAuth === true || options.tunnel === true;
|
|
95
|
+
let noAuth = options.disableAuth === true || (options.tunnel === true && !options.mesh);
|
|
94
96
|
|
|
95
97
|
if (!noAuth) {
|
|
96
98
|
if (options.auth) {
|
|
@@ -131,6 +133,9 @@ async function main() {
|
|
|
131
133
|
// the display on.
|
|
132
134
|
keepalive: options.keepalive !== false && process.env.AIORDIE_DISABLE_KEEPALIVE !== '1',
|
|
133
135
|
keepaliveDisplay: options.keepaliveDisplay === true || process.env.AIORDIE_KEEPALIVE_DISPLAY === '1',
|
|
136
|
+
// Mesh binds loopback-only always: the tailnet `serve` proxy reaches the
|
|
137
|
+
// port, the LAN never does (even with --https). Other modes: all interfaces.
|
|
138
|
+
bindHost: options.mesh ? '127.0.0.1' : undefined,
|
|
134
139
|
};
|
|
135
140
|
|
|
136
141
|
console.log('Starting ai-or-die...');
|
|
@@ -193,6 +198,22 @@ async function main() {
|
|
|
193
198
|
}
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
// Mesh: permanent Tailscale reachability. Coexists with --tunnel (mesh for
|
|
202
|
+
// owned devices, tunnel fallback for borrowed ones). Auth token stays ON.
|
|
203
|
+
if (options.mesh) {
|
|
204
|
+
const { MeshManager } = require('../src/mesh-manager');
|
|
205
|
+
const mesh = new MeshManager({
|
|
206
|
+
port,
|
|
207
|
+
dev: options.dev,
|
|
208
|
+
onUrl: (meshUrl) => {
|
|
209
|
+
const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
|
|
210
|
+
console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
app.setMeshManager(mesh);
|
|
214
|
+
await mesh.start();
|
|
215
|
+
}
|
|
216
|
+
|
|
196
217
|
console.log('\nPress Ctrl+C to stop the server\n');
|
|
197
218
|
|
|
198
219
|
// Shutdown is owned by the server's single SIGINT/SIGTERM handler
|
package/mesh/go.mod
ADDED
package/mesh/main.go
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Command aiordie-mesh is the userspace mesh sidecar for ai-or-die. It joins a
|
|
2
|
+
// Tailscale tailnet entirely in userspace via tsnet (no kernel TUN, no admin,
|
|
3
|
+
// no system service) and reverse-proxies the tailnet listener to ai-or-die's
|
|
4
|
+
// local HTTP/WebSocket port. Only this one port is exposed on the mesh; the
|
|
5
|
+
// rest of the machine never joins. Enrollment is one-time via TS_AUTHKEY; the
|
|
6
|
+
// node identity persists in --statedir so the key is never needed again.
|
|
7
|
+
//
|
|
8
|
+
// stdout protocol (parsed by src/mesh-manager.js):
|
|
9
|
+
// MESH-URL https://<name> node is up and serving
|
|
10
|
+
// MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
|
|
11
|
+
// MESH-ERR <msg> fatal
|
|
12
|
+
package main
|
|
13
|
+
|
|
14
|
+
import (
|
|
15
|
+
"context"
|
|
16
|
+
"flag"
|
|
17
|
+
"fmt"
|
|
18
|
+
"net/http"
|
|
19
|
+
"net/http/httputil"
|
|
20
|
+
"net/url"
|
|
21
|
+
"os"
|
|
22
|
+
"strings"
|
|
23
|
+
"time"
|
|
24
|
+
|
|
25
|
+
"tailscale.com/tsnet"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
func main() {
|
|
29
|
+
port := flag.String("port", "7777", "local ai-or-die port to expose")
|
|
30
|
+
host := flag.String("hostname", "aiordie", "tailnet hostname")
|
|
31
|
+
dir := flag.String("statedir", "./ts-state", "persistent node state dir")
|
|
32
|
+
flag.Parse()
|
|
33
|
+
|
|
34
|
+
s := &tsnet.Server{Dir: *dir, Hostname: *host}
|
|
35
|
+
// Surface the login URL exactly once instead of tsnet's default stderr spam.
|
|
36
|
+
s.AuthKey = os.Getenv("TS_AUTHKEY")
|
|
37
|
+
defer s.Close()
|
|
38
|
+
|
|
39
|
+
// Bring the node up; report the enroll URL if it needs login.
|
|
40
|
+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
41
|
+
defer cancel()
|
|
42
|
+
if _, err := s.Up(ctx); err != nil {
|
|
43
|
+
// Up blocks until authed; on timeout it's almost always missing/!key.
|
|
44
|
+
fmt.Printf("MESH-NEEDLOGIN https://login.tailscale.com/admin/settings/keys\n")
|
|
45
|
+
fmt.Printf("MESH-ERR %v\n", err)
|
|
46
|
+
os.Exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ln, err := s.Listen("tcp", ":80")
|
|
50
|
+
if err != nil {
|
|
51
|
+
fmt.Printf("MESH-ERR %v\n", err)
|
|
52
|
+
os.Exit(1)
|
|
53
|
+
}
|
|
54
|
+
target, _ := url.Parse("http://127.0.0.1:" + *port)
|
|
55
|
+
rp := httputil.NewSingleHostReverseProxy(target) // handles WS upgrade via hijack
|
|
56
|
+
|
|
57
|
+
st, _ := s.Up(ctx)
|
|
58
|
+
name := *host
|
|
59
|
+
if st != nil && st.Self != nil && st.Self.DNSName != "" {
|
|
60
|
+
name = strings.TrimSuffix(st.Self.DNSName, ".")
|
|
61
|
+
}
|
|
62
|
+
fmt.Printf("MESH-URL https://%s\n", name)
|
|
63
|
+
|
|
64
|
+
_ = http.Serve(ln, rp)
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MeshManager — permanent reachability over a Tailscale tailnet via the
|
|
4
|
+
// aiordie-mesh sidecar (tsnet in USERSPACE: no kernel TUN, no admin, no system
|
|
5
|
+
// service). Only ai-or-die's own port joins the mesh; the rest of the machine
|
|
6
|
+
// stays off it. Mirrors TunnelManager's lifecycle (detect → start → backoff →
|
|
7
|
+
// stop) so bin/ai-or-die.js supervises it like the dev tunnel. Verified E2E:
|
|
8
|
+
// the sidecar enrolls and reverse-proxies the local port to <host>.ts.net.
|
|
9
|
+
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
const URL_TIMEOUT_MS = 60000; // enroll + name can take ~30s on first run
|
|
16
|
+
const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
|
|
17
|
+
const MIN_RESTART_DELAY_MS = 1000;
|
|
18
|
+
const MAX_RESTART_DELAY_MS = 30000;
|
|
19
|
+
const MAX_RETRIES = 10;
|
|
20
|
+
|
|
21
|
+
class MeshManager {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.port = options.port || 7777; // the ai-or-die port to expose
|
|
24
|
+
this.dev = options.dev || false;
|
|
25
|
+
this.onUrl = options.onUrl || (() => {});
|
|
26
|
+
// Consume the key once and scrub it everywhere it could leak.
|
|
27
|
+
this._authKey = options.authKey || process.env.AIORDIE_TS_AUTHKEY || null;
|
|
28
|
+
delete process.env.AIORDIE_TS_AUTHKEY;
|
|
29
|
+
this._childEnv = { ...process.env };
|
|
30
|
+
delete this._childEnv.AIORDIE_TS_AUTHKEY;
|
|
31
|
+
|
|
32
|
+
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
33
|
+
const base = process.platform === 'win32'
|
|
34
|
+
? path.join(localApp, 'ai-or-die')
|
|
35
|
+
: path.join(os.homedir(), '.ai-or-die');
|
|
36
|
+
this.stateDir = options.stateDir || path.join(base, 'ts-state');
|
|
37
|
+
const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
|
|
38
|
+
this.sidecar = options.sidecar || path.join(base, 'bin', exe);
|
|
39
|
+
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
40
|
+
|
|
41
|
+
this.proc = null;
|
|
42
|
+
this.dnsName = null;
|
|
43
|
+
this.stopping = false;
|
|
44
|
+
this.retryCount = 0;
|
|
45
|
+
this._totalRestarts = 0;
|
|
46
|
+
this._stabilityTimer = null;
|
|
47
|
+
this._restartDelayTimer = null;
|
|
48
|
+
this._restartDelayResolve = null;
|
|
49
|
+
this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Never throws — degrades to localhost/devtunnel on any failure. */
|
|
53
|
+
async start() {
|
|
54
|
+
console.log('\n Connecting mesh (Tailscale userspace)...');
|
|
55
|
+
this.stopping = false;
|
|
56
|
+
if (!fs.existsSync(this.sidecar)) { this._printMissing(); return; }
|
|
57
|
+
if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
|
|
58
|
+
try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
|
|
59
|
+
await this._spawn();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getStatus() {
|
|
63
|
+
return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `https://${this.dnsName}` : null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stop() {
|
|
67
|
+
this.stopping = true;
|
|
68
|
+
this._clearStabilityTimer();
|
|
69
|
+
clearTimeout(this._restartDelayTimer);
|
|
70
|
+
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
71
|
+
if (!this.proc) return;
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const t = setTimeout(() => { try { this.proc.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
|
|
74
|
+
this.proc.once('exit', () => { clearTimeout(t); resolve(); });
|
|
75
|
+
try { this.proc.kill(); } catch (_) {}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** State dir already holds a node identity → no key needed. */
|
|
80
|
+
_enrolled() {
|
|
81
|
+
try { return fs.existsSync(path.join(this.stateDir, 'tailscaled.state')); } catch (_) { return false; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_spawn() {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
87
|
+
// Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
|
|
88
|
+
// stripped from this process + base child env, so it reaches only this child.
|
|
89
|
+
const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
|
|
90
|
+
this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
91
|
+
let done = false;
|
|
92
|
+
const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
|
|
93
|
+
this.proc.stdout.on('data', (d) => {
|
|
94
|
+
const out = d.toString();
|
|
95
|
+
if (this.dev) process.stdout.write(` [mesh] ${out}`);
|
|
96
|
+
const url = out.match(/MESH-URL (https:\/\/\S+)/);
|
|
97
|
+
if (url && !this.dnsName) {
|
|
98
|
+
this.dnsName = url[1].replace(/^https:\/\//, '');
|
|
99
|
+
this._authKey = null;
|
|
100
|
+
this._startStabilityTimer();
|
|
101
|
+
this.onUrl(`https://${this.dnsName}`);
|
|
102
|
+
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
103
|
+
}
|
|
104
|
+
if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
|
|
105
|
+
});
|
|
106
|
+
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
107
|
+
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(); } });
|
|
108
|
+
this.proc.on('exit', (code) => {
|
|
109
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
|
|
110
|
+
if (!this.stopping && code !== 0) this._restart();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_printMissing() {
|
|
116
|
+
console.log('\n \x1b[33mMesh: sidecar not installed.\x1b[0m Fetched on next release build; see docs/specs/mesh.md.');
|
|
117
|
+
console.log(' Continuing on localhost/devtunnel.\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_printNotEnrolled() {
|
|
121
|
+
const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
|
|
122
|
+
console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
|
|
123
|
+
console.log(' 1. Reusable, tagged key (NOT ephemeral): https://login.tailscale.com/admin/settings/keys');
|
|
124
|
+
console.log(` 2. \x1b[1m${ex}\x1b[0m`);
|
|
125
|
+
console.log(' 3. Revoke the key once the machine appears in the console.');
|
|
126
|
+
console.log(' Continuing on localhost/devtunnel meanwhile.\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_startStabilityTimer() {
|
|
130
|
+
this._clearStabilityTimer();
|
|
131
|
+
this._stabilityTimer = setTimeout(() => { this.retryCount = 0; }, this._stabilityThresholdMs);
|
|
132
|
+
if (this._stabilityTimer.unref) this._stabilityTimer.unref();
|
|
133
|
+
}
|
|
134
|
+
_clearStabilityTimer() { if (this._stabilityTimer) { clearTimeout(this._stabilityTimer); this._stabilityTimer = null; } }
|
|
135
|
+
|
|
136
|
+
async _restart() {
|
|
137
|
+
this._totalRestarts++; this.retryCount++;
|
|
138
|
+
if (this.retryCount > MAX_RETRIES) { console.error(' \x1b[31mMesh sidecar crashed too many times. Server continues on localhost.\x1b[0m'); return; }
|
|
139
|
+
const delay = Math.min(2 ** (this.retryCount - 1) * MIN_RESTART_DELAY_MS, MAX_RESTART_DELAY_MS);
|
|
140
|
+
await new Promise((resolve) => { this._restartDelayResolve = resolve; this._restartDelayTimer = setTimeout(resolve, delay); if (this._restartDelayTimer.unref) this._restartDelayTimer.unref(); });
|
|
141
|
+
this._restartDelayResolve = null;
|
|
142
|
+
if (this.stopping) return;
|
|
143
|
+
await this._spawn();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { MeshManager, _constants: { URL_TIMEOUT_MS, MAX_RETRIES } };
|
package/src/server.js
CHANGED
|
@@ -117,6 +117,9 @@ const BACKPRESSURE_LIMIT_BG = 128 * 1024;
|
|
|
117
117
|
class ClaudeCodeWebServer {
|
|
118
118
|
constructor(options = {}) {
|
|
119
119
|
this.port = options.port != null ? options.port : 7777;
|
|
120
|
+
// Bind host: null = all interfaces (default; devtunnel/LAN). Mesh mode pins
|
|
121
|
+
// '127.0.0.1' so only the tailnet `serve` proxy reaches the port, not the LAN.
|
|
122
|
+
this.bindHost = options.bindHost || null;
|
|
120
123
|
this.auth = options.auth;
|
|
121
124
|
this.noAuth = options.noAuth || false;
|
|
122
125
|
this.dev = options.dev || false;
|
|
@@ -168,6 +171,7 @@ class ClaudeCodeWebServer {
|
|
|
168
171
|
onEvent: (sessionId, event) => this.handleVSCodeTunnelEvent(sessionId, event),
|
|
169
172
|
});
|
|
170
173
|
this.tunnelManager = null; // Set via setTunnelManager() from CLI entry point
|
|
174
|
+
this.meshManager = null; // Set via setMeshManager() from CLI entry point
|
|
171
175
|
this.installAdvisor = new InstallAdvisor();
|
|
172
176
|
// Pure test-runner detection — keeps heavy native/local-model subsystems
|
|
173
177
|
// (STT, sticky-notes) inert in the suite so it never downloads models or
|
|
@@ -450,6 +454,10 @@ class ClaudeCodeWebServer {
|
|
|
450
454
|
this.tunnelManager = tm;
|
|
451
455
|
}
|
|
452
456
|
|
|
457
|
+
setMeshManager(mm) {
|
|
458
|
+
this.meshManager = mm;
|
|
459
|
+
}
|
|
460
|
+
|
|
453
461
|
async saveSessionsToDisk(force = false) {
|
|
454
462
|
if (force) {
|
|
455
463
|
this.sessionStore.markDirty();
|
|
@@ -560,6 +568,7 @@ class ClaudeCodeWebServer {
|
|
|
560
568
|
Promise.resolve().then(() => this.stickyNoteEngine.shutdown()),
|
|
561
569
|
Promise.resolve().then(() => this.sttEngine.shutdown()),
|
|
562
570
|
Promise.resolve().then(() => (this.tunnelManager ? this.tunnelManager.stop() : undefined)),
|
|
571
|
+
Promise.resolve().then(() => (this.meshManager ? this.meshManager.stop() : undefined)),
|
|
563
572
|
]);
|
|
564
573
|
await this.close();
|
|
565
574
|
clearTimeout(forceExitTimer);
|
|
@@ -3420,8 +3429,18 @@ class ClaudeCodeWebServer {
|
|
|
3420
3429
|
this.handleWebSocketConnection(ws, req);
|
|
3421
3430
|
});
|
|
3422
3431
|
|
|
3432
|
+
// WS keepalive: DERP relays (mesh mode) drop idle sockets; a server ping
|
|
3433
|
+
// every 15s keeps long-lived terminal connections alive and reaps dead ones.
|
|
3434
|
+
this._wsKeepalive = setInterval(() => {
|
|
3435
|
+
for (const ws of this.wss.clients) {
|
|
3436
|
+
if (ws.readyState === WebSocket.OPEN) { try { ws.ping(); } catch (_) {} }
|
|
3437
|
+
}
|
|
3438
|
+
}, 15000);
|
|
3439
|
+
if (this._wsKeepalive.unref) this._wsKeepalive.unref();
|
|
3440
|
+
this.wss.on('close', () => clearInterval(this._wsKeepalive));
|
|
3441
|
+
|
|
3423
3442
|
return new Promise((resolve, reject) => {
|
|
3424
|
-
|
|
3443
|
+
const onListen = (err) => {
|
|
3425
3444
|
if (err) {
|
|
3426
3445
|
reject(err);
|
|
3427
3446
|
} else {
|
|
@@ -3431,7 +3450,10 @@ class ClaudeCodeWebServer {
|
|
|
3431
3450
|
this.keepaliveManager.start();
|
|
3432
3451
|
resolve(server);
|
|
3433
3452
|
}
|
|
3434
|
-
}
|
|
3453
|
+
};
|
|
3454
|
+
// bindHost null → all interfaces (default). Mesh pins 127.0.0.1.
|
|
3455
|
+
if (this.bindHost) server.listen(this.port, this.bindHost, onListen);
|
|
3456
|
+
else server.listen(this.port, onListen);
|
|
3435
3457
|
});
|
|
3436
3458
|
}
|
|
3437
3459
|
|