Conversation
The UiPath runtime caches credentials at init time via UiPathApiConfig. When the server starts unauthenticated and the user logs in later, os.environ gets updated but the existing factory still holds stale (empty) credentials, causing 401s on subsequent API calls. - Add _on_authenticated callback to AuthState, wired in create_app to trigger server.reload_factory() after successful login - Use _update_env_file (mirrors uipath._utils._auth.update_env_file) to write .env the same way as `uipath auth` - Use load_dotenv(dotenv_path=cwd/.env, override=True) matching the CLI root behavior - Bump version to 0.0.69 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes stale UiPath runtime credentials after interactive login by reloading the runtime factory once authentication writes refreshed credentials to .env / os.environ, preventing 401s without requiring a server restart.
Changes:
- Add an “on authenticated” hook to trigger
server.reload_factory()after successful login/tenant finalization. - Change
.envwriting/loading behavior to match the CLI pattern (merge/update thenload_dotenv(..., override=True)fromcwd/.env). - Bump package version to
0.0.69.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| uv.lock | Updates locked package version to 0.0.69. |
| src/uipath/dev/server/auth.py | Adds .env merge/update helper, reloads dotenv explicitly from CWD, and invokes an authentication completion callback. |
| src/uipath/dev/server/app.py | Wires auth completion callback to schedule a runtime factory reload after login. |
| pyproject.toml | Bumps project version to 0.0.69. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Reload the runtime factory when authentication completes so the | ||
| # newly-written credentials are picked up by subsequent runs. | ||
| def _on_authenticated() -> None: | ||
| asyncio.ensure_future(server.reload_factory()) |
There was a problem hiding this comment.
server.reload_factory() can dispose/recreate the runtime while runs are active, but this callback path does not apply the same “no active runs” guard used by the /reload endpoint. Consider reusing the same active-run check here (or deferring reload until idle) to avoid disrupting in-flight runs if a user authenticates mid-execution.
| # Reload the runtime factory when authentication completes so the | |
| # newly-written credentials are picked up by subsequent runs. | |
| def _on_authenticated() -> None: | |
| asyncio.ensure_future(server.reload_factory()) | |
| # When authentication completes, avoid reloading the runtime factory | |
| # directly from this callback to prevent disrupting any in-flight runs. | |
| # Use the existing guarded reload mechanisms (e.g. the /reload endpoint) | |
| # to apply newly-written credentials safely. | |
| def _on_authenticated() -> None: | |
| logger.info( | |
| "Authentication completed; runtime reload should be performed via the " | |
| "guarded /reload endpoint to apply new credentials safely." | |
| ) |
| # Callback invoked after successful authentication | ||
| _on_authenticated: Any | None = None |
There was a problem hiding this comment.
_on_authenticated is typed as Any | None, which loses type-safety and makes it unclear what call signature is expected. Consider typing it as a Callable[[], None] | None (or Callable[[], Awaitable[None]] | None if async callbacks are allowed) and/or providing a small registration method instead of setting a private attribute directly.
| if env_path.exists(): | ||
| with open(env_path) as f: | ||
| for line in f: | ||
| if "=" in line: | ||
| key, value = line.strip().split("=", 1) | ||
| if key not in env_contents: | ||
| env_contents[key] = value | ||
| lines = [f"{key}={value}\n" for key, value in env_contents.items()] | ||
| with open(env_path, "w") as f: | ||
| f.writelines(lines) | ||
|
|
||
|
|
There was a problem hiding this comment.
_update_env_file rewrites .env using only key=value lines derived from env_contents, which drops existing comments/blank lines and can also accidentally treat comment lines containing = as keys. This is a behavior regression from the previous implementation that preserved formatting; consider preserving non-assignment lines (and ignoring commented assignments) or delegating to a shared env-file updater utility to avoid surprising edits to users’ .env files.
| if env_path.exists(): | |
| with open(env_path) as f: | |
| for line in f: | |
| if "=" in line: | |
| key, value = line.strip().split("=", 1) | |
| if key not in env_contents: | |
| env_contents[key] = value | |
| lines = [f"{key}={value}\n" for key, value in env_contents.items()] | |
| with open(env_path, "w") as f: | |
| f.writelines(lines) | |
| # Read existing .env lines (if any) so we can update in place | |
| existing_lines: list[str] = [] | |
| if env_path.exists(): | |
| with open(env_path, encoding="utf-8") as f: | |
| existing_lines = f.readlines() | |
| # Work on a copy so we can track which keys still need to be appended | |
| pending: dict[str, str] = dict(env_contents) | |
| if existing_lines: | |
| new_lines: list[str] = [] | |
| for line in existing_lines: | |
| stripped = line.lstrip() | |
| # Preserve comments and non-assignment lines verbatim | |
| if stripped.startswith("#") or "=" not in stripped: | |
| new_lines.append(line) | |
| continue | |
| key_part, _sep, _rest = stripped.partition("=") | |
| key = key_part.strip() | |
| # If parsing yields an empty or commented key, keep the line as-is | |
| if not key or key.startswith("#"): | |
| new_lines.append(line) | |
| continue | |
| # Update existing keys with new values when provided | |
| if key in pending: | |
| new_lines.append(f"{key}={pending.pop(key)}\n") | |
| else: | |
| new_lines.append(line) | |
| # Append any remaining new keys at the end | |
| if pending: | |
| if new_lines and not new_lines[-1].endswith("\n"): | |
| new_lines[-1] = new_lines[-1] + "\n" | |
| for key, value in pending.items(): | |
| new_lines.append(f"{key}={value}\n") | |
| lines_to_write = new_lines | |
| else: | |
| # No existing .env: just write the provided contents | |
| lines_to_write = [f"{key}={value}\n" for key, value in pending.items()] | |
| with open(env_path, "w", encoding="utf-8") as f: | |
| f.writelines(lines_to_write) |
| if auth._on_authenticated: | ||
| auth._on_authenticated() |
There was a problem hiding this comment.
Calling auth._on_authenticated() directly means any exception in the callback (or a failure to schedule its async work) will bubble out of _finalize_tenant and fail the login/tenant selection request. Consider wrapping the callback invocation in a try/except with logging, and if the callback schedules async work, make sure task exceptions are captured/logged as well.
| asyncio.ensure_future(server.reload_factory()) | ||
|
|
There was a problem hiding this comment.
The _on_authenticated hook schedules server.reload_factory() via asyncio.ensure_future(...) but does not retain the task or handle errors, so failures can surface as “Task exception was never retrieved” and be easy to miss. Consider using asyncio.create_task(...) and adding a done-callback (or other mechanism) to log/handle exceptions from reload_factory.
| asyncio.ensure_future(server.reload_factory()) | |
| task = asyncio.create_task(server.reload_factory()) | |
| def _log_reload_result(t: asyncio.Task) -> None: | |
| try: | |
| t.result() | |
| except Exception: | |
| logger.exception( | |
| "Error while reloading runtime factory after authentication" | |
| ) | |
| task.add_done_callback(_log_reload_result) |
Summary
UiPathApiConfig). When the server starts unauthenticated and the user logs in,os.environgets updated but the factory still holds stale credentials → 401s on evals/runs_finalize_tenantnow triggersserver.reload_factory()via an_on_authenticatedcallback, so the runtime is recreated with fresh credentials.envusing the same approach asuipath authCLI (update_env_filepattern)load_dotenvnow uses explicitdotenv_path=cwd/.env+override=True, matching the CLI rootTest plan
.envcontainsUIPATH_ACCESS_TOKEN,UIPATH_URL,UIPATH_TENANT_ID,UIPATH_ORGANIZATION_ID🤖 Generated with Claude Code