Skip to content

Commit 00fa0ed

Browse files
committed
feat(sdk): dual-version channels — stable=Chromium 149 (default), latest=151
Adds a channel selector to both SDKs and the release/Docker layout so users can pick the Chromium base: - stable -> Fortress on Chromium 149.0.7827.232 (the recommended default; matches the Chrome version most real users run, so it blends in best) - latest -> Chromium 151.0.7908.0 (newest engine) Python: Fortress(channel="stable"|"latest") or FORTRESS_CHANNEL; caches per release tag. Node: Fortress.launch({channel}) / CHANNELS export; same env override. Docker: tilion/fortress:149 and :151. README documents both. SHA-verified downloads unchanged.
1 parent 2415557 commit 00fa0ed

6 files changed

Lines changed: 107 additions & 43 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,30 @@ sudo apt install ./tilion-fortress_151.0.7908.0_amd64.deb && tilion https://exam
188188
> [!TIP]
189189
> The SDK ships the compiled build **plus `patches/`**, so you can rebuild the engine yourself and verify every surface correction against the source. Downloads are SHA-256-verified against the release `SHA256SUMS` automatically.
190190
191+
### Versions
192+
193+
Fortress ships on **two Chromium bases** — pick your trade-off between blend-in and currency:
194+
195+
| Channel | Chromium | When to use |
196+
|---|---|---|
197+
| **`stable`** *(default)* | **149** | Recommended — matches the Chrome version the mass of real users run, so it blends in best |
198+
| **`latest`** | **151** | Newest engine (reports a version slightly ahead of stable) |
199+
200+
```python
201+
Fortress().start() # Python: stable (149) by default
202+
Fortress(channel="latest").start() # opt into 151
203+
```
204+
```js
205+
await Fortress.launch(); // Node: stable (149) by default
206+
await Fortress.launch({ channel: "latest" });
207+
```
208+
```bash
209+
docker run --rm -p 9222:9222 tilion/fortress:149 # or :151
210+
# or set FORTRESS_CHANNEL=latest for either SDK
211+
```
212+
213+
Native binaries: **Linux x64** (both versions) + **Windows x64** (151); Windows-149 and macOS run via the Docker image.
214+
191215
### Drop it into your AI agent
192216

193217
Fortress is the browser your agent drives: raw CDP on `:9222`, no stealth plugins to wire up. There are two ways in.

sdk/node/index.js

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ import { createHash } from "node:crypto";
1111

1212
export const VERSION = "151.0.7908.0";
1313
const REPO = "tiliondev/fortress";
14-
const TAG = `v${VERSION}`;
15-
const DOCKER_IMAGE = "tilion/fortress:latest";
14+
// Two release channels. "stable" = Chromium 149 (recommended default — matches the Chrome version
15+
// the mass of real users run). "latest" = 151 (newest engine). Override with { channel } or the
16+
// FORTRESS_CHANNEL env var.
17+
export const CHANNELS = {
18+
stable: { tag: "v149.0.7827.232", docker: "tilion/fortress:149" },
19+
latest: { tag: "v151.0.7908.0", docker: "tilion/fortress:151" },
20+
};
21+
const DEFAULT_CHANNEL = process.env.FORTRESS_CHANNEL || "stable";
1622
const CACHE = process.env.FORTRESS_BROWSERS_PATH || join(homedir(), ".cache", "tilion-fortress");
17-
const HOST = process.env.FORTRESS_DOWNLOAD_HOST || `https://github.com/${REPO}/releases/download/${TAG}`;
23+
const hostFor = (tag) => process.env.FORTRESS_DOWNLOAD_HOST || `https://github.com/${REPO}/releases/download/${tag}`;
1824

