diff --git a/setup.py b/setup.py index 04e100e0e..4115318f1 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,7 @@ 'cbor2>=5.6.2', 'multidict>=6.0.5', 'ordered-set>=4.1.0', - 'hio>=0.6.14', + 'hio==0.6.14', 'multicommand>=1.0.0', 'jsonschema>=4.21.1', 'falcon>=3.1.3', diff --git a/src/keri/app/cli/commands/delegate/confirm.py b/src/keri/app/cli/commands/delegate/confirm.py index 25eb27764..5d640234d 100644 --- a/src/keri/app/cli/commands/delegate/confirm.py +++ b/src/keri/app/cli/commands/delegate/confirm.py @@ -121,12 +121,10 @@ def confirmDo(self, tymth, tock=0.0): if ilk in (coring.Ilks.dip,): typ = "inception" delpre = eserder.sad["di"] - elif ilk in (coring.Ilks.drt,): typ = "rotation" dkever = self.hby.kevers[eserder.pre] delpre = dkever.delpre - else: continue @@ -170,11 +168,11 @@ def confirmDo(self, tymth, tock=0.0): saider = self.hby.db.cgms.get(keys=(prefixer.qb64, sner.qb64)) if saider is not None: break - yield self.tock - print(f"Delegate {eserder.pre} {typ} event committed.") + print(f"Delegate {typ} event {eserder.pre} committed.") + self.hby.db.delegables.rem(keys=(pre, sn), val=edig) self.remove(self.toRemove) return True @@ -211,27 +209,23 @@ def confirmDo(self, tymth, tock=0.0): while not witDoer.cues: _ = yield self.tock - print(f'Delegagtor Prefix {hab.pre}') - print(f'\tDelegate {eserder.pre} {typ} Anchored at Seq. No. {hab.kever.sner.num}') + print(f'Delegagtor Prefix {hab.pre}') + print(f'\tDelegate {typ} event {eserder.pre} Anchored at Seq. No. {hab.kever.sner.num}') # wait for confirmation of fully commited event if eserder.pre in self.hby.kevers: self.witq.query(src=hab.pre, pre=eserder.pre, sn=eserder.sn) - while eserder.sn < self.hby.kevers[eserder.pre].sn: yield self.tock - - print(f"Delegate {eserder.pre} {typ} event committed.") else: # It should be an inception event then... wits = [werfer.qb64 for werfer in eserder.berfers] self.witq.query(src=hab.pre, pre=eserder.pre, sn=eserder.sn, wits=wits) - while eserder.pre not in self.hby.kevers: yield self.tock - print(f"Delegate {eserder.pre} {typ} event committed.") + print(f"Delegate {typ} event {eserder.pre} committed.") - self.hby.db.delegables.rem(keys=(pre, sn)) + self.hby.db.delegables.rem(keys=(pre, sn), val=edig) self.remove(self.toRemove) return True diff --git a/src/keri/app/habbing.py b/src/keri/app/habbing.py index 7d746434d..2948541c4 100644 --- a/src/keri/app/habbing.py +++ b/src/keri/app/habbing.py @@ -102,7 +102,7 @@ def openHab(name="test", base="", salt=None, temp=True, cf=None, **kwa): with openHby(name=name, base=base, salt=salt, temp=temp, cf=cf) as hby: if (hab := hby.habByName(name)) is None: - hab = hby.makeHab(name=name, icount=1, isith='1', ncount=1, nsith='1', **kwa) + hab = hby.makeHab(name=name, icount=1, isith='1', ncount=1, nsith='1', cf=cf, **kwa) yield hby, hab diff --git a/src/keri/core/eventing.py b/src/keri/core/eventing.py index 93c88c432..96f5efcf9 100644 --- a/src/keri/core/eventing.py +++ b/src/keri/core/eventing.py @@ -1682,27 +1682,58 @@ def locallyOwned(self, pre: str | None = None): def locallyDelegated(self, pre: str): - """Returns True if pre is in .prefixes and not in .groups - False otherwise. Use when pre is a delegator for some event and - want to confirm that pre is also locallyOwned thereby making the - associated event locallyDelegated. + """Returns True if pre w is in .prefixes which includes group AIDs in + self.groups which have a local member AID. + + Which means it is either locally controlled single sig or a multi-sig + group with a locally controlled member. + False otherwise. + + Use when pre is a delegator, i.e. the delpre from some delegated event and + want to confirm that pre is also locally controller as either the single + sig AID or the group multisig AID of a locally controlled member of the group. Indicates that provided identifier prefix is controlled by a local - controller from .prefixes but is not a group with local member. - i.e pre is a locally owned (controlled) AID (identifier prefix) + controller from .prefixes is a group prefix that is controlled by a local + member of that group. + Because delpre may be None, changes the default to "" instead of self.prefixer.pre because self.prefixer.pre is delegate not delegator of self. Unaccepted dip events do not have self.delpre set yet. Returns: - (bool): True if pre is local hab but not group hab + (bool): True if pre is local hab or group hab that has a local member When pre="" empty or None then returns False Parameters: pre (str): qb64 identifier prefix if any. + + + ToDo: this code does not account for stale group members as delegators. + i.e. a stale group membed is a member AID for a group AID in .groups + for which the member AID was a signing (smids) or rotating (rmids) member + in the past but is no longer. For delegation approval there must be + a local member for the delegator group AID that is a current signing member + i,e. in .smids for the group hab. + + The current logic allows an event to be escrowed for later approval + but whose delpre (delegator) is a group with a stale local member + That later approval must detect and properly handle the staleness. + + Alternatively the logic could be changed to short circut that later + work by checking here for staleness. For example: + delpre.mhab.pre in delpre's hab.smids (not stale ) + + + if pre in self.groups: # local group delegator + habord = self.db.habs.get(keys=(pre,)) + return habord.mid in habord.smids # True not stale, False stale + + return pre in self.prefixes # otherwise local non-group delegator + """ pre = pre if pre is not None else "" - return self.locallyOwned(pre=pre) + return pre in self.prefixes def locallyWitnessed(self, *, wits: list[str]=None, serder: (str)=None): @@ -2388,7 +2419,8 @@ def valSigsWigsDel(self, serder, sigers, verfers, tholder, # seal in this case can't be malicious since sourced locally. # Doesn't get to here until fully signed and witnessed. - if self.locallyDelegated(delpre) and not self.locallyOwned(): # local delegator + # should only run for delegated inception and rotation, not interaction. Ixn does not require approval. + if serder.ilk != Ilks.ixn and self.locallyDelegated(delpre) and not self.locallyOwned(): # local delegator # must be local if locallyDelegated or caught above as misfit if delseqner is None or delsaider is None: # missing delegation seal # so escrow delegable. So local delegator can approve OOB. diff --git a/tests/app/app_helpers.py b/tests/app/app_helpers.py new file mode 100644 index 000000000..a34ae5d67 --- /dev/null +++ b/tests/app/app_helpers.py @@ -0,0 +1,85 @@ +import json +from contextlib import contextmanager +from typing import List, Generator, Tuple, Any + +from hio.base import Doer, Doist + +from keri.app import habbing, delegating +from keri.app.agenting import WitnessReceiptor, Receiptor +from keri.app.configing import Configer +from keri.app.delegating import Anchorer +from keri.app.forwarding import Poster +from keri.app.habbing import openHab, HaberyDoer, Habery, Hab, openHby +from keri.app.indirecting import MailboxDirector, setupWitness +from keri.app.notifying import Notifier +from keri.core import Salter +from keri.peer.exchanging import Exchanger + + +@contextmanager +def openWit(name: str = 'wan', tcpPort: int = 6632, httpPort: int = 6642, salt: bytes = b'abcdefg0123456789') -> Generator[Tuple[Habery, Hab, List[Doer], str], None, None]: + """ + Context manager for a KERI witness along with the Doers needed to run it. + Expects the Doers to be run by the caller. + + Returns a tuple of (Habery, Hab, witness Doers, witness controller OOBI URL) + """ + salt = Salter(raw=salt).qb64 + # Witness config + witCfg = f"""{{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "{name}": {{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "curls": ["tcp://127.0.0.1:{tcpPort}/", "http://127.0.0.1:{httpPort}/"]}}}}""" + cf = Configer(name=name, temp=False, reopen=True, clear=False) + cf.put(json.loads(witCfg)) + with ( + openHab(salt=bytes(salt, 'utf-8'), name=name, transferable=False, temp=True, cf=cf) as (hby, hab) + ): + oobi = f'http://127.0.0.1:{httpPort}/oobi/{hab.pre}/controller?name={name}&tag=witness' + hbyDoer = HaberyDoer(habery=hby) + doers: List[Doer] = [hbyDoer] + doers.extend(setupWitness(alias=name, hby=hby, tcpPort=tcpPort, httpPort=httpPort)) + yield hby, hab, doers, oobi + + +@contextmanager +def openCtrlWited(witOobi: str, name: str = 'aceCtlrKS', salt: bytes = b'aaaaaaa0123456789') -> Generator[Tuple[Habery, List[Doer]], None, None]: + """ + Context manager for setting up a KERI controller that uses a witness as its mailbox and witness. + Sets up the Doers needed to run a controller including both single sig and multi-sig handlers. + Relies on an outer context manager or caller to perform OOBI resolution and inception of the controller AID. + Receives a witness OOBI URL to use as its configured witness. + + Expects the Doers to be run by the caller. + + Returns a tuple of (Habery, controller Doers) + """ + ctlrCfg = f"""{{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "iurls": [\"{witOobi}\"]}}""" + cf = Configer(name=name, temp=False, reopen=True, clear=False) + cf.put(json.loads(ctlrCfg)) + with openHby(salt=salt, name=name, temp=True, cf=cf) as hby: + hbyDoer = habbing.HaberyDoer(habery=hby) + anchorer = Anchorer(hby=hby, proxy=None) + postman = Poster(hby=hby) + exc = Exchanger(hby=hby, handlers=[]) + notifier = Notifier(hby=hby) + delegating.loadHandlers(hby=hby, exc=exc, notifier=notifier) + mbx = MailboxDirector(hby=hby, exc=exc, topics=['/receipt', '/replay', '/reply', '/delegate', '/multisig']) + witReceiptor = WitnessReceiptor(hby=hby) + receiptor = Receiptor(hby=hby) + doers = [hbyDoer, anchorer, postman, mbx, witReceiptor, receiptor] + yield hby, doers + +@contextmanager +def openCtrlWitIcpd( + doist: Doist, witOobi: str, witDoers: List[Doer], + name: str = 'aceCtlrKS', + salt: bytes = b'aaaaaaa0123456789', + alias='aceCtlrAIC'): + """ + Uses the Doist to perform both OOBI resolution of the witness and inception of the controller AID. + """ + pass \ No newline at end of file diff --git a/tests/app/cli/cli_helpers.py b/tests/app/cli/cli_helpers.py new file mode 100644 index 000000000..e3f72c60d --- /dev/null +++ b/tests/app/cli/cli_helpers.py @@ -0,0 +1,187 @@ +# -*- encoding: utf-8 -*- +""" +tests.app.cli.cli_helpers module + +""" +import threading +from hio.core.serial import serialing + +from hio.base import doing +from hio.help import Deck + + +class AskDoer(serialing.ConsoleDoer): + """ + Doer that prompts for input and echoes it back. + Inherits from ConsoleDoer to handle console open/close. + """ + + def recur(self, tyme, deeds=None): + """ + Process console input + """ + # ConsoleDoer.recur calls console.service() which is empty, but we can call it if needed. + # super(AskDoer, self).recur(tyme) + + line = self.console.get() # Read from console + if line: + msg = f"You said: {line.decode('utf-8')}\n" + self.console.put(msg.encode('utf-8')) + return True # Return True to abort/finish this Doer + + return False # Return False to continue + +# def test_ask_doer(): +# """ +# Sample test that uses AskDoer (extending ConsoleDoer) to receive and print input. +# """ +# print("\n\n****************************************************************") +# print("Please enter some text in the console (or press Enter if testing):") +# print("****************************************************************\n") +# +# console = serialing.Console() +# asker = AskDoer(console=console) +# +# # Run with a real-time Doist +# tock = 0.03125 +# doist = doing.Doist(tock=tock, real=True) +# +# # This will run until AskDoer receives input and returns True +# doist.do(doers=[asker]) +# +# print("\nTest Complete.") + +# def test_ask_doer_mocked(): +# with patch('hio.core.serial.serialing.Console') as MockConsole: +# mock_console = MockConsole.return_value +# +# mock_console.get.side_effect = [b'Hello, World!', None] # Simulate input +# +# asker = AskDoer(console=mock_console) +# doist = doing.Doist(tock=0.03125, real=True) +# doist.do(doers=[asker]) +# print("\nMocked Test Complete.") + +class PromptPrinterDoer(doing.Doer): + """ + Doer that prints lines received from a queue. + """ + def __init__(self, cmds: Deck, **kwa): + super(PromptPrinterDoer, self).__init__(**kwa) + self.cmds = cmds if cmds is not None else Deck() + + def recur(self, tyme, deeds=None): + """ + Check the queue for input and print it. + """ + while self.cmds: + cmd = self.cmds.pop() + print(f"Received cmd: {cmd}") + return False # Continue running + + +# ------------------------------------------------------------------------------ +# Prompt Toolkit Integration Example +# ------------------------------------------------------------------------------ + +class PromptToolkitDoer(doing.Doer): + """ + A Doer that runs prompt_toolkit in a separate thread to avoid blocking the hio loop. + """ + def __init__(self, ins: Deck, cmds: Deck, outs: Deck, **kwa): + super(PromptToolkitDoer, self).__init__(**kwa) + self.ins = ins if ins is not None else Deck() + self.cmds = cmds if cmds is not None else Deck() + self.outs = outs if outs is not None else Deck() + self.thread = None + self.stop_event = threading.Event() + self.thread_error = None # To propagate exceptions from thread + + def _prompt_thread(self): + """ + Thread that runs the blocking prompt_toolkit session. + """ + try: + # Import here to avoid hard dependency if not installed + from prompt_toolkit import PromptSession + session = PromptSession() + + print("\nkREPL started. Ctrl-D or 'exit' to quit") + + while not self.stop_event.is_set(): + try: + # blocks here, but it's okay because we are in a thread + text = session.prompt('> ') + self.ins.push(text) + if text.strip().lower() == 'exit': + break + except (EOFError, KeyboardInterrupt): + self.ins.push('exit') + break + except Exception as ex: + self.thread_error = ex + + def enter(self, **kwa): + """ + Start the input thread on enter + """ + self.thread = threading.Thread(target=self._prompt_thread, daemon=True) + self.thread.start() + + def recur(self, tyme, deeds=None): + """ + Check the queue for input from the thread. + """ + # Check for thread errors first to propagate them immediately + if self.thread_error: + raise self.thread_error + + while self.ins: + cmd = self.ins.pop() + if cmd == 'exit': + self.stop_event.set() + return True # Stop the Doer + print(f"Processing command: {cmd}") + self.cmds.push(cmd) + + return False + + def exit(self): + """ + Cleanup + """ + self.stop_event.set() + print("PromptToolkitDoer exiting...") + # We can't easily kill the blocking prompt in the thread, but setting the event + # prevents the loop from restarting if it wakes up. + +def test_prompt_toolkit_integration(): + """ + Demonstrates using prompt_toolkit with hio + """ + try: + import prompt_toolkit + except ImportError: + print("Skipping test_prompt_toolkit_integration: prompt_toolkit not installed") + return + + print("\n\n****************************************************************") + print("kREPL - KERI Read-Eval-Print Loop using prompt_toolkit") + print("Includes history (Up/Down), and Ctrl-R search!") + print("****************************************************************\n") + + ins = Deck() + cmds = Deck() + outs = Deck() + printer = PromptPrinterDoer(cmds=outs) + prompter = PromptToolkitDoer(ins, cmds, outs) + doist = doing.Doist(tock=0.1, real=True) + doist.do(doers=[prompter, printer]) + print("\nPrompt Toolkit Test Complete.") + + + +if __name__ == "__main__": + # test_ask_doer_mocked() + # test_ask_doer() # Requires real console, commented out for auto-runs + test_prompt_toolkit_integration() # Uncomment to test if prompt_toolkit is installed diff --git a/tests/app/multisig_helpers.py b/tests/app/multisig_helpers.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/test_grouping.py b/tests/app/test_grouping.py index 39f8a1c4e..dad23927d 100644 --- a/tests/app/test_grouping.py +++ b/tests/app/test_grouping.py @@ -5,12 +5,15 @@ """ from contextlib import contextmanager +from hio.base import Doist + from keri import kering, core from keri.app import habbing, grouping, notifying from keri.core import coring, eventing, parsing, serdering from keri.vdr import eventing as veventing from keri.db import dbing from keri.peer import exchanging +from tests.app.app_helpers import openWit, openCtrlWited def test_counselor(): @@ -900,3 +903,12 @@ def test_multisig_interact_handler(mockHelpingNowUTC): prefixers = hby1.db.maids.get(keys=(esaid,)) assert len(prefixers) == 1 assert prefixers[0].qb64 == ghab2.mhab.pre + +def test_multisig_delegate(): + doist = Doist(limit=0.0, tock=0.03125, real=True) + with ( + openWit() as (witHby, witHab, witDoers, witOobi), + openCtrlWited(doist, witOobi) as (ctrlHby, ctrlHab, ctrlDoers, ctrlOobi) + ): + print(f"Witnesses: {[witHab.pre]}") + print(f"Controller: {ctrlHab.pre}") \ No newline at end of file