From f633a10f801ca9cbc4abaa7c4f7198f68f9fc872 Mon Sep 17 00:00:00 2001 From: TSYS Group COO Date: Thu, 13 Aug 2026 20:49:39 -0500 Subject: [PATCH] fix: resolve BW state sync issue -- add sync() to login lifecycle The provisioner's BitwardenHelper.login() was missing the critical `bw sync` step that the host wrapper includes. Without syncing after login, the container's local vault cache was empty/stale, causing items to vanish between container runs. Added sync() call at end of login() and before list_items(). Also fixed container UID/GID to match host user (1002:1002) for proper bind-mount access, and added source-code volume mounts for fast iteration. Verified with 5-phase cross-container persistence test (create in container A, verify in fresh container B, update in C, confirm in D). --- Dockerfile | 4 +- bw_helper.py | 13 +++++ docker-compose.yml | 4 ++ test_bw_persistence.py | 127 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 test_bw_persistence.py diff --git a/Dockerfile b/Dockerfile index d196d4f..ed5d7d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,8 @@ RUN wget -q -O /tmp/bw.zip \ COPY requirements.txt /tmp/ RUN python3 -m pip install --no-cache-dir --break-system-packages -r /tmp/requirements.txt -# Create non-root user for Playwright -RUN groupadd -r provision && useradd -r -g provision -G audio,video -m -d /home/provision provision \ +# Create non-root user for Playwright, matching host UID/GID for bind-mount access +RUN groupadd -r -g 1002 provision && useradd -r -u 1002 -g provision -G audio,video -m -d /home/provision provision \ && mkdir -p "/home/provision/.config/Bitwarden CLI" \ && chown -R provision:provision /home/provision diff --git a/bw_helper.py b/bw_helper.py index b36a02c..80d08a2 100644 --- a/bw_helper.py +++ b/bw_helper.py @@ -110,6 +110,18 @@ class BitwardenHelper: if not self.session: raise RuntimeError("BW unlock failed -- no session token returned") + self.sync() + + def sync(self) -> None: + """Sync the local vault cache with the server. + + Must be called after login and before any read to guarantee + the local cache reflects the latest server state. Without this, + items created by other clients (e.g. the host bw wrapper) will + not appear in list/search results. + """ + self._run_bw(["sync"]) + def generate_password(self, length: int = 32) -> str: """Generate a strong password.""" return self._run_bw(["generate", "-ulns", "--length", str(length)]) @@ -280,6 +292,7 @@ class BitwardenHelper: def list_items(self) -> list[dict]: """List all items in the vault.""" + self.sync() output = self._run_bw(["list", "items"]) return json.loads(output) diff --git a/docker-compose.yml b/docker-compose.yml index 5112ed2..a947ea8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,7 @@ services: - ./agents.yaml:/app/agents.yaml:ro - ./state:/app/state - ./bw-state:/home/provision/.config/Bitwarden CLI + - ./bw_helper.py:/app/bw_helper.py:ro + - ./provision-agent.py:/app/provision-agent.py:ro + - ./test_bw_helper.py:/app/test_bw_helper.py:ro + - ./test_bw_persistence.py:/app/test_bw_persistence.py:ro diff --git a/test_bw_persistence.py b/test_bw_persistence.py new file mode 100644 index 0000000..4b44d72 --- /dev/null +++ b/test_bw_persistence.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +test_bw_persistence.py -- Cross-container BW state persistence test. + +Verifies that BW items created in one container run are visible in a +subsequent container run (the "state sync" issue from session 1). + +This test is designed to be invoked twice: + Run 1 (create): python3 test_bw_persistence.py create + Run 2 (verify): python3 test_bw_persistence.py verify + Run 3 (update): python3 test_bw_persistence.py update + Run 4 (confirm): python3 test_bw_persistence.py confirm + Run 5 (cleanup): python3 test_bw_persistence.py cleanup + +Each run is a SEPARATE container invocation. If the bw-state bind mount +and sync logic are working, run 2 will see the item created in run 1, +and run 4 will see the update from run 3. + +Usage: + docker compose run --rm --entrypoint python3 provision \ + test_bw_persistence.py +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from bw_helper import BitwardenHelper + +TEST_ITEM = "TEST-PERSISTENCE-CROSSCONTAINER" +TEST_PASSWORD_ORIG = "OriginalPersistPassword123!" +TEST_PASSWORD_UPDATED = "UpdatedPersistPassword456@" + + +def make_helper() -> BitwardenHelper: + return BitwardenHelper( + client_id=os.environ["BW_CLIENTID"], + client_secret=os.environ["BW_CLIENTSECRET"], + password=os.environ["BW_PASSWORD"], + server_url=os.environ.get("BW_SERVER", ""), + ) + + +def phase_create(): + bw = make_helper() + bw.login() + + existing = bw.get_item_id(TEST_ITEM) + if existing: + bw._run_bw(["delete", "item", existing]) + + item_id = bw.create_item( + name=TEST_ITEM, + username="persist-test@example.com", + password=TEST_PASSWORD_ORIG, + uris=["https://persist.example.com"], + collection_name="test", + ) + assert item_id, "create should return an ID" + print(f"CREATE_OK: item_id={item_id}") + print(f"HOST_SYNC_CHECK: run 'bw list items' on host to verify visibility") + + +def phase_verify(): + bw = make_helper() + bw.login() + + assert bw.item_exists(TEST_ITEM), \ + "FRESH CONTAINER CANNOT SEE ITEM CREATED BY PREVIOUS CONTAINER" + pw = bw.get_item_password(TEST_ITEM) + assert pw == TEST_PASSWORD_ORIG, \ + f"Password mismatch: expected {TEST_PASSWORD_ORIG}, got {pw}" + print("VERIFY_OK: item visible in fresh container run") + + +def phase_update(): + bw = make_helper() + bw.login() + + original_id = bw.get_item_id(TEST_ITEM) + assert original_id, "item must exist before update" + + updated_id = bw.update_item(TEST_ITEM, password=TEST_PASSWORD_UPDATED) + assert updated_id == original_id, "update must preserve item ID" + print(f"UPDATE_OK: item_id={updated_id}") + + +def phase_confirm(): + bw = make_helper() + bw.login() + + pw = bw.get_item_password(TEST_ITEM) + assert pw == TEST_PASSWORD_UPDATED, \ + f"Update did not persist: expected {TEST_PASSWORD_UPDATED}, got {pw}" + print("CONFIRM_OK: update visible in fresh container run") + + +def phase_cleanup(): + bw = make_helper() + bw.login() + + item_id = bw.get_item_id(TEST_ITEM) + if item_id: + bw._run_bw(["delete", "item", item_id]) + assert not bw.item_exists(TEST_ITEM), "cleanup failed" + print("CLEANUP_OK") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: test_bw_persistence.py ") + sys.exit(1) + + phase = sys.argv[1] + phases = { + "create": phase_create, + "verify": phase_verify, + "update": phase_update, + "confirm": phase_confirm, + "cleanup": phase_cleanup, + } + if phase not in phases: + print(f"Unknown phase: {phase}") + sys.exit(1) + phases[phase]() + print("SUCCESS")