Skip to content

Commit fb9ebd2

Browse files
committed
release: merge develop into main for v0.32.3
2 parents cab8966 + f4ddd6e commit fb9ebd2

19 files changed

Lines changed: 726 additions & 73 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.32.3] - 2026-04-25
9+
10+
Patch release fixing a long-standing Workspace UI bug where folders refused to open and the dev console flooded with `400 Path is a directory` requests, plus a small UX win on the file share dialog (reuse existing share links instead of generating a new token every time). Also includes the upstream PR #51 (private-repo plugin update flow + ClickUp webhook compat + DetachedInstanceError).
11+
12+
### Fixed
13+
14+
- **`dashboard/frontend/src/App.tsx`** — section-stable `routeKey` for the `SectionBoundary`. Previously every `navigate({replace:true})` inside `/workspace/*`, `/agents/:name`, `/tickets/:id`, `/skills/:name` and `/docs` produced a new `location.key`, which changed the boundary's React `key` and remounted the entire page. In the Workspace this wiped `selectedPath`, `expanded` state in `TreeItem`, and refs on every folder click — folders never stayed open and the URL→state effect re-fired the file probe (`GET /api/workspace/file?path=workspace/development` → 400) on every mount. Now subpaths within the same section share one stable key; the boundary still resets between sections.
15+
- **`dashboard/frontend/src/components/workspace/FileTree.tsx`** — split the toggle in `TreeItem.handleClick` into explicit open / close branches. The previous `setExpanded(prev => !prev)` toggle was vulnerable to any re-trigger flipping a freshly-opened folder back closed.
16+
- **`dashboard/frontend/src/pages/Workspace.tsx`** — added `knownDirsRef` so the URL→`selectedPath` deep-link effect can skip the redundant `GET /api/workspace/file?path=…` probe when the path is already known to be a directory (e.g. user just clicked it). The probe used to 400 on every directory navigation, polluting server logs and racing with `setSelectedPath` re-renders.
17+
18+
### Changed
19+
20+
- **`dashboard/backend/routes/shares.py`** — new `GET /api/shares/by-path?path=X` endpoint returning the most recent **active** (enabled + non-expired) share for a path, or 404. Same permission gate (`workspace.manage`) and folder-access check as `POST /api/shares`.
21+
- **`dashboard/frontend/src/components/workspace/ShareDialog.tsx`** — on open, probe `by-path` and reuse any existing active share instead of always minting a new token. The dialog now shows the existing link with formatted expiry, view counter, and a destructive **Revoke and regenerate** action when you actually want to rotate the link. New share creation only happens when there isn't one already.
22+
23+
### Included from PR #51
24+
25+
- **Plugins + triggers** — private-repo update flow, ClickUp webhook compatibility, and a `DetachedInstanceError` fix landed via PR #51 ahead of this patch.
26+
827
## [0.32.2] - 2026-04-24
928

1029
Patch release working around a bug in `@anthropic-ai/claude-agent-sdk` (v0.2.104+) where Linux auto-discovery tries the `-musl` platform package before glibc regardless of the host's actual libc. On glibc VPS installs (Ubuntu / Debian) with both platform packages present in `node_modules`, the SDK spawned the musl binary and failed with `Claude Code native binary not found` because the musl dynamic loader was absent — breaking every chat session on the affected VPS with no local repro. See upstream [issue #296](https://github.com/anthropics/claude-agent-sdk-typescript/issues/296).

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.32.2",
3+
"version": "0.32.3",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,7 @@ def auth_middleware():
834834
from routes.mempalace import bp as mempalace_bp
835835
from routes.tasks import bp as tasks_bp
836836
from routes.triggers import bp as triggers_bp
837+
from routes.terminal_proxy import bp as terminal_proxy_bp, register_websocket_proxy as _register_terminal_ws
837838
from routes.backups import bp as backups_bp
838839
from routes.providers import bp as providers_bp
839840
from routes.settings import bp as settings_bp
@@ -887,6 +888,25 @@ def auth_middleware():
887888
app.register_blueprint(mempalace_bp)
888889
app.register_blueprint(tasks_bp)
889890
app.register_blueprint(triggers_bp)
891+
app.register_blueprint(terminal_proxy_bp)
892+
893+
# Mount the terminal-server WebSocket proxy on the same Sock instance the
894+
# rest of the app uses. Done after the blueprint is registered so route
895+
# names are unique. Without this, browsers connecting from a host other
896+
# than the one running the Node terminal-server (LAN, Tailscale Funnel,
897+
# SSH tunnel without the dynamic port forwarded) cannot reach it directly
898+
# due to CORS preflight + private-network-access policies.
899+
try:
900+
from flask_sock import Sock as _Sock
901+
_terminal_sock = _Sock(app)
902+
_register_terminal_ws(_terminal_sock)
903+
except Exception as _exc:
904+
import logging as _logging
905+
_logging.getLogger(__name__).warning(
906+
"terminal_proxy: failed to mount WebSocket proxy: %s — terminal "
907+
"interactions will require direct access to the terminal-server port.",
908+
_exc,
909+
)
890910
app.register_blueprint(backups_bp)
891911
app.register_blueprint(providers_bp)
892912
app.register_blueprint(settings_bp)

