Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ client.docks.ports.remove(dock.id, 3000)
`url` is `None` while hosted ports are off. Leaving `visibility` out of a repeat
`expose` keeps the port's current visibility.

## Environments

`client.environments.list(organization_id)`, `get(ref, organization_id=...)` and
`update(ref, ..., provider_keys="plain")` read and change environments. Reads need
an organization member; `update` needs an organization admin.
`provider_keys` is `"gateway"` (default: provider keys stay in the control plane and
docks get a placeholder key plus gateway base URLs) or `"plain"` (the real key is
written into the dock, so anyone inside it can read it). `egress_allow=[...]` confines
docks to those HTTPS hosts and `egress_allow=None` clears the list. Posture changes
(the pass flags, `safe_for_third_parties`, `provider_keys`, `egress_allow`) make a new
environment version; renames, `is_default` and `secrets_access` do not. Variable
values are never returned, only `var_names`. Connectors
also report `credential_type` (`oauth`, `personal_token`, `bot_token`, `org_token`,
`user_token`, `token` or `None`).

## Connectors and images

Connect a workspace to a tool once (organization admins only; members get
Expand Down Expand Up @@ -145,7 +160,9 @@ except DockError as err:
Some codes raise a subclass of `DockError`, so you can catch them directly:
`ConnectorNotConnectedError` (`connector_not_connected`, 409),
`ConnectorAdminOnlyError` (`connector_admin_only`, 403),
`ConnectorNotFoundError` (`connector_not_found`, 404), `ImageUnknownError`
`ConnectorNotFoundError` (`connector_not_found`, 404),
`ConnectorTokenInvalidError` (`connector_token_invalid`, 400),
`ConnectorUnreachableError` (`connector_unreachable`, 502), `ImageUnknownError`
(`image_unknown`, 400), `ImageUnavailableError` (`image_unavailable`, 400),
`PortNotFoundError` (`port_not_found`, 404), `PortLimitReachedError`
(`port_limit_reached`, 409) and `PortsUnavailableError` (`ports_unavailable`,
Expand Down
6 changes: 6 additions & 0 deletions src/usedock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
ConnectorAdminOnlyError,
ConnectorNotConnectedError,
ConnectorNotFoundError,
ConnectorTokenInvalidError,
ConnectorUnreachableError,
DockError,
ImageUnavailableError,
ImageUnknownError,
Expand All @@ -15,6 +17,7 @@
ConnectorState,
DockInfo,
DockState,
Environment,
ExecResult,
Image,
Operation,
Expand All @@ -29,10 +32,13 @@
"ConnectorNotConnectedError",
"ConnectorNotFoundError",
"ConnectorState",
"ConnectorTokenInvalidError",
"ConnectorUnreachableError",
"Dock",
"DockError",
"DockInfo",
"DockState",
"Environment",
"ExecResult",
"Image",
"ImageUnavailableError",
Expand Down
69 changes: 69 additions & 0 deletions src/usedock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ConnectorState,
DockInfo,
DockState,
Environment,
ExecResult,
Image,
Operation,
Expand Down Expand Up @@ -95,6 +96,7 @@ def __init__(
self.operations = Operations(self)
self.connectors = Connectors(self)
self.images = Images(self)
self.environments = Environments(self)

def close(self) -> None:
self._http.close()
Expand Down Expand Up @@ -470,3 +472,70 @@ def __init__(self, client: Dock) -> None:

def list(self) -> List[Image]:
return [Image.from_dict(d) for d in self._c._request("GET", "/v1/images")]


class Environments:
"""Organization environments. ``ref`` is an id or a name; pass ``organization_id``
when addressing by name."""

def __init__(self, client: Dock) -> None:
self._c = client

def list(self, organization_id: str) -> List[Environment]:
data = self._c._request(
"GET", "/v1/environments", params={"organizationId": organization_id}
)
return [Environment.from_dict(d) for d in data]

def get(self, ref: str, *, organization_id: Optional[str] = None) -> Environment:
data = self._c._request(
"GET", f"/v1/environments/{_seg(ref)}", params=_org_params(organization_id)
)
return Environment.from_dict(data)

def update(
self,
ref: str,
*,
organization_id: Optional[str] = None,
name: Optional[str] = None,
is_default: Optional[bool] = None,
secrets_access: Optional[str] = None,
safe_for_third_parties: Optional[bool] = None,
pass_github: Optional[bool] = None,
pass_secrets: Optional[bool] = None,
pass_dock_credentials: Optional[bool] = None,
pass_agents_credentials: Optional[bool] = None,
provider_keys: Optional[str] = None,
egress_allow: Optional[List[str]] = UNSET,
) -> Environment:
"""Rename, move the default or change posture; needs an organization admin.
``provider_keys`` is ``"gateway"`` (default; keys stay in the control plane) or
``"plain"`` (the real key is written into the dock). ``egress_allow=None`` clears
the allowlist; leave it out to keep it. Posture changes (the pass flags,
``safe_for_third_parties``, ``provider_keys``, ``egress_allow``) create a new
version; ``name``, ``is_default`` and ``secrets_access`` do not."""
body = _body(
name=name,
isDefault=is_default,
secretsAccess=secrets_access,
safeForThirdParties=safe_for_third_parties,
passGithub=pass_github,
passSecrets=pass_secrets,
passDockCredentials=pass_dock_credentials,
passAgentsCredentials=pass_agents_credentials,
providerKeys=provider_keys,
)
if egress_allow is not UNSET:
body["egressAllow"] = egress_allow
data = self._c._request(
"PATCH",
f"/v1/environments/{_seg(ref)}",
body=body,
params=_org_params(organization_id),
)
return Environment.from_dict(data)


def _org_params(organization_id: Optional[str]) -> Optional[Dict[str, str]]:
return {"organizationId": organization_id} if organization_id else None
10 changes: 10 additions & 0 deletions src/usedock/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ class ConnectorNotFoundError(DockError):
"""``connector_not_found`` (404): disconnecting a connector that is not connected."""


class ConnectorTokenInvalidError(DockError):
"""``connector_token_invalid`` (400): the vendor rejected the token passed to ``connect``."""


class ConnectorUnreachableError(DockError):
"""``connector_unreachable`` (502): the vendor could not be reached to verify the token."""


class ImageUnknownError(DockError):
"""``image_unknown`` (400): the image is not in the Dockyard catalog."""

Expand All @@ -55,6 +63,8 @@ class PortsUnavailableError(DockError):
"connector_not_connected": ConnectorNotConnectedError,
"connector_admin_only": ConnectorAdminOnlyError,
"connector_not_found": ConnectorNotFoundError,
"connector_token_invalid": ConnectorTokenInvalidError,
"connector_unreachable": ConnectorUnreachableError,
"image_unknown": ImageUnknownError,
"image_unavailable": ImageUnavailableError,
"port_not_found": PortNotFoundError,
Expand Down
57 changes: 56 additions & 1 deletion src/usedock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ def from_dict(cls, data: Dict[str, Any]) -> Operation:
@dataclass
class Connector:
"""A workspace connector. ``status`` is ``connected``, ``not_connected`` or
``reauth_required``; ``read_only`` kinds (github, model-keys) cannot be connected by token."""
``reauth_required``; ``read_only`` kinds (github, model-keys) cannot be connected by token.
``credential_type`` is ``oauth``, ``personal_token``, ``bot_token``, ``org_token``,
``user_token``, ``token`` or ``None`` (not connected or not recorded)."""

kind: str
type: str
Expand All @@ -125,6 +127,7 @@ class Connector:
oauth_available: bool = False
read_only: bool = False
updated_at: Optional[str] = None
credential_type: Optional[str] = None
raw: Dict[str, Any] = field(default_factory=dict, repr=False)

@classmethod
Expand All @@ -138,6 +141,7 @@ def from_dict(cls, data: Dict[str, Any]) -> Connector:
oauth_available=bool(data.get("oauthAvailable", False)),
read_only=bool(data.get("readOnly", False)),
updated_at=_opt_str(data, "updatedAt"),
credential_type=_opt_str(data, "credentialType"),
raw=data,
)

Expand Down Expand Up @@ -200,3 +204,54 @@ def from_dict(cls, data: Dict[str, Any]) -> Port:
created_at=_opt_str(data, "createdAt"),
raw=data,
)


@dataclass
class Environment:
"""An environment. ``provider_keys`` is how the organization's provider keys reach
docks: ``gateway`` (default) keeps them in the control plane and gives the dock a
placeholder key plus gateway base URLs; ``plain`` writes the real key into the dock,
so anyone who can run a command in it can read the key (weaker; admins only).
``egress_allow`` is ``None`` when egress is open minus private ranges. Variables are
listed by name only (``var_names``); values are never returned."""

id: str
organization_id: str
name: str
provider_keys: str = "gateway"
is_default: bool = False
current_version: Optional[int] = None
secrets_access: Optional[str] = None
safe_for_third_parties: bool = False
pass_github: bool = False
pass_secrets: bool = False
pass_dock_credentials: bool = False
pass_agents_credentials: bool = False
egress_allow: Optional[List[str]] = None
var_names: List[str] = field(default_factory=list)
created_at: Optional[str] = None
updated_at: Optional[str] = None
raw: Dict[str, Any] = field(default_factory=dict, repr=False)

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Environment:
egress = data.get("egressAllow")
return cls(
id=_str(data, "id"),
organization_id=_str(data, "organizationId"),
name=_str(data, "name"),
provider_keys=_opt_str(data, "providerKeys") or "gateway",
is_default=bool(data.get("isDefault", False)),
current_version=_opt_int(data, "currentVersion"),
secrets_access=_opt_str(data, "secretsAccess"),
safe_for_third_parties=bool(data.get("safeForThirdParties", False)),
pass_github=bool(data.get("passGithub", False)),
pass_secrets=bool(data.get("passSecrets", False)),
pass_dock_credentials=bool(data.get("passDockCredentials", False)),
pass_agents_credentials=bool(data.get("passAgentsCredentials", False)),
egress_allow=_str_list(data, "egressAllow") if isinstance(egress, list) else None,
var_names=_str_list(data, "varNames"),
created_at=_opt_str(data, "createdAt"),
updated_at=_opt_str(data, "updatedAt"),
raw=data,
)
92 changes: 92 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
ConnectorAdminOnlyError,
ConnectorNotConnectedError,
ConnectorNotFoundError,
ConnectorTokenInvalidError,
ConnectorUnreachableError,
Dock,
DockError,
DockState,
Expand Down Expand Up @@ -531,6 +533,7 @@ def test_missing_scenarios_skip_by_default_but_fail_when_required(tmp_path: Path
"status": "connected",
"accountLabel": "Acme",
"scopes": ["read"],
"credentialType": "personal_token",
"oauthAvailable": False,
"readOnly": False,
"updatedAt": "2026-01-01T00:00:00.000Z",
Expand All @@ -557,6 +560,12 @@ def test_connectors_list() -> None:
assert items[0].scopes == ["read"]
assert items[0].read_only is False
assert items[0].raw["type"] == "tool"
assert items[0].credential_type == "personal_token"


def test_connector_credential_type_defaults_to_none() -> None:
client, _ = recorder(ok([{**CONNECTOR, "status": "not_connected", "credentialType": None}]))
assert client.connectors.list("w")[0].credential_type is None


def test_connectors_connect_and_disconnect() -> None:
Expand Down Expand Up @@ -643,6 +652,8 @@ def test_images_list() -> None:
[
("connector_not_connected", 409, ConnectorNotConnectedError),
("connector_admin_only", 403, ConnectorAdminOnlyError),
("connector_token_invalid", 400, ConnectorTokenInvalidError),
("connector_unreachable", 502, ConnectorUnreachableError),
("image_unknown", 400, ImageUnknownError),
("image_unavailable", 400, ImageUnavailableError),
("port_not_found", 404, PortNotFoundError),
Expand Down Expand Up @@ -696,3 +707,84 @@ def test_files_read_list_over_command_limit_raise_before_sending(op: str) -> Non
getattr(client.docks.files, op)("d1", path)
assert (info.value.code, info.value.status) == ("file_too_large", 0)
assert len(seen) == 0


ENVIRONMENT = {
"id": "e1",
"organizationId": "o1",
"name": "base",
"isDefault": True,
"currentVersion": 3,
"secretsAccess": "admins",
"providerKeys": "gateway",
"egressAllow": None,
}


def test_environments_list_and_get_read_provider_keys() -> None:
client, seen = recorder(ok([ENVIRONMENT]))
envs = client.environments.list("o 1")
assert (seen[0].method, seen[0].url.path, seen[0].url.params["organizationId"]) == (
"GET",
"/v1/environments",
"o 1",
)
assert envs[0].provider_keys == "gateway"
client, seen = recorder(ok(ENVIRONMENT))
env = client.environments.get("base", organization_id="o1")
assert (seen[0].url.path, seen[0].url.params["organizationId"]) == (
"/v1/environments/base",
"o1",
)
assert env.provider_keys == "gateway"
assert env.egress_allow is None


def test_environments_update_sends_provider_keys() -> None:
client, seen = recorder(ok({**ENVIRONMENT, "providerKeys": "plain"}))
env = client.environments.update("base", organization_id="o1", provider_keys="plain")
assert (seen[0].method, seen[0].url.path) == ("PATCH", "/v1/environments/base")
assert body_of(seen[0]) == {"providerKeys": "plain"}
assert env.provider_keys == "plain"


def test_environments_update_egress_allow_null_clears() -> None:
client, seen = recorder(ok(ENVIRONMENT))
client.environments.update("base", egress_allow=None)
assert body_of(seen[0]) == {"egressAllow": None}


def test_environment_reads_posture_fields() -> None:
data = {
**ENVIRONMENT,
"safeForThirdParties": True,
"passGithub": True,
"passSecrets": False,
"passDockCredentials": True,
"passAgentsCredentials": True,
"egressAllow": ["api.example.com"],
"varNames": ["A", "B"],
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z",
}
client, _ = recorder(ok(data))
env = client.environments.get("e1")
assert (
env.safe_for_third_parties,
env.pass_github,
env.pass_secrets,
env.pass_dock_credentials,
env.pass_agents_credentials,
) == (True, True, False, True, True)
assert env.egress_allow == ["api.example.com"]
assert env.var_names == ["A", "B"]
assert (env.created_at, env.updated_at) == (
"2026-01-01T00:00:00.000Z",
"2026-01-02T00:00:00.000Z",
)


def test_environments_update_sends_posture_and_egress_list() -> None:
client, seen = recorder(ok(ENVIRONMENT))
client.environments.update("e1", pass_github=False, egress_allow=["api.example.com"])
assert body_of(seen[0]) == {"passGithub": False, "egressAllow": ["api.example.com"]}
Loading