openvibers/openvibe.link Commits
Restore Actor browser sign-ins and sign out individual sites (#144)
Changes
@@@ -170,7 +170,7 @@ STREAM_QUALITY = 70STREAM_MAX_FAILURES = 5INPUT_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 = 2048MAX_DESK_PROGRAMS = 16PROFILE_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"), Noneif 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 Nonesummary = ("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":@@@ -233,6 +233,7 @@ class Browser:self.tab_change = None # "opened" or "closed" since the last _mark: said in the action's receiptself.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 oftenone. A kept profile keeps them alongside, for the next desk (restore_cookies)."""@@@ -20,6 +20,34 @@ from openvibe_plugin import Runtime # noqa: E402from 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."""@@@ -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")
@@ -170,7 +170,7 @@ STREAM_QUALITY = 70
@@ -170,7 +170,7 @@ STREAM_QUALITY = 70
STREAM_MAX_FAILURES = 5
STREAM_MAX_FAILURES = 5
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
BROWSER_READS = frozenset({"read", "screenshot", "close", "active_origin"})
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_URL = 2048
MAX_DESK_PROGRAMS = 16
MAX_DESK_PROGRAMS = 16
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
@@ -1994,6 +1994,13 @@ class Computer(Plugin):
@@ -1994,6 +1994,13 @@ class Computer(Plugin):
action = v.get("action")
action = v.get("action")
if action not in BROWSER_READS and action not in BROWSER_ACTS:
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)))
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
selector, text, key, url = v.get("selector"), v.get("text"), v.get("key"), None
if action == "open":
if action == "open":
url = self._browser_url(v.get("url"), level)
url = self._browser_url(v.get("url"), level)
@@ -2050,6 +2057,12 @@ class Computer(Plugin):
@@ -2050,6 +2057,12 @@ class Computer(Plugin):
out = b.open(url)
out = b.open(url)
elif action == "read":
elif action == "read":
out = b.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":
elif action == "active_origin":
out = b.active_origin()
out = b.active_origin()
elif action == "blind_fill":
elif action == "blind_fill":
@@ -2176,6 +2189,9 @@ class Computer(Plugin):
@@ -2176,6 +2189,9 @@ class Computer(Plugin):
shown = _plain(text) if len(_plain(text)) <= 120 else None
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
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"))))
"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":
elif action == "key":
summary = "press %s%s" % (_plain(v["keys"]), where)
summary = "press %s%s" % (_plain(v["keys"]), where)
elif action == "scroll":
elif action == "scroll":
@@ -233,6 +233,7 @@ class Browser:
@@ -233,6 +233,7 @@ class Browser:
self.tab_change = None # "opened" or "closed" since the last _mark: said in the action's receipt
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.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.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)
self.after_start = None # called once the browser runs (a kept profile's session cookies go back in)
# ---- the process and the pipe ------------------------------------------------------------------------------------
# ---- the process and the pipe ------------------------------------------------------------------------------------
@@ -757,6 +758,58 @@ class Browser:
@@ -757,6 +758,58 @@ class Browser:
os.replace(path + ".part", path)
os.replace(path + ".part", path)
# ---- cookies a kept profile carries (Node v0.24.1) ------------------------------------------------------------
# ---- 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):
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
"""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)."""
one. A kept profile keeps them alongside, for the next desk (restore_cookies)."""
@@ -20,6 +20,34 @@ from openvibe_plugin import Runtime # noqa: E402
@@ -20,6 +20,34 @@ from openvibe_plugin import Runtime # noqa: E402
from test_computer import Out, reply # 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:
class FakeBrowser:
"""What the plugin asks of a browser, recorded; pages are made up."""
"""What the plugin asks of a browser, recorded; pages are made up."""
@@ -428,6 +428,8 @@ class DeskPluginTest(unittest.TestCase):
@@ -428,6 +428,8 @@ class DeskPluginTest(unittest.TestCase):
self.assertIn(("mouse", "click", 500, 250), a.calls)
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": "scroll", "dy": 3, "desk": "desk-a"}))
reply(self.out, self.cmd("input", {"action": "type", "text": "hello", "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"}))
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")],
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")
[("wheel", 500, 250, 3), ("insert", "hello"), ("keys", "ctrl+a")], "a scroll with no point is at the middle")