anyagent-bridge 0.5.0

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.
Files changed (42) hide show
  1. package/.env.example +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/bin/anyagent-bridge.js +127 -0
  5. package/client/index.html +525 -0
  6. package/config.example.json +69 -0
  7. package/docs/INSTALL.md +138 -0
  8. package/docs/ROADMAP.md +168 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/WALKTHROUGH.md +82 -0
  11. package/docs/screenshots/.gitkeep +3 -0
  12. package/docs/screenshots/01-startup-banner.png +0 -0
  13. package/docs/screenshots/02-terminal-view.png +0 -0
  14. package/docs/screenshots/03-agent-running.png +0 -0
  15. package/docs/screenshots/04-mobile.png +0 -0
  16. package/package.json +57 -0
  17. package/server/auth/index.js +20 -0
  18. package/server/auth/manager.js +448 -0
  19. package/server/auth/oauth.js +154 -0
  20. package/server/auth/providers/github.js +59 -0
  21. package/server/auth/providers/google.js +44 -0
  22. package/server/auth/sessions.js +160 -0
  23. package/server/auth/store.js +135 -0
  24. package/server/auth/totp.js +140 -0
  25. package/server/index.js +1779 -0
  26. package/server/safety/audit.js +139 -0
  27. package/server/safety/clientip.js +73 -0
  28. package/server/safety/index.js +17 -0
  29. package/server/safety/manager.js +507 -0
  30. package/server/safety/redact.js +153 -0
  31. package/server/safety/sandbox.js +130 -0
  32. package/server/tunnel/adapters/cloudflare-quick.js +40 -0
  33. package/server/tunnel/adapters/cloudflared-named.js +49 -0
  34. package/server/tunnel/adapters/devtunnel.js +54 -0
  35. package/server/tunnel/adapters/tailscale.js +42 -0
  36. package/server/tunnel/base-adapter.js +185 -0
  37. package/server/tunnel/detect.js +65 -0
  38. package/server/tunnel/index.js +15 -0
  39. package/server/tunnel/manager.js +321 -0
  40. package/server/tunnel/registry.js +31 -0
  41. package/test/stage4-boot.js +98 -0
  42. package/test/stage4-smoke.js +267 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * AnyAgent Bridge — TunnelManager (Stage 2)
