darwin-agentic-cloud 0.1.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.
- darwin/__init__.py +10 -0
- darwin/agenticcloud/__init__.py +7 -0
- darwin/agenticcloud/attestation.py +77 -0
- darwin/agenticcloud/cli.py +502 -0
- darwin/agenticcloud/cost.py +76 -0
- darwin/agenticcloud/hashing.py +32 -0
- darwin/agenticcloud/mcp_server.py +303 -0
- darwin/agenticcloud/runtime.py +109 -0
- darwin/agenticcloud/sandbox.py +235 -0
- darwin/agenticcloud/server.py +230 -0
- darwin/agenticcloud/signing.py +100 -0
- darwin/agenticcloud/storage.py +230 -0
- darwin/agenticcloud/templates/docs.html +528 -0
- darwin/agenticcloud/types.py +57 -0
- darwin_agentic_cloud-0.1.0.dist-info/METADATA +444 -0
- darwin_agentic_cloud-0.1.0.dist-info/RECORD +19 -0
- darwin_agentic_cloud-0.1.0.dist-info/WHEEL +4 -0
- darwin_agentic_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- darwin_agentic_cloud-0.1.0.dist-info/licenses/LICENSE +202 -0
darwin/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Build and verify signed attestations.
|
|
2
|
+
|
|
3
|
+
An attestation is the cryptographic proof that a workload ran. It binds
|
|
4
|
+
together: the workload that was requested, the result that was produced,
|
|
5
|
+
the substrate that ran it, the cost, and the signer's identity.
|
|
6
|
+
|
|
7
|
+
The signature covers the entire attestation payload. Any tampering with
|
|
8
|
+
any field breaks verification.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
|
|
17
|
+
from darwin.agenticcloud import ATTESTATION_SCHEMA
|
|
18
|
+
from darwin.agenticcloud.hashing import canonical_json, content_hash
|
|
19
|
+
from darwin.agenticcloud.signing import Signer, verify_signature
|
|
20
|
+
from darwin.agenticcloud.types import Attestation, ExecutionResult, SignedAttestation, WorkloadSpec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_signed_attestation(
|
|
24
|
+
spec: WorkloadSpec,
|
|
25
|
+
result: ExecutionResult,
|
|
26
|
+
signer: Signer,
|
|
27
|
+
) -> SignedAttestation:
|
|
28
|
+
"""Build a signed attestation from a workload spec and execution result."""
|
|
29
|
+
spec_dict = asdict(spec)
|
|
30
|
+
result_dict = asdict(result)
|
|
31
|
+
|
|
32
|
+
attestation = Attestation(
|
|
33
|
+
schema=ATTESTATION_SCHEMA,
|
|
34
|
+
attestation_id=str(uuid.uuid4()),
|
|
35
|
+
workload_spec_hash=content_hash(spec_dict),
|
|
36
|
+
workload_spec=spec_dict,
|
|
37
|
+
execution_result=result_dict,
|
|
38
|
+
signer_key_id=signer.key_id(),
|
|
39
|
+
issued_at=time.time(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
attestation_dict = asdict(attestation)
|
|
43
|
+
canonical = canonical_json(attestation_dict)
|
|
44
|
+
|
|
45
|
+
return SignedAttestation(
|
|
46
|
+
attestation=attestation_dict,
|
|
47
|
+
signature_b64=signer.sign(canonical),
|
|
48
|
+
public_key_b64=signer.public_key_b64(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify_attestation(signed: SignedAttestation | dict) -> bool:
|
|
53
|
+
"""Verify a signed attestation.
|
|
54
|
+
|
|
55
|
+
Returns True if the signature is valid for the attestation payload
|
|
56
|
+
under the included public key. Returns False on any tampering or
|
|
57
|
+
malformed input.
|
|
58
|
+
|
|
59
|
+
Accepts either a SignedAttestation dataclass or a dict (as produced
|
|
60
|
+
by serializing one — e.g. from JSON).
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(signed, SignedAttestation):
|
|
63
|
+
attestation = signed.attestation
|
|
64
|
+
signature_b64 = signed.signature_b64
|
|
65
|
+
public_key_b64 = signed.public_key_b64
|
|
66
|
+
elif isinstance(signed, dict):
|
|
67
|
+
try:
|
|
68
|
+
attestation = signed["attestation"]
|
|
69
|
+
signature_b64 = signed["signature_b64"]
|
|
70
|
+
public_key_b64 = signed["public_key_b64"]
|
|
71
|
+
except KeyError:
|
|
72
|
+
return False
|
|
73
|
+
else:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
canonical = canonical_json(attestation)
|
|
77
|
+
return verify_signature(canonical, signature_b64, public_key_b64)
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Darwin Agentic Cloud command-line interface.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
darwin run hello.py
|
|
5
|
+
darwin run hello.py --timeout 30 --memory 256
|
|
6
|
+
darwin keys show
|
|
7
|
+
darwin attest verify ./attestation.json
|
|
8
|
+
darwin serve
|
|
9
|
+
darwin mcp serve
|
|
10
|
+
darwin history list
|
|
11
|
+
darwin history stats
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from datetime import UTC
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
from darwin.agenticcloud.attestation import verify_attestation
|
|
27
|
+
from darwin.agenticcloud.runtime import Runtime
|
|
28
|
+
from darwin.agenticcloud.signing import Signer
|
|
29
|
+
from darwin.agenticcloud.types import WorkloadSpec
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="darwin",
|
|
33
|
+
help="Darwin Agentic Cloud — verifiable compute for AI agents.",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
keys_app = typer.Typer(help="Manage signing keys.", no_args_is_help=True)
|
|
38
|
+
attest_app = typer.Typer(help="Work with attestations.", no_args_is_help=True)
|
|
39
|
+
history_app = typer.Typer(help="Query attestation history.", no_args_is_help=True)
|
|
40
|
+
mcp_app = typer.Typer(help="Model Context Protocol (MCP) server.", no_args_is_help=True)
|
|
41
|
+
|
|
42
|
+
app.add_typer(keys_app, name="keys")
|
|
43
|
+
app.add_typer(attest_app, name="attest")
|
|
44
|
+
app.add_typer(history_app, name="history")
|
|
45
|
+
app.add_typer(mcp_app, name="mcp")
|
|
46
|
+
|
|
47
|
+
console = Console()
|
|
48
|
+
err_console = Console(stderr=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -------------------------------------------------------------------
|
|
52
|
+
# Top-level commands
|
|
53
|
+
# -------------------------------------------------------------------
|
|
54
|
+
@app.command()
|
|
55
|
+
def run(
|
|
56
|
+
file: Annotated[Path, typer.Argument(help="Path to the script to run.")],
|
|
57
|
+
language: Annotated[
|
|
58
|
+
str, typer.Option("--language", "-l", help="Language (python or node).")
|
|
59
|
+
] = "python",
|
|
60
|
+
timeout: Annotated[int, typer.Option("--timeout", "-t", help="Timeout in seconds.")] = 30,
|
|
61
|
+
memory: Annotated[int, typer.Option("--memory", "-m", help="Memory limit in MB.")] = 512,
|
|
62
|
+
cost_cap: Annotated[float, typer.Option("--cost-cap", help="Cost ceiling in USD.")] = 0.01,
|
|
63
|
+
save: Annotated[
|
|
64
|
+
Path | None, typer.Option("--save", help="Write the signed attestation to this path.")
|
|
65
|
+
] = None,
|
|
66
|
+
json_only: Annotated[
|
|
67
|
+
bool, typer.Option("--json", help="Print only the signed attestation JSON.")
|
|
68
|
+
] = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Execute a script in the darwin.agenticcloud sandbox and emit a signed attestation."""
|
|
71
|
+
if not file.exists():
|
|
72
|
+
err_console.print(f"[red]File not found:[/red] {file}")
|
|
73
|
+
raise typer.Exit(code=2)
|
|
74
|
+
|
|
75
|
+
code = file.read_text(encoding="utf-8")
|
|
76
|
+
spec = WorkloadSpec(
|
|
77
|
+
code=code,
|
|
78
|
+
language=language,
|
|
79
|
+
timeout_sec=timeout,
|
|
80
|
+
memory_mb=memory,
|
|
81
|
+
cost_cap_usd=cost_cap,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
runtime = Runtime()
|
|
85
|
+
signed = runtime.run(spec)
|
|
86
|
+
|
|
87
|
+
signed_dict = {
|
|
88
|
+
"attestation": signed.attestation,
|
|
89
|
+
"signature_b64": signed.signature_b64,
|
|
90
|
+
"public_key_b64": signed.public_key_b64,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if save is not None:
|
|
94
|
+
save.write_text(json.dumps(signed_dict, indent=2), encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
if json_only:
|
|
97
|
+
print(json.dumps(signed_dict, indent=2))
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
_print_execution(signed_dict, save)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _print_execution(signed_dict: dict, saved_to: Path | None) -> None:
|
|
104
|
+
a = signed_dict["attestation"]
|
|
105
|
+
er = a["execution_result"]
|
|
106
|
+
|
|
107
|
+
status = er["status"]
|
|
108
|
+
color = {
|
|
109
|
+
"ok": "green",
|
|
110
|
+
"error": "red",
|
|
111
|
+
"timeout": "yellow",
|
|
112
|
+
"oom": "yellow",
|
|
113
|
+
"cost_exceeded": "red",
|
|
114
|
+
}.get(status, "white")
|
|
115
|
+
|
|
116
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
117
|
+
table.add_column(style="bold")
|
|
118
|
+
table.add_column()
|
|
119
|
+
table.add_row("status", f"[{color}]{status}[/{color}]")
|
|
120
|
+
table.add_row("exit_code", str(er["exit_code"]))
|
|
121
|
+
table.add_row("wall_time", f"{er['wall_time_sec']:.3f} s")
|
|
122
|
+
table.add_row("cost", f"${er['cost_usd']:.8f}")
|
|
123
|
+
table.add_row("substrate", er["substrate_id"])
|
|
124
|
+
table.add_row("workload_id", er["workload_id"])
|
|
125
|
+
table.add_row("attestation_id", a["attestation_id"])
|
|
126
|
+
table.add_row("signer_key_id", a["signer_key_id"])
|
|
127
|
+
table.add_row("output_hash", er["output_hash"][:16] + "…")
|
|
128
|
+
|
|
129
|
+
console.print(Panel(table, title="darwin.agenticcloud execution", border_style=color))
|
|
130
|
+
|
|
131
|
+
if er["stdout"]:
|
|
132
|
+
console.print(Panel(er["stdout"].rstrip("\n"), title="stdout", border_style="dim"))
|
|
133
|
+
if er["stderr"]:
|
|
134
|
+
console.print(Panel(er["stderr"].rstrip("\n"), title="stderr", border_style="yellow"))
|
|
135
|
+
|
|
136
|
+
if saved_to is not None:
|
|
137
|
+
console.print(f"[dim]Signed attestation saved to[/dim] {saved_to}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def serve(
|
|
142
|
+
host: Annotated[str, typer.Option("--host", help="Bind address.")] = "127.0.0.1",
|
|
143
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Bind port.")] = 8787,
|
|
144
|
+
reload: Annotated[
|
|
145
|
+
bool, typer.Option("--reload", help="Reload on file changes (dev only).")
|
|
146
|
+
] = False,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Run the darwin.agenticcloud HTTP server."""
|
|
149
|
+
import uvicorn
|
|
150
|
+
|
|
151
|
+
uvicorn.run(
|
|
152
|
+
"darwin.agenticcloud.server:app", host=host, port=port, reload=reload, log_level="info"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def version() -> None:
|
|
158
|
+
"""Print the Darwin version."""
|
|
159
|
+
import darwin
|
|
160
|
+
|
|
161
|
+
print(darwin.__version__)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# -------------------------------------------------------------------
|
|
165
|
+
# keys
|
|
166
|
+
# -------------------------------------------------------------------
|
|
167
|
+
@keys_app.command("show")
|
|
168
|
+
def keys_show() -> None:
|
|
169
|
+
"""Show the current signing key identity."""
|
|
170
|
+
signer = Signer()
|
|
171
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
172
|
+
table.add_column(style="bold")
|
|
173
|
+
table.add_column()
|
|
174
|
+
table.add_row("key_id", signer.key_id())
|
|
175
|
+
table.add_row("public_key_b64", signer.public_key_b64())
|
|
176
|
+
table.add_row("key_path", str(signer.key_path))
|
|
177
|
+
console.print(Panel(table, title="darwin.agenticcloud signing key", border_style="cyan"))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@keys_app.command("init")
|
|
181
|
+
def keys_init() -> None:
|
|
182
|
+
"""Initialize the signing keypair (no-op if one already exists)."""
|
|
183
|
+
signer = Signer()
|
|
184
|
+
console.print(f"[green]Key ready:[/green] {signer.key_id()}")
|
|
185
|
+
console.print(f"[dim]Path:[/dim] {signer.key_path}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# -------------------------------------------------------------------
|
|
189
|
+
# attest
|
|
190
|
+
# -------------------------------------------------------------------
|
|
191
|
+
@attest_app.command("verify")
|
|
192
|
+
def attest_verify(
|
|
193
|
+
file: Annotated[Path, typer.Argument(help="Path to a signed attestation JSON file.")],
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Verify a signed attestation."""
|
|
196
|
+
if not file.exists():
|
|
197
|
+
err_console.print(f"[red]File not found:[/red] {file}")
|
|
198
|
+
raise typer.Exit(code=2)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
202
|
+
except json.JSONDecodeError as e:
|
|
203
|
+
err_console.print(f"[red]Invalid JSON:[/red] {e}")
|
|
204
|
+
raise typer.Exit(code=2) from e
|
|
205
|
+
|
|
206
|
+
ok = verify_attestation(data)
|
|
207
|
+
if ok:
|
|
208
|
+
a = data.get("attestation", {})
|
|
209
|
+
console.print("[green]✓ verified[/green]")
|
|
210
|
+
console.print(f" attestation_id: {a.get('attestation_id', '?')}")
|
|
211
|
+
console.print(f" signer_key_id: {a.get('signer_key_id', '?')}")
|
|
212
|
+
console.print(f" schema: {a.get('schema', '?')}")
|
|
213
|
+
raise typer.Exit(code=0)
|
|
214
|
+
else:
|
|
215
|
+
console.print("[red]✗ verification failed[/red]")
|
|
216
|
+
raise typer.Exit(code=1)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@attest_app.command("show")
|
|
220
|
+
def attest_show(
|
|
221
|
+
file: Annotated[Path, typer.Argument(help="Path to a signed attestation JSON file.")],
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Pretty-print a signed attestation."""
|
|
224
|
+
if not file.exists():
|
|
225
|
+
err_console.print(f"[red]File not found:[/red] {file}")
|
|
226
|
+
raise typer.Exit(code=2)
|
|
227
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
228
|
+
print(json.dumps(data, indent=2))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# -------------------------------------------------------------------
|
|
232
|
+
# history
|
|
233
|
+
# -------------------------------------------------------------------
|
|
234
|
+
@history_app.command("list")
|
|
235
|
+
def history_list(
|
|
236
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max rows to return.")] = 20,
|
|
237
|
+
status: Annotated[str | None, typer.Option("--status", help="Filter by status.")] = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""List recent attestations."""
|
|
240
|
+
from darwin.agenticcloud.storage import AttestationStore
|
|
241
|
+
|
|
242
|
+
store = AttestationStore()
|
|
243
|
+
rows = store.list_by_status(status, limit=limit) if status else store.list_recent(limit=limit)
|
|
244
|
+
|
|
245
|
+
if not rows:
|
|
246
|
+
console.print("[dim]No attestations stored yet.[/dim]")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
table = Table(show_header=True, box=None, padding=(0, 1))
|
|
250
|
+
table.add_column("issued_at", style="dim")
|
|
251
|
+
table.add_column("status")
|
|
252
|
+
table.add_column("workload_id")
|
|
253
|
+
table.add_column("cost", justify="right")
|
|
254
|
+
table.add_column("wall_time", justify="right")
|
|
255
|
+
table.add_column("substrate")
|
|
256
|
+
table.add_column("id (short)", style="dim")
|
|
257
|
+
|
|
258
|
+
from datetime import datetime
|
|
259
|
+
|
|
260
|
+
for r in rows:
|
|
261
|
+
color = {"ok": "green", "error": "red", "timeout": "yellow", "cost_exceeded": "red"}.get(
|
|
262
|
+
r.status, "white"
|
|
263
|
+
)
|
|
264
|
+
ts = datetime.fromtimestamp(r.issued_at, tz=UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
265
|
+
table.add_row(
|
|
266
|
+
ts,
|
|
267
|
+
f"[{color}]{r.status}[/{color}]",
|
|
268
|
+
r.workload_id,
|
|
269
|
+
f"${r.cost_usd:.8f}",
|
|
270
|
+
f"{r.wall_time_sec:.3f}s",
|
|
271
|
+
r.substrate_id,
|
|
272
|
+
r.attestation_id[:8],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
console.print(table)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@history_app.command("stats")
|
|
279
|
+
def history_stats() -> None:
|
|
280
|
+
"""Show aggregate stats across stored attestations."""
|
|
281
|
+
from darwin.agenticcloud.storage import AttestationStore
|
|
282
|
+
|
|
283
|
+
store = AttestationStore()
|
|
284
|
+
total_count = store.count()
|
|
285
|
+
total_cost = store.total_cost_usd()
|
|
286
|
+
|
|
287
|
+
ok_count = len(store.list_by_status("ok", limit=10**9))
|
|
288
|
+
err_count = len(store.list_by_status("error", limit=10**9))
|
|
289
|
+
timeout_count = len(store.list_by_status("timeout", limit=10**9))
|
|
290
|
+
rejected_count = len(store.list_by_status("cost_exceeded", limit=10**9))
|
|
291
|
+
|
|
292
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
293
|
+
table.add_column(style="bold")
|
|
294
|
+
table.add_column()
|
|
295
|
+
table.add_row("total executions", str(total_count))
|
|
296
|
+
table.add_row("total cost", f"${total_cost:.8f}")
|
|
297
|
+
table.add_row("status: ok", str(ok_count))
|
|
298
|
+
table.add_row("status: error", str(err_count))
|
|
299
|
+
table.add_row("status: timeout", str(timeout_count))
|
|
300
|
+
table.add_row("status: cost_exceeded", str(rejected_count))
|
|
301
|
+
|
|
302
|
+
console.print(
|
|
303
|
+
Panel(table, title="darwin.agenticcloud attestation history", border_style="cyan")
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@history_app.command("show")
|
|
308
|
+
def history_show(
|
|
309
|
+
attestation_id: Annotated[str, typer.Argument(help="Attestation ID (full or first 8 chars).")],
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Show the full signed attestation for a given ID."""
|
|
312
|
+
from darwin.agenticcloud.storage import AttestationStore
|
|
313
|
+
|
|
314
|
+
store = AttestationStore()
|
|
315
|
+
|
|
316
|
+
if len(attestation_id) < 36:
|
|
317
|
+
candidates = [
|
|
318
|
+
a for a in store.list_recent(limit=10**9) if a.attestation_id.startswith(attestation_id)
|
|
319
|
+
]
|
|
320
|
+
if not candidates:
|
|
321
|
+
err_console.print(f"[red]No attestation matching prefix:[/red] {attestation_id}")
|
|
322
|
+
raise typer.Exit(code=2)
|
|
323
|
+
if len(candidates) > 1:
|
|
324
|
+
err_console.print(
|
|
325
|
+
f"[red]Ambiguous prefix:[/red] {attestation_id} matches {len(candidates)} attestations"
|
|
326
|
+
)
|
|
327
|
+
raise typer.Exit(code=2)
|
|
328
|
+
attestation_id = candidates[0].attestation_id
|
|
329
|
+
|
|
330
|
+
fetched = store.get(attestation_id)
|
|
331
|
+
if fetched is None:
|
|
332
|
+
err_console.print(f"[red]Not found:[/red] {attestation_id}")
|
|
333
|
+
raise typer.Exit(code=2)
|
|
334
|
+
|
|
335
|
+
print(json.dumps(fetched.signed_attestation, indent=2))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# -------------------------------------------------------------------
|
|
339
|
+
# mcp
|
|
340
|
+
# -------------------------------------------------------------------
|
|
341
|
+
@mcp_app.command("serve")
|
|
342
|
+
def mcp_serve() -> None:
|
|
343
|
+
"""Run the darwin.agenticcloud MCP server on stdio.
|
|
344
|
+
|
|
345
|
+
Intended to be spawned by an MCP client (Claude Desktop, Cursor, etc.)
|
|
346
|
+
over stdio. Do not run this manually unless you're piping JSON-RPC
|
|
347
|
+
into it.
|
|
348
|
+
"""
|
|
349
|
+
from darwin.agenticcloud.mcp_server import run as run_mcp
|
|
350
|
+
|
|
351
|
+
run_mcp()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
app()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@mcp_app.command("install")
|
|
359
|
+
def mcp_install(
|
|
360
|
+
client: Annotated[
|
|
361
|
+
str, typer.Option("--client", help="MCP client: 'claude-desktop' or 'cursor'.")
|
|
362
|
+
] = "claude-desktop",
|
|
363
|
+
name: Annotated[
|
|
364
|
+
str, typer.Option("--name", help="Server entry name in the config.")
|
|
365
|
+
] = "darwin",
|
|
366
|
+
force: Annotated[
|
|
367
|
+
bool, typer.Option("--force", help="Overwrite an existing entry without prompting.")
|
|
368
|
+
] = False,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Install darwin.agenticcloud as an MCP server in a supported client.
|
|
371
|
+
|
|
372
|
+
Detects the client's config file, adds an entry that spawns
|
|
373
|
+
`python -m darwin.agenticcloud.mcp_server` using the current
|
|
374
|
+
Python interpreter, and writes the config back. Idempotent.
|
|
375
|
+
"""
|
|
376
|
+
import os
|
|
377
|
+
import platform
|
|
378
|
+
import sys
|
|
379
|
+
from pathlib import Path
|
|
380
|
+
|
|
381
|
+
home = Path.home()
|
|
382
|
+
system = platform.system()
|
|
383
|
+
|
|
384
|
+
if client == "claude-desktop":
|
|
385
|
+
if system == "Darwin":
|
|
386
|
+
config_path = (
|
|
387
|
+
home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
388
|
+
)
|
|
389
|
+
elif system == "Windows":
|
|
390
|
+
appdata = os.environ.get("APPDATA")
|
|
391
|
+
if not appdata:
|
|
392
|
+
err_console.print("[red]APPDATA env var not set; can't locate Claude config.[/red]")
|
|
393
|
+
raise typer.Exit(code=2)
|
|
394
|
+
config_path = Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
395
|
+
elif system == "Linux":
|
|
396
|
+
config_path = home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
397
|
+
else:
|
|
398
|
+
err_console.print(f"[red]Unsupported OS for claude-desktop:[/red] {system}")
|
|
399
|
+
raise typer.Exit(code=2)
|
|
400
|
+
elif client == "cursor":
|
|
401
|
+
config_path = home / ".cursor" / "mcp.json"
|
|
402
|
+
else:
|
|
403
|
+
err_console.print(f"[red]Unknown client:[/red] {client}")
|
|
404
|
+
raise typer.Exit(code=2)
|
|
405
|
+
|
|
406
|
+
# Load existing config (or create empty)
|
|
407
|
+
if config_path.exists():
|
|
408
|
+
try:
|
|
409
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
410
|
+
except json.JSONDecodeError as e:
|
|
411
|
+
err_console.print(f"[red]Config file exists but is invalid JSON:[/red] {e}")
|
|
412
|
+
raise typer.Exit(code=2) from e
|
|
413
|
+
else:
|
|
414
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
config = {}
|
|
416
|
+
|
|
417
|
+
config.setdefault("mcpServers", {})
|
|
418
|
+
|
|
419
|
+
if name in config["mcpServers"] and not force:
|
|
420
|
+
existing = config["mcpServers"][name]
|
|
421
|
+
if existing.get("command") == sys.executable and existing.get("args") == [
|
|
422
|
+
"-m",
|
|
423
|
+
"darwin.agenticcloud.mcp_server",
|
|
424
|
+
]:
|
|
425
|
+
console.print(f"[green]✓ {name} already installed in {client} (no changes).[/green]")
|
|
426
|
+
console.print(f" config: {config_path}")
|
|
427
|
+
console.print(f" python: {sys.executable}")
|
|
428
|
+
raise typer.Exit(code=0)
|
|
429
|
+
else:
|
|
430
|
+
err_console.print(f"[yellow]Entry '{name}' already exists in {config_path}:[/yellow]")
|
|
431
|
+
err_console.print(f" {json.dumps(existing, indent=2)}")
|
|
432
|
+
err_console.print("Use --force to overwrite, or pick a different --name.")
|
|
433
|
+
raise typer.Exit(code=1)
|
|
434
|
+
|
|
435
|
+
config["mcpServers"][name] = {
|
|
436
|
+
"command": sys.executable,
|
|
437
|
+
"args": ["-m", "darwin.agenticcloud.mcp_server"],
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
441
|
+
|
|
442
|
+
console.print(
|
|
443
|
+
f"[green]✓ Installed darwin.agenticcloud as MCP server '{name}' in {client}.[/green]"
|
|
444
|
+
)
|
|
445
|
+
console.print(f" config: {config_path}")
|
|
446
|
+
console.print(f" command: {sys.executable}")
|
|
447
|
+
console.print(" args: -m darwin.agenticcloud.mcp_server")
|
|
448
|
+
console.print()
|
|
449
|
+
console.print("[dim]Restart your MCP client to pick up the change.[/dim]")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@mcp_app.command("uninstall")
|
|
453
|
+
def mcp_uninstall(
|
|
454
|
+
client: Annotated[
|
|
455
|
+
str, typer.Option("--client", help="MCP client: 'claude-desktop' or 'cursor'.")
|
|
456
|
+
] = "claude-desktop",
|
|
457
|
+
name: Annotated[str, typer.Option("--name", help="Server entry name to remove.")] = "darwin",
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Remove an MCP server entry from the client config."""
|
|
460
|
+
import os
|
|
461
|
+
import platform
|
|
462
|
+
from pathlib import Path
|
|
463
|
+
|
|
464
|
+
home = Path.home()
|
|
465
|
+
system = platform.system()
|
|
466
|
+
|
|
467
|
+
if client == "claude-desktop":
|
|
468
|
+
if system == "Darwin":
|
|
469
|
+
config_path = (
|
|
470
|
+
home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
471
|
+
)
|
|
472
|
+
elif system == "Windows":
|
|
473
|
+
appdata = os.environ.get("APPDATA")
|
|
474
|
+
if not appdata:
|
|
475
|
+
err_console.print("[red]APPDATA env var not set.[/red]")
|
|
476
|
+
raise typer.Exit(code=2)
|
|
477
|
+
config_path = Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
478
|
+
elif system == "Linux":
|
|
479
|
+
config_path = home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
480
|
+
else:
|
|
481
|
+
err_console.print(f"[red]Unsupported OS for claude-desktop:[/red] {system}")
|
|
482
|
+
raise typer.Exit(code=2)
|
|
483
|
+
elif client == "cursor":
|
|
484
|
+
config_path = home / ".cursor" / "mcp.json"
|
|
485
|
+
else:
|
|
486
|
+
err_console.print(f"[red]Unknown client:[/red] {client}")
|
|
487
|
+
raise typer.Exit(code=2)
|
|
488
|
+
|
|
489
|
+
if not config_path.exists():
|
|
490
|
+
console.print(f"[dim]No config file at {config_path} (nothing to remove).[/dim]")
|
|
491
|
+
raise typer.Exit(code=0)
|
|
492
|
+
|
|
493
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
494
|
+
if "mcpServers" not in config or name not in config["mcpServers"]:
|
|
495
|
+
console.print(f"[dim]Entry '{name}' not found in {config_path} (nothing to remove).[/dim]")
|
|
496
|
+
raise typer.Exit(code=0)
|
|
497
|
+
|
|
498
|
+
del config["mcpServers"][name]
|
|
499
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
500
|
+
|
|
501
|
+
console.print(f"[green]✓ Removed MCP server '{name}' from {client}.[/green]")
|
|
502
|
+
console.print(f" config: {config_path}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Cost model and budget enforcement for DAC.
|
|
2
|
+
|
|
3
|
+
Costs are computed per substrate. For v0, local Docker has a flat
|
|
4
|
+
wall-time rate. Real substrates (GPU, decentralized providers, sovereign
|
|
5
|
+
clouds) will have richer rate cards in v0.2.
|
|
6
|
+
|
|
7
|
+
Enforcement:
|
|
8
|
+
- Pre-flight: max_possible_cost(spec) is computed before sandbox launch.
|
|
9
|
+
If it would exceed the cap, we reject with BudgetExceeded — no
|
|
10
|
+
sandbox launched, no resources consumed.
|
|
11
|
+
- In-flight: tracked in v0.2 when variable-rate substrates land.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from darwin.agenticcloud.types import WorkloadSpec
|
|
17
|
+
|
|
18
|
+
# Rate card: USD per wall-time second by substrate.
|
|
19
|
+
# Tracked separately from substrate identity so a substrate can update
|
|
20
|
+
# its rate over time without breaking attestation verifiability.
|
|
21
|
+
SUBSTRATE_RATES_PER_SEC: dict[str, float] = {
|
|
22
|
+
"local-docker-v0": 0.0001,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
DEFAULT_RATE_PER_SEC = 0.0001
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BudgetExceeded(Exception):
|
|
29
|
+
"""Raised when a workload's cost cap is or would be exceeded."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, *, projected_usd: float, cap_usd: float) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.projected_usd = projected_usd
|
|
34
|
+
self.cap_usd = cap_usd
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rate_for_substrate(substrate_id: str) -> float:
|
|
38
|
+
"""Return USD per wall-time second for a substrate."""
|
|
39
|
+
return SUBSTRATE_RATES_PER_SEC.get(substrate_id, DEFAULT_RATE_PER_SEC)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cost_for_seconds(seconds: float, substrate_id: str) -> float:
|
|
43
|
+
"""Compute the cost (USD) for a given wall-time duration on a substrate."""
|
|
44
|
+
rate = rate_for_substrate(substrate_id)
|
|
45
|
+
return round(seconds * rate, 8)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def max_possible_cost(spec: WorkloadSpec, substrate_id: str) -> float:
|
|
49
|
+
"""The largest cost this workload could incur if it runs to its timeout."""
|
|
50
|
+
return cost_for_seconds(spec.timeout_sec, substrate_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_budget(spec: WorkloadSpec, substrate_id: str) -> None:
|
|
54
|
+
"""Pre-flight: raise BudgetExceeded if the workload's max cost exceeds its cap.
|
|
55
|
+
|
|
56
|
+
This is the cheap, defensive check. It runs before any sandbox is
|
|
57
|
+
launched. It rejects requests where timeout * rate would exceed cap,
|
|
58
|
+
so a hallucinating agent that asks for a 600-second timeout under a
|
|
59
|
+
$0.01 cap gets stopped before consuming any resources.
|
|
60
|
+
"""
|
|
61
|
+
if spec.cost_cap_usd <= 0:
|
|
62
|
+
raise BudgetExceeded(
|
|
63
|
+
f"cost_cap_usd must be > 0; got {spec.cost_cap_usd}",
|
|
64
|
+
projected_usd=0.0,
|
|
65
|
+
cap_usd=spec.cost_cap_usd,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
projected = max_possible_cost(spec, substrate_id)
|
|
69
|
+
if projected > spec.cost_cap_usd:
|
|
70
|
+
raise BudgetExceeded(
|
|
71
|
+
f"Projected max cost ${projected:.8f} exceeds cap ${spec.cost_cap_usd:.8f} "
|
|
72
|
+
f"(timeout={spec.timeout_sec}s @ ${rate_for_substrate(substrate_id):.8f}/s "
|
|
73
|
+
f"on {substrate_id})",
|
|
74
|
+
projected_usd=projected,
|
|
75
|
+
cap_usd=spec.cost_cap_usd,
|
|
76
|
+
)
|