bazaar-contrib-codex 0.3.0rc1__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,78 @@
1
+ """Register the Codex builder and its privately composed Harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any, ClassVar
8
+
9
+ from bazaar_contrib_audit import AUDIT
10
+ from bazaar_contrib_harness import (
11
+ HARNESS,
12
+ HARNESS_BUILDERS,
13
+ HARNESS_INPUT,
14
+ HarnessBuilderConfig,
15
+ HarnessInspection,
16
+ harness_builder_key,
17
+ )
18
+ from bazaar_contrib_timer import TIMER
19
+ from bazaar_core import Context, Contrib
20
+ from pydantic import BaseModel, ConfigDict
21
+
22
+ from .inspection import inspect
23
+ from .runtime import CodexHarness
24
+
25
+
26
+ class Config(BaseModel):
27
+ model_config = ConfigDict(frozen=True)
28
+ model: str | None = None
29
+ effort: str | None = None
30
+
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(ctx: Context, config: Config):
34
+ instance = CodexHarness(
35
+ ctx.accept(HARNESS_INPUT),
36
+ ctx.accept(TIMER),
37
+ ctx.accept(AUDIT),
38
+ config.model,
39
+ config.effort,
40
+ )
41
+ ctx.offer(HARNESS, instance)
42
+ try:
43
+ yield
44
+ finally:
45
+ await instance.aclose()
46
+
47
+
48
+ harness = Contrib(
49
+ config=Config,
50
+ accepts=[HARNESS_INPUT, TIMER, AUDIT],
51
+ offers=[HARNESS],
52
+ lifespan=lifespan,
53
+ )
54
+
55
+
56
+ class BuilderConfig(HarnessBuilderConfig):
57
+ kind: ClassVar[str] = "codex"
58
+ entity_model: ClassVar[type[BaseModel]] = Config
59
+ entry: ClassVar[Contrib[Any]] = harness
60
+
61
+ @classmethod
62
+ async def inspect(cls, workspace: Path, *, timeout: float) -> HarnessInspection: # noqa: ASYNC109
63
+ return await inspect(workspace, timeout=timeout)
64
+
65
+
66
+ @asynccontextmanager
67
+ async def builder_lifespan(ctx: Context, config: BuilderConfig):
68
+ with ctx.accept(HARNESS_BUILDERS).register(config.builder()) as builder:
69
+ ctx.offer(harness_builder_key(builder.kind), builder)
70
+ yield
71
+
72
+
73
+ builder = Contrib(
74
+ config=BuilderConfig,
75
+ accepts=[HARNESS_BUILDERS],
76
+ offers=[harness_builder_key("codex")],
77
+ lifespan=builder_lifespan,
78
+ )
@@ -0,0 +1,151 @@
1
+ """Translate Codex permission requests through the public approval contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from bazaar_contrib_harness import ApprovalHandler, ApprovalRequest, ApprovalScope
9
+
10
+ from .transport import AppServerError, Packet
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ COMMAND = "item/commandExecution/requestApproval"
15
+ FILE = "item/fileChange/requestApproval"
16
+ ELICITATION = "mcpServer/elicitation/request"
17
+ PERMISSIONS = "item/permissions/requestApproval"
18
+ USER_INPUT = "item/tool/requestUserInput"
19
+
20
+ LABELS = {
21
+ ApprovalScope.ONCE: "Allow",
22
+ ApprovalScope.SESSION: "Allow for this session",
23
+ ApprovalScope.ALWAYS: "Allow and don't ask me again",
24
+ }
25
+
26
+
27
+ def decline(packet: Packet) -> dict[str, Any]:
28
+ match packet.method:
29
+ case _ if packet.method in (COMMAND, FILE):
30
+ return {"decision": "decline"}
31
+ case _ if packet.method == PERMISSIONS:
32
+ return {"permissions": {}}
33
+ case _ if packet.method == USER_INPUT:
34
+ return {
35
+ "answers": {
36
+ question["id"]: {"answers": ["__codex_mcp_decline__"]}
37
+ for question in packet.params.get("questions", [])
38
+ if question["id"].startswith("mcp_tool_call_approval_")
39
+ }
40
+ }
41
+ case _ if packet.method == ELICITATION:
42
+ return {"action": "decline", "content": None}
43
+ case _:
44
+ raise AppServerError(
45
+ f"Unsupported Codex server request: {packet.method}", code=-32601
46
+ )
47
+
48
+
49
+ async def interact(
50
+ packet: Packet,
51
+ *,
52
+ session_id: str,
53
+ turn_id: str,
54
+ handler: ApprovalHandler,
55
+ timeout: float, # noqa: ASYNC109
56
+ ) -> dict[str, Any]:
57
+ params = packet.params
58
+ scopes = [ApprovalScope.ONCE]
59
+ amendment = None
60
+ match packet.method:
61
+ case _ if packet.method in (COMMAND, FILE):
62
+ action = "command_execution" if packet.method == COMMAND else "file_change"
63
+ decisions = params.get("availableDecisions") or []
64
+ if packet.method == FILE or "acceptForSession" in decisions:
65
+ scopes.append(ApprovalScope.SESSION)
66
+ for decision in decisions:
67
+ match decision:
68
+ case {"acceptWithExecpolicyAmendment": _}:
69
+ amendment = decision
70
+ case {
71
+ "applyNetworkPolicyAmendment": {
72
+ "network_policy_amendment": {"action": "allow"}
73
+ }
74
+ }:
75
+ amendment = decision
76
+ if amendment is not None:
77
+ break
78
+ if amendment is not None:
79
+ scopes.append(ApprovalScope.ALWAYS)
80
+ case _ if packet.method == PERMISSIONS:
81
+ action = "permissions"
82
+ scopes.append(ApprovalScope.SESSION)
83
+ case _ if packet.method == USER_INPUT:
84
+ if not params["questions"] or not all(
85
+ question["id"].startswith("mcp_tool_call_approval_")
86
+ for question in params["questions"]
87
+ ):
88
+ logger.warning("Unsupported Codex user-input request: %s", packet.id)
89
+ return decline(packet)
90
+ action = "mcp_tool_approval"
91
+ labels = {option["label"] for option in params["questions"][0]["options"]}
92
+ scopes.extend(
93
+ scope
94
+ for scope in (ApprovalScope.SESSION, ApprovalScope.ALWAYS)
95
+ if LABELS[scope] in labels
96
+ )
97
+ case _ if packet.method == ELICITATION:
98
+ metadata = params.get("_meta") or {}
99
+ if metadata.get("codex_approval_kind") != "mcp_tool_call":
100
+ logger.warning("Unsupported Codex elicitation request: %s", packet.id)
101
+ return decline(packet)
102
+ action = "mcp_tool_approval"
103
+ scopes.extend(
104
+ scope
105
+ for scope in (ApprovalScope.SESSION, ApprovalScope.ALWAYS)
106
+ if scope.value in metadata.get("persist", [])
107
+ )
108
+ case _:
109
+ logger.warning("Unsupported Codex server request: %s", packet.method)
110
+ return decline(packet)
111
+ request = ApprovalRequest(
112
+ request_id=str(packet.id),
113
+ session_id=session_id,
114
+ turn_id=turn_id,
115
+ action=action,
116
+ details=params,
117
+ allowed_scopes=tuple(scopes),
118
+ **({"created_at_ms": params["startedAtMs"]} if "startedAtMs" in params else {}),
119
+ )
120
+ result = await handler.request_approval(request, timeout=timeout)
121
+ if result.decision != "approved":
122
+ return decline(packet)
123
+ match packet.method:
124
+ case _ if packet.method in (COMMAND, FILE):
125
+ decision: Any = "accept"
126
+ if result.scope is ApprovalScope.SESSION:
127
+ decision = "acceptForSession"
128
+ elif result.scope is ApprovalScope.ALWAYS:
129
+ decision = amendment
130
+ return {"decision": decision}
131
+ case _ if packet.method == PERMISSIONS:
132
+ return {
133
+ "permissions": params["permissions"],
134
+ "scope": "session" if result.scope is ApprovalScope.SESSION else "turn",
135
+ }
136
+ case _ if packet.method == USER_INPUT:
137
+ return {
138
+ "answers": {
139
+ question["id"]: {"answers": [LABELS[result.scope]]}
140
+ for question in params["questions"]
141
+ }
142
+ }
143
+ case _ if packet.method == ELICITATION:
144
+ response: dict[str, Any] = {
145
+ "action": "accept",
146
+ "content": None,
147
+ }
148
+ if result.scope is not ApprovalScope.ONCE:
149
+ response["_meta"] = {"persist": result.scope.value}
150
+ return response
151
+ return decline(packet)
@@ -0,0 +1,120 @@
1
+ """Query Codex metadata without creating an Agent, thread or turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from bazaar_contrib_harness import HarnessInspection, HarnessModel, HarnessSkill
11
+
12
+ from .approval import decline
13
+ from .transport import AppServer, AppServerError, Packet, background
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ async def inspect(workspace: Path, *, timeout: float) -> HarnessInspection: # noqa: ASYNC109
19
+ try:
20
+ process = await asyncio.create_subprocess_exec(
21
+ "codex",
22
+ "--version",
23
+ stdout=asyncio.subprocess.PIPE,
24
+ stderr=asyncio.subprocess.PIPE,
25
+ )
26
+ except OSError as error:
27
+ return HarnessInspection(available=False, error=str(error))
28
+ try:
29
+ stdout, stderr = await process.communicate()
30
+ finally:
31
+ if process.returncode is None:
32
+ try:
33
+ process.kill()
34
+ except ProcessLookupError:
35
+ pass
36
+ background(process.communicate())
37
+ if process.returncode or not stdout.strip():
38
+ return HarnessInspection(
39
+ available=False,
40
+ error=stderr.decode(errors="replace").strip()
41
+ or f"codex --version exited ({process.returncode})",
42
+ )
43
+ version = stdout.decode(errors="replace").strip().split()[-1]
44
+ server: AppServer | None = None
45
+ models: list[HarnessModel] = []
46
+ skills: tuple[HarnessSkill, ...] = ()
47
+
48
+ async def answer(packet: Packet) -> dict[str, Any]:
49
+ logger.warning("No session owns Codex inspection request %s", packet.id)
50
+ return decline(packet)
51
+
52
+ try:
53
+ server = await AppServer.start(
54
+ workspace,
55
+ {},
56
+ answer,
57
+ lambda packet: logger.debug(
58
+ "Codex inspection notification: %s", packet.method
59
+ ),
60
+ timeout=timeout,
61
+ )
62
+ await server.initialize()
63
+ cursor = None
64
+ while True:
65
+ page = await server.request("model/list", {"cursor": cursor})
66
+ models.extend(
67
+ HarnessModel(
68
+ id=item["model"],
69
+ name=item["displayName"],
70
+ efforts=tuple(
71
+ option["reasoningEffort"]
72
+ for option in item["supportedReasoningEfforts"]
73
+ ),
74
+ default_effort=item["defaultReasoningEffort"],
75
+ default=item["isDefault"],
76
+ )
77
+ for item in page["data"]
78
+ if not item.get("hidden", False)
79
+ )
80
+ cursor = page.get("nextCursor")
81
+ if cursor is None:
82
+ break
83
+ result = await server.request("skills/list", {"cwds": [str(workspace)]})
84
+ skills = tuple(
85
+ HarnessSkill(
86
+ name=skill["name"],
87
+ description=(skill.get("interface") or {}).get("shortDescription")
88
+ or skill.get("shortDescription")
89
+ or skill["description"],
90
+ source={"repo": "workspace", "user": "personal"}.get(
91
+ skill["scope"], "system"
92
+ ),
93
+ )
94
+ for entry in result["data"]
95
+ for skill in entry["skills"]
96
+ if skill.get("enabled", True)
97
+ )
98
+ return HarnessInspection(
99
+ available=True,
100
+ version=version,
101
+ models=tuple(models),
102
+ skills=skills,
103
+ error="\n".join(
104
+ error["message"]
105
+ for entry in result["data"]
106
+ for error in entry.get("errors", [])
107
+ )
108
+ or None,
109
+ )
110
+ except (AppServerError, OSError) as error:
111
+ return HarnessInspection(
112
+ available=True,
113
+ version=version,
114
+ models=tuple(models),
115
+ skills=skills,
116
+ error=str(error) or type(error).__name__,
117
+ )
118
+ finally:
119
+ if server is not None:
120
+ server.close(timeout=timeout)
File without changes