openvibers/openvibe.link Commits

Restore Actor browser sign-ins and sign out individual sites (#144)

OP OpenVibers committed 4 days ago · e061b54f9f · 4 files, +100 −1

* Carry browser sign-ins between Actor desks and clear one site

* Keep typed sign-ins out of desk action summaries

* Signing out of a site signs out of its subdomains

Opus's review (with Run #217): the desk deleted cookies whose domain was exactly the site, so signing out of google.com
left accounts.google.com signed in. Cookies and storage on the site or under it go now.

Changes

ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex 76f98d0..ab5e399 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -170,7 +170,7 @@ STREAM_QUALITY = 70
STREAM_MAX_FAILURES = 5
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
BROWSER_READS = frozenset({"read", "screenshot", "close", "active_origin"})
--BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back", "blind_fill"})
++BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back", "blind_fill", "profile_export", "profile_import", "profile_sign_out"})
MAX_URL = 2048
MAX_DESK_PROGRAMS = 16
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
@@@ -1994,6 +1994,13 @@ class Computer(Plugin):
action = v.get("action")
if action not in BROWSER_READS and action not in BROWSER_ACTS:
raise Fault("bad_value", "action is one of %s" % ", ".join(sorted(BROWSER_READS | BROWSER_ACTS)))
++ if action.startswith("profile_"):
++ if not desk or desk == "main":
++ raise Fault("not_allowed", "Saved sign-ins belong to a private desk")
++ if action == "profile_import" and (not isinstance(v.get("profile"), dict) or len(json.dumps(v["profile"])) > 47000):
++ raise Fault("bad_value", "Invalid saved sign-ins")
++ if action == "profile_sign_out" and (not isinstance(v.get("site"), str) or not re.fullmatch(r"[a-z0-9.-]{1,253}", v["site"])):
++ raise Fault("bad_value", "Invalid site")
selector, text, key, url = v.get("selector"), v.get("text"), v.get("key"), None
if action == "open":
url = self._browser_url(v.get("url"), level)
@@@ -2050,6 +2057,12 @@ class Computer(Plugin):
out = b.open(url)
elif action == "read":
out = b.read()
++ elif action == "profile_export":
++ out = b.profile_export()
++ elif action == "profile_import":
++ out = b.profile_import(v["profile"])
++ elif action == "profile_sign_out":
++ out = b.profile_sign_out(v["site"])
elif action == "active_origin":
out = b.active_origin()
elif action == "blind_fill":
@@@ -2176,6 +2189,9 @@ class Computer(Plugin):
shown = _plain(text) if len(_plain(text)) <= 120 else None
summary = ("type %d characters%s: %r" % (len(text), where, shown) if shown is not None else
"type %d characters%s, sha256 %s" % (len(text), where, _digest(text.encode("utf-8"))))
++ if desk:
++ # A person may be typing a sign-in on this desk. Keep neither text nor a password digest in history.
++ summary = "type %d characters%s" % (len(text), where)
elif action == "key":
summary = "press %s%s" % (_plain(v["keys"]), where)
elif action == "scroll":
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex 4b3da7f..772ce55 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -233,6 +233,7 @@ class Browser:
self.tab_change = None # "opened" or "closed" since the last _mark: said in the action's receipt
self.last_navigation = None # the URL of the last main-frame navigation (a desk stream's allow-list reads it)
self.follow_mu = threading.Lock()
++ self.storage_origins = set()
self.after_start = None # called once the browser runs (a kept profile's session cookies go back in)
# ---- the process and the pipe ------------------------------------------------------------------------------------
@@@ -757,6 +758,58 @@ class Browser:
os.replace(path + ".part", path)
# ---- cookies a kept profile carries (Node v0.24.1) ------------------------------------------------------------
++ def profile_export(self):
++ """Private Run transport only; never return this to a model or write it to a log."""
++ self._need()
++ cookies = self.call("Storage.getCookies", {}, session=False).get("cookies") or []
++ frame = self.call("Page.getFrameTree").get("frameTree", {})
++ def visit(tree):
++ origin = tree.get("frame", {}).get("securityOrigin", "")
++ if origin.startswith(("https://", "http://")):
++ self.storage_origins.add(origin)
++ for child in tree.get("childFrames", []):
++ visit(child)
++ visit(frame)
++ stores = []
++ for origin in sorted(self.storage_origins):
++ entries = self.call("DOMStorage.getDOMStorageItems", {"storageId": {"securityOrigin": origin, "isLocalStorage": True}}).get("entries", [])
++ stores.append({"origin": origin, "entries": entries})
++ out = {"cookies": [{k: c[k] for k in COOKIE_FIELDS if k in c} for c in cookies], "origins": stores}
++ if len(json.dumps(out).encode()) > 47000:
++ raise BrowserError("profile_too_large", "Saved sign-ins exceed this computer's limit")
++ return out
++
++ def profile_import(self, profile):
++ self._need()
++ self.restore_cookies(profile.get("cookies", []))
++ for store in profile.get("origins", []):
++ origin = store["origin"]
++ if not origin.startswith(("https://", "http://")):
++ raise BrowserError("bad_value", "Invalid saved site")
++ self.storage_origins.add(origin)
++ for key, value in store.get("entries", []):
++ self.call("DOMStorage.setDOMStorageItem", {"storageId": {"securityOrigin": origin, "isLocalStorage": True}, "key": key, "value": value})
++ return {"done": True}
++
++ def profile_sign_out(self, site):
++ from urllib.parse import urlparse
++ self._need()
++ cookies = self.call("Storage.getCookies", {}, session=False).get("cookies") or []
++
++ def under(host):
++ # The site and every subdomain of it: signing out of google.com also signs out of accounts.google.com.
++ h = (host or "").lstrip(".").lower()
++ return h == site or h.endswith("." + site)
++
++ for cookie in cookies:
++ if under(cookie.get("domain", "")):
++ self.call("Network.deleteCookies", {k: cookie[k] for k in ("name", "domain", "path", "partitionKey") if k in cookie})
++ for origin in list(self.storage_origins):
++ if under(urlparse(origin).hostname):
++ self.call("Storage.clearDataForOrigin", {"origin": origin, "storageTypes": "local_storage,indexeddb,cache_storage,service_workers"})
++ self.storage_origins.discard(origin)
++ return {"done": True}
++
def session_cookies(self):
"""The cookies that end with the browser (no expiry): Chrome drops them when it quits, and a sign-in is often
one. A kept profile keeps them alongside, for the next desk (restore_cookies)."""
ddiff --git a/plugins/computer/tests/test_browser.py b/plugins/computer/tests/test_browser.py
iindex 7903c16..1ffec33 100644
---- a/plugins/computer/tests/test_browser.py
++++ b/plugins/computer/tests/test_browser.py
@@@ -20,6 +20,34 @@ from openvibe_plugin import Runtime # noqa: E402
from test_computer import Out, reply # noqa: E402
++class ActorProfileTests(unittest.TestCase):
++ def test_restart_and_site_only_signout(self):
++ state = {"cookies": [{"name": "login", "value": "fixture-a", "domain": ".a.example", "path": "/"}, {"name": "login", "value": "fixture-b", "domain": "b.example", "path": "/"}, {"name": "sso", "value": "fixture-c", "domain": "accounts.a.example", "path": "/"}], "origins": {"https://a.example": [["theme", "dark"]]}}
++ def browser(state):
++ b = Browser("unused", "/unused")
++ b._need = lambda: None
++ def call(method, params=None, **kw):
++ params = params or {}
++ if method == "Storage.getCookies": return {"cookies": state["cookies"]}
++ if method == "Storage.setCookies": state["cookies"].extend(params["cookies"])
++ if method == "Page.getFrameTree": return {"frameTree": {"frame": {"securityOrigin": "https://a.example"}}}
++ if method == "DOMStorage.getDOMStorageItems": return {"entries": state["origins"].get(params["storageId"]["securityOrigin"], [])}
++ if method == "DOMStorage.setDOMStorageItem": state["origins"].setdefault(params["storageId"]["securityOrigin"], []).append([params["key"], params["value"]])
++ if method == "Network.deleteCookies": state["cookies"] = [c for c in state["cookies"] if c["domain"] != params["domain"]]
++ if method == "Storage.clearDataForOrigin": state["origins"].pop(params["origin"], None)
++ return {}
++ b.call = call
++ return b
++ saved = browser(state).profile_export()
++ restarted = {"cookies": [], "origins": {}}
++ b = browser(restarted)
++ b.profile_import(saved)
++ self.assertEqual(restarted, state)
++ b.profile_sign_out("a.example")
++ self.assertEqual([c["domain"] for c in restarted["cookies"]], ["b.example"])
++ self.assertEqual(restarted["origins"], {})
++
++
class FakeBrowser:
"""What the plugin asks of a browser, recorded; pages are made up."""
ddiff --git a/plugins/computer/tests/test_desk.py b/plugins/computer/tests/test_desk.py
iindex 60ce204..14fe97a 100644
---- a/plugins/computer/tests/test_desk.py
++++ b/plugins/computer/tests/test_desk.py
@@@ -428,6 +428,8 @@ class DeskPluginTest(unittest.TestCase):
self.assertIn(("mouse", "click", 500, 250), a.calls)
reply(self.out, self.cmd("input", {"action": "scroll", "dy": 3, "desk": "desk-a"}))
reply(self.out, self.cmd("input", {"action": "type", "text": "hello", "desk": "desk-a"}))
++ _, summary = self.plugin._input_op("page", {"action":"type", "text":"synthetic sign-in"}, env={}, desk="desk-a")
++ self.assertEqual(summary, "type 17 characters")
reply(self.out, self.cmd("input", {"action": "key", "keys": "ctrl+a", "desk": "desk-a"}))
self.assertEqual([c for c in a.calls if c[0] in ("wheel", "insert", "keys")],
[("wheel", 500, 250, 3), ("insert", "hello"), ("keys", "ctrl+a")], "a scroll with no point is at the middle")