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.
Files changed (65) hide show
  1. {proteworks-0.1.9 → proteworks-0.1.10}/PKG-INFO +1 -1
  2. {proteworks-0.1.9 → proteworks-0.1.10}/pyproject.toml +1 -1
  3. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/__init__.py +2 -2
  4. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/api.py +11 -3
  5. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/auth.py +10 -1
  6. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/config.py +29 -14
  7. proteworks-0.1.10/src/proteworks/mcp/profile.py +78 -0
  8. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_files.py +20 -9
  9. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_orgs.py +23 -3
  10. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_studio.py +3 -0
  11. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp_host.py +2 -0
  12. proteworks-0.1.10/src/proteworks/redact.py +97 -0
  13. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/session.py +5 -0
  14. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/rfd4_proteina.py +14 -0
  15. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/workflows.py +80 -4
  16. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/PKG-INFO +1 -1
  17. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/SOURCES.txt +1 -0
  18. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_adversarial.py +2 -0
  19. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_files.py +25 -1
  20. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_host.py +2 -0
  21. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_studio.py +29 -0
  22. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_workflows.py +24 -0
  23. proteworks-0.1.10/tests/test_redact.py +31 -0
  24. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_rfd4_proteina_workflow.py +21 -0
  25. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_session.py +5 -1
  26. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_token_store.py +3 -4
  27. proteworks-0.1.9/src/proteworks/mcp/profile.py +0 -30
  28. proteworks-0.1.9/src/proteworks/redact.py +0 -47
  29. {proteworks-0.1.9 → proteworks-0.1.10}/README.md +0 -0
  30. {proteworks-0.1.9 → proteworks-0.1.10}/setup.cfg +0 -0
  31. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/browse.py +0 -0
  32. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/cli.py +0 -0
  33. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/folders.py +0 -0
  34. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/index.py +0 -0
  35. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/__init__.py +0 -0
  36. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/errors.py +0 -0
  37. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/paths.py +0 -0
  38. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/runtime.py +0 -0
  39. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/server.py +0 -0
  40. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/mcp/tools_workflows.py +0 -0
  41. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/orgs.py +0 -0
  42. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/pull.py +0 -0
  43. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/push.py +0 -0
  44. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/results.py +0 -0
  45. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/__init__.py +0 -0
  46. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/common.py +0 -0
  47. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/__init__.py +0 -0
  48. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/base.py +0 -0
  49. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/proteinmpnn.py +0 -0
  50. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/refolding.py +0 -0
  51. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/rfd3.py +0 -0
  52. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/kinds/structure_prediction.py +0 -0
  53. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio/registry.py +0 -0
  54. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks/studio_proteinmpnn.py +0 -0
  55. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/dependency_links.txt +0 -0
  56. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/entry_points.txt +0 -0
  57. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/requires.txt +0 -0
  58. {proteworks-0.1.9 → proteworks-0.1.10}/src/proteworks.egg-info/top_level.txt +0 -0
  59. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_browse.py +0 -0
  60. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_cli.py +0 -0
  61. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_config_dir.py +0 -0
  62. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_folders.py +0 -0
  63. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_errors.py +0 -0
  64. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_mcp_paths.py +0 -0
  65. {proteworks-0.1.9 → proteworks-0.1.10}/tests/test_transfer_result.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteworks
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Sync files with Proteworks using file-browser paths.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proteworks"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "Sync files with Proteworks using file-browser paths."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -8,6 +8,6 @@ try:
8
8
  try:
9
9
  __version__ = version("proteworks")
10
10
  except PackageNotFoundError:
11
- __version__ = "0.1.9"
11
+ __version__ = "0.1.10"
12
12
  except Exception: # pragma: no cover
13
- __version__ = "0.1.9"
13
+ __version__ = "0.1.10"
@@ -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 LOOPBACK_PORT, LOOPBACK_REDIRECT_PATH, Settings, TokenStore
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. The file lives under ``~/.config/proteworks`` (mode
114
- 0600), which ``setup-claude-science`` already grants read access.
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) -> None:
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
- # File fallback is enough for sandboxed MCP hosts.
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
- self.path.write_text(token + "\n", encoding="utf-8")
167
- try:
168
- os.chmod(self.path, 0o600)
169
- except OSError:
170
- pass
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
- and confirm_transfer=true to transfer. Remote examples: '~/My Files/campaigns/run/',
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 and
76
- confirm_transfer=true to upload. A single .zip is unpacked into the destination
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 .zip is unpacked into the destination "
198
- "unless extract_mode='never'. Requires login and an active organization."
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. Required before list_files, pull, or push when the user "
156
- "belongs to more than one org."
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 {k: v for k, v in parameters.items() if k not in CLIENT_ONLY_PARAM_KEYS}
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteworks
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Sync files with Proteworks using file-browser paths.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -54,6 +54,7 @@ tests/test_mcp_host.py
54
54
  tests/test_mcp_paths.py
55
55
  tests/test_mcp_studio.py
56
56
  tests/test_mcp_workflows.py
57
+ tests/test_redact.py
57
58
  tests/test_rfd4_proteina_workflow.py
58
59
  tests/test_session.py
59
60
  tests/test_token_store.py
@@ -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
- result = open_session(settings=_settings(tmp_path, server.url), token_store=MemoryTokens())
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 test_token_store_lazy_migrates_keyring_to_file(tmp_path: Path, monkeypatch):
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