dashboard/backend/plugin_loader.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,23 @@ def resolve_source(source_url: str, auth_token: str | None = None) -> Path:
290290
staging_slug = f"{owner}-{repo}-{ref}".replace("/", "-")
291291
try:
292292
return PluginInstaller.fetch_from_tarball(tar_url, staging_slug, auth_token=auth_token)
293-
except RuntimeError:
294-
# fallback: try as tag ref
293+
except RuntimeError as branch_err:
294+
# fallback: try as tag ref. If that also fails, surface a
295+
# unified error so the caller knows both branch and tag
296+
# namespaces were tried — otherwise the bare
297+
# "refs/tags/<ref>: 404" message is misleading when the ref
298+
# was actually a branch name.
295299
tar_url_tag = f"https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{ref}"
296-
return PluginInstaller.fetch_from_tarball(tar_url_tag, staging_slug, auth_token=auth_token)
300+
try:
301+
return PluginInstaller.fetch_from_tarball(
302+
tar_url_tag, staging_slug, auth_token=auth_token
303+
)
304+
except RuntimeError as tag_err:
305+
raise RuntimeError(
306+
f"ref '{ref}' not found in {owner}/{repo} "
307+
f"(tried branches and tags). "
308+
f"Branch: {branch_err}. Tag: {tag_err}."
309+
) from tag_err
297310

298311
if s.startswith("https://"):
299312
# Use a safe staging slug derived from the URL
@@ -477,12 +490,23 @@ def resolve_source_with_sha(
477490
tar_url, staging_slug, auth_token=auth_token
478491
)
479492
return path, sha
480-
except RuntimeError:
493+
except RuntimeError as branch_err:
481494
tar_url_tag = f"https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{ref}"
482-
path, sha = PluginInstaller.fetch_from_tarball_with_sha(
483-
tar_url_tag, staging_slug, auth_token=auth_token
484-
)
485-
return path, sha
495+
try:
496+
path, sha = PluginInstaller.fetch_from_tarball_with_sha(
497+
tar_url_tag, staging_slug, auth_token=auth_token
498+
)
499+
return path, sha
500+
except RuntimeError as tag_err:
501+
# Both branch and tag namespaces exhausted — raise a
502+
# unified error rather than the bare tag 404 so callers
503+
# can distinguish ref-not-found from private-repo auth
504+
# failures. Kept in sync with ``resolve_source`` above.
505+
raise RuntimeError(
506+
f"ref '{ref}' not found in {owner}/{repo} "
507+
f"(tried branches and tags). "
508+
f"Branch: {branch_err}. Tag: {tag_err}."
509+
) from tag_err
486510

487511
# Plain https:// tarball
488512
staging_slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", s)[-80:]

dashboard/backend/routes/plugins.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2158,7 +2158,10 @@ def serve_widget(slug: str, subpath: str):
21582158
# ---------------------------------------------------------------------------
21592159

21602160
# Module-level preview cache: key=(slug, source_url), value=(fetched_at_float, result_dict)
2161-
_PREVIEW_CACHE: dict[tuple[str, str], tuple[float, dict]] = {}
2161+
# Cache key: (slug, source_url, has_auth_token). has_auth_token flag prevents
2162+
# leaking private-repo previews to unauthenticated callers — the value of the
2163+
# token is deliberately not part of the key (no secrets in memory indices).
2164+
_PREVIEW_CACHE: dict[tuple[str, str, bool], tuple[float, dict]] = {}
21622165
_PREVIEW_CACHE_LOCK = threading.Lock()
21632166
_PREVIEW_CACHE_TTL = 300 # seconds
21642167

@@ -2264,7 +2267,9 @@ def _extract_ids_and_entries(manifest: dict) -> dict[str, dict[str, Any]]:
22642267
return added, removed, modified
22652268

22662269

2267-
def _compute_preview(slug: str, source_url: str) -> dict:
2270+
def _compute_preview(
2271+
slug: str, source_url: str, auth_token: str | None = None
2272+
) -> dict:
22682273
"""Fetch candidate manifest and compute diff against installed manifest.
22692274
22702275
Pure read-only: no DB writes, no file writes to plugins/{slug}/.
@@ -2273,6 +2278,12 @@ def _compute_preview(slug: str, source_url: str) -> dict:
22732278
tarball_sha7 derivation: SHA256 of sorted manifest.files SHAs concatenated.
22742279
If manifest.files is empty, falls back to SHA256 of serialized manifest JSON.
22752280
First 7 chars of the hex digest are returned (deterministic, no re-download needed).
2281+
2282+
Args:
2283+
slug: Installed plugin slug.
2284+
source_url: Candidate source (github:..., https://...).
2285+
auth_token: Optional GitHub PAT — required for private repos. Sent as
2286+
``Authorization: token <pat>`` header by ``resolve_source``.
22762287
"""
22772288
from plugin_schema import load_plugin_manifest
22782289
from plugin_loader import PluginInstaller, _parse_version
@@ -2316,7 +2327,7 @@ def _compute_preview(slug: str, source_url: str) -> dict:
23162327

