opencode icon indicating copy to clipboard operation
opencode copied to clipboard

Opencode Hangs when used as CLI tool

Open GalloDaSballo opened this issue 1 week ago • 8 comments

Description

First of all Opencode is goated, we love it.

We've been using it professionally.

We've frequently running into an issue, it seems that sometimes it hangs forever, we're thinking this happens when a request fails, but have no clear way to debug.

We store all opencode logs for debuggin and would be happy to provide more insight

OpenCode version

Latest

Steps to reproduce

Use opencode run as CLI and with Sonnet 4.5 [speculation] Opencode eventually asks for permissions outside of the folder Your CI/CD Project is dead

Operating System

Linux / Docker

Terminal

No response

GalloDaSballo avatar Dec 21 '25 07:12 GalloDaSballo

This issue might be a duplicate of existing issues. Please check:

  • #3213: Bug: opencode run and TUI exits hang on v0.15+ - Similar hanging behavior when using CLI with streaming output
  • #2940: [BUG] OpenCode just hangs randomly after receiving instructions - Matches the random hanging symptom
  • #731: Critical Stability Issues: App Freezing & Hanging Analysis - Comprehensive analysis of multiple hanging scenarios
  • #4255: OpenCode v1.0.25 Hangs Indefinitely with LM Studio + Qwen Models Due to Empty tool_calls Array - Hanging with specific model configurations
  • #3273: MCP client failing makes opencode hang - Potential root cause: failed MCP servers causing indefinite hangs

Feel free to ignore if none of these address your specific case.

github-actions[bot] avatar Dec 21 '25 07:12 github-actions[bot]

Can you share logs?

rekram1-node avatar Dec 21 '25 07:12 rekram1-node

Can you share logs?

Updated logs to a GH repo: opencode-stuck-container-logs-1766302508 Invite you from "GalloBurner2"

After more checks it seems that the issue is that Opencode asks us for permissions when using the CLI version, which we have no way to handle

GalloDaSballo avatar Dec 21 '25 07:12 GalloDaSballo

You can handle permissions using:

  • plugins
  • opencode config files

Every possible permission is configurable via config

If you need the permissions in place but want to answer them or if u want super granular controls you can define a plugin hook that runs when a permission is being asked, u can execute logic to determine what to do, you could even have it send u a dm and use your reaction to it as an approve or deny

You should be able to do basically anything using just those two?

Which permission hang was it? Was it external directory access? Easy to whitelist in ur config

rekram1-node avatar Dec 21 '25 08:12 rekram1-node

You can handle permissions using:

  • plugins
  • opencode config files

Every possible permission is configurable via config

If you need the permissions in place but want to answer them or if u want super granular controls you can define a plugin hook that runs when a permission is being asked, u can execute logic to determine what to do, you could even have it send u a dm and use your reaction to it as an approve or deny

You should be able to do basically anything using just those two?

Which permission hang was it? Was it external directory access? Easy to whitelist in ur config

I'm not sure.

Current we have this in our subagent definition:

---
description: "NAME"
mode: subagent
temperature: 0.1
tools:
  write: true
  edit: true
  read: true
  grep: true
  list: true
  glob: true
  todowrite: true
  todoread: true
permissions:
  write: true
  edit: true
  read: true
  grep: true
  list: true
  glob: true
  todowrite: true
  todoread: true
---

Whereas this is the content of the logs:

{"type":"tool_use","timestamp":1766256670432,"sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","part":{"id":"prt_b3d1878f30011QHwxBvv52ZAUB","sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","messageID":"msg_b3d186cb7001lIJS2WJhAKP3nf","type":"tool","callID":"toolu_vrtx_01DE26KaZpk2f9VBBwoMUYsY","tool":"bash","state":{"status":"completed","input":{"command":"cat > /tmp/analyzed_coverage.json << 'EOFMARKER'\n{\n  \"timestamp\": \"1766256537\",\n  \"lcov_file\": \"echidna/covered.1766256537.lcov\",\n  \"missing_coverage\": [\n    {\n      \"function\": \"_checkCanCall\",\n      \"contract\": \"AccessManaged\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/AccessManaged.sol\",\n      \"function_range\": {\n        \"start\": 95,\n        \"end\": 111\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"95-97\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"95:   function _checkCanCall(address caller, bytes calldata data) internal virtual {\",\n          \"96:     (bool immediate, uint32 delay) = AuthorityUtils.canCallWithDelay(\",\n          \"97:       authority(),\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, indicating that the entire function _checkCanCall is never being called. This function is invoked by the `restricted` modifier (line 56), which means no functions using the `restricted` modifier are being tested by the fuzzer. This is likely an unreachable state issue - the system doesn't have any target function handlers that call restricted functions in Hub or other AccessManaged contracts. To fix this, we need to add target function handlers that call restricted functions (e.g., admin functions on Hub like configureAsset, setInterestRateStrategy, etc.).\"\n    },\n    {\n      \"function\": \"_checkCanCall\",\n      \"contract\": \"AccessManaged\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/AccessManaged.sol\",\n      \"function_range\": {\n        \"start\": 95,\n        \"end\": 111\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"99-100\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"99:       address(this),\",\n          \"100:       bytes4(data[0:4])\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, indicating that the entire function _checkCanCall is never being called. This is the same root cause as the previous entry - no restricted functions are being invoked. Lines 99-100 are part of the call to AuthorityUtils.canCallWithDelay that would check permissions. To fix this, we need to add target function handlers that call restricted functions on AccessManaged contracts.\"\n    },\n    {\n      \"function\": \"_checkCanCall\",\n      \"contract\": \"AccessManaged\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/AccessManaged.sol\",\n      \"function_range\": {\n        \"start\": 95,\n        \"end\": 111\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"103-106\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"103:       if (delay > 0) {\",\n          \"104:         _consumingSchedule = true;\",\n          \"105:         IAccessManager(authority()).consumeScheduledOp(caller, data);\",\n          \"106:         _consumingSchedule = false;\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, indicating that the entire function _checkCanCall is never being called. Lines 103-106 handle the delayed execution path where a scheduled operation is consumed. Even if we add handlers for restricted functions, this specific branch requires that canCallWithDelay returns immediate=false and delay>0, which means the operation was scheduled but not immediately executable. This requires setting up scheduled operations through the AccessManager, which is a complex state. To cover this, we would need handlers that: 1) schedule operations via AccessManager.schedule(), 2) wait for the delay, and 3) execute the scheduled operation.\"\n    },\n    {\n      \"function\": \"_checkCanCall\",\n      \"contract\": \"AccessManaged\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/AccessManaged.sol\",\n      \"function_range\": {\n        \"start\": 95,\n        \"end\": 111\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"108\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"108:         revert AccessManagedUnauthorized(caller);\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, indicating that the entire function _checkCanCall is never being called. Line 108 is the revert that occurs when immediate=false and delay==0, meaning the caller is not authorized at all (neither immediate access nor scheduled access). To reach this line, we need to: 1) have handlers that call restricted functions, and 2) ensure the fuzzer sometimes calls these with unauthorized callers. The second part should happen naturally once we add the handlers.\"\n    },\n    {\n      \"function\": \"_setAuthority\",\n      \"contract\": \"AccessManaged\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/AccessManaged.sol\",\n      \"function_range\": {\n        \"start\": 86,\n        \"end\": 89\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"86-87\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"86:   function _setAuthority(address newAuthority) internal virtual {\",\n          \"87:     _authority = newAuthority;\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, indicating this function is never called after contract initialization. The _setAuthority function is called in the constructor (line 28) during initialization, but those lines may not be tracked by coverage tools. It's also called by the public setAuthority function (line 74). To cover these lines, we need to add a target function handler that calls setAuthority on AccessManaged contracts (like Hub or Spoke). However, setAuthority itself requires the caller to be the current authority (line 68), so we'd need to clamp the caller to be the authority address.\"\n    },\n    {\n      \"function\": \"reclaim\",\n      \"contract\": \"Hub\",\n      \"source_file\": \"/app/repo/src/hub/Hub.sol\",\n      \"function_range\": {\n        \"start\": 444,\n        \"end\": 458\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"457\",\n        \"last_covered_line\": 455,\n        \"code\": [\n          \"455: [LAST COVERED]     IERC20(asset.underlying).safeTransferFrom(msg.sender, address(this), amount);\",\n          \"457:     emit Reclaim(assetId, msg.sender, amount);\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 455, which successfully executes safeTransferFrom, transferring tokens from msg.sender to the Hub contract. Line 457 is just an emit statement that should execute after the transfer. The fact that line 457 is uncovered while 455 is covered is unusual - emit statements are typically covered if the previous line executes successfully. This could be a coverage instrumentation quirk or the safeTransferFrom is reverting in a way that's not being detected properly. However, the most likely explanation is that the reclaim function itself is being called successfully (reaching line 455), but the coverage tool is not properly tracking the emit statement. This appears to be a false positive or coverage tool limitation rather than an actual coverage gap that needs fixing.\"\n    },\n    {\n      \"function\": \"rayDivDown\",\n      \"contract\": \"WadRayMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/WadRayMath.sol\",\n      \"function_range\": {\n        \"start\": 103,\n        \"end\": 112\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"107\",\n        \"last_covered_line\": 106,\n        \"code\": [\n          \"106: [LAST COVERED]       if or(iszero(b), iszero(iszero(gt(a, div(not(0), RAY))))) {\",\n          \"107:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 106, which is the condition check: `if or(iszero(b), iszero(iszero(gt(a, div(not(0), RAY)))))`. This checks for two error conditions: 1) division by zero (b==0), or 2) overflow where a > type(uint256).max / RAY. Line 106 is covered (the condition is evaluated), but line 107 (the revert) is uncovered, meaning the condition never evaluates to true. This is actually good - it means the fuzzer is not triggering overflow or division-by-zero errors in rayDivDown. These are error paths that we generally don't need to cover, as they represent invalid inputs. No fix is needed - this is expected behavior for a well-constrained fuzzing campaign.\"\n    },\n    {\n      \"function\": \"rayDivUp\",\n      \"contract\": \"WadRayMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/WadRayMath.sol\",\n      \"function_range\": {\n        \"start\": 117,\n        \"end\": 127\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"121\",\n        \"last_covered_line\": 120,\n        \"code\": [\n          \"120: [LAST COVERED]       if or(iszero(b), iszero(iszero(gt(a, div(not(0), RAY))))) {\",\n          \"121:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 120, which is the condition check for division by zero or overflow in rayDivUp. Line 121 (the revert) is uncovered, meaning these error conditions are never triggered. This is the same pattern as rayDivDown - the fuzzer is not causing invalid inputs that would trigger the overflow/division-by-zero checks. This is expected and desired behavior. No fix is needed.\"\n    },\n    {\n      \"function\": \"rayMulUp\",\n      \"contract\": \"WadRayMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/WadRayMath.sol\",\n      \"function_range\": {\n        \"start\": 88,\n        \"end\": 98\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"92\",\n        \"last_covered_line\": 91,\n        \"code\": [\n          \"91: [LAST COVERED]       if iszero(or(iszero(b), iszero(gt(a, div(not(0), b))))) {\",\n          \"92:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 91, which checks for multiplication overflow in rayMulUp. Line 92 (the revert) is uncovered, meaning the overflow condition is never triggered. This is expected behavior - the fuzzer is operating within valid parameter ranges. No fix is needed.\"\n    },\n    {\n      \"function\": \"toRay\",\n      \"contract\": \"WadRayMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/WadRayMath.sol\",\n      \"function_range\": {\n        \"start\": 146,\n        \"end\": 155\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"146\",\n        \"last_covered_line\": 125,\n        \"code\": [\n          \"125: [LAST COVERED]       c := add(div(c, b), gt(mod(c, b), 0))\",\n          \"146:   function toRay(uint256 a) internal pure returns (uint256 b) {\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 125, which is in the rayDivUp function. Line 146 is the start of the toRay function, which is uncovered. This indicates that toRay is never being called in the fuzzing campaign. The toRay function converts a regular uint256 to RAY units by multiplying by 1e27. To cover this function, we would need to identify where toRay should be used in the protocol and ensure those code paths are being exercised. However, if the protocol doesn't actually use toRay in any of the main operational paths, this might be dead code. Checking the codebase would reveal if toRay is used anywhere - if not, this is expected uncovered code.\"\n    },\n    {\n      \"function\": \"toRay\",\n      \"contract\": \"WadRayMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/WadRayMath.sol\",\n      \"function_range\": {\n        \"start\": 146,\n        \"end\": 155\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"148\",\n        \"last_covered_line\": 125,\n        \"code\": [\n          \"125: [LAST COVERED]       c := add(div(c, b), gt(mod(c, b), 0))\",\n          \"148:       b := mul(a, RAY)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 125 from rayDivUp. Line 148 is inside the toRay function (the multiplication `b := mul(a, RAY)`), which is uncovered because toRay itself is never called. Same root cause as the previous entry - toRay is not being used in the fuzzing campaign. If toRay is actually used in the protocol, we need to ensure those code paths are covered; if not, this is expected.\"\n    },\n    {\n      \"function\": \"toRay\",\n      \"contract\": \"WadRayMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/WadRayMath.sol\",\n      \"function_range\": {\n        \"start\": 146,\n        \"end\": 155\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"151-152\",\n        \"last_covered_line\": 125,\n        \"code\": [\n          \"125: [LAST COVERED]       c := add(div(c, b), gt(mod(c, b), 0))\",\n          \"151:       if iszero(eq(div(b, RAY), a)) {\",\n          \"152:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 125 from rayDivUp. Lines 151-152 are the overflow check in toRay, which are uncovered because toRay is never called. Same root cause as previous toRay entries. If toRay is unused in the protocol, this is expected; otherwise, we need to add coverage for code paths that use toRay.\"\n    },\n    {\n      \"function\": \"add\",\n      \"contract\": \"MathUtils\",\n      \"source_file\": \"/app/repo/src/libraries/math/MathUtils.sol\",\n      \"function_range\": {\n        \"start\": 39,\n        \"end\": 42\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"41\",\n        \"last_covered_line\": 40,\n        \"code\": [\n          \"40: [LAST COVERED]     if (b >= 0) return a + uint256(b);\",\n          \"41:     return a - uint256(-b);\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 40: `if (b >= 0) return a + uint256(b)`. This line executes and returns when b is non-negative. Line 41 is uncovered, which handles the case where b is negative (b < 0). The add function allows adding a signed integer to an unsigned integer - when b is negative, it subtracts. The fact that line 41 is uncovered means the fuzzer never calls this function with a negative value for b. To cover this, we would need to identify where this function is used in the protocol and ensure it's called with negative int256 values. However, if the protocol never uses negative values with this function, this is expected uncovered code.\"\n    },\n    {\n      \"function\": \"calculateLinearInterest\",\n      \"contract\": \"MathUtils\",\n      \"source_file\": \"/app/repo/src/libraries/math/MathUtils.sol\",\n      \"function_range\": {\n        \"start\": 17,\n        \"end\": 28\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"23\",\n        \"last_covered_line\": 22,\n        \"code\": [\n          \"22: [LAST COVERED]       if gt(lastUpdateTimestamp, timestamp()) {\",\n          \"23:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 22, which checks if lastUpdateTimestamp is in the future (greater than current block.timestamp). Line 23 (the revert) is uncovered, meaning this error condition never triggers. This is correct behavior - the protocol should never have lastUpdateTimestamp values in the future under normal operation. The fuzzer is properly respecting the timestamp constraints. This is an error path that we don't need to cover. No fix needed.\"\n    },\n    {\n      \"function\": \"mulDivDown\",\n      \"contract\": \"MathUtils\",\n      \"source_file\": \"/app/repo/src/libraries/math/MathUtils.sol\",\n      \"function_range\": {\n        \"start\": 77,\n        \"end\": 88\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"81\",\n        \"last_covered_line\": 80,\n        \"code\": [\n          \"80: [LAST COVERED]       if iszero(c) {\",\n          \"81:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 80, which checks for division by zero (c == 0). Line 81 (the revert) is uncovered, meaning the fuzzer never passes c=0 to mulDivDown. This is expected - the fuzzer is avoiding division by zero errors. This is an error path that doesn't need coverage. No fix needed.\"\n    },\n    {\n      \"function\": \"mulDivDown\",\n      \"contract\": \"MathUtils\",\n      \"source_file\": \"/app/repo/src/libraries/math/MathUtils.sol\",\n      \"function_range\": {\n        \"start\": 77,\n        \"end\": 88\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"84\",\n        \"last_covered_line\": 83,\n        \"code\": [\n          \"83: [LAST COVERED]       if iszero(or(iszero(b), iszero(gt(a, div(not(0), b))))) {\",\n          \"84:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 83, which checks for multiplication overflow in mulDivDown. Line 84 (the revert) is uncovered, meaning overflow never occurs. This is expected behavior - the fuzzer is operating within valid ranges. This is an error path that doesn't need coverage. No fix needed.\"\n    },\n    {\n      \"function\": \"mulDivUp\",\n      \"contract\": \"MathUtils\",\n      \"source_file\": \"/app/repo/src/libraries/math/MathUtils.sol\",\n      \"function_range\": {\n        \"start\": 93,\n        \"end\": 106\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"97\",\n        \"last_covered_line\": 96,\n        \"code\": [\n          \"96: [LAST COVERED]       if iszero(c) {\",\n          \"97:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 96, which checks for division by zero in mulDivUp. Line 97 (the revert) is uncovered, meaning c is never 0. This is expected - the fuzzer is avoiding division by zero. This is an error path that doesn't need coverage. No fix needed.\"\n    },\n    {\n      \"function\": \"percentMulDown\",\n      \"contract\": \"PercentageMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/PercentageMath.sol\",\n      \"function_range\": {\n        \"start\": 16,\n        \"end\": 28\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"23\",\n        \"last_covered_line\": 22,\n        \"code\": [\n          \"22: [LAST COVERED]       if iszero(or(iszero(percentage), iszero(gt(value, div(not(0), percentage))))) {\",\n          \"23:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 22, which checks for multiplication overflow in percentMulDown. Line 23 (the revert) is uncovered, meaning overflow never occurs. This is expected - the fuzzer is using valid parameter ranges. This is an error path that doesn't need coverage. No fix needed.\"\n    },\n    {\n      \"function\": \"percentMulUp\",\n      \"contract\": \"PercentageMath\",\n      \"source_file\": \"/app/repo/src/libraries/math/PercentageMath.sol\",\n      \"function_range\": {\n        \"start\": 33,\n        \"end\": 44\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"37\",\n        \"last_covered_line\": 36,\n        \"code\": [\n          \"36: [LAST COVERED]       if iszero(or(iszero(percentage), iszero(gt(value, div(not(0), percentage))))) {\",\n          \"37:         revert(0, 0)\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 36, which checks for multiplication overflow in percentMulUp. Line 37 (the revert) is uncovered, meaning overflow never occurs. This is expected - the fuzzer is using valid parameter ranges. This is an error path that doesn't need coverage. No fix needed.\"\n    },\n    {\n      \"function\": \"safeTransfer\",\n      \"contract\": \"SafeERC20\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/SafeERC20.sol\",\n      \"function_range\": {\n        \"start\": 37,\n        \"end\": 39\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"38\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"38:     _callOptionalReturn(token, abi.encodeCall(token.transfer, (to, value)));\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, indicating that safeTransfer is never being called. The protocol is using safeTransferFrom (which is covered) but not safeTransfer. This makes sense - in a lending protocol, tokens are typically transferred from users to the protocol (safeTransferFrom), not from the protocol to arbitrary addresses using the simpler transfer. If the protocol doesn't have code paths that use safeTransfer, this is expected uncovered code. To verify, we should check if there are any protocol functions that transfer tokens out using safeTransfer - if not, this can remain uncovered.\"\n    },\n    {\n      \"function\": \"safeTransferFrom\",\n      \"contract\": \"SafeERC20\",\n      \"source_file\": \"/app/repo/src/dependencies/openzeppelin/SafeERC20.sol\",\n      \"function_range\": {\n        \"start\": 45,\n        \"end\": 47\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"45\",\n        \"last_covered_line\": null,\n        \"code\": [\n          \"45:   function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is null, but this is misleading - the coverage tracking shows line 45 (the function signature) as uncovered, but the function body (line 46) is likely covered. This is a coverage instrumentation artifact where the function declaration line itself isn't marked as covered even though the function is being called (we know safeTransferFrom is used throughout the protocol, including in the reclaim function at line 455 of Hub.sol). This is a false positive - no fix needed.\"\n    },\n    {\n      \"function\": \"_add\",\n      \"contract\": \"EnumerableSet\",\n      \"source_file\": \"/app/repo/lib/setup-helpers/src/EnumerableSet.sol\",\n      \"function_range\": {\n        \"start\": 65,\n        \"end\": 75\n      },\n      \"uncovered_code\": {\n        \"line_range\": \"73\",\n        \"last_covered_line\": 71,\n        \"code\": [\n          \"71: [LAST COVERED]             return true;\",\n          \"73:             return false;\"\n        ]\n      },\n      \"analysis\": \"The last_covered_line is 71: `return true;`, which is in the if branch that executes when a value is successfully added to the set (when it wasn't already present). Line 73 is `return false;` in the else branch, which executes when trying to add a value that's already in the set. The fact that line 73 is uncovered means the fuzzer never attempts to add duplicate values to EnumerableSet instances. To cover this, we would need to ensure the fuzzer sometimes calls functions that add the same value twice to a set. However, this might be difficult to achieve without specific clamping that reuses values. This is likely low priority since it's just the 'already exists' return path.\"\n    }\n  ],\n  \"summary\": {\n    \"functions_analyzed\": 94,\n    \"functions_with_missing_coverage\": 16,\n    \"uncovered_sections\": 22,\n    \"full_coverage\": false\n  }\n}\nEOFMARKER\n","description":"Create analyzed coverage JSON with all analyses"},"output":"","title":"Create analyzed coverage JSON with all analyses","metadata":{"output":"","exit":0,"description":"Create analyzed coverage JSON with all analyses"},"time":{"start":1766256670426,"end":1766256670431}}}}
{"type":"text","timestamp":1766256670446,"sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","part":{"id":"prt_b3d187247002M78pFMfnvx42hh","sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","messageID":"msg_b3d186cb7001lIJS2WJhAKP3nf","type":"text","text":"Now I have enough context to analyze all the coverage gaps. Let me create a comprehensive analysis for each entry. I'll build the updated JSON structure with analysis fields added:","time":{"start":1766256670444,"end":1766256670444}}}
{"type":"step_finish","timestamp":1766256670468,"sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","part":{"id":"prt_b3d19f6ed001hJiFZsDIJvYh5v","sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","messageID":"msg_b3d186cb7001lIJS2WJhAKP3nf","type":"step-finish","reason":"tool-calls","snapshot":"1f25d0905985d74f36e5075330cdc2f15811cc5a","cost":0.1444977,"tokens":{"input":12973,"output":6555,"reasoning":0,"cache":{"read":24179,"write":0}}}}
{"type":"step_start","timestamp":1766256672003,"sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","part":{"id":"prt_b3d19fd01001G2J3UDa0TeHB51","sessionID":"ses_4c2e7defeffeJjQ3Y4SGOPgXfp","messageID":"msg_b3d19f71f0012PBJrs2kyDS6bM","type":"step-start","snapshot":"1f25d0905985d74f36e5075330cdc2f15811cc5a"}}
[?25l│
◆  Permission required to run: This command references paths outside of /app/repo
│  ● Allow once
│  ○ Always allow
│  ○ Reject
└

Opencode was started at /app/repo via cd /app/repo && opencode run PROMPT

Wdyt?

GalloDaSballo avatar Dec 21 '25 08:12 GalloDaSballo

Looks like you're being asked for the external_directory permission.

It also looks like your permissions are not all valid: https://opencode.ai/docs/permissions/#external_directory

https://github.com/sst/opencode/blob/4828fd1eac87de8ae4e4d89ffe766e7ac3b7d42e/packages/opencode/src/agent/agent.ts#L56-L64

justfortheloveof avatar Dec 21 '25 16:12 justfortheloveof

Yup @justfortheloveof is correct^^

just set

"external_directory": "allow" in your agent definition and it will not hang

rekram1-node avatar Dec 22 '25 03:12 rekram1-node

Yup @justfortheloveof is correct^^

just set

"external_directory": "allow" in your agent definition and it will not hang

Thank you @rekram1-node anfd @justfortheloveof I understand the fix for our use case. Although I wouldn't want the AI being able to write outside of the workspace we created for it.

What are your thoughts around this being a bug? If I'm running the AI as CLI, do I reasonably have a way to process it's request for permissions? Afair Claude Code would attempt something, then give up and end the work early rather than hang.

If there's a programmatic workaround (analyzing the logs), I'd be interested in learning more. Currently our alternative is to monitor opencode logs and then kill the process if it hangs.

GalloDaSballo avatar Dec 22 '25 07:12 GalloDaSballo

You're saying that when running "non-interactively" the run shouldn't block on a permission request? That makes sense and for the time being can be achieved by not having any permission set to "ask" but only to "allow" or "deny". Perhaps flags can be added the run subcommand to control permissions and satisfy your use case, but I'm not sure what is desired or not there.

FYI, there is also an experimental feature that might be relevant here, see #4729, where you can use:

  "experimental": {
    "continue_loop_on_deny": true,
  }

in your config to allow the LLM to continue after it was denied permission

justfortheloveof avatar Dec 22 '25 16:12 justfortheloveof

Yeah ig if u run as cli we hsouls auto deny and continue on loop deny probs?

rekram1-node avatar Dec 22 '25 17:12 rekram1-node

this isn't a "bug" this was intentional cause people asked for it but it's kinda stupid so im happy to change it

rekram1-node avatar Dec 22 '25 17:12 rekram1-node

this isn't a "bug" this was intentional cause people asked for it but it's kinda stupid so im happy to change it

Yeah I don't have a strong opinion, I generally would rather see Opencode throw or smth cause that's easy to capture and monitor.

We'll likely set a global config to deny everything outside the wd.

Thank you for the great work and advice on this.

GalloDaSballo avatar Dec 22 '25 17:12 GalloDaSballo