3
+ *
4
+ * Owns the tunnel lifecycle state machine, provider selection (via the
5
+ * registry), URL-acquisition watchdog, and restart/backoff policy. All
6
+ * orchestration lives here; adapters stay dumb. The server process NEVER
7
+ * crashes or restarts because of the tunnel — every non-`running` state means
8
+ * "localhost-only, fully functional".
9
+ *
10
+ * States: idle | starting | running | stopped | error
11
+ *
12
+ * Terminal errors (need user action, no auto-retry):
13
+ * CLI_NOT_FOUND, LOGIN_REQUIRED, NOT_IN_TAILNET, NOT_CONFIGURED, SPAWN_FAILED
14
+ * Retryable errors (backoff, mirrors the PTY respawn storm guard):
15
+ * URL_TIMEOUT, EXIT_BEFORE_URL, CRASHED
16
+ */
17
+
18
+ const { EventEmitter } = require('events');
19
+ const { getAdapter, listProviders } = require('./registry');
20
+
21
+ const DEFAULTS = {
22
+ urlTimeoutMs: 30000,
23
+ killGraceMs: 4000,
24
+ restart: { maxPerWindow: 5, windowMs: 10000, backoffMs: 5000, backoffMaxMs: 60000 }
25
+ };
26
+
27
+ const TERMINAL_CODES = new Set([
28
+ 'CLI_NOT_FOUND', 'LOGIN_REQUIRED', 'NOT_IN_TAILNET', 'NOT_CONFIGURED', 'SPAWN_FAILED'
29
+ ]);
30
+
31
+ class TunnelManager extends EventEmitter {
32
+ constructor(tunnelConfig, logger) {
33
+ super();
34
+ this.config = tunnelConfig || {};
35
+ this.logger = logger || console;
36
+
37
+ this.urlTimeoutMs = this.config.urlTimeoutMs || DEFAULTS.urlTimeoutMs;
38
+ this.killGraceMs = this.config.killGraceMs || DEFAULTS.killGraceMs;
39
+ this.restartCfg = { ...DEFAULTS.restart, ...(this.config.restart || {}) };
40
+ this.providerId = this.config.provider || 'devtunnel';
41
+
42
+ this.state = 'idle';
43
+ this.url = null;
44
+ this.since = null;
45
+ this.pid = null;
46
+ this.lastError = null;
47
+ this.attempts = 0;
48
+
49
+ this.adapter = null;
50
+ this._stopping = false;
51
+ this._watchdog = null;
52
+ this._backoffTimer = null;
53
+ this._stableTimer = null; // resets the restart budget once running holds windowMs
54
+ this._reachedRunning = false; // distinguishes a post-running crash from never-started
55
+ this._restartWindowStart = 0;
56
+ this._restartCount = 0;
57
+ this._port = null;
58
+ this._host = '127.0.0.1'; // tunnels always target the loopback service
59
+ }
60
+
61
+ // Detach a doomed adapter so its buffered late events can never mutate manager
62
+ // state (the root cause of the restart race). Safe: adapter.stop() reaps the
63
+ // child via its own internal listener, not these.
64
+ _disposeAdapter() {
65
+ if (this.adapter) {
66
+ try { this.adapter.removeAllListeners(); } catch (e) { /* noop */ }
67
+ }
68
+ }
69
+
70
+ _clearStableTimer() {
71
+ if (this._stableTimer) { clearTimeout(this._stableTimer); this._stableTimer = null; }
72
+ }
73
+
74
+ // Arm on reaching 'running': if the tunnel stays up for one window, hand it a
75
+ // fresh restart budget (spec §7: window resets only after holding running).
76
+ _armStableTimer() {
77
+ this._clearStableTimer();
78
+ this._stableTimer = setTimeout(() => {
79
+ this._stableTimer = null;
80
+ this._restartWindowStart = 0;
81
+ this._restartCount = 0;
82
+ }, this.restartCfg.windowMs);
83
+ if (this._stableTimer.unref) this._stableTimer.unref();
84
+ }
85
+
86
+ _isEnabled() { return this.config && this.config.enabled === true; }
87
+
88
+ _setState(state, extra) {
89
+ this.state = state;
90
+ if (extra && 'url' in extra) this.url = extra.url;
91
+ if (extra && 'error' in extra) this.lastError = extra.error;
92
+ this.since = Date.now();
93
+ this.emit('state', this.getStatus());
94
+ }
95
+
96
+ /** Snapshot for the status endpoint / SEAM 1. Synchronous, never spawns. */
97
+ getStatus() {
98
+ // Preserve Stage-1 `tunnel: null` byte-for-byte when disabled & untouched.
99
+ if (this.state === 'idle' && !this._isEnabled()) return null;
100
+ const Adapter = getAdapter(this.providerId);
101
+ const stable = Adapter ? Adapter.stableUrl : undefined;
102
+ return {
103
+ provider: this.providerId,
104
+ state: this.state,
105
+ url: this.url,
106
+ since: this.since,
107
+ pid: this.pid,
108
+ lastError: this.lastError,
109
+ attempts: this.attempts,
110
+ ephemeral: stable === undefined ? undefined : !stable,
111
+ stableUrl: stable
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Start the tunnel. Idempotent. Non-blocking (URL arrives async).
117
+ * @param {number} [port] local port to expose; remembered for restarts.
118
+ */
119
+ start(port) {
120
+ if (port != null) this._port = port;
121
+ if (!this._isEnabled()) { this.state = 'idle'; return; }
122
+ if (this.state === 'starting' || this.state === 'running') return;
123
+
124
+ const Adapter = getAdapter(this.providerId);
125
+ if (!Adapter) {
126
+ this.logger.warn(`[Tunnel] Unknown provider '${this.providerId}' (known: ${listProviders().join(', ')}) — localhost-only`);
127
+ this._setState('error', { error: `unknown provider '${this.providerId}'` });
128
+ return;
129
+ }
130
+ this._spawnAdapter(Adapter);
131
+ }
132
+
133
+ _spawnAdapter(Adapter) {
134
+ this._disposeAdapter(); // detach any prior adapter's listeners first
135
+ this._clearStableTimer();
136
+ this._stopping = false;
137
+ this._reachedRunning = false;
138
+ this.attempts += 1;
139
+ const providerConfig = this.config[this.providerId] || {};
140
+ this.adapter = new Adapter({
141
+ host: this._host,
142
+ port: this._port,
143
+ providerConfig,
144
+ killGraceMs: this.killGraceMs,
145
+ logger: this.logger
146
+ });
147
+ this._setState('starting');
148
+
149
+ Promise.resolve(this.adapter.detect()).then((det) => {
150
+ if (this.state !== 'starting' || this._stopping) return; // stopped meanwhile
151
+ if (!det.available) {
152
+ this._terminalError('CLI_NOT_FOUND', `'${Adapter.binaryName}' not found on PATH. ${Adapter.installHint || ''}`.trim());
153
+ return;
154
+ }
155
+ this._wireAdapter(this.adapter);
156
+ this.adapter.start();
157
+ this._armWatchdog();
158
+ }).catch((e) => {
159
+ this._terminalError('SPAWN_FAILED', e.message);
160
+ });
161
+ }
162
+
163
+ _wireAdapter(a) {
164
+ a.on('url', (url) => {
165
+ this._clearWatchdog();
166
+ this._reachedRunning = true;
167
+ this._armStableTimer();
168
+ this.pid = a.pid;
169
+ this._setState('running', { url });
170
+ this.emit('ready', this.getStatus());
171
+ this.logger.log(`[Tunnel] ${this.providerId} ready: ${url}`);
172
+ });
173
+ a.on('ready', () => {
174
+ // cloudflared-named: the CLI never prints the URL; it is the configured hostname.
175
+ this._clearWatchdog();
176
+ this._reachedRunning = true;
177
+ this._armStableTimer();
178
+ this.pid = a.pid;
179
+ const host = this._namedHostname();
180
+ const url = host ? `https://${host}` : null;
181
+ this._setState('running', { url });
182
+ this.emit('ready', this.getStatus());
183
+ this.logger.log(`[Tunnel] ${this.providerId} ready${url ? ': ' + url : ' (set tunnel["cloudflared-named"].hostname to display the URL)'}`);
184
+ });
185
+ a.on('error', (err) => this._onAdapterError(err));
186
+ a.on('exit', ({ code, signal }) => this._onAdapterExit(code, signal));
187
+ }
188
+
189
+ _namedHostname() {
190
+ const pc = this.config['cloudflared-named'] || {};
191
+ return pc.hostname || null;
192
+ }
193
+
194
+ _onAdapterError(err) {
195
+ const code = (err && err.code) || 'ERROR';
196
+ const message = (err && err.message) || '';
197
+ if (TERMINAL_CODES.has(code)) {
198
+ this._terminalError(code, message);
199
+ } else {
200
+ this.lastError = `${code}: ${message}`.trim();
201
+ this._killAdapterQuietly();
202
+ this._scheduleRetry(code);
203
+ }
204
+ }
205
+
206
+ _onAdapterExit(code, signal) {
207
+ this.pid = null;
208
+ if (this._stopping) return; // expected (we killed it)
209
+ if (this.state === 'stopped' || this.state === 'error') return;
210
+ this._clearWatchdog();
211
+ this._clearStableTimer();
212
+ // Classify by whether we ever reached running — url may be null even when
213
+ // running (cloudflared-named with no configured hostname).
214
+ const reason = this._reachedRunning ? 'CRASHED' : 'EXIT_BEFORE_URL';
215
+ this.logger.warn(`[Tunnel] ${this.providerId} ${reason} (code=${code} signal=${signal || ''})`);
216
+ this._scheduleRetry(reason);
217
+ }
218
+
219
+ _terminalError(code, message) {
220
+ this._clearWatchdog();
221
+ this._clearStableTimer();
222
+ this._killAdapterQuietly();
223
+ this.pid = null;
224
+ this.url = null;
225
+ this._setState('error', { error: `${code}: ${message}` });
226
+ this.logger.warn(`[Tunnel] ${this.providerId} ${code} — ${message} (localhost-only; no auto-retry)`);
227
+ }
228
+
229
+ _armWatchdog() {
230
+ this._clearWatchdog();
231
+ this._watchdog = setTimeout(() => {
232
+ this._watchdog = null;
233
+ if (this.state !== 'starting') return;
234
+ this.logger.warn(`[Tunnel] ${this.providerId} produced no URL within ${this.urlTimeoutMs}ms`);
235
+ this._killAdapterQuietly();
236
+ this._scheduleRetry('URL_TIMEOUT');
237
+ }, this.urlTimeoutMs);
238
+ if (this._watchdog.unref) this._watchdog.unref();
239
+ }
240
+
241
+ _clearWatchdog() {
242
+ if (this._watchdog) { clearTimeout(this._watchdog); this._watchdog = null; }
243
+ }
244
+
245
+ // Kill the current child without counting its exit as an unexpected crash.
246
+ // Detaches the adapter's listeners first so its late output can't flip state.
247
+ _killAdapterQuietly() {
248
+ this._stopping = true;
249
+ if (this.adapter) {
250
+ const a = this.adapter;
251
+ this._disposeAdapter();
252
+ Promise.resolve(a.stop()).catch(() => {});
253
+ }
254
+ }
255
+
256
+ _scheduleRetry(reason) {
257
+ // The window is reset only by a sustained 'running' period (see _armStableTimer),
258
+ // never merely by elapsed time — so spaced failures can't refill the budget.
259
+ if (!this._restartWindowStart) this._restartWindowStart = Date.now();
260
+ this._restartCount += 1;
261
+
262
+ if (this._restartCount > this.restartCfg.maxPerWindow) {
263
+ this.url = null;
264
+ this._setState('error', { error: `${reason} — restart storm (${this._restartCount} in ${this.restartCfg.windowMs}ms), giving up` });
265
+ this.logger.error(`[Tunnel] ${this.providerId} restart storm — staying localhost-only. Use POST /api/tunnel/restart to retry.`);
266
+ return;
267
+ }
268
+
269
+ let delay = this.restartCfg.backoffMs * Math.pow(2, this._restartCount - 1);
270
+ if (delay > this.restartCfg.backoffMaxMs) delay = this.restartCfg.backoffMaxMs;
271
+
272
+ this.url = null;
273
+ this._setState('error', { error: `${reason} — retrying in ${delay}ms (attempt ${this._restartCount})` });
274
+ this.logger.warn(`[Tunnel] ${this.providerId} ${reason} — retry in ${delay}ms`);
275
+
276
+ this._backoffTimer = setTimeout(() => {
277
+ this._backoffTimer = null;
278
+ // A stop()/restart()/terminal-error that ran during the backoff supersedes
279
+ // this retry — only fire if we're still in the backing-off 'error' state.
280
+ if (this.state !== 'error') return;
281
+ if (!this._isEnabled()) return;
282
+ const Adapter = getAdapter(this.providerId);
283
+ if (Adapter) this._spawnAdapter(Adapter);
284
+ }, delay);
285
+ if (this._backoffTimer.unref) this._backoffTimer.unref();
286
+ }
287
+
288
+ _clearBackoffTimer() {
289
+ if (this._backoffTimer) { clearTimeout(this._backoffTimer); this._backoffTimer = null; }
290
+ }
291
+
292
+ /** Stop the tunnel. Idempotent; safe before start(). */
293
+ async stop() {
294
+ this._stopping = true;
295
+ this._clearWatchdog();
296
+ this._clearStableTimer();
297
+ this._clearBackoffTimer();
298
+ if (this.adapter) {
299
+ const a = this.adapter;
300
+ this._disposeAdapter(); // late events from the dying child can't flip state
301
+ try { await a.stop(); } catch (e) { /* best effort */ }
302
+ }
303
+ this.pid = null;
304
+ this.url = null;
305
+ this._reachedRunning = false;
306
+ this._setState('stopped');
307
+ }
308
+
309
+ /** Stop then start, with a fresh restart budget. */
310
+ restart() {
311
+ this._restartWindowStart = 0;
312
+ this._restartCount = 0;
313
+ this._clearBackoffTimer();
314
+ Promise.resolve(this.stop()).then(() => {
315
+ this._stopping = false;
316
+ this.start();
317
+ }).catch(() => {});
318
+ }
319
+ }
320
+
321
+ module.exports = TunnelManager;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * AnyAgent Bridge — tunnel provider registry (Stage 2)
3
+ *
4
+ * The extensibility seam: a map of providerId -> Adapter class. Adding a 5th
5
+ * provider is exactly two lines — require its file and register() it here.
6
+ * Nothing else in the codebase needs to change.
7
+ */
8
+
9
+ const adapters = new Map();
10
+
11
+ function register(Adapter) {
12
+ if (!Adapter || !Adapter.id) {
13
+ throw new Error('register(): adapter is missing a static id');
14
+ }
15
+ adapters.set(Adapter.id, Adapter);
16
+ }
17
+
18
+ function getAdapter(id) {
19
+ return adapters.get(id) || null;
20
+ }
21
+
22
+ function listProviders() {
23
+ return Array.from(adapters.keys());
24
+ }
25
+
26
+ register(require('./adapters/devtunnel'));
27
+ register(require('./adapters/cloudflare-quick'));
28
+ register(require('./adapters/tailscale'));
29
+ register(require('./adapters/cloudflared-named'));
30
+
31
+ module.exports = { register, getAdapter, listProviders };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Stage 4 boot test — boots the real server and proves the cardinal invariant at the
3
+ * integrated level: with safety off the server is byte-identical to Stage 3 (no
4
+ * `safety` key in /api/system/status, no safety banner lines, no /api/safety/* routes),
5
+ * and with safety on the subsystem is wired (status key, routes, audit recording).
6
+ *
7
+ * Zero dependencies — child_process + global fetch (Node >=18). Run:
8
+ * node test/stage4-boot.js
9
+ */
10
+ 'use strict';
11
+ const { spawn } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ const ROOT = path.join(__dirname, '..');
17
+ const PORT = 3997;
18
+ let pass = 0, fail = 0;
19
+ const ok = (m) => { console.log(` ok ${m}`); pass++; };
20
+ const bad = (m) => { console.error(` FAIL ${m}`); fail++; };
21
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
22
+
23
+ function bootServer(extraEnv) {
24
+ const env = { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', BRIDGE_TUNNEL_ENABLED: 'false', ...extraEnv };
25
+ const child = spawn(process.execPath, [path.join(ROOT, 'server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
26
+ let out = '';
27
+ child.stdout.on('data', d => { out += d.toString(); });
28
+ child.stderr.on('data', d => { out += d.toString(); });
29
+ return { child, log: () => out };
30
+ }
31
+
32
+ async function waitHealthy() {
33
+ for (let i = 0; i < 50; i++) {
34
+ try { const r = await fetch(`http://127.0.0.1:${PORT}/health`); if (r.ok) return true; } catch (e) { /* not up yet */ }
35
+ await sleep(200);
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function tokenFrom(log) {
41
+ const m = /Access token \([a-z]+\): ([a-f0-9]+)/.exec(log);
42
+ return m ? m[1] : null;
43
+ }
44
+
45
+ async function stop(child) {
46
+ child.kill('SIGTERM');
47
+ for (let i = 0; i < 25; i++) { if (child.exitCode !== null || child.signalCode) return; await sleep(200); }
48
+ try { child.kill('SIGKILL'); } catch (e) {}
49
+ }
50
+
51
+ async function main() {
52
+ // ── safety OFF (default) ──
53
+ console.log('\n── boot: safety OFF (default) ──');
54
+ let s = bootServer({});
55
+ if (!await waitHealthy()) { bad('server did not boot (safety off)'); console.error(s.log()); await stop(s.child); }
56
+ else {
57
+ const T = tokenFrom(s.log());
58
+ const st = await (await fetch(`http://127.0.0.1:${PORT}/api/system/status?token=${T}`)).json();
59
+ if (!('safety' in st)) ok('system/status has NO safety key when off (byte-identical)'); else bad('system/status leaked a safety key when off');
60
+ if ('tunnel' in st && 'auth' in st && 'server' in st) ok('system/status keeps Stage-3 keys'); else bad('Stage-3 keys missing');
61
+ if (!/Sandbox:|Audit:|SAFETY:/.test(s.log())) ok('banner has no safety lines when off'); else bad('banner leaked safety lines when off');
62
+ const code = (await fetch(`http://127.0.0.1:${PORT}/api/safety/status?token=${T}`)).status;
63
+ if (code === 404) ok('/api/safety/status is 404 when off (no routes registered)'); else bad(`/api/safety/status returned ${code}, expected 404`);
64
+ await stop(s.child);
65
+ }
66
+
67
+ // ── safety ON + audit ──
68
+ console.log('\n── boot: safety ON + audit ──');
69
+ try { fs.rmSync(path.join(ROOT, '.data/audit'), { recursive: true, force: true }); } catch (e) {}
70
+ s = bootServer({ BRIDGE_SAFETY_ENABLED: 'true', BRIDGE_AUDIT_ENABLED: 'true' });
71
+ if (!await waitHealthy()) { bad('server did not boot (safety on)'); console.error(s.log()); await stop(s.child); }
72
+ else {
73
+ const T = tokenFrom(s.log());
74
+ const st = await (await fetch(`http://127.0.0.1:${PORT}/api/system/status?token=${T}`)).json();
75
+ if (st.safety && st.safety.killSwitch) ok('system/status has safety status when on'); else bad('safety status missing when on');
76
+ const code = (await fetch(`http://127.0.0.1:${PORT}/api/safety/status?token=${T}`)).status;
77
+ if (code === 200) ok('/api/safety/status is 200 when on'); else bad(`/api/safety/status returned ${code}`);
78
+ if (/Audit:/.test(s.log())) ok('banner shows the Audit summary when on'); else bad('banner missing the Audit line');
79
+ // an audited mutation with NO filesystem side effect: DELETE a path that does not
80
+ // exist (allowed-but-absent → 404, still logged as file.delete).
81
+ const ghost = path.join(os.homedir(), '.aab-stage4-ghost-never-exists');
82
+ await fetch(`http://127.0.0.1:${PORT}/api/file?path=${encodeURIComponent(ghost)}&token=${T}`, { method: 'DELETE' });
83
+ await sleep(300);
84
+ let files = [];
85
+ try { files = fs.readdirSync(path.join(ROOT, '.data/audit')).filter(f => /^audit-.*\.jsonl$/.test(f)); } catch (e) {}
86
+ if (files.length) {
87
+ ok('audit JSONL file created');
88
+ const body = fs.readFileSync(path.join(ROOT, '.data/audit', files[0]), 'utf8');
89
+ if (/file\.delete/.test(body)) ok('file.delete event recorded'); else bad('file.delete not in audit log');
90
+ } else bad('no audit file created');
91
+ await stop(s.child);
92
+ }
93
+
94
+ console.log(`\n ${pass} passed, ${fail} failed\n`);
95
+ process.exit(fail ? 1 : 0);
96
+ }
97
+
98
+ main().catch(e => { console.error(e); process.exit(1); });