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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- 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
|
+
]
|