ai-or-die 0.1.83 → 0.1.85
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 +24 -2
- package/mesh/go.mod +5 -0
- package/mesh/main.go +65 -0
- package/package.json +1 -1
- package/src/mesh-manager.js +164 -0
- package/src/server.js +24 -2
- package/src/utils/sidecar-installer.js +109 -0
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,7 +89,8 @@ 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 — even with --mesh.
|
|
93
|
+
// (--mesh alone keeps the Bearer token on; --tunnel always wins.)
|
|
92
94
|
let authToken = null;
|
|
93
95
|
let noAuth = options.disableAuth === true || options.tunnel === true;
|
|
94
96
|
|
|
@@ -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...');
|
|
@@ -139,7 +144,8 @@ async function main() {
|
|
|
139
144
|
console.log(`Plan: ${options.plan}`);
|
|
140
145
|
console.log(`Aliases: Claude → "${serverOptions.claudeAlias}", Codex → "${serverOptions.codexAlias}", Copilot → "${serverOptions.copilotAlias}", Gemini → "${serverOptions.geminiAlias}", Terminal → "${serverOptions.terminalAlias}"`);
|
|
141
146
|
|
|
142
|
-
// Display authentication status
|
|
147
|
+
// Display authentication status. --tunnel disables auth (tunnel controls
|
|
148
|
+
// access) even with --mesh; --mesh alone keeps the Bearer token on.
|
|
143
149
|
if (options.tunnel) {
|
|
144
150
|
console.log('\n🌍 TUNNEL MODE — authentication disabled (tunnel controls access)');
|
|
145
151
|
} else if (noAuth) {
|
|
@@ -193,6 +199,22 @@ async function main() {
|
|
|
193
199
|
}
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
// Mesh: permanent Tailscale reachability. Coexists with --tunnel (mesh for
|
|
203
|
+
// owned devices, tunnel fallback for borrowed ones). Auth token stays ON.
|
|
204
|
+
if (options.mesh) {
|
|
205
|
+
const { MeshManager } = require('../src/mesh-manager');
|
|
206
|
+
const mesh = new MeshManager({
|
|
207
|
+
port,
|
|
208
|
+
dev: options.dev,
|
|
209
|
+
onUrl: (meshUrl) => {
|
|
210
|
+
const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
|
|
211
|
+
console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
app.setMeshManager(mesh);
|
|
215
|
+
await mesh.start();
|
|
216
|
+
}
|
|
217
|
+
|
|
196
218
|
console.log('\nPress Ctrl+C to stop the server\n');
|
|
197
219
|
|
|
198
220
|
// 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,164 @@
|
|
|
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)) {
|
|
57
|
+
if (!(await this._ensureSidecar())) { this._printMissing(); return; }
|
|
58
|
+
}
|
|
59
|
+
if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
|
|
60
|
+
try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
|
|
61
|
+
await this._spawn();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Download + verify the sidecar from the matching release. Best-effort. */
|
|
65
|
+
async _ensureSidecar() {
|
|
66
|
+
try {
|
|
67
|
+
const { ensureSidecar } = require('./utils/sidecar-installer');
|
|
68
|
+
let version = '0.0.0';
|
|
69
|
+
try { version = require('../package.json').version; } catch (_) {}
|
|
70
|
+
console.log(' [mesh] fetching sidecar binary...');
|
|
71
|
+
await ensureSidecar(version, this.sidecar);
|
|
72
|
+
return fs.existsSync(this.sidecar);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (this.dev) console.error(' [mesh] sidecar fetch failed:', e.message);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getStatus() {
|
|
80
|
+
return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `https://${this.dnsName}` : null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async stop() {
|
|
84
|
+
this.stopping = true;
|
|
85
|
+
this._clearStabilityTimer();
|
|
86
|
+
clearTimeout(this._restartDelayTimer);
|
|
87
|
+
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
88
|
+
if (!this.proc) return;
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const t = setTimeout(() => { try { this.proc.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
|
|
91
|
+
this.proc.once('exit', () => { clearTimeout(t); resolve(); });
|
|
92
|
+
try { this.proc.kill(); } catch (_) {}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** State dir already holds a node identity → no key needed. */
|
|
97
|
+
_enrolled() {
|
|
98
|
+
try { return fs.existsSync(path.join(this.stateDir, 'tailscaled.state')); } catch (_) { return false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_spawn() {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
104
|
+
// Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
|
|
105
|
+
// stripped from this process + base child env, so it reaches only this child.
|
|
106
|
+
const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
|
|
107
|
+
this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
108
|
+
let done = false;
|
|
109
|
+
const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
|
|
110
|
+
this.proc.stdout.on('data', (d) => {
|
|
111
|
+
const out = d.toString();
|
|
112
|
+
if (this.dev) process.stdout.write(` [mesh] ${out}`);
|
|
113
|
+
const url = out.match(/MESH-URL (https:\/\/\S+)/);
|
|
114
|
+
if (url && !this.dnsName) {
|
|
115
|
+
this.dnsName = url[1].replace(/^https:\/\//, '');
|
|
116
|
+
this._authKey = null;
|
|
117
|
+
this._startStabilityTimer();
|
|
118
|
+
this.onUrl(`https://${this.dnsName}`);
|
|
119
|
+
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
120
|
+
}
|
|
121
|
+
if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
|
|
122
|
+
});
|
|
123
|
+
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
124
|
+
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(); } });
|
|
125
|
+
this.proc.on('exit', (code) => {
|
|
126
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
|
|
127
|
+
if (!this.stopping && code !== 0) this._restart();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_printMissing() {
|
|
133
|
+
console.log('\n \x1b[33mMesh: sidecar not installed.\x1b[0m Fetched on next release build; see docs/specs/mesh.md.');
|
|
134
|
+
console.log(' Continuing on localhost/devtunnel.\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_printNotEnrolled() {
|
|
138
|
+
const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
|
|
139
|
+
console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
|
|
140
|
+
console.log(' 1. Reusable, tagged key (NOT ephemeral): https://login.tailscale.com/admin/settings/keys');
|
|
141
|
+
console.log(` 2. \x1b[1m${ex}\x1b[0m`);
|
|
142
|
+
console.log(' 3. Revoke the key once the machine appears in the console.');
|
|
143
|
+
console.log(' Continuing on localhost/devtunnel meanwhile.\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_startStabilityTimer() {
|
|
147
|
+
this._clearStabilityTimer();
|
|
148
|
+
this._stabilityTimer = setTimeout(() => { this.retryCount = 0; }, this._stabilityThresholdMs);
|
|
149
|
+
if (this._stabilityTimer.unref) this._stabilityTimer.unref();
|
|
150
|
+
}
|
|
151
|
+
_clearStabilityTimer() { if (this._stabilityTimer) { clearTimeout(this._stabilityTimer); this._stabilityTimer = null; } }
|
|
152
|
+
|
|
153
|
+
async _restart() {
|
|
154
|
+
this._totalRestarts++; this.retryCount++;
|
|
155
|
+
if (this.retryCount > MAX_RETRIES) { console.error(' \x1b[31mMesh sidecar crashed too many times. Server continues on localhost.\x1b[0m'); return; }
|
|
156
|
+
const delay = Math.min(2 ** (this.retryCount - 1) * MIN_RESTART_DELAY_MS, MAX_RESTART_DELAY_MS);
|
|
157
|
+
await new Promise((resolve) => { this._restartDelayResolve = resolve; this._restartDelayTimer = setTimeout(resolve, delay); if (this._restartDelayTimer.unref) this._restartDelayTimer.unref(); });
|
|
158
|
+
this._restartDelayResolve = null;
|
|
159
|
+
if (this.stopping) return;
|
|
160
|
+
await this._spawn();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
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
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sidecar installer — fetches the prebuilt aiordie-mesh tsnet binary for the
|
|
4
|
+
// current platform from the matching GitHub release and verifies it against the
|
|
5
|
+
// release's published SHA-256 checksums before use. Mirrors the download/verify
|
|
6
|
+
// shape of gguf-model-manager (resumable not needed — the binary is ~20MB).
|
|
7
|
+
//
|
|
8
|
+
// Asset convention (published by .github/workflows/release-on-main.yml):
|
|
9
|
+
// aiordie-mesh-<plat>-<arch>[.exe] plat: windows|linux|darwin arch: amd64|arm64
|
|
10
|
+
// aiordie-mesh-checksums.txt "<sha256> <assetname>" per line
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const fsp = require('fs').promises;
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
const REPO = 'animeshkundu/ai-or-die';
|
|
19
|
+
const PLAT = { win32: 'windows', linux: 'linux', darwin: 'darwin' };
|
|
20
|
+
const ARCH = { x64: 'amd64', arm64: 'arm64' };
|
|
21
|
+
|
|
22
|
+
function assetName() {
|
|
23
|
+
const plat = PLAT[process.platform];
|
|
24
|
+
const arch = ARCH[process.arch];
|
|
25
|
+
if (!plat || !arch) return null;
|
|
26
|
+
return `aiordie-mesh-${plat}-${arch}${process.platform === 'win32' ? '.exe' : ''}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sidecarPath() {
|
|
30
|
+
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
31
|
+
const base = process.platform === 'win32' ? path.join(localApp, 'ai-or-die') : path.join(os.homedir(), '.ai-or-die');
|
|
32
|
+
return path.join(base, 'bin', `aiordie-mesh${process.platform === 'win32' ? '.exe' : ''}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function _sha256(file) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const h = crypto.createHash('sha256');
|
|
38
|
+
const s = fs.createReadStream(file);
|
|
39
|
+
s.on('data', (c) => h.update(c));
|
|
40
|
+
s.on('end', () => resolve(h.digest('hex')));
|
|
41
|
+
s.on('error', reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function _fetchText(url, ms = 15000) {
|
|
46
|
+
const ac = new AbortController();
|
|
47
|
+
const t = setTimeout(() => ac.abort(), ms);
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetch(url, { signal: ac.signal });
|
|
50
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
51
|
+
return await r.text();
|
|
52
|
+
} finally { clearTimeout(t); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function _download(url, tmp, ms = 120000) {
|
|
56
|
+
const ac = new AbortController();
|
|
57
|
+
const t = setTimeout(() => ac.abort(), ms);
|
|
58
|
+
try {
|
|
59
|
+
const r = await fetch(url, { signal: ac.signal });
|
|
60
|
+
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
|
|
61
|
+
// Exclusive create — never follow/overwrite a pre-planted file or symlink.
|
|
62
|
+
const out = fs.createWriteStream(tmp, { flags: 'wx' });
|
|
63
|
+
const reader = r.body.getReader();
|
|
64
|
+
for (;;) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) break;
|
|
67
|
+
await new Promise((res, rej) => out.write(value, (e) => (e ? rej(e) : res())));
|
|
68
|
+
}
|
|
69
|
+
await new Promise((res) => out.end(res));
|
|
70
|
+
} finally { clearTimeout(t); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Ensure the sidecar binary exists locally AND matches the release checksum;
|
|
74
|
+
// download + verify if missing or stale. Returns the path; throws on failure.
|
|
75
|
+
async function ensureSidecar(version, dest = sidecarPath()) {
|
|
76
|
+
const name = assetName();
|
|
77
|
+
if (!name) throw new Error(`unsupported platform ${process.platform}/${process.arch}`);
|
|
78
|
+
|
|
79
|
+
// Resolve the expected hash first so an existing file is also verified.
|
|
80
|
+
const baseUrl = `https://github.com/${REPO}/releases/download/v${version}`;
|
|
81
|
+
const sums = await _fetchText(`${baseUrl}/aiordie-mesh-checksums.txt`);
|
|
82
|
+
const line = sums.split('\n').find((l) => {
|
|
83
|
+
const f = l.trim().split(/\s+/)[1]; // exact filename column, not endsWith
|
|
84
|
+
return f === name;
|
|
85
|
+
});
|
|
86
|
+
if (!line) throw new Error(`no checksum for ${name} in release v${version}`);
|
|
87
|
+
const want = line.trim().split(/\s+/)[0].toLowerCase();
|
|
88
|
+
|
|
89
|
+
// Existing file: trust only if it matches; otherwise replace it.
|
|
90
|
+
if (fs.existsSync(dest)) {
|
|
91
|
+
if ((await _sha256(dest)).toLowerCase() === want) return dest;
|
|
92
|
+
try { await fsp.unlink(dest); } catch (_) {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
96
|
+
const tmp = `${dest}.${crypto.randomBytes(8).toString('hex')}.incomplete`;
|
|
97
|
+
try {
|
|
98
|
+
await _download(`${baseUrl}/${name}`, tmp);
|
|
99
|
+
// Verify BEFORE the bytes ever reach the runnable path (no TOCTOU window).
|
|
100
|
+
if ((await _sha256(tmp)).toLowerCase() !== want) throw new Error(`checksum mismatch for ${name}`);
|
|
101
|
+
if (process.platform !== 'win32') { try { await fsp.chmod(tmp, 0o755); } catch (_) {} }
|
|
102
|
+
await fsp.rename(tmp, dest);
|
|
103
|
+
} finally {
|
|
104
|
+
try { await fsp.unlink(tmp); } catch (_) {}
|
|
105
|
+
}
|
|
106
|
+
return dest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { ensureSidecar, sidecarPath, assetName };
|