onec-interactive-runtime-core 0.1.19__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.
Files changed (87) hide show
  1. onec_interactive_runtime_core-0.1.19.dist-info/METADATA +12 -0
  2. onec_interactive_runtime_core-0.1.19.dist-info/RECORD +87 -0
  3. onec_interactive_runtime_core-0.1.19.dist-info/WHEEL +4 -0
  4. onec_interactive_runtime_core-0.1.19.dist-info/licenses/COPYRIGHT +9 -0
  5. onec_interactive_runtime_core-0.1.19.dist-info/licenses/LICENSE +232 -0
  6. onec_interactive_runtime_core-0.1.19.dist-info/licenses/THIRD_PARTY_NOTICES.txt +32 -0
  7. onec_runtime/AGENTS.md +7 -0
  8. onec_runtime/__init__.py +3 -0
  9. onec_runtime/artifacts.py +59 -0
  10. onec_runtime/bootstrap.py +310 -0
  11. onec_runtime/breakpoint_workspace.py +249 -0
  12. onec_runtime/bsl/__init__.py +139 -0
  13. onec_runtime/bsl/diagnostics.py +1046 -0
  14. onec_runtime/bsl/full_ast_worker_projection.py +522 -0
  15. onec_runtime/bsl/generated_semantic_parser.py +6322 -0
  16. onec_runtime/bsl/lexer.py +164 -0
  17. onec_runtime/bsl/module_catalog.py +420 -0
  18. onec_runtime/bsl/module_delta.py +1079 -0
  19. onec_runtime/bsl/module_universe.py +1877 -0
  20. onec_runtime/bsl/notebook_cells.py +232 -0
  21. onec_runtime/bsl/notebook_method_globals.py +74 -0
  22. onec_runtime/bsl/notebook_methods.py +175 -0
  23. onec_runtime/bsl/parser_artifact_identity.py +119 -0
  24. onec_runtime/bsl/parser_target.py +388 -0
  25. onec_runtime/bsl/preprocessor.py +16 -0
  26. onec_runtime/bsl/semantic_lowering.py +1539 -0
  27. onec_runtime/bsl/source_maps.py +2411 -0
  28. onec_runtime/bsl/worker_dependency_resolver.py +289 -0
  29. onec_runtime/bsl/worker_preprocessor.py +247 -0
  30. onec_runtime/bsl/worker_projection_model.py +137 -0
  31. onec_runtime/bsl/worker_reload_source_map.py +1697 -0
  32. onec_runtime/capture.py +113 -0
  33. onec_runtime/capture_source.py +453 -0
  34. onec_runtime/compact_table.py +376 -0
  35. onec_runtime/compact_table_backend.py +500 -0
  36. onec_runtime/config.py +198 -0
  37. onec_runtime/configurator_agent.py +520 -0
  38. onec_runtime/controller_worker.py +400 -0
  39. onec_runtime/credentials.py +18 -0
  40. onec_runtime/epf_container.py +196 -0
  41. onec_runtime/errors.py +205 -0
  42. onec_runtime/experiment.py +130 -0
  43. onec_runtime/extension_bundle.py +944 -0
  44. onec_runtime/extension_lifecycle.py +402 -0
  45. onec_runtime/extension_state.py +222 -0
  46. onec_runtime/fault_injection.py +30 -0
  47. onec_runtime/kernel.py +179 -0
  48. onec_runtime/lease.py +123 -0
  49. onec_runtime/observation.py +287 -0
  50. onec_runtime/performance_profile.py +245 -0
  51. onec_runtime/privacy.py +263 -0
  52. onec_runtime/processes.py +265 -0
  53. onec_runtime/prototype_runtime.py +2644 -0
  54. onec_runtime/rdbg/__init__.py +1 -0
  55. onec_runtime/rdbg/models.py +130 -0
  56. onec_runtime/rdbg/reconnect.py +59 -0
  57. onec_runtime/rdbg/session.py +913 -0
  58. onec_runtime/rdbg/transport.py +138 -0
  59. onec_runtime/rdbg/xml_codec.py +980 -0
  60. onec_runtime/recovery.py +106 -0
  61. onec_runtime/recovery_journal.py +79 -0
  62. onec_runtime/resources/extension/OnecInteractiveRuntime.cfe +0 -0
  63. onec_runtime/resources/extension/extension-manifest.json +108 -0
  64. onec_runtime/runtime_api.py +5272 -0
  65. onec_runtime/runtime_contracts.py +196 -0
  66. onec_runtime/server_worker.py +2034 -0
  67. onec_runtime/session.py +2123 -0
  68. onec_runtime/startup_diagnostics.py +45 -0
  69. onec_runtime/stop_routing.py +65 -0
  70. onec_runtime/supervised_processes.py +232 -0
  71. onec_runtime/supervised_runtime.py +512 -0
  72. onec_runtime/supervisor.py +363 -0
  73. onec_runtime/supervisor_model.py +148 -0
  74. onec_runtime/supervisor_protocol.py +77 -0
  75. onec_runtime/table_materialization.py +471 -0
  76. onec_runtime/table_transfer_backend.py +203 -0
  77. onec_runtime/table_value.py +311 -0
  78. onec_runtime/toolchain.py +730 -0
  79. onec_runtime/value_materialization.py +432 -0
  80. onec_runtime/value_transfer_backend.py +199 -0
  81. onec_runtime/worker_breakpoints.py +1431 -0
  82. onec_runtime/worker_epf.py +313 -0
  83. onec_runtime/worker_epf_template.py +53 -0
  84. onec_runtime/worker_stage_protocol.py +516 -0
  85. onec_runtime/worker_universe.py +3772 -0
  86. onec_runtime_build/__init__.py +1 -0
  87. onec_runtime_build/parser_target_development.py +116 -0
