Repository navigation
Install the workspace policy at ug configure #1259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
836d088
0731949
b25da2d
f55d62d
c3f49b7
f8b2f1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # Managed workspace policy | ||
|
|
||
| `ug configure` reads the workspace's published `CodingAgentConfig` through | ||
| `GET /api/ai-gateway/v2/coding-agent-configs` and installs it machine-wide at | ||
| `/etc/unitygateway/managed-settings.json` on Linux and macOS. | ||
|
|
||
| The installed file contains only: | ||
|
|
||
| ```json | ||
| { | ||
| "workspace": "https://your-workspace", | ||
| "config": {} | ||
| } | ||
| ``` | ||
|
|
||
| `config` is the raw remote configuration, not normalized agent settings. The empty object above | ||
| only illustrates the shape: a workspace without a published policy does not leave a policy file. | ||
| The directory is root-owned `0755` and the file is root-owned `0644`, so ordinary users can read it | ||
| without sudo. Authentication tokens and profiles stay in user-owned state. | ||
|
|
||
| **Never put secrets in managed policy headers.** The policy is readable by every local user, and | ||
| ug does not redact raw fields. Do not put passwords, bearer tokens, API keys, or session cookies in | ||
| `http_headers` or anywhere else in the configuration. Routing headers such as a workspace ID are | ||
| appropriate. | ||
|
|
||
| ## Configure | ||
|
|
||
| - `ug configure` forces a fresh read of the workspace policy, so it never installs a stale one. | ||
| - Changed policy is written with sudo from a terminal. Where sudo cannot prompt (for example in a | ||
| script), ug uses non-interactive sudo and fails if a password would be needed. | ||
| - Root-run configure installs directly, without sudo. | ||
| - Unchanged policy writes nothing. A re-read that differs only in `retrieved_time` is not a change. | ||
| - A workspace that publishes no policy, or has the feature disabled, removes the installed file. | ||
| Configure decides the machine's workspace, so this also removes a policy installed for a different | ||
| workspace. | ||
| - If the installation or removal fails, configure stops with an error that says the machine-wide | ||
| file needs sudo. Rerun `ug configure` from a terminal where you can use sudo, or ask your IT team | ||
| or an administrator to run it. | ||
| - Windows has no machine-wide policy file. `UG_SKIP_OS_SETTINGS=1` and `ug configure --user` also | ||
| skip it, so no file is written. | ||
|
|
||
| Launches never install or remove the machine-wide policy. Per-user caching of the fetched policy | ||
| (`~/.ucode/managed-config.json`) is unchanged by this write path. | ||
|
|
||
| ## Limits | ||
|
|
||
| - The policy describes one workspace for the whole machine. Configuring another workspace replaces it | ||
| for all users. | ||
| - `ug revert` removes the policy, with sudo. Without sudo it warns and keeps the file. | ||
| - Installation needs sudo on Linux and macOS. On MDM machines, ask IT to run the provisioning step | ||
| rather than attempting administrator-only configure yourself. | ||
| - Live sudo and ownership checks need a disposable clean machine, not a developer workstation. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,8 +11,10 @@ | |
| - re-reading it on each launch (reusing a read younger than :data:`MANAGED_CONFIG_TTL` rather than | ||
| re-fetching), falling back to the persisted copy when the read fails. | ||
| There is deliberately one file: the workspace is the source of truth, so the pulled copy lives in | ||
| ``managed-config.json`` and a launch re-reads it from there. | ||
| An explicit ``ug configure`` also installs the workspace policy machine-wide at | ||
| ``/etc/unitygateway/managed-settings.json`` (Linux and macOS) through | ||
| :func:`ucode.managed_files.update_workspace_policy`. Launches never write that file; they read and | ||
| refresh only the per-user cache above. | ||
| :func:`refresh_managed_config` is the launch path's entry point. It is called before model discovery, | ||
| because the manifest decides whether that discovery is needed at all; the launch path then hands the | ||
|
|
@@ -37,10 +39,19 @@ | |
| fetch_model_recommendation, | ||
| get_databricks_token, | ||
| ) | ||
| from ucode.managed_files import ( | ||
| OS, | ||
| UNITY_GATEWAY_MANAGED_SETTINGS_PATH, | ||
| current_os, | ||
| managed_settings_skipped, | ||
| update_workspace_policy, | ||
| ) | ||
| from ucode.time_utils import parse_update_time | ||
| from ucode.ui import console, print_warning | ||
| from ucode.ui import console, normalize_workspace_url, print_warning | ||
|
|
||
| MANAGED_CONFIG_PATH = config_io.APP_DIR / "managed-config.json" | ||
| # The machine-wide workspace policy that explicit ``ug configure`` installs (Linux and macOS only). | ||
| MACHINE_POLICY_PATH = UNITY_GATEWAY_MANAGED_SETTINGS_PATH | ||
|
|
||
| # Shown to a developer when their workspace has no admin-defined managed config yet — the normal | ||
| # case, not an error. Kept here so the CLI (which surfaces it) uses one consistent message. | ||
|
|
@@ -378,6 +389,10 @@ class FetchedManagedConfig(NamedTuple): | |
| reason: str | None | ||
|
|
||
|
|
||
| class ManagedPolicyInstallDenied(RuntimeError): | ||
| """``ug configure`` could not install, replace, or remove the machine-wide workspace policy.""" | ||
|
|
||
|
|
||
| class ManagedConfigResult(NamedTuple): | ||
| """The launch-path refresh outcome: the ``manifest`` to apply (None when absent or dropped) and | ||
| ``feature_disabled``, True when the coding-agent-configs feature is off server-side.""" | ||
|
|
@@ -666,6 +681,86 @@ def save_managed_state(workspace: str, config: dict, *, outcome: str | None = No | |
| _restrict_permissions(MANAGED_CONFIG_PATH) | ||
|
|
||
|
|
||
| def _machine_policy_installable() -> bool: | ||
| """Whether this run can hold the machine-wide policy: Linux or macOS, not opted out with | ||
| ``UG_SKIP_OS_SETTINGS`` or the ``user`` scope. Windows has no machine policy file.""" | ||
| return not managed_settings_skipped() and current_os() in (OS.LINUX, OS.MACOS) | ||
|
|
||
|
|
||
| def _policy_content(config: dict) -> dict: | ||
| """The config compared for changes; ``retrieved_time`` changes on every read, so it is ignored.""" | ||
| return {key: value for key, value in config.items() if key != "retrieved_time"} | ||
|
|
||
|
|
||
| def _install_machine_policy(workspace: str, config: dict) -> None: | ||
| """Make the machine policy match an explicit configure's result. | ||
| An empty ``config`` removes the installed policy, whichever workspace it was installed for: the | ||
| configured workspace now decides the machine's workspace. Otherwise the file is rewritten only | ||
| when its workspace or configuration differs. Raises RuntimeError when sudo is unavailable. | ||
| """ | ||
| if not config: | ||
| update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True) | ||
| return | ||
|
Comment on lines
+702
to
+704
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We assume single user for ug today, so shared machine issues are not a concern. Since we are explicitly modifying the global files, it is expected that all users use the same workspace. |
||
| workspace = normalize_workspace_url(workspace) | ||
| installed = config_io.read_json_safe(MACHINE_POLICY_PATH) | ||
| installed_workspace = installed.get("workspace") | ||
| installed_config = installed.get("config") | ||
| if ( | ||
| isinstance(installed_workspace, str) | ||
| and installed_workspace.strip() | ||
| and normalize_workspace_url(installed_workspace) == workspace | ||
| and isinstance(installed_config, dict) | ||
| and _policy_content(installed_config) == _policy_content(config) | ||
| ): | ||
| return | ||
| payload = {"workspace": workspace, "config": config} | ||
|
billiam-wang marked this conversation as resolved.
|
||
| update_workspace_policy( | ||
| MACHINE_POLICY_PATH, | ||
| json.dumps(payload, indent=2) + "\n", | ||
| interactive=True, | ||
| allow_sudo=True, | ||
| ) | ||
|
|
||
|
|
||
| def revert_machine_policy() -> str: | ||
| """Remove the installed workspace policy for ``ug revert``: "removed", "unchanged", or "failed". | ||
| Nothing else writes this file, so revert deletes it outright. Without sudo it warns and keeps | ||
| the file, like the other machine-wide settings revert restores. | ||
| """ | ||
| if not _machine_policy_installable() or not MACHINE_POLICY_PATH.exists(): | ||
| return "unchanged" | ||
| try: | ||
| update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True) | ||
| except RuntimeError as exc: | ||
| print_warning( | ||
| f"Could not remove the workspace policy at {MACHINE_POLICY_PATH}: {exc} Rerun " | ||
| "`ug revert` from a terminal where you can use sudo." | ||
| ) | ||
| return "failed" | ||
| return "removed" | ||
|
|
||
|
|
||
| def _sync_machine_policy(workspace: str, config: dict, *, force_refresh: bool) -> None: | ||
|
billiam-wang marked this conversation as resolved.
|
||
| """Install or remove the machine policy for an explicit configure; launches never write it. | ||
| A failed install raises :class:`ManagedPolicyInstallDenied`, so configure reports it instead of | ||
| implying the machine is managed for this workspace. | ||
| """ | ||
| if not force_refresh or not _machine_policy_installable(): | ||
| return | ||
| try: | ||
| _install_machine_policy(workspace, config) | ||
| except RuntimeError as exc: | ||
| message = ( | ||
| f"Could not install the workspace policy at {MACHINE_POLICY_PATH}: {exc} " | ||
| "This machine-wide file needs sudo. Rerun `ug configure` from a terminal where you " | ||
| "can use sudo, or ask your IT team or an administrator to run it." | ||
| ) | ||
| raise ManagedPolicyInstallDenied(message) from exc | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Published config already needed sudo since it writes to the global file. This is not a new requirement. |
||
|
|
||
|
|
||
| def _restrict_permissions(path: Path) -> None: | ||
| """Best-effort chmod 0600. No-op where unsupported (e.g. Windows), where the effective | ||
| read-only guarantee is left to a later change.""" | ||
|
|
@@ -783,6 +878,7 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag | |
| if reason is not None: | ||
| if _is_feature_disabled(reason): | ||
| save_managed_state(workspace, {}, outcome=_OUTCOME_FEATURE_DISABLED) | ||
| _sync_machine_policy(workspace, {}, force_refresh=force_refresh) | ||
| return ManagedConfigResult(None, True) | ||
| fallback = _persisted_fallback(workspace, reason, refused=_is_permission_denied(reason)) | ||
| return ManagedConfigResult(fallback, False) | ||
|
|
@@ -791,9 +887,11 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag | |
| # the file doubles as the fallback above, so a removed policy would otherwise come back | ||
| # into force after the next transient outage. | ||
| save_managed_state(workspace, {}, outcome=_OUTCOME_NONE) | ||
| _sync_machine_policy(workspace, {}, force_refresh=force_refresh) | ||
| return ManagedConfigResult(None, False) | ||
| # Persist the raw config verbatim; hand callers the normalized manifest they expect. | ||
| save_managed_state(workspace, raw, outcome=_OUTCOME_PUBLISHED) | ||
| _sync_machine_policy(workspace, raw, force_refresh=force_refresh) | ||
| return ManagedConfigResult(normalize_managed_config(raw), False) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this file read anywhere? nothing in src/ reads
/etc/unitygateway/managed-settings.json, so configure now asks for sudo (and can hard fail) for a file nothing uses. can we hold this back until a reader lands, or put it behind a gate?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is intentional. The followup consumes the new file: #1260