diff --git a/.gitignore b/.gitignore index b542c6444..3cf59ccdc 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,11 @@ celerybeat.pid # Environments .env +# Every .env variant, not just the bare name. A `.env.bak-telegram` written by a +# config switcher carried a live OpenRouter key into a commit, and GitHub push +# protection is what caught it â the bare pattern below did not. +.env.* +*.bak-telegram .venv env/ venv/ @@ -195,3 +200,9 @@ agents/**/learnings.md agents/**/sessions/ agents/**/dry_runs/ agents/**/delegations/ + +# Working notes on Gateway defects, edited by more than one session at a time. +# Tracking it made every `git add -A` sweep whichever session was mid-edit into the other +# one's commit â which is how 1529d04 came to carry a message describing work it does not +# contain. Kept on disk, out of the index. +GATEWAY_ISSUES.md diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index dfb781a95..fede53c04 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -53,6 +53,28 @@ async def execute( ) running = [e for e in perf.executors if e["status"] == "RUNNING"] + # A terminal executor that still reports an on-chain position address has + # stranded live exposure with no automated owner: an involuntary hold + # (POSITION_HOLD with hold_reason set â an LP close that exhausted its + # retries) or a legacy FAILED-with-position. It would otherwise vanish + # from this RUNNING-only summary â surface it until it is recovered. + orphaned = [ + e + for e in perf.executors + if e["status"] != "RUNNING" + and not (e.get("custom_info") or {}).get("orphan_resolved") + and ( + (e.get("custom_info") or {}).get("orphaned_position") + or ( + (e.get("custom_info") or {}).get("hold_reason") + and (e.get("custom_info") or {}).get("position_address") + ) + or ( + str(e.get("close_type") or "").upper() == "FAILED" + and (e.get("custom_info") or {}).get("position_address") + ) + ) + ] lines = [ ( f"Active Executors ({len(running)}) [agent: {agent_id}]:" @@ -65,6 +87,20 @@ async def execute( lines.append( f" {r['pair']} {side} ${r['pnl']:+.2f} (V:${r['volume']:,.0f})" ) + for o in orphaned: + pos = (o.get("custom_info") or {}).get("position_address") + reason = (o.get("custom_info") or {}).get("hold_reason") or "close failed" + lines.append( + f" đ¨ ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " + f"({reason}) with position {pos} still open on-chain. Stopping the executor " + "will NOT close it â it has already terminated. Close it with " + 'manage_clmm(action="close", position_address=..., pool_address=...); ' + 'manage_executors(action="orphaned") reports the dex, pool and network for ' + "the call, and pool_address is required. A new lp_executor CANNOT adopt the " + "position and would mint a second one. Then mark it recovered with " + f"manage_executors(action=\"resolve_orphan\", executor_id=\"{o['id']}\"). " + "Do not open new positions on these funds first." + ) if perf.bot_names: lines.append(f" Bots operated: {', '.join(perf.bot_names)}") lines.append( @@ -90,6 +126,7 @@ async def execute( name=self.name, data={ "executors": running, + "orphaned_executors": orphaned, "all_executors": perf.executors, "total_pnl": perf.total_pnl, "realized_pnl": perf.realized_pnl, diff --git a/condor/agents/risk.py b/condor/agents/risk.py index c7d941b55..2e77f1ad6 100644 --- a/condor/agents/risk.py +++ b/condor/agents/risk.py @@ -263,25 +263,25 @@ async def callback(tool_call: dict, options: list[dict]) -> dict: log.warning("Blocked %s: tool arguments could not be read", tool_name) return {"outcome": {"outcome": "cancelled"}} - # Dry-run mode: block ALL mutating actions + # Dry-run mode: block ALL mutating actions. + # + # Everything here is already inside `is_dangerous_tool_call`, which + # did the per-action matching against DANGEROUS_*_ACTIONS. Repeating + # it per tool was not just redundant, it reopened SEC-093: that gate + # fails CLOSED on an unreadable action, so `manage_clmm` with a + # missing, null or non-string `action` arrives here as dangerous â + # and the re-check then read it as "" , matched no set, fell through + # every branch and hit the auto-approve tail. A malformed write + # executed for real in the one mode whose whole promise is that + # nothing does. Reaching this line is the decision; blocking is + # unconditional. if execution_mode == "dry_run": - if tool_name == "manage_executors": - action = input_data.get("action", "") - if action in ("create", "stop"): - log.info("Dry-run mode: blocked manage_executors(%s)", action) - return {"outcome": {"outcome": "cancelled"}} - elif tool_name == "manage_bots": - action = input_data.get("action", "") - if action in DANGEROUS_BOT_ACTIONS: - log.info("Dry-run mode: blocked manage_bots(%s)", action) - return {"outcome": {"outcome": "cancelled"}} - elif tool_name in ( - "place_order", - "manage_gateway_swaps", - "manage_gateway_clmm", - ): - log.info("Dry-run mode: blocked %s", tool_name) - return {"outcome": {"outcome": "cancelled"}} + log.info( + "Dry-run mode: blocked %s(%s)", + tool_name, + input_data.get("action", "") or "?", + ) + return {"outcome": {"outcome": "cancelled"}} # For executor actions, run risk check if tool_name == "manage_executors": diff --git a/condor/preferences.py b/condor/preferences.py index f11cb425c..9e08d276f 100644 --- a/condor/preferences.py +++ b/condor/preferences.py @@ -117,7 +117,8 @@ def _load_from_cm(user_data: Dict) -> None: DEFAULT_DEX_NETWORK = "solana-mainnet-beta" DEFAULT_DEX_CONNECTOR = "jupiter" DEFAULT_DEX_PAIR = "SOL-USDC" -DEFAULT_DEX_SLIPPAGE = "1.0" +# No slippage default on purpose: an unset slippage is OMITTED from the Gateway request so the +# connector's own configured slippage applies. An explicit value (including "0") is a real value. DEFAULT_DEX_SIDE = "BUY" DEFAULT_DEX_AMOUNT = "1.0" @@ -342,7 +343,6 @@ def _get_default_preferences() -> UserPreferences: "dex": { "default_network": DEFAULT_DEX_NETWORK, "default_connector": DEFAULT_DEX_CONNECTOR, - "default_slippage": DEFAULT_DEX_SLIPPAGE, "last_swap": {}, "last_pool": {}, }, @@ -635,6 +635,19 @@ def get_dex_connector(user_data: Dict, network: Optional[str] = None) -> str: return get_dex_prefs(user_data).get("default_connector", DEFAULT_DEX_CONNECTOR) +def get_dex_slippage(user_data: Dict) -> Optional[str]: + """Get the user's DEX slippage override, or None to use the connector's own setting""" + return get_dex_prefs(user_data).get("default_slippage") + + +def set_dex_slippage(user_data: Dict, slippage: str) -> None: + """Set default DEX slippage percentage""" + prefs = _ensure_preferences(user_data) + prefs["dex"]["default_slippage"] = slippage + _sync_section_to_cm(user_data, "dex") + logger.info(f"Set DEX slippage to {slippage}%") + + def get_dex_last_swap(user_data: Dict) -> DEXSwapParams: """Get last DEX swap parameters""" return deepcopy(get_dex_prefs(user_data).get("last_swap", {})) @@ -667,6 +680,9 @@ def get_dex_swap_defaults(user_data: Dict) -> DEXSwapParams: 1. System defaults 2. User's configured defaults 3. Last swap params (highest priority) + + "slippage" is present ONLY when the user set one. Absent means "omit slippage_pct from the + Gateway request" so the connector's configured slippage applies. """ prefs = get_dex_prefs(user_data) last_swap = prefs.get("last_swap", {}) @@ -675,17 +691,20 @@ def get_dex_swap_defaults(user_data: Dict) -> DEXSwapParams: "network", prefs.get("default_network", DEFAULT_DEX_NETWORK) ) - return { + defaults: DEXSwapParams = { "connector": last_swap.get("connector", get_dex_connector(user_data, network)), "network": network, "trading_pair": last_swap.get("trading_pair", DEFAULT_DEX_PAIR), "side": DEFAULT_DEX_SIDE, "amount": DEFAULT_DEX_AMOUNT, - "slippage": last_swap.get( - "slippage", prefs.get("default_slippage", DEFAULT_DEX_SLIPPAGE) - ), } + slippage = last_swap.get("slippage", prefs.get("default_slippage")) + if slippage is not None: + defaults["slippage"] = slippage + + return defaults + # ============================================ # PUBLIC API - GENERAL diff --git a/condor/runtime/danger.py b/condor/runtime/danger.py index fc2a7540a..840de9635 100644 --- a/condor/runtime/danger.py +++ b/condor/runtime/danger.py @@ -21,7 +21,9 @@ DANGEROUS_TOOLS = { "place_order", "manage_gateway_swaps", # execute action - "manage_gateway_clmm", # open/close position + "manage_clmm", # every action that moves liquidity + "manage_amm", # every action that moves liquidity + "manage_gateway_config", # only the wallets resource; see below } # Tools that are always blocked (RBAC bypass prevention) @@ -45,8 +47,29 @@ # Actions within manage_gateway_swaps that require confirmation DANGEROUS_SWAP_ACTIONS = {"execute"} -# Actions within manage_gateway_clmm that require confirmation -DANGEROUS_CLMM_ACTIONS = {"open_position", "close_position"} +# Actions within manage_clmm that require confirmation. These are the tool's +# own action literals â a name that does not match one lets the call through +# ungated, so they are asserted against the registered tool in the tests. +DANGEROUS_CLMM_ACTIONS = { + "open", + "close", + "add_liquidity", + "remove_liquidity", + "collect_fees", + "create_pool", +} + +# Actions within manage_amm that require confirmation +DANGEROUS_AMM_ACTIONS = {"add_liquidity", "remove_liquidity", "create_pool"} + +# Resource types within manage_gateway_config that require confirmation. This tool +# is gated on `resource_type`, not `action`, because what it edits matters and how +# it edits does not: `wallets` + `add` takes a PRIVATE KEY, and `delete` removes a +# signing wallet. Everything else it touches â tokens, pools, connectors, networks â +# is Gateway's own symbol/address mapping. Deleting a token there moves no funds and +# changes nothing on-chain, so gating it would put a human in front of a config edit +# while the trades that edit enables stay where they are. +DANGEROUS_CONFIG_RESOURCES = {"wallets"} def tool_call_name(tool_call: dict[str, Any]) -> str: @@ -104,6 +127,24 @@ def _has_dangerous_action( return action in dangerous_actions +def _has_dangerous_resource( + tool_call: dict[str, Any], dangerous_resources: set[str] +) -> bool: + """Whether a resource-gated tool call selects one of its dangerous resources. + + The resource-typed twin of :func:`_has_dangerous_action`, and it fails closed the + same way (SEC-093): unreadable arguments, or a missing/non-string ``resource_type``, + count as dangerous. + """ + input_data = tool_call_input(tool_call) + if input_data is None: + return True + resource = input_data.get("resource_type") + if not isinstance(resource, str) or not resource: + return True + return resource in dangerous_resources + + def is_dangerous_tool_call(tool_call: dict[str, Any]) -> bool: """Check if a tool call requires user confirmation.""" tool_name = tool_call_name(tool_call) @@ -114,10 +155,16 @@ def is_dangerous_tool_call(tool_call: dict[str, Any]) -> bool: if tool_name == "manage_gateway_swaps": return _has_dangerous_action(tool_call, DANGEROUS_SWAP_ACTIONS) - # For manage_gateway_clmm, only open/close are dangerous - if tool_name == "manage_gateway_clmm": + # For the LP tools, only the actions that move liquidity are dangerous + if tool_name == "manage_clmm": return _has_dangerous_action(tool_call, DANGEROUS_CLMM_ACTIONS) + if tool_name == "manage_amm": + return _has_dangerous_action(tool_call, DANGEROUS_AMM_ACTIONS) + + if tool_name == "manage_gateway_config": + return _has_dangerous_resource(tool_call, DANGEROUS_CONFIG_RESOURCES) + return True # manage_executors with create/stop actions @@ -186,13 +233,47 @@ def format_tool_summary(tool_call: dict[str, Any]) -> str: amount = input_data.get("amount", "?") return f"Swap {side} {amount} {pair}" - if tool_name == "manage_gateway_clmm": + if tool_name == "manage_gateway_config": + resource = input_data.get("resource_type", "?") + action = input_data.get("action", "?") + if resource == "wallets": + if action == "add": + chain = input_data.get("chain", "?") + return f"Import a {chain} wallet into Gateway (private key)" + if action == "delete": + addr = str(input_data.get("wallet_address") or "?") + return f"Remove wallet {addr[:12]}... from Gateway" + return f"Gateway config: {action} {resource}" + + if tool_name in ("manage_clmm", "manage_amm"): action = input_data.get("action", "?") - if action == "open_position": - return "Open LP position" - if action == "close_position": - return "Close LP position" - return f"CLMM: {action}" + kind = "CLMM" if tool_name == "manage_clmm" else "AMM" + connector = input_data.get("connector", "?") + pool = ( + input_data.get("pool_address") or input_data.get("position_address") or "?" + ) + if action == "open": + lower = input_data.get("lower_price", "?") + upper = input_data.get("upper_price", "?") + return ( + f"Open {kind} position on {connector} pool {pool} over {lower}-{upper}" + ) + if action == "close": + return f"Close {kind} position {pool} on {connector}" + if action == "add_liquidity": + base = input_data.get("base_token_amount", "?") + quote = input_data.get("quote_token_amount", "?") + return f"Add {base} base / {quote} quote to {kind} {pool} on {connector}" + if action == "remove_liquidity": + pct = input_data.get("percentage_to_remove", "?") + return f"Remove {pct}% from {kind} position {pool} on {connector}" + if action == "collect_fees": + return f"Collect fees from {kind} position {pool} on {connector}" + if action == "create_pool": + base = input_data.get("base_token", "?") + quote = input_data.get("quote_token", "?") + return f"Create {kind} pool {base}-{quote} on {connector}" + return f"{kind}: {action}" # Generic fallback return tool_name diff --git a/frontend/index.html b/frontend/index.html index f0b623304..d4ddeefcd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,8 +8,6 @@
- -