23172328
# Resolve candidate source (may hit network / tmp — outside the lock)
23182329
try:
2319-
new_plugin_dir = PluginInstaller.resolve_source(source_url)
2330+
new_plugin_dir = PluginInstaller.resolve_source(source_url, auth_token=auth_token)
23202331
except ValueError as exc:
23212332
raise ValueError(f"invalid_source: {exc}") from exc
23222333
except RuntimeError as exc:
@@ -2422,6 +2433,11 @@ def preview_plugin_update(slug: str):
24222433
"""Read-only diff preview before applying an update.
24232434
24242435
Query param: ?source=<url> (defaults to installed source_url when omitted)
2436+
Optional header ``X-Plugin-Auth-Token``: GitHub PAT required to fetch
2437+
candidate from a private repository (same semantics as ``auth_token`` on
2438+
``POST /api/plugins/preview``). Kept out of the query string so it does
2439+
not leak into access logs.
2440+
24252441
Returns 200 with diff JSON (or up_to_date: true).
24262442
Never writes to disk or DB.
24272443
"""
@@ -2440,7 +2456,12 @@ def preview_plugin_update(slug: str):
24402456
if not source_url:
24412457
return jsonify({"error": "invalid_source", "message": "No source URL provided and none stored"}), 400
24422458

2443-
cache_key = (slug, source_url)
2459+
# Optional GitHub PAT for private repos (header only — never logged)
2460+
auth_token = request.headers.get("X-Plugin-Auth-Token") or None
2461+
2462+
# Cache key includes auth_token presence (not value) to avoid sharing
2463+
# private-repo results across unauthenticated requests.
2464+
cache_key = (slug, source_url, bool(auth_token))
24442465

24452466
# Cache read — lock only around dict access, not network I/O
24462467
with _PREVIEW_CACHE_LOCK:
@@ -2452,7 +2473,7 @@ def preview_plugin_update(slug: str):
24522473

24532474
# Cache miss — compute outside lock
24542475
try:
2455-
result = _compute_preview(slug, source_url)
2476+
result = _compute_preview(slug, source_url, auth_token=auth_token)
24562477
except ValueError as exc:
24572478
msg = str(exc)
24582479
if msg.startswith("invalid_source:"):
@@ -2543,10 +2564,21 @@ def update_plugin(slug: str):
25432564
data = request.get_json(force=True, silent=True) or {}
25442565
source_url = data.get("source_url", installed_source)
25452566

2567+
# Optional GitHub PAT for private repos. Mirrors /api/plugins/install —
2568+
# body field first, falling back to ``X-Plugin-Auth-Token`` header for
2569+
# callers that reuse the same header they sent to update/preview.
2570+
auth_token = (
2571+
data.get("auth_token")
2572+
or request.headers.get("X-Plugin-Auth-Token")
2573+
or None
2574+
)
2575+
25462576
# 3. Resolve new plugin source (accepts local path, github:..., https://...)
25472577
from plugin_loader import PluginInstaller
25482578
try:
2549-
new_plugin_dir = PluginInstaller.resolve_source(source_url)
2579+
new_plugin_dir = PluginInstaller.resolve_source(
2580+
source_url, auth_token=auth_token
2581+
)
25502582
except ValueError as exc:
25512583
return jsonify({"error": "invalid_source", "message": str(exc)}), 400
25522584
except RuntimeError as exc:

dashboard/backend/routes/shares.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,41 @@ def list_shares():
130130
return jsonify({"shares": [s.to_dict() for s in shares]})
131131

132132

133+
@bp.route("/api/shares/by-path", methods=["GET"])
134+
@login_required
135+
@require_permission("workspace", "manage")
136+
def get_active_share_by_path():
137+
"""Return the most recent ACTIVE (enabled + not expired) share for a path,
138+
so the UI can reuse it instead of generating a new token every time."""
139+
path = (request.args.get("path") or "").strip()
140+
if not path:
141+
return jsonify({"error": "path is required", "code": "bad_path"}), 400
142+
143+
if not has_workspace_folder_access(current_user.role, path):
144+
return jsonify({"error": "Access to this workspace folder is restricted", "code": "forbidden"}), 403
145+
146+
now = datetime.now(timezone.utc)
147+
candidates = (
148+
FileShare.query
149+
.filter_by(path=path, enabled=True)
150+
.order_by(FileShare.created_at.desc())
151+
.all()
152+
)
153+
for share in candidates:
154+
if share.expires_at is not None:
155+
expires = share.expires_at
156+
if expires.tzinfo is None:
157+
expires = expires.replace(tzinfo=timezone.utc)
158+
if now > expires:
159+
continue
160+
base_url = request.host_url.rstrip("/")
161+
return jsonify({
162+
**share.to_dict(),
163+
"url": f"{base_url}/share/{share.token}",
164+
})
165+
return jsonify({"error": "No active share for this path", "code": "not_found"}), 404
166+
167+
133168
@bp.route("/api/shares/<token>", methods=["DELETE"])
134169
@login_required
135170
@require_permission("workspace", "manage")

0 commit comments

Comments
 (0)