1925
// platform key -> { asset, kind, launcher }
2026
export const ASSETS = {
@@ -47,9 +53,9 @@ export async function sha256(path) {
4753
return h.digest("hex");
4854
}
4955

50-
export async function expectedSha(asset) {
56+
export async function expectedSha(asset, host) {
5157
try {
52-
const r = await fetch(`${HOST}/SHA256SUMS`);
58+
const r = await fetch(`${host}/SHA256SUMS`);
5359
if (!r.ok) return null;
5460
for (const line of (await r.text()).split("\n")) {
5561
const p = line.trim().split(/\s+/);
@@ -59,19 +65,19 @@ export async function expectedSha(asset) {
5965
return null;
6066
}
6167

62-
async function ensureNative(plat) {
68+
async function ensureNative(plat, host, tag) {
6369
const { asset, kind, launcher } = ASSETS[plat];
64-
const root = join(CACHE, VERSION, plat);
70+
const root = join(CACHE, tag, plat); // cache per release tag so channels don't collide
6571
const launcherPath = join(root, launcher);
6672
if (existsSync(launcherPath)) return launcherPath;
6773
mkdirSync(root, { recursive: true });
6874
const archive = join(root, asset);
69-
process.stderr.write(`[tilion-fortress] downloading ${HOST}/${asset} ...\n`);
70-
const res = await fetch(`${HOST}/${asset}`);
75+
process.stderr.write(`[tilion-fortress] downloading ${host}/${asset} ...\n`);
76+
const res = await fetch(`${host}/${asset}`);
7177
if (!res.ok) throw new Error(`download failed: ${res.status}`);
7278
await pipeline(res.body, createWriteStream(archive));
7379

74-
const exp = await expectedSha(asset);
80+
const exp = await expectedSha(asset, host);
7581
if (exp) {
7682
const act = await sha256(archive);
7783
if (act !== exp) throw new Error(`SHA256 mismatch for ${asset}: expected ${exp}, got ${act}`);
@@ -93,8 +99,8 @@ async function ensureNative(plat) {
9399
return launcherPath;
94100
}
95101

96-
async function assetExists(plat) {
97-
try { return (await fetch(`${HOST}/${ASSETS[plat].asset}`, { method: "HEAD" })).ok; }
102+
async function assetExists(plat, host) {
103+
try { return (await fetch(`${host}/${ASSETS[plat].asset}`, { method: "HEAD" })).ok; }
98104
catch { return false; }
99105
}
100106

@@ -109,21 +115,24 @@ async function waitCdp(port, timeoutMs = 40000) {
109115
}
110116

111117
export class Fortress {
112-
constructor({ port = 9222, persona = null, extraArgs = [], headless = true } = {}) {
113-
Object.assign(this, { port, persona, extraArgs, headless, proc: null, dockerName: null, cdpUrl: null });
118+
constructor({ port = 9222, persona = null, extraArgs = [], headless = true, channel = DEFAULT_CHANNEL } = {}) {
119+
if (!CHANNELS[channel]) throw new Error(`unknown channel '${channel}'; use one of ${Object.keys(CHANNELS)}`);
120+
const { tag, docker } = CHANNELS[channel];
121+
Object.assign(this, { port, persona, extraArgs, headless, channel, tag, docker, host: hostFor(tag),
122+
proc: null, dockerName: null, cdpUrl: null });
114123
}
115124
static async launch(opts) { return new Fortress(opts).start(); }
116125

117126
async start() {
118127
const plat = resolvePlatform();
119-
const native = plat && (plat === "linux-x64" || await assetExists(plat));
128+
const native = plat && (plat === "linux-x64" || await assetExists(plat, this.host));
120129
if (native) await this._startNative(plat); else this._startDocker();
121130
this.cdpUrl = await waitCdp(this.port);
122131
return this;
123132
}
124133

125134
async _startNative(plat) {
126-
const launcher = await ensureNative(plat);
135+
const launcher = await ensureNative(plat, this.host, this.tag);
127136
const args = [];
128137
if (this.headless) args.push("--headless=new", "--no-sandbox");
129138
args.push(`--remote-debugging-port=${this.port}`, `--user-data-dir=${join(CACHE, "profile")}`,
@@ -135,7 +144,7 @@ export class Fortress {
135144
if (spawnSync("docker", ["--version"]).status !== 0)
136145
throw new Error("No native binary for this platform yet and Docker not installed. Install Docker Desktop or use Linux x64.");
137146
this.dockerName = `tilion-fortress-${process.pid}-${this.port}`;
138-
const args = ["run", "-d", "--rm", "--name", this.dockerName, "-p", `${this.port}:9222`, DOCKER_IMAGE,
147+
const args = ["run", "-d", "--rm", "--name", this.dockerName, "-p", `${this.port}:9222`, this.docker,
139148
...personaArgs(this.persona), ...this.extraArgs];
140149
if (spawnSync("docker", args, { stdio: "ignore" }).status !== 0) throw new Error("docker run failed");
141150
}

sdk/node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tilion-fortress",
3-
"version": "151.0.7908",
3+
"version": "151.0.7909",
44
"description": "Install and drive the Fortress stealth Chromium engine. Prebuilt binary, no source.",
55
"type": "module",
66
"main": "index.js",

sdk/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "tilion-fortress"
7-
version = "151.0.7908.0.post1"
7+
version = "151.0.7908.0.post3"
88
description = "Install and drive the Fortress stealth Chromium engine. Prebuilt binary, no source."
99
readme = "README.md"
1010
requires-python = ">=3.8"

sdk/python/tests/test_sdk.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,35 +101,50 @@ def test_expected_sha_parses_matching_asset(monkeypatch):
101101
f"deadbeef tilion-fortress-win-x64.zip\n"
102102
).encode()
103103
monkeypatch.setattr(tf.urllib.request, "urlopen", lambda *a, **k: _FakeResp(body))
104-
assert tf._expected_sha(asset) == "aa11bb22"
104+
assert tf._expected_sha(asset, "https://h") == "aa11bb22"
105105

106106

107107
def test_expected_sha_handles_starred_binary_marker(monkeypatch):
108108
# `sha256sum` writes "<hash> *<file>" in binary mode; the parser strips the leading '*'.
109109
asset = tf._ASSETS["linux-x64"][0]
110110
body = f"cafef00d *{asset}\n".encode()
111111
monkeypatch.setattr(tf.urllib.request, "urlopen", lambda *a, **k: _FakeResp(body))
112-
assert tf._expected_sha(asset) == "cafef00d"
112+
assert tf._expected_sha(asset, "https://h") == "cafef00d"
113113

114114

115115
def test_expected_sha_returns_none_when_absent(monkeypatch):
116116
body = b"aa11bb22 some-other-asset.tar.gz\n"
117117
monkeypatch.setattr(tf.urllib.request, "urlopen", lambda *a, **k: _FakeResp(body))
118-
assert tf._expected_sha(tf._ASSETS["linux-x64"][0]) is None
118+
assert tf._expected_sha(tf._ASSETS["linux-x64"][0], "https://h") is None
119119

120120

121121
def test_expected_sha_swallows_network_error(monkeypatch):
122122
def boom(*a, **k):
123123
raise OSError("network down")
124124
monkeypatch.setattr(tf.urllib.request, "urlopen", boom)
125125
# Must degrade to None (caller then warns + skips), never raise.
126-
assert tf._expected_sha("anything") is None
126+
assert tf._expected_sha("anything", "https://h") is None
127127

128128

129129
# --------------------------------------------------------------------------- release wiring
130-
def test_version_and_tag_are_coherent():
130+
def test_channels_are_coherent():
131131
import re
132132
assert re.fullmatch(r"\d+\.\d+\.\d+.*", tf.__version__)
133-
assert re.fullmatch(r"v\d+\.\d+\.\d+\.\d+", tf._TAG)
134133
assert tf._REPO == "tiliondev/fortress"
135-
assert tf._DOCKER_IMAGE.startswith("tilion/fortress")
134+
assert tf._DEFAULT_CHANNEL in tf._CHANNELS
135+
assert tf._DEFAULT_CHANNEL == "stable" # 149 is the recommended default
136+
for ch, cfg in tf._CHANNELS.items():
137+
assert re.fullmatch(r"v\d+\.\d+\.\d+\.\d+", cfg["tag"])
138+
assert cfg["docker"].startswith("tilion/fortress")
139+
assert tf._CHANNELS["stable"]["tag"].startswith("v149")
140+
assert tf._CHANNELS["latest"]["tag"].startswith("v151")
141+
142+
143+
def test_channel_resolution():
144+
assert tf.Fortress(channel="stable")._tag == "v149.0.7827.232"
145+
assert tf.Fortress(channel="latest")._tag == "v151.0.7908.0"
146+
assert tf.Fortress()._tag == tf.Fortress(channel=tf._DEFAULT_CHANNEL)._tag
147+
try:
148+
tf.Fortress(channel="nope"); assert False
149+
except ValueError:
150+
pass

sdk/python/tilion_fortress/__init__.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,25 @@
1414
import hashlib, json, os, platform, shutil, subprocess, sys, tarfile, time, urllib.request, zipfile
1515
from pathlib import Path
1616

17-
__version__ = "151.0.7908.0.post1"
17+
__version__ = "151.0.7908.0.post3"
1818
__all__ = ["Fortress", "resolve_platform"]
1919

2020
_REPO = "tiliondev/fortress"
21-
_TAG = "v151.0.7908.0" # engine release tag (decoupled from package version)
22-
_DOCKER_IMAGE = "tilion/fortress:latest"
21+
# Two release channels. "stable" = Chromium 149 (the recommended default — it matches the Chrome
22+
# version the mass of real users run, so it blends in best). "latest" = 151 (newest engine).
23+
# Override per-instance with channel=..., or globally with the FORTRESS_CHANNEL env var.
24+
_CHANNELS = {
25+
"stable": {"tag": "v149.0.7827.232", "docker": "tilion/fortress:149"},
26+
"latest": {"tag": "v151.0.7908.0", "docker": "tilion/fortress:151"},
27+
}
28+
_DEFAULT_CHANNEL = os.environ.get("FORTRESS_CHANNEL", "stable")
2329
_CACHE = Path(os.environ.get("FORTRESS_BROWSERS_PATH",
2430
Path.home() / ".cache" / "tilion-fortress"))
25-
_HOST = os.environ.get("FORTRESS_DOWNLOAD_HOST",
26-
f"https://github.com/{_REPO}/releases/download/{_TAG}")
31+
32+
33+
def _host(tag: str) -> str:
34+
return os.environ.get("FORTRESS_DOWNLOAD_HOST",
35+
f"https://github.com/{_REPO}/releases/download/{tag}")
2736

2837
# platform key -> (release asset, archive kind, launcher relative path)
2938
_ASSETS = {
@@ -53,10 +62,10 @@ def _sha256(path: Path) -> str:
5362
return h.hexdigest()
5463

5564

56-
def _expected_sha(asset: str) -> str | None:
65+
def _expected_sha(asset: str, host: str) -> str | None:
5766
"""Fetch SHA256SUMS from the release and return the hash for `asset`."""
5867
try:
59-
with urllib.request.urlopen(f"{_HOST}/SHA256SUMS", timeout=30) as r:
68+
with urllib.request.urlopen(f"{host}/SHA256SUMS", timeout=30) as r:
6069
for line in r.read().decode().splitlines():
6170
parts = line.split()
6271
if len(parts) == 2 and parts[1].lstrip("*") == asset:
@@ -66,20 +75,20 @@ def _expected_sha(asset: str) -> str | None:
6675
return None
6776

6877

69-
def _download(plat: str) -> Path:
78+
def _download(plat: str, host: str, tag: str) -> Path:
7079
"""Ensure the bundle for `plat` is present + verified; return the launcher path."""
7180
asset, kind, launcher_rel = _ASSETS[plat]
72-
root = _CACHE / __version__ / plat
81+
root = _CACHE / tag / plat # cache per release tag so channels don't collide
7382
launcher = root / launcher_rel
7483
if launcher.exists():
7584
return launcher
7685
root.mkdir(parents=True, exist_ok=True)
7786
archive = root / asset
78-
url = f"{_HOST}/{asset}"
87+
url = f"{host}/{asset}"
7988
sys.stderr.write(f"[tilion-fortress] downloading {url} ...\n")
8089
urllib.request.urlretrieve(url, archive)
8190

82-
expected = _expected_sha(asset)
91+
expected = _expected_sha(asset, host)
8392
if expected:
8493
actual = _sha256(archive)
8594
if actual != expected:
@@ -120,8 +129,16 @@ class Fortress:
120129
"""A running Fortress instance exposing a CDP endpoint at ``cdp_url``."""
121130

122131
def __init__(self, port: int = 9222, persona: dict | None = None,
123-
extra_args: list[str] | None = None, headless: bool = True):
132+
extra_args: list[str] | None = None, headless: bool = True,
133+
channel: str | None = None):
124134
self.port, self.persona, self.extra_args, self.headless = port, persona, extra_args or [], headless
135+
ch = channel or _DEFAULT_CHANNEL
136+
if ch not in _CHANNELS:
137+
raise ValueError(f"unknown channel {ch!r}; use one of {list(_CHANNELS)}")
138+
self.channel = ch
139+
self._tag = _CHANNELS[ch]["tag"]
140+
self._docker = _CHANNELS[ch]["docker"]
141+
self._host = _host(self._tag)
125142
self._proc = self._docker_name = self.cdp_url = None
126143

127144
def start(self) -> "Fortress":
@@ -135,18 +152,17 @@ def start(self) -> "Fortress":
135152
self.cdp_url = self._wait_cdp()
136153
return self
137154

138-
@staticmethod
139-
def _asset_exists(plat: str) -> bool:
155+
def _asset_exists(self, plat: str) -> bool:
140156
asset = _ASSETS[plat][0]
141157
try:
142-
req = urllib.request.Request(f"{_HOST}/{asset}", method="HEAD")
158+
req = urllib.request.Request(f"{self._host}/{asset}", method="HEAD")
143159
with urllib.request.urlopen(req, timeout=15):
144160
return True
145161
except Exception:
146162
return False
147163

148164
def _start_native(self, plat: str):
149-
launcher = _download(plat)
165+
launcher = _download(plat, self._host, self._tag)
150166
args = [str(launcher)]
151167
if self.headless:
152168
args += ["--headless=new", "--no-sandbox"]
@@ -161,7 +177,7 @@ def _start_docker(self):
161177
"Install Docker Desktop, or run on Linux x64.")
162178
self._docker_name = f"tilion-fortress-{os.getpid()}-{self.port}"
163179
args = ["docker", "run", "-d", "--rm", "--name", self._docker_name,
164-
"-p", f"{self.port}:9222", _DOCKER_IMAGE] + _persona_args(self.persona) + self.extra_args
180+
"-p", f"{self.port}:9222", self._docker] + _persona_args(self.persona) + self.extra_args
165181
subprocess.run(args, check=True, stdout=subprocess.DEVNULL)
166182

167183
def _wait_cdp(self, timeout: float = 40.0) -> str:

0 commit comments

Comments
 (0)