master
Colby Knox v2.0.741 (insiders): Login import keeps working where nb cannot run it (pup-bridge#145): pup asks nb first, and only wh… 87889f6 5d ago
#!/usr/bin/env python3
"""Embed browser-side assets into the Rust build (run by tools/ship.sh; commit the outputs).
- rust/browser/src/ext_assets.rs: the four MV3 extension folders (toolbar, identity, identity-anon, annotate)
- rust/dashboard/src/assets_gen.rs: dashboard images (settings-ex, settings-samples, tab-badges), newbie-panel.js, recorder.html, welcome.html
Files are referenced with include_bytes! relative to the crate, so the exe carries them; nothing is copied into rust/."""
import os, sys
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def rel(p, crate): return os.path.relpath(p, os.path.join(root, 'rust', crate, 'src')).replace('\\', '/')
# extensions
dirs = [('extension', 'pup-extension'), ('extension-identity', 'pup-identity-extension'), ('extension-identity-anon', 'pup-identity-anon-extension'), ('extension-annotate', 'pup-annotate-extension'), ('extension-nbe', 'pup-nbe-extension')]
out = ["//! GENERATED by tools/gen-assets.py: the MV3 extension folders, embedded. Do not edit.", "", "/// (source folder, staged folder name under ~/.adom, files)", "pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &["]
for src, dst in dirs:
    d = os.path.join(root, 'src', src)
    if not os.path.isdir(d): continue
    # RECURSIVE: nbe keeps its code in src/ and its art in icons/, and a top-level-only listdir
    # silently embedded just its manifest -> Chrome refused the whole extension with "Could not load
    # background script ''" (John caught the dialog, 2026-09-13). Paths stay forward-slashed.
    files = []
    for base, _dirs, names in os.walk(d):
        for f in sorted(names):
            full = os.path.join(base, f)
            files.append((os.path.relpath(full, d).replace(os.sep, '/'), full))
    files.sort()
    out.append(f'    ("{src}", "{dst}", &[')
    for name, full in files: out.append(f'        ("{name}", include_bytes!("{rel(full, "browser")}")),')
    out.append('    ]),')
out.append('];')
open(os.path.join(root, 'rust', 'browser', 'src', 'ext_assets.rs'), 'w').write("\n".join(out) + "\n")
# dashboard assets
items = []
for sub in ['icons/settings-ex', 'icons/settings-samples', 'icons/tab-badges']:
    d = os.path.join(root, 'src', sub)
    for f in sorted(os.listdir(d)):
        p = os.path.join(d, f)
        if os.path.isfile(p): items.append((f'{sub}/{f}', p))
for f in ['newbie-panel.js', 'welcome.html', 'recorder.html']:
    p = os.path.join(root, 'src', f)
    if os.path.isfile(p): items.append((f, p))
out = ["//! GENERATED by tools/gen-assets.py: dashboard images and page assets, embedded. Do not edit.", "", "pub static FILES: &[(&str, &[u8])] = &["]
for name, p in items: out.append(f'    ("{name}", include_bytes!("{rel(p, "dashboard")}")),')
out.append('];'); out.append(''); out.append('pub fn get(name: &str) -> Option<&\'static [u8]> { FILES.iter().find(|(n, _)| *n == name).map(|(_, b)| *b) }')
open(os.path.join(root, 'rust', 'dashboard', 'src', 'assets_gen.rs'), 'w').write("\n".join(out) + "\n")
print(f"ext_assets.rs: {len(dirs)} folders; assets_gen.rs: {len(items)} files")

# verb catalog: the describe entries (name/summary/input/timeoutSeconds/example/hint/related/pitfalls) recorded
# from the Node build's pup_describe (docs/contract/pup_describe.json), minus the desktop_ aliases dropped in
# 2.0.400. The Rust dispatcher stamps _hint/related/_next/pitfalls from it (Node: VERB_META in sendJSON).
import json
_g = json.load(open(os.path.join(root, 'docs', 'contract', 'pup_describe.json'))); _o = json.loads(_g['output'])
_verbs = [v for v in _o['verbs'] if not v['name'].startswith('desktop_')]
# The Rust build adds verbs the Node describe golden lacks (2 real, 2 doc-only). Keep them across regen.
_have = {v['name'] for v in _verbs}
# pup_list_sessions is a Rust-side ALIAS onto the same caller-scoped implementation as pup_my_windows
# (adom/pup-bridge#108: agents called the legacy name and got not_in_rust_yet with no equivalent named).
# The Node describe golden predates the alias, so synthesise its entry here or it regenerates away.
# 2.0.592 (pup-bridge#116): pup_select, a <select> chooser by value or label, both lanes; synthesised from pup_fill.
if 'pup_select' not in _have:
    _f = next((v for v in _verbs if v['name'] == 'pup_fill'), None)
    if _f:
        _sv = json.loads(json.dumps(_f)); _sv['name'] = 'pup_select'
        _sv['summary'] = 'Choose a <select> option by value or visible label, in the background (fires input+change).'
        _sv['input'] = { 'selector': 'required str', 'value': 'optional str (option value)', 'label': 'optional str (visible text, case-insensitive, substring ok)', 'sessionId': _f['input'].get('sessionId', 'optional str') }
        _sv['example'] = { 'selector': 'select[name=timezone]', 'label': 'Central Time' }
        _sv['hint'] = 'Sets the option through the native setter and fires input+change so framework-bound selects update; works on both lanes (DevTools evaluate, so page CSP and Trusted Types cannot refuse it). For custom dropdowns (Material, React Select) use pup_click on the control, then pup_click the option by text. option_not_found lists the first 30 options.'
        _sv['related'] = ['pup_fill', 'pup_click', 'pup_type']
        _verbs.append(_sv)
# pup-bridge#145: pup_upload_file, a local file onto a page's <input type=file>, both lanes; synthesised from pup_fill.
if 'pup_upload_file' not in _have:
    _f = next((v for v in _verbs if v['name'] == 'pup_fill'), None)
    if _f:
        _up = json.loads(json.dumps(_f)); _up['name'] = 'pup_upload_file'
        _up['summary'] = "Upload a file from this PC to a page: put it on the page's <input type=file> with no OS file picker, in the background, at any size."
        _up['input'] = { 'filePath': 'required str unless filePaths: absolute path on the PC the browser runs on', 'filePaths': 'optional array of paths, for an input that takes several files', 'selector': 'optional str (the input, or its Browse button or label; hidden inputs, open shadow roots and same-origin frames are searched)', 'text': 'optional str (visible text of the Browse control, instead of selector)', 'armForNextChooser': 'optional bool (the page creates its input when Browse is clicked: arm, then pup_click the Browse control)', 'chooserStatus': 'optional bool (did the armed chooser get answered?)', 'sessionId': _f['input'].get('sessionId', 'optional str') }
        _up['example'] = { 'selector': 'input[type=file]', 'filePath': 'C:/Users/me/Downloads/gerbers.zip' }
        _up['hint'] = "Chrome reads the file itself, so a 5 GB file costs the same as a small one: never base64 a file through pup_eval again. The path is on the PC, not in your container: adom-bridge send_files first if the file is in the container. The verb reads the input back and fires no extra events, so a page that uploads on change sends the file once. attached:true means the page has the file, not that the site has it: screenshot for the page's own progress. Key, password and browser-login files, AppData, hidden folders in the home folder, system folders and network paths are refused (errorCode path_refused)."
        _up['related'] = ['pup_click', 'pup_screenshot', 'pup_wait', 'pup_download']
        _up['pitfalls'] = ["Do not click a Browse button without arming first: the OS dialog opens in front of the user and freezes the window.", "A missing path, two files on a single-file input and a disabled input are refused here because Chrome would accept them silently."]
        _up['output'] = { 'attached': 'bool', 'verified': 'bool (read back from the page)', 'files': '[{name, sizeBytes}]', 'clearedByPage': 'bool (the page took the file and cleared the input)', 'acceptWarning': 'optional str' }
        _verbs.append(_up)
# 2.0.596 (John, 2026-09-16): Google sign-in as the right account, with the account search behind it.
_to = lambda n: next((v for v in _verbs if v['name'] == n), None)
if 'pup_google_accounts' not in _have and _to('pup_credentials'):
    _ga = json.loads(json.dumps(_to('pup_credentials'))); _ga['name'] = 'pup_google_accounts'
    _ga['summary'] = "List the user's Google accounts: every Chrome/Edge profile, nb's browser profiles, pup's vault, and which ones pup's own profile is already signed into. Cached a day."
    _ga['input'] = { 'refresh': 'optional bool (redo the whole search now)' }
    _ga['example'] = {}
    _ga['hint'] = "Each account comes back with kind (work or personal, from its domain), the browser profile labels the user gave it (\"John Personal\"), whether pup is already signed in, whether pup holds its password, and likelyStale for an account found only in pup's vault. pup never chooses between accounts: pick from the task, the user's work and personal skills, or ask the user."
    _ga['related'] = ['pup_google_signin', 'pup_credentials', 'pup_import_browser_logins']
    _ga['pitfalls'] = ["An account only in pup's vault and signed into no browser here is usually years old; Google will demand a second step for it."]
    _verbs.append(_ga)
if 'pup_google_signin' not in _have and _to('pup_login'):
    _gs = json.loads(json.dumps(_to('pup_login'))); _gs['name'] = 'pup_google_signin'
    _gs['summary'] = "Sign the user into Google as a named account, hands-free: account search, email step, saved password, and the verification step handed back with exact next calls."
    _gs['input'] = {
        'account': 'optional str: an email, or "work" / "personal" when exactly one account of that kind exists. Omit and pup lists the accounts and asks (google_account_choice_required).',
        'submitReason': 'str, 10+ chars: required before pup sends a saved password (why this account, for what task)',
        'continue': 'optional str: where to land after sign-in (default: that account\'s Gmail)',
        'window': 'optional str: the pup window to use (a new tab in it); default the calling thread\'s window',
        'code': 'optional str: finish a verification step with the code Google sent (with window)',
        'challenge': 'optional str: pick a verification option by its words, e.g. "text message" (with window)',
        'refresh': 'optional bool: redo the account search first',
    }
    _gs['example'] = { 'account': '[email protected]', 'submitReason': 'the task is the work calendar, so the work account' }
    _gs['hint'] = "Already signed into pup: no password is sent, the window just opens the account. Otherwise pup fills the email and the password saved for exactly that account and submits. If Google asks for a second step the answer is google_verification_required with the options or the code field, and the exact call to finish it; a text code can be read by a thread with the user's standing permission to read their texts. google_password_missing names where the account is signed in and how to import its password (pup_import_browser_logins {hostFilter:\"google.com\"}). The account is confirmed by name at the end: Gmail silently opens another signed-in account when the requested one is not signed in."
    _gs['related'] = ['pup_google_accounts', 'pup_login', 'pup_import_browser_logins']
    _gs['pitfalls'] = ["Do not restart a sign-in that stopped on a verification step: that sends the password again and a new code. Finish it with {window, code} or {window, challenge}.", "Never ask the user to type their password to you: import it or let them sign in once in the pup window."]
    _verbs.append(_gs)
if 'pup_list_sessions' not in _have:
    _mw = next((v for v in _verbs if v['name'] == 'pup_my_windows'), None)
    if _mw:
        _al = dict(_mw)
        _al['name'] = 'pup_list_sessions'
        _al['summary'] = 'Alias of pup_my_windows: list the sessions owned by the calling ai-thread. Passive, never launches a browser.'
        _verbs.append(_al)
        _have.add('pup_list_sessions')
# pup_close_all_windows: same #108 class, aliased onto close_window's {all:true} path in 2.0.553.
if 'pup_close_all_windows' not in _have:
    _cw = next((v for v in _verbs if v['name'] == 'pup_close_window'), None)
    if _cw:
        _al2 = dict(_cw)
        _al2['name'] = 'pup_close_all_windows'
        _al2['summary'] = "Close every pup window owned by the calling ai-thread (pup_close_window with all:true). Other threads' windows are never touched."
        _verbs.append(_al2)
        _have.add('pup_close_all_windows')
CATALOG_EXTRA = ['pup_assisted_click', 'pup_import_scan', 'pup_import_browser_logins', 'pup_NATIVE_INPUT_WARNING', 'pup_eval_frame_note', 'pup_vault_sync', 'pup_vault_export', 'pup_vault_import']
_existing = {}
try:
    _cur = json.load(open(os.path.join(root, 'rust', 'verbs', 'assets', 'verb-catalog.json')))
    _existing = {v['name']: v for v in _cur.get('verbs', [])}
except Exception: pass
for _n in CATALOG_EXTRA:
    if _n not in _have and _n in _existing: _verbs.append(_existing[_n])
# 2.0.672 (pup-bridge#124): ONE source for the verb surface. Every verb the Rust dispatch routes (PHASE1_VERBS in
# rust/verbs/src/lib.rs) must have exactly one catalog entry, and bridge.json's `verbs` is written from the same list,
# so `adom-bridge commands` (the manifest) and pup_describe can never disagree again (55 vs 85 on 2026-09-22). A
# hand-written entry already in the catalog is kept for any routed verb; a routed verb with no entry anywhere stops
# the build. The two advisory notes that used to sit in the verb list move to `notes` (they are not callable).
import re as _re
_lib = open(os.path.join(root, 'rust', 'verbs', 'src', 'lib.rs')).read()
_routed = list(dict.fromkeys(_re.findall(r'"([a-z_]+)"', _re.search(r'PHASE1_VERBS: &\[&str\] = &\[(.*?)\];', _lib, _re.S).group(1))))
_have = {v['name'] for v in _verbs}
for _n in _routed:
    if _n not in _have and _n in _existing: _verbs.append(_existing[_n]); _have.add(_n)
def _entry(name, summary, inp, example, hint, related, timeout=30, like=None):
    base = json.loads(json.dumps(next((v for v in _verbs if v['name'] == like), {}))) if like else {}
    base.update({'name': name, 'summary': summary, 'input': inp, 'example': example, 'hint': hint, 'related': related, 'timeoutSeconds': timeout})
    base.setdefault('output', {}); base.setdefault('pitfalls', []); base.setdefault('longRunning', False); base.setdefault('statusVerb', 'pup_readiness')
    return base
_STUBS = {
    'pup_eval': lambda: dict(_entry('pup_eval', 'Alias of pup_evaluate: evaluate JS in the page and return the result. Add frame:"<name|url-substring|index>" to run inside an iframe.', {}, {}, '', ['pup_evaluate'], like='pup_evaluate'), summary='Alias of pup_evaluate: evaluate JS in the page and return the result. Add frame:"<name|url-substring|index>" to run inside an iframe.'),
    'pup_focus_window': lambda: dict(_entry('pup_focus_window', '', {}, {}, '', ['pup_raise_os_window'], like='pup_raise_os_window'), summary="Alias of pup_raise_os_window (foregroundReason required). It is also what a pup taskbar button's relaunch command calls when the user clicks it; a human caller gets no warning toast."),
    'pup_dialog': lambda: _entry('pup_dialog', 'Answer a JavaScript dialog (alert, confirm, prompt, beforeunload) on the page: accept or dismiss, with promptText for a prompt.', {'action': 'accept | dismiss (default accept)', 'promptText': 'optional str: the answer typed into a prompt()', 'window': 'optional: the pup window (default the calling thread\'s)'}, {'action': 'accept'}, 'A dialog blocks every other verb on that tab until it is answered. pup_errors and the verb that tripped it name the open dialog.', ['pup_errors', 'pup_click']),
    'pup_clear_cookies': lambda: _entry('pup_clear_cookies', "Delete cookies in the window's profile for one site (domain or origin), optionally only the named cookies.", {'domain': 'str, e.g. example.com (or origin / origins)', 'origin': 'optional str', 'origins': 'optional [str]', 'names': 'optional [str]: only these cookie names'}, {'domain': 'example.com'}, "Clears the SHARED profile's cookies for that site: every pup window on the profile is signed out of it. Use it to test a logged-out flow, not as a reset.", ['pup_clear_site_data', 'pup_credentials']),
    'pup_clear_site_data': lambda: _entry('pup_clear_site_data', 'Clear storage for a site (cookies, localStorage, IndexedDB, cache) in the window profile.', {'origin': 'str: https://example.com (or origins)', 'origins': 'optional [str]', 'all': 'optional bool: every origin in the profile', 'everyone': 'optional bool: required with all:true because it signs every pup window out of everything'}, {'origin': 'https://example.com'}, 'all:true wipes the whole shared profile and is refused without everyone:true.', ['pup_clear_cookies']),
    'pup_restart_browser': lambda: _entry('pup_restart_browser', "Restart pup's browser for a profile; every window on it comes back at its page, in the background.", {'profile': 'optional str (default adom-you)'}, {}, 'Use it when a lane is wedged. Windows are relaunched from their saved state; in-page drafts are lost.', ['pup_quit_idle_browsers', 'pup_readiness'], timeout=90),
    'pup_quit_idle_browsers': lambda: _entry('pup_quit_idle_browsers', 'Quit every pup browser that has no windows (so the next open loads freshly staged extensions).', {}, {}, 'A browser with live windows is never touched.', ['pup_restart_browser']),
    'pup_highfps_extend': lambda: _entry('pup_highfps_extend', 'Renew the highFps lease on a window that must keep rendering while in the background.', {'minutes': 'int, max 120', 'window': "optional: the pup window (default the calling thread's)"}, {'minutes': 20}, 'The lease auto-expires so a background window can never drain the battery; renew only while you still need it.', ['pup_open_window']),
    'pup_audit_icons': lambda: _entry('pup_audit_icons', "Read back each pup window's taskbar identity from Windows and compare it with what pup stamped.", {}, {}, 'Detector only: nothing is re-stamped.', ['pup_set_identity', 'pup_sweep_identities']),
    'pup_cache_prune': lambda: _entry('pup_cache_prune', "Delete superseded browser builds and leftover partial downloads from pup's browser download cache, keeping the build pup drives and anything in use.", {'dryRun': 'optional bool: report what would go, delete nothing'}, {'dryRun': True}, 'pup runs this a minute after every boot (2.0.677). Reports removed (path, MB), kept (with why) and freedMB.', ['pup_readiness', 'pup_reap_profiles']),
    'pup_sweep_identities': lambda: _entry('pup_sweep_identities', 'Unregister every pup taskbar identity (registry key and any Start Menu anchor) that no live window holds, and delete stale tile/icon generations.', {}, {}, 'pup runs this a minute after every boot and releases a window identity on close (2.0.666+); on demand it is for a box that ran older builds. Returns unregistered, shortcutsRemoved, iconDirsRemoved.', ['pup_audit_icons']),
    'credential_list': lambda: dict(_entry('credential_list', '', {}, {}, '', ['pup_credentials'], like='pup_credentials'), summary='Alias of pup_credentials: list saved logins (host and username only, never passwords).'),
    'credential_set': lambda: _entry('credential_set', "Store a login in pup's vault for a host.", {'host': 'str', 'username': 'str', 'password': 'str (never echoed or logged)'}, {'host': 'example.com', 'username': '[email protected]', 'password': '<secret>'}, 'Matched across the site\'s whole registrable domain on return visits.', ['pup_credentials', 'credential_delete', 'pup_login']),
    'credential_delete': lambda: _entry('credential_delete', "Remove a saved login from pup's vault.", {'host': 'str', 'username': 'optional str: only this account'}, {'host': 'example.com'}, 'Removes the saved password from the vault only; a session the profile is already signed into stays signed in (pup_clear_cookies ends that).', ['pup_credentials', 'credential_set']),
    'pup_launch': lambda: dict(_entry('pup_launch', '', {}, {}, '', ['pup_open_window'], like='pup_open_window'), summary='Alias of pup_open_window (url defaults to about:blank).'),
}
# 2.0.687: entries whose text came from the Node build but whose Rust behavior differs. An AI reads these as the
# contract, so they must describe what the exe does (pup_uninstall took confirm:"uninstall" and offered a dry run in
# Node; the Rust verb takes confirm:true and has no dry run).
_OVERRIDES = {
    'pup_import_browser_logins': {
        'summary': "Seed pup's vault from the user's OWN saved browser passwords (or, with sessionFor, one site's signed-in session), so pup can sign in as them on sites they already use. Through nb, the browser exports its own store and the user answers one Windows Hello; where nb cannot run (not installed, too old, no extension connected) pup falls back to Adom Bridge's decrypt helper (a consent and a Windows admin prompt; Edge yes, Chrome 156+ no). Returns COUNTS ONLY; values are never logged or returned.",
        'input': {'hostFilter': 'optional str or str[]: only import logins whose host contains one of these', 'sessionFor': 'optional str: one site (e.g. "x.com") whose signed-in session to bring over instead of passwords', 'profile': 'optional str: the browser profile nb reads, as nbrowser_profiles lists it (e.g. "chrome:[email protected]"); default the active one', 'browsers': 'optional str[] (helper fallback only): subset of ["chrome","edge","brave"]', 'sourceProfile': 'optional str (helper fallback, sessionFor): which "<browser> / <profile>" to take when several have a session', 'reason': 'optional str: shown to the user with the prompt'},
        'hint': "Answers consent_pending while the user approves; call again with the SAME arguments to collect the result (no second prompt). The answer says via:\"nb\" or via:\"ab-helper\", and which profile was read. no_data means that profile has no saved passwords: pass profile:\"<key from nbrowser_profiles>\" for another.",
    },
    'pup_uninstall': {
        'summary': "DESTRUCTIVE: remove pup's on-disk footprint from this machine: every pup profile and saved window, the staged extensions, the taskbar icons and tiles, and every taskbar identity pup registered. Pass confirm:true. The saved-login vault is kept (credential_delete removes logins one by one), and Adom Bridge's bridge_uninstall removes the pup program itself.",
        'input': {'confirm': 'required bool true: actually remove'},
        'example': {'confirm': True},
        'hint': 'There is no dry run: list what you would lose first with pup_status (profiles, windows) and pup_credentials.',
    },
    'pup_reap_profiles': {
        'summary': "Delete pup profiles nobody uses: by default every profile idle for staleDays (14) that is not protected (adom-you, adom-wiki-authed, pup-dashboard, _recorder), not open in a browser, and not needed to restore a saved window; with isoOnly:true only leaked throwaway iso-* profiles. dryRun:true lists without deleting. pup's own boot pass runs the isoOnly form.",
        'input': {'dryRun': 'optional bool: list, delete nothing', 'staleDays': 'optional int, default 14', 'isoOnly': 'optional bool: only throwaway iso-* profiles', 'all': 'optional bool: ignore the idle age (still never a protected, open or needed profile)'},
        'example': {'dryRun': True},
    },
}
for _v in _verbs:
    if _v['name'] in _OVERRIDES: _v.update(_OVERRIDES[_v['name']])
_missing = []
for _n in _routed:
    if _n in _have: continue
    if _n in _STUBS: _verbs.append(_STUBS[_n]()); _have.add(_n)
    else: _missing.append(_n)
if _missing: raise SystemExit(f"VERB SURFACE: routed but no catalog entry: {_missing}. Add each to _STUBS in tools/gen-assets.py (pup-bridge#124).")
_notes = [v for v in _verbs if v['name'] not in _routed] + [v for v in _existing.values() if v['name'] in ('pup_NATIVE_INPUT_WARNING', 'pup_eval_frame_note') and v['name'] not in _have]
_seen = set(); _verbs = [v for v in _verbs if v['name'] in _routed and not (v['name'] in _seen or _seen.add(v['name']))]
_order = {n: i for i, n in enumerate(_routed)}; _verbs.sort(key=lambda v: _order[v['name']])
json.dump({'progressContract': _o['progressContract'], 'verbs': _verbs, 'notes': [{'name': v['name'], 'summary': v.get('summary') or v.get('hint') or ''} for v in _notes]}, open(os.path.join(root, 'rust', 'verbs', 'assets', 'verb-catalog.json'), 'w'), indent=1)
_bjp = os.path.join(root, 'src', 'bridge.json'); _bj = json.load(open(_bjp))
_bj['verbs'] = [v['name'] for v in _verbs]
open(_bjp, 'w').write(json.dumps(_bj, indent=2) + '\n')
print(f"verb-catalog.json: {len(_verbs)} verbs, {len(_notes)} notes; bridge.json verbs: {len(_bj['verbs'])} (one list)")