mobileops-managed-agent 0.2.2__tar.gz → 0.3.1__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 (24) hide show
  1. {mobileops_managed_agent-0.2.2/src/mobileops_managed_agent.egg-info → mobileops_managed_agent-0.3.1}/PKG-INFO +19 -2
  2. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/README.md +18 -1
  3. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/pyproject.toml +1 -1
  4. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/__init__.py +40 -0
  5. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/assistant_model.py +12 -0
  6. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/assistants.py +11 -1
  7. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/capabilities.py +2 -2
  8. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/client.py +21 -1
  9. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/credential_vault.py +1 -0
  10. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/resources.py +0 -1
  11. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/sandbox_profile.py +1 -0
  12. mobileops_managed_agent-0.3.1/src/mobileops_managed_agent/sandbox_profiles.py +148 -0
  13. mobileops_managed_agent-0.3.1/src/mobileops_managed_agent/tool_permissions.py +97 -0
  14. mobileops_managed_agent-0.3.1/src/mobileops_managed_agent/vaults.py +231 -0
  15. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1/src/mobileops_managed_agent.egg-info}/PKG-INFO +19 -2
  16. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent.egg-info/SOURCES.txt +3 -0
  17. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/MANIFEST.in +0 -0
  18. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/setup.cfg +0 -0
  19. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/auth.py +0 -0
  20. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/files.py +0 -0
  21. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent/response_schema.py +0 -0
  22. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent.egg-info/dependency_links.txt +0 -0
  23. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent.egg-info/requires.txt +0 -0
  24. {mobileops_managed_agent-0.2.2 → mobileops_managed_agent-0.3.1}/src/mobileops_managed_agent.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobileops-managed-agent
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Server-side SDK for the MobileOPS Managed Agent API.
5
5
  Project-URL: Homepage, https://github.com/InnoMindX/deepagent/tree/main/managed-agent-sdk/python
6
6
  Project-URL: Repository, https://github.com/InnoMindX/deepagent
@@ -18,11 +18,13 @@ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
18
18
 
19
19
  Server-side Python SDK for the MobileOPS Managed Agent API.
20
20
 
21
- The SDK exposes three surfaces:
21
+ The SDK exposes five surfaces:
22
22
 
23
23
  - `client.agent` wraps the LangGraph runtime client and exposes the supported Assistant CRUD methods at `client.agent.assistants`.
24
24
  - `client.files` exposes MobileOPS Sandbox Workspace file routes.
25
25
  - `client.resources` manages Context Hub resources, versions, and latest-version files.
26
+ - `client.vaults` manages credential vaults and their write-only credentials.
27
+ - `client.sandbox_profiles` manages sandbox profiles (运行环境).
26
28
 
27
29
  Assistant create and update retain LangGraph's ordinary arguments, while
28
30
  persisted configuration is exposed through product fields. Pass
@@ -37,6 +39,8 @@ therefore only affects Runs started on **new** Threads; existing Threads keep
37
39
  the schema captured at their creation.
38
40
 
39
41
  ```py
42
+ import os
43
+
40
44
  from mobileops_managed_agent import MobileOpsManagedAgentClient
41
45
 
42
46
  client = MobileOpsManagedAgentClient(api_url=api_url, api_key=api_key)
@@ -45,12 +49,25 @@ thread = await client.agent.threads.create()
45
49
  await client.agent.runs.create(thread["thread_id"], assistant_id, input=input)
46
50
 
47
51
  skill = await client.resources.create("skills", zip_bytes) # slug inferred
52
+ profile = await client.sandbox_profiles.create(
53
+ name="prod",
54
+ spec={"network": {"allow": ["*.example.com"]}},
55
+ )
56
+ vault = await client.vaults.create(name="prod")
57
+ await client.vaults.credentials.create(
58
+ vault["id"],
59
+ name="GRAFANA_TOKEN",
60
+ value=os.environ["GRAFANA_TOKEN"],
61
+ allowed_hosts=["grafana.example.com"],
62
+ )
48
63
  assistant = await client.agent.assistants.create(
49
64
  name="Operations agent",
50
65
  skills=[{
51
66
  "ref": skill["ref"], # e.g. incident-response@sha256-…
52
67
  "target_path": "/workspace/skills", # zip already contains {slug}/
53
68
  }],
69
+ sandbox_profile=profile["id"],
70
+ credential_vault=vault["id"],
54
71
  )
55
72
 
56
73
  await client.resources.versions.create("skills", skill["slug"], next_zip_bytes)
@@ -2,11 +2,13 @@
2
2
 
3
3
  Server-side Python SDK for the MobileOPS Managed Agent API.
4
4
 
5
- The SDK exposes three surfaces:
5
+ The SDK exposes five surfaces:
6
6
 
7
7
  - `client.agent` wraps the LangGraph runtime client and exposes the supported Assistant CRUD methods at `client.agent.assistants`.
8
8
  - `client.files` exposes MobileOPS Sandbox Workspace file routes.
9
9
  - `client.resources` manages Context Hub resources, versions, and latest-version files.
10
+ - `client.vaults` manages credential vaults and their write-only credentials.
11
+ - `client.sandbox_profiles` manages sandbox profiles (运行环境).
10
12
 
11
13
  Assistant create and update retain LangGraph's ordinary arguments, while
12
14
  persisted configuration is exposed through product fields. Pass
@@ -21,6 +23,8 @@ therefore only affects Runs started on **new** Threads; existing Threads keep
21
23
  the schema captured at their creation.
22
24
 
23
25
  ```py
