@pa1nd/horse-browser 0.4.0 → 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.
package/SKILL.md CHANGED
@@ -78,7 +78,7 @@ self.listTabs(label: string) -> Promise<Tab[]>
78
78
  // }
79
79
  ```
80
80
 
81
- **`bh_open` / `bh_list` / `bh_switch_tab` are pre-installed.** `install.sh` writes them into browser-harness's `agent-workspace/agent_helpers.py` (which auto-loads on every call), so they're available immediately — just call `bh_open(url)`.
81
+ **`bh_open` / `bh_list` / `bh_switch_tab` are pre-installed.** `install.sh` writes them to browser-harness's `agent-workspace/horse_helpers.py`, loaded on every call via a small stub it adds once to `agent_helpers.py` (your own additions to that file are never touched), so they're available immediately — just call `bh_open(url)`.
82
82
 
83
83
  If `bh_open` is somehow undefined (a browser-harness checkout that never ran our `install.sh`), re-run `horse-browser`'s `install.sh` to install them — don't hand-roll your own; the focus-safe behaviour is subtle. You pass CDP `targetId`s only — the extension bridges to chrome `tabId`s internally.
84
84
 
package/agent-helpers.py CHANGED
@@ -1,8 +1,10 @@
1
1
  # horse-browser helpers for browser-harness.
2
2
  #
3
- # install.sh appends these into browser-harness's agent-workspace/agent_helpers.py
4
- # (which auto-loads on every browser-harness call), so bh_open() is available on
5
- # the first run — no agent has to install the recipe by hand.
3
+ # install.sh installs this file as <workspace>/horse_helpers.py and appends a
4
+ # load-once stub to agent_helpers.py (which browser-harness auto-loads on every
5
+ # call), so bh_open() is available on the first run — no agent has to install the
6
+ # recipe by hand. Updates overwrite horse_helpers.py only; anything a user keeps
7
+ # in agent_helpers.py itself is never touched.
6
8
  #
7
9
  # What they give you (pass CDP targetIds; the extension bridges to chrome tabIds):
8
10
  # bh_open(url) open a tab WITHOUT raising the browser over your macOS app,
@@ -213,3 +215,48 @@ def bh_open(url):
213
215
 
214
216
  def bh_list():
215
217
  return ext_call("listTabs", _session_id()) or [] if _session_id() else []
218
+
219
+
220
+ # ── screenshots: a unique file per call ──────────────────────────────────────────
221
+ # Stock capture_screenshot() defaults every call — from EVERY session's daemon — to
222
+ # the ONE shared file <tmp>/shot.png. With concurrent sessions, a neighbour overwrites
223
+ # it between this session writing the shot and the agent Reading the path it printed,
224
+ # so the "screenshot" shows another session's tab. Same namespace-merge trick as cdp()
225
+ # above: defining capture_screenshot here replaces the stock one everywhere. Each call
226
+ # mints its own file (named after the session's daemon lane) and returns that path, so
227
+ # the agent's existing print-the-path-then-Read flow just works.
228
+ from browser_harness.helpers import capture_screenshot as _real_capture_screenshot
229
+ from browser_harness import _ipc as _bh_ipc
230
+
231
+ _hb_shots_swept = False
232
+
233
+
234
+ def _hb_sweep_shots(max_age_s=86400):
235
+ # Unique names accumulate where the single shot.png didn't — drop day-old ones.
236
+ # Once per process, and only in processes that actually screenshot.
237
+ global _hb_shots_swept
238
+ if _hb_shots_swept:
239
+ return
240
+ _hb_shots_swept = True
241
+ import time
242
+ now = time.time()
243
+ try:
244
+ for e in os.scandir(_bh_ipc._TMP):
245
+ if e.name.startswith("shot-") and e.name.endswith(".png") and now - e.stat().st_mtime > max_age_s:
246
+ try: os.remove(e.path)
247
+ except OSError: pass
248
+ except OSError:
249
+ pass
250
+
251
+
252
+ def capture_screenshot(path=None, full=False, max_dim=None):
253
+ """Save a PNG of the current viewport to a per-call unique file; returns the path.
254
+ Args as stock browser-harness: full=capture beyond viewport, max_dim=downscale."""
255
+ if path is None:
256
+ _hb_sweep_shots()
257
+ import tempfile
258
+ fd, path = tempfile.mkstemp(
259
+ prefix=f"shot-{os.environ.get('BU_NAME', 'default')}-",
260
+ suffix=".png", dir=str(_bh_ipc._TMP))
261
+ os.close(fd)
262
+ return _real_capture_screenshot(path, full=full, max_dim=max_dim)
@@ -137,3 +137,42 @@ chrome.runtime.onInstalled.addListener((details) => {
137
137
  // First run on a fresh profile only: open the welcome page (not on updates/restarts).
138
138
  if (details.reason === "install") chrome.tabs.create({ url: chrome.runtime.getURL("hello.html"), active: true });
139
139
  });
140
+
141
+ // ── Passive network monitor ── Tier 1 realness debug aid. chrome.webRequest observes every
142
+ // request WITHOUT touching the page — no window.fetch/XHR wrapper, which would itself be a
143
+ // fingerprint tell (a non-native fetch.toString). We buffer the last N *outcomes* per tab
144
+ // (onCompleted carries the status; onErrorOccurred carries blocks/aborts), so an agent can
145
+ // see what a click actually fired — decisive for spotting silent gating. State lives in the
146
+ // SW's memory: the 30s keepalive alarm keeps it warm during a session, but a hard SW
147
+ // eviction resets it — fine for a read-right-after-you-act ring buffer.
148
+ const NETLOG = new Map(); // tabId -> [{ t, method, url, type, status, fromCache, error }]
149
+ const NETLOG_MAX = 200;
150
+ function netPush(tabId, entry) {
151
+ if (tabId == null || tabId < 0) return; // -1 = not tied to a tab (SW/extension fetches)
152
+ let buf = NETLOG.get(tabId);
153
+ if (!buf) { buf = []; NETLOG.set(tabId, buf); }
154
+ buf.push(entry);
155
+ if (buf.length > NETLOG_MAX) buf.shift();
156
+ }
157
+ const NET_URLS = { urls: ["http://*/*", "https://*/*"] };
158
+ chrome.webRequest.onCompleted.addListener(
159
+ (d) => netPush(d.tabId, { t: d.timeStamp, method: d.method, url: d.url, type: d.type, status: d.statusCode, fromCache: d.fromCache }),
160
+ NET_URLS,
161
+ );
162
+ chrome.webRequest.onErrorOccurred.addListener(
163
+ (d) => netPush(d.tabId, { t: d.timeStamp, method: d.method, url: d.url, type: d.type, status: null, error: d.error }),
164
+ NET_URLS,
165
+ );
166
+ chrome.tabs.onRemoved.addListener((tabId) => NETLOG.delete(tabId)); // don't leak buffers
167
+
168
+ // getNetLog(targetId) / clearNetLog(targetId) — CDP-callable like groupTab/listTabs. Agents
169
+ // pass a CDP targetId; we bridge to the chrome tabId the same way the grouper does.
170
+ self.getNetLog = async (targetId) => {
171
+ const tabId = await tabIdForTargetId(targetId);
172
+ return NETLOG.get(tabId) || [];
173
+ };
174
+ self.clearNetLog = async (targetId) => {
175
+ const tabId = await tabIdForTargetId(targetId);
176
+ NETLOG.delete(tabId);
177
+ return true;
178
+ };
@@ -1,14 +1,28 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Agent Tab Grouper",
4
- "version": "0.5.12",
5
- "description": "Groups CDP-driven automation tabs into a per-session tab group, and serves the Agent Monitor a live CCTV grid of all sessions' tabs. Driven over CDP via Runtime.evaluate against this extension's service worker.",
4
+ "version": "0.6.0",
5
+ "description": "Groups CDP-driven automation tabs into a per-session tab group and serves the Agent Monitor (a live CCTV grid). Also carries Tier 1 realness: an always-on Chrome-real fingerprint (UA-CH + sec-ch-ua) and a passive per-tab network log. Driven over CDP via Runtime.evaluate against this extension's service worker.",
6
6
  "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" },
7
7
  "background": { "service_worker": "background.js" },
8
+ "content_scripts": [
9
+ {
10
+ "matches": ["http://*/*", "https://*/*"],
11
+ "js": ["realchrome.js"],
12
+ "run_at": "document_start",
13
+ "world": "MAIN",
14
+ "all_frames": true
15
+ }
16
+ ],
17
+ "declarative_net_request": {
18
+ "rule_resources": [
19
+ { "id": "realness_headers", "enabled": true, "path": "rules.json" }
20
+ ]
21
+ },
8
22
  "action": {
9
23
  "default_title": "Open Agent Monitor",
10
24
  "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
11
25
  },
12
- "permissions": ["tabs", "tabGroups", "debugger", "alarms"],
13
- "host_permissions": ["http://127.0.0.1:9223/*"]
26
+ "permissions": ["tabs", "tabGroups", "debugger", "alarms", "webRequest", "declarativeNetRequest"],
27
+ "host_permissions": ["http://*/*", "https://*/*"]
14
28
  }
@@ -0,0 +1,27 @@
1
+ // Tier 1 realness — the JS half of the Chrome-for-Testing → stable Google Chrome mask.
2
+ // Runs in the page's MAIN world at document_start (see manifest content_scripts), so it
3
+ // patches navigator.userAgentData BEFORE the page's first script reads it (verified: a
4
+ // parse-time read already sees "Google Chrome", 10/10). The wire half — the sec-ch-ua
5
+ // request header — is done coherently by declarativeNetRequest (rules.json), so JS and
6
+ // headers agree. Payload lifted verbatim from the proven real_chrome() helper.
7
+ (function(){ 'use strict'; try{
8
+ var u = navigator.userAgentData; if(!u) return;
9
+ var FULL='150.0.7871.47', MAJOR='150';
10
+ function low(){ return [{brand:'Not;A=Brand',version:'8'},{brand:'Chromium',version:MAJOR},{brand:'Google Chrome',version:MAJOR}]; }
11
+ function full(){ return [{brand:'Not;A=Brand',version:'8.0.0.0'},{brand:'Chromium',version:FULL},{brand:'Google Chrome',version:FULL}]; }
12
+ var proto = Object.getPrototypeOf(u);
13
+ function nativeProxy(orig, impl){ return new Proxy(orig, { apply:function(t,ta,a){ return impl(t,ta,a); } }); }
14
+ var bd = Object.getOwnPropertyDescriptor(proto,'brands');
15
+ if(bd && bd.get && !u.brands.some(function(b){return b.brand==='Google Chrome'})){
16
+ Object.defineProperty(proto,'brands',{ get: nativeProxy(bd.get, function(){ return low(); }), set:undefined, enumerable:bd.enumerable, configurable:true });
17
+ }
18
+ if(typeof proto.getHighEntropyValues==='function'){
19
+ proto.getHighEntropyValues = nativeProxy(proto.getHighEntropyValues, function(t,ta,a){
20
+ return Reflect.apply(t,ta,a).then(function(r){ if(r&&typeof r==='object'){ if('brands' in r) r.brands=low(); if('fullVersionList' in r) r.fullVersionList=full(); if('uaFullVersion' in r) r.uaFullVersion=FULL; } return r; });
21
+ });
22
+ }
23
+ if(typeof proto.toJSON==='function'){
24
+ proto.toJSON = nativeProxy(proto.toJSON, function(t,ta,a){ var o=Reflect.apply(t,ta,a); if(o&&typeof o==='object') o.brands=low(); return o; });
25
+ }
26
+ try{ if(navigator.webdriver===true) Object.defineProperty(Navigator.prototype,'webdriver',{get:function(){return false},configurable:true}); }catch(e){}
27
+ }catch(e){} })();
@@ -0,0 +1,23 @@
1
+ [
2
+ {
3
+ "id": 1,
4
+ "priority": 1,
5
+ "action": {
6
+ "type": "modifyHeaders",
7
+ "requestHeaders": [
8
+ {
9
+ "header": "sec-ch-ua",
10
+ "operation": "set",
11
+ "value": "\"Not;A=Brand\";v=\"8\", \"Chromium\";v=\"150\", \"Google Chrome\";v=\"150\""
12
+ }
13
+ ]
14
+ },
15
+ "condition": {
16
+ "urlFilter": "*",
17
+ "resourceTypes": [
18
+ "main_frame", "sub_frame", "stylesheet", "script", "image",
19
+ "font", "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket", "other"
20
+ ]
21
+ }
22
+ }
23
+ ]
package/install.sh CHANGED
@@ -64,12 +64,14 @@ else
64
64
  fi
65
65
 
66
66
  # 3. bh_open helpers into browser-harness ───────────────────────────────────────
67
- # browser-harness auto-loads <workspace>/agent_helpers.py on every call; we append our
68
- # bh_open/bh_list/bh_switch_tab helpers there so they exist on the first run no agent
69
- # installs the recipe by hand. The workspace location varies by version/install (≤0.1.0:
70
- # <repo>/agent-workspace; 0.1.1+: ~/.config/browser-harness/agent-workspace; or whatever
71
- # BH_AGENT_WORKSPACE points at) so instead of guessing, we ASK browser-harness itself
72
- # where it loads from via its own python (helpers.AGENT_WORKSPACE). Append-once.
67
+ # browser-harness auto-loads <workspace>/agent_helpers.py on every call. Our helpers
68
+ # ship as horse_helpers.py a file THIS installer owns and overwrites outright on
69
+ # every sync plus a load-once stub appended to agent_helpers.py exactly once and
70
+ # never rewritten, so anything a user adds to agent_helpers.py is never touched.
71
+ # The workspace location varies by version/install (≤0.1.0: <repo>/agent-workspace;
72
+ # 0.1.1+: ~/.config/browser-harness/agent-workspace; or whatever BH_AGENT_WORKSPACE
73
+ # points at) — so instead of guessing, we ASK browser-harness itself where it loads
74
+ # from via its own python (helpers.AGENT_WORKSPACE).
73
75
  if ! command -v browser-harness >/dev/null 2>&1; then
74
76
  # browser-harness is a separate (Python) prerequisite. In a hand/curl install it's
75
77
  # required up front. Under npm it's a *declared* prereq the user may not have yet — so
@@ -82,17 +84,54 @@ if ! command -v browser-harness >/dev/null 2>&1; then
82
84
  exit 1
83
85
  fi
84
86
  HELPERS_SRC="$HERE/agent-helpers.py"
87
+ # Legacy marker: pre-0.4.1 installs appended the helpers INLINE under this line, and
88
+ # re-syncs replaced marker→EOF — silently eating anything a user had added below the
89
+ # block. Kept only so those files can be migrated once.
85
90
  HELPERS_MARKER="# ── horse-browser helpers (installed by horse-browser/install.sh) ──"
86
- install_helpers_into() { # $1 = workspace dir; (re)syncs our helpers block idempotent, so
87
- local dst="$1/agent_helpers.py" # re-running install.sh deploys helper UPDATES, not just the first time.
88
- mkdir -p "$1" 2>/dev/null || return 0
89
- # drop any previously-installed block (marker EOF), trim trailing blanks, then re-append
91
+ LOADER_MARKER="# >>> horse-browser: bh_open helpers (managed loader do not edit) >>>"
92
+ install_helpers_into() { # $1 = workspace dir; (re)syncs the helpers idempotent, so
93
+ local ws="$1" dst="$1/agent_helpers.py" # re-running install.sh deploys helper UPDATES too.
94
+ mkdir -p "$ws" 2>/dev/null || return 0
95
+ cp "$HELPERS_SRC" "$ws/horse_helpers.py"
96
+ # one-time migration of a legacy inline block: strip it ONLY on an exact byte match
97
+ # with the shipped source, so user additions below it survive. A modified/unknown
98
+ # block is left in place — harmless, the loader runs after it and its defs win.
90
99
  if [ -f "$dst" ] && grep -qF "$HELPERS_MARKER" "$dst"; then
91
- awk -v m="$HELPERS_MARKER" 'index($0,m){exit} {print}' "$dst" \
92
- | awk 'NF{last=NR} {b[NR]=$0} END{for(i=1;i<=last;i++)print b[i]}' > "$dst.tmp" && mv "$dst.tmp" "$dst"
100
+ python3 - "$dst" "$HELPERS_SRC" "$HELPERS_MARKER" <<'PY' || true
101
+ import sys
102
+ dst, src, marker = sys.argv[1], sys.argv[2], sys.argv[3]
103
+ text, block = open(dst).read(), open(src).read()
104
+ i = text.find(marker)
105
+ legacy = marker + "\n" + block
106
+ if i >= 0 and text[i:].startswith(legacy):
107
+ head = text[:i].rstrip("\n")
108
+ rest = text[i + len(legacy):].lstrip("\n")
109
+ parts = [p for p in (head, rest) if p]
110
+ open(dst, "w").write("\n\n".join(parts) + ("\n" if parts else ""))
111
+ print(" migrated: legacy inline helper block removed (everything else kept)")
112
+ elif i >= 0:
113
+ print(" note: a legacy horse-browser block is present but doesn't match the shipped", file=sys.stderr)
114
+ print(" source — left in place (the horse_helpers.py loader below supersedes", file=sys.stderr)
115
+ print(" it); remove it from " + dst + " by hand when convenient.", file=sys.stderr)
116
+ PY
117
+ fi
118
+ if ! grep -qF "$LOADER_MARKER" "$dst" 2>/dev/null; then
119
+ if [ -s "$dst" ]; then printf '\n' >> "$dst"; fi
120
+ printf '%s\n' "$LOADER_MARKER" >> "$dst"
121
+ cat >> "$dst" <<'LOADER'
122
+ # The helpers live in horse_helpers.py next to this file. install.sh overwrites THAT
123
+ # file on every sync and never rewrites this one — your own code here is safe.
124
+ try:
125
+ import os as _hb_os
126
+ _hb_path = _hb_os.path.join(_hb_os.path.dirname(_hb_os.path.abspath(__file__)), "horse_helpers.py")
127
+ exec(compile(open(_hb_path).read(), _hb_path, "exec"))
128
+ except Exception as _hb_err:
129
+ import sys as _hb_sys
130
+ print("horse-browser: couldn't load horse_helpers.py (%r) — re-run horse-browser's install.sh" % (_hb_err,), file=_hb_sys.stderr)
131
+ # <<< horse-browser: bh_open helpers <<<
132
+ LOADER
93
133
  fi
94
- { [ -s "$dst" ] && printf '\n\n'; printf '%s\n' "$HELPERS_MARKER"; cat "$HELPERS_SRC"; } >> "$dst"
95
- echo "✓ synced bh_open helpers → $dst"
134
+ echo " synced bh_open helpers $ws/horse_helpers.py (loaded via stub in $dst)"
96
135
  }
97
136
  # (a) the workspace browser-harness ACTUALLY loads from — ask it via its own python
98
137
  # (the CLI's shebang points at it); helpers.AGENT_WORKSPACE honours BH_AGENT_WORKSPACE and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pa1nd/horse-browser",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },