bazaar-compute-node 0.1.3__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 (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,382 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .app.application import NodeApplication
13
+ from .app.config import ConfigurationError, load_node_configuration
14
+ from .app.registry import AdapterFactories, AdapterRegistry, ProviderLoadError
15
+ from .app.transport import LocalCommandClient, local_endpoint_for_path
16
+ from .core.paths import resolve_data_dir
17
+ from .core.runtime import RuntimeSandboxMode
18
+
19
+ DEFAULT_AUDIT = "logging"
20
+ DEFAULT_STORAGE = "sqlite"
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ default_data_dir = resolve_data_dir()
25
+ parser = argparse.ArgumentParser(
26
+ prog="bcn",
27
+ description=(
28
+ "Runtime-agnostic computer node daemon for agents and channels. "
29
+ f"Persistent node root: {default_data_dir}."
30
+ ),
31
+ )
32
+ parser.add_argument(
33
+ "--version",
34
+ action="version",
35
+ version=f"%(prog)s {__version__}",
36
+ )
37
+ parser.add_argument(
38
+ "command",
39
+ nargs="?",
40
+ choices=("start", "stop", "restart", "run"),
41
+ help="Daemon command; providing adapter options without a command means start.",
42
+ )
43
+ parser.add_argument("--channel")
44
+ parser.add_argument("--runtime")
45
+ parser.add_argument(
46
+ "--model",
47
+ type=_non_empty_option,
48
+ help="Optional model override passed to the selected runtime.",
49
+ )
50
+ parser.add_argument(
51
+ "--effort",
52
+ type=_non_empty_option,
53
+ help="Optional reasoning effort passed to the selected runtime.",
54
+ )
55
+ parser.add_argument(
56
+ "--sandbox-mode",
57
+ type=RuntimeSandboxMode,
58
+ choices=tuple(RuntimeSandboxMode),
59
+ help="Filesystem sandbox mode applied to runtime turns.",
60
+ )
61
+ parser.add_argument(
62
+ "--network-access",
63
+ action=argparse.BooleanOptionalAction,
64
+ default=None,
65
+ help="Allow runtime commands to access the network.",
66
+ )
67
+ parser.add_argument("--storage")
68
+ parser.add_argument("--audit")
69
+ parser.add_argument(
70
+ "--endpoint",
71
+ type=Path,
72
+ help="Local command endpoint path on Unix; Windows derives a named pipe.",
73
+ )
74
+ parser.add_argument(
75
+ "--foreground",
76
+ action="store_true",
77
+ help="Run the selected node in the current process instead of daemonizing.",
78
+ )
79
+ return parser
80
+
81
+
82
+ def _non_empty_option(value: str) -> str:
83
+ if not value:
84
+ raise argparse.ArgumentTypeError("option value must be non-empty")
85
+ return value
86
+
87
+
88
+ def _endpoint_path(args: argparse.Namespace, data_dir: Path) -> Path:
89
+ return (args.endpoint or data_dir / "bcn.sock").expanduser()
90
+
91
+
92
+ def _require_adapters(
93
+ parser: argparse.ArgumentParser, args: argparse.Namespace
94
+ ) -> None:
95
+ if args.channel is None or args.runtime is None:
96
+ parser.error("--channel and --runtime must be provided together")
97
+
98
+
99
+ def _load_factories(
100
+ args: argparse.Namespace,
101
+ parser: argparse.ArgumentParser,
102
+ ) -> AdapterFactories:
103
+ _require_adapters(parser, args)
104
+ try:
105
+ return AdapterRegistry().load(
106
+ channel=args.channel,
107
+ runtime=args.runtime,
108
+ storage=args.storage,
109
+ audit=args.audit,
110
+ )
111
+ except ProviderLoadError as error:
112
+ parser.error(str(error))
113
+
114
+
115
+ def _runtime_options(args: argparse.Namespace) -> dict[str, str]:
116
+ return {
117
+ name: value
118
+ for name, value in (
119
+ ("model", args.model),
120
+ ("effort", args.effort),
121
+ )
122
+ if value is not None
123
+ }
124
+
125
+
126
+ def _apply_runtime_configuration(
127
+ args: argparse.Namespace,
128
+ parser: argparse.ArgumentParser,
129
+ ) -> None:
130
+ try:
131
+ configuration = load_node_configuration()
132
+ except ConfigurationError as error:
133
+ parser.error(str(error))
134
+ for name in (
135
+ "channel",
136
+ "runtime",
137
+ "storage",
138
+ "audit",
139
+ "model",
140
+ "effort",
141
+ "sandbox_mode",
142
+ "network_access",
143
+ ):
144
+ if getattr(args, name) is None:
145
+ setattr(args, name, getattr(configuration, name))
146
+ if args.endpoint is None and configuration.endpoint is not None:
147
+ args.endpoint = Path(configuration.endpoint).expanduser()
148
+ if args.storage is None:
149
+ args.storage = DEFAULT_STORAGE
150
+ if args.audit is None:
151
+ args.audit = DEFAULT_AUDIT
152
+ args.runtime_env_include = configuration.runtime_env_include
153
+ args.channel_options = {
154
+ "bot_id": configuration.wecom_bot_id,
155
+ "websocket_url": configuration.wecom_websocket_url,
156
+ }
157
+
158
+
159
+ async def _run_node(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
160
+ factories = _load_factories(args, parser)
161
+
162
+ data_dir = resolve_data_dir()
163
+ node = NodeApplication(
164
+ factories=factories,
165
+ endpoint_path=_endpoint_path(args, data_dir),
166
+ runtime_options=_runtime_options(args),
167
+ runtime_sandbox_mode=args.sandbox_mode,
168
+ runtime_network_access=args.network_access,
169
+ channel_options=args.channel_options if args.channel == "wecom" else {},
170
+ runtime_environment_include=args.runtime_env_include,
171
+ )
172
+ await node.start()
173
+ print(
174
+ f"bcn ready channel={args.channel} runtime={args.runtime} endpoint={node.endpoint}",
175
+ flush=True,
176
+ )
177
+ try:
178
+ await node.wait()
179
+ finally:
180
+ await node.stop()
181
+ return 0
182
+
183
+
184
+ def _daemon_command(args: argparse.Namespace, data_dir: Path) -> list[str]:
185
+ command = [
186
+ sys.executable,
187
+ "-m",
188
+ "bazaar_compute_node.cli",
189
+ "run",
190
+ "--channel",
191
+ args.channel,
192
+ "--runtime",
193
+ args.runtime,
194
+ "--storage",
195
+ args.storage,
196
+ "--audit",
197
+ args.audit,
198
+ "--endpoint",
199
+ str(_endpoint_path(args, data_dir)),
200
+ ]
201
+ for name in ("model", "effort", "sandbox_mode"):
202
+ value = getattr(args, name)
203
+ if value is not None:
204
+ command.extend((f"--{name.replace('_', '-')}", value))
205
+ command.append("--network-access" if args.network_access else "--no-network-access")
206
+ return command
207
+
208
+
209
+ def _spawn_daemon(
210
+ command: Sequence[str],
211
+ log_path: Path,
212
+ ) -> subprocess.Popen[bytes]:
213
+ with log_path.open("ab") as log_file:
214
+ if os.name == "nt":
215
+ return subprocess.Popen(
216
+ command,
217
+ stdin=subprocess.DEVNULL,
218
+ stdout=log_file,
219
+ stderr=subprocess.STDOUT,
220
+ close_fds=True,
221
+ creationflags=(
222
+ subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
223
+ ),
224
+ )
225
+ return subprocess.Popen(
226
+ command,
227
+ stdin=subprocess.DEVNULL,
228
+ stdout=log_file,
229
+ stderr=subprocess.STDOUT,
230
+ close_fds=True,
231
+ start_new_session=True,
232
+ )
233
+
234
+
235
+ async def _endpoint_is_reachable(
236
+ endpoint: str,
237
+ *,
238
+ timeout: float,
239
+ ) -> bool:
240
+ try:
241
+ response = await LocalCommandClient.request(
242
+ endpoint,
243
+ {"kind": "control", "operation": "health"},
244
+ timeout=timeout,
245
+ )
246
+ except Exception: # noqa: BLE001
247
+ return False
248
+ return response.get("ok") is True
249
+
250
+
251
+ async def _wait_for_endpoint(
252
+ endpoint: str,
253
+ process: subprocess.Popen[bytes],
254
+ *,
255
+ timeout: float,
256
+ ) -> None:
257
+ deadline = asyncio.get_running_loop().time() + timeout
258
+ while asyncio.get_running_loop().time() < deadline:
259
+ if await _endpoint_is_reachable(endpoint, timeout=0.5):
260
+ return
261
+ if process.poll() is not None:
262
+ raise RuntimeError(
263
+ f"daemon exited before becoming ready; see "
264
+ f"{resolve_data_dir() / 'bcn.log'}"
265
+ )
266
+ await asyncio.sleep(0.05)
267
+ raise TimeoutError(f"daemon did not become ready within {timeout:g} seconds")
268
+
269
+
270
+ async def _wait_for_endpoint_exit(
271
+ endpoint: str,
272
+ *,
273
+ timeout: float,
274
+ ) -> bool:
275
+ deadline = asyncio.get_running_loop().time() + timeout
276
+ while asyncio.get_running_loop().time() < deadline:
277
+ if not await _endpoint_is_reachable(endpoint, timeout=0.5):
278
+ return True
279
+ await asyncio.sleep(0.05)
280
+ return not await _endpoint_is_reachable(endpoint, timeout=0.5)
281
+
282
+
283
+ async def _start_daemon(
284
+ args: argparse.Namespace,
285
+ parser: argparse.ArgumentParser,
286
+ ) -> int:
287
+ _load_factories(args, parser)
288
+ data_dir = resolve_data_dir()
289
+ data_dir.mkdir(parents=True, exist_ok=True)
290
+ endpoint_path = _endpoint_path(args, data_dir)
291
+ endpoint = local_endpoint_for_path(endpoint_path)
292
+ if os.name == "nt" and await _endpoint_is_reachable(endpoint, timeout=0.5):
293
+ parser.error("bcn is already running")
294
+ if os.name != "nt" and endpoint_path.exists():
295
+ parser.error(f"bcn endpoint already exists: {endpoint_path}")
296
+
297
+ log_path = data_dir / "bcn.log"
298
+ daemon_command = _daemon_command(args, data_dir)
299
+ process = await asyncio.to_thread(_spawn_daemon, daemon_command, log_path)
300
+ try:
301
+ await _wait_for_endpoint(endpoint, process, timeout=10)
302
+ except BaseException:
303
+ if process.poll() is None:
304
+ process.terminate()
305
+ await asyncio.to_thread(process.wait, 5)
306
+ raise
307
+ print(
308
+ f"bcn started pid={process.pid} channel={args.channel} "
309
+ f"runtime={args.runtime} endpoint={endpoint}",
310
+ flush=True,
311
+ )
312
+ return 0
313
+
314
+
315
+ async def _stop_daemon(
316
+ args: argparse.Namespace,
317
+ parser: argparse.ArgumentParser,
318
+ ) -> int:
319
+ data_dir = resolve_data_dir()
320
+ endpoint_path = _endpoint_path(args, data_dir)
321
+ endpoint = local_endpoint_for_path(endpoint_path)
322
+ if not await _endpoint_is_reachable(endpoint, timeout=0.5):
323
+ print("bcn is not running", flush=True)
324
+ return 0
325
+
326
+ try:
327
+ response = await LocalCommandClient.request(
328
+ endpoint,
329
+ {"kind": "control", "operation": "shutdown"},
330
+ timeout=5,
331
+ )
332
+ except Exception as error: # noqa: BLE001
333
+ parser.error(f"cannot reach bcn daemon: {error}")
334
+ if response.get("ok") is not True:
335
+ parser.error(
336
+ f"bcn daemon rejected shutdown: {response.get('code', 'COMMAND_FAILED')}"
337
+ )
338
+ if not await _wait_for_endpoint_exit(endpoint, timeout=10):
339
+ parser.error(f"bcn endpoint did not stop within 10 seconds: {endpoint}")
340
+ print(f"bcn stopped endpoint={endpoint}", flush=True)
341
+ return 0
342
+
343
+
344
+ async def _restart_daemon(
345
+ args: argparse.Namespace,
346
+ parser: argparse.ArgumentParser,
347
+ ) -> int:
348
+ if (args.channel is None) != (args.runtime is None):
349
+ parser.error("--channel and --runtime must be provided together")
350
+ if args.channel is None or args.runtime is None:
351
+ parser.error("restart requires --channel and --runtime when config is missing")
352
+ await _stop_daemon(args, parser)
353
+ return await _start_daemon(args, parser)
354
+
355
+
356
+ async def async_main(argv: Sequence[str] | None = None) -> int:
357
+ parser = build_parser()
358
+ args = parser.parse_args(argv)
359
+ command = args.command
360
+ _apply_runtime_configuration(args, parser)
361
+ if command is None:
362
+ if args.channel is None and args.runtime is None:
363
+ parser.print_help()
364
+ return 0
365
+ command = "start"
366
+ if command == "run" or (command == "start" and args.foreground):
367
+ return await _run_node(args, parser)
368
+ if command == "start":
369
+ return await _start_daemon(args, parser)
370
+ if command == "stop":
371
+ return await _stop_daemon(args, parser)
372
+ if command == "restart":
373
+ return await _restart_daemon(args, parser)
374
+ parser.error(f"unsupported command: {command}")
375
+
376
+
377
+ def main(argv: Sequence[str] | None = None) -> int:
378
+ return asyncio.run(async_main(argv))
379
+
380
+
381
+ if __name__ == "__main__":
382
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Optional adapter implementations for the core contracts."""
@@ -0,0 +1,63 @@
1
+ """Codex App Server process, protocol, and runtime adapter."""
2
+
3
+ from .client import (
4
+ CodexAppServerClient,
5
+ CodexAppServerProtocolError,
6
+ CodexErrorInfo,
7
+ CodexThreadInfo,
8
+ CodexTurnInfo,
9
+ build_initialize_params,
10
+ build_thread_resume_params,
11
+ build_thread_start_params,
12
+ build_turn_interrupt_params,
13
+ build_turn_start_params,
14
+ parse_error_notification,
15
+ parse_thread_response,
16
+ parse_turn_notification,
17
+ parse_turn_response,
18
+ )
19
+ from .events import CodexTurnEventStream
20
+ from .process import (
21
+ JsonlProcessSpec,
22
+ JsonlProcessState,
23
+ JsonlProcessSupervisor,
24
+ )
25
+ from .protocol import (
26
+ JsonlMessage,
27
+ JsonlProcessExited,
28
+ JsonlProcessNotRunning,
29
+ JsonlProtocolError,
30
+ JsonlRemoteError,
31
+ JsonlRequestTimeout,
32
+ JsonlTransportError,
33
+ )
34
+ from .runtime import CodexAppServerRuntime
35
+
36
+ __all__ = [
37
+ "CodexAppServerClient",
38
+ "CodexAppServerProtocolError",
39
+ "CodexAppServerRuntime",
40
+ "CodexErrorInfo",
41
+ "CodexThreadInfo",
42
+ "CodexTurnEventStream",
43
+ "CodexTurnInfo",
44
+ "JsonlMessage",
45
+ "JsonlProcessExited",
46
+ "JsonlProcessNotRunning",
47
+ "JsonlProcessSpec",
48
+ "JsonlProcessState",
49
+ "JsonlProcessSupervisor",
50
+ "JsonlProtocolError",
51
+ "JsonlRemoteError",
52
+ "JsonlRequestTimeout",
53
+ "JsonlTransportError",
54
+ "build_initialize_params",
55
+ "build_thread_resume_params",
56
+ "build_thread_start_params",
57
+ "build_turn_interrupt_params",
58
+ "build_turn_start_params",
59
+ "parse_error_notification",
60
+ "parse_thread_response",
61
+ "parse_turn_notification",
62
+ "parse_turn_response",
63
+ ]
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import cast
6
+
7
+ from ...core.models import ApprovalDecision, ApprovalRequest, ApprovalResult
8
+ from .protocol import (
9
+ CodexAppServerProtocolError,
10
+ JsonlMessage,
11
+ JsonlRequestId,
12
+ is_request_id,
13
+ )
14
+
15
+ _COMMAND_METHOD = "item/commandExecution/requestApproval"
16
+ _FILE_CHANGE_METHOD = "item/fileChange/requestApproval"
17
+ _PERMISSIONS_METHOD = "item/permissions/requestApproval"
18
+
19
+ _APPROVAL_METHODS = frozenset(
20
+ {
21
+ _COMMAND_METHOD,
22
+ _FILE_CHANGE_METHOD,
23
+ _PERMISSIONS_METHOD,
24
+ }
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class CodexApprovalRequest:
30
+ """Provider request plus its neutral approval projection."""
31
+
32
+ request_id: JsonlRequestId
33
+ method: str
34
+ params: Mapping[str, object]
35
+ request: ApprovalRequest
36
+
37
+
38
+ def is_approval_method(method: object) -> bool:
39
+ return isinstance(method, str) and method in _APPROVAL_METHODS
40
+
41
+
42
+ def parse_approval_request(
43
+ message: JsonlMessage,
44
+ *,
45
+ session_id: str,
46
+ runtime_session_id: str,
47
+ turn_id: str,
48
+ provider_thread_id: str,
49
+ provider_turn_id: str | None,
50
+ ) -> CodexApprovalRequest:
51
+ method = message.get("method")
52
+ if not isinstance(method, str) or not is_approval_method(method):
53
+ raise CodexAppServerProtocolError("message is not an approval request")
54
+ request_id = message.get("id")
55
+ if not is_request_id(request_id):
56
+ raise CodexAppServerProtocolError(
57
+ "approval request id must be an integer or string"
58
+ )
59
+ request_id = cast(JsonlRequestId, request_id)
60
+ params = message.get("params")
61
+ if not isinstance(params, Mapping):
62
+ raise CodexAppServerProtocolError("approval request params must be an object")
63
+
64
+ thread_value = _require_text(params, "threadId")
65
+ if thread_value != provider_thread_id:
66
+ raise CodexAppServerProtocolError(
67
+ "approval request thread does not match runtime thread"
68
+ )
69
+ provider_turn_value = _require_text(params, "turnId")
70
+ if provider_turn_id is None or provider_turn_value != provider_turn_id:
71
+ raise CodexAppServerProtocolError(
72
+ "approval request turn does not match runtime turn"
73
+ )
74
+ provider_item_id = _require_text(params, "itemId")
75
+ started_at_ms = params.get("startedAtMs")
76
+ if (
77
+ not isinstance(started_at_ms, int)
78
+ or isinstance(started_at_ms, bool)
79
+ or started_at_ms < 0
80
+ ):
81
+ raise CodexAppServerProtocolError(
82
+ "approval request startedAtMs must be a non-negative integer"
83
+ )
84
+ action = {
85
+ _COMMAND_METHOD: "command_execution",
86
+ _FILE_CHANGE_METHOD: "file_change",
87
+ _PERMISSIONS_METHOD: "permissions",
88
+ }[method]
89
+ if method == _PERMISSIONS_METHOD and not isinstance(
90
+ params.get("permissions"), Mapping
91
+ ):
92
+ raise CodexAppServerProtocolError(
93
+ "permissions approval request has no permissions object"
94
+ )
95
+
96
+ metadata: dict[str, object] = {
97
+ "provider_method": method,
98
+ "provider_item_id": provider_item_id,
99
+ }
100
+ for provider_key, metadata_key in (
101
+ ("approvalId", "provider_approval_id"),
102
+ ("environmentId", "provider_environment_id"),
103
+ ):
104
+ value = params.get(provider_key)
105
+ if isinstance(value, str) and value:
106
+ metadata[metadata_key] = value
107
+ return CodexApprovalRequest(
108
+ request_id=request_id,
109
+ method=method,
110
+ params=params,
111
+ request=ApprovalRequest(
112
+ request_id=str(request_id),
113
+ session_id=session_id,
114
+ runtime_session_id=runtime_session_id,
115
+ action=action,
116
+ created_at_ms=started_at_ms,
117
+ turn_id=turn_id,
118
+ metadata=metadata,
119
+ ),
120
+ )
121
+
122
+
123
+ def build_approval_response(
124
+ approval: CodexApprovalRequest,
125
+ result: ApprovalResult,
126
+ ) -> Mapping[str, object]:
127
+ if result.request_id != approval.request.request_id:
128
+ raise ValueError("approval result request id does not match provider request")
129
+ approved = result.decision is ApprovalDecision.APPROVED
130
+ if approval.method in {_COMMAND_METHOD, _FILE_CHANGE_METHOD}:
131
+ return {"decision": "accept" if approved else "decline"}
132
+ if approval.method == _PERMISSIONS_METHOD:
133
+ if not approved:
134
+ return {"permissions": {}}
135
+ permissions = approval.params.get("permissions")
136
+ if not isinstance(permissions, Mapping):
137
+ raise CodexAppServerProtocolError(
138
+ "permissions approval request has no permissions object"
139
+ )
140
+ return {"permissions": dict(permissions), "scope": "turn"}
141
+ raise CodexAppServerProtocolError("unsupported approval response method")
142
+
143
+
144
+ def approval_error(error: BaseException) -> Mapping[str, object]:
145
+ """Return a provider-safe JSON-RPC error without leaking request contents."""
146
+
147
+ return {
148
+ "code": -32000,
149
+ "message": f"approval bridge failed: {type(error).__name__}",
150
+ }
151
+
152
+
153
+ def _require_text(params: Mapping[str, object], field_name: str) -> str:
154
+ value = params.get(field_name)
155
+ if not isinstance(value, str) or not value:
156
+ raise CodexAppServerProtocolError(
157
+ f"approval request {field_name} must be non-empty text"
158
+ )
159
+ return value
160
+
161
+
162
+ __all__ = [
163
+ "CodexApprovalRequest",
164
+ "approval_error",
165
+ "build_approval_response",
166
+ "is_approval_method",
167
+ "parse_approval_request",
168
+ ]