26
+ import os
27
+
24
28
  from mobileops_managed_agent import MobileOpsManagedAgentClient
25
29
 
26
30
  client = MobileOpsManagedAgentClient(api_url=api_url, api_key=api_key)
@@ -29,12 +33,25 @@ thread = await client.agent.threads.create()
29
33
  await client.agent.runs.create(thread["thread_id"], assistant_id, input=input)
30
34
 
31
35
  skill = await client.resources.create("skills", zip_bytes) # slug inferred
36
+ profile = await client.sandbox_profiles.create(
37
+ name="prod",
38
+ spec={"network": {"allow": ["*.example.com"]}},
39
+ )
40
+ vault = await client.vaults.create(name="prod")
41
+ await client.vaults.credentials.create(
42
+ vault["id"],
43
+ name="GRAFANA_TOKEN",
44
+ value=os.environ["GRAFANA_TOKEN"],
45
+ allowed_hosts=["grafana.example.com"],
46
+ )
32
47
  assistant = await client.agent.assistants.create(
33
48
  name="Operations agent",
34
49
  skills=[{
35
50
  "ref": skill["ref"], # e.g. incident-response@sha256-…
36
51
  "target_path": "/workspace/skills", # zip already contains {slug}/
37
52
  }],
53
+ sandbox_profile=profile["id"],
54
+ credential_vault=vault["id"],
38
55
  )
39
56
 
40
57
  await client.resources.versions.create("skills", skill["slug"], next_zip_bytes)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mobileops-managed-agent"
7
- version = "0.2.2"
7
+ version = "0.3.1"
8
8
  description = "Server-side SDK for the MobileOPS Managed Agent API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,6 +17,7 @@ from .client import MobileOpsAgentClient, MobileOpsManagedAgentClient
17
17
  from .credential_vault import (
18
18
  CREDENTIAL_VAULT_CONFIG_KEY,
19
19
  CREDENTIAL_VAULT_ID_PREFIX,
20
+ SECRET_REDACTED_VALUE,
20
21
  CredentialVaultReference,
21
22
  validate_credential_vault,
22
23
  )
