session-bundle-kit 0.3.1__py3-none-any.whl

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.
@@ -0,0 +1,20 @@
1
+ """Session bundle export, import, and diff toolkit."""
2
+
3
+ from session_bundle_kit.bundle import SessionBundle
4
+ from session_bundle_kit.diff import diff_bundles
5
+ from session_bundle_kit.export import export_playwright_context
6
+ from session_bundle_kit.import_bundle import import_bundle, validate_bundle
7
+ from session_bundle_kit.models import BUNDLE_SPEC, BUNDLE_VERSION
8
+ from session_bundle_kit.security import scan_plaintext_secrets
9
+
10
+ __all__ = [
11
+ "BUNDLE_SPEC",
12
+ "BUNDLE_VERSION",
13
+ "SessionBundle",
14
+ "diff_bundles",
15
+ "export_playwright_context",
16
+ "import_bundle",
17
+ "scan_plaintext_secrets",
18
+ "validate_bundle",
19
+ ]
20
+ __version__ = "0.3.1"
@@ -0,0 +1,123 @@
1
+ """Session bundle zip read/write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import zipfile
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import quote, unquote
11
+
12
+ from session_bundle_kit.models import (
13
+ BUNDLE_VERSION,
14
+ BundleManifest,
15
+ IndexedDbMeta,
16
+ OriginStorage,
17
+ )
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SessionBundle:
22
+ manifest: BundleManifest
23
+ cookies: list[dict[str, Any]] = field(default_factory=list)
24
+ origins: list[OriginStorage] = field(default_factory=list)
25
+ indexeddb: list[IndexedDbMeta] = field(default_factory=list)
26
+
27
+ def to_playwright_storage_state(self) -> dict[str, Any]:
28
+ return {
29
+ "cookies": self.cookies,
30
+ "origins": [origin.to_dict() for origin in self.origins],
31
+ }
32
+
33
+ @classmethod
34
+ def from_playwright_storage_state(
35
+ cls,
36
+ payload: dict[str, Any],
37
+ *,
38
+ indexeddb: list[IndexedDbMeta] | None = None,
39
+ ) -> SessionBundle:
40
+ cookies = payload.get("cookies", [])
41
+ origins_raw = payload.get("origins", [])
42
+ origins = [OriginStorage.from_dict(item) for item in origins_raw if isinstance(item, dict)]
43
+ idb = indexeddb or []
44
+ manifest = BundleManifest(
45
+ version=BUNDLE_VERSION,
46
+ source="playwright-storage-state",
47
+ cookie_count=len(cookies) if isinstance(cookies, list) else 0,
48
+ origin_count=len(origins),
49
+ indexeddb_count=len(idb),
50
+ )
51
+ return cls(
52
+ manifest=manifest,
53
+ cookies=cookies if isinstance(cookies, list) else [],
54
+ origins=origins,
55
+ indexeddb=idb,
56
+ )
57
+
58
+ def write_zip(self, path: Path | str) -> None:
59
+ target = Path(path)
60
+ target.parent.mkdir(parents=True, exist_ok=True)
61
+ with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
62
+ zf.writestr("manifest.json", json.dumps(self.manifest.to_dict(), indent=2))
63
+ zf.writestr("cookies.json", json.dumps(self.cookies, indent=2))
64
+ for origin in self.origins:
65
+ name = _origin_path(origin.origin)
66
+ zf.writestr(name, json.dumps(origin.to_dict(), indent=2))
67
+ if self.indexeddb:
68
+ zf.writestr(
69
+ "indexeddb/metadata.json",
70
+ json.dumps([item.to_dict() for item in self.indexeddb], indent=2),
71
+ )
72
+
73
+ @classmethod
74
+ def read_zip(cls, path: Path | str) -> SessionBundle:
75
+ target = Path(path)
76
+ if not target.is_file():
77
+ raise FileNotFoundError(f"Bundle not found: {target}")
78
+
79
+ with zipfile.ZipFile(target, "r") as zf:
80
+ names = zf.namelist()
81
+ if "manifest.json" not in names:
82
+ raise ValueError("Bundle missing manifest.json")
83
+ manifest_raw = json.loads(zf.read("manifest.json"))
84
+ if not isinstance(manifest_raw, dict):
85
+ raise ValueError("manifest.json must be a JSON object")
86
+ if "version" not in manifest_raw:
87
+ raise ValueError("manifest.json missing required field 'version'")
88
+ manifest = BundleManifest.from_dict(manifest_raw)
89
+ cookies = json.loads(zf.read("cookies.json")) if "cookies.json" in names else []
90
+ if not isinstance(cookies, list):
91
+ raise ValueError("cookies.json must be a JSON array")
92
+
93
+ origins: list[OriginStorage] = []
94
+ for name in names:
95
+ if name.startswith("origins/") and name.endswith(".json"):
96
+ payload = json.loads(zf.read(name))
97
+ if isinstance(payload, dict):
98
+ origins.append(OriginStorage.from_dict(payload))
99
+
100
+ indexeddb: list[IndexedDbMeta] = []
101
+ if "indexeddb/metadata.json" in names:
102
+ raw = json.loads(zf.read("indexeddb/metadata.json"))
103
+ if isinstance(raw, list):
104
+ indexeddb = [
105
+ IndexedDbMeta.from_dict(item) for item in raw if isinstance(item, dict)
106
+ ]
107
+
108
+ return cls(
109
+ manifest=manifest,
110
+ cookies=cookies,
111
+ origins=origins,
112
+ indexeddb=indexeddb,
113
+ )
114
+
115
+
116
+ def _origin_path(origin: str) -> str:
117
+ safe = quote(origin, safe="")
118
+ return f"origins/{safe}.json"
119
+
120
+
121
+ def origin_from_path(path: str) -> str:
122
+ name = Path(path).stem
123
+ return unquote(name)
@@ -0,0 +1,189 @@
1
+ """Click CLI for session-bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from session_bundle_kit.diff import diff_bundles, format_diff
12
+ from session_bundle_kit.export import export_playwright_context
13
+ from session_bundle_kit.import_bundle import import_bundle
14
+
15
+ EXIT_OK = 0
16
+ EXIT_VALIDATION = 1
17
+ EXIT_ERROR = 2
18
+
19
+
20
+ @click.group(invoke_without_command=True)
21
+ @click.version_option(package_name="session-bundle-kit")
22
+ @click.option("--show-deal", is_flag=True, help="Print Multilogin coupon info and exit.")
23
+ @click.pass_context
24
+ def main(ctx: click.Context, show_deal: bool) -> None:
25
+ """Export, import, and diff browser session bundles."""
26
+ if show_deal:
27
+ from session_bundle_kit.deal import print_show_deal
28
+
29
+ print_show_deal()
30
+ ctx.exit(0)
31
+
32
+
33
+ @main.command("export")
34
+ @click.option(
35
+ "--playwright-context",
36
+ "context_path",
37
+ type=click.Path(exists=True, path_type=Path),
38
+ required=True,
39
+ )
40
+ @click.option("-o", "--output", "output_path", type=click.Path(path_type=Path), required=True)
41
+ def export_cmd(context_path: Path, output_path: Path) -> None:
42
+ """Export Playwright storage state JSON to a session bundle zip."""
43
+ try:
44
+ bundle = export_playwright_context(context_path, output_path)
45
+ click.echo(
46
+ f"Exported {bundle.manifest.cookie_count} cookies, "
47
+ f"{bundle.manifest.origin_count} origins -> {output_path}"
48
+ )
49
+ except Exception as exc:
50
+ raise click.ClickException(str(exc)) from exc
51
+
52
+
53
+ @main.command("import")
54
+ @click.argument("bundle_path", type=click.Path(exists=True, path_type=Path))
55
+ @click.option(
56
+ "--dry-run/--apply",
57
+ "dry_run",
58
+ default=True,
59
+ show_default=True,
60
+ help="Validate only; never writes browser state (default).",
61
+ )
62
+ @click.option("--json", "as_json", is_flag=True)
63
+ def import_cmd(bundle_path: Path, dry_run: bool, as_json: bool) -> None:
64
+ """Validate or import a session bundle."""
65
+ report = import_bundle(bundle_path, dry_run=dry_run)
66
+ if as_json:
67
+ click.echo(json.dumps(report.to_dict(), indent=2))
68
+ else:
69
+ status = "VALID" if report.valid else "INVALID"
70
+ click.echo(f"{status}: {bundle_path}")
71
+ click.echo(
72
+ f" cookies={report.cookie_count} origins={report.origin_count} "
73
+ f"localStorage keys={report.local_storage_keys} "
74
+ f"sessionStorage keys={report.session_storage_keys} "
75
+ f"indexedDB meta={report.indexeddb_count}"
76
+ )
77
+ for issue in report.issues:
78
+ click.echo(f" issue: {issue}")
79
+ for warning in report.warnings:
80
+ click.echo(f" warn: {warning}", err=True)
81
+ if not report.valid:
82
+ sys.exit(EXIT_VALIDATION)
83
+ sys.exit(EXIT_OK)
84
+
85
+
86
+ @main.command("diff")
87
+ @click.argument("left_bundle", type=click.Path(exists=True, path_type=Path))
88
+ @click.argument("right_bundle", type=click.Path(exists=True, path_type=Path))
89
+ @click.option("--json", "as_json", is_flag=True)
90
+ def diff_cmd(left_bundle: Path, right_bundle: Path, as_json: bool) -> None:
91
+ """Diff two session bundle zip files."""
92
+ try:
93
+ result = diff_bundles(left_bundle, right_bundle)
94
+ click.echo(json.dumps(result.to_dict(), indent=2) if as_json else format_diff(result))
95
+ except Exception as exc:
96
+ raise click.ClickException(str(exc)) from exc
97
+
98
+
99
+ @main.command("mlx-pull")
100
+ @click.option("--profile-id", required=True, help="MLX profile UUID.")
101
+ @click.option(
102
+ "-o",
103
+ "--output",
104
+ "output_path",
105
+ type=click.Path(path_type=Path),
106
+ required=True,
107
+ help="Output bundle.zip (session-bundle-v1).",
108
+ )
109
+ @click.option("--folder-id", envvar="MLX_FOLDER_ID", help="MLX folder UUID (export API).")
110
+ @click.option("--token", envvar="MLX_TOKEN")
111
+ @click.option("--launcher-url", default=None, help="Launcher base (MLX_LAUNCHER_URL).")
112
+ @click.option("--no-unlock", is_flag=True, help="Skip Cloud API profile unlock.")
113
+ def mlx_pull_cmd(
114
+ profile_id: str,
115
+ output_path: Path,
116
+ folder_id: str | None,
117
+ token: str | None,
118
+ launcher_url: str | None,
119
+ no_unlock: bool,
120
+ ) -> None:
121
+ """Export MLX profile session cookies to a neutral bundle.zip."""
122
+ from session_bundle_kit.mlx import MlxBundleClient, launcher_url_from_env, require_mlx_extra
123
+
124
+ require_mlx_extra()
125
+ if not token:
126
+ raise click.ClickException("--token or MLX_TOKEN required.")
127
+
128
+ client = MlxBundleClient(
129
+ token=token,
130
+ launcher_base=launcher_url or launcher_url_from_env(),
131
+ )
132
+ try:
133
+ result = client.pull_bundle(
134
+ profile_id,
135
+ output_path,
136
+ folder_id=folder_id,
137
+ unlock=not no_unlock,
138
+ )
139
+ click.echo(json.dumps(result, indent=2))
140
+ except Exception as exc:
141
+ raise click.ClickException(str(exc)) from exc
142
+ finally:
143
+ client.close()
144
+
145
+
146
+ @main.command("mlx-push")
147
+ @click.option("--profile-id", required=True, help="MLX profile UUID.")
148
+ @click.option(
149
+ "--bundle",
150
+ "bundle_path",
151
+ type=click.Path(exists=True, path_type=Path),
152
+ required=True,
153
+ )
154
+ @click.option("--token", envvar="MLX_TOKEN")
155
+ @click.option("--launcher-url", default=None, help="Launcher base (MLX_LAUNCHER_URL).")
156
+ @click.option(
157
+ "--dry-run",
158
+ is_flag=True,
159
+ help="Validate bundle and report import plan without calling MLX API.",
160
+ )
161
+ def mlx_push_cmd(
162
+ profile_id: str,
163
+ bundle_path: Path,
164
+ token: str | None,
165
+ launcher_url: str | None,
166
+ dry_run: bool,
167
+ ) -> None:
168
+ """Push session-bundle-v1 cookies to an MLX profile (Launcher import + unlock)."""
169
+ from session_bundle_kit.mlx import MlxBundleClient, launcher_url_from_env, require_mlx_extra
170
+
171
+ require_mlx_extra()
172
+ if not dry_run and not token:
173
+ raise click.ClickException("--token or MLX_TOKEN required (or use --dry-run).")
174
+
175
+ client = MlxBundleClient(
176
+ token=token or "",
177
+ launcher_base=launcher_url or launcher_url_from_env(),
178
+ )
179
+ try:
180
+ result = client.push_bundle(profile_id, bundle_path, dry_run=dry_run)
181
+ click.echo(json.dumps(result, indent=2))
182
+ except Exception as exc:
183
+ raise click.ClickException(str(exc)) from exc
184
+ finally:
185
+ client.close()
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
@@ -0,0 +1,25 @@
1
+ """Multilogin partner coupon output for --show-deal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ SHOW_DEAL_TEXT = """Partner info (affiliate links — optional, not required for session-bundle)
8
+
9
+ Moving Playwright sessions into isolated profiles? MLX Launcher cookie import
10
+ fits after export/dry-run — not as a substitute for matching proxy/fingerprint.
11
+
12
+ Multilogin X — antidetect browser & cloud phone (verify eligibility before checkout)
13
+
14
+ SAAS50 — browser plans (eligible new purchases)
15
+ MIN50 — cloud phone (eligible new purchases)
16
+
17
+ https://multilogin.com/?ref=SAAS50
18
+ Coupons: https://anti-detect.github.io/
19
+ Scripts: https://t.me/Multilogin_Scripts_Bot
20
+
21
+ Disclosure: we may earn a commission. Offers change; confirm on vendor site."""
22
+
23
+
24
+ def print_show_deal() -> None:
25
+ click.echo(SHOW_DEAL_TEXT)
@@ -0,0 +1,120 @@
1
+ """Diff two session bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from session_bundle_kit.bundle import SessionBundle
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class BundleDiff:
14
+ cookies_added: int = 0
15
+ cookies_removed: int = 0
16
+ cookies_changed: int = 0
17
+ cookie_domains_added: list[str] = field(default_factory=list)
18
+ cookie_domains_removed: list[str] = field(default_factory=list)
19
+ cookie_domains_changed: list[str] = field(default_factory=list)
20
+ origins_added: list[str] = field(default_factory=list)
21
+ origins_removed: list[str] = field(default_factory=list)
22
+ storage_keys_changed: list[str] = field(default_factory=list)
23
+ indexeddb_changed: list[str] = field(default_factory=list)
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ return {
27
+ "cookies": {
28
+ "added": self.cookies_added,
29
+ "removed": self.cookies_removed,
30
+ "changed": self.cookies_changed,
31
+ "domains_added": self.cookie_domains_added,
32
+ "domains_removed": self.cookie_domains_removed,
33
+ "domains_changed": self.cookie_domains_changed,
34
+ },
35
+ "origins": {
36
+ "added": self.origins_added,
37
+ "removed": self.origins_removed,
38
+ },
39
+ "storage_keys_changed": self.storage_keys_changed,
40
+ "indexeddb_changed": self.indexeddb_changed,
41
+ }
42
+
43
+
44
+ def _cookie_key(cookie: dict[str, Any]) -> tuple[str, str, str]:
45
+ return (
46
+ str(cookie.get("domain", "")),
47
+ str(cookie.get("path", "/")),
48
+ str(cookie.get("name", "")),
49
+ )
50
+
51
+
52
+ def _cookie_domain(cookie: dict[str, Any]) -> str:
53
+ return str(cookie.get("domain", ""))
54
+
55
+
56
+ def diff_bundles(left_path: Path | str, right_path: Path | str) -> BundleDiff:
57
+ left = SessionBundle.read_zip(left_path)
58
+ right = SessionBundle.read_zip(right_path)
59
+ result = BundleDiff()
60
+
61
+ left_cookies = {_cookie_key(c): c for c in left.cookies}
62
+ right_cookies = {_cookie_key(c): c for c in right.cookies}
63
+ added_keys = set(right_cookies) - set(left_cookies)
64
+ removed_keys = set(left_cookies) - set(right_cookies)
65
+ result.cookies_added = len(added_keys)
66
+ result.cookies_removed = len(removed_keys)
67
+ result.cookie_domains_added = sorted({_cookie_domain(right_cookies[k]) for k in added_keys})
68
+ result.cookie_domains_removed = sorted({_cookie_domain(left_cookies[k]) for k in removed_keys})
69
+
70
+ changed_domains: set[str] = set()
71
+ for key in set(left_cookies) & set(right_cookies):
72
+ if left_cookies[key].get("value") != right_cookies[key].get("value"):
73
+ result.cookies_changed += 1
74
+ changed_domains.add(_cookie_domain(left_cookies[key]))
75
+ result.cookie_domains_changed = sorted(changed_domains)
76
+
77
+ left_origins = {o.origin: o for o in left.origins}
78
+ right_origins = {o.origin: o for o in right.origins}
79
+ result.origins_added = sorted(set(right_origins) - set(left_origins))
80
+ result.origins_removed = sorted(set(left_origins) - set(right_origins))
81
+
82
+ for origin in set(left_origins) & set(right_origins):
83
+ lo = left_origins[origin]
84
+ ro = right_origins[origin]
85
+ lk = {e.name: e.value for e in lo.local_storage + lo.session_storage}
86
+ rk = {e.name: e.value for e in ro.local_storage + ro.session_storage}
87
+ for name in set(lk) | set(rk):
88
+ if lk.get(name) != rk.get(name):
89
+ result.storage_keys_changed.append(f"{origin}::{name}")
90
+
91
+ left_idb = {(d.origin, d.database_name): d for d in left.indexeddb}
92
+ right_idb = {(d.origin, d.database_name): d for d in right.indexeddb}
93
+ for key in set(left_idb) | set(right_idb):
94
+ if left_idb.get(key) != right_idb.get(key):
95
+ result.indexeddb_changed.append(f"{key[0]}::{key[1]}")
96
+
97
+ return result
98
+
99
+
100
+ def format_diff(diff: BundleDiff) -> str:
101
+ lines = [
102
+ "Session Bundle Diff",
103
+ "===================",
104
+ f"Cookies: +{diff.cookies_added} -{diff.cookies_removed} ~{diff.cookies_changed}",
105
+ f" domains added: {', '.join(diff.cookie_domains_added) or '(none)'}",
106
+ f" domains removed: {', '.join(diff.cookie_domains_removed) or '(none)'}",
107
+ f" domains changed: {', '.join(diff.cookie_domains_changed) or '(none)'}",
108
+ f"Origins added: {', '.join(diff.origins_added) or '(none)'}",
109
+ f"Origins removed: {', '.join(diff.origins_removed) or '(none)'}",
110
+ f"Storage keys changed: {len(diff.storage_keys_changed)}",
111
+ f"IndexedDB changed: {len(diff.indexeddb_changed)}",
112
+ ]
113
+ if diff.storage_keys_changed:
114
+ lines.append("")
115
+ lines.append("Storage changes:")
116
+ for item in diff.storage_keys_changed[:20]:
117
+ lines.append(f" - {item}")
118
+ if len(diff.storage_keys_changed) > 20:
119
+ lines.append(f" ... and {len(diff.storage_keys_changed) - 20} more")
120
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,33 @@
1
+ """Export Playwright storage state to bundle zip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from session_bundle_kit.bundle import SessionBundle
10
+ from session_bundle_kit.models import IndexedDbMeta
11
+
12
+
13
+ def load_playwright_context(path: Path | str) -> dict[str, Any]:
14
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
15
+ if not isinstance(payload, dict):
16
+ raise ValueError("Playwright storage state must be a JSON object")
17
+ return payload
18
+
19
+
20
+ def export_playwright_context(
21
+ context_path: Path | str,
22
+ output_path: Path | str,
23
+ *,
24
+ indexeddb_metadata: list[IndexedDbMeta] | None = None,
25
+ ) -> SessionBundle:
26
+ """Create a session bundle zip from Playwright storage state JSON."""
27
+ payload = load_playwright_context(context_path)
28
+ bundle = SessionBundle.from_playwright_storage_state(
29
+ payload,
30
+ indexeddb=indexeddb_metadata,
31
+ )
32
+ bundle.write_zip(output_path)
33
+ return bundle
@@ -0,0 +1,110 @@
1
+ """Import and validate session bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from session_bundle_kit.bundle import SessionBundle
11
+ from session_bundle_kit.models import BUNDLE_SPEC, BUNDLE_VERSION, SUPPORTED_SPECS
12
+ from session_bundle_kit.security import scan_plaintext_secrets
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ImportReport:
17
+ valid: bool
18
+ bundle_path: str
19
+ cookie_count: int = 0
20
+ origin_count: int = 0
21
+ indexeddb_count: int = 0
22
+ local_storage_keys: int = 0
23
+ session_storage_keys: int = 0
24
+ issues: list[str] = field(default_factory=list)
25
+ warnings: list[str] = field(default_factory=list)
26
+ dry_run: bool = True
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return {
30
+ "valid": self.valid,
31
+ "bundle_path": self.bundle_path,
32
+ "dry_run": self.dry_run,
33
+ "counts": {
34
+ "cookies": self.cookie_count,
35
+ "origins": self.origin_count,
36
+ "indexeddb": self.indexeddb_count,
37
+ "local_storage_keys": self.local_storage_keys,
38
+ "session_storage_keys": self.session_storage_keys,
39
+ },
40
+ "issues": self.issues,
41
+ "warnings": self.warnings,
42
+ }
43
+
44
+
45
+ def validate_bundle(path: Path | str) -> ImportReport:
46
+ report = ImportReport(valid=True, bundle_path=str(path), dry_run=True)
47
+ try:
48
+ bundle = SessionBundle.read_zip(path)
49
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
50
+ report.valid = False
51
+ report.issues.append(str(exc))
52
+ return report
53
+
54
+ _validate_manifest(bundle, report)
55
+
56
+ report.cookie_count = len(bundle.cookies)
57
+ report.origin_count = len(bundle.origins)
58
+ report.indexeddb_count = len(bundle.indexeddb)
59
+ report.local_storage_keys = sum(len(o.local_storage) for o in bundle.origins)
60
+ report.session_storage_keys = sum(len(o.session_storage) for o in bundle.origins)
61
+
62
+ if bundle.manifest.cookie_count != report.cookie_count:
63
+ report.issues.append(
64
+ f"manifest cookie_count={bundle.manifest.cookie_count} "
65
+ f"but cookies.json has {report.cookie_count}"
66
+ )
67
+ if bundle.manifest.origin_count != report.origin_count:
68
+ report.issues.append(
69
+ f"manifest origin_count={bundle.manifest.origin_count} "
70
+ f"but found {report.origin_count} origin files"
71
+ )
72
+
73
+ for cookie in bundle.cookies:
74
+ if not cookie.get("name") or not cookie.get("domain"):
75
+ report.issues.append(f"cookie missing name/domain: {cookie!r}")
76
+ report.valid = False
77
+
78
+ report.warnings.extend(scan_plaintext_secrets(bundle))
79
+ if report.issues:
80
+ report.valid = False
81
+ return report
82
+
83
+
84
+ def _validate_manifest(bundle: SessionBundle, report: ImportReport) -> None:
85
+ manifest = bundle.manifest
86
+ if manifest.spec not in SUPPORTED_SPECS:
87
+ report.warnings.append(
88
+ f"unknown bundle spec {manifest.spec!r} (supported: {sorted(SUPPORTED_SPECS)})"
89
+ )
90
+ if manifest.version > BUNDLE_VERSION:
91
+ report.warnings.append(
92
+ f"bundle version {manifest.version} is newer than toolkit v{BUNDLE_VERSION}"
93
+ )
94
+ elif manifest.version < BUNDLE_VERSION:
95
+ report.warnings.append(
96
+ f"bundle version {manifest.version} is older than current spec {BUNDLE_SPEC}"
97
+ )
98
+ if manifest.spec != BUNDLE_SPEC and manifest.version == BUNDLE_VERSION:
99
+ report.warnings.append(f"expected spec {BUNDLE_SPEC!r}, got {manifest.spec!r}")
100
+
101
+
102
+ def import_bundle(path: Path | str, *, dry_run: bool = True) -> ImportReport:
103
+ """Validate a bundle; dry-run performs no writes (default)."""
104
+ report = validate_bundle(path)
105
+ report.dry_run = dry_run
106
+ if not dry_run:
107
+ report.warnings.append(
108
+ "Live browser import not implemented; use Playwright storage_state API"
109
+ )
110
+ return report
@@ -0,0 +1,285 @@
1
+ """MLX profile session export (pull) and bundle import (push)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from session_bundle_kit.bundle import SessionBundle
12
+ from session_bundle_kit.import_bundle import validate_bundle
13
+ from session_bundle_kit.models import BUNDLE_SPEC, BUNDLE_VERSION, BundleManifest
14
+
15
+ _MLX_HINT = "Install MLX support with: pip install session-bundle-kit[mlx]"
16
+ DEFAULT_CLOUD_BASE = "https://api.multilogin.com"
17
+ DEFAULT_LAUNCHER_BASE = "https://launcher.mlx.yt:45001"
18
+
19
+
20
+ def require_mlx_extra() -> None:
21
+ if importlib.util.find_spec("httpx") is None:
22
+ raise ImportError(_MLX_HINT)
23
+
24
+
25
+ def token_from_env() -> str | None:
26
+ return os.environ.get("MLX_TOKEN")
27
+
28
+
29
+ def launcher_url_from_env() -> str:
30
+ return (
31
+ os.environ.get("MLX_LAUNCHER_URL")
32
+ or os.environ.get("MLX_LAUNCHER_HOST")
33
+ or DEFAULT_LAUNCHER_BASE
34
+ ).rstrip("/")
35
+
36
+
37
+ def cloud_url_from_env() -> str:
38
+ return os.environ.get("MLX_CLOUD_URL", DEFAULT_CLOUD_BASE).rstrip("/")
39
+
40
+
41
+ def cookies_to_mlx(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
42
+ """Playwright storage_state cookies → MLX Launcher import format."""
43
+ payload: list[dict[str, Any]] = []
44
+ for cookie in cookies:
45
+ item: dict[str, Any] = {
46
+ "domain": cookie.get("domain", ""),
47
+ "name": cookie.get("name", ""),
48
+ "value": cookie.get("value", ""),
49
+ "path": cookie.get("path", "/"),
50
+ "httpOnly": bool(cookie.get("httpOnly", False)),
51
+ "secure": bool(cookie.get("secure", False)),
52
+ }
53
+ expires = cookie.get("expires")
54
+ if expires not in (None, -1, "-1"):
55
+ item["expirationDate"] = expires
56
+ if cookie.get("sameSite"):
57
+ item["sameSite"] = cookie["sameSite"]
58
+ payload.append(item)
59
+ return payload
60
+
61
+
62
+ def cookies_from_mlx(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
63
+ """MLX export cookies → Playwright storage_state cookie list."""
64
+ payload: list[dict[str, Any]] = []
65
+ for cookie in cookies:
66
+ item: dict[str, Any] = {
67
+ "domain": cookie.get("domain", ""),
68
+ "name": cookie.get("name", ""),
69
+ "value": cookie.get("value", ""),
70
+ "path": cookie.get("path", "/"),
71
+ "httpOnly": bool(cookie.get("httpOnly", False)),
72
+ "secure": bool(cookie.get("secure", False)),
73
+ }
74
+ expires = cookie.get("expirationDate", cookie.get("expires"))
75
+ if expires not in (None, -1, "-1"):
76
+ item["expires"] = expires
77
+ if cookie.get("sameSite"):
78
+ item["sameSite"] = cookie["sameSite"]
79
+ payload.append(item)
80
+ return payload
81
+
82
+
83
+ def parse_mlx_cookie_export(data: dict[str, Any]) -> list[dict[str, Any]]:
84
+ """Normalize MLX ``/cookies/export`` data.cookies (array or JSON string)."""
85
+ raw = data.get("cookies")
86
+ if raw is None:
87
+ return []
88
+ if isinstance(raw, str):
89
+ parsed = json.loads(raw)
90
+ if isinstance(parsed, str):
91
+ parsed = json.loads(parsed)
92
+ raw = parsed
93
+ if not isinstance(raw, list):
94
+ raise RuntimeError("MLX export cookies must be a JSON array")
95
+ mlx_cookies = [item for item in raw if isinstance(item, dict)]
96
+ return cookies_from_mlx(mlx_cookies)
97
+
98
+
99
+ def bundle_from_mlx_cookies(
100
+ cookies: list[dict[str, Any]],
101
+ *,
102
+ profile_id: str,
103
+ ) -> SessionBundle:
104
+ """Build neutral session-bundle-v1 from MLX-exported cookies only."""
105
+ manifest = BundleManifest(
106
+ version=BUNDLE_VERSION,
107
+ spec=BUNDLE_SPEC,
108
+ source="mlx-profile-export",
109
+ cookie_count=len(cookies),
110
+ origin_count=0,
111
+ indexeddb_count=0,
112
+ )
113
+ return SessionBundle(
114
+ manifest=manifest,
115
+ cookies=cookies,
116
+ origins=[],
117
+ indexeddb=[],
118
+ )
119
+
120
+
121
+ class MlxBundleClient:
122
+ """Pull (export) and push (import) session bundles via MLX APIs."""
123
+
124
+ def __init__(
125
+ self,
126
+ *,
127
+ token: str,
128
+ cloud_base: str | None = None,
129
+ launcher_base: str | None = None,
130
+ timeout: float = 60.0,
131
+ transport: Any | None = None,
132
+ ) -> None:
133
+ require_mlx_extra()
134
+ import httpx # noqa: PLC0415
135
+
136
+ cloud_kwargs: dict[str, Any] = {
137
+ "base_url": (cloud_base or cloud_url_from_env()).rstrip("/"),
138
+ "timeout": timeout,
139
+ }
140
+ launcher_kwargs: dict[str, Any] = {
141
+ "base_url": (launcher_base or launcher_url_from_env()).rstrip("/"),
142
+ "timeout": timeout,
143
+ }
144
+ if transport is not None:
145
+ cloud_kwargs["transport"] = transport
146
+ launcher_kwargs["transport"] = transport
147
+
148
+ self._cloud = httpx.Client(**cloud_kwargs)
149
+ self._launcher = httpx.Client(**launcher_kwargs)
150
+ self._token = token
151
+
152
+ def close(self) -> None:
153
+ self._cloud.close()
154
+ self._launcher.close()
155
+
156
+ def _headers(self) -> dict[str, str]:
157
+ return {
158
+ "Accept": "application/json",
159
+ "Content-Type": "application/json",
160
+ "Authorization": f"Bearer {self._token}",
161
+ }
162
+
163
+ def unlock_profile(self, profile_id: str) -> dict[str, Any]:
164
+ response = self._cloud.get(
165
+ "/profile/unlock",
166
+ headers=self._headers(),
167
+ params={"profile_ids": profile_id},
168
+ )
169
+ return _check(response)
170
+
171
+ def export_cookies(
172
+ self,
173
+ profile_id: str,
174
+ *,
175
+ folder_id: str | None = None,
176
+ ) -> list[dict[str, Any]]:
177
+ body: dict[str, str] = {"profile_id": profile_id}
178
+ if folder_id:
179
+ body["folder_id"] = folder_id
180
+ response = self._launcher.post(
181
+ "/api/v1/cookies/export",
182
+ headers=self._headers(),
183
+ json=body,
184
+ )
185
+ data = _check(response)
186
+ return parse_mlx_cookie_export(data)
187
+
188
+ def import_cookies(self, profile_id: str, cookies: list[dict[str, Any]]) -> dict[str, Any]:
189
+ response = self._launcher.post(
190
+ "/api/v1/cookies/import",
191
+ headers=self._headers(),
192
+ json={"profile_id": profile_id, "cookies": cookies},
193
+ )
194
+ return _check(response)
195
+
196
+ def pull_bundle(
197
+ self,
198
+ profile_id: str,
199
+ output_path: Path | str,
200
+ *,
201
+ folder_id: str | None = None,
202
+ unlock: bool = True,
203
+ ) -> dict[str, Any]:
204
+ """Export MLX profile cookies into a session-bundle-v1 zip."""
205
+ unlock_result: dict[str, Any] | None = None
206
+ if unlock:
207
+ unlock_result = self.unlock_profile(profile_id)
208
+
209
+ cookies = self.export_cookies(profile_id, folder_id=folder_id)
210
+ bundle = bundle_from_mlx_cookies(cookies, profile_id=profile_id)
211
+ bundle.write_zip(output_path)
212
+
213
+ return {
214
+ "profile_id": profile_id,
215
+ "output": str(output_path),
216
+ "spec": BUNDLE_SPEC,
217
+ "cookie_count": len(cookies),
218
+ "origin_count": 0,
219
+ "indexeddb_metadata": 0,
220
+ "unlock": unlock_result,
221
+ "note": (
222
+ "MLX export provides cookies only; localStorage/sessionStorage/IDB "
223
+ "are not included in Launcher cookie export"
224
+ ),
225
+ }
226
+
227
+ def push_bundle(
228
+ self,
229
+ profile_id: str,
230
+ bundle_path: Path | str,
231
+ *,
232
+ dry_run: bool = False,
233
+ ) -> dict[str, Any]:
234
+ """Import session-bundle-v1 cookies into an MLX profile."""
235
+ report = validate_bundle(bundle_path)
236
+ bundle = SessionBundle.read_zip(bundle_path)
237
+ mlx_cookies = cookies_to_mlx(bundle.cookies)
238
+
239
+ result: dict[str, Any] = {
240
+ "profile_id": profile_id,
241
+ "bundle_path": str(bundle_path),
242
+ "spec": bundle.manifest.spec,
243
+ "valid": report.valid,
244
+ "cookie_count": len(mlx_cookies),
245
+ "origin_count": len(bundle.origins),
246
+ "indexeddb_metadata": len(bundle.indexeddb),
247
+ "dry_run": dry_run,
248
+ "issues": list(report.issues),
249
+ "warnings": list(report.warnings),
250
+ "note": (
251
+ "localStorage/sessionStorage/IDB are manifest-only; cookies pushed via Launcher"
252
+ ),
253
+ }
254
+
255
+ if not report.valid:
256
+ raise RuntimeError(f"Bundle validation failed: {', '.join(report.issues)}")
257
+
258
+ if dry_run:
259
+ result["would_import_cookies"] = len(mlx_cookies)
260
+ return result
261
+
262
+ unlock = self.unlock_profile(profile_id)
263
+ imported = self.import_cookies(profile_id, mlx_cookies)
264
+ result["unlock"] = unlock
265
+ result["import"] = imported
266
+ return result
267
+
268
+
269
+ # Backward-compatible alias
270
+ MlxBundlePusher = MlxBundleClient
271
+
272
+
273
+ def _check(response: Any) -> dict[str, Any]:
274
+ body = response.json()
275
+ if response.status_code >= 400:
276
+ status = body.get("status", {}) if isinstance(body, dict) else {}
277
+ message = status.get("message") if isinstance(status, dict) else response.text
278
+ raise RuntimeError(message or f"HTTP {response.status_code}")
279
+ if isinstance(body, dict):
280
+ status = body.get("status", {})
281
+ if isinstance(status, dict) and status.get("error_code"):
282
+ raise RuntimeError(str(status.get("message") or status["error_code"]))
283
+ data = body.get("data")
284
+ return data if isinstance(data, dict) else {"raw": data}
285
+ return {"raw": body}
@@ -0,0 +1,113 @@
1
+ """Bundle data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ BUNDLE_VERSION = 1
9
+ BUNDLE_SPEC = "session-bundle-v1"
10
+ SUPPORTED_SPECS = frozenset({BUNDLE_SPEC})
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class StorageEntry:
15
+ name: str
16
+ value: str
17
+
18
+ def to_dict(self) -> dict[str, str]:
19
+ return {"name": self.name, "value": self.value}
20
+
21
+ @classmethod
22
+ def from_dict(cls, data: dict[str, Any]) -> StorageEntry:
23
+ return cls(name=str(data.get("name", "")), value=str(data.get("value", "")))
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class OriginStorage:
28
+ origin: str
29
+ local_storage: list[StorageEntry] = field(default_factory=list)
30
+ session_storage: list[StorageEntry] = field(default_factory=list)
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ return {
34
+ "origin": self.origin,
35
+ "localStorage": [e.to_dict() for e in self.local_storage],
36
+ "sessionStorage": [e.to_dict() for e in self.session_storage],
37
+ }
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: dict[str, Any]) -> OriginStorage:
41
+ return cls(
42
+ origin=str(data.get("origin", "")),
43
+ local_storage=[
44
+ StorageEntry.from_dict(item)
45
+ for item in data.get("localStorage", [])
46
+ if isinstance(item, dict)
47
+ ],
48
+ session_storage=[
49
+ StorageEntry.from_dict(item)
50
+ for item in data.get("sessionStorage", [])
51
+ if isinstance(item, dict)
52
+ ],
53
+ )
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class IndexedDbMeta:
58
+ origin: str
59
+ database_name: str
60
+ version: int | None = None
61
+ object_stores: list[str] = field(default_factory=list)
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ payload: dict[str, Any] = {
65
+ "origin": self.origin,
66
+ "database_name": self.database_name,
67
+ }
68
+ if self.version is not None:
69
+ payload["version"] = self.version
70
+ if self.object_stores:
71
+ payload["object_stores"] = self.object_stores
72
+ return payload
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: dict[str, Any]) -> IndexedDbMeta:
76
+ stores = data.get("object_stores", [])
77
+ return cls(
78
+ origin=str(data.get("origin", "")),
79
+ database_name=str(data.get("database_name", "")),
80
+ version=data.get("version"),
81
+ object_stores=[str(s) for s in stores] if isinstance(stores, list) else [],
82
+ )
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class BundleManifest:
87
+ version: int = BUNDLE_VERSION
88
+ spec: str = BUNDLE_SPEC
89
+ source: str = "playwright-storage-state"
90
+ cookie_count: int = 0
91
+ origin_count: int = 0
92
+ indexeddb_count: int = 0
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ return {
96
+ "spec": self.spec,
97
+ "version": self.version,
98
+ "source": self.source,
99
+ "cookie_count": self.cookie_count,
100
+ "origin_count": self.origin_count,
101
+ "indexeddb_count": self.indexeddb_count,
102
+ }
103
+
104
+ @classmethod
105
+ def from_dict(cls, data: dict[str, Any]) -> BundleManifest:
106
+ return cls(
107
+ version=int(data.get("version", BUNDLE_VERSION)),
108
+ spec=str(data.get("spec", BUNDLE_SPEC)),
109
+ source=str(data.get("source", "unknown")),
110
+ cookie_count=int(data.get("cookie_count", 0)),
111
+ origin_count=int(data.get("origin_count", 0)),
112
+ indexeddb_count=int(data.get("indexeddb_count", 0)),
113
+ )
@@ -0,0 +1,38 @@
1
+ """Security checks for session bundle contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from session_bundle_kit.bundle import SessionBundle
8
+
9
+ _SECRET_KEY_RE = re.compile(
10
+ r"(password|passwd|pwd|passphrase|secret|credential|api[_-]?key|auth[_-]?token)",
11
+ re.IGNORECASE,
12
+ )
13
+ _INLINE_PASSWORD_RE = re.compile(
14
+ r'["\']?(?:password|passwd|pwd)["\']?\s*[:=]\s*["\']?[^"\']{4,}',
15
+ re.IGNORECASE,
16
+ )
17
+
18
+
19
+ def scan_plaintext_secrets(bundle: SessionBundle) -> list[str]:
20
+ """Return warnings for likely plaintext secrets in web storage."""
21
+ warnings: list[str] = []
22
+ for origin in bundle.origins:
23
+ for store, entries in (
24
+ ("localStorage", origin.local_storage),
25
+ ("sessionStorage", origin.session_storage),
26
+ ):
27
+ for entry in entries:
28
+ if _SECRET_KEY_RE.search(entry.name):
29
+ warnings.append(
30
+ f"{store} key {entry.name!r} on {origin.origin} "
31
+ "may store a plaintext password or secret"
32
+ )
33
+ elif _INLINE_PASSWORD_RE.search(entry.value):
34
+ warnings.append(
35
+ f"{store} value on {origin.origin} (key {entry.name!r}) "
36
+ "looks like embedded credential JSON"
37
+ )
38
+ return warnings
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: session-bundle-kit
3
+ Version: 0.3.1
4
+ Summary: Playwright storage_state zip export — backup sessions, diff bundles, MLX cookie pull/push. CLI: session-bundle.
5
+ Project-URL: Homepage, https://github.com/session-bundle-kit/session-bundle-kit
6
+ Project-URL: Documentation, https://github.com/session-bundle-kit/session-bundle-kit#readme
7
+ Project-URL: Repository, https://github.com/session-bundle-kit/session-bundle-kit
8
+ Project-URL: Issues, https://github.com/session-bundle-kit/session-bundle-kit/issues
9
+ Author: session-bundle-kit contributors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: browser-backup,browser-session-migration,chrome-136,context-backup,cookie-export,indexeddb-manifest,localstorage-backup,multilogin-cookies,playwright-storage-state,portable-session,session-bundle,session-diff,session-export,session-restore,storage-state,zip-export
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: click>=8.1
27
+ Provides-Extra: dev
28
+ Requires-Dist: httpx>=0.27; extra == 'dev'
29
+ Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.8; extra == 'dev'
32
+ Provides-Extra: mlx
33
+ Requires-Dist: httpx>=0.27; extra == 'mlx'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # session-bundle-kit
37
+
38
+ **Playwright session export & backup** — zip storage_state cookies and localStorage; MLX cookie pull/push for profile migration.
39
+
40
+ [![PyPI version](https://img.shields.io/pypi/v/session-bundle-kit.svg)](https://pypi.org/project/session-bundle-kit/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/session-bundle-kit.svg)](https://pypi.org/project/session-bundle-kit/)
42
+ [![License: MIT](https://img.shields.io/pypi/l/session-bundle-kit.svg)](https://pypi.org/project/session-bundle-kit/)
43
+
44
+ ```bash
45
+ pip install session-bundle-kit
46
+ session-bundle export state.json -o bundle.zip
47
+ ```
48
+
49
+ CLI: **`session-bundle`** · Python **3.10+** · optional **`[mlx]`** for Launcher helpers
50
+
51
+ **Playwright session export & backup** — zip `storage_state` cookies and localStorage; MLX cookie pull/push for profile migration.
52
+
53
+ Export and compare **browser session bundles** — cookies, `localStorage`, `sessionStorage`, and IndexedDB metadata — from Playwright storage state JSON.
54
+
55
+ Pure Python zip format. Optional MLX push for cookie import into antidetect profiles.
56
+
57
+ ## Problem
58
+
59
+ Playwright `storage_state.json` is a single file mixing cookies and per-origin storage. Teams need:
60
+
61
+ - Portable **zip bundles** with a manifest for CI and backups
62
+ - **Dry-run validation** before restoring sessions
63
+ - **Diff** between two session snapshots
64
+ - Push cookies into **MLX profiles** after export
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install session-bundle-kit
70
+ ```
71
+
72
+ MLX profile push:
73
+
74
+ ```bash
75
+ pip install session-bundle-kit[mlx]
76
+ ```
77
+
78
+ ## Quick start
79
+
80
+ ```bash
81
+ # Export Playwright storage state -> bundle zip
82
+ session-bundle export --playwright-context ./state.json -o bundle.zip
83
+
84
+ # Validate bundle (dry-run default)
85
+ session-bundle import bundle.zip --dry-run
86
+
87
+ # Compare two bundles
88
+ session-bundle diff bundle_a.zip bundle_b.zip
89
+ ```
90
+
91
+ ## Bundle format (spec v1)
92
+
93
+ ```
94
+ bundle.zip
95
+ ├── manifest.json # spec, version, counts, source
96
+ ├── cookies.json # Playwright cookie list
97
+ ├── origins/
98
+ │ └── https%3A%2F%2Fexample.com.json # localStorage + sessionStorage
99
+ └── indexeddb/
100
+ └── metadata.json # IDB database names/versions (metadata only)
101
+ ```
102
+
103
+ IndexedDB **metadata** is included for audit/diff; full IDB binary export is out of scope.
104
+
105
+ `manifest.json` example:
106
+
107
+ ```json
108
+ {
109
+ "spec": "session-bundle-v1",
110
+ "version": 1,
111
+ "source": "playwright-storage-state",
112
+ "cookie_count": 12,
113
+ "origin_count": 3,
114
+ "indexeddb_count": 0
115
+ }
116
+ ```
117
+
118
+ ## CLI
119
+
120
+ | Command | Description |
121
+ |---------|-------------|
122
+ | `session-bundle export --playwright-context FILE -o OUT.zip` | Create bundle from Playwright state |
123
+ | `session-bundle import FILE --dry-run` | Validate bundle without writing (default) |
124
+ | `session-bundle diff A.zip B.zip` | Cookie domain + storage/IDB diff |
125
+ | `session-bundle mlx-pull --profile-id UUID -o bundle.zip` | Export MLX profile cookies → bundle (`[mlx]`) |
126
+ | `session-bundle mlx-push --profile-id UUID --bundle FILE` | Import bundle cookies to MLX (`[mlx]`) |
127
+ | `session-bundle mlx-push ... --dry-run` | Validate bundle; no MLX API calls |
128
+
129
+ ## API
130
+
131
+ ```python
132
+ from session_bundle_kit import export_playwright_context, validate_bundle, diff_bundles
133
+
134
+ export_playwright_context("state.json", "bundle.zip")
135
+ report = validate_bundle("bundle.zip")
136
+ diff = diff_bundles("old.zip", "new.zip")
137
+ ```
138
+
139
+ Restore to Playwright:
140
+
141
+ ```python
142
+ from session_bundle_kit.bundle import SessionBundle
143
+
144
+ bundle = SessionBundle.read_zip("bundle.zip")
145
+ context = await browser.new_context(storage_state=bundle.to_playwright_storage_state())
146
+ ```
147
+
148
+ ## MLX pull / push (`[mlx]` extra)
149
+
150
+ Round-trip **cookies** between MLX profiles and neutral **session-bundle-v1** zips. Storage/IDB in bundles from Playwright export are manifest-only on push; MLX Launcher APIs move cookies only.
151
+
152
+ **Pull** (profile → zip):
153
+
154
+ 1. `GET api.multilogin.com/profile/unlock?profile_ids=UUID`
155
+ 2. `POST launcher.mlx.yt:45001/api/v1/cookies/export`
156
+
157
+ **Push** (zip → profile):
158
+
159
+ 1. Validate bundle (`--dry-run` stops here)
160
+ 2. `GET api.multilogin.com/profile/unlock?profile_ids=UUID`
161
+ 3. `POST launcher.mlx.yt:45001/api/v1/cookies/import`
162
+
163
+ ```bash
164
+ export MLX_TOKEN="..."
165
+
166
+ # Export MLX profile session to portable bundle
167
+ session-bundle mlx-pull --profile-id PROFILE_UUID -o bundle.zip
168
+
169
+ # Validate before import
170
+ session-bundle mlx-push --profile-id PROFILE_UUID --bundle bundle.zip --dry-run
171
+
172
+ # Import cookies into target profile
173
+ session-bundle mlx-push --profile-id PROFILE_UUID --bundle bundle.zip
174
+ ```
175
+
176
+ Peer pattern for Launcher lifecycle: see `cdp-connect-kit` MLX integration docs.
177
+
178
+ ## When login sessions break after profile switch (playbook)
179
+
180
+ Sites re-challenge when cookies land on a **new fingerprint or IP**. Use bundles to move **stored** credentials deliberately — not as a bypass.
181
+
182
+ | Symptom | Likely cause | Next step |
183
+ |---------|--------------|-----------|
184
+ | Import ok, still logged out | Domain/subdomain mismatch, expired cookies | `session-bundle diff` old vs new; fix with [cookie-jar-bridge](https://pypi.org/project/cookie-jar-bridge/) |
185
+ | Step-up / CAPTCHA after restore | IP or fingerprint drift | Match proxy lane; probe with [playwright-cdp-probe](https://pypi.org/project/playwright-cdp-probe/) |
186
+ | MLX push succeeds, site fails | Cookies only — no `localStorage` on target | Restore storage via Playwright `storage_state` on same profile |
187
+ | Migration metadata only | `antidetect-importer` left `_cookies` sidecar | `mlx-pull` source → edit → `mlx-push --dry-run` → push |
188
+
189
+ **Session migration pipeline:**
190
+
191
+ ```bash
192
+ session-bundle export --playwright-context state.json -o bundle.zip
193
+ session-bundle import bundle.zip --dry-run
194
+ session-bundle mlx-push --profile-id TARGET_UUID --bundle bundle.zip --dry-run
195
+ export MLX_TOKEN=...
196
+ session-bundle mlx-push --profile-id TARGET_UUID --bundle bundle.zip
197
+ cdp-probe mlx --profile-id TARGET_UUID --url https://your-app.example # verify session + exposure
198
+ ```
199
+
200
+ Treat bundle zips as **secrets** — same sensitivity as password files.
201
+
202
+ ## When Chrome `user-data-dir` fails in 2026
203
+
204
+ Starting with **Chrome 136** (March 2025), Google changed how remote debugging works on the default profile. Per the [Chrome Developers blog](https://developer.chrome.com/blog/remote-debugging-port):
205
+
206
+ - `--remote-debugging-port` and `--remote-debugging-pipe` are **ignored** when targeting Chrome's **default** user data directory.
207
+ - You must pass a **custom** `--user-data-dir` pointing to a non-default profile path for CDP to bind.
208
+ - The change targets cookie theft via remote debugging after [App-Bound Encryption](https://developer.chrome.com/blog/app-bound-encryption); Chrome for Testing still supports the older automation workflow.
209
+
210
+ **Practical impact:** copying your daily `Default` profile and launching with `--remote-debugging-port` no longer works on stock Chrome 136+. Session-bundle-kit sidesteps this by exporting **Playwright `storage_state.json`** (cookies + origin storage) into portable zips — restore via `new_context(storage_state=...)` on antidetect or isolated profiles instead of attaching CDP to your real Chrome profile.
211
+
212
+ Enterprise admins can still control remote debugging via the [Allow remote debugging](https://support.google.com/chrome/a/answer/2657289) Chrome policy on managed devices.
213
+
214
+ ## Limitations
215
+
216
+ - **No live IDB export** — IndexedDB section is metadata manifest only.
217
+ - **sessionStorage** preserved in bundle format but Playwright native export may omit it; include in custom state JSON if needed.
218
+ - **mlx-push** imports cookies only; storage must be restored via Playwright or profile sync separately.
219
+ - **Secrets** — bundles contain session secrets; treat zip files as credentials. Import warns on likely plaintext passwords in web storage.
220
+
221
+ ## Production
222
+
223
+ Export sessions to portable zips, validate with `mlx-push --dry-run`, then import cookies into MLX profiles after `antidetect-importer` migration or failed login on a new profile.
224
+
225
+ **Partner note (affiliate):** When restoring sessions into **isolated Multilogin X** profiles (not shared Chrome `user-data-dir`), Launcher cookie import pairs with profile-level proxy and fingerprint. Eligible new browser purchases may accept code **`SAAS50`** at [multilogin.com](https://multilogin.com/?ref=SAAS50) — verify terms before checkout. Export/diff/validate work without MLX. Full disclosure: [docs/AFFILIATE.md](docs/AFFILIATE.md). Coupon details only via `session-bundle --show-deal` (never printed by default).
226
+
227
+ **FAQ:** [docs/FAQ.md](docs/FAQ.md) — Playwright storage_state export, Chrome 136 CDP, MLX cookie import.
228
+
229
+
230
+ ## Related tools (on PyPI)
231
+
232
+ | Package | CLI | Role |
233
+ |---------|-----|------|
234
+ | [playwright-cdp-probe](https://pypi.org/project/playwright-cdp-probe/) | `cdp-probe` | CDP / WebDriver exposure score |
235
+ | [cookie-jar-bridge](https://pypi.org/project/cookie-jar-bridge/) | `cookie-bridge` | Netscape ↔ Playwright cookies |
236
+ | [proxy-lane-checker](https://pypi.org/project/proxy-lane-checker/) | `proxy-lane` | Proxy TCP / HTTP / geo / DNSBL |
237
+ | [fingerprint-coherence](https://pypi.org/project/fingerprint-coherence/) | `fp-coherence` | UA / screen / timezone lint |
238
+
239
+ **Toolkit pipeline:** `proxy-lane check` → `fp-coherence audit` → automate → `cdp-probe run` → `cookie-bridge validate`
240
+
241
+ ## License
242
+
243
+ MIT
244
+
245
+ ---
246
+
247
+ **Production antidetect:** [Multilogin X](https://multilogin.com/?ref=SAAS50) · Code `SAAS50` (-50% browser) · `MIN50` (-50% cloud phone)
248
+ More scripts: [@Multilogin_Scripts_Bot](https://t.me/Multilogin_Scripts_Bot) · [Coupons](https://anti-detect.github.io/)
@@ -0,0 +1,15 @@
1
+ session_bundle_kit/__init__.py,sha256=7Ohl5YiaV7ZKvYg-Fn8eD7jpvp73B8jfs5MVmNRdJ2c,652
2
+ session_bundle_kit/bundle.py,sha256=AQMO-YDvsIiN0D7zWZsueK-rTIB65QpAy--zvh1e4iM,4489
3
+ session_bundle_kit/cli.py,sha256=JkZxU3JhTeG5MnDmvgmRmjMTC8obaE7KX6CXAk_bFUg,6225
4
+ session_bundle_kit/deal.py,sha256=H4CiOBsqh4U88YbtOPJctPMa4l7POqBCLvFLByDEHKQ,830
5
+ session_bundle_kit/diff.py,sha256=GuSUoOXX_zMUdCjTHhqlMFybMC1HCU2r5tP7ciRioi0,4975
6
+ session_bundle_kit/export.py,sha256=oEa44APpZtc-Vm3gIHxE9eXSzwBrDTQjMWlj8G9qjP8,994
7
+ session_bundle_kit/import_bundle.py,sha256=93BB2zU0iomPUwphVAzOblp53nTbXHtEm4zp7ookMCs,3972
8
+ session_bundle_kit/mlx.py,sha256=tC63fRqZyFwaR1JeubPncRzLUMPPXlbBHrR_lOkhBWI,9462
9
+ session_bundle_kit/models.py,sha256=8AfKOx_YHU6AcrpzNeTsAck9Rp5dp46kbnX_tO8BWHs,3506
10
+ session_bundle_kit/security.py,sha256=_6R2f1WTwbRKFQLdhH__bIj--Gt5qOf-3rtXCZ4qoeU,1337
11
+ session_bundle_kit-0.3.1.dist-info/METADATA,sha256=rCGL3-Qln7zSBiVMha_CPkysO8l2wBd3ch-b35WtCrA,11364
12
+ session_bundle_kit-0.3.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ session_bundle_kit-0.3.1.dist-info/entry_points.txt,sha256=sX9LBSfqC__eOyTBpJbdIhxxI5E_HnfbX3JN_sS6TPQ,63
14
+ session_bundle_kit-0.3.1.dist-info/licenses/LICENSE,sha256=vVN3xzHbJKXlwLBCl91WK95OLPEtmqu3AlnSpz3UJx0,1088
15
+ session_bundle_kit-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ session-bundle = session_bundle_kit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 session-bundle-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.