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.
- bazaar_contrib_codex/__init__.py +78 -0
- bazaar_contrib_codex/approval.py +151 -0
- bazaar_contrib_codex/inspection.py +120 -0
- bazaar_contrib_codex/py.typed +0 -0
- bazaar_contrib_codex/runtime.py +1034 -0
- bazaar_contrib_codex/source.py +93 -0
- bazaar_contrib_codex/transport.py +508 -0
- bazaar_contrib_codex-0.3.0rc1.dist-info/METADATA +42 -0
- bazaar_contrib_codex-0.3.0rc1.dist-info/RECORD +12 -0
- bazaar_contrib_codex-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_contrib_codex-0.3.0rc1.dist-info/entry_points.txt +3 -0
- bazaar_contrib_codex-0.3.0rc1.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|