@@ -0,0 +1,520 @@
1
+ """Prepare the product extension through a run-owned loopback Configurator Agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager, suppress
6
+ import json
7
+ from pathlib import Path
8
+ import socket
9
+ import subprocess
10
+ import tempfile
11
+ from threading import Event, Timer
12
+ from time import monotonic, sleep
13
+ from typing import Callable, Iterator
14
+
15
+ import paramiko
16
+
17
+ from onec_runtime.config import RuntimeConfig
18
+ from onec_runtime.errors import ExtensionLifecycleError
19
+ from onec_runtime.extension_bundle import EXTENSION_NAME
20
+
21
+
22
+ _HOST = "127.0.0.1"
23
+ _PROMPT = b"designer> "
24
+ _START_TIMEOUT_SECONDS = 30.0
25
+ _AUTH_TIMEOUT_SECONDS = 8.0
26
+ _COMMAND_TIMEOUT_SECONDS = 30.0
27
+ _MUTATION_TIMEOUT_SECONDS = 300.0
28
+ _STOP_TIMEOUT_SECONDS = 10.0
29
+ _CLEANUP_RETRY_NOTE = (
30
+ "Configurator Agent resource cleanup is incomplete; retry with exception.retry_cleanup()"
31
+ )
32
+ _DISCONNECT_FAILURE_NOTE = "Configurator Agent infobase disconnect failed"
33
+
34
+
35
+ class _DiagnosticTransport(paramiko.Transport):
36
+ """Retain the Agent's SSH disconnect reason before Paramiko drops it."""
37
+
38
+ disconnect_reason: str | None = None
39
+
40
+ def _parse_disconnect(self, message: paramiko.Message) -> None:
41
+ message.get_int()
42
+ self.disconnect_reason = message.get_text()
43
+ message.rewind()
44
+ super()._parse_disconnect(message)
45
+
46
+
47
+ def _designer_lock_error(client: paramiko.SSHClient) -> ExtensionLifecycleError | None:
48
+ try:
49
+ transport = client.get_transport()
50
+ reason = getattr(transport, "disconnect_reason", None)
51
+ except Exception:
52
+ return None
53
+ if isinstance(reason, str) and (
54
+ "cannot lock the infobase because it is open in designer" in reason.casefold()
55
+ ):
56
+ return ExtensionLifecycleError(
57
+ "Configurator Agent cannot lock the infobase because it is open in Designer; "
58
+ "close the regular Designer session and retry, or use "
59
+ "ExtensionMode.MANUAL for an already prepared extension"
60
+ )
61
+ return None
62
+
63
+
64
+ def _expose_cleanup_retry(error: BaseException, retry_cleanup: Callable[[], None]) -> None:
65
+ setattr(error, "retry_cleanup", retry_cleanup)
66
+ if _CLEANUP_RETRY_NOTE not in getattr(error, "__notes__", ()):
67
+ error.add_note(_CLEANUP_RETRY_NOTE)
68
+
69
+
70
+ def _available_loopback_port() -> int:
71
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
72
+ listener.bind((_HOST, 0))
73
+ return int(listener.getsockname()[1])
74
+
75
+
76
+ def _read_prompt(channel: paramiko.Channel, *, timeout: float) -> bytes:
77
+ deadline = monotonic() + timeout
78
+ payload = bytearray()
79
+ while not payload.endswith(_PROMPT):
80
+ remaining = deadline - monotonic()
81
+ if remaining <= 0:
82
+ raise ExtensionLifecycleError("Configurator Agent response timed out")
83
+ channel.settimeout(min(remaining, 1.0))
84
+ try:
85
+ chunk = channel.recv(65536)
86
+ except socket.timeout:
87
+ continue
88
+ if not chunk:
89
+ raise ExtensionLifecycleError("Configurator Agent closed its SSH channel")
90
+ payload.extend(chunk)
91
+ return bytes(payload[: -len(_PROMPT)])
92
+
93
+
94
+ def _parse_response(payload: bytes) -> list[dict[str, object]]:
95
+ try:
96
+ result = json.loads(payload.decode("utf-8"))
97
+ except (ValueError, UnicodeError):
98
+ raise ExtensionLifecycleError("Invalid Configurator Agent JSON response") from None
99
+ if not isinstance(result, list) or not result or not all(
100
+ isinstance(item, dict) for item in result
101
+ ):
102
+ raise ExtensionLifecycleError("Invalid Configurator Agent response structure")
103
+ if any(item.get("type") == "error" for item in result):
104
+ # Agent error text can echo credentials; identify our command instead.
105
+ raise ExtensionLifecycleError("Configurator Agent rejected the command")
106
+ return result
107
+
108
+
109
+ def _command(
110
+ channel: paramiko.Channel, command: str, transcript: list[str],
111
+ *, timeout_s: float | None = None,
112
+ ) -> list[dict[str, object]]:
113
+ timeout = _COMMAND_TIMEOUT_SECONDS if timeout_s is None else timeout_s
114
+ transcript.append("> " + command)
115
+ channel.settimeout(timeout)
116
+ channel.sendall((command + "\n").encode("utf-8"))
117
+ try:
118
+ response = _parse_response(
119
+ _read_prompt(channel, timeout=timeout)
120
+ )
121
+ except ExtensionLifecycleError as error:
122
+ transcript.append("< " + str(error))
123
+ raise ExtensionLifecycleError(f"{error}: {command}") from None
124
+ # Do not persist arbitrary server replies, which may contain credentials.
125
+ transcript.append("< response accepted")
126
+ return response
127
+
128
+
129
+ def _properties(response: list[dict[str, object]]) -> dict[str, object]:
130
+ found = [item.get("body") for item in response if item.get("type") == "extension-properties"]
131
+ if (
132
+ len(found) != 1
133
+ or not isinstance(found[0], dict)
134
+ or found[0].get("name") != EXTENSION_NAME
135
+ or type(found[0].get("safe-mode")) is not bool
136
+ or type(found[0].get("unsafe-action-protection")) is not bool
137
+ or found[0].get("active") is not True
138
+ ):
139
+ raise ExtensionLifecycleError(
140
+ f"Expected the active {EXTENSION_NAME} extension with explicit safety properties"
141
+ )
142
+ return found[0]
143
+
144
+
145
+ def _connect_client(
146
+ config: RuntimeConfig, port: int, process: subprocess.Popen,
147
+ clients: list[paramiko.SSHClient],
148
+ ) -> paramiko.SSHClient:
149
+ deadline = monotonic() + _START_TIMEOUT_SECONDS
150
+ while (remaining := deadline - monotonic()) > 0:
151
+ if process.poll() is not None:
152
+ raise ExtensionLifecycleError(
153
+ f"Configurator Agent exited with code {process.returncode}; see agent.log"
154
+ )
155
+ client = paramiko.SSHClient()
156
+ clients.append(client)
157
+ connected = False
158
+ try:
159
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
160
+ client.connect(
161
+ _HOST,
162
+ port=port,
163
+ username=config.username,
164
+ password=config.password,
165
+ look_for_keys=False,
166
+ allow_agent=False,
167
+ timeout=min(remaining, 1.0),
168
+ banner_timeout=min(remaining, 2.0),
169
+ auth_timeout=min(remaining, _AUTH_TIMEOUT_SECONDS),
170
+ transport_factory=_DiagnosticTransport,
171
+ )
172
+ connected = True
173
+ return client
174
+ except paramiko.AuthenticationException:
175
+ if lock_error := _designer_lock_error(client):
176
+ raise lock_error from None
177
+ empty_password = " with an empty password" if not config.password else ""
178
+ manual_hint = (
179
+ " For an already installed and prepared extension, use "
180
+ "extension_mode=ExtensionMode.MANUAL."
181
+ if not config.password else ""
182
+ )
183
+ raise ExtensionLifecycleError(
184
+ "Configurator Agent rejected SSH authentication"
185
+ f"{empty_password}; 1C client login may still succeed "
186
+ f"separately.{manual_hint}"
187
+ ) from None
188
+ except (OSError, paramiko.SSHException):
189
+ if lock_error := _designer_lock_error(client):
190
+ raise lock_error from None
191
+ sleep(min(0.1, max(0.0, deadline - monotonic())))
192
+ finally:
193
+ if not connected:
194
+ try:
195
+ client.close()
196
+ except Exception:
197
+ pass # Keep the failed client in the owned cleanup closure.
198
+ else:
199
+ clients.remove(client)
200
+ raise ExtensionLifecycleError(
201
+ "Configurator Agent did not open its loopback SSH endpoint"
202
+ )
203
+
204
+
205
+ def _invoke_shell(channel: paramiko.Channel) -> None:
206
+ # Paramiko's shell acknowledgement ignores Channel.settimeout(). Closing
207
+ # the channel releases its waiting event when the peer never acknowledges.
208
+ expired = Event()
209
+
210
+ def expire() -> None:
211
+ expired.set()
212
+ with suppress(Exception):
213
+ channel.close()
214
+
215
+ timer = Timer(_COMMAND_TIMEOUT_SECONDS, expire)
216
+ timer.daemon = True
217
+ timer.start()
218
+ try:
219
+ channel.invoke_shell()
220
+ except Exception:
221
+ if expired.is_set():
222
+ raise ExtensionLifecycleError("Configurator Agent shell request timed out") from None
223
+ raise ExtensionLifecycleError("Configurator Agent could not open its SSH shell") from None
224
+ finally:
225
+ timer.cancel()
226
+ if expired.is_set():
227
+ raise ExtensionLifecycleError("Configurator Agent shell request timed out")
228
+
229
+
230
+ def _stop_process(process: subprocess.Popen) -> None:
231
+ if process.poll() is not None:
232
+ return
233
+ try:
234
+ process.terminate()
235
+ process.wait(timeout=_STOP_TIMEOUT_SECONDS)
236
+ except Exception:
237
+ if process.poll() is None:
238
+ process.kill()
239
+ process.wait(timeout=_STOP_TIMEOUT_SECONDS)
240
+
241
+
242
+ @contextmanager
243
+ def _owned_agent(config: RuntimeConfig, logs: Path) -> Iterator[paramiko.Channel]:
244
+ logs.mkdir(parents=True, exist_ok=True)
245
+ port = _available_loopback_port()
246
+ base = tempfile.TemporaryDirectory(prefix="agent-", dir=logs)
247
+ process = None
248
+ clients: list[paramiko.SSHClient] = []
249
+ channel = None
250
+ primary_error: BaseException | None = None
251
+
252
+ def retry_cleanup() -> None:
253
+ nonlocal channel, process, base
254
+ cleanup_failed = False
255
+ if channel is not None:
256
+ try:
257
+ channel.close()
258
+ except Exception:
259
+ cleanup_failed = True
260
+ else:
261
+ channel = None
262
+ for pending_client in list(clients):
263
+ try:
264
+ pending_client.close()
265
+ except Exception:
266
+ cleanup_failed = True
267
+ else:
268
+ clients.remove(pending_client)
269
+ if process is not None:
270
+ try:
271
+ _stop_process(process)
272
+ except Exception:
273
+ cleanup_failed = True
274
+ else:
275
+ process = None
276
+ # A still-running agent may need its private base until a later retry.
277
+ if base is not None and process is None:
278
+ try:
279
+ base.cleanup()
280
+ except Exception:
281
+ cleanup_failed = True
282
+ else:
283
+ base = None
284
+ if cleanup_failed:
285
+ error = ExtensionLifecycleError("Configurator Agent resource cleanup failed")
286
+ _expose_cleanup_retry(error, retry_cleanup)
287
+ raise error from None
288
+
289
+ try:
290
+ command = [
291
+ str(config.designer_exe), "DESIGNER", *config.infobase_arguments,
292
+ "/AgentMode", "/AgentSSHHostKeyAuto", "/AgentBaseDir", base.name,
293
+ "/AgentPort", str(port), "/AgentListenAddress", _HOST,
294
+ "/DisableStartupDialogs", "/DisableStartupMessages",
295
+ "/Out", str(logs / "agent.log"),
296
+ ]
297
+ try:
298
+ process = subprocess.Popen(
299
+ command,
300
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
301
+ shell=False, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
302
+ )
303
+ except OSError:
304
+ raise ExtensionLifecycleError(
305
+ "Configurator Agent could not start; check the configured 1C executable"
306
+ ) from None
307
+ client = _connect_client(config, port, process, clients)
308
+ transport = client.get_transport()
309
+ if transport is None:
310
+ raise ExtensionLifecycleError("Configurator Agent has no SSH transport")
311
+ channel = transport.open_session(timeout=_COMMAND_TIMEOUT_SECONDS)
312
+ channel.settimeout(_COMMAND_TIMEOUT_SECONDS)
313
+ _invoke_shell(channel)
314
+ _read_prompt(channel, timeout=_COMMAND_TIMEOUT_SECONDS)
315
+ yield channel
316
+ except BaseException as error:
317
+ primary_error = error
318
+ raise
319
+ finally:
320
+ if channel is not None:
321
+ with suppress(Exception):
322
+ channel.settimeout(_STOP_TIMEOUT_SECONDS)
323
+ channel.sendall(b"common shutdown\n")
324
+ process.wait(timeout=_STOP_TIMEOUT_SECONDS)
325
+ try:
326
+ retry_cleanup()
327
+ except ExtensionLifecycleError:
328
+ if primary_error is not None:
329
+ _expose_cleanup_retry(primary_error, retry_cleanup)
330
+ else:
331
+ raise
332
+
333
+
334
+ def _disable_safe_mode(
335
+ channel: paramiko.Channel, transcript: list[str],
336
+ ) -> dict[str, dict[str, object]]:
337
+ get = f"config extensions properties get --extension={EXTENSION_NAME}"
338
+ before = _properties(_command(channel, get, transcript))
339
+ if before["safe-mode"]:
340
+ _command(
341
+ channel,
342
+ f"config extensions properties set --extension={EXTENSION_NAME} --safe-mode=no",
343
+ transcript,
344
+ )
345
+ after = _properties(_command(channel, get, transcript))
346
+ if after["safe-mode"] is not False:
347
+ raise ExtensionLifecycleError("Configurator Agent did not disable safe-mode")
348
+ # JSON comparison also preserves types: Python treats 1 == True.
349
+ if (
350
+ json.dumps(
351
+ {key: value for key, value in before.items() if key != "safe-mode"},
352
+ sort_keys=True,
353
+ )
354
+ != json.dumps(
355
+ {key: value for key, value in after.items() if key != "safe-mode"},
356
+ sort_keys=True,
357
+ )
358
+ ):
359
+ raise ExtensionLifecycleError("Configurator Agent changed other extension properties")
360
+ return {"before": before, "after": after}
361
+
362
+
363
+ class ExtensionAgentEditor:
364
+ """One connected agent reused across extension mutation and verification."""
365
+
366
+ def __init__(
367
+ self, config: RuntimeConfig, channel: paramiko.Channel, transcript: list[str],
368
+ ) -> None:
369
+ self._config = config
370
+ self._channel = channel
371
+ self._transcript = transcript
372
+ self.connected = True
373
+
374
+ def connect(self) -> None:
375
+ if not self.connected:
376
+ _command(self._channel, "common connect-ib", self._transcript)
377
+ self.connected = True
378
+
379
+ def disconnect(self) -> None:
380
+ if self.connected:
381
+ _command(self._channel, "common disconnect-ib", self._transcript)
382
+ self.connected = False
383
+
384
+ @contextmanager
385
+ def designer_access(self) -> Iterator[None]:
386
+ """Release the infobase lock while a headless Designer dumps XML/CFE."""
387
+ self.disconnect()
388
+ primary_error: BaseException | None = None
389
+ try:
390
+ yield
391
+ except BaseException as error:
392
+ primary_error = error
393
+ raise
394
+ finally:
395
+ try:
396
+ self.connect()
397
+ except BaseException:
398
+ if primary_error is None:
399
+ raise
400
+ primary_error.add_note("Configurator Agent infobase reconnect failed")
401
+
402
+ def load_cfe(self, source: Path) -> None:
403
+ if not self.connected:
404
+ raise ExtensionLifecycleError("Configurator Agent is disconnected")
405
+ _command(
406
+ self._channel,
407
+ f'config load-cfg --file="{source.resolve()}" --extension={EXTENSION_NAME}',
408
+ self._transcript,
409
+ timeout_s=_MUTATION_TIMEOUT_SECONDS,
410
+ )
411
+
412
+ def apply(self) -> None:
413
+ if not self.connected:
414
+ raise ExtensionLifecycleError("Configurator Agent is disconnected")
415
+ dynamic = "--dynamic-disable " if self._config.uses_external_infobase else ""
416
+ _command(
417
+ self._channel,
418
+ f"config update-db-cfg {dynamic}--extension={EXTENSION_NAME}",
419
+ self._transcript,
420
+ timeout_s=_MUTATION_TIMEOUT_SECONDS,
421
+ )
422
+
423
+ def disable_safe_mode(self) -> dict[str, dict[str, object]]:
424
+ if not self.connected:
425
+ raise ExtensionLifecycleError("Configurator Agent is disconnected")
426
+ return _disable_safe_mode(self._channel, self._transcript)
427
+
428
+
429
+ @contextmanager
430
+ def edit_extension(
431
+ config: RuntimeConfig, logs: Path,
432
+ ) -> Iterator[ExtensionAgentEditor]:
433
+ """Keep one owned Agent for mutation, release it for Designer XML dumps."""
434
+ logs = Path(logs).resolve()
435
+ logs.mkdir(parents=True, exist_ok=True)
436
+ transcript: list[str] = []
437
+ primary_error: BaseException | None = None
438
+ try:
439
+ with _owned_agent(config, logs) as channel:
440
+ _command(channel, "options set --output-format=json", transcript)
441
+ _command(channel, "common connect-ib", transcript)
442
+ editor = ExtensionAgentEditor(config, channel, transcript)
443
+ try:
444
+ yield editor
445
+ except BaseException as error:
446
+ primary_error = error
447
+ raise
448
+ finally:
449
+ try:
450
+ editor.disconnect()
451
+ except BaseException:
452
+ if primary_error is None:
453
+ raise
454
+ primary_error.add_note(_DISCONNECT_FAILURE_NOTE)
455
+ finally:
456
+ try:
457
+ (logs / "commands.log").write_text("\n".join(transcript) + "\n", encoding="utf-8")
458
+ except OSError:
459
+ if primary_error is None:
460
+ raise ExtensionLifecycleError("Configurator Agent command log could not be written") from None
461
+ primary_error.add_note("Configurator Agent command log could not be written")
462
+
463
+
464
+ def prepare_extension(config: RuntimeConfig, logs: Path) -> dict[str, dict[str, object]]:
465
+ """Disable only the verified product extension's safe mode and read it back.
466
+
467
+ The lifecycle caller must verify the packaged artifact's permanent identity
468
+ and fingerprints under its infobase lock before calling this function.
469
+ """
470
+ logs = Path(logs).resolve()
471
+ transcript: list[str] = []
472
+ primary_error: BaseException | None = None
473
+ try:
474
+ logs.mkdir(parents=True, exist_ok=True)
475
+ with _owned_agent(config, logs) as channel:
476
+ _command(channel, "options set --output-format=json", transcript)
477
+ _command(channel, "common connect-ib", transcript)
478
+ connected_error: BaseException | None = None
479
+ try:
480
+ return _disable_safe_mode(channel, transcript)
481
+ except BaseException as error:
482
+ connected_error = error
483
+ raise
484
+ finally:
485
+ try:
486
+ _command(channel, "common disconnect-ib", transcript)
487
+ except Exception:
488
+ if connected_error is None:
489
+ raise
490
+ connected_error.add_note(_DISCONNECT_FAILURE_NOTE)
491
+ except ExtensionLifecycleError as error:
492
+ primary_error = error
493
+ raise
494
+ except Exception as error:
495
+ primary_error = ExtensionLifecycleError(
496
+ "Configurator Agent preparation failed; see commands.log"
497
+ )
498
+ # Preserve our cleanup evidence without copying untrusted exception text.
499
+ for note in getattr(error, "__notes__", ()):
500
+ if note in (_CLEANUP_RETRY_NOTE, _DISCONNECT_FAILURE_NOTE):
501
+ primary_error.add_note(note)
502
+ retry_cleanup = getattr(error, "retry_cleanup", None)
503
+ if callable(retry_cleanup):
504
+ _expose_cleanup_retry(primary_error, retry_cleanup)
505
+ raise primary_error from None
506
+ except BaseException as error:
507
+ primary_error = error
508
+ raise
509
+ finally:
510
+ try:
511
+ (logs / "commands.log").write_text("\n".join(transcript) + "\n", encoding="utf-8")
512
+ except OSError:
513
+ message = "Configurator Agent command log could not be written"
514
+ if primary_error is not None:
515
+ primary_error.add_note(message)
516
+ else:
517
+ raise ExtensionLifecycleError(message) from None
518
+
519
+
520
+ __all__ = ["ExtensionAgentEditor", "edit_extension", "prepare_extension"]