proteworks 0.1.9__tar.gz → 0.1.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proteworks-0.1.9 → proteworks-0.1.10}/PKG-INFO +1 -1
- {proteworks-0.1.9 → proteworks-0.1.10}/pyproject.toml +1 -1
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/__init__.py +2 -2
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/api.py +11 -3
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/auth.py +10 -1
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/config.py +29 -14
- proteworks-0.1.10/src/proteworks/mcp/profile.py +78 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_files.py +20 -9
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_orgs.py +23 -3
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_studio.py +3 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp_host.py +2 -0
- proteworks-0.1.10/src/proteworks/redact.py +97 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/session.py +5 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/rfd4_proteina.py +14 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/workflows.py +80 -4
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/PKG-INFO +1 -1
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/SOURCES.txt +1 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_adversarial.py +2 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_files.py +25 -1
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_host.py +2 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_studio.py +29 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_workflows.py +24 -0
- proteworks-0.1.10/tests/test_redact.py +31 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_rfd4_proteina_workflow.py +21 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_session.py +5 -1
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_token_store.py +3 -4
- proteworks-0.1.9/src/proteworks/mcp/profile.py +0 -30
- proteworks-0.1.9/src/proteworks/redact.py +0 -47
- {proteworks-0.1.9 → proteworks-0.1.10}/README.md +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/setup.cfg +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/browse.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/cli.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/folders.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/index.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/__init__.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/errors.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/paths.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/runtime.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/server.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_workflows.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/orgs.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/pull.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/push.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/results.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/__init__.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/common.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/__init__.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/base.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/proteinmpnn.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/refolding.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/rfd3.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/structure_prediction.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/registry.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio_proteinmpnn.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/dependency_links.txt +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/entry_points.txt +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/requires.txt +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/top_level.txt +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_browse.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_cli.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_config_dir.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_folders.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_errors.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_paths.py +0 -0
- {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_transfer_result.py +0 -0
|
@@ -37,13 +37,21 @@ class ApiError(Exception):
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def assert_allowed_storage_url(url: str) -> None:
|
|
40
|
-
"""Reject signed URLs whose host is outside the storage allowlist (SSRF defense).
|
|
40
|
+
"""Reject signed URLs whose host is outside the storage allowlist (SSRF defense).
|
|
41
|
+
|
|
42
|
+
Production GCS signed URLs must be HTTPS. Plain HTTP is allowed only for
|
|
43
|
+
localhost / 127.0.0.1 (FakeApi and local integration tests).
|
|
44
|
+
"""
|
|
41
45
|
parsed = urlparse(str(url or ""))
|
|
42
|
-
if parsed.scheme not in {"https", "http"}:
|
|
43
|
-
raise ApiError("Storage URL must use http(s)", 400)
|
|
44
46
|
host = (parsed.hostname or "").lower()
|
|
45
47
|
if not host:
|
|
46
48
|
raise ApiError("Storage URL missing host", 400)
|
|
49
|
+
if parsed.scheme == "https":
|
|
50
|
+
pass
|
|
51
|
+
elif parsed.scheme == "http" and host in {"localhost", "127.0.0.1"}:
|
|
52
|
+
pass
|
|
53
|
+
else:
|
|
54
|
+
raise ApiError("Storage URL must use https", 400)
|
|
47
55
|
suffixes = allowed_storage_host_suffixes()
|
|
48
56
|
if any(host == suffix or host.endswith("." + suffix) for suffix in suffixes):
|
|
49
57
|
return
|
|
@@ -12,7 +12,13 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
|
|
12
12
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
|
-
from proteworks.config import
|
|
15
|
+
from proteworks.config import (
|
|
16
|
+
LOOPBACK_PORT,
|
|
17
|
+
LOOPBACK_REDIRECT_PATH,
|
|
18
|
+
ConfigStore,
|
|
19
|
+
Settings,
|
|
20
|
+
TokenStore,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
def pkce_pair() -> tuple[str, str]:
|
|
@@ -118,4 +124,7 @@ def login(settings: Settings, token_store: TokenStore, stderr) -> None:
|
|
|
118
124
|
if not access_token:
|
|
119
125
|
raise SystemExit("Login failed.")
|
|
120
126
|
token_store.set(access_token)
|
|
127
|
+
# Bind credentials to this API URL so a later PROTEWORKS_API_URL change cannot
|
|
128
|
+
# silently send the bearer token to a different host.
|
|
129
|
+
ConfigStore(settings.config_dir / "config.json").bind_api_url(settings.api_url)
|
|
121
130
|
print("Signed in.", file=stderr)
|
|
@@ -101,6 +101,12 @@ class ConfigStore:
|
|
|
101
101
|
data["api_url"] = api_url
|
|
102
102
|
self.write(data)
|
|
103
103
|
|
|
104
|
+
def bind_api_url(self, api_url: str) -> None:
|
|
105
|
+
"""Persist the API URL this credential set is bound to (without changing org)."""
|
|
106
|
+
data = self.read()
|
|
107
|
+
data["api_url"] = api_url
|
|
108
|
+
self.write(data)
|
|
109
|
+
|
|
104
110
|
def bound_api_url(self) -> Optional[str]:
|
|
105
111
|
value = self.read().get("api_url")
|
|
106
112
|
return str(value) if value else None
|
|
@@ -110,8 +116,9 @@ class TokenStore:
|
|
|
110
116
|
"""Access token in the OS keychain, with a config-dir file fallback.
|
|
111
117
|
|
|
112
118
|
Claude Science sandboxes block D-Bus/SecretService, so keyring alone fails
|
|
113
|
-
inside the connector.
|
|
114
|
-
0600), which ``setup-claude-science``
|
|
119
|
+
inside the connector. When keyring cannot store the token, the file lives
|
|
120
|
+
under ``~/.config/proteworks`` (mode 0600), which ``setup-claude-science``
|
|
121
|
+
already grants read access. A working keyring never copies the token to disk.
|
|
115
122
|
"""
|
|
116
123
|
|
|
117
124
|
SERVICE = "proteworks"
|
|
@@ -125,15 +132,14 @@ class TokenStore:
|
|
|
125
132
|
def get(self) -> Optional[str]:
|
|
126
133
|
token = self._keyring_get()
|
|
127
134
|
if token:
|
|
128
|
-
# Lazy-migrate so an existing keychain login works inside Science.
|
|
129
|
-
if not self.path.is_file():
|
|
130
|
-
self._file_set(token)
|
|
131
135
|
return token
|
|
132
136
|
return self._file_get()
|
|
133
137
|
|
|
134
138
|
def set(self, token: str) -> None:
|
|
139
|
+
if self._keyring_set(token):
|
|
140
|
+
return
|
|
141
|
+
# Keyring unavailable (e.g. Claude Science sandbox): persist to disk only.
|
|
135
142
|
self._file_set(token)
|
|
136
|
-
self._keyring_set(token)
|
|
137
143
|
|
|
138
144
|
def _keyring_get(self) -> Optional[str]:
|
|
139
145
|
try:
|
|
@@ -143,14 +149,15 @@ class TokenStore:
|
|
|
143
149
|
except Exception:
|
|
144
150
|
return None
|
|
145
151
|
|
|
146
|
-
def _keyring_set(self, token: str) ->
|
|
152
|
+
def _keyring_set(self, token: str) -> bool:
|
|
153
|
+
"""Store in keyring. Returns True on success."""
|
|
147
154
|
try:
|
|
148
155
|
import keyring
|
|
149
156
|
|
|
150
157
|
keyring.set_password(self.SERVICE, "access_token", token)
|
|
158
|
+
return True
|
|
151
159
|
except Exception:
|
|
152
|
-
|
|
153
|
-
return
|
|
160
|
+
return False
|
|
154
161
|
|
|
155
162
|
def _file_get(self) -> Optional[str]:
|
|
156
163
|
try:
|
|
@@ -163,8 +170,16 @@ class TokenStore:
|
|
|
163
170
|
|
|
164
171
|
def _file_set(self, token: str) -> None:
|
|
165
172
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
174
|
+
fd = os.open(self.path, flags, 0o600)
|
|
175
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
176
|
+
fh.write(token + "\n")
|
|
177
|
+
mode = self.path.stat().st_mode & 0o777
|
|
178
|
+
if mode != 0o600:
|
|
179
|
+
try:
|
|
180
|
+
os.chmod(self.path, 0o600)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise OSError(f"Could not set credentials file mode 0600: {self.path}") from exc
|
|
183
|
+
mode = self.path.stat().st_mode & 0o777
|
|
184
|
+
if mode != 0o600:
|
|
185
|
+
raise OSError(f"Credentials file mode is {oct(mode)}, expected 0o600: {self.path}")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""MCP capability profile (read-only vs full)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
_ENV_READ_ONLY = "PROTEWORKS_MCP_READ_ONLY"
|
|
9
|
+
_ENV_ALLOW_ORG_SWITCH = "PROTEWORKS_MCP_ALLOW_ORG_SWITCH"
|
|
10
|
+
_ENV_ALLOW_TRANSFERS = "PROTEWORKS_MCP_ALLOW_TRANSFERS"
|
|
11
|
+
|
|
12
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_read_only() -> bool:
|
|
16
|
+
"""When true, mutating tools (submit, push, mkdir, cancel, save-selected) refuse."""
|
|
17
|
+
return (os.environ.get(_ENV_READ_ONLY) or "").strip().lower() in _TRUTHY
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def refuse_if_read_only(action: str) -> dict | None:
|
|
21
|
+
if not is_read_only():
|
|
22
|
+
return None
|
|
23
|
+
return {
|
|
24
|
+
"ok": False,
|
|
25
|
+
"error": "read_only",
|
|
26
|
+
"message": (
|
|
27
|
+
f"{action} is disabled while {_ENV_READ_ONLY} is set. "
|
|
28
|
+
"Unset it to enable submit/push/create_folder/cancel/save tools."
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def refuse_if_transfers_disallowed(action: str) -> dict | None:
|
|
34
|
+
"""Block non-dry-run pull/push unless the connector env explicitly allows transfers.
|
|
35
|
+
|
|
36
|
+
``confirm_transfer=true`` alone is not enough: an MCP agent can always pass it.
|
|
37
|
+
"""
|
|
38
|
+
raw = (os.environ.get(_ENV_ALLOW_TRANSFERS) or "").strip().lower()
|
|
39
|
+
if raw in _TRUTHY:
|
|
40
|
+
return None
|
|
41
|
+
return {
|
|
42
|
+
"ok": False,
|
|
43
|
+
"error": "transfer_not_allowed",
|
|
44
|
+
"message": (
|
|
45
|
+
f"{action} requires {_ENV_ALLOW_TRANSFERS}=1 in the connector environment "
|
|
46
|
+
"(in addition to dry_run=false and confirm_transfer=true)."
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def org_switch_blocked(
|
|
52
|
+
*,
|
|
53
|
+
current_org_id: Optional[str],
|
|
54
|
+
target_org_id: str,
|
|
55
|
+
) -> Optional[str]:
|
|
56
|
+
"""Return an error message when changing an already-set org without env allow.
|
|
57
|
+
|
|
58
|
+
Initial organization selection (no current id) only needs confirm=true.
|
|
59
|
+
"""
|
|
60
|
+
current = (current_org_id or "").strip()
|
|
61
|
+
target = (target_org_id or "").strip()
|
|
62
|
+
if not current or current == target:
|
|
63
|
+
return None
|
|
64
|
+
raw = (os.environ.get(_ENV_ALLOW_ORG_SWITCH) or "").strip()
|
|
65
|
+
if not raw:
|
|
66
|
+
return (
|
|
67
|
+
f"Changing the active organization requires {_ENV_ALLOW_ORG_SWITCH}=1 "
|
|
68
|
+
"(or a comma-separated allowlist of target org ids) in the connector environment."
|
|
69
|
+
)
|
|
70
|
+
if raw.lower() in _TRUTHY:
|
|
71
|
+
return None
|
|
72
|
+
allowed = {part.strip() for part in raw.split(",") if part.strip()}
|
|
73
|
+
if target in allowed:
|
|
74
|
+
return None
|
|
75
|
+
return (
|
|
76
|
+
f"Organization {target!r} is not listed in {_ENV_ALLOW_ORG_SWITCH} "
|
|
77
|
+
"(set to 1 to allow any switch, or list allowed org ids)."
|
|
78
|
+
)
|
|
@@ -8,7 +8,7 @@ from proteworks.browse import list_remote
|
|
|
8
8
|
from proteworks.folders import mkdir_remote
|
|
9
9
|
from proteworks.mcp.errors import tool_error_kind, tool_error_message, tool_failure
|
|
10
10
|
from proteworks.mcp.paths import mcp_workspace_root, mcp_workspace_roots, resolve_under_workspace
|
|
11
|
-
from proteworks.mcp.profile import refuse_if_read_only
|
|
11
|
+
from proteworks.mcp.profile import refuse_if_read_only, refuse_if_transfers_disallowed
|
|
12
12
|
from proteworks.mcp.runtime import tool_session
|
|
13
13
|
from proteworks.pull import pull_path
|
|
14
14
|
from proteworks.push import looks_like_glob, push_paths
|
|
@@ -40,8 +40,9 @@ def list_files(remote: str, limit: int = 200) -> dict[str, Any]:
|
|
|
40
40
|
def pull(remote: str, dest: str, dry_run: bool = True, confirm_transfer: bool = False) -> dict[str, Any]:
|
|
41
41
|
"""Download a remote file, folder/, or wildcard into a local path.
|
|
42
42
|
|
|
43
|
-
Default dry_run=true lists planned paths without downloading. Set dry_run=false
|
|
44
|
-
|
|
43
|
+
Default dry_run=true lists planned paths without downloading. Set dry_run=false,
|
|
44
|
+
confirm_transfer=true, and PROTEWORKS_MCP_ALLOW_TRANSFERS=1 in the connector
|
|
45
|
+
environment to transfer. Remote examples: '~/My Files/campaigns/run/',
|
|
45
46
|
'~/My Files/msa/*.cif'. dest must be under an allowlisted local project root
|
|
46
47
|
(see proteworks mcp setup-claude-science --mcp-root).
|
|
47
48
|
"""
|
|
@@ -50,6 +51,10 @@ def pull(remote: str, dest: str, dry_run: bool = True, confirm_transfer: bool =
|
|
|
50
51
|
"confirmation_required",
|
|
51
52
|
"Set confirm_transfer=true when dry_run=false to download files.",
|
|
52
53
|
)
|
|
54
|
+
if not dry_run:
|
|
55
|
+
blocked = refuse_if_transfers_disallowed("pull")
|
|
56
|
+
if blocked:
|
|
57
|
+
return blocked
|
|
53
58
|
session = tool_session()
|
|
54
59
|
if isinstance(session, SessionError):
|
|
55
60
|
return {"ok": False, "error": session.code, "message": session.message}
|
|
@@ -72,9 +77,9 @@ def push(
|
|
|
72
77
|
) -> dict[str, Any]:
|
|
73
78
|
"""Upload a local file, directory, glob, or .zip into a remote folder.
|
|
74
79
|
|
|
75
|
-
``remote`` must end with '/'. Prefer dry_run=true first; set dry_run=false
|
|
76
|
-
confirm_transfer=true to upload. A single
|
|
77
|
-
unless extract_mode='never'.
|
|
80
|
+
``remote`` must end with '/'. Prefer dry_run=true first; set dry_run=false,
|
|
81
|
+
confirm_transfer=true, and PROTEWORKS_MCP_ALLOW_TRANSFERS=1 to upload. A single
|
|
82
|
+
.zip is unpacked into the destination unless extract_mode='never'.
|
|
78
83
|
"""
|
|
79
84
|
blocked = refuse_if_read_only("push")
|
|
80
85
|
if blocked:
|
|
@@ -84,6 +89,10 @@ def push(
|
|
|
84
89
|
"confirmation_required",
|
|
85
90
|
"Set confirm_transfer=true when dry_run=false to upload files.",
|
|
86
91
|
)
|
|
92
|
+
if not dry_run:
|
|
93
|
+
xfer_blocked = refuse_if_transfers_disallowed("push")
|
|
94
|
+
if xfer_blocked:
|
|
95
|
+
return xfer_blocked
|
|
87
96
|
mode = str(extract_mode or "auto").strip().lower()
|
|
88
97
|
if mode not in {"auto", "always", "never"}:
|
|
89
98
|
return tool_failure(
|
|
@@ -181,7 +190,8 @@ def register(server) -> None:
|
|
|
181
190
|
name="pull",
|
|
182
191
|
description=(
|
|
183
192
|
"Download from Proteworks to a local path under an allowlisted project root. "
|
|
184
|
-
"Prefer dry_run=true first. When dry_run=false, set confirm_transfer=true
|
|
193
|
+
"Prefer dry_run=true first. When dry_run=false, set confirm_transfer=true and "
|
|
194
|
+
"PROTEWORKS_MCP_ALLOW_TRANSFERS=1 in the connector environment. "
|
|
185
195
|
"Remote paths use file-browser syntax ('~/My Files/.../'); trailing slash is a "
|
|
186
196
|
"folder. If path escapes the workspace, the user should run: "
|
|
187
197
|
"proteworks mcp setup-claude-science --mcp-root <project>. "
|
|
@@ -194,7 +204,8 @@ def register(server) -> None:
|
|
|
194
204
|
description=(
|
|
195
205
|
"Upload a local file, directory, glob, or .zip to a Proteworks folder "
|
|
196
206
|
"(remote must end with '/'). Prefer dry_run=true first; when dry_run=false "
|
|
197
|
-
"set confirm_transfer=true. A single
|
|
198
|
-
"unless extract_mode='never'.
|
|
207
|
+
"set confirm_transfer=true and PROTEWORKS_MCP_ALLOW_TRANSFERS=1. A single "
|
|
208
|
+
".zip is unpacked into the destination unless extract_mode='never'. "
|
|
209
|
+
"Requires login and an active organization."
|
|
199
210
|
),
|
|
200
211
|
)
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from proteworks.config import Settings, TokenStore
|
|
8
8
|
from proteworks.mcp.errors import tool_error_kind, tool_error_message
|
|
9
|
+
from proteworks.mcp.profile import org_switch_blocked, refuse_if_read_only
|
|
9
10
|
from proteworks.mcp.runtime import tool_auth, tool_settings
|
|
10
11
|
from proteworks.orgs import (
|
|
11
12
|
OrgSelectionError,
|
|
@@ -102,7 +103,14 @@ def organization_list() -> dict[str, Any]:
|
|
|
102
103
|
|
|
103
104
|
|
|
104
105
|
def organization_set(name: str, confirm: bool = False) -> dict[str, Any]:
|
|
105
|
-
"""Set the active organization by name or id from organization_list (requires confirm=true).
|
|
106
|
+
"""Set the active organization by name or id from organization_list (requires confirm=true).
|
|
107
|
+
|
|
108
|
+
Changing an already-set org also requires PROTEWORKS_MCP_ALLOW_ORG_SWITCH in the
|
|
109
|
+
connector environment (confirm=true alone is not agent-proof).
|
|
110
|
+
"""
|
|
111
|
+
blocked = refuse_if_read_only("organization_set")
|
|
112
|
+
if blocked:
|
|
113
|
+
return blocked
|
|
106
114
|
if not confirm:
|
|
107
115
|
return {
|
|
108
116
|
"ok": False,
|
|
@@ -119,11 +127,21 @@ def organization_set(name: str, confirm: bool = False) -> dict[str, Any]:
|
|
|
119
127
|
org = find_organization(api.list_organizations(), name)
|
|
120
128
|
except OrgSelectionError as exc:
|
|
121
129
|
return {"ok": False, "error": "org_required", "message": str(exc)}
|
|
130
|
+
switch_err = org_switch_blocked(
|
|
131
|
+
current_org_id=config.organization_id(),
|
|
132
|
+
target_org_id=str(org["id"]),
|
|
133
|
+
)
|
|
134
|
+
if switch_err:
|
|
135
|
+
return {"ok": False, "error": "org_switch_not_allowed", "message": switch_err}
|
|
136
|
+
previous_id = config.organization_id()
|
|
122
137
|
config.set_organization(org["id"], settings.api_url)
|
|
138
|
+
switched = bool(previous_id and previous_id != org["id"])
|
|
123
139
|
return {
|
|
124
140
|
"ok": True,
|
|
125
141
|
"organization": {"id": org.get("id"), "name": org.get("name")},
|
|
126
142
|
"message": f"Active organization: {org['name']}",
|
|
143
|
+
"org_switched": switched,
|
|
144
|
+
"previous_organization_id": previous_id if switched else None,
|
|
127
145
|
}
|
|
128
146
|
finally:
|
|
129
147
|
api.close()
|
|
@@ -152,7 +170,9 @@ def register(server) -> None:
|
|
|
152
170
|
name="organization_set",
|
|
153
171
|
description=(
|
|
154
172
|
"Save the active Proteworks organization by exact name or id from organization_list. "
|
|
155
|
-
"Requires confirm=true.
|
|
156
|
-
"
|
|
173
|
+
"Requires confirm=true. Changing an already-set org also requires "
|
|
174
|
+
"PROTEWORKS_MCP_ALLOW_ORG_SWITCH=1 (or a comma-separated org-id allowlist) in the "
|
|
175
|
+
"connector environment. Required before list_files, pull, or push when the user "
|
|
176
|
+
"belongs to more than one org. Disabled while PROTEWORKS_MCP_READ_ONLY is set."
|
|
157
177
|
),
|
|
158
178
|
)
|
|
@@ -210,6 +210,9 @@ def prepare_refolding_from_proteinmpnn_studio(
|
|
|
210
210
|
upload_folder_id: Optional[str] = None,
|
|
211
211
|
) -> dict[str, Any]:
|
|
212
212
|
"""Build a refolding SubmitWorkflowRequest from a ProteinMPNN .studio selection (no submit)."""
|
|
213
|
+
blocked = refuse_if_read_only("prepare_refolding_from_proteinmpnn_studio")
|
|
214
|
+
if blocked:
|
|
215
|
+
return blocked
|
|
213
216
|
err = assert_allowlisted("refolding")
|
|
214
217
|
if err:
|
|
215
218
|
return tool_failure("not_allowlisted", err)
|
|
@@ -661,6 +661,8 @@ def setup_claude_science(
|
|
|
661
661
|
"\n"
|
|
662
662
|
"Optional env (only if the UI has an env section):\n"
|
|
663
663
|
f" PROTEWORKS_CONFIG_DIR={config_dir}\n"
|
|
664
|
+
" PROTEWORKS_MCP_ALLOW_TRANSFERS=1 # required for non-dry-run pull/push\n"
|
|
665
|
+
" PROTEWORKS_MCP_ALLOW_ORG_SWITCH=1 # required to change an already-set org\n"
|
|
664
666
|
"\n"
|
|
665
667
|
"Paste only that Command path. Do not use uv-tool paths, python -m, or a\n"
|
|
666
668
|
"second copy under $HOME — one install under /opt/proteworks.\n"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Keep signed URLs and storage locations out of logs and saved state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_SECRET_KEYS = {
|
|
10
|
+
"url",
|
|
11
|
+
"upload_url",
|
|
12
|
+
"download_url",
|
|
13
|
+
"signed_url",
|
|
14
|
+
"cloud_storage_url",
|
|
15
|
+
"upload_headers",
|
|
16
|
+
"access_token",
|
|
17
|
+
"refresh_token",
|
|
18
|
+
"id_token",
|
|
19
|
+
"token",
|
|
20
|
+
"authorization",
|
|
21
|
+
"Authorization",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# JWT-shaped strings (header.payload.signature) and common API key prefixes.
|
|
25
|
+
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
|
|
26
|
+
_BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]+=*")
|
|
27
|
+
_SK_RE = re.compile(r"\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b")
|
|
28
|
+
_GOOG_SIGNED_RE = re.compile(
|
|
29
|
+
r"(?i)(?:X-Goog-(?:Signature|Credential|Algorithm|Date|Expires|SignedHeaders)=|"
|
|
30
|
+
r"GoogleAccessId=)[^\s&]+"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _redact_string(value: str) -> str:
|
|
35
|
+
lowered = value.lower()
|
|
36
|
+
if lowered.startswith("gs://"):
|
|
37
|
+
return "[redacted]"
|
|
38
|
+
if any(
|
|
39
|
+
host in lowered
|
|
40
|
+
for host in (
|
|
41
|
+
"storage.googleapis.com",
|
|
42
|
+
"googleapis.com",
|
|
43
|
+
"googleusercontent.com",
|
|
44
|
+
)
|
|
45
|
+
):
|
|
46
|
+
return "[redacted]"
|
|
47
|
+
if "x-goog-signature=" in lowered or "x-goog-credential=" in lowered:
|
|
48
|
+
return "[redacted]"
|
|
49
|
+
if lowered.startswith("bearer "):
|
|
50
|
+
return "[redacted]"
|
|
51
|
+
# Full-string JWT / opaque token heuristics.
|
|
52
|
+
if value.startswith("eyJ") and value.count(".") >= 2:
|
|
53
|
+
return "[redacted]"
|
|
54
|
+
if value.startswith("sk-") or value.startswith("sk-ant-"):
|
|
55
|
+
return "[redacted]"
|
|
56
|
+
cleaned = value
|
|
57
|
+
cleaned = _JWT_RE.sub("[redacted]", cleaned)
|
|
58
|
+
cleaned = _BEARER_RE.sub("Bearer [redacted]", cleaned)
|
|
59
|
+
cleaned = _SK_RE.sub("[redacted]", cleaned)
|
|
60
|
+
cleaned = _GOOG_SIGNED_RE.sub("[redacted]", cleaned)
|
|
61
|
+
return cleaned
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact(value: Any) -> Any:
|
|
65
|
+
"""Return a copy with storage URLs and signed-URL fields removed."""
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
cleaned = {}
|
|
68
|
+
for key, item in value.items():
|
|
69
|
+
if key in _SECRET_KEYS:
|
|
70
|
+
cleaned[key] = "[redacted]"
|
|
71
|
+
else:
|
|
72
|
+
cleaned[key] = redact(item)
|
|
73
|
+
return cleaned
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
return [redact(item) for item in value]
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
return _redact_string(value)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RedactFilter(logging.Filter):
|
|
82
|
+
"""Drop storage URLs from log records."""
|
|
83
|
+
|
|
84
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
85
|
+
if isinstance(record.msg, str):
|
|
86
|
+
record.msg = str(redact(record.msg))
|
|
87
|
+
if record.args:
|
|
88
|
+
record.args = redact(record.args)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def install_redacting_logs() -> None:
|
|
93
|
+
"""Attach the redaction filter to the root logger once."""
|
|
94
|
+
root = logging.getLogger()
|
|
95
|
+
if any(isinstance(item, RedactFilter) for item in root.filters):
|
|
96
|
+
return
|
|
97
|
+
root.addFilter(RedactFilter())
|
|
@@ -53,6 +53,11 @@ def open_session(
|
|
|
53
53
|
api.close()
|
|
54
54
|
return SessionError(code="org_required", message=str(exc))
|
|
55
55
|
api.organization_id = org["id"]
|
|
56
|
+
# Persist org + API URL on first session (e.g. single-org auto-select) so
|
|
57
|
+
# bound_api_url always sticks for subsequent authenticated_client checks.
|
|
58
|
+
org_id = str(org["id"])
|
|
59
|
+
if config.organization_id() != org_id or not config.bound_api_url():
|
|
60
|
+
config.set_organization(org_id, settings.api_url)
|
|
56
61
|
return Session(api=api, organization=org, settings=settings, config=config)
|
|
57
62
|
|
|
58
63
|
|
|
@@ -20,13 +20,24 @@ from proteworks.studio.kinds.base import (
|
|
|
20
20
|
|
|
21
21
|
_EXCLUDE = frozenset({"assets_json", "row_key", "designID", "design_id", "example_id"})
|
|
22
22
|
|
|
23
|
+
# Keep aligned with Protein Studio rfd4ProteinaMetricLabels DEFAULT_METRIC_CANDIDATES
|
|
24
|
+
# and common design-native parquet columns written by the RFD4 bundle builder.
|
|
23
25
|
KNOWN_SCORE_COLUMNS = (
|
|
26
|
+
"radius_of_gyration.radius_of_gyration_coi",
|
|
27
|
+
"radius_of_gyration.radius_of_gyration",
|
|
24
28
|
"clash_metrics.frac_atoms_clashing_1p5A",
|
|
29
|
+
"bond_length_metrics.bond_length.frac_unreasonable",
|
|
25
30
|
"bond_length_metrics.frac_unreasonable",
|
|
26
31
|
"sse_distribution.helix_fraction",
|
|
27
32
|
"sse_distribution.sheet_fraction",
|
|
33
|
+
"sse_distribution.loop_fraction",
|
|
34
|
+
"sse_distribution.helix_fraction_coi",
|
|
35
|
+
"sse_distribution.sheet_fraction_coi",
|
|
28
36
|
"sequence_adherence.sequence_adherence",
|
|
29
37
|
"hotspot_obedience.hotspot_obedience",
|
|
38
|
+
"amino_acid_composition.ala_fraction",
|
|
39
|
+
"ca_rmsd_to_input",
|
|
40
|
+
"join_point_rmsd",
|
|
30
41
|
)
|
|
31
42
|
|
|
32
43
|
RFD4_PROTEINA_SPEC = StudioKindSpec(
|
|
@@ -60,10 +71,13 @@ class Rfd4ProteinaStudioAdapter(StudioKindAdapter):
|
|
|
60
71
|
"bond",
|
|
61
72
|
"helix",
|
|
62
73
|
"sheet",
|
|
74
|
+
"loop",
|
|
63
75
|
"adherence",
|
|
64
76
|
"hotspot",
|
|
65
77
|
"rmsd",
|
|
66
78
|
"fraction",
|
|
79
|
+
"gyration",
|
|
80
|
+
"radius",
|
|
67
81
|
),
|
|
68
82
|
exclude_keys=_EXCLUDE,
|
|
69
83
|
)
|
|
@@ -36,6 +36,71 @@ CLIENT_ONLY_PARAM_KEYS = frozenset(
|
|
|
36
36
|
}
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
# Keep in sync with Protein Studio backend CLIENT_RESERVED_WORKFLOW_PARAMETER_KEYS
|
|
40
|
+
# (server-injected GCS paths and pod shapes). Also reject pattern matches below.
|
|
41
|
+
_REFOLDING_METHOD_GPU_KEYS = (
|
|
42
|
+
"boltz",
|
|
43
|
+
"protenix",
|
|
44
|
+
"opendde",
|
|
45
|
+
"promera",
|
|
46
|
+
"intellifold",
|
|
47
|
+
"openfold3",
|
|
48
|
+
"chai1",
|
|
49
|
+
"rf3",
|
|
50
|
+
"esmfold",
|
|
51
|
+
"esmfold2",
|
|
52
|
+
"alphafold2",
|
|
53
|
+
"alphafold-initial-guess",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
CLIENT_RESERVED_WORKFLOW_PARAMETER_KEYS = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"output-gcs-path",
|
|
59
|
+
"output-gcs-path-base",
|
|
60
|
+
"scratch-gcs-path",
|
|
61
|
+
"gcs-bucket",
|
|
62
|
+
"json-tarball-gcs-path",
|
|
63
|
+
"msa-tarball-gcs-path",
|
|
64
|
+
"msa-output-tarball-gcs-path",
|
|
65
|
+
"yaml-tarball-gcs-path",
|
|
66
|
+
"templates-tarball-gcs-path",
|
|
67
|
+
"execution-plan-gcs-path",
|
|
68
|
+
"reuse-copy-instructions-gcs-path",
|
|
69
|
+
"input-manifest-gcs-path",
|
|
70
|
+
"gpu-pod-cpu",
|
|
71
|
+
"gpu-pod-memory",
|
|
72
|
+
"gpu-compute-class",
|
|
73
|
+
"esmc-gpu-pod-cpu",
|
|
74
|
+
"esmc-gpu-pod-memory",
|
|
75
|
+
"esmc-gpu-compute-class",
|
|
76
|
+
*(
|
|
77
|
+
key
|
|
78
|
+
for method in _REFOLDING_METHOD_GPU_KEYS
|
|
79
|
+
for key in (
|
|
80
|
+
f"{method}-gpu-pod-cpu",
|
|
81
|
+
f"{method}-gpu-pod-memory",
|
|
82
|
+
f"{method}-gpu-compute-class",
|
|
83
|
+
)
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_client_reserved_param_key(key: str) -> bool:
|
|
90
|
+
"""True for server-only workflow parameters (exact keys or GCS/pod-shape patterns)."""
|
|
91
|
+
dashed = str(key or "").strip().replace("_", "-")
|
|
92
|
+
if not dashed:
|
|
93
|
+
return False
|
|
94
|
+
if dashed in CLIENT_RESERVED_WORKFLOW_PARAMETER_KEYS:
|
|
95
|
+
return True
|
|
96
|
+
if dashed.endswith("-gcs-path") or dashed.endswith("-gcs-path-base"):
|
|
97
|
+
return True
|
|
98
|
+
if dashed.endswith("-gpu-pod-cpu") or dashed.endswith("-gpu-pod-memory"):
|
|
99
|
+
return True
|
|
100
|
+
if dashed.endswith("-gpu-compute-class"):
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
39
104
|
# Hyphen ↔ underscore aliases accepted for file roles when filtering overrides.
|
|
40
105
|
FILE_ROLE_ALIASES: dict[str, str] = {
|
|
41
106
|
"msa-output-folder": "msa_output_folder",
|
|
@@ -72,8 +137,6 @@ _EXTRA_PARAM_PREFIXES: dict[str, tuple[str, ...]] = {
|
|
|
72
137
|
"batch",
|
|
73
138
|
"target",
|
|
74
139
|
"compile",
|
|
75
|
-
"hf_",
|
|
76
|
-
"hf-",
|
|
77
140
|
"metrics",
|
|
78
141
|
"design",
|
|
79
142
|
),
|
|
@@ -359,6 +422,8 @@ def normalize_file_role(name: str) -> str:
|
|
|
359
422
|
|
|
360
423
|
|
|
361
424
|
def _param_key_allowed(key: str, allowed: frozenset[str], workflow_name: str) -> bool:
|
|
425
|
+
if is_client_reserved_param_key(key):
|
|
426
|
+
return False
|
|
362
427
|
if key in allowed or key in EXTRA_AGENT_PARAM_KEYS:
|
|
363
428
|
return True
|
|
364
429
|
prefixes = _EXTRA_PARAM_PREFIXES.get(workflow_name) or ()
|
|
@@ -371,16 +436,23 @@ def filter_parameters_to_schema(
|
|
|
371
436
|
*,
|
|
372
437
|
workflow_name: str = "",
|
|
373
438
|
) -> tuple[dict[str, Any], Optional[str]]:
|
|
374
|
-
"""Keep schema / allowlisted advanced keys; reject unknown keys (client-only stripped)."""
|
|
439
|
+
"""Keep schema / allowlisted advanced keys; reject unknown/reserved keys (client-only stripped)."""
|
|
375
440
|
out: dict[str, Any] = {}
|
|
376
441
|
unknown: list[str] = []
|
|
442
|
+
reserved: list[str] = []
|
|
377
443
|
for key, value in parameters.items():
|
|
378
444
|
if key in CLIENT_ONLY_PARAM_KEYS:
|
|
379
445
|
continue
|
|
446
|
+
if is_client_reserved_param_key(key):
|
|
447
|
+
reserved.append(key)
|
|
448
|
+
continue
|
|
380
449
|
if not _param_key_allowed(key, allowed, workflow_name):
|
|
381
450
|
unknown.append(key)
|
|
382
451
|
continue
|
|
383
452
|
out[key] = value
|
|
453
|
+
if reserved:
|
|
454
|
+
preview = ", ".join(sorted(reserved)[:8])
|
|
455
|
+
return out, f"Reserved parameter key(s) are server-only: {preview}."
|
|
384
456
|
if unknown:
|
|
385
457
|
preview = ", ".join(sorted(unknown)[:8])
|
|
386
458
|
return out, f"Unknown parameter key(s) not in schema: {preview}."
|
|
@@ -421,7 +493,11 @@ def defaults_from_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
421
493
|
|
|
422
494
|
|
|
423
495
|
def strip_client_only(parameters: dict[str, Any]) -> dict[str, Any]:
|
|
424
|
-
return {
|
|
496
|
+
return {
|
|
497
|
+
k: v
|
|
498
|
+
for k, v in parameters.items()
|
|
499
|
+
if k not in CLIENT_ONLY_PARAM_KEYS and not is_client_reserved_param_key(k)
|
|
500
|
+
}
|
|
425
501
|
|
|
426
502
|
|
|
427
503
|
def merge_workflow_submission(
|
|
@@ -119,6 +119,8 @@ def test_resolve_under_workspace_rejects_escape(tmp_path, monkeypatch):
|
|
|
119
119
|
|
|
120
120
|
def test_storage_url_host_allowlist():
|
|
121
121
|
assert_allowed_storage_url("https://storage.googleapis.com/bucket/obj")
|
|
122
|
+
with pytest.raises(ApiError, match="https"):
|
|
123
|
+
assert_allowed_storage_url("http://storage.googleapis.com/bucket/obj")
|
|
122
124
|
with pytest.raises(ApiError):
|
|
123
125
|
assert_allowed_storage_url("https://evil.example/steal")
|
|
124
126
|
|
|
@@ -83,6 +83,16 @@ def test_pull_and_push_tools_dry_run_and_transfer(tmp_path, monkeypatch):
|
|
|
83
83
|
assert denied["ok"] is False
|
|
84
84
|
assert denied["error"] == "confirmation_required"
|
|
85
85
|
|
|
86
|
+
no_env = tools_files.pull(
|
|
87
|
+
"~/My Files/campaigns/",
|
|
88
|
+
str(dest),
|
|
89
|
+
dry_run=False,
|
|
90
|
+
confirm_transfer=True,
|
|
91
|
+
)
|
|
92
|
+
assert no_env["ok"] is False
|
|
93
|
+
assert no_env["error"] == "transfer_not_allowed"
|
|
94
|
+
|
|
95
|
+
monkeypatch.setenv("PROTEWORKS_MCP_ALLOW_TRANSFERS", "1")
|
|
86
96
|
got = tools_files.pull(
|
|
87
97
|
"~/My Files/campaigns/",
|
|
88
98
|
str(dest),
|
|
@@ -138,7 +148,7 @@ def test_create_folder_tool(tmp_path):
|
|
|
138
148
|
server.stop()
|
|
139
149
|
|
|
140
150
|
|
|
141
|
-
def test_organization_set_tool(tmp_path):
|
|
151
|
+
def test_organization_set_tool(tmp_path, monkeypatch):
|
|
142
152
|
server = FakeApi([_org("a", "Alpha"), _org("b", "Beta")], []).start()
|
|
143
153
|
try:
|
|
144
154
|
set_overrides(settings=_settings(tmp_path, server.url), token_store=MemoryTokens())
|
|
@@ -150,11 +160,25 @@ def test_organization_set_tool(tmp_path):
|
|
|
150
160
|
assert denied["ok"] is False
|
|
151
161
|
assert denied["error"] == "confirmation_required"
|
|
152
162
|
|
|
163
|
+
# Initial set (no org configured yet) only needs confirm=true.
|
|
153
164
|
set_result = tools_orgs.organization_set("Beta", confirm=True)
|
|
154
165
|
assert set_result["ok"] is True
|
|
155
166
|
assert set_result["organization"]["id"] == "b"
|
|
167
|
+
assert set_result["org_switched"] is False
|
|
156
168
|
|
|
157
169
|
status = tools_orgs.auth_status()
|
|
158
170
|
assert status["organization"]["name"] == "Beta"
|
|
171
|
+
|
|
172
|
+
# Switching away from an already-set org requires connector env allow.
|
|
173
|
+
blocked = tools_orgs.organization_set("Alpha", confirm=True)
|
|
174
|
+
assert blocked["ok"] is False
|
|
175
|
+
assert blocked["error"] == "org_switch_not_allowed"
|
|
176
|
+
|
|
177
|
+
monkeypatch.setenv("PROTEWORKS_MCP_ALLOW_ORG_SWITCH", "1")
|
|
178
|
+
switched = tools_orgs.organization_set("Alpha", confirm=True)
|
|
179
|
+
assert switched["ok"] is True
|
|
180
|
+
assert switched["organization"]["id"] == "a"
|
|
181
|
+
assert switched["org_switched"] is True
|
|
182
|
+
assert switched["previous_organization_id"] == "b"
|
|
159
183
|
finally:
|
|
160
184
|
server.stop()
|
|
@@ -160,6 +160,8 @@ def test_setup_claude_science_dry_run_and_write(tmp_path: Path):
|
|
|
160
160
|
assert "proteworks-production.storage.googleapis.com" in printed
|
|
161
161
|
assert "Science rejects wildcards" in printed
|
|
162
162
|
assert "Do not add bare storage.googleapis.com" in printed
|
|
163
|
+
assert "PROTEWORKS_MCP_ALLOW_TRANSFERS=1" in printed
|
|
164
|
+
assert "PROTEWORKS_MCP_ALLOW_ORG_SWITCH=1" in printed
|
|
163
165
|
|
|
164
166
|
|
|
165
167
|
def test_science_read_paths_includes_config_dir(tmp_path: Path):
|
|
@@ -143,6 +143,11 @@ def test_inspect_studio_refolding_and_rfd3(tmp_path, monkeypatch):
|
|
|
143
143
|
finally:
|
|
144
144
|
server.stop()
|
|
145
145
|
|
|
146
|
+
# Fresh FakeApi port → clear URL binding written by the first session.
|
|
147
|
+
bound = tmp_path / "config" / "config.json"
|
|
148
|
+
if bound.exists():
|
|
149
|
+
bound.unlink()
|
|
150
|
+
|
|
146
151
|
# RFD3
|
|
147
152
|
studio = _rfd3_studio_bytes()
|
|
148
153
|
server = FakeApi([_org("reg", "Research")], [_item()], signed_get_body=studio).start()
|
|
@@ -207,6 +212,30 @@ def test_save_selected_studio_refolding(tmp_path, monkeypatch):
|
|
|
207
212
|
server.stop()
|
|
208
213
|
|
|
209
214
|
|
|
215
|
+
def test_prepare_refolding_from_proteinmpnn_studio_read_only(tmp_path, monkeypatch):
|
|
216
|
+
_mcp_env(tmp_path, monkeypatch)
|
|
217
|
+
monkeypatch.setenv("PROTEWORKS_MCP_READ_ONLY", "1")
|
|
218
|
+
studio = _mpnn_studio_bytes()
|
|
219
|
+
server = FakeApi([_org("reg", "Research")], [_item()], signed_get_body=studio).start()
|
|
220
|
+
try:
|
|
221
|
+
set_overrides(settings=_settings(tmp_path, server.url), token_store=MemoryTokens())
|
|
222
|
+
prepared = tools_studio.prepare_refolding_from_proteinmpnn_studio(
|
|
223
|
+
studio_file_id="studio-1",
|
|
224
|
+
sequence_ids=["seq-a"],
|
|
225
|
+
files={"refolding_msa_output_folder": "msa-folder-1"},
|
|
226
|
+
parameters={
|
|
227
|
+
"binder-chains": ["B"],
|
|
228
|
+
"target-chains": ["A"],
|
|
229
|
+
"refolding-run-boltz": "true",
|
|
230
|
+
},
|
|
231
|
+
upload_folder_id="folder-1",
|
|
232
|
+
)
|
|
233
|
+
assert prepared["ok"] is False
|
|
234
|
+
assert prepared["error"] == "read_only"
|
|
235
|
+
finally:
|
|
236
|
+
server.stop()
|
|
237
|
+
|
|
238
|
+
|
|
210
239
|
def test_inspect_and_prepare_refolding_from_proteinmpnn_studio(tmp_path, monkeypatch):
|
|
211
240
|
_mcp_env(tmp_path, monkeypatch)
|
|
212
241
|
studio = _mpnn_studio_bytes()
|
|
@@ -90,6 +90,8 @@ def test_merge_strips_client_only_and_applies_overrides():
|
|
|
90
90
|
"num_designs": 3,
|
|
91
91
|
"generator_residue_id_map": {"x": 1},
|
|
92
92
|
"motif_spec": {},
|
|
93
|
+
"output-gcs-path": "gs://evil/path",
|
|
94
|
+
"msa-tarball-gcs-path": "gs://evil/msa",
|
|
93
95
|
},
|
|
94
96
|
output_folder_name="Out",
|
|
95
97
|
)
|
|
@@ -99,9 +101,31 @@ def test_merge_strips_client_only_and_applies_overrides():
|
|
|
99
101
|
assert payload["parameters"]["inference-mode"] == "exact"
|
|
100
102
|
assert "generator_residue_id_map" not in payload["parameters"]
|
|
101
103
|
assert "motif_spec" not in payload["parameters"]
|
|
104
|
+
assert "output-gcs-path" not in payload["parameters"]
|
|
105
|
+
assert "msa-tarball-gcs-path" not in payload["parameters"]
|
|
102
106
|
assert payload["output_folder_name"] == "Out"
|
|
103
107
|
|
|
104
108
|
|
|
109
|
+
def test_prepare_rejects_reserved_gcs_and_pod_params(tmp_path):
|
|
110
|
+
server = FakeApi([_org("reg", "Research")], [_item()]).start()
|
|
111
|
+
try:
|
|
112
|
+
set_overrides(settings=_settings(tmp_path, server.url), token_store=MemoryTokens())
|
|
113
|
+
prepared = tools_workflows.prepare_workflow_submission(
|
|
114
|
+
"rf-diffusion3",
|
|
115
|
+
parameters={
|
|
116
|
+
"contig": "40-60",
|
|
117
|
+
"output-gcs-path": "gs://evil/path",
|
|
118
|
+
"gpu-pod-cpu": "32",
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
assert prepared["ok"] is False
|
|
122
|
+
assert prepared["error"] == "validation_error"
|
|
123
|
+
assert "Reserved" in prepared["message"]
|
|
124
|
+
assert server.last_submit_body is None
|
|
125
|
+
finally:
|
|
126
|
+
server.stop()
|
|
127
|
+
|
|
128
|
+
|
|
105
129
|
def test_prepare_and_submit_reject_non_allowlisted(tmp_path):
|
|
106
130
|
server = FakeApi([_org("reg", "Research")], [_item()]).start()
|
|
107
131
|
try:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Redaction of storage URLs and credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from proteworks.redact import redact
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_redact_storage_and_tokens():
|
|
9
|
+
payload = {
|
|
10
|
+
"url": "https://storage.googleapis.com/bucket/obj?X-Goog-Signature=abc",
|
|
11
|
+
"access_token": "secret",
|
|
12
|
+
"note": "ok",
|
|
13
|
+
"nested": {
|
|
14
|
+
"cloud_storage_url": "gs://bucket/obj",
|
|
15
|
+
"msg": "Bearer eyJhbGciOiJIUzI1NiJ9.e30.signature",
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
cleaned = redact(payload)
|
|
19
|
+
assert cleaned["url"] == "[redacted]"
|
|
20
|
+
assert cleaned["access_token"] == "[redacted]"
|
|
21
|
+
assert cleaned["note"] == "ok"
|
|
22
|
+
assert cleaned["nested"]["cloud_storage_url"] == "[redacted]"
|
|
23
|
+
assert "eyJ" not in cleaned["nested"]["msg"]
|
|
24
|
+
assert "Bearer [redacted]" in cleaned["nested"]["msg"] or cleaned["nested"]["msg"] == "[redacted]"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_redact_inline_jwt_and_sk():
|
|
28
|
+
text = "token=eyJhbGciOiJIUzI1NiJ9.e30.abc key=sk-ant-abcdef12345678"
|
|
29
|
+
cleaned = redact(text)
|
|
30
|
+
assert "eyJ" not in cleaned
|
|
31
|
+
assert "sk-ant-" not in cleaned
|
|
@@ -20,6 +20,27 @@ def test_studio_adapter_registered():
|
|
|
20
20
|
assert adapter.spec.route_slug == "rfd4-proteina"
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def test_studio_adapter_indexes_design_native_scores():
|
|
24
|
+
adapter = adapter_for_data_kind("rfd4-proteina")
|
|
25
|
+
assert adapter is not None
|
|
26
|
+
rows = adapter.summarize_rows(
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
"designID": "design_0",
|
|
30
|
+
"row_key": "design_0",
|
|
31
|
+
"clash_metrics.frac_atoms_clashing_1p5A": "0.01",
|
|
32
|
+
"radius_of_gyration.radius_of_gyration_coi": "12.5",
|
|
33
|
+
"hotspot_obedience.hotspot_obedience": "0.9",
|
|
34
|
+
"assets_json": '[{"asset_key":"structure","file_id":"fid-0","filename":"design_0.cif"}]',
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
assert len(rows) == 1
|
|
39
|
+
assert rows[0].scores.get("clash_metrics.frac_atoms_clashing_1p5A") == 0.01
|
|
40
|
+
assert rows[0].scores.get("radius_of_gyration.radius_of_gyration_coi") == 12.5
|
|
41
|
+
assert rows[0].scores.get("hotspot_obedience.hotspot_obedience") == 0.9
|
|
42
|
+
|
|
43
|
+
|
|
23
44
|
def test_binder_requires_structure_and_contig():
|
|
24
45
|
err = assert_rfd4_proteina_has_design_spec(
|
|
25
46
|
{"design_mode": "binder", "contig": "50-120, /0, A1-10"},
|
|
@@ -37,9 +37,13 @@ def test_open_session_requires_org_when_multiple(tmp_path):
|
|
|
37
37
|
def test_open_session_succeeds_for_single_org(tmp_path):
|
|
38
38
|
server = FakeApi([_org("reg", "Research")], []).start()
|
|
39
39
|
try:
|
|
40
|
-
|
|
40
|
+
settings = _settings(tmp_path, server.url)
|
|
41
|
+
result = open_session(settings=settings, token_store=MemoryTokens())
|
|
41
42
|
assert isinstance(result, Session)
|
|
42
43
|
assert result.organization["id"] == "reg"
|
|
44
|
+
# Single-org auto-select must persist org + API URL binding.
|
|
45
|
+
assert result.config.organization_id() == "reg"
|
|
46
|
+
assert result.config.bound_api_url() == settings.api_url
|
|
43
47
|
result.api.close()
|
|
44
48
|
finally:
|
|
45
49
|
server.stop()
|
|
@@ -50,16 +50,15 @@ def test_token_store_file_fallback_when_keyring_fails(tmp_path: Path, monkeypatc
|
|
|
50
50
|
assert store.get() == "sandbox-token"
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def
|
|
53
|
+
def test_token_store_working_keyring_does_not_write_file(tmp_path: Path, monkeypatch):
|
|
54
54
|
monkeypatch.setenv("PROTEWORKS_CONFIG_DIR", str(tmp_path))
|
|
55
55
|
backend = _MemoryKeyring()
|
|
56
56
|
keyring.set_keyring(backend)
|
|
57
|
-
backend.set_password(TokenStore.SERVICE, "access_token", "from-keyring")
|
|
58
57
|
store = TokenStore()
|
|
58
|
+
store.set("from-keyring")
|
|
59
59
|
assert not store.path.is_file()
|
|
60
60
|
assert store.get() == "from-keyring"
|
|
61
|
-
assert store.path.is_file()
|
|
62
|
-
assert store.path.read_text(encoding="utf-8").strip() == "from-keyring"
|
|
61
|
+
assert not store.path.is_file()
|
|
63
62
|
|
|
64
63
|
|
|
65
64
|
def test_token_store_reads_file_when_keyring_empty(tmp_path: Path, monkeypatch):
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""MCP capability profile (read-only vs full)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
|
-
_ENV_READ_ONLY = "PROTEWORKS_MCP_READ_ONLY"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def is_read_only() -> bool:
|
|
11
|
-
"""When true, mutating tools (submit, push, mkdir, cancel, save-selected) refuse."""
|
|
12
|
-
return (os.environ.get(_ENV_READ_ONLY) or "").strip().lower() in {
|
|
13
|
-
"1",
|
|
14
|
-
"true",
|
|
15
|
-
"yes",
|
|
16
|
-
"on",
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def refuse_if_read_only(action: str) -> dict | None:
|
|
21
|
-
if not is_read_only():
|
|
22
|
-
return None
|
|
23
|
-
return {
|
|
24
|
-
"ok": False,
|
|
25
|
-
"error": "read_only",
|
|
26
|
-
"message": (
|
|
27
|
-
f"{action} is disabled while {_ENV_READ_ONLY} is set. "
|
|
28
|
-
"Unset it to enable submit/push/create_folder/cancel/save tools."
|
|
29
|
-
),
|
|
30
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""Keep signed URLs and storage locations out of logs and saved state."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
_SECRET_KEYS = {"url", "upload_url", "cloud_storage_url", "upload_headers"}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def redact(value: Any) -> Any:
|
|
12
|
-
"""Return a copy with storage URLs and signed-URL fields removed."""
|
|
13
|
-
if isinstance(value, dict):
|
|
14
|
-
cleaned = {}
|
|
15
|
-
for key, item in value.items():
|
|
16
|
-
if key in _SECRET_KEYS:
|
|
17
|
-
cleaned[key] = "[redacted]"
|
|
18
|
-
else:
|
|
19
|
-
cleaned[key] = redact(item)
|
|
20
|
-
return cleaned
|
|
21
|
-
if isinstance(value, list):
|
|
22
|
-
return [redact(item) for item in value]
|
|
23
|
-
if isinstance(value, str):
|
|
24
|
-
lowered = value.lower()
|
|
25
|
-
if lowered.startswith("gs://") or "storage.googleapis.com" in lowered:
|
|
26
|
-
return "[redacted]"
|
|
27
|
-
return value
|
|
28
|
-
return value
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class RedactFilter(logging.Filter):
|
|
32
|
-
"""Drop storage URLs from log records."""
|
|
33
|
-
|
|
34
|
-
def filter(self, record: logging.LogRecord) -> bool:
|
|
35
|
-
if isinstance(record.msg, str):
|
|
36
|
-
record.msg = str(redact(record.msg))
|
|
37
|
-
if record.args:
|
|
38
|
-
record.args = redact(record.args)
|
|
39
|
-
return True
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def install_redacting_logs() -> None:
|
|
43
|
-
"""Attach the redaction filter to the root logger once."""
|
|
44
|
-
root = logging.getLogger()
|
|
45
|
-
if any(isinstance(item, RedactFilter) for item in root.filters):
|
|
46
|
-
return
|
|
47
|
-
root.addFilter(RedactFilter())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|