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,130 @@
1
+ /**
2
+ * AnyAgent Bridge — Docker sandbox argv/env builder (Stage 4)
3
+ *
4
+ * Pure, stateless, unit-testable. Builds the `docker run …` argument vector and the
5
+ * MINIMAL environment handed to the docker client process. No shell string is ever
6
+ * constructed — pty.spawn receives an args ARRAY, so there is no interpolation /
7
+ * injection surface (same discipline as the tunnel base-adapter).
8
+ *
9
+ * Secret handling (critical): the docker CLIENT env is a small allowlist (PATH /
10
+ * HOME / DOCKER_* + the explicitly passed-through names) — NEVER the bridge's full
11
+ * process.env, which would leak BRIDGE_AUTH_TOKEN / BRIDGE_SESSION_SECRET into the
12
+ * container's reach. Passed-through secrets use the `-e NAME` (value-from-client-env)
13
+ * form so their VALUES never land in argv / the process table.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const { findOnPath } = require('../tunnel/detect');
20
+
21
+ // Never forward the bridge's own secrets into a container, even if an operator
22
+ // lists them in envPassthrough.
23
+ const ENV_DENYLIST = /^(BRIDGE_|AAB_)/i;
24
+ const ENV_DENY_EXACT = new Set(['AUTH_TOKEN', 'SESSION_SECRET', 'BRIDGE_AUTH_TOKEN', 'BRIDGE_SESSION_SECRET']);
25
+
26
+ function detectDocker() {
27
+ try { return findOnPath('docker'); } catch (e) { return null; }
28
+ }
29
+
30
+ /** A `docker run -v` host path. On Windows, map `C:\foo` → `//c/foo` for Docker Desktop. */
31
+ function dockerMountPath(p) {
32
+ const resolved = path.resolve(p);
33
+ if (process.platform === 'win32') {
34
+ const m = /^([A-Za-z]):[\\/](.*)$/.exec(resolved);
35
+ if (m) return `//${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}`;
36
+ return resolved.replace(/\\/g, '/');
37
+ }
38
+ return resolved;
39
+ }
40
+
41
+ /** Filter an envPassthrough list down to names that are safe and present in env. */
42
+ function resolvePassthrough(names, env) {
43
+ const e = env || process.env;
44
+ const out = [];
45
+ const seen = new Set();
46
+ for (const name of Array.isArray(names) ? names : []) {
47
+ if (typeof name !== 'string' || !name) continue;
48
+ if (ENV_DENYLIST.test(name) || ENV_DENY_EXACT.has(name)) continue;
49
+ if (e[name] === undefined) continue;
50
+ if (seen.has(name)) continue;
51
+ seen.add(name);
52
+ out.push(name);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Minimal environment for the docker CLIENT process (NOT the container). */
58
+ function buildClientEnv(passthroughNames, env) {
59
+ const e = env || process.env;
60
+ const out = {};
61
+ const base = ['PATH', 'Path', 'HOME', 'USERPROFILE', 'SystemRoot', 'TEMP', 'TMP',
62
+ 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH'];
63
+ for (const k of base) if (e[k] !== undefined) out[k] = e[k];
64
+ for (const name of passthroughNames) if (e[name] !== undefined) out[name] = e[name];
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Whether a resolved working dir may be bind-mounted. Refuses HOME and any
70
+ * configured allowed-base root — never mount a whole home directory into a
71
+ * container (too broad / surprising). `blocked` is a list of absolute paths.
72
+ */
73
+ function isSandboxableDir(dir, blocked) {
74
+ if (!dir) return false;
75
+ const resolved = path.resolve(dir);
76
+ return !(blocked || []).some(b => {
77
+ try { return path.resolve(b) === resolved; } catch (e) { return false; }
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Build the `docker run` argv. Caller supplies the already-resolved image and the
83
+ * already-filtered passthrough names.
84
+ * opts = { containerName, hostProjectDir, image, passthroughNames, cfg }
85
+ */
86
+ function buildDockerArgs(opts) {
87
+ const { containerName, hostProjectDir, image, passthroughNames, cfg } = opts;
88
+ const workdir = cfg.workdir || '/workspace';
89
+ const args = ['run', '--rm', '-it', '--name', containerName, '--hostname', 'aab'];
90
+
91
+ const mount = `${dockerMountPath(hostProjectDir)}:${workdir}${cfg.mountMode === 'ro' ? ':ro' : ''}`;
92
+ args.push('-v', mount, '-w', workdir);
93
+
94
+ args.push('--network', cfg.network || 'bridge');
95
+
96
+ if (cfg.memory) { args.push('--memory', String(cfg.memory), '--memory-swap', String(cfg.memory)); }
97
+ if (cfg.cpus) args.push('--cpus', String(cfg.cpus));
98
+ if (cfg.pidsLimit) args.push('--pids-limit', String(cfg.pidsLimit));
99
+
100
+ if (cfg.noNewPrivileges !== false) args.push('--security-opt', 'no-new-privileges');
101
+ if (cfg.dropAllCaps) args.push('--cap-drop', 'ALL');
102
+ if (cfg.readOnlyRootfs) {
103
+ args.push('--read-only', '--tmpfs', '/tmp:rw,nosuid,size=256m', '--tmpfs', '/run:rw,nosuid,size=64m');
104
+ }
105
+ // --user maps host uid:gid so files on the bind mount are operator-owned. This is
106
+ // a Linux-native truth only; on Docker Desktop (mac/win) the VM remaps UIDs and a
107
+ // host uid here can make the mount unwritable — so gate strictly to linux.
108
+ if (cfg.runAsHostUser && process.platform === 'linux' && typeof process.getuid === 'function') {
109
+ args.push('--user', `${process.getuid()}:${process.getgid()}`);
110
+ }
111
+
112
+ args.push('-e', 'TERM=xterm-256color');
113
+ for (const name of passthroughNames || []) args.push('-e', name); // value-from-client-env
114
+
115
+ if (Array.isArray(cfg.extraArgs)) for (const a of cfg.extraArgs) if (typeof a === 'string' && a) args.push(a);
116
+
117
+ args.push(image);
118
+
119
+ const shellArgv = cfg.shell
120
+ ? (Array.isArray(cfg.shell) ? cfg.shell.slice() : [cfg.shell])
121
+ : ['/bin/sh', '-l'];
122
+ for (const s of shellArgv) args.push(String(s));
123
+
124
+ return args;
125
+ }
126
+
127
+ module.exports = {
128
+ detectDocker, dockerMountPath, resolvePassthrough, buildClientEnv,
129
+ isSandboxableDir, buildDockerArgs, ENV_DENYLIST
130
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Cloudflare Quick Tunnel adapter (ephemeral, no account).
3
+ *
4
+ * cloudflared tunnel --no-autoupdate --url http://127.0.0.1:<PORT>
5
+ *
6
+ * No login required. A random *.trycloudflare.com URL is printed to STDERR
7
+ * (verified: stdout is empty) and rotates on every run. Testing-grade only
8
+ * (~200 concurrent request cap, no SSE).
9
+ */
10
+
11
+ const BaseAdapter = require('../base-adapter');
12
+
13
+ const URL_RE = /(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/i;
14
+ const FAIL_RE = /Failed to request quick Tunnel/i;
15
+
16
+ class CloudflareQuickAdapter extends BaseAdapter {
17
+ static get id() { return 'cloudflare-quick'; }
18
+ static get label() { return 'Cloudflare Quick Tunnel'; }
19
+ static get binaryName() { return 'cloudflared'; }
20
+ static get stableUrl() { return false; }
21
+ static get requiresAccount() { return false; }
22
+ static get installHint() {
23
+ return 'Install cloudflared: macOS `brew install cloudflared`; Windows `winget install --id Cloudflare.cloudflared`; '
24
+ + 'Linux: github.com/cloudflare/cloudflared/releases.';
25
+ }
26
+
27
+ buildArgs() {
28
+ return ['tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${this.port}`];
29
+ }
30
+
31
+ parseLine(line) {
32
+ const m = line.match(URL_RE);
33
+ if (m) return { url: m[1] };
34
+ // A hard failure to create the quick tunnel — let the manager retry/back off.
35
+ if (FAIL_RE.test(line)) return { error: { code: 'EXIT_BEFORE_URL', message: line.trim() } };
36
+ return null;
37
+ }
38
+ }
39
+
40
+ module.exports = CloudflareQuickAdapter;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * cloudflared NAMED tunnel adapter (stable custom hostname).
3
+ *
4
+ * cloudflared tunnel --loglevel info run <tunnelName>
5
+ *
6
+ * Requires a Cloudflare account + zone with a pre-created tunnel and a DNS route
7
+ * (`cloudflared tunnel login` -> `tunnel create <name>` -> `tunnel route dns
8
+ * <name> <hostname>`). The public hostname is NOT printed by the CLI — it is the
9
+ * configured `hostname`. Readiness is detected from the stderr line
10
+ * "Registered tunnel connection"; the URL is then `https://<hostname>`.
11
+ *
12
+ * The local service the tunnel proxies to is defined by the `ingress:` rules in
13
+ * ~/.cloudflared/config.yml (not on the command line), so no port is passed.
14
+ */
15
+
16
+ const BaseAdapter = require('../base-adapter');
17
+
18
+ const READY_RE = /Registered tunnel connection/;
19
+ const ERR_RE = /Cannot determine default origin certificate|credentials file not found|tunnel not found|failed to parse tunnel|Cannot find credentials/i;
20
+
21
+ class CloudflaredNamedAdapter extends BaseAdapter {
22
+ static get id() { return 'cloudflared-named'; }
23
+ static get label() { return 'cloudflared named tunnel'; }
24
+ static get binaryName() { return 'cloudflared'; }
25
+ static get stableUrl() { return true; }
26
+ static get requiresAccount() { return true; }
27
+ static get installHint() {
28
+ return 'Pre-create a named tunnel: `cloudflared tunnel login` -> `tunnel create <name>` -> '
29
+ + '`tunnel route dns <name> <hostname>`. Set tunnel["cloudflared-named"].tunnelName and .hostname in config.';
30
+ }
31
+
32
+ buildArgs() {
33
+ const pc = this.providerConfig || {};
34
+ if (!pc.tunnelName) {
35
+ throw new Error('cloudflared-named requires tunnel["cloudflared-named"].tunnelName');
36
+ }
37
+ return ['tunnel', '--loglevel', 'info', 'run', String(pc.tunnelName)];
38
+ }
39
+
40
+ parseLine(line) {
41
+ if (ERR_RE.test(line)) {
42
+ return { error: { code: 'NOT_CONFIGURED', message: 'cloudflared named tunnel not set up (login / create / route dns)' } };
43
+ }
44
+ if (READY_RE.test(line)) return { ready: true };
45
+ return null;
46
+ }
47
+ }
48
+
49
+ module.exports = CloudflaredNamedAdapter;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Microsoft Dev Tunnels adapter (default provider).
3
+ *
4
+ * devtunnel host -p <PORT> --allow-anonymous (temporary tunnel; URL rotates)
5
+ * devtunnel host <tunnelId> -p <PORT> ... (reuse a pre-created tunnel)
6
+ *
7
+ * Requires a one-time `devtunnel user login`. `--allow-anonymous` lets browser
8
+ * visitors open the public URL without an MS login (only the bridge token gates
9
+ * access). The CLI prints a multi-line block; the public URL is on the line
10
+ * starting "Connect via browser:".
11
+ */
12
+
13
+ const BaseAdapter = require('../base-adapter');
14
+
15
+ // Anchor on the human label so we never grab the "Inspect network activity:" URL.
16
+ // Captures the first https://<id>.<region>.devtunnels.ms[...] token, stopping at a comma.
17
+ const URL_RE = /(?:Connect via browser:|Hosting port[^\n]*?\bat)\s+(https:\/\/[^\s,]+\.devtunnels\.ms[^\s,]*)/;
18
+ const AUTH_ERR_RE = /not logged in|devtunnel user login|unauthorized|\b401\b/i;
19
+
20
+ class DevtunnelAdapter extends BaseAdapter {
21
+ static get id() { return 'devtunnel'; }
22
+ static get label() { return 'Microsoft Dev Tunnels'; }
23
+ static get binaryName() { return 'devtunnel'; }
24
+ static get stableUrl() { return false; }
25
+ static get requiresAccount() { return true; }
26
+ static get installHint() {
27
+ return 'Install: macOS `brew install --cask devtunnel`; Windows `winget install Microsoft.devtunnel`; '
28
+ + 'Linux `curl -sL https://aka.ms/DevTunnelCliInstall | bash`. First run once: `devtunnel user login`.';
29
+ }
30
+
31
+ buildArgs() {
32
+ const pc = this.providerConfig || {};
33
+ const args = ['host'];
34
+ if (pc.tunnelId) args.push(String(pc.tunnelId));
35
+ args.push('-p', String(this.port));
36
+ // --allow-anonymous applies to ad-hoc (temporary) tunnels. A pre-created
37
+ // tunnel (tunnelId) carries its own access policy from `devtunnel access
38
+ // create`, so don't force the flag there unless the operator opts in.
39
+ const wantAnon = pc.tunnelId ? pc.allowAnonymous === true : pc.allowAnonymous !== false;
40
+ if (wantAnon) args.push('--allow-anonymous');
41
+ return args;
42
+ }
43
+
44
+ parseLine(line) {
45
+ const m = line.match(URL_RE);
46
+ if (m) return { url: m[1] };
47
+ if (AUTH_ERR_RE.test(line)) {
48
+ return { error: { code: 'LOGIN_REQUIRED', message: 'Dev Tunnels needs login — run: devtunnel user login' } };
49
+ }
50
+ return null;
51
+ }
52
+ }
53
+
54
+ module.exports = DevtunnelAdapter;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tailscale Funnel adapter (private mesh, stable public URL).
3
+ *
4
+ * tailscale funnel <PORT>
5
+ *
6
+ * Requires a logged-in node (`tailscale up`) with Funnel enabled in the tailnet
7
+ * ACL. Prints "Available on the internet:" then the stable
8
+ * https://<machine>.<tailnet>.ts.net URL to STDOUT (source-confirmed). May need
9
+ * sudo depending on the platform.
10
+ */
11
+
12
+ const BaseAdapter = require('../base-adapter');
13
+
14
+ const URL_RE = /(https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.ts\.net[^\s]*)/i;
15
+ const ERR_RE = /Funnel not available|Funnel is not enabled|NeedsLogin|logged out|invalid auth/i;
16
+
17
+ class TailscaleAdapter extends BaseAdapter {
18
+ static get id() { return 'tailscale'; }
19
+ static get label() { return 'Tailscale Funnel'; }
20
+ static get binaryName() { return 'tailscale'; }
21
+ static get stableUrl() { return true; }
22
+ static get requiresAccount() { return true; }
23
+ static get installHint() {
24
+ return 'Install tailscale, run `tailscale up`, and enable Funnel in your tailnet ACL. '
25
+ + 'macOS `brew install tailscale`; Linux `curl -fsSL https://tailscale.com/install.sh | sh`.';
26
+ }
27
+
28
+ buildArgs() {
29
+ return ['funnel', String(this.port)];
30
+ }
31
+
32
+ parseLine(line) {
33
+ const m = line.match(URL_RE);
34
+ if (m) return { url: m[1] };
35
+ if (ERR_RE.test(line)) {
36
+ return { error: { code: 'NOT_IN_TAILNET', message: 'Run `tailscale up` and enable Funnel in your tailnet ACL' } };
37
+ }
38
+ return null;
39
+ }
40
+ }
41
+
42
+ module.exports = TailscaleAdapter;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * AnyAgent Bridge — tunnel BaseAdapter (Stage 2)
3
+ *
4
+ * Shared plumbing every tunnel adapter reuses: detect the CLI, spawn it
5
+ * (no shell, child dies with the server), scan BOTH stdout and stderr
6
+ * line-by-line, and stop it cleanly (SIGTERM -> SIGKILL on POSIX; taskkill
7
+ * tree-kill on Windows). Concrete adapters override only the static metadata,
8
+ * buildArgs(), and parseLine(). Adapters never touch global state, the HTTP
9
+ * layer, or the banner — they talk to the manager purely through events.
10
+ *
11
+ * Events emitted:
12
+ * 'url' (url) first public URL parsed
13
+ * 'ready'() readiness reached but URL is known out-of-band (cloudflared-named)
14
+ * 'error'({code, message}) spawn failure or provider error
15
+ * 'exit' ({code, signal}) child process ended
16
+ * 'log' (line) passthrough of a CLI output line (diagnostics)
17
+ */
18
+
19
+ const { EventEmitter } = require('events');
20
+ const { spawn } = require('child_process');
21
+ const { detect: detectBinary } = require('./detect');
22
+
23
+ class BaseAdapter extends EventEmitter {
24
+ // Subclasses MUST override these static getters:
25
+ // static get id() provider id, e.g. 'devtunnel'
26
+ // static get label() human name
27
+ // static get binaryName() CLI to probe/spawn
28
+ // static get stableUrl() boolean
29
+ // static get requiresAccount() boolean
30
+ // static get installHint() string
31
+
32
+ constructor({ host, port, providerConfig, killGraceMs, logger } = {}) {
33
+ super();
34
+ this.host = host || '127.0.0.1';
35
+ this.port = port;
36
+ this.providerConfig = providerConfig || {};
37
+ this.killGraceMs = killGraceMs || 4000;
38
+ this.logger = logger || console;
39
+
40
+ this.child = null;
41
+ this.binaryPath = null;
42
+ this._stdoutBuf = '';
43
+ this._stderrBuf = '';
44
+ this._urlEmitted = false;
45
+ this._killTimer = null;
46
+ }
47
+
48
+ get pid() { return this.child && this.child.pid ? this.child.pid : null; }
49
+ get stableUrl() { return this.constructor.stableUrl; }
50
+ get requiresAccount() { return this.constructor.requiresAccount; }
51
+
52
+ /** Delegate to detect.js. Never throws. */
53
+ async detect() {
54
+ const bin = this.constructor.binaryName;
55
+ const res = detectBinary(bin);
56
+ this.binaryPath = res.path;
57
+ return {
58
+ available: res.available,
59
+ path: res.path,
60
+ reason: res.available ? undefined : `${bin} not found on PATH`
61
+ };
62
+ }
63
+
64
+ /** Override: return string[] argv (after the binary). */
65
+ buildArgs() { return []; }
66
+
67
+ /** Override: return {url}|{ready:true}|{error:{code,message}}|null. */
68
+ parseLine(line, stream) { return null; }
69
+
70
+ /** Spawn the CLI. Idempotent (no-op if already running). */
71
+ start() {
72
+ if (this.child) return;
73
+ const bin = this.binaryPath || this.constructor.binaryName;
74
+
75
+ let args;
76
+ try {
77
+ args = this.buildArgs();
78
+ } catch (e) {
79
+ this.emit('error', { code: 'NOT_CONFIGURED', message: e.message });
80
+ return;
81
+ }
82
+ // extraArgs come from config.json (a local-operator trust boundary, as
83
+ // trusted as the code itself). spawn() uses array argv with shell:false, so
84
+ // there is no shell-injection surface; each entry is one literal CLI arg.
85
+ const extra = Array.isArray(this.providerConfig.extraArgs) ? this.providerConfig.extraArgs : [];
86
+ args = args.concat(extra);
87
+
88
+ try {
89
+ this.child = spawn(bin, args, {
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ detached: false, // child stays in the server's process group
92
+ windowsHide: true
93
+ });
94
+ } catch (e) {
95
+ this.child = null;
96
+ this.emit('error', { code: 'SPAWN_FAILED', message: e.message });
97
+ return;
98
+ }
99
+
100
+ this.child.on('error', (err) => {
101
+ const code = (err && err.code === 'ENOENT') ? 'CLI_NOT_FOUND' : 'SPAWN_FAILED';
102
+ this.emit('error', { code, message: err.message });
103
+ });
104
+
105
+ if (this.child.stdout) this.child.stdout.on('data', (d) => this._onChunk('stdout', d));
106
+ if (this.child.stderr) this.child.stderr.on('data', (d) => this._onChunk('stderr', d));
107
+
108
+ this.child.on('exit', (code, signal) => {
109
+ this.child = null;
110
+ this.emit('exit', { code, signal });
111
+ });
112
+ }
113
+
114
+ _onChunk(stream, chunk) {
115
+ const key = stream === 'stdout' ? '_stdoutBuf' : '_stderrBuf';
116
+ this[key] += chunk.toString('utf8');
117
+ let idx;
118
+ while ((idx = this[key].indexOf('\n')) >= 0) {
119
+ const line = this[key].slice(0, idx).replace(/\r$/, '');
120
+ this[key] = this[key].slice(idx + 1);
121
+ this._handleLine(line, stream);
122
+ }
123
+ }
124
+
125
+ _handleLine(line, stream) {
126
+ if (!line) return;
127
+ this.emit('log', line);
128
+
129
+ let res = null;
130
+ try {
131
+ res = this.parseLine(line, stream);
132
+ } catch (e) {
133
+ return;
134
+ }
135
+ if (!res) return;
136
+
137
+ if (res.url && !this._urlEmitted) {
138
+ this._urlEmitted = true;
139
+ this.emit('url', res.url);
140
+ } else if (res.ready && !this._urlEmitted) {
141
+ this._urlEmitted = true;
142
+ this.emit('ready');
143
+ } else if (res.error && !this._urlEmitted) {
144
+ // Errors only matter before we have a URL. Once running, a transient error
145
+ // line is not fatal — actual death is reported via the child 'exit' event.
146
+ this.emit('error', res.error);
147
+ }
148
+ }
149
+
150
+ /** Stop the child. SIGTERM, then SIGKILL after killGraceMs. Idempotent. */
151
+ async stop() {
152
+ const child = this.child;
153
+ if (!child) return;
154
+ const grace = this.killGraceMs;
155
+ return new Promise((resolve) => {
156
+ let done = false;
157
+ const finish = () => {
158
+ if (done) return;
159
+ done = true;
160
+ if (this._killTimer) { clearTimeout(this._killTimer); this._killTimer = null; }
161
+ resolve();
162
+ };
163
+ child.once('exit', finish);
164
+ try {
165
+ if (process.platform === 'win32') {
166
+ // SIGTERM is unreliable on Windows and CLIs spawn grandchildren;
167
+ // taskkill /T kills the whole tree. Handle its 'error' so a missing
168
+ // taskkill.exe never becomes an unhandled event (the SIGKILL timer
169
+ // below is the backstop either way).
170
+ const tk = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
171
+ tk.on('error', (e) => { try { this.logger.warn(`[Tunnel] taskkill failed: ${e.message}`); } catch (_) {} });
172
+ } else {
173
+ child.kill('SIGTERM');
174
+ }
175
+ } catch (e) { /* fall through to the hard-kill timer */ }
176
+ this._killTimer = setTimeout(() => {
177
+ try { child.kill('SIGKILL'); } catch (e) { /* already gone */ }
178
+ finish();
179
+ }, grace);
180
+ if (this._killTimer.unref) this._killTimer.unref();
181
+ });
182
+ }
183
+ }
184
+
185
+ module.exports = BaseAdapter;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * AnyAgent Bridge — tunnel CLI detection (Stage 2)
3
+ *
4
+ * Cross-platform "is this CLI on PATH, and where" probe. A pure fs scan of PATH
5
+ * (no shell spawned), so it behaves identically on macOS / Linux / Windows.
6
+ * On Windows it honors PATHEXT (.EXE/.CMD/.BAT/...). Never throws.
7
+ *
8
+ * Not cached: detect() runs only on tunnel start/restart (rare), and a fresh
9
+ * scan means a CLI installed (or removed) after boot is seen on the next
10
+ * /api/tunnel/restart rather than a stale result lingering for the process life.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ // Candidate filenames to look for in each PATH dir. On Windows a bare name is
17
+ // not directly executable — it must carry a PATHEXT extension.
18
+ function _candidates(bin) {
19
+ if (process.platform !== 'win32') return [bin];
20
+ const exts = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
21
+ .split(';').map(e => e.trim()).filter(Boolean);
22
+ const names = [bin]; // in case the caller already supplied an extension
23
+ for (const ext of exts) {
24
+ names.push(bin + ext.toLowerCase());
25
+ names.push(bin + ext.toUpperCase());
26
+ }
27
+ return names;
28
+ }
29
+
30
+ function _isExecutableFile(p) {
31
+ try {
32
+ const st = fs.statSync(p);
33
+ if (!st.isFile()) return false;
34
+ if (process.platform === 'win32') return true; // PATHEXT already gates it
35
+ fs.accessSync(p, fs.constants.X_OK);
36
+ return true;
37
+ } catch (e) {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /** Resolve a binary name to an absolute path by scanning PATH. null if absent. */
43
+ function findOnPath(bin) {
44
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
45
+ const names = _candidates(bin);
46
+ for (const dir of dirs) {
47
+ for (const name of names) {
48
+ const full = path.join(dir, name);
49
+ if (_isExecutableFile(full)) return full;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** Detect a CLI binary. Returns { available, path }. Never throws. */
56
+ function detect(binaryName) {
57
+ try {
58
+ const resolved = findOnPath(binaryName);
59
+ return { available: !!resolved, path: resolved };
60
+ } catch (e) {
61
+ return { available: false, path: null };
62
+ }
63
+ }
64
+
65
+ module.exports = { detect, findOnPath };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * AnyAgent Bridge — tunnel subsystem entry (Stage 2)
3
+ *
4
+ * The only module server/index.js imports. Creates a TunnelManager and exposes
5
+ * the provider list for diagnostics.
6
+ */
7
+
8
+ const TunnelManager = require('./manager');
9
+ const { listProviders } = require('./registry');
10
+
11
+ function createTunnelManager(tunnelConfig, logger) {
12
+ return new TunnelManager(tunnelConfig, logger);
13
+ }
14
+
15
+ module.exports = { createTunnelManager, listProviders };