Skip to content

Commit 79e7377

Browse files
committed
fix: permission issues in sandboxes
1 parent 9bb38fd commit 79e7377

2 files changed

Lines changed: 34 additions & 11 deletions

File tree

src/semble/installer/agents.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@
1616

1717
_HOME = Path.home()
1818

19+
20+
def _exists_or_denied(path: Path) -> bool:
21+
"""Distinguish between existence and permission issues."""
22+
try:
23+
path.stat()
24+
except FileNotFoundError:
25+
return False
26+
# PermissionError is a subclass of OSError
27+
# which is why this looks the way it does.
28+
except PermissionError:
29+
return True
30+
except OSError:
31+
return False
32+
return True
33+
34+
1935
Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"]
2036
Mode = Literal["install", "uninstall"]
2137

@@ -33,15 +49,7 @@ class IntegrationType(str, Enum):
3349

3450

3551
def semble_pin() -> str:
36-
"""Return the uvx --from specifier for the semble MCP server.
37-
38-
Version-pinned for normal installs (rerunning `semble install` after an
39-
upgrade rewrites this pin to match). For an editable or local-directory
40-
install, pins to the local source path instead, so generated configs
41-
launch the checkout being developed rather than the released package.
42-
For a non-editable git install, pins to the exact installed commit, since
43-
that source may not correspond to any released PyPI version at all.
44-
"""
52+
"""Return the uvx --from specifier for the semble MCP server."""
4553
try:
4654
raw = importlib.metadata.distribution("semble").read_text("direct_url.json")
4755
if raw:
@@ -159,7 +167,7 @@ def _opencode_mcp_path() -> Path:
159167
base = Path(xdg) / "opencode" if xdg else _HOME / ".config" / "opencode"
160168
jsonc = base / "opencode.jsonc"
161169
json_ = base / "opencode.json"
162-
return jsonc if jsonc.exists() else (json_ if json_.exists() else jsonc)
170+
return jsonc if _exists_or_denied(jsonc) else (json_ if _exists_or_denied(json_) else jsonc)
163171

164172

165173
def _vscode_mcp_path() -> Path:
@@ -316,4 +324,4 @@ def is_detected(agent: AgentTarget) -> bool:
316324
"""Return True if the agent appears to be installed."""
317325
if agent.binary and shutil.which(agent.binary):
318326
return True
319-
return bool(agent.config_dir and agent.config_dir.exists())
327+
return bool(agent.config_dir and _exists_or_denied(agent.config_dir))

tests/test_installer.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import sys
44
from dataclasses import replace
5+
from pathlib import Path
56

67
import pytest
78

@@ -482,6 +483,20 @@ def test_is_detected(monkeypatch, tmp_path):
482483
agent_no_bin = replace(agent, binary=None, config_dir=tmp_path)
483484
assert is_detected(agent_no_bin)
484485

486+
agent_missing = replace(agent, binary=None, config_dir=tmp_path / "nonexistent")
487+
assert not is_detected(agent_missing)
488+
489+
490+
def test_is_detected_true_when_config_dir_stat_denied(monkeypatch, tmp_path):
491+
"""A config dir blocked by sandboxing (EPERM/EACCES) still counts as detected, not absent."""
492+
agent = replace(next(a for a in AGENTS if a.id == "claude"), binary=None, config_dir=tmp_path)
493+
494+
def _denied(self):
495+
raise PermissionError(1, "Operation not permitted")
496+
497+
monkeypatch.setattr(Path, "stat", _denied)
498+
assert is_detected(agent)
499+
485500

486501
def test_checkbox(monkeypatch):
487502
"""_checkbox wraps questionary.checkbox and returns the selected values."""

0 commit comments

Comments
 (0)