gumloop 0.4.9__tar.gz → 0.5.0__tar.gz
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.
- {gumloop-0.4.9 → gumloop-0.5.0}/.github/workflows/ci.yml +5 -3
- {gumloop-0.4.9 → gumloop-0.5.0}/PKG-INFO +41 -1
- gumloop-0.5.0/README.md +131 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/pyproject.toml +1 -0
- gumloop-0.5.0/scripts/import-logins.sh +93 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/scripts/install.sh +1 -1
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/_client.py +14 -1
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/_version.py +1 -1
- gumloop-0.5.0/src/gumloop/browser_logins/__init__.py +19 -0
- gumloop-0.5.0/src/gumloop/browser_logins/chromium_cookies.py +205 -0
- gumloop-0.5.0/src/gumloop/browser_logins/discovery.py +172 -0
- gumloop-0.5.0/src/gumloop/browser_logins/extract.py +105 -0
- gumloop-0.5.0/src/gumloop/browser_logins/filter.py +85 -0
- gumloop-0.5.0/src/gumloop/browser_logins/firefox_cookies.py +43 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/agents.py +117 -1
- gumloop-0.5.0/src/gumloop/cli/commands/browser.py +310 -0
- gumloop-0.5.0/src/gumloop/cli/commands/evaluations.py +481 -0
- gumloop-0.5.0/src/gumloop/cli/commands/models.py +165 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/main.py +6 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/plugin_assets/gumloop/skills/gumloop-cli/SKILL.md +1 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/plugin_assets/gumloop/skills/gumloop-cli/references/commands.md +11 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/__init__.py +12 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/agents.py +101 -2
- gumloop-0.5.0/src/gumloop/resources/browser_profiles.py +66 -0
- gumloop-0.5.0/src/gumloop/resources/evaluations.py +274 -0
- gumloop-0.5.0/src/gumloop/resources/organizations.py +21 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/types.py +260 -1
- gumloop-0.5.0/tests/cli/test_browser_logins.py +361 -0
- gumloop-0.5.0/tests/cli/test_evaluations.py +203 -0
- gumloop-0.5.0/tests/cli/test_models.py +111 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_agents.py +26 -1
- gumloop-0.5.0/tests/sdk/test_evaluations.py +205 -0
- gumloop-0.5.0/tests/sdk/test_models.py +76 -0
- gumloop-0.5.0/tests/sdk/test_organizations.py +34 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/uv.lock +121 -79
- gumloop-0.4.9/README.md +0 -92
- {gumloop-0.4.9 → gumloop-0.5.0}/.env.example +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/.github/workflows/release.yml +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/.gitignore +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/.pre-commit-config.yaml +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/.python-version +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/LICENSE +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/scripts/check +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/scripts/fmt +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/scripts/sdk_examples.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/_auth.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/_gumcp_transport.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/_http.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/_sync_output.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/brain.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/chat.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/mcp.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/plugin.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/skills.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/sync.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/commands/update.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/cli/plugin_assets/gumloop/plugin.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/errors.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/oauth.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/py.typed +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/brain.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/chat.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/mcp.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/skills.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/sync.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/spec/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/spec/_compat.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/spec/_extensions.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/archive.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/bundle.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/bundle_validation.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/errors.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/hashing.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/installed.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/local_state.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/lock.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/markers.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/reconcile.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/reporting.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/results.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/run.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/scheduler.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/target_files.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/targets.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/src/gumloop/sync/wire.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/conftest.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/sync_scenario_helpers.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/sync_test_fakes.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_agents.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_brain.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_chat.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_context.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_home_isolation.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_login.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_main.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_mcp.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_plugin.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_skills.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_bundle.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_command.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_command_safety.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_contract_fixtures.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_enrollment.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_fixture_corpus.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_hashing.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_installed.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_reconciliation_safety.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_scheduler_macos.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/cli/test_sync_targets.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/conftest.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/README.md +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/generated-bundle-transfer-limit.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-absolute-path.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-bytes-per-file-limit.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-device.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-duplicate-entry.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-fifo.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-files-per-skill-limit.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-nul-name.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-parent-traversal.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-reserved-marker.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-socket.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-symlink.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-total-uncompressed-limit.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/invalid-windows-drive-prefix.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/valid-single-skill-stored.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/archives/valid-single-skill.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/bundles/newer-than-plan.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/bundles/normal.zip +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/catalog.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/corpus.sha256 +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/hashes/content-v1.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/hashes/manifest-v1.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-identity.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-install-name.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/manifests/invalid-manifest-hash.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/manifests/newer-than-plan.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/manifests/normal-bundle.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/responses/below-pro.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/responses/cli-upgrade-required.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/responses/lost-membership.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/responses/normal-empty-plan.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/fixtures/skill_sync/v1/responses/normal-plan.json +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/integration/conftest.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/integration/test_live.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/__init__.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/conftest.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/helpers.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_brain.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_chat.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_client.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_errors.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_gumcp_transport.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_mcp.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_skills.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_sync.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/sdk/test_teams.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/skill_sync_fixtures.py +0 -0
- {gumloop-0.4.9 → gumloop-0.5.0}/tests/test_client.py +0 -0
|
@@ -25,10 +25,12 @@ jobs:
|
|
|
25
25
|
- run: uv run --frozen ruff format --check .
|
|
26
26
|
- run: uv run --frozen ruff check .
|
|
27
27
|
- run: uv lock --check
|
|
28
|
-
- name: Check
|
|
28
|
+
- name: Check installer script syntax
|
|
29
29
|
run: |
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
for script in scripts/install.sh scripts/import-logins.sh; do
|
|
31
|
+
sh -n "$script"
|
|
32
|
+
bash -n "$script"
|
|
33
|
+
done
|
|
32
34
|
|
|
33
35
|
typecheck:
|
|
34
36
|
runs-on: ubuntu-latest
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gumloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python client for the Gumloop API
|
|
5
5
|
Project-URL: Homepage, https://github.com/gumloop/gumloop-py
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
|
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Requires-Python: >=3.10
|
|
15
15
|
Requires-Dist: click>=8.0
|
|
16
|
+
Requires-Dist: cryptography>=42.0.0
|
|
16
17
|
Requires-Dist: httpx-sse>=0.4.0
|
|
17
18
|
Requires-Dist: httpx>=0.24.1
|
|
18
19
|
Requires-Dist: keyring>=24
|
|
@@ -41,6 +42,26 @@ The installer is fully self-contained under `~/.gumloop` — it ships its own Py
|
|
|
41
42
|
gumloop update
|
|
42
43
|
```
|
|
43
44
|
|
|
45
|
+
### Import your browser sign-ins into a browser profile
|
|
46
|
+
|
|
47
|
+
Gumloop agents that use the Browser ability keep their sign-ins in a **browser profile**. Import the sites you are already signed into locally (Chrome, Brave, Edge, Chromium, Arc, Firefox on macOS or Linux) without an extension. By default every site in the local browser profile you pick comes across; you see the sites and cookie counts and confirm before anything is sent:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gumloop browser import-logins # every site, into your personal default profile
|
|
51
|
+
gumloop browser import-logins --browser brave --exclude-domain doubleclick.net
|
|
52
|
+
gumloop browser import-logins --include-domain github.com --include-domain linear.app
|
|
53
|
+
gumloop browser import-logins --url https://mail.google.com --team <team_id> --into 'Ops inbox' # one site
|
|
54
|
+
gumloop browser profiles list
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or, without installing anything first (set `GUMLOOP_LOGIN_URL` to import a single site, `GUMLOOP_EXCLUDE_DOMAINS` / `GUMLOOP_INCLUDE_DOMAINS` to narrow a whole-profile import):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
curl -fsSL https://gumloop.com/cli/import-logins.sh | sh
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Cookies only: local storage and IndexedDB stay on your machine, so sites that keep the session there ask the agent to sign in once, after which the agent's browser keeps it. Imported cookies are encrypted with the profile's own key before storage and are never shown back in the UI or API; sign-ins the agent picks up while running are saved back to the same profile. Rename, remove sites from, or delete profiles on the Secrets page in Gumloop.
|
|
64
|
+
|
|
44
65
|
## SDK
|
|
45
66
|
|
|
46
67
|
To use the client as a library in your own Python project:
|
|
@@ -115,3 +136,22 @@ async def main() -> None:
|
|
|
115
136
|
|
|
116
137
|
asyncio.run(main())
|
|
117
138
|
```
|
|
139
|
+
|
|
140
|
+
## Route a task to the right model
|
|
141
|
+
|
|
142
|
+
Ask Gumloop Chew which of your candidate models should handle a task. Decision only — nothing runs.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from gumloop import Gumloop
|
|
146
|
+
|
|
147
|
+
client = Gumloop(api_key="your_api_key", user_id="your_user_id")
|
|
148
|
+
|
|
149
|
+
decision = client.models.route(
|
|
150
|
+
input="Summarize this email thread and draft a reply",
|
|
151
|
+
models=["gpt-5.6-luna", "x-ai/grok-4.6", "claude-opus-5"],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
print(decision.route.model, decision.route.lane, decision.route.fallback_models)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Omit `models` to route across the full Chew catalog. Each call bills one small classifier completion.
|
gumloop-0.5.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Gumloop Python Client
|
|
2
|
+
|
|
3
|
+
A Python client for the Gumloop API that makes it easy to run and monitor Gumloop flows, plus the `gumloop` CLI.
|
|
4
|
+
|
|
5
|
+
## CLI
|
|
6
|
+
|
|
7
|
+
Install on macOS or Linux:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
curl -fsSL https://gumloop.com/cli/install.sh | sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The installer is fully self-contained under `~/.gumloop` — it ships its own Python, never touches your system Python, and needs no sudo. Run `gumloop --help` to get started and update any time with:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gumloop update
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Import your browser sign-ins into a browser profile
|
|
20
|
+
|
|
21
|
+
Gumloop agents that use the Browser ability keep their sign-ins in a **browser profile**. Import the sites you are already signed into locally (Chrome, Brave, Edge, Chromium, Arc, Firefox on macOS or Linux) without an extension. By default every site in the local browser profile you pick comes across; you see the sites and cookie counts and confirm before anything is sent:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gumloop browser import-logins # every site, into your personal default profile
|
|
25
|
+
gumloop browser import-logins --browser brave --exclude-domain doubleclick.net
|
|
26
|
+
gumloop browser import-logins --include-domain github.com --include-domain linear.app
|
|
27
|
+
gumloop browser import-logins --url https://mail.google.com --team <team_id> --into 'Ops inbox' # one site
|
|
28
|
+
gumloop browser profiles list
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or, without installing anything first (set `GUMLOOP_LOGIN_URL` to import a single site, `GUMLOOP_EXCLUDE_DOMAINS` / `GUMLOOP_INCLUDE_DOMAINS` to narrow a whole-profile import):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
curl -fsSL https://gumloop.com/cli/import-logins.sh | sh
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Cookies only: local storage and IndexedDB stay on your machine, so sites that keep the session there ask the agent to sign in once, after which the agent's browser keeps it. Imported cookies are encrypted with the profile's own key before storage and are never shown back in the UI or API; sign-ins the agent picks up while running are saved back to the same profile. Rename, remove sites from, or delete profiles on the Secrets page in Gumloop.
|
|
38
|
+
|
|
39
|
+
## SDK
|
|
40
|
+
|
|
41
|
+
To use the client as a library in your own Python project:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv add gumloop
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from gumloop import GumloopClient
|
|
51
|
+
|
|
52
|
+
# Initialize the client
|
|
53
|
+
client = GumloopClient(
|
|
54
|
+
api_key="your_api_key",
|
|
55
|
+
user_id="your_user_id"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Run a flow and wait for outputs
|
|
59
|
+
output = client.run_flow(
|
|
60
|
+
flow_id="your_flow_id",
|
|
61
|
+
inputs={
|
|
62
|
+
"recipient": "example@email.com",
|
|
63
|
+
"subject": "Hello",
|
|
64
|
+
"body": "World"
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
print(output)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Authenticate with a team API key
|
|
72
|
+
|
|
73
|
+
Team (workspace) API keys are scoped to a single team. Pass the team's ID and
|
|
74
|
+
the acting member's user ID — every request is validated against that team and
|
|
75
|
+
only reaches resources the team owns.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from gumloop import Gumloop
|
|
79
|
+
|
|
80
|
+
client = Gumloop(
|
|
81
|
+
api_key="your_team_api_key",
|
|
82
|
+
user_id="your_user_id", # must be a member of the team
|
|
83
|
+
team_id="your_team_id",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
agents = client.agents.list() # scoped to the team
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
|
|
90
|
+
|
|
91
|
+
## Chat with an agent (streaming)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import asyncio
|
|
95
|
+
|
|
96
|
+
from gumloop import AsyncGumloop
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def main() -> None:
|
|
100
|
+
async with AsyncGumloop(access_token="your_access_token") as client:
|
|
101
|
+
agents = await client.agents.list()
|
|
102
|
+
agent = agents.agents[0]
|
|
103
|
+
|
|
104
|
+
async for event in client.sessions.stream(
|
|
105
|
+
agent.id,
|
|
106
|
+
input="Hello, what can you do?",
|
|
107
|
+
):
|
|
108
|
+
print(event)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
asyncio.run(main())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Route a task to the right model
|
|
115
|
+
|
|
116
|
+
Ask Gumloop Chew which of your candidate models should handle a task. Decision only — nothing runs.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from gumloop import Gumloop
|
|
120
|
+
|
|
121
|
+
client = Gumloop(api_key="your_api_key", user_id="your_user_id")
|
|
122
|
+
|
|
123
|
+
decision = client.models.route(
|
|
124
|
+
input="Summarize this email thread and draft a reply",
|
|
125
|
+
models=["gpt-5.6-luna", "x-ai/grok-4.6", "claude-opus-5"],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
print(decision.route.model, decision.route.lane, decision.route.fallback_models)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Omit `models` to route across the full Chew catalog. Each call bills one small classifier completion.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Bring this machine's browser sign-ins into your Gumloop browser profile (every site, cookies only):
|
|
3
|
+
# curl -fsSL https://gumloop.com/cli/import-logins.sh | sh
|
|
4
|
+
# One site only:
|
|
5
|
+
# GUMLOOP_LOGIN_URL='https://app.example.com' sh -c 'curl -fsSL https://gumloop.com/cli/import-logins.sh | sh'
|
|
6
|
+
|
|
7
|
+
set -eu
|
|
8
|
+
|
|
9
|
+
INSTALL_URL="${GUMLOOP_INSTALL_URL:-https://gumloop.com/cli/install.sh}"
|
|
10
|
+
GUMLOOP_HOME="${GUMLOOP_INSTALL_DIR:-$HOME/.gumloop}"
|
|
11
|
+
SHIM_PATH="$HOME/.local/bin/gumloop"
|
|
12
|
+
|
|
13
|
+
if [ -t 1 ]; then
|
|
14
|
+
BOLD="$(printf '\033[1m')"
|
|
15
|
+
RED="$(printf '\033[31m')"
|
|
16
|
+
RESET="$(printf '\033[0m')"
|
|
17
|
+
else
|
|
18
|
+
BOLD="" RED="" RESET=""
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
step() { printf '%s==>%s %s\n' "${BOLD}" "${RESET}" "$1"; }
|
|
22
|
+
fail() { printf '%serror:%s %s\n' "${RED}" "${RESET}" "$1" >&2; exit 1; }
|
|
23
|
+
|
|
24
|
+
# Prompts read /dev/tty, not stdin: under `curl | sh` stdin is the script.
|
|
25
|
+
has_tty() { ( : < /dev/tty ) 2>/dev/null; }
|
|
26
|
+
|
|
27
|
+
case "$(uname -s)" in
|
|
28
|
+
Darwin|Linux) ;;
|
|
29
|
+
*) fail "the Gumloop CLI runs on macOS and Linux. On Windows, use the Gumloop Chrome extension from the Secrets page instead." ;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
find_cli() {
|
|
33
|
+
if [ -x "$SHIM_PATH" ]; then
|
|
34
|
+
printf '%s' "$SHIM_PATH"
|
|
35
|
+
elif [ -x "$GUMLOOP_HOME/venv/bin/gumloop" ]; then
|
|
36
|
+
printf '%s' "$GUMLOOP_HOME/venv/bin/gumloop"
|
|
37
|
+
elif command -v gumloop >/dev/null 2>&1; then
|
|
38
|
+
command -v gumloop
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
CLI="$(find_cli || true)"
|
|
43
|
+
if [ -z "$CLI" ]; then
|
|
44
|
+
step "Installing the Gumloop CLI"
|
|
45
|
+
if command -v curl >/dev/null 2>&1; then
|
|
46
|
+
curl -fsSL "$INSTALL_URL" | GUMLOOP_SKIP_LOGIN=1 sh
|
|
47
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
48
|
+
wget -qO- "$INSTALL_URL" | GUMLOOP_SKIP_LOGIN=1 sh
|
|
49
|
+
else
|
|
50
|
+
fail "curl or wget is required"
|
|
51
|
+
fi
|
|
52
|
+
CLI="$(find_cli || true)"
|
|
53
|
+
[ -n "$CLI" ] || fail "the Gumloop CLI did not install; see the messages above."
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
if ! "$CLI" browser --help >/dev/null 2>&1; then
|
|
57
|
+
step "Updating the Gumloop CLI (this version cannot import sites)"
|
|
58
|
+
"$CLI" update >/dev/null 2>&1 || fail "could not update the Gumloop CLI; run '$CLI update' and retry."
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
url="${GUMLOOP_LOGIN_URL:-}"
|
|
62
|
+
|
|
63
|
+
if [ -z "${GUMLOOP_API_KEY:-}" ] && [ -z "${GUMLOOP_ACCESS_TOKEN:-}" ]; then
|
|
64
|
+
if ! "$CLI" browser profiles list --json >/dev/null 2>&1; then
|
|
65
|
+
step "Signing in to Gumloop"
|
|
66
|
+
has_tty || fail "not signed in and no terminal to sign in on. Set GUMLOOP_API_KEY and GUMLOOP_USER_ID, or run 'gumloop login' first."
|
|
67
|
+
"$CLI" login < /dev/tty
|
|
68
|
+
fi
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
set -- browser import-logins
|
|
72
|
+
if [ -n "$url" ]; then
|
|
73
|
+
set -- "$@" --url "$url" --yes
|
|
74
|
+
else
|
|
75
|
+
# A whole profile is previewed and confirmed on the terminal; headless runs have already opted in.
|
|
76
|
+
has_tty || set -- "$@" --yes
|
|
77
|
+
for domain in $(printf '%s' "${GUMLOOP_INCLUDE_DOMAINS:-}" | tr ',' ' '); do set -- "$@" --include-domain "$domain"; done
|
|
78
|
+
for domain in $(printf '%s' "${GUMLOOP_EXCLUDE_DOMAINS:-}" | tr ',' ' '); do set -- "$@" --exclude-domain "$domain"; done
|
|
79
|
+
fi
|
|
80
|
+
[ -n "${GUMLOOP_BROWSER_PROFILE_ID:-}" ] && set -- "$@" --into "$GUMLOOP_BROWSER_PROFILE_ID"
|
|
81
|
+
[ -n "${GUMLOOP_TEAM_ID:-}" ] && set -- "$@" --team "$GUMLOOP_TEAM_ID"
|
|
82
|
+
[ -n "${GUMLOOP_BROWSER:-}" ] && set -- "$@" --browser "$GUMLOOP_BROWSER"
|
|
83
|
+
|
|
84
|
+
if [ -n "$url" ]; then
|
|
85
|
+
step "Importing your sign-in for ${url}"
|
|
86
|
+
else
|
|
87
|
+
step "Importing your browser's sign-ins"
|
|
88
|
+
fi
|
|
89
|
+
if has_tty; then
|
|
90
|
+
"$CLI" "$@" < /dev/tty
|
|
91
|
+
else
|
|
92
|
+
"$CLI" "$@"
|
|
93
|
+
fi
|
|
@@ -279,7 +279,7 @@ case "$login_state" in
|
|
|
279
279
|
printf 'or install a keychain (e.g. apt install gnome-keyring libsecret-1-0) and run `gumloop login`.\n\n'
|
|
280
280
|
;;
|
|
281
281
|
*)
|
|
282
|
-
if has_tty && confirm "Log in to Gumloop now?"; then
|
|
282
|
+
if [ -z "${GUMLOOP_SKIP_LOGIN:-}" ] && has_tty && confirm "Log in to Gumloop now?"; then
|
|
283
283
|
"$SHIM_PATH" login < /dev/tty || true
|
|
284
284
|
fi
|
|
285
285
|
;;
|
|
@@ -18,15 +18,21 @@ from gumloop.resources import Artifacts
|
|
|
18
18
|
from gumloop.resources import AsyncAgents
|
|
19
19
|
from gumloop.resources import AsyncArtifacts
|
|
20
20
|
from gumloop.resources import AsyncBrain
|
|
21
|
+
from gumloop.resources import AsyncBrowserProfiles
|
|
21
22
|
from gumloop.resources import AsyncChat
|
|
23
|
+
from gumloop.resources import AsyncEvaluations
|
|
22
24
|
from gumloop.resources import AsyncMCP
|
|
23
25
|
from gumloop.resources import AsyncModels
|
|
26
|
+
from gumloop.resources import AsyncOrganizations
|
|
24
27
|
from gumloop.resources import AsyncSessions
|
|
25
28
|
from gumloop.resources import AsyncSkills
|
|
26
29
|
from gumloop.resources import AsyncTeams
|
|
27
30
|
from gumloop.resources import Brain
|
|
31
|
+
from gumloop.resources import BrowserProfiles
|
|
28
32
|
from gumloop.resources import Chat
|
|
33
|
+
from gumloop.resources import Evaluations
|
|
29
34
|
from gumloop.resources import Models
|
|
35
|
+
from gumloop.resources import Organizations
|
|
30
36
|
from gumloop.resources import Sessions
|
|
31
37
|
from gumloop.resources import Skills
|
|
32
38
|
from gumloop.resources import Sync
|
|
@@ -46,7 +52,8 @@ def _derive_stream_base_url(base_url: str) -> str:
|
|
|
46
52
|
class Gumloop:
|
|
47
53
|
"""Sync Gumloop SDK client. Composes an :class:`HttpClient` with the
|
|
48
54
|
resource classes (``agents``, ``sessions``, ``mcp``, ``teams``,
|
|
49
|
-
``skills``, ``artifacts``, ``brain``,
|
|
55
|
+
``organizations``, ``evaluations``, ``skills``, ``artifacts``, ``brain``,
|
|
56
|
+
``models``). The :attr:`oauth`
|
|
50
57
|
attribute holds OAuth helpers — kept off the transport because it
|
|
51
58
|
bootstraps the bearer token rather than consuming one."""
|
|
52
59
|
|
|
@@ -92,10 +99,13 @@ class Gumloop:
|
|
|
92
99
|
self.models = Models(self._http)
|
|
93
100
|
self.mcp = MCP(self._http)
|
|
94
101
|
self.teams = Teams(self._http)
|
|
102
|
+
self.organizations = Organizations(self._http)
|
|
103
|
+
self.evaluations = Evaluations(self._http)
|
|
95
104
|
self.skills = Skills(self._http)
|
|
96
105
|
self.sync = Sync(self._http)
|
|
97
106
|
self.artifacts = Artifacts(self._http)
|
|
98
107
|
self.brain = Brain(self._http)
|
|
108
|
+
self.browser_profiles = BrowserProfiles(self._http)
|
|
99
109
|
self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
|
|
100
110
|
|
|
101
111
|
def close(self) -> None:
|
|
@@ -151,9 +161,12 @@ class AsyncGumloop:
|
|
|
151
161
|
self.models = AsyncModels(self._http)
|
|
152
162
|
self.mcp = AsyncMCP(self._http)
|
|
153
163
|
self.teams = AsyncTeams(self._http)
|
|
164
|
+
self.organizations = AsyncOrganizations(self._http)
|
|
165
|
+
self.evaluations = AsyncEvaluations(self._http)
|
|
154
166
|
self.skills = AsyncSkills(self._http)
|
|
155
167
|
self.artifacts = AsyncArtifacts(self._http)
|
|
156
168
|
self.brain = AsyncBrain(self._http)
|
|
169
|
+
self.browser_profiles = AsyncBrowserProfiles(self._http)
|
|
157
170
|
self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
|
|
158
171
|
|
|
159
172
|
async def __aenter__(self) -> AsyncGumloop:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from gumloop.browser_logins.discovery import BrowserKind
|
|
4
|
+
from gumloop.browser_logins.discovery import LocalProfile
|
|
5
|
+
from gumloop.browser_logins.discovery import discover_profiles
|
|
6
|
+
from gumloop.browser_logins.extract import ExtractResult
|
|
7
|
+
from gumloop.browser_logins.extract import extract_profile_cookies
|
|
8
|
+
from gumloop.browser_logins.extract import extract_site_cookies
|
|
9
|
+
from gumloop.browser_logins.filter import site_of_url
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BrowserKind",
|
|
13
|
+
"ExtractResult",
|
|
14
|
+
"LocalProfile",
|
|
15
|
+
"discover_profiles",
|
|
16
|
+
"extract_profile_cookies",
|
|
17
|
+
"extract_site_cookies",
|
|
18
|
+
"site_of_url",
|
|
19
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import shutil
|
|
5
|
+
import sqlite3
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import hashes
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers import Cipher
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers import algorithms
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers import modes
|
|
18
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
19
|
+
|
|
20
|
+
from gumloop.browser_logins.discovery import BrowserKind
|
|
21
|
+
|
|
22
|
+
_SALT = b"saltysalt"
|
|
23
|
+
_IV = b" " * 16
|
|
24
|
+
_KEY_LENGTH = 16
|
|
25
|
+
_MAC_ITERATIONS = 1003
|
|
26
|
+
_LINUX_ITERATIONS = 1
|
|
27
|
+
_LINUX_DEFAULT_PASSWORD = b"peanuts"
|
|
28
|
+
_WEBKIT_EPOCH_OFFSET_US = 11_644_473_600 * 1_000_000
|
|
29
|
+
_SAME_SITE = {0: "None", 1: "Lax", 2: "Strict"}
|
|
30
|
+
|
|
31
|
+
_QUERY = (
|
|
32
|
+
"SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, "
|
|
33
|
+
"samesite, has_expires, top_frame_site_key FROM cookies"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KeychainAccessError(RuntimeError):
|
|
38
|
+
"""The OS refused to hand over the browser's cookie encryption key."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ChromiumReadResult:
|
|
43
|
+
cookies: list[dict[str, Any]]
|
|
44
|
+
undecryptable: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def macos_safe_storage_password(
|
|
48
|
+
service: str, runner: Callable[..., subprocess.CompletedProcess] = subprocess.run
|
|
49
|
+
) -> bytes:
|
|
50
|
+
completed = runner(
|
|
51
|
+
["security", "find-generic-password", "-w", "-s", service],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
if completed.returncode != 0 or not completed.stdout.strip():
|
|
57
|
+
raise KeychainAccessError(
|
|
58
|
+
f"macOS Keychain did not release the '{service}' key (was the prompt denied?). "
|
|
59
|
+
"Allow access when macOS asks, or use the Gumloop Chrome extension instead."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return completed.stdout.strip().encode("utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def linux_safe_storage_password(service: str) -> bytes:
|
|
66
|
+
try:
|
|
67
|
+
import secretstorage # type: ignore[import-not-found] # noqa: PLC0415 - optional, desktop-only dependency
|
|
68
|
+
|
|
69
|
+
connection = secretstorage.dbus_init()
|
|
70
|
+
collection = secretstorage.get_default_collection(connection)
|
|
71
|
+
for item in collection.get_all_items():
|
|
72
|
+
if item.get_label() == service:
|
|
73
|
+
secret = item.get_secret()
|
|
74
|
+
if secret:
|
|
75
|
+
return bytes(secret)
|
|
76
|
+
except Exception: # noqa: BLE001 - no bus, no keyring, no secretstorage: fall back to the default key
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
return _LINUX_DEFAULT_PASSWORD
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def derive_key(password: bytes, *, iterations: int) -> bytes:
|
|
83
|
+
kdf = PBKDF2HMAC(algorithm=hashes.SHA1(), length=_KEY_LENGTH, salt=_SALT, iterations=iterations)
|
|
84
|
+
return kdf.derive(password)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_key(browser: BrowserKind, *, platform: str | None = None) -> bytes:
|
|
88
|
+
platform = platform or sys.platform
|
|
89
|
+
if platform == "darwin":
|
|
90
|
+
return derive_key(macos_safe_storage_password(browser.safe_storage_service), iterations=_MAC_ITERATIONS)
|
|
91
|
+
|
|
92
|
+
return derive_key(linux_safe_storage_password(browser.safe_storage_service), iterations=_LINUX_ITERATIONS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def decrypt_value(encrypted: bytes, key: bytes, host_key: str) -> str | None:
|
|
96
|
+
"""Chrome 130+ prefixes the plaintext with SHA-256(host_key)."""
|
|
97
|
+
if len(encrypted) < 3:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
prefix, body = encrypted[:3], encrypted[3:]
|
|
101
|
+
if prefix not in (b"v10", b"v11"):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if len(body) % 16 != 0 or not body:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
decryptor = Cipher(algorithms.AES(key), modes.CBC(_IV)).decryptor()
|
|
108
|
+
padded = decryptor.update(body) + decryptor.finalize()
|
|
109
|
+
pad = padded[-1]
|
|
110
|
+
if pad < 1 or pad > 16 or padded[-pad:] != bytes([pad]) * pad:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
plain = padded[:-pad]
|
|
114
|
+
host_hash = hashlib.sha256(host_key.encode("utf-8")).digest()
|
|
115
|
+
if plain[:32] == host_hash:
|
|
116
|
+
plain = plain[32:]
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
return plain.decode("utf-8")
|
|
120
|
+
except UnicodeDecodeError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _webkit_to_unix(expires_utc: int) -> float | None:
|
|
125
|
+
if not expires_utc:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
return (expires_utc - _WEBKIT_EPOCH_OFFSET_US) / 1_000_000
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def copy_database(db_path: Path) -> tuple[Path, Path]:
|
|
132
|
+
"""Copy the database (and its journal/WAL) so a running browser's lock does not matter."""
|
|
133
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="gumloop-cookies-"))
|
|
134
|
+
copied = temp_dir / db_path.name
|
|
135
|
+
shutil.copy2(db_path, copied)
|
|
136
|
+
for suffix in ("-journal", "-wal", "-shm"):
|
|
137
|
+
sidecar = db_path.with_name(db_path.name + suffix)
|
|
138
|
+
if sidecar.exists():
|
|
139
|
+
shutil.copy2(sidecar, temp_dir / sidecar.name)
|
|
140
|
+
|
|
141
|
+
return temp_dir, copied
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def read_cookies(db_path: Path, key: bytes | None, *, keep: Callable[[str], bool] | None = None) -> ChromiumReadResult:
|
|
145
|
+
"""Cookies as CDP ``CookieParam``-shaped dicts; ``keep(host_key)`` filters before any value is decrypted."""
|
|
146
|
+
temp_dir, copied = copy_database(db_path)
|
|
147
|
+
try:
|
|
148
|
+
connection = sqlite3.connect(f"file:{copied}?mode=ro", uri=True)
|
|
149
|
+
try:
|
|
150
|
+
rows = connection.execute(_QUERY).fetchall()
|
|
151
|
+
finally:
|
|
152
|
+
connection.close()
|
|
153
|
+
finally:
|
|
154
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
155
|
+
|
|
156
|
+
cookies: list[dict[str, Any]] = []
|
|
157
|
+
undecryptable = 0
|
|
158
|
+
for (
|
|
159
|
+
host_key,
|
|
160
|
+
name,
|
|
161
|
+
value,
|
|
162
|
+
encrypted_value,
|
|
163
|
+
path,
|
|
164
|
+
expires_utc,
|
|
165
|
+
is_secure,
|
|
166
|
+
is_httponly,
|
|
167
|
+
samesite,
|
|
168
|
+
has_expires,
|
|
169
|
+
top_frame,
|
|
170
|
+
) in rows:
|
|
171
|
+
if keep is not None and not keep(host_key):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
plain = value or ""
|
|
175
|
+
if not plain and encrypted_value:
|
|
176
|
+
decrypted = decrypt_value(bytes(encrypted_value), key, host_key) if key is not None else None
|
|
177
|
+
if decrypted is None:
|
|
178
|
+
undecryptable += 1
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
plain = decrypted
|
|
182
|
+
|
|
183
|
+
cookie: dict[str, Any] = {
|
|
184
|
+
"name": name,
|
|
185
|
+
"value": plain,
|
|
186
|
+
"domain": host_key,
|
|
187
|
+
"path": path or "/",
|
|
188
|
+
"secure": bool(is_secure),
|
|
189
|
+
"httpOnly": bool(is_httponly),
|
|
190
|
+
}
|
|
191
|
+
same_site = _SAME_SITE.get(samesite)
|
|
192
|
+
if same_site:
|
|
193
|
+
cookie["sameSite"] = same_site
|
|
194
|
+
|
|
195
|
+
if has_expires:
|
|
196
|
+
expires = _webkit_to_unix(expires_utc)
|
|
197
|
+
if expires:
|
|
198
|
+
cookie["expires"] = expires
|
|
199
|
+
|
|
200
|
+
if top_frame:
|
|
201
|
+
cookie["partitionKey"] = {"topLevelSite": top_frame}
|
|
202
|
+
|
|
203
|
+
cookies.append(cookie)
|
|
204
|
+
|
|
205
|
+
return ChromiumReadResult(cookies=cookies, undecryptable=undecryptable)
|