netconverter-cli 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,331 @@
1
+ """User-directed local orchestration; providers never receive this object."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import time
7
+ import uuid
8
+ from .workspace import Workspace, WorkspaceError
9
+ from .api import APIError, Client, ExternalClient
10
+ from .backend import EXTERNAL_MAX_BYTES, download_external, submit_external
11
+ from .catalog import Catalog
12
+
13
+ # Query commands → the NetConverter MCP tool of the same purpose (full API profile).
14
+ QUERY_TOOLS = {
15
+ "unused_objects": "get_unused_objects",
16
+ "routes": "get_route_summary",
17
+ "interfaces": "get_interface_summary",
18
+ "policy": "get_acl_summary",
19
+ "nat": "get_nat_summary",
20
+ "dependencies": "get_object_dependencies",
21
+ "flow": "simulate_flow",
22
+ }
23
+
24
+
25
+ # Internal pipeline evidence stays on the server (upstream 0439995).
26
+ INTERNAL_EVIDENCE_FILES = frozenset({"evidence.json", "pipeline-evidence.json"})
27
+
28
+
29
+ def fetch_artifacts(client, job, output: Workspace) -> list:
30
+ """Download every manifest artifact, verify size and SHA-256, publish atomically."""
31
+ saved = []
32
+ for item in client.artifacts(job)["artifacts"]:
33
+ if item["filename"] in INTERNAL_EVIDENCE_FILES:
34
+ continue
35
+ data = client.download(job, item["artifact_id"])
36
+ if any(data.get(k) != item[k] for k in ("filename", "sha256", "bytes")):
37
+ raise WorkspaceError("Artifact manifest changed; retry download")
38
+ try:
39
+ content = base64.b64decode(data["content_base64"], validate=True)
40
+ except ValueError:
41
+ raise WorkspaceError("Invalid artifact encoding") from None
42
+ if len(content) != item["bytes"]:
43
+ raise WorkspaceError("Artifact size mismatch")
44
+ saved.append(output.save_artifact(job, item["filename"], content, item["sha256"]))
45
+ return saved
46
+
47
+
48
+ class Controller:
49
+ """``kind`` is the credential profile: ``external`` (full API, any vendor) or
50
+ ``quick`` (0.1.x pilot key). ``remote`` returns a RemoteMCP for full-API queries."""
51
+
52
+ def __init__(self, workspace, client, provider=None, *, kind=None, remote=None):
53
+ self.workspace = Workspace(workspace)
54
+ self.client = client
55
+ self.provider = provider
56
+ self.kind = kind or ("external" if isinstance(client, ExternalClient) else "quick")
57
+ self.remote_factory = remote
58
+ self._remote = None
59
+ self.state = self.workspace.load()
60
+ self.allowance = {}
61
+ self.catalog = Catalog()
62
+
63
+ @property
64
+ def external(self) -> bool:
65
+ return self.kind == "external"
66
+
67
+ def capabilities(self):
68
+ try:
69
+ self.allowance = self.client.capabilities()
70
+ except APIError as exc:
71
+ if self.external and exc.status == 404:
72
+ raise WorkspaceError("This NetConverter server does not publish its capabilities yet "
73
+ "(GET /capabilities); update the server, then retry /allowance") from None
74
+ raise
75
+ self.catalog = (Catalog.from_external if self.external else Catalog.from_quick)(self.allowance)
76
+ return self.allowance
77
+
78
+ def max_bytes(self) -> int:
79
+ if self.catalog.loaded:
80
+ return self.catalog.max_bytes
81
+ return EXTERNAL_MAX_BYTES if self.external else Catalog().max_bytes
82
+
83
+ def select(self, filename, vendor):
84
+ if self.external:
85
+ return self._select_local(filename, vendor)
86
+ path, content, digest = self.workspace.snapshot(filename)
87
+ result = self.client.upload(content, vendor)
88
+ if result["source_sha256"] != digest:
89
+ raise WorkspaceError("Server revision checksum mismatch")
90
+ handle = result["configuration_id"]
91
+ self.state["configurations"][handle] = {
92
+ "path": str(path.relative_to(self.workspace.root)),
93
+ "sha256": digest,
94
+ "vendor": vendor,
95
+ }
96
+ self.state["selected"] = handle
97
+ self.state.pop("active_job", None)
98
+ self.workspace.store(self.state)
99
+ return result
100
+
101
+ def _select_local(self, filename, vendor):
102
+ """Full API: nothing is uploaded until submit; the revision is pinned by SHA-256."""
103
+ path, _, digest = self.workspace.snapshot(filename, self.max_bytes())
104
+ handle = "local_" + digest[:16]
105
+ self.state["configurations"][handle] = {
106
+ "path": str(path.relative_to(self.workspace.root)),
107
+ "sha256": digest,
108
+ "vendor": vendor,
109
+ "local": True,
110
+ }
111
+ self.state["selected"] = handle
112
+ self.state.pop("active_job", None)
113
+ self.workspace.store(self.state)
114
+ return {"configuration_id": handle, "source_sha256": digest, "vendor": vendor, "local": True}
115
+
116
+ def _selected_revision(self, handle):
117
+ metadata = self.state["configurations"].get(handle) or {}
118
+ _, content, digest = self.workspace.snapshot(metadata.get("path", ""), self.max_bytes())
119
+ if digest != metadata.get("sha256"):
120
+ raise WorkspaceError("The selected file changed after you chose it; select it again")
121
+ return metadata, content
122
+
123
+ def submit(self, operation, options=None):
124
+ selected = self.state.get("selected")
125
+ if not selected:
126
+ raise WorkspaceError("Open a configuration first")
127
+ if self.external and operation == "analyze":
128
+ operation = "validate"
129
+ request = {
130
+ "configuration_id": selected,
131
+ "operation": operation,
132
+ "options": options or {},
133
+ }
134
+ pending = self.state.get("pending")
135
+ if pending and pending["request"] != request:
136
+ raise WorkspaceError(
137
+ "An interrupted submission is pending; use resume before submitting different work"
138
+ )
139
+ key = pending["key"] if pending else uuid.uuid4().hex
140
+ self.state["pending"] = {"key": key, "request": request, "started": time.time()}
141
+ self.workspace.store(self.state)
142
+ try:
143
+ if self.external:
144
+ metadata, content = self._selected_revision(selected)
145
+ settings = dict(options or {})
146
+ target = settings.pop("target_vendor", None)
147
+ result = submit_external(self.client, content, metadata["vendor"], target,
148
+ operation, settings, key)
149
+ else:
150
+ wire = "analyze" if operation == "validate" else operation # the pilot API's name
151
+ result = self.client.submit(selected, wire, options or {}, key)
152
+ except APIError as exc:
153
+ if exc.status in {400, 403, 404, 413, 422, 429}:
154
+ self.state.pop("pending", None)
155
+ self.workspace.store(self.state)
156
+ raise
157
+ self.state["jobs"][result["job_id"]] = {
158
+ "configuration_id": selected,
159
+ "operation": operation,
160
+ "options": options or {},
161
+ "submitted_at": self.state["pending"]["started"],
162
+ }
163
+ self.state["active_job"] = result["job_id"]
164
+ self.state.pop("pending", None)
165
+ self.workspace.store(self.state)
166
+ return result
167
+
168
+ def resume(self, job_id=None):
169
+ if self.state.get("pending"):
170
+ request = self.state["pending"]["request"]
171
+ return self.submit(request["operation"], request["options"])
172
+ job = job_id or self.state.get("active_job")
173
+ if not job:
174
+ return self.client.jobs()
175
+ result = self.client.job(job) if self.external else self.client.status(job)
176
+ self.state["active_job"] = job
177
+ configuration = result.get("configuration_id")
178
+ if configuration:
179
+ self.state["selected"] = configuration
180
+ self.state["jobs"].setdefault(job, {})["configuration_id"] = configuration
181
+ self.workspace.store(self.state)
182
+ return result
183
+
184
+ def jobs(self):
185
+ return self.client.jobs()
186
+
187
+ def remote(self):
188
+ if self._remote is None:
189
+ self._remote = self.remote_factory() if self.remote_factory else None
190
+ if self._remote is None:
191
+ raise WorkspaceError("Questions about a job need NetConverter MCP access; sign in with netconverter login --clerk")
192
+ return self._remote
193
+
194
+ def _query_remote(self, job, operation, arguments):
195
+ from .remote_mcp import RemoteError
196
+
197
+ tool = QUERY_TOOLS.get(operation)
198
+ if not tool:
199
+ raise WorkspaceError("Unsupported question; use /help")
200
+ try:
201
+ remote = self.remote()
202
+ remote.call_tool("select_job", {"job_id": job})
203
+ reply = remote.call_tool(tool, arguments)
204
+ except RemoteError as exc:
205
+ raise WorkspaceError(f"NetConverter could not answer this question ({exc.code})") from None
206
+ details = reply.get("structuredContent")
207
+ if not isinstance(details, dict):
208
+ try:
209
+ details = json.loads(reply["content"][0]["text"])
210
+ except (KeyError, IndexError, TypeError, ValueError):
211
+ details = {"result": "No structured result"}
212
+ if reply.get("isError"):
213
+ raise WorkspaceError(str(details.get("message") or details.get("error") or "The question failed"))
214
+ return {"job_id": job, "operation": operation, "details": details,
215
+ "query_id": operation + "_" + uuid.uuid4().hex[:12]}
216
+
217
+ def query(self, operation, arguments=None):
218
+ job = self.state.get("active_job")
219
+ if not job:
220
+ raise WorkspaceError("Select a completed job with resume first")
221
+ if self.external:
222
+ result = self._query_remote(job, operation, arguments or {})
223
+ return self._save_query(job, result)
224
+ digest = hashlib.sha256(
225
+ json.dumps([job, operation, arguments or {}], sort_keys=True).encode()
226
+ ).hexdigest()
227
+ keys = self.state.setdefault("query_keys", {})
228
+ key = keys.setdefault(digest, uuid.uuid4().hex)
229
+ self.workspace.store(self.state)
230
+ result = self.client.query(job, operation, arguments or {}, key)
231
+ offset = result.get("pagination", {}).get("next_offset")
232
+
233
+ def merge(left, right):
234
+ for field, value in right.items():
235
+ if isinstance(value, list):
236
+ left.setdefault(field, []).extend(value)
237
+ elif isinstance(value, dict):
238
+ merge(left.setdefault(field, {}), value)
239
+
240
+ while offset is not None:
241
+ page = self.client.query(
242
+ job, operation, arguments or {}, key, offset=offset
243
+ )
244
+ merge(result["details"], page["details"])
245
+ next_offset = page.get("pagination", {}).get("next_offset")
246
+ if next_offset is not None and next_offset <= offset:
247
+ raise WorkspaceError("Invalid findings pagination")
248
+ offset = next_offset
249
+ result["pagination"] = {"complete": True}
250
+ return self._save_query(job, result)
251
+
252
+ def _save_query(self, job, result):
253
+ config = self.state["jobs"].get(job, {}).get("configuration_id")
254
+ source = self.state["configurations"].get(config, {}).get("path")
255
+ parent = (
256
+ self.workspace._inside((self.workspace.root / source).parent)
257
+ if source
258
+ else self.workspace.root
259
+ )
260
+ # PostgreSQL JSONB may reorder keys on an idempotent replay. Canonical
261
+ # bytes keep repeat downloads stable while preserving older artifacts.
262
+ content = json.dumps(result, indent=2, default=str, sort_keys=True).encode()
263
+ digest = hashlib.sha256(content).hexdigest()
264
+ output = Workspace(parent)
265
+ query_id = Client.identifier(result["query_id"])
266
+ result["saved"] = [
267
+ str(output.save_artifact(job, query_id + "." + digest[:12] + "." + ext, content, digest))
268
+ for ext in ("json", "txt")
269
+ ]
270
+ return result
271
+
272
+ def download(self):
273
+ job = self.state.get("active_job")
274
+ if not job:
275
+ raise WorkspaceError("Select a job first")
276
+ config = self.state["jobs"].get(job, {}).get("configuration_id")
277
+ metadata = self.state["configurations"].get(config, {})
278
+ parent = (
279
+ (self.workspace.root / metadata.get("path", ".")).parent
280
+ if metadata.get("path")
281
+ else self.workspace.root
282
+ )
283
+ parent = self.workspace._inside(parent)
284
+ if self.external:
285
+ paths = download_external(self.client, job, Workspace(parent))
286
+ else:
287
+ paths = fetch_artifacts(self.client, job, Workspace(parent))
288
+ return {"job_id": job, "saved": [str(path) for path in paths]}
289
+
290
+ def target(self):
291
+ job = self.state.get("active_job")
292
+ if not job:
293
+ raise WorkspaceError("Select a completed conversion first")
294
+ if self.external:
295
+ return self._target_local(job)
296
+ result = self.client.target(job)
297
+ source = self.state["jobs"].get(job, {}).get("configuration_id")
298
+ self.state["configurations"][result["configuration_id"]] = {
299
+ "parent_job_id": job,
300
+ "display_name": "Generated " + result["vendor"] + " · " + job,
301
+ "vendor": result["vendor"],
302
+ "path": self.state["configurations"].get(source, {}).get("path", ""),
303
+ "sha256": result["source_sha256"],
304
+ }
305
+ self.state["selected"] = result["configuration_id"]
306
+ self.state.pop("active_job", None)
307
+ self.workspace.store(self.state)
308
+ return result
309
+
310
+ def interpret(self, text):
311
+ if not self.provider:
312
+ raise WorkspaceError("No model connected. Use /help for guided commands.")
313
+ return self.provider.select(text)
314
+
315
+ def _target_local(self, job):
316
+ """Full API: save the translated config beside the source, then select it."""
317
+ status = self.client.job(job)
318
+ target = status.get("target_vendor")
319
+ translated = self.client.translated(job)
320
+ if not translated or not target or target == "n/a":
321
+ raise WorkspaceError("This job has no generated configuration yet")
322
+ config = self.state["jobs"].get(job, {}).get("configuration_id")
323
+ source = self.state["configurations"].get(config, {}).get("path")
324
+ parent = self.workspace._inside((self.workspace.root / source).parent) if source else self.workspace.root
325
+ saved = Workspace(parent).save_artifact(job, "translated.txt", translated,
326
+ hashlib.sha256(translated).hexdigest())
327
+ result = self._select_local(str(saved.relative_to(self.workspace.root)), target)
328
+ self.state["configurations"][result["configuration_id"]].update(
329
+ parent_job_id=job, display_name=f"Generated {target} · {job}")
330
+ self.workspace.store(self.state)
331
+ return {**result, "parent_job_id": job}
@@ -0,0 +1,94 @@
1
+ """Credentials never enter workspace files or model messages."""
2
+
3
+ import getpass
4
+ import os
5
+
6
+ import keyring
7
+
8
+ SERVICE = "NetConverter Terminal"
9
+ # Only operating-system credential stores are trusted. Third-party keyring
10
+ # backends (plaintext files, encrypted-file backends with a master password on
11
+ # disk) are refused; there is no fallback secrets file.
12
+ _OS_BACKENDS = frozenset(
13
+ {
14
+ "keyring.backends.macOS",
15
+ "keyring.backends.Windows",
16
+ "keyring.backends.SecretService",
17
+ }
18
+ )
19
+
20
+
21
+ def _os_backend():
22
+ backend = keyring.get_keyring()
23
+ if backend.__class__.__module__ not in _OS_BACKENDS:
24
+ return None
25
+ return backend
26
+
27
+
28
+ def keyring_available() -> bool:
29
+ try:
30
+ return _os_backend() is not None
31
+ except Exception:
32
+ return False
33
+
34
+
35
+ def get_secret(name: str, *, prompt: bool = True) -> str:
36
+ value = os.environ.get(
37
+ "NETCONVERTER_API_KEY"
38
+ if name == "server" or name.startswith("server:")
39
+ else f"NETCONVERTER_{name.upper()}_API_KEY"
40
+ )
41
+ if value:
42
+ return value
43
+ try:
44
+ backend = _os_backend()
45
+ if backend is None:
46
+ return (
47
+ getpass.getpass(f"{name} API key (hidden, memory only): ").strip()
48
+ if prompt
49
+ else ""
50
+ )
51
+ value = backend.get_password(SERVICE, name)
52
+ except Exception:
53
+ value = None
54
+ if value:
55
+ return value
56
+ if not prompt:
57
+ return ""
58
+ return getpass.getpass(f"{name} API key (hidden, memory only): ").strip()
59
+
60
+
61
+ def save_secret(name: str, value: str) -> bool:
62
+ try:
63
+ backend = _os_backend()
64
+ if backend is None:
65
+ return False
66
+ backend.set_password(SERVICE, name, value)
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+
72
+ def load_secret(name: str) -> str:
73
+ """Read a stored secret without prompting and without environment overrides."""
74
+ try:
75
+ backend = _os_backend()
76
+ if backend is None:
77
+ return ""
78
+ return backend.get_password(SERVICE, name) or ""
79
+ except Exception:
80
+ return ""
81
+
82
+
83
+ def delete_secret(name: str) -> bool:
84
+ """Remove a stored secret; True when nothing remains stored under ``name``."""
85
+ try:
86
+ backend = _os_backend()
87
+ if backend is None:
88
+ return False
89
+ if backend.get_password(SERVICE, name) is None:
90
+ return True
91
+ backend.delete_password(SERVICE, name)
92
+ return True
93
+ except Exception:
94
+ return False