agora-adapter 0.2.0__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.
- agora/__init__.py +1 -0
- agora/adapter/__init__.py +3 -0
- agora/adapter/__main__.py +3 -0
- agora/adapter/cli.py +261 -0
- agora/adapter/client.py +160 -0
- agora/adapter/diagnostics.py +76 -0
- agora/adapter/dsh-policy.mjs +13 -0
- agora/adapter/dsh.patch.yml +21 -0
- agora/adapter/dsh.py +282 -0
- agora/adapter/handoff.py +38 -0
- agora/adapter/harness.py +413 -0
- agora/adapter/interfaces.py +94 -0
- agora/adapter/launcher.py +110 -0
- agora/adapter/state.py +144 -0
- agora/adapter/supervisor.py +390 -0
- agora/contracts.py +135 -0
- agora/validation.py +17 -0
- agora_adapter-0.2.0.dist-info/METADATA +133 -0
- agora_adapter-0.2.0.dist-info/RECORD +22 -0
- agora_adapter-0.2.0.dist-info/WHEEL +4 -0
- agora_adapter-0.2.0.dist-info/entry_points.txt +2 -0
- agora_adapter-0.2.0.dist-info/licenses/LICENSE +21 -0
agora/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agora's thin agent adapters. PocketBase owns storage, identity, and realtime."""
|
agora/adapter/cli.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Run one room-scoped agent on the operator's machine."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from agora.adapter import __version__
|
|
19
|
+
from agora.adapter.client import AccessLost, Hall
|
|
20
|
+
from agora.adapter.diagnostics import Diagnostics
|
|
21
|
+
from agora.adapter.dsh import DSH
|
|
22
|
+
from agora.adapter.harness import Claude, Codex, HarnessError
|
|
23
|
+
from agora.adapter.state import State
|
|
24
|
+
from agora.adapter.supervisor import Supervisor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parser():
|
|
28
|
+
result = argparse.ArgumentParser(
|
|
29
|
+
description=__doc__,
|
|
30
|
+
epilog="For a hidden token prompt, use: agora-adapter connect --help",
|
|
31
|
+
)
|
|
32
|
+
result.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
33
|
+
result.add_argument("--hall", help="Hall origin, e.g. https://hall.example.com")
|
|
34
|
+
result.add_argument("--room", help="15-character room ID")
|
|
35
|
+
result.add_argument("--harness", choices=("claude", "codex", "dsh"))
|
|
36
|
+
result.add_argument("--cwd", type=Path, help="Local workspace exposed to the harness")
|
|
37
|
+
result.add_argument(
|
|
38
|
+
"--token-env",
|
|
39
|
+
default="AGORA_ROOM_TOKEN",
|
|
40
|
+
help="Environment variable holding the agent token",
|
|
41
|
+
)
|
|
42
|
+
result.add_argument("--state", type=Path, help="Private SQLite checkpoint file")
|
|
43
|
+
result.add_argument("--wake-on", choices=("mentions", "human", "controls"), default="mentions")
|
|
44
|
+
result.add_argument(
|
|
45
|
+
"--allow-edits",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Allow harness-native workspace editing; never bypass approvals",
|
|
48
|
+
)
|
|
49
|
+
result.add_argument("--model", help="Optional harness model override")
|
|
50
|
+
result.add_argument(
|
|
51
|
+
"--inspect", action="store_true", help="Read local --state without starting a harness"
|
|
52
|
+
)
|
|
53
|
+
result.add_argument(
|
|
54
|
+
"--diagnostics",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Include the last private diagnostic records with --inspect",
|
|
57
|
+
)
|
|
58
|
+
result.add_argument(
|
|
59
|
+
"--diagnose",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Check Codex/DSH startup/resume without submitting a model turn",
|
|
62
|
+
)
|
|
63
|
+
result.add_argument(
|
|
64
|
+
"--handoff",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Wait for the desktop Codex turn to finish before taking over",
|
|
67
|
+
)
|
|
68
|
+
result.add_argument(
|
|
69
|
+
"--handoff-timeout", type=float, default=600, help="Maximum handoff wait in seconds"
|
|
70
|
+
)
|
|
71
|
+
result.add_argument(
|
|
72
|
+
"--fork-session",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Explicitly fork existing history into an adapter-owned Codex session",
|
|
75
|
+
)
|
|
76
|
+
result.add_argument(
|
|
77
|
+
"--recover",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Replay an uncertain turn after inspecting its side effects",
|
|
80
|
+
)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def inspect_state(path, *, diagnostics=False):
|
|
85
|
+
with sqlite3.connect(f"file:{path.resolve()}?mode=ro", uri=True) as db:
|
|
86
|
+
meta = {key: json.loads(value) for key, value in db.execute("SELECT key, value FROM meta")}
|
|
87
|
+
result = {key: meta.get(key) for key in ("scope", "session", "attempt", "last_failure")}
|
|
88
|
+
result["inbox"] = [
|
|
89
|
+
dict(zip(("source", "id", "status"), row, strict=True))
|
|
90
|
+
for row in db.execute("SELECT source, id, status FROM inbox WHERE status != 'done'")
|
|
91
|
+
]
|
|
92
|
+
journal = Path(str(path) + ".diagnostics.jsonl")
|
|
93
|
+
result["diagnostics_path"] = str(journal)
|
|
94
|
+
if diagnostics and journal.exists():
|
|
95
|
+
# The journal is bounded and redacted at write time; no room transcripts are logged.
|
|
96
|
+
result["diagnostics"] = [
|
|
97
|
+
json.loads(line) for line in journal.read_text().splitlines()[-10:]
|
|
98
|
+
]
|
|
99
|
+
print(json.dumps(result, indent=2))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def run(args):
|
|
103
|
+
token = os.environ.get(args.token_env, "")
|
|
104
|
+
if not token or any(char.isspace() for char in token):
|
|
105
|
+
raise ValueError("Set the room token in the selected environment variable.")
|
|
106
|
+
cwd = args.cwd.resolve(strict=True)
|
|
107
|
+
if not cwd.is_dir():
|
|
108
|
+
raise ValueError("The workspace must be a directory.")
|
|
109
|
+
hall = Hall(args.hall, token, args.room)
|
|
110
|
+
state = harness = None
|
|
111
|
+
bootstrap = args.state or Path.home() / ".local/state/agora/startup"
|
|
112
|
+
diagnostics = Diagnostics(Path(str(bootstrap) + ".diagnostics.jsonl"), secrets=(token,))
|
|
113
|
+
try:
|
|
114
|
+
await hall.authenticate()
|
|
115
|
+
mcp = await hall.discovery()
|
|
116
|
+
scope = {
|
|
117
|
+
"hall": hall.url,
|
|
118
|
+
"room": hall.room,
|
|
119
|
+
"agent": hall.person["id"],
|
|
120
|
+
"harness": args.harness,
|
|
121
|
+
"cwd": str(cwd),
|
|
122
|
+
}
|
|
123
|
+
key = hashlib.sha256(repr(sorted(scope.items())).encode()).hexdigest()[:24]
|
|
124
|
+
path = args.state or Path.home() / ".local/state/agora" / (key + ".sqlite3")
|
|
125
|
+
diagnostics = Diagnostics(Path(str(path) + ".diagnostics.jsonl"), secrets=(token,))
|
|
126
|
+
state = State(path, scope, args.recover, hold_uncertain=True)
|
|
127
|
+
if state.recovery:
|
|
128
|
+
diagnostics.record("recovery", **state.recovery)
|
|
129
|
+
harness = {"claude": Claude, "codex": Codex, "dsh": DSH}[args.harness](
|
|
130
|
+
cwd=cwd,
|
|
131
|
+
mcp=mcp,
|
|
132
|
+
token=token,
|
|
133
|
+
allow_edits=args.allow_edits,
|
|
134
|
+
model=args.model,
|
|
135
|
+
)
|
|
136
|
+
harness.diagnostics = diagnostics
|
|
137
|
+
supervisor = Supervisor(hall, state, harness, wake_on=args.wake_on)
|
|
138
|
+
task = asyncio.current_task()
|
|
139
|
+
loop = asyncio.get_running_loop()
|
|
140
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
141
|
+
loop.add_signal_handler(sig, task.cancel)
|
|
142
|
+
logging.info(
|
|
143
|
+
"Listening for room activity. Harness: %s. Checkpoints: %s", args.harness, path
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
session = state.get("session")
|
|
147
|
+
if args.fork_session and session:
|
|
148
|
+
# Reject a blind replay before creating a replacement session.
|
|
149
|
+
if state.unfinished():
|
|
150
|
+
raise HarnessError("Inspect uncertain work and use --recover before forking.")
|
|
151
|
+
session = await harness.fork_session(session, lambda sid: state.set("session", sid))
|
|
152
|
+
state.set("last_failure", None)
|
|
153
|
+
supervisor.retries = 0
|
|
154
|
+
logging.info("Adapter-owned Codex session: %s", session)
|
|
155
|
+
if args.handoff and session:
|
|
156
|
+
logging.info(
|
|
157
|
+
"Handoff waiting for the current Codex turn; do not send more desktop turns."
|
|
158
|
+
)
|
|
159
|
+
await hall.activity("error")
|
|
160
|
+
waiter = asyncio.create_task(harness.wait_for_idle(session, args.handoff_timeout))
|
|
161
|
+
try:
|
|
162
|
+
while not waiter.done():
|
|
163
|
+
try:
|
|
164
|
+
await asyncio.wait_for(asyncio.shield(waiter), 15)
|
|
165
|
+
except TimeoutError:
|
|
166
|
+
if waiter.done():
|
|
167
|
+
raise
|
|
168
|
+
await hall.activity("error")
|
|
169
|
+
await waiter
|
|
170
|
+
finally:
|
|
171
|
+
waiter.cancel()
|
|
172
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
173
|
+
await waiter
|
|
174
|
+
if args.diagnose:
|
|
175
|
+
if args.harness == "dsh":
|
|
176
|
+
await harness.prepare(session, lambda sid: state.set("session", sid))
|
|
177
|
+
else:
|
|
178
|
+
await harness.start()
|
|
179
|
+
if session:
|
|
180
|
+
await harness.check_idle(session)
|
|
181
|
+
await harness.request("thread/resume", harness.thread_params(session))
|
|
182
|
+
logging.info(
|
|
183
|
+
"%s startup/resume succeeded. No model turn was submitted.", args.harness
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
await supervisor.run()
|
|
187
|
+
finally:
|
|
188
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
189
|
+
loop.remove_signal_handler(sig)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
if diagnostics:
|
|
192
|
+
incident = diagnostics.record(
|
|
193
|
+
"adapter_failure", exception=type(exc).__name__, message=str(exc)
|
|
194
|
+
)
|
|
195
|
+
logging.error("Adapter failure %s. Diagnostics: %s", incident, diagnostics.path)
|
|
196
|
+
if state and not state.get("last_failure"):
|
|
197
|
+
state.set(
|
|
198
|
+
"last_failure",
|
|
199
|
+
{
|
|
200
|
+
"incident": incident,
|
|
201
|
+
"message": str(exc)
|
|
202
|
+
if isinstance(exc, HarnessError)
|
|
203
|
+
else "Adapter startup or lifecycle failed.",
|
|
204
|
+
"blocked": True,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
# Without the state lock, another adapter may own this identity. Do not
|
|
208
|
+
# overwrite its activity merely because this process failed to start.
|
|
209
|
+
if state:
|
|
210
|
+
try:
|
|
211
|
+
await hall.activity("error")
|
|
212
|
+
except (AccessLost, httpx.HTTPError):
|
|
213
|
+
pass
|
|
214
|
+
raise
|
|
215
|
+
finally:
|
|
216
|
+
if harness:
|
|
217
|
+
await harness.close()
|
|
218
|
+
if state:
|
|
219
|
+
state.close()
|
|
220
|
+
await hall.close()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main():
|
|
224
|
+
if sys.argv[1:2] == ["connect"]:
|
|
225
|
+
from agora.adapter.launcher import main as connect
|
|
226
|
+
|
|
227
|
+
connect(sys.argv[2:])
|
|
228
|
+
return
|
|
229
|
+
logging.Formatter.converter = time.gmtime
|
|
230
|
+
logging.basicConfig(
|
|
231
|
+
level=logging.INFO,
|
|
232
|
+
format="%(asctime)sZ %(levelname)s %(message)s",
|
|
233
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
234
|
+
)
|
|
235
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
236
|
+
argument_parser = parser()
|
|
237
|
+
args = argument_parser.parse_args()
|
|
238
|
+
if args.inspect:
|
|
239
|
+
if not args.state:
|
|
240
|
+
argument_parser.error("--inspect requires --state")
|
|
241
|
+
inspect_state(args.state, diagnostics=args.diagnostics)
|
|
242
|
+
return
|
|
243
|
+
if not all((args.hall, args.room, args.harness, args.cwd)):
|
|
244
|
+
argument_parser.error("--hall, --room, --harness and --cwd are required")
|
|
245
|
+
if (args.handoff or args.fork_session) and args.harness != "codex":
|
|
246
|
+
argument_parser.error("--handoff and --fork-session require --harness codex")
|
|
247
|
+
if args.diagnose and args.harness not in ("codex", "dsh"):
|
|
248
|
+
argument_parser.error("--diagnose requires --harness codex or dsh")
|
|
249
|
+
if args.handoff_timeout <= 0:
|
|
250
|
+
argument_parser.error("--handoff-timeout must be positive")
|
|
251
|
+
try:
|
|
252
|
+
asyncio.run(run(args))
|
|
253
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
254
|
+
pass
|
|
255
|
+
except (ValueError, AccessLost, HarnessError) as exc:
|
|
256
|
+
raise SystemExit(str(exc)) from None
|
|
257
|
+
except Exception:
|
|
258
|
+
# Provider and transport exceptions may contain prompts, URLs, or credentials.
|
|
259
|
+
raise SystemExit(
|
|
260
|
+
"Adapter failed; inspect local state and harness logs before restarting."
|
|
261
|
+
) from None
|
agora/adapter/client.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""PocketBase SSE is a hint; reconnects replay the durable room records."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator, Sequence
|
|
6
|
+
from typing import cast
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from agora.adapter.interfaces import Activity, InputRecord
|
|
12
|
+
from agora.contracts import Person, Room
|
|
13
|
+
from agora.validation import record_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccessLost(Exception):
|
|
17
|
+
"""The adapter must stop its local harness and require fresh authorization."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def origin_url(value: str) -> str:
|
|
21
|
+
parsed = urlsplit(value)
|
|
22
|
+
if (
|
|
23
|
+
parsed.scheme not in ("http", "https")
|
|
24
|
+
or not parsed.hostname
|
|
25
|
+
or parsed.username
|
|
26
|
+
or parsed.password
|
|
27
|
+
or parsed.query
|
|
28
|
+
or parsed.fragment
|
|
29
|
+
or parsed.path not in ("", "/")
|
|
30
|
+
or (parsed.scheme == "http" and parsed.hostname not in ("127.0.0.1", "localhost", "::1"))
|
|
31
|
+
):
|
|
32
|
+
raise ValueError("Use an HTTPS hall origin (HTTP is supported only on loopback).")
|
|
33
|
+
return value.rstrip("/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def sse_events(response: httpx.Response) -> AsyncIterator[tuple[str, dict]]:
|
|
37
|
+
event = "message"
|
|
38
|
+
data: list[str] = []
|
|
39
|
+
async for line in response.aiter_lines():
|
|
40
|
+
if not line:
|
|
41
|
+
if data:
|
|
42
|
+
yield event, json.loads("\n".join(data))
|
|
43
|
+
event, data = "message", []
|
|
44
|
+
elif line.startswith("event:"):
|
|
45
|
+
event = line[6:].strip()
|
|
46
|
+
elif line.startswith("data:"):
|
|
47
|
+
data.append(line[5:].lstrip(" "))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Hall:
|
|
51
|
+
def __init__(self, url: str, token: str, room: str, *, transport=None):
|
|
52
|
+
self.url, self.room = origin_url(url), record_id(room)
|
|
53
|
+
self.token = token
|
|
54
|
+
self.http = httpx.AsyncClient(
|
|
55
|
+
base_url=self.url,
|
|
56
|
+
headers={"Authorization": "Bearer " + token},
|
|
57
|
+
timeout=30,
|
|
58
|
+
follow_redirects=False,
|
|
59
|
+
transport=transport,
|
|
60
|
+
)
|
|
61
|
+
self.person: Person | None = None
|
|
62
|
+
|
|
63
|
+
async def request(self, method, path, **kwargs):
|
|
64
|
+
response = await self.http.request(method, path, **kwargs)
|
|
65
|
+
if response.status_code in (401, 403, 404):
|
|
66
|
+
raise AccessLost("Room or agent access is no longer available.")
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
return response.json() if response.content else None
|
|
69
|
+
|
|
70
|
+
async def authenticate(self) -> Room:
|
|
71
|
+
auth = await self.request("POST", "/api/collections/people/auth-refresh")
|
|
72
|
+
person = auth["record"]
|
|
73
|
+
if person.get("kind") != "agent" or person.get("agent_room") != self.room:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Use an invited agent token for this room, never a human or admin token."
|
|
76
|
+
)
|
|
77
|
+
room = await self.request("GET", f"/api/collections/rooms/records/{self.room}")
|
|
78
|
+
if self.person and self.person["id"] != person["id"]:
|
|
79
|
+
raise AccessLost("The authenticated identity changed.")
|
|
80
|
+
self.person = person
|
|
81
|
+
return cast(Room, room)
|
|
82
|
+
|
|
83
|
+
async def discovery(self):
|
|
84
|
+
data = await self.request("GET", "/.well-known/agora.json")
|
|
85
|
+
mcp = data.get("mcp", "")
|
|
86
|
+
parsed = urlsplit(mcp)
|
|
87
|
+
origin_url(f"{parsed.scheme}://{parsed.netloc}")
|
|
88
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
89
|
+
raise ValueError("Invalid MCP discovery URL.")
|
|
90
|
+
return mcp
|
|
91
|
+
|
|
92
|
+
async def activity(self, status: Activity) -> None:
|
|
93
|
+
await self.request(
|
|
94
|
+
"POST", "/api/agora/agents/me/activity", json={"status": status}, timeout=5
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def records(
|
|
98
|
+
self, collection: str, cursor: str | int | None = None
|
|
99
|
+
) -> AsyncIterator[list[InputRecord]]:
|
|
100
|
+
filters = f'room="{self.room}"'
|
|
101
|
+
if collection == "agent_controls":
|
|
102
|
+
if self.person is None:
|
|
103
|
+
raise AccessLost("Authenticate before reading control records.")
|
|
104
|
+
filters += f' && agent="{self.person["id"]}" && sequence > {int(cursor or 0)}'
|
|
105
|
+
sort = "sequence"
|
|
106
|
+
else:
|
|
107
|
+
sort = "created,id"
|
|
108
|
+
if cursor:
|
|
109
|
+
filters += " && created >= " + json.dumps(cursor)
|
|
110
|
+
page = 1
|
|
111
|
+
while True:
|
|
112
|
+
result = await self.request(
|
|
113
|
+
"GET",
|
|
114
|
+
f"/api/collections/{collection}/records",
|
|
115
|
+
params={"filter": filters, "sort": sort, "perPage": 100, "page": page},
|
|
116
|
+
)
|
|
117
|
+
yield result["items"]
|
|
118
|
+
if page >= result["totalPages"]:
|
|
119
|
+
return
|
|
120
|
+
page += 1
|
|
121
|
+
|
|
122
|
+
async def watch(
|
|
123
|
+
self, dirty: asyncio.Event, *, subscriptions: Sequence[str] | None = None
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Subscribe before signalling replay, closing the snapshot/subscription race."""
|
|
126
|
+
delay = 1
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
async with self.http.stream(
|
|
130
|
+
"GET", "/api/realtime", timeout=httpx.Timeout(30, read=360)
|
|
131
|
+
) as response:
|
|
132
|
+
response.raise_for_status()
|
|
133
|
+
async for event, data in sse_events(response):
|
|
134
|
+
if event == "PB_CONNECT":
|
|
135
|
+
await self.request(
|
|
136
|
+
"POST",
|
|
137
|
+
"/api/realtime",
|
|
138
|
+
json={
|
|
139
|
+
"clientId": data["clientId"],
|
|
140
|
+
"subscriptions": subscriptions
|
|
141
|
+
or [
|
|
142
|
+
"messages/*",
|
|
143
|
+
"revisions/*",
|
|
144
|
+
"agent_controls/*",
|
|
145
|
+
f"rooms/{self.room}",
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
delay = 1
|
|
150
|
+
dirty.set()
|
|
151
|
+
elif data.get("record", {}).get("room", self.room) == self.room:
|
|
152
|
+
dirty.set()
|
|
153
|
+
except (httpx.HTTPError, ValueError, KeyError):
|
|
154
|
+
# No upstream exception text: URLs may contain credentials in other contexts.
|
|
155
|
+
pass
|
|
156
|
+
await asyncio.sleep(delay)
|
|
157
|
+
delay = min(delay * 2, 30)
|
|
158
|
+
|
|
159
|
+
async def close(self) -> None:
|
|
160
|
+
await self.http.aclose()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Bounded, private operator diagnostics; never a room transcript or reasoning log."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Diagnostics:
|
|
13
|
+
def __init__(self, path: Path, *, secrets=(), limit=1024 * 1024):
|
|
14
|
+
self.path, self.secrets, self.limit = path, tuple(filter(None, secrets)), limit
|
|
15
|
+
|
|
16
|
+
def clean(self, value):
|
|
17
|
+
if isinstance(value, dict):
|
|
18
|
+
return {
|
|
19
|
+
str(key): "[REDACTED]"
|
|
20
|
+
if any(
|
|
21
|
+
word in str(key).lower()
|
|
22
|
+
for word in (
|
|
23
|
+
"token",
|
|
24
|
+
"secret",
|
|
25
|
+
"password",
|
|
26
|
+
"authorization",
|
|
27
|
+
"api_key",
|
|
28
|
+
"apikey",
|
|
29
|
+
"credential",
|
|
30
|
+
"cookie",
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
else self.clean(item)
|
|
34
|
+
for key, item in value.items()
|
|
35
|
+
}
|
|
36
|
+
if isinstance(value, (list, tuple)):
|
|
37
|
+
return [self.clean(item) for item in value[:50]]
|
|
38
|
+
if not isinstance(value, str):
|
|
39
|
+
return value
|
|
40
|
+
for secret in self.secrets:
|
|
41
|
+
value = value.replace(secret, "[REDACTED]")
|
|
42
|
+
value = re.sub(r"(?i)\bBearer\s+[^\s\"'<>]+", "Bearer [REDACTED]", value)
|
|
43
|
+
value = re.sub(
|
|
44
|
+
r"(?i)(\b(?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s&,;\"']+",
|
|
45
|
+
r"\1[REDACTED]",
|
|
46
|
+
value,
|
|
47
|
+
)
|
|
48
|
+
value = re.sub(r"\bsk-[\w-]+", "[REDACTED]", value)
|
|
49
|
+
return value[:8000]
|
|
50
|
+
|
|
51
|
+
def record(self, event, **fields):
|
|
52
|
+
record = self.clean(
|
|
53
|
+
{
|
|
54
|
+
"id": uuid4().hex[:12],
|
|
55
|
+
"at": datetime.now(UTC).isoformat(),
|
|
56
|
+
"event": event,
|
|
57
|
+
**fields,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
62
|
+
if self.path.exists() and self.path.stat().st_size >= self.limit:
|
|
63
|
+
self.path.replace(self.path.with_suffix(self.path.suffix + ".previous"))
|
|
64
|
+
fd = os.open(self.path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
|
|
65
|
+
with os.fdopen(fd, "w") as handle:
|
|
66
|
+
os.fchmod(handle.fileno(), 0o600)
|
|
67
|
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
68
|
+
handle.flush()
|
|
69
|
+
os.fsync(handle.fileno())
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
# Diagnostic failure must be visible without replacing the original failure.
|
|
72
|
+
logging.error(
|
|
73
|
+
"Cannot persist adapter diagnostics at %s (errno %s).", self.path, exc.errno
|
|
74
|
+
)
|
|
75
|
+
return None
|
|
76
|
+
return record["id"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Apply the operator's choice before both fresh and restored Agents are published.
|
|
2
|
+
// DSH otherwise preserves a resumed session's earlier permission events.
|
|
3
|
+
export const inject = ['permissionPresets'];
|
|
4
|
+
|
|
5
|
+
export function apply(ctx) {
|
|
6
|
+
const mode = process.env.AGORA_DSH_PERMISSION_MODE;
|
|
7
|
+
if (!['read-only', 'workspace-write'].includes(mode)) {
|
|
8
|
+
throw new Error('Agora requires an explicit read-only or workspace-write policy.');
|
|
9
|
+
}
|
|
10
|
+
ctx.on('agent/prepare', ({ agent }) => {
|
|
11
|
+
ctx.permissionPresets.set(agent.session, mode);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Invocation-only policy; room credentials are passed over ACP, never stored here.
|
|
2
|
+
- id: sandbox-policy
|
|
3
|
+
config:
|
|
4
|
+
mode: !!js process.env.AGORA_DSH_PERMISSION_MODE
|
|
5
|
+
workspaceRoot: !!js process.cwd()
|
|
6
|
+
|
|
7
|
+
- id: approval
|
|
8
|
+
config:
|
|
9
|
+
policy: never
|
|
10
|
+
|
|
11
|
+
- id: permission
|
|
12
|
+
config:
|
|
13
|
+
defaultPreset: !!js process.env.AGORA_DSH_PERMISSION_MODE
|
|
14
|
+
presets:
|
|
15
|
+
read-only: {sandbox: read-only, approval: never}
|
|
16
|
+
workspace-write: {sandbox: workspace-write, approval: never}
|
|
17
|
+
danger-full-access: {sandbox: danger-full-access, approval: never}
|
|
18
|
+
|
|
19
|
+
- insert:
|
|
20
|
+
- id: agora-permission
|
|
21
|
+
name: ./dsh-policy.mjs
|