@@ -39,9 +40,29 @@ from .response_schema import (
39
40
  from .sandbox_profile import (
40
41
  SANDBOX_PROFILE_CONFIG_KEY,
41
42
  SANDBOX_PROFILE_ID_PREFIX,
43
+ SYSTEM_DEFAULT_PROFILE_ID,
42
44
  SandboxProfileReference,
43
45
  validate_sandbox_profile,
44
46
  )
47
+ from .sandbox_profiles import (
48
+ MobileOpsSandboxProfilesClient,
49
+ MobileOpsSandboxProfilesError,
50
+ SandboxProfile,
51
+ SandboxProfileCreateInput,
52
+ SandboxProfileNetwork,
53
+ SandboxProfileSpec,
54
+ SandboxProfileUpdateInput,
55
+ )
56
+ from .vaults import (
57
+ MobileOpsVaultsClient,
58
+ MobileOpsVaultsError,
59
+ Vault,
60
+ VaultCreateInput,
61
+ VaultCredential,
62
+ VaultCredentialInput,
63
+ VaultCredentialUpdateInput,
64
+ VaultUpdateInput,
65
+ )
45
66
 
46
67
  __all__ = [
47
68
  "MobileOpsManagedAgentClient",
@@ -51,9 +72,24 @@ __all__ = [
51
72
  "MobileOpsAssistantsClient",
52
73
  "MobileOpsResourcesClient",
53
74
  "MobileOpsResourcesError",
75
+ "MobileOpsVaultsClient",
76
+ "MobileOpsVaultsError",
77
+ "MobileOpsSandboxProfilesClient",
78
+ "MobileOpsSandboxProfilesError",
54
79
  "ResourceFile",
55
80
  "ResourceSummary",
56
81
  "ResourceVersion",
82
+ "Vault",
83
+ "VaultCreateInput",
84
+ "VaultCredential",
85
+ "VaultCredentialInput",
86
+ "VaultCredentialUpdateInput",
87
+ "VaultUpdateInput",
88
+ "SandboxProfile",
89
+ "SandboxProfileCreateInput",
90
+ "SandboxProfileNetwork",
91
+ "SandboxProfileSpec",
92
+ "SandboxProfileUpdateInput",
57
93
  "CapabilityBundle",
58
94
  "MobileOpsAssistantConfig",
59
95
  "MobileOpsAssistant",
@@ -69,10 +105,14 @@ __all__ = [
69
105
  "validate_response_schema",
70
106
  "SANDBOX_PROFILE_CONFIG_KEY",
71
107
  "SANDBOX_PROFILE_ID_PREFIX",
108
+ "SYSTEM_DEFAULT_PROFILE_ID",
72
109
  "SandboxProfileReference",
73
110
  "validate_sandbox_profile",
74
111
  "CREDENTIAL_VAULT_CONFIG_KEY",
75
112
  "CREDENTIAL_VAULT_ID_PREFIX",
113
+ "SECRET_REDACTED_VALUE",
76
114
  "CredentialVaultReference",
77
115
  "validate_credential_vault",
78
116
  ]
117
+
118
+ from .tool_permissions import validate_tool_permissions, TOOL_PERMISSIONS_CONFIG_KEY
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .tool_permissions import TOOL_PERMISSIONS_CONFIG_KEY, validate_tool_permissions
4
+
3
5
  from typing import Any, NotRequired, TypedDict
4
6
 
5
7
  from .capabilities import (
@@ -52,6 +54,7 @@ class MobileOpsAssistant(TypedDict, total=False):
52
54
  mcp_servers: dict[str, CapabilityMcpServer]
53
55
  response_schema: JsonSchema
54
56
  sandbox_profile: str
57
+ tool_permissions: dict[str, Any]
55
58
  credential_vault: str
56
59
  raw: Any
57
60
  created_at: str
@@ -133,6 +136,7 @@ def to_wire_config(**kwargs: Any) -> MobileOpsAssistantConfig | None:
133
136
  and not has_response_schema
134
137
  and not has_sandbox_profile
135
138
  and not has_credential_vault
139
+ and "tool_permissions" not in kwargs
136
140
  ):
137
141
  return None
138
142
  configurable: dict[str, Any] = {}
@@ -153,6 +157,10 @@ def to_wire_config(**kwargs: Any) -> MobileOpsAssistantConfig | None:
153
157
  configurable[CREDENTIAL_VAULT_CONFIG_KEY] = validate_credential_vault(
154
158
  {"ref": credential_vault}
155
159
  )
160
+ if "tool_permissions" in kwargs:
161
+ configurable[TOOL_PERMISSIONS_CONFIG_KEY] = validate_tool_permissions(
162
+ kwargs["tool_permissions"], list((kwargs.get("mcp_servers") or {}).keys())
163
+ )
156
164
  return {"configurable": configurable} # type: ignore[return-value]
157
165
 
158
166
 
@@ -327,6 +335,10 @@ def from_wire_assistant(raw: object) -> MobileOpsAssistant:
327
335
  product["response_schema"] = response_schema
328
336
  if sandbox_profile is not None:
329
337
  product["sandbox_profile"] = sandbox_profile
338
+ if configurable and TOOL_PERMISSIONS_CONFIG_KEY in configurable:
339
+ product["tool_permissions"] = validate_tool_permissions(
340
+ configurable[TOOL_PERMISSIONS_CONFIG_KEY]
341
+ )
330
342
  if credential_vault is not None:
331
343
  product["credential_vault"] = credential_vault
332
344
  if isinstance(doc.get("name"), str):
@@ -52,6 +52,7 @@ class MobileOpsAssistantsClient:
52
52
  response_schema: JsonSchema | None = None,
53
53
  sandbox_profile: str | None = None,
54
54
  credential_vault: str | None = None,
55
+ tool_permissions: dict[str, Any] | _NotGiven = _NOT_GIVEN,
55
56
  assistant_id: str | None = None,
56
57
  if_exists: OnConflictBehavior | None = None,
57
58
  context: Context | None = None,
@@ -94,6 +95,8 @@ class MobileOpsAssistantsClient:
94
95
  if credential_vault is not None:
95
96
  product_kwargs["credential_vault"] = credential_vault
96
97
 
98
+ if not isinstance(tool_permissions, _NotGiven):
99
+ product_kwargs["tool_permissions"] = tool_permissions
97
100
  wire = to_wire_create_kwargs(**product_kwargs)
98
101
  native_kwargs = {
99
102
  key: wire[key]
@@ -172,6 +175,7 @@ class MobileOpsAssistantsClient:
172
175
  response_schema: JsonSchema | None | _NotGiven = _NOT_GIVEN,
173
176
  sandbox_profile: str | None | _NotGiven = _NOT_GIVEN,
174
177
  credential_vault: str | None | _NotGiven = _NOT_GIVEN,
178
+ tool_permissions: dict[str, Any] | _NotGiven = _NOT_GIVEN,
175
179
  context: Context | None = None,
176
180
  headers: Mapping[str, str] | None = None,
177
181
  params: QueryParamTypes | None = None,
@@ -212,7 +216,9 @@ class MobileOpsAssistantsClient:
212
216
  )
213
217
  ) or not isinstance(response_schema, _NotGiven) or not isinstance(
214
218
  sandbox_profile, _NotGiven
215
- ) or not isinstance(credential_vault, _NotGiven)
219
+ ) or not isinstance(credential_vault, _NotGiven) or not isinstance(
220
+ tool_permissions, _NotGiven
221
+ )
216
222
  if changes_config:
217
223
  current = await self.get(
218
224
  assistant_id,
@@ -248,6 +254,10 @@ class MobileOpsAssistantsClient:
248
254
  if not isinstance(sandbox_profile, _NotGiven)
249
255
  else current.get("sandbox_profile")
250
256
  )
257
+ if not isinstance(tool_permissions, _NotGiven):
258
+ product_kwargs["tool_permissions"] = tool_permissions
259
+ elif "tool_permissions" in current:
260
+ product_kwargs["tool_permissions"] = current["tool_permissions"]
251
261
  product_kwargs["credential_vault"] = (
252
262
  credential_vault
253
263
  if not isinstance(credential_vault, _NotGiven)
@@ -298,7 +298,7 @@ _SIZE_BUDGET_PROXY_HOST = f"{'x' * 253}:65535"
298
298
 
299
299
 
300
300
  def _size_budget_upstream_host(url: object) -> str | None:
301
- """Mirror backend auto-Host injection for tunnel absolute-form authority."""
301
+ """Mirror backend upstream Host configuration for tunneled targets."""
302
302
  if not isinstance(url, str) or not url.strip():
303
303
  return None
304
304
  from urllib.parse import urlparse
@@ -337,7 +337,7 @@ def _assert_mcp_gateway_config_launchable(servers: dict) -> None:
337
337
  continue
338
338
  # Budget processd backendTunnel + auto Host injected at sandbox setup.
339
339
  policies: dict = {
340
- "backendTunnel": {"proxy": {"host": _SIZE_BUDGET_PROXY_HOST}},
340
+ "backendTunnel": {"mode": "connect", "proxy": {"host": _SIZE_BUDGET_PROXY_HOST}},
341
341
  }
342
342
  headers = server.get("headers")
343
343
  header_set: dict = dict(headers) if isinstance(headers, dict) else {}
@@ -9,6 +9,8 @@ from .auth import build_headers
9
9
  from .files import MobileOpsFilesClient
10
10
  from .assistants import MobileOpsAssistantsClient
11
11
  from .resources import MobileOpsResourcesClient
12
+ from .sandbox_profiles import MobileOpsSandboxProfilesClient
13
+ from .vaults import MobileOpsVaultsClient
12
14
 
13
15
 
14
16
  class MobileOpsAgentClient:
@@ -62,6 +64,18 @@ class MobileOpsManagedAgentClient:
62
64
  headers=headers,
63
65
  http_client=http_client,
64
66
  )
67
+ self.vaults = MobileOpsVaultsClient(
68
+ api_url=api_url,
69
+ api_key=api_key,
70
+ headers=headers,
71
+ http_client=http_client,
72
+ )
73
+ self.sandbox_profiles = MobileOpsSandboxProfilesClient(
74
+ api_url=api_url,
75
+ api_key=api_key,
76
+ headers=headers,
77
+ http_client=http_client,
78
+ )
65
79
 
66
80
  async def aclose(self) -> None:
67
81
  try:
@@ -72,7 +86,13 @@ class MobileOpsManagedAgentClient:
72
86
  try:
73
87
  await self.files.aclose()
74
88
  finally:
75
- await self.resources.aclose()
89
+ try:
90
+ await self.resources.aclose()
91
+ finally:
92
+ try:
93
+ await self.vaults.aclose()
94
+ finally:
95
+ await self.sandbox_profiles.aclose()
76
96
 
77
97
  async def __aenter__(self) -> "MobileOpsManagedAgentClient":
78
98
  return self
@@ -12,6 +12,7 @@ from typing import Any, TypeAlias
12
12
 
13
13
  CREDENTIAL_VAULT_CONFIG_KEY = "haiops_credential_vault"
14
14
  CREDENTIAL_VAULT_ID_PREFIX = "vlt_"
15
+ SECRET_REDACTED_VALUE = "***"
15
16
 
16
17
  CredentialVaultReference: TypeAlias = dict[str, str]
17
18
 
@@ -36,7 +36,6 @@ class ResourceSummary(TypedDict):
36
36
 
37
37
  class ResourceFile(TypedDict):
38
38
  path: str
39
- content_b64: NotRequired[str]
40
39
  size: NotRequired[int]
41
40
 
42
41
 
@@ -12,6 +12,7 @@ from typing import Any, TypeAlias
12
12
 
13
13
  SANDBOX_PROFILE_CONFIG_KEY = "haiops_sandbox_profile"
14
14
  SANDBOX_PROFILE_ID_PREFIX = "sbp_"
15
+ SYSTEM_DEFAULT_PROFILE_ID = f"{SANDBOX_PROFILE_ID_PREFIX}system_default"
15
16
 
16
17
  SandboxProfileReference: TypeAlias = dict[str, str]
17
18
 
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, NotRequired, TypedDict
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from .auth import build_headers
10
+ from .sandbox_profile import SYSTEM_DEFAULT_PROFILE_ID
11
+
12
+ __all__ = [
13
+ "SYSTEM_DEFAULT_PROFILE_ID",
14
+ "SandboxProfile",
15
+ "SandboxProfileCreateInput",
16
+ "SandboxProfileNetwork",
17
+ "SandboxProfileSpec",
18
+ "SandboxProfileUpdateInput",
19
+ "MobileOpsSandboxProfilesError",
20
+ "MobileOpsSandboxProfilesClient",
21
+ ]
22
+
23
+
24
+ class SandboxProfileNetwork(TypedDict):
25
+ allow: list[str]
26
+ allow_ports: NotRequired[list[int]]
27
+
28
+
29
+ class SandboxProfileSpec(TypedDict):
30
+ network: SandboxProfileNetwork
31
+
32
+
33
+ class SandboxProfile(TypedDict):
34
+ id: str
35
+ name: str
36
+ spec: SandboxProfileSpec
37
+ created_at: NotRequired[str]
38
+ updated_at: NotRequired[str]
39
+
40
+
41
+ class SandboxProfileCreateInput(TypedDict):
42
+ name: str
43
+ spec: SandboxProfileSpec
44
+
45
+
46
+ class SandboxProfileUpdateInput(TypedDict, total=False):
47
+ name: str
48
+ spec: SandboxProfileSpec
49
+
50
+
51
+ class MobileOpsSandboxProfilesError(Exception):
52
+ def __init__(self, response: httpx.Response, body: Any) -> None:
53
+ self.status_code = response.status_code
54
+ self.response = response
55
+ self.body = body
56
+ message = (
57
+ body.get("error")
58
+ if isinstance(body, dict) and isinstance(body.get("error"), str)
59
+ else (
60
+ "MobileOPS Sandbox Profiles request failed with status "
61
+ f"{response.status_code}"
62
+ )
63
+ )
64
+ super().__init__(message)
65
+
66
+
67
+ class MobileOpsSandboxProfilesClient:
68
+ def __init__(
69
+ self,
70
+ *,
71
+ api_url: str,
72
+ api_key: str,
73
+ headers: Mapping[str, str] | None = None,
74
+ http_client: httpx.AsyncClient | None = None,
75
+ ) -> None:
76
+ if not api_url:
77
+ raise ValueError("api_url is required")
78
+ if not api_key:
79
+ raise ValueError("api_key is required")
80
+ self._api_url = api_url.rstrip("/")
81
+ self._headers = build_headers(api_key, headers)
82
+ self._client = http_client or httpx.AsyncClient()
83
+ self._owns_client = http_client is None
84
+
85
+ async def aclose(self) -> None:
86
+ if self._owns_client:
87
+ await self._client.aclose()
88
+
89
+ async def list(self) -> list[SandboxProfile]:
90
+ payload = await self._request_json("GET", self._profiles_url())
91
+ return payload["profiles"]
92
+
93
+ async def create(
94
+ self, *, name: str, spec: SandboxProfileSpec
95
+ ) -> SandboxProfile:
96
+ payload = await self._request_json(
97
+ "POST", self._profiles_url(), json={"name": name, "spec": spec}
98
+ )
99
+ return payload["profile"]
100
+
101
+ async def retrieve(self, id: str) -> SandboxProfile:
102
+ payload = await self._request_json("GET", self._profile_url(id))
103
+ return payload["profile"]
104
+
105
+ async def update(
106
+ self,
107
+ id: str,
108
+ *,
109
+ name: str | None = None,
110
+ spec: SandboxProfileSpec | None = None,
111
+ ) -> SandboxProfile:
112
+ payload_body: dict[str, Any] = {}
113
+ if name is not None:
114
+ payload_body["name"] = name
115
+ if spec is not None:
116
+ payload_body["spec"] = spec
117
+ payload = await self._request_json(
118
+ "PATCH", self._profile_url(id), json=payload_body
119
+ )
120
+ return payload["profile"]
121
+
122
+ async def delete(self, id: str) -> None:
123
+ await self._request("DELETE", self._profile_url(id))
124
+
125
+ def _profiles_url(self) -> str:
126
+ return f"{self._api_url}/context-hub/v1/sandbox-profiles"
127
+
128
+ def _profile_url(self, id: str) -> str:
129
+ return f"{self._profiles_url()}/{quote(id, safe='')}"
130
+
131
+ async def _request_json(
132
+ self, method: str, url: str, **kwargs: Any
133
+ ) -> dict[str, Any]:
134
+ return (await self._request(method, url, **kwargs)).json()
135
+
136
+ async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
137
+ headers = {**self._headers, **kwargs.pop("headers", {})}
138
+ response = await self._client.request(method, url, headers=headers, **kwargs)
139
+ if response.is_error:
140
+ content_type = response.headers.get("content-type", "")
141
+ body: Any = response.text
142
+ if "application/json" in content_type:
143
+ try:
144
+ body = response.json()
145
+ except ValueError:
146
+ pass
147
+ raise MobileOpsSandboxProfilesError(response, body)
148
+ return response
@@ -0,0 +1,97 @@
1
+ """Tool permission policies. Approval decisions never modify configured rules.
2
+ Keep validators aligned with the shared conformance fixtures.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import re
9
+ from typing import Any
10
+
11
+ TOOL_PERMISSIONS_CONFIG_KEY = "haiops_tool_permissions"
12
+ BUILTIN_TOOL_NAMES = frozenset(
13
+ {
14
+ "ls",
15
+ "read_file",
16
+ "write_file",
17
+ "edit_file",
18
+ "delete",
19
+ "glob",
20
+ "grep",
21
+ "execute",
22
+ "eval",
23
+ "task",
24
+ "write_todos",
25
+ "request_user_input",
26
+ }
27
+ )
28
+
29
+
30
+ def _object(value: Any, keys: set[str]) -> dict:
31
+ if not isinstance(value, dict) or set(value) - keys:
32
+ raise ValueError("Invalid tool permissions object or unsupported field")
33
+ return value
34
+
35
+
36
+ def _rule(value: Any, named: bool = False) -> dict:
37
+ r = _object(
38
+ value, {"enabled", "permission_policy"} | ({"name"} if named else set())
39
+ )
40
+ if "enabled" in r and type(r["enabled"]) is not bool:
41
+ raise ValueError("enabled must be boolean")
42
+ if "permission_policy" in r:
43
+ p = _object(r["permission_policy"], {"type"})
44
+ if p.get("type") not in ("always_allow", "always_ask"):
45
+ raise ValueError("Unsupported permission policy")
46
+ return r
47
+
48
+
49
+ def validate_tool_permissions(value: Any, servers: list[str] | None = None) -> dict:
50
+ p = _object(value, {"version", "toolsets"})
51
+ if (
52
+ type(p.get("version")) is not int
53
+ or p["version"] != 1
54
+ or not isinstance(p.get("toolsets"), list)
55
+ or len(p["toolsets"]) > 128
56
+ ):
57
+ raise ValueError(
58
+ "Tool permissions require version 1 and a toolsets array (max 128)"
59
+ )
60
+ seen = set()
61
+ for raw in p["toolsets"]:
62
+ s = _object(raw, {"type", "mcp_server_name", "default_config", "configs"})
63
+ if s.get("type") not in ("builtin", "mcp"):
64
+ raise ValueError("Unknown toolset type")
65
+ if s["type"] == "mcp":
66
+ server = s.get("mcp_server_name")
67
+ if (
68
+ not isinstance(server, str)
69
+ or not re.fullmatch(r"[A-Za-z0-9.-]+", server)
70
+ or (servers is not None and server not in servers)
71
+ ):
72
+ raise ValueError("Unknown or invalid MCP server")
73
+ elif "mcp_server_name" in s:
74
+ raise ValueError("Only MCP toolsets accept mcp_server_name")
75
+ key = (s["type"], s.get("mcp_server_name"))
76
+ if key in seen:
77
+ raise ValueError("Duplicate toolset")
78
+ seen.add(key)
79
+ if "default_config" in s:
80
+ _rule(s["default_config"])
81
+ if "configs" in s:
82
+ if not isinstance(s["configs"], list) or len(s["configs"]) > 1024:
83
+ raise ValueError("configs must be an array (max 1024)")
84
+ names = set()
85
+ for raw_config in s["configs"]:
86
+ c = _rule(raw_config, True)
87
+ name = c.get("name")
88
+ if (
89
+ not isinstance(name, str)
90
+ or not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", name)
91
+ or name in names
92
+ ):
93
+ raise ValueError("Invalid or duplicate tool name")
94
+ if s["type"] == "builtin" and name not in BUILTIN_TOOL_NAMES:
95
+ raise ValueError("Unknown built-in tool")
96
+ names.add(name)
97
+ return copy.deepcopy(p)
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, NotRequired, TypedDict
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from .auth import build_headers
10
+ from .credential_vault import SECRET_REDACTED_VALUE
11
+
12
+ __all__ = [
13
+ "SECRET_REDACTED_VALUE",
14
+ "Vault",
15
+ "VaultCreateInput",
16
+ "VaultCredential",
17
+ "VaultCredentialInput",
18
+ "VaultCredentialUpdateInput",
19
+ "VaultUpdateInput",
20
+ "MobileOpsVaultsError",
21
+ "MobileOpsVaultsClient",
22
+ ]
23
+
24
+
25
+ class Vault(TypedDict):
26
+ id: str
27
+ name: str
28
+ credential_count: int
29
+ created_at: NotRequired[str]
30
+ updated_at: NotRequired[str]
31
+
32
+
33
+ class VaultCredential(TypedDict):
34
+ id: str
35
+ vault_id: str
36
+ name: str
37
+ value: str
38
+ allowed_hosts: list[str]
39
+ header: bool
40
+ body: bool
41
+ created_at: NotRequired[str]
42
+ updated_at: NotRequired[str]
43
+
44
+
45
+ class VaultCreateInput(TypedDict):
46
+ name: str
47
+
48
+
49
+ class VaultUpdateInput(TypedDict, total=False):
50
+ name: str
51
+
52
+
53
+ class VaultCredentialInput(TypedDict):
54
+ name: str
55
+ value: str
56
+ allowed_hosts: list[str]
57
+ header: NotRequired[bool]
58
+ body: NotRequired[bool]
59
+
60
+
61
+ class VaultCredentialUpdateInput(TypedDict, total=False):
62
+ name: str
63
+ value: str
64
+ allowed_hosts: list[str]
65
+ header: bool
66
+ body: bool
67
+
68
+
69
+ class MobileOpsVaultsError(Exception):
70
+ def __init__(self, response: httpx.Response, body: Any) -> None:
71
+ self.status_code = response.status_code
72
+ self.response = response
73
+ self.body = body
74
+ message = (
75
+ body.get("error")
76
+ if isinstance(body, dict) and isinstance(body.get("error"), str)
77
+ else f"MobileOPS Vaults request failed with status {response.status_code}"
78
+ )
79
+ super().__init__(message)
80
+
81
+
82
+ class MobileOpsVaultCredentialsClient:
83
+ def __init__(self, vaults: "MobileOpsVaultsClient") -> None:
84
+ self._vaults = vaults
85
+
86
+ async def list(self, vault_id: str) -> list[VaultCredential]:
87
+ payload = await self._vaults._request_json(
88
+ "GET", self._vaults._credentials_url(vault_id)
89
+ )
90
+ return payload["credentials"]
91
+
92
+ async def create(
93
+ self,
94
+ vault_id: str,
95
+ *,
96
+ name: str,
97
+ value: str,
98
+ allowed_hosts: list[str],
99
+ header: bool | None = None,
100
+ body: bool | None = None,
101
+ ) -> VaultCredential:
102
+ payload_body: dict[str, Any] = {
103
+ "name": name,
104
+ "value": value,
105
+ "allowed_hosts": allowed_hosts,
106
+ }
107
+ if header is not None:
108
+ payload_body["header"] = header
109
+ if body is not None:
110
+ payload_body["body"] = body
111
+ payload = await self._vaults._request_json(
112
+ "POST",
113
+ self._vaults._credentials_url(vault_id),
114
+ json=payload_body,
115
+ )
116
+ return payload["credential"]
117
+
118
+ async def update(
119
+ self,
120
+ vault_id: str,
121
+ credential_id: str,
122
+ *,
123
+ name: str | None = None,
124
+ value: str | None = None,
125
+ allowed_hosts: list[str] | None = None,
126
+ header: bool | None = None,
127
+ body: bool | None = None,
128
+ ) -> VaultCredential:
129
+ payload_body: dict[str, Any] = {}
130
+ if name is not None:
131
+ payload_body["name"] = name
132
+ if value is not None:
133
+ payload_body["value"] = value
134
+ if allowed_hosts is not None:
135
+ payload_body["allowed_hosts"] = allowed_hosts
136
+ if header is not None:
137
+ payload_body["header"] = header
138
+ if body is not None:
139
+ payload_body["body"] = body
140
+ payload = await self._vaults._request_json(
141
+ "PATCH",
142
+ self._vaults._credential_url(vault_id, credential_id),
143
+ json=payload_body,
144
+ )
145
+ return payload["credential"]
146
+
147
+ async def delete(self, vault_id: str, credential_id: str) -> None:
148
+ await self._vaults._request(
149
+ "DELETE", self._vaults._credential_url(vault_id, credential_id)
150
+ )
151
+
152
+
153
+ class MobileOpsVaultsClient:
154
+ def __init__(
155
+ self,
156
+ *,
157
+ api_url: str,
158
+ api_key: str,
159
+ headers: Mapping[str, str] | None = None,
160
+ http_client: httpx.AsyncClient | None = None,
161
+ ) -> None:
162
+ if not api_url:
163
+ raise ValueError("api_url is required")
164
+ if not api_key:
165
+ raise ValueError("api_key is required")
166
+ self._api_url = api_url.rstrip("/")
167
+ self._headers = build_headers(api_key, headers)
168
+ self._client = http_client or httpx.AsyncClient()
169
+ self._owns_client = http_client is None
170
+ self.credentials = MobileOpsVaultCredentialsClient(self)
171
+
172
+ async def aclose(self) -> None:
173
+ if self._owns_client:
174
+ await self._client.aclose()
175
+
176
+ async def list(self) -> list[Vault]:
177
+ payload = await self._request_json("GET", self._vaults_url())
178
+ return payload["vaults"]
179
+
180
+ async def create(self, *, name: str) -> Vault:
181
+ payload = await self._request_json(
182
+ "POST", self._vaults_url(), json={"name": name}
183
+ )
184
+ return payload["vault"]
185
+
186
+ async def retrieve(self, id: str) -> Vault:
187
+ payload = await self._request_json("GET", self._vault_url(id))
188
+ return payload["vault"]
189
+
190
+ async def update(self, id: str, *, name: str | None = None) -> Vault:
191
+ payload_body: dict[str, Any] = {}
192
+ if name is not None:
193
+ payload_body["name"] = name
194
+ payload = await self._request_json(
195
+ "PATCH", self._vault_url(id), json=payload_body
196
+ )
197
+ return payload["vault"]
198
+
199
+ async def delete(self, id: str) -> None:
200
+ await self._request("DELETE", self._vault_url(id))
201
+
202
+ def _vaults_url(self) -> str:
203
+ return f"{self._api_url}/context-hub/v1/vaults"
204
+
205
+ def _vault_url(self, id: str) -> str:
206
+ return f"{self._vaults_url()}/{quote(id, safe='')}"
207
+
208
+ def _credentials_url(self, vault_id: str) -> str:
209
+ return f"{self._vault_url(vault_id)}/credentials"
210
+
211
+ def _credential_url(self, vault_id: str, credential_id: str) -> str:
212
+ return f"{self._credentials_url(vault_id)}/{quote(credential_id, safe='')}"
213
+
214
+ async def _request_json(
215
+ self, method: str, url: str, **kwargs: Any
216
+ ) -> dict[str, Any]:
217
+ return (await self._request(method, url, **kwargs)).json()
218
+
219
+ async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
220
+ headers = {**self._headers, **kwargs.pop("headers", {})}
221
+ response = await self._client.request(method, url, headers=headers, **kwargs)
222
+ if response.is_error:
223
+ content_type = response.headers.get("content-type", "")
224
+ body: Any = response.text
225
+ if "application/json" in content_type:
226
+ try:
227
+ body = response.json()
228
+ except ValueError:
229
+ pass
230
+ raise MobileOpsVaultsError(response, body)
231
+ return response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobileops-managed-agent
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Server-side SDK for the MobileOPS Managed Agent API.
5
5
  Project-URL: Homepage, https://github.com/InnoMindX/deepagent/tree/main/managed-agent-sdk/python
6
6
  Project-URL: Repository, https://github.com/InnoMindX/deepagent
@@ -18,11 +18,13 @@ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
18
18
 
19
19
  Server-side Python SDK for the MobileOPS Managed Agent API.
20
20
 
21
- The SDK exposes three surfaces:
21
+ The SDK exposes five surfaces:
22
22
 
23
23
  - `client.agent` wraps the LangGraph runtime client and exposes the supported Assistant CRUD methods at `client.agent.assistants`.
24
24
  - `client.files` exposes MobileOPS Sandbox Workspace file routes.
25
25
  - `client.resources` manages Context Hub resources, versions, and latest-version files.
26
+ - `client.vaults` manages credential vaults and their write-only credentials.
27
+ - `client.sandbox_profiles` manages sandbox profiles (运行环境).
26
28
 
27
29
  Assistant create and update retain LangGraph's ordinary arguments, while
28
30
  persisted configuration is exposed through product fields. Pass
@@ -37,6 +39,8 @@ therefore only affects Runs started on **new** Threads; existing Threads keep
37
39
  the schema captured at their creation.
38
40
 
39
41
  ```py
42
+ import os
43
+
40
44
  from mobileops_managed_agent import MobileOpsManagedAgentClient
41
45
 
42
46
  client = MobileOpsManagedAgentClient(api_url=api_url, api_key=api_key)
@@ -45,12 +49,25 @@ thread = await client.agent.threads.create()
45
49
  await client.agent.runs.create(thread["thread_id"], assistant_id, input=input)
46
50
 
47
51
  skill = await client.resources.create("skills", zip_bytes) # slug inferred
52
+ profile = await client.sandbox_profiles.create(
53
+ name="prod",
54
+ spec={"network": {"allow": ["*.example.com"]}},
55
+ )
56
+ vault = await client.vaults.create(name="prod")
57
+ await client.vaults.credentials.create(
58
+ vault["id"],
59
+ name="GRAFANA_TOKEN",
60
+ value=os.environ["GRAFANA_TOKEN"],
61
+ allowed_hosts=["grafana.example.com"],
62
+ )
48
63
  assistant = await client.agent.assistants.create(
49
64
  name="Operations agent",
50
65
  skills=[{
51
66
  "ref": skill["ref"], # e.g. incident-response@sha256-…
52
67
  "target_path": "/workspace/skills", # zip already contains {slug}/
53
68
  }],
69
+ sandbox_profile=profile["id"],
70
+ credential_vault=vault["id"],
54
71
  )
55
72
 
56
73
  await client.resources.versions.create("skills", skill["slug"], next_zip_bytes)
@@ -12,6 +12,9 @@ src/mobileops_managed_agent/files.py
12
12
  src/mobileops_managed_agent/resources.py
13
13
  src/mobileops_managed_agent/response_schema.py
14
14
  src/mobileops_managed_agent/sandbox_profile.py
15
+ src/mobileops_managed_agent/sandbox_profiles.py
16
+ src/mobileops_managed_agent/tool_permissions.py
17
+ src/mobileops_managed_agent/vaults.py
15
18
  src/mobileops_managed_agent.egg-info/PKG-INFO
16
19
  src/mobileops_managed_agent.egg-info/SOURCES.txt
17
20
  src/mobileops_managed_agent.egg-info/dependency_links.txt