Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ jobs:
restore-keys: |
${{ runner.os }}-pre-commit-

- name: Install aac CLI (Agent Access Protocol)
run: |
curl -sL https://github.com/bitwarden/agent-access/releases/latest/download/aac-linux-x86_64.tar.gz | tar xz
sudo mv aac /usr/local/bin/
aac --version

- name: Install dependencies
run: uv sync --dev --all-extras

Expand Down
50 changes: 50 additions & 0 deletions examples/auth-aac-agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Example: Using AacVault with a local notte agent.

Prerequisites:
- `aac` CLI installed (https://github.com/bitwarden/agent-access/releases)
- User running `aac listen` on their machine (connected to their Bitwarden vault)
- Pairing token from `aac listen` output

Usage:
# Terminal 1: User starts aac listen
aac listen

# Terminal 2: Run this agent with the pairing token
export NOTTE_API_KEY="your-notte-api-key" # pragma: allowlist secret
python agent.py --token ABC-DEF-GHI
"""

import argparse
import os

from dotenv import load_dotenv
from notte_core.credentials.aac import AacVault
from notte_sdk import NotteClient

_ = load_dotenv()


def main():
parser = argparse.ArgumentParser(description="Run notte agent with aac vault")
parser.add_argument("--token", help="aac pairing token (or set AAC_TOKEN env var)")
args = parser.parse_args()

token = args.token or os.environ.get("AAC_TOKEN")
if not token:
print("Error: provide --token or set AAC_TOKEN env var")
print("Run 'aac listen' in another terminal to get a pairing token")
exit(1)

notte = NotteClient()

with AacVault(token=token) as vault, notte.Session(open_viewer=True) as session:
agent = notte.Agent(vault=vault, session=session)
output = agent.run(task="Go to github.com and login with your provided credentials")

print(output)
if not output.success:
exit(-1)


if __name__ == "__main__":
main()
37 changes: 37 additions & 0 deletions examples/auth-bitwarden-agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Example: Using BitwardenVault with a local notte agent.

Prerequisites:
- pip install bitwarden-sdk
- BWS_ACCESS_TOKEN environment variable set
- BWS_ORGANIZATION_ID environment variable set
- Secrets stored in Bitwarden Secrets Manager with JSON values:
{"url": "https://github.com/login", "password": "...", "username": "...", "email": "..."}

Usage:
export BWS_ACCESS_TOKEN="0.your-token-here..." # pragma: allowlist secret
export BWS_ORGANIZATION_ID="your-org-id"
export NOTTE_API_KEY="your-notte-api-key" # pragma: allowlist secret
python agent.py
"""

from dotenv import load_dotenv
from notte_core.credentials.bitwarden import BitwardenVault
from notte_sdk import NotteClient

_ = load_dotenv()


def main():
notte = NotteClient()

with BitwardenVault() as vault, notte.Session(open_viewer=True) as session:
agent = notte.Agent(vault=vault, session=session)
output = agent.run(task="Go to github.com and login with your provided credentials")

print(output)
if not output.success:
exit(-1)


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions packages/notte-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ dependencies = [
]

[project.optional-dependencies]
bitwarden = [
"bitwarden-sdk>=2.0.0",
]
tempo = [
"opentelemetry-exporter-otlp>=1.27.0",
]
Expand Down
196 changes: 196 additions & 0 deletions packages/notte-core/src/notte_core/credentials/aac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
from __future__ import annotations

import json
import os
import shutil
import subprocess
from typing import Unpack

from typing_extensions import override

from notte_core.common.logging import logger
from notte_core.common.resource import SyncResource
from notte_core.credentials.base import BaseVault, Credential, CredentialsDict, CreditCardDict
from notte_core.utils.url import get_root_domain


class AacVault(BaseVault, SyncResource):
"""Vault backed by the Agent Access Protocol (aac).

Fetches credentials on-demand through an E2E encrypted Noise tunnel
via Bitwarden's proxy. Works with any aac-compatible credential provider
(Bitwarden Password Manager, 1Password, etc.).

Credentials are requested per-domain when the agent encounters a login
form — never bulk-loaded, never persisted.

The user must be running `aac listen` on their machine for this vault
to function.
"""

def __init__(
self,
token: str | None = None,
proxy_url: str = "wss://ap.lesspassword.dev",
session: str | None = None,
timeout: int = 120,
aac_path: str = "aac",
):
"""
Args:
token: Rendezvous code (ABC-DEF-GHI) or PSK token for pairing.
If None, uses a cached session.
proxy_url: WebSocket URL of the aac proxy server.
session: Hex fingerprint (or prefix) of a cached session to use.
If None and no token, auto-selects the single cached session.
timeout: Timeout in seconds for credential responses.
aac_path: Path to the aac CLI binary.
"""
super().__init__()
self.token: str | None = token or os.environ.get("AAC_TOKEN")
self.proxy_url: str = proxy_url
self._session: str | None = session
self._timeout: int = timeout
self._aac_path: str = aac_path
self._paired: bool = False

@override
def start(self) -> None:
if shutil.which(self._aac_path) is None:
raise RuntimeError(
f"'{self._aac_path}' CLI not found. Install from: https://github.com/bitwarden/agent-access/releases"
)
if self.token:
self._pair()

@override
def stop(self) -> None:
self._paired = False

def _pair(self) -> None:
"""Establish the E2E tunnel by pairing with the user's aac listen."""
assert self.token is not None
cmd: list[str] = [
self._aac_path,
"connect",
"--token",
self.token,
"--proxy-url",
self.proxy_url,
"--output",
"json",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) # noqa: S603
if result.returncode != 0:
try:
data = json.loads(result.stdout)
msg = data.get("error", {}).get("message", result.stderr)
except (json.JSONDecodeError, TypeError):
msg = result.stderr.strip() or result.stdout.strip()
raise RuntimeError(f"aac pairing failed: {msg}")

try:
data = json.loads(result.stdout)
if not data.get("success"):
msg = data.get("error", {}).get("message", result.stdout.strip())
raise RuntimeError(f"aac pairing failed: {msg}")
except (json.JSONDecodeError, TypeError):
pass

self._paired = True
Comment thread
giordano-lucas marked this conversation as resolved.
logger.info("[AacVault] Paired successfully via aac tunnel")

def _request_credential(self, domain: str) -> dict[str, str | None] | None:
"""Request a single credential through the E2E tunnel."""
cmd: list[str] = [
self._aac_path,
"connect",
"--domain",
domain,
"--proxy-url",
self.proxy_url,
"--output",
"json",
"--timeout",
str(self._timeout),
]
if self._session:
cmd.extend(["--session", self._session])

result = subprocess.run(cmd, capture_output=True, text=True, timeout=self._timeout + 10) # noqa: S603
if result.returncode != 0:
try:
data = json.loads(result.stdout)
msg = data.get("error", {}).get("message", "unknown error")
except (json.JSONDecodeError, TypeError):
msg = result.stderr.strip() or f"exit code {result.returncode}"
logger.warning(f"[AacVault] Credential request failed for {domain}: {msg}")
return None

try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
logger.warning(f"[AacVault] Invalid JSON response for {domain}")
return None

if not data.get("success"):
return None

return data.get("credential")

@override
async def _get_credentials_impl(self, url: str) -> CredentialsDict | None:
domain = get_root_domain(url)
if not domain:
return None

cred = self._request_credential(domain)
if cred is None:
return None

password = cred.get("password")
if not password:
return None
Comment on lines +139 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate credential payload type before accessing fields.

If credential is not an object, this path can raise at runtime when calling .get(...).

Suggested fix
-        return data.get("credential")
+        credential = data.get("credential")
+        if not isinstance(credential, dict):
+            logger.warning(f"[AacVault] Invalid credential payload for {domain}")
+            return None
+        return credential
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/notte-core/src/notte_core/credentials/aac.py` around lines 131 -
145, The code in _get_credentials_impl assumes the returned cred from
_request_credential(domain) is a mapping and calls cred.get(...), which can
raise if cred is not a dict; validate the payload type first (e.g., check
isinstance(cred, dict) or typing.Mapping) and return None (or handle/log
appropriately) when it's not the expected object before accessing fields like
"password"; update the logic around the cred variable in _get_credentials_impl
to perform this type check and then safely extract password and other fields.

Comment thread
giordano-lucas marked this conversation as resolved.

result: CredentialsDict = {"password": password}
username = cred.get("username")
if username:
result["username"] = username
totp = cred.get("totp")
if totp:
result["mfa_secret"] = totp
return result
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment on lines +142 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug (P1): Blocking subprocess.run (via _request_credential) called synchronously inside an async method stalls the event loop for up to _timeout seconds. Use asyncio.to_thread.

Suggested change
Suggested change
async def _get_credentials_impl(self, url: str) -> CredentialsDict | None:
domain = get_root_domain(url)
if not domain:
return None
cred = self._request_credential(domain)
if cred is None:
return None
password = cred.get("password")
if not password:
return None
result: CredentialsDict = {"password": password}
username = cred.get("username")
if username:
result["username"] = username
totp = cred.get("totp")
if totp:
result["mfa_secret"] = totp
return result
@override
async def _get_credentials_impl(self, url: str) -> CredentialsDict | None:
import asyncio
domain = get_root_domain(url)
if not domain:
return None
cred = await asyncio.to_thread(self._request_credential, domain)
if cred is None:
return None
password = cred.get("password")
if not password:
return None
result: CredentialsDict = {"password": password}
username = cred.get("username")
if username:
result["username"] = username
totp = cred.get("totp")
if totp:
result["mfa_secret"] = totp
return result
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/notte-core/src/notte_core/credentials/aac.py, line 142:

<issue>
Blocking `subprocess.run` (via `_request_credential`) called synchronously inside an `async` method stalls the event loop for up to `_timeout` seconds. Use `asyncio.to_thread`.
</issue>


@override
async def get_credentials_async(self, url: str) -> CredentialsDict | None:
"""Override to bypass TOTP generation — aac returns live codes, not secrets."""
credentials = await self._get_credentials_impl(url)
if credentials is None:
return None
# Track retrieved credentials for screenshot masking
self._retrieved_credentials[url] = credentials
return credentials

@override
async def _add_credentials(self, url: str, creds: CredentialsDict) -> None:
raise NotImplementedError("aac is read-only — manage credentials in your vault app")

@override
async def delete_credentials_async(self, url: str) -> None:
raise NotImplementedError("aac is read-only — manage credentials in your vault app")

@override
async def list_credentials_async(self) -> list[Credential]:
return []

@override
async def set_credit_card_async(self, **kwargs: Unpack[CreditCardDict]) -> None:
raise NotImplementedError("Credit card not supported via aac")

@override
async def get_credit_card_async(self) -> CreditCardDict:
raise NotImplementedError("Credit card not supported via aac")

@override
async def delete_credit_card_async(self) -> None:
raise NotImplementedError("Credit card not supported via aac")
Loading
Loading