Skip to content

Commit 09af071

Browse files
Merge branch 'horse-cockroach-43481eda' into security-hardening-agent-workflows
2 parents 8ed63c4 + 738c467 commit 09af071

9 files changed

Lines changed: 358 additions & 41 deletions

‎rsconnect/device_login.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,10 @@ def _start_result(state: dict[str, Any]) -> dict[str, Any]:
770770
}
771771

772772

773+
def _device_code_expired(state: dict[str, Any]) -> bool:
774+
return state["tokens"] is None and state["expires_at"] <= time.time()
775+
776+
773777
def start_connect_login(
774778
url: str,
775779
name: str,
@@ -799,7 +803,7 @@ def _start_connect_login(
799803
store = _store()
800804
_assert_nickname_target(store, "connect", name, server)
801805
state = _read_state("connect", name)
802-
if state and state["expires_at"] <= time.time():
806+
if state and _device_code_expired(state):
803807
_remove_state("connect", name)
804808
state = None
805809
if state:
@@ -833,6 +837,7 @@ def _start_connect_login(
833837
"OAuth client ID",
834838
)
835839
client_id, response = _start_device_request(metadata, server, client_id, None, insecure, ca_data, deadline)
840+
_remaining(deadline)
836841
state = _make_state(
837842
"connect", name, server, metadata, response, client_id, None, set_default, insecure, ca_data, None
838843
)
@@ -864,7 +869,7 @@ def _start_cloud_login(
864869
store = _store()
865870
_assert_nickname_target(store, "cloud", name, server, account)
866871
state = _read_state("cloud", name)
867-
if state and state["expires_at"] <= time.time():
872+
if state and _device_code_expired(state):
868873
_remove_state("cloud", name)
869874
state = None
870875
if state:
@@ -878,6 +883,7 @@ def _start_cloud_login(
878883
client_id = _identifier(connect_cloud.client_id(environment), "OAuth client ID")
879884
deadline = time.monotonic() + _START_TIMEOUT
880885
client_id, response = _start_device_request(metadata, server, client_id, connect_cloud.SCOPE, False, None, deadline)
886+
_remaining(deadline)
881887
state = _make_state(
882888
"cloud",
883889
name,
@@ -1167,7 +1173,7 @@ def _load_pending(kind: str, name: str) -> dict[str, Any]:
11671173
state = _read_state(kind, name)
11681174
if state is None:
11691175
raise RSConnectException('No pending "%s" login exists for nickname "%s".' % (kind, name))
1170-
if state["tokens"] is None and state["expires_at"] <= time.time():
1176+
if _device_code_expired(state):
11711177
_remove_state(kind, name)
11721178
raise _Expired()
11731179
_assert_nickname_target(_store(), kind, name, state["server"], state["account"])

‎rsconnect/http_support.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ def interrupt_request() -> None:
638638
raise socket.timeout("HTTP request deadline exceeded.")
639639
response_body = response.read()
640640
with request_lock:
641-
self._apply_request_deadline()
641+
# A complete token response must reach its checkpoint even after the deadline.
642642
request_completed = True
643643
return response, response_body
644644
except Exception:

‎rsconnect/preflight.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,24 @@ def _deployment_store_paths(target: str) -> list[str]:
155155
def load_preflight_app_store(path: str) -> AppStore:
156156
"""Load safe deployment history for executor target inference."""
157157
module_file = fake_module_file_from_directory(path)
158-
try:
159-
store = AppStore(module_file, strict_read=True)
160-
records = store.get_all()
161-
if not isinstance(records, list) or any(
162-
not isinstance(record, Mapping) or not isinstance(record.get("server_url"), str) or not record["server_url"]
163-
for record in records
164-
):
165-
raise TypeError("Malformed local deployment metadata.")
166-
except _APP_STORE_READ_ERRORS:
167-
return AppStore(module_file, autoload=False, strict_read=True)
158+
store_paths = [path, module_file] if os.path.isfile(path) else [module_file]
159+
store = AppStore(module_file, autoload=False, strict_read=True)
160+
for store_path in store_paths:
161+
try:
162+
candidate = AppStore(store_path, strict_read=True)
163+
records = candidate.get_all()
164+
if not isinstance(records, list) or any(
165+
not isinstance(record, Mapping)
166+
or not isinstance(record.get("server_url"), str)
167+
or not record["server_url"]
168+
for record in records
169+
):
170+
raise TypeError("Malformed local deployment metadata.")
171+
except _APP_STORE_READ_ERRORS:
172+
return AppStore(store_path, autoload=False, strict_read=True)
173+
store = candidate
174+
if records:
175+
return store
168176
return store
169177

170178

‎rsconnect/preflight_node.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,16 @@ def _evaluate_node_range(
176176
return _parse_node_evaluation(result.stdout, len(versions))
177177

178178

179-
def _server_status(value: Any, warnings: List[str]) -> Tuple[Optional[Dict[str, Any]], bool]:
179+
def _server_status(value: Any, warnings: List[str], actions: List[str]) -> Tuple[Optional[Dict[str, Any]], bool]:
180180
if not isinstance(value, MappingABC):
181-
warnings.append("Connect did not return Node.js status metadata; compatibility is unknown.")
181+
warnings.append("Connect did not return Node.js runtime status flags; compatibility is unknown.")
182+
actions.append("Check whether this Connect server exposes Node.js runtime status flags.")
182183
return None, False
183184
status = dict(cast(Mapping[str, Any], value))
185+
if not any(field in status for field in _NODEJS_STATUS_FLAGS):
186+
warnings.append("Connect did not return Node.js runtime status flags; compatibility is unknown.")
187+
actions.append("Check whether this Connect server exposes Node.js runtime status flags.")
188+
return status, False
184189
complete = True
185190
for field in _NODEJS_STATUS_FLAGS:
186191
if field not in status:
@@ -222,30 +227,34 @@ def _server_installations(value: Any, warnings: List[str]) -> Tuple[List[Tuple[s
222227
return installations, metadata_complete, publishability_complete
223228

224229

225-
def _server_nodejs_info(settings: Any, warnings: List[str]) -> Dict[str, Any]:
230+
def _server_nodejs_info(settings: Any, warnings: List[str], actions: List[str]) -> Dict[str, Any]:
226231
info: Dict[str, Any] = {
227232
"enabled": None,
228233
"status": None,
229234
"installations": [],
230-
"metadata_complete": False,
235+
"status_complete": False,
236+
"runtime_complete": False,
231237
"publishability_complete": False,
232238
}
233239
if not isinstance(settings, MappingABC):
234240
warnings.append("Connect did not return Node.js settings; compatibility is unknown.")
241+
actions.append("Check whether this Connect server exposes Node.js runtime settings.")
235242
return info
236243

237244
settings = cast(Mapping[str, Any], settings)
238245
enabled = settings.get("enabled")
239246
if not isinstance(enabled, bool):
240247
warnings.append("Connect did not report whether Node.js is enabled; compatibility is unknown.")
241-
status, status_complete = _server_status(settings.get("status"), warnings)
248+
actions.append("Check whether Node.js is enabled in the Connect server settings.")
249+
status, status_complete = _server_status(settings.get("status"), warnings, actions)
242250
installations, installations_complete, publishability_complete = _server_installations(
243251
settings.get("installations"), warnings
244252
)
245253
info["enabled"] = enabled if isinstance(enabled, bool) else None
246254
info["status"] = status
247255
info["installations"] = installations
248-
info["metadata_complete"] = isinstance(enabled, bool) and status_complete and installations_complete
256+
info["status_complete"] = status_complete
257+
info["runtime_complete"] = isinstance(enabled, bool) and installations_complete
249258
info["publishability_complete"] = publishability_complete
250259
return info
251260

@@ -283,7 +292,7 @@ def _unmatched_server_compatibility(
283292
def _server_compatibility(info: Dict[str, Any], is_new: bool, evaluation: Optional[Dict[str, Any]]) -> str:
284293
if info["enabled"] is False or _nodejs_status_failures(info):
285294
return "incompatible"
286-
if info["enabled"] is not True or not info["metadata_complete"]:
295+
if not info["runtime_complete"]:
287296
return "unknown"
288297

289298
installations = info["installations"]
@@ -294,7 +303,7 @@ def _server_compatibility(info: Dict[str, Any], is_new: bool, evaluation: Option
294303
if not _valid_node_evaluation(evaluation):
295304
return "unknown"
296305
if _has_matching_installation(indices, cast(Dict[str, Any], evaluation)):
297-
return "compatible"
306+
return "compatible" if info["status_complete"] else "unknown"
298307
return _unmatched_server_compatibility(indices, is_new, info, cast(Dict[str, Any], evaluation))
299308

300309

@@ -359,9 +368,8 @@ def _read_server_nodejs_info(client: RSConnectClient, warnings: List[str], actio
359368
settings = client.nodejs_settings()
360369
except RSConnectException as err:
361370
warnings.append(f"Could not read server Node.js settings: {err}")
362-
actions.append("Check whether this Connect server exposes Node.js runtime settings.")
363371
settings = None
364-
return _server_nodejs_info(settings, warnings)
372+
return _server_nodejs_info(settings, warnings, actions)
365373

366374

367375
def _evaluate_project_range(

‎tests/test_agent_cli_e2e.py‎

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@
4141
logs=cloud_base + "/v1",
4242
)
4343
44+
token_read_delay = os.environ.get("RSCONNECT_E2E_TOKEN_READ_DELAY")
45+
if token_read_delay:
46+
import http.client
47+
import time
48+
49+
original_read = http.client.HTTPResponse.read
50+
51+
def read_completed_token(self, *args, **kwargs):
52+
body = original_read(self, *args, **kwargs)
53+
if self.status == 200 and b'"access_token"' in body:
54+
time.sleep(float(token_read_delay))
55+
return body
56+
57+
http.client.HTTPResponse.read = read_completed_token
58+
4459
runpy.run_module("rsconnect.main", run_name="__main__")
4560
"""
4661

@@ -360,6 +375,7 @@ def _cli_environment(home: Path) -> dict[str, str]:
360375
"CONNECT_CLOUD_OAUTH_CLIENT_ID",
361376
"SHINYAPPS_ACCOUNT",
362377
"RSCONNECT_E2E_CLOUD_BASE_URL",
378+
"RSCONNECT_E2E_TOKEN_READ_DELAY",
363379
"HTTP_PROXY",
364380
"http_proxy",
365381
"HTTPS_PROXY",
@@ -1327,6 +1343,59 @@ def test_cloud_finish_deadline_covers_delayed_refresh_and_keeps_checkpoint(
13271343
assert saved["connect_cloud_refresh_token"] == "refresh-token"
13281344

13291345

1346+
@pytest.mark.parametrize("kind", ["connect", "cloud"])
1347+
def test_completed_token_response_is_checkpointed_after_deadline_and_device_expiry(
1348+
tmp_path: Path, local_http_server: _LocalHTTPServer, kind: str
1349+
) -> None:
1350+
home = tmp_path / "home"
1351+
environment = _cli_environment(home)
1352+
name = "completed-token-" + kind
1353+
if kind == "cloud":
1354+
start_args = ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"]
1355+
finish_args = ["add", "--connect-cloud", "--name", name, "--finish"]
1356+
cloud_base = local_http_server.base_url
1357+
else:
1358+
start_args = ["login", "--server", local_http_server.base_url, "--name", name, "--no-wait"]
1359+
finish_args = ["login", "--name", name, "--finish"]
1360+
cloud_base = None
1361+
1362+
started = _run_cli(start_args, environment, cloud_base_url=cloud_base)
1363+
assert started.returncode == 0, _output(started)
1364+
local_http_server.approved = True
1365+
time.sleep(1.1)
1366+
delayed_environment = {**environment, "RSCONNECT_E2E_TOKEN_READ_DELAY": "1.2"}
1367+
pending = _run_cli([*finish_args, "--timeout", "1"], delayed_environment, cloud_base_url=cloud_base)
1368+
1369+
assert pending.returncode == 0, _output(pending)
1370+
assert _json_output(pending)["status"] == "pending"
1371+
state_path = _device_states(home, kind)[0]
1372+
state = json.loads(state_path.read_text(encoding="utf-8"))
1373+
assert state["tokens"]["access_token"] == "access-token"
1374+
assert state["tokens"]["refresh_token"] == "refresh-token"
1375+
state["expires_at"] = time.time() - 1
1376+
state_path.write_text(json.dumps(state), encoding="utf-8")
1377+
local_http_server.device_error = "invalid_grant"
1378+
1379+
restarted = _run_cli(start_args, environment, cloud_base_url=cloud_base)
1380+
assert restarted.returncode == 0, _output(restarted)
1381+
assert _json_output(restarted)["expires_in"] == 0
1382+
assert json.loads(state_path.read_text(encoding="utf-8"))["tokens"] == state["tokens"]
1383+
assert len(_requests(local_http_server, "/oauth/device/authorize")) == 1
1384+
1385+
finished = _run_cli([*finish_args, "--timeout", "5"], environment, cloud_base_url=cloud_base)
1386+
assert finished.returncode == 0, _output(finished)
1387+
assert _json_output(finished)["status"] == "done"
1388+
assert not _device_states(home, kind)
1389+
assert len(_requests(local_http_server, "/oauth/token")) == 1
1390+
saved = _saved_servers(home)[name]
1391+
token_field = "connect_cloud_access_token" if kind == "cloud" else "oauth_access_token"
1392+
assert saved[token_field] == "access-token"
1393+
for result in (started, pending, restarted, finished):
1394+
assert "access-token" not in _output(result)
1395+
assert "refresh-token" not in _output(result)
1396+
assert "local-device-code" not in _output(result)
1397+
1398+
13301399
@pytest.mark.parametrize("phase", ["headers", "body"])
13311400
@pytest.mark.parametrize("kind", ["connect", "cloud"])
13321401
def test_finish_deadline_interrupts_slow_responses_and_can_resume(
@@ -1780,7 +1849,7 @@ def test_oversized_valid_deployment_record_is_unknown_and_explicit_id_bypasses_i
17801849
@pytest.mark.parametrize(
17811850
"contents, warning_text",
17821851
[
1783-
pytest.param("[" * 10000 + "0" + "]" * 10000, "recursion", id="nested-json"),
1852+
pytest.param("[" * 10000 + "0" + "]" * 10000, "deployment metadata", id="nested-json"),
17841853
pytest.param(
17851854
'{"app_id":' + "9" * 5000 + "}",
17861855
"integer string conversion",
@@ -1792,7 +1861,7 @@ def test_oversized_valid_deployment_record_is_unknown_and_explicit_id_bypasses_i
17921861
),
17931862
],
17941863
)
1795-
def test_parser_rejected_deployment_record_is_unknown_without_fixing(
1864+
def test_unusable_deployment_record_is_unknown_without_fixing(
17961865
tmp_path: Path, local_http_server: _LocalHTTPServer, runtime: str, contents: str, warning_text: str
17971866
) -> None:
17981867
environment = _cli_environment(tmp_path / "home")
@@ -1818,6 +1887,7 @@ def test_parser_rejected_deployment_record_is_unknown_without_fixing(
18181887
assert report["changed_files"] == []
18191888
assert report["actions"]
18201889
assert any(warning_text in warning.lower() for warning in report["warnings"])
1890+
assert record.read_text(encoding="utf-8") == contents
18211891
assert not (project / ".python-version").exists()
18221892
assert not _requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID)
18231893

@@ -1876,8 +1946,9 @@ def test_resumable_reauthentication_preserves_legacy_url_and_content_history(
18761946
assert not (project / ".python-version").exists()
18771947

18781948

1949+
@pytest.mark.parametrize("filename", [None, "app.py", "manifest.json"])
18791950
def test_safe_deployment_history_still_selects_its_server_before_the_default(
1880-
tmp_path: Path, local_http_server: _LocalHTTPServer
1951+
tmp_path: Path, local_http_server: _LocalHTTPServer, filename: str | None
18811952
) -> None:
18821953
home = tmp_path / "home"
18831954
environment = _cli_environment(home)
@@ -1893,7 +1964,9 @@ def test_safe_deployment_history_still_selects_its_server_before_the_default(
18931964
next(home.rglob("servers.json")).write_text(json.dumps(servers), encoding="utf-8")
18941965
project = tmp_path / "history-target-project"
18951966
project.mkdir()
1896-
module_file = project / (project.name + ".py")
1967+
module_file = project / (filename or project.name + ".py")
1968+
if filename:
1969+
module_file.write_text("{}" if filename.endswith(".json") else "app = object()\n", encoding="utf-8")
18971970
AppStore(str(module_file)).set(
18981971
local_http_server.base_url,
18991972
str(module_file),
@@ -1904,7 +1977,8 @@ def test_safe_deployment_history_still_selects_its_server_before_the_default(
19041977
"python-api",
19051978
)
19061979

1907-
result = _run_cli(["preflight", str(project), "--fix"], environment)
1980+
target = module_file if filename else project
1981+
result = _run_cli(["preflight", str(target), "--fix"], environment)
19081982

19091983
assert result.returncode == 0, _output(result)
19101984
report = _json_output(result)

0 commit comments

Comments
 (0)