Skip to content
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()
35 changes: 35 additions & 0 deletions examples/auth-bitwarden-agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Example: Using BitwardenVault with a local notte agent.

Prerequisites:
- `bws` CLI installed (https://github.com/bitwarden/sdk/releases)
- BWS_ACCESS_TOKEN environment variable set (or pass access_token directly)
- 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 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()
188 changes: 188 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,188 @@
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}")

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