agentcap-cli 0.1.2__tar.gz → 0.1.3__tar.gz

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.
Files changed (42) hide show
  1. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/PKG-INFO +7 -1
  2. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/README.md +6 -0
  3. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/pyproject.toml +1 -1
  4. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/__init__.py +1 -1
  5. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/cli.py +61 -16
  6. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/license.py +15 -8
  7. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/proxy.py +100 -4
  8. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/runner.py +32 -15
  9. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_cli.py +134 -8
  10. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_license.py +39 -0
  11. agentcap_cli-0.1.3/tests/test_proxy_cancel.py +98 -0
  12. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_runner.py +54 -0
  13. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/.gitignore +0 -0
  14. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/LICENSE +0 -0
  15. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/landing/README.md +0 -0
  16. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/adapters.py +0 -0
  17. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/alerts.py +0 -0
  18. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/clamp.py +0 -0
  19. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/data/pricing.json +0 -0
  20. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/demo.py +0 -0
  21. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/devtools/__init__.py +0 -0
  22. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/devtools/fake_upstream.py +0 -0
  23. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/estimate.py +0 -0
  24. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/ledger.py +0 -0
  25. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/loop.py +0 -0
  26. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/pricing.py +0 -0
  27. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/upstreams.py +0 -0
  28. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/usage.py +0 -0
  29. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/license_support.py +0 -0
  30. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/proxy_support.py +0 -0
  31. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_adapters.py +0 -0
  32. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_alerts.py +0 -0
  33. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_clamp.py +0 -0
  34. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_estimate.py +0 -0
  35. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_ledger.py +0 -0
  36. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_loop.py +0 -0
  37. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_pricing.py +0 -0
  38. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_proxy_core.py +0 -0
  39. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_proxy_extras.py +0 -0
  40. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_proxy_streaming.py +0 -0
  41. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_upstreams.py +0 -0
  42. {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentcap-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit.
5
5
  Project-URL: Homepage, https://agentcap.launchbench.studio
6
6
  Project-URL: Repository, https://github.com/Blitz00X/agentcap
@@ -71,6 +71,8 @@ pipx install agentcap-cli
71
71
  pip install agentcap-cli
72
72
  ```
73
73
 
74
+ No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
75
+
74
76
  From a checkout:
75
77
 
76
78
  ```bash
@@ -187,6 +189,8 @@ Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google'
187
189
  usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
188
190
  tested end-to-end against a local fake API only, not yet with real paid keys.
189
191
 
192
+ **Platforms:** Linux and macOS. Windows: not yet (use WSL2).
193
+
190
194
  ## How it works
191
195
 
192
196
  1. `agentcap run` starts the proxy on a random free port, bound **only** to
@@ -348,6 +352,8 @@ Pick what matches your setup — these tools can also be combined.
348
352
 
349
353
  MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
350
354
 
355
+ After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
356
+
351
357
  The Pro licence key is separate from the source licence. How it is checked:
352
358
 
353
359
  - On activation the key is validated against Polar's public licence API
@@ -41,6 +41,8 @@ pipx install agentcap-cli
41
41
  pip install agentcap-cli
42
42
  ```
43
43
 
44
+ No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
45
+
44
46
  From a checkout:
45
47
 
46
48
  ```bash
@@ -157,6 +159,8 @@ Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google'
157
159
  usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
158
160
  tested end-to-end against a local fake API only, not yet with real paid keys.
159
161
 
162
+ **Platforms:** Linux and macOS. Windows: not yet (use WSL2).
163
+
160
164
  ## How it works
161
165
 
162
166
  1. `agentcap run` starts the proxy on a random free port, bound **only** to
@@ -318,6 +322,8 @@ Pick what matches your setup — these tools can also be combined.
318
322
 
319
323
  MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
320
324
 
325
+ After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
326
+
321
327
  The Pro licence key is separate from the source licence. How it is checked:
322
328
 
323
329
  - On activation the key is validated against Polar's public licence API
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentcap-cli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.2"
5
+ __version__ = "0.1.3"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -33,11 +33,33 @@ from .upstreams import UpstreamConfigError, resolve_upstreams
33
33
 
34
34
  DEFAULT_SERVE_PORT = 8787
35
35
 
36
+ WINDOWS_UNSUPPORTED = (
37
+ "agentcap: Windows is not supported yet (process-group stop is POSIX-only). "
38
+ "Use WSL2, macOS or Linux."
39
+ )
40
+ PRO_CAP_MESSAGE = (
41
+ "agentcap: --hour-cap/--day-cap require an active Pro license; "
42
+ "command was not started. Run `agentcap license status`, then "
43
+ '`agentcap license activate "YOUR_LICENSE_KEY"`.'
44
+ )
45
+ LICENSE_USAGE_MESSAGE = (
46
+ 'Usage: agentcap license activate "YOUR_LICENSE_KEY" '
47
+ "(copy the key from your Polar purchase / customer portal)"
48
+ )
49
+ LICENSE_INVALID_MESSAGE = (
50
+ "License key not found or no longer valid. Check the copied key; "
51
+ "if it still fails, email kutaykarahan6@gmail.com."
52
+ )
53
+
54
+
55
+ def _windows_unsupported() -> bool:
56
+ return os.name == "nt" or sys.platform.startswith("win")
57
+
36
58
 
37
59
  def _build_parser() -> argparse.ArgumentParser:
38
60
  parser = argparse.ArgumentParser(
39
61
  prog="agentcap",
40
- description="Local, hard USD cap for AI agent runs.",
62
+ description="Local USD spend cap for AI agent runs.",
41
63
  )
42
64
  parser.add_argument("--version", action="version", version=f"agentcap {__version__}")
43
65
  subparsers = parser.add_subparsers(dest="subcommand")
@@ -166,19 +188,41 @@ def _license_manager() -> LicenseManager:
166
188
  return LicenseManager()
167
189
 
168
190
 
191
+ def _license_persist_error(exc: OSError, path) -> str:
192
+ detail = exc.strerror or str(exc)
193
+ return (
194
+ "agentcap: license validated, but could not save activation to "
195
+ f"{path}: {detail}. Check write permission and free disk space."
196
+ )
197
+
198
+
169
199
  def _license(args: argparse.Namespace) -> int:
200
+ manager = _license_manager()
170
201
  if args.action == "activate":
171
202
  if not args.key:
172
- print("agentcap: license activate requires a key", file=sys.stderr)
203
+ print(LICENSE_USAGE_MESSAGE, file=sys.stderr)
173
204
  return 2
174
- status = _license_manager().activate(args.key)
205
+ try:
206
+ status = manager.activate(args.key)
207
+ except OSError as exc:
208
+ print(_license_persist_error(exc, manager.path), file=sys.stderr)
209
+ return 1
175
210
  print(f"tier: {status.tier}")
176
211
  print(f"valid: {status.valid}")
177
212
  print(f"reason: {status.reason}")
213
+ if (
214
+ not status.valid
215
+ and "could not reach the license server" not in status.reason
216
+ ):
217
+ print(LICENSE_INVALID_MESSAGE, file=sys.stderr)
178
218
  return 0 if status.valid else 1
179
219
 
180
220
  if args.action == "deactivate":
181
- _license_manager().clear()
221
+ try:
222
+ manager.clear()
223
+ except OSError as exc:
224
+ print(_license_persist_error(exc, manager.path), file=sys.stderr)
225
+ return 1
182
226
  print("license cache cleared")
183
227
  return 0
184
228
 
@@ -189,6 +233,7 @@ def _license(args: argparse.Namespace) -> int:
189
233
  print(f"tier: {status.tier}")
190
234
  print(f"valid: {status.valid}")
191
235
  print(f"reason: {status.reason}")
236
+ print(f"cache path: {manager.path}")
192
237
  return 0
193
238
 
194
239
 
@@ -219,6 +264,9 @@ def _upstream_overrides() -> dict[str, str] | None:
219
264
 
220
265
 
221
266
  def _run(args: argparse.Namespace) -> int:
267
+ if _windows_unsupported():
268
+ print(WINDOWS_UNSUPPORTED, file=sys.stderr)
269
+ return 2
222
270
  argv = list(args.argv)
223
271
  if argv and argv[0] == "--":
224
272
  argv = argv[1:]
@@ -244,18 +292,12 @@ def _run(args: argparse.Namespace) -> int:
244
292
  return 2
245
293
 
246
294
  active_features = set(features(status_from_environment().tier))
247
- if hour_cap is not None and "hour_cap" not in active_features:
248
- print(
249
- "agentcap: --hour-cap requires AgentCap Pro; ignoring it on the free tier.",
250
- file=sys.stderr,
251
- )
252
- hour_cap = None
253
- if day_cap is not None and "day_cap" not in active_features:
254
- print(
255
- "agentcap: --day-cap requires AgentCap Pro; ignoring it on the free tier.",
256
- file=sys.stderr,
257
- )
258
- day_cap = None
295
+ if (
296
+ (hour_cap is not None and "hour_cap" not in active_features)
297
+ or (day_cap is not None and "day_cap" not in active_features)
298
+ ):
299
+ print(PRO_CAP_MESSAGE, file=sys.stderr)
300
+ return 2
259
301
 
260
302
  try:
261
303
  runner = Runner(
@@ -327,6 +369,9 @@ def _serve(args: argparse.Namespace) -> int:
327
369
 
328
370
  # ------------------------------------------------------------------------ demo
329
371
  def _demo(_args: argparse.Namespace) -> int:
372
+ if _windows_unsupported():
373
+ print(WINDOWS_UNSUPPORTED, file=sys.stderr)
374
+ return 2
330
375
  from .demo import run_demo
331
376
 
332
377
  return run_demo()
@@ -569,16 +569,23 @@ class LicenseManager:
569
569
 
570
570
  if status.valid:
571
571
  if self.persists:
572
- self._write(
573
- effective_key,
574
- status,
575
- now,
576
- activation_id,
577
- last_attempt_at=now,
578
- )
572
+ try:
573
+ self._write(
574
+ effective_key,
575
+ status,
576
+ now,
577
+ activation_id,
578
+ last_attempt_at=now,
579
+ )
580
+ except OSError:
581
+ # A read-only cache must not break a validated run.
582
+ pass
579
583
  elif same_key is not None and self.persists:
580
584
  # Definitive rejection: make sure a stale Pro cache cannot survive.
581
- self.clear()
585
+ try:
586
+ self.clear()
587
+ except OSError:
588
+ pass
582
589
  return status
583
590
 
584
591
  def _offline(
@@ -8,8 +8,10 @@ Design rules that this module enforces:
8
8
  the request (HTTP 403/400) instead of forwarding it unmetered.
9
9
  * Credentials (``Authorization``, ``x-goog-api-key``, ``?key=``) are forwarded
10
10
  untouched but never parsed, logged, stored or emitted in events.
11
- * Failures to meter (client disconnect, missing usage block) settle the whole
12
- reservation: over-charging a run is safe, under-charging is not.
11
+ * Failures to meter settle the reservation conservatively: a mid-stream
12
+ disconnect or a missing usage block charges the whole reservation, while a
13
+ disconnect before upstream headers charges only the estimated input (output
14
+ was never delivered).
13
15
 
14
16
  Endpoint contract::
15
17
 
@@ -112,6 +114,10 @@ class _Settlement:
112
114
  requested_tier: str | None = None
113
115
  multiplier: Decimal = Decimal("1")
114
116
  now: datetime | None = None
117
+ #: Padded input-token estimate used to reserve this request. If the client
118
+ #: disconnects before the upstream answers, this is the part the provider
119
+ #: may already have billed.
120
+ input_tokens: int = 0
115
121
 
116
122
 
117
123
  @dataclass(frozen=True)
@@ -851,6 +857,7 @@ class _Proxy:
851
857
  requested_tier=requested_tier,
852
858
  multiplier=tier_multiplier,
853
859
  now=now,
860
+ input_tokens=input_tokens,
854
861
  )
855
862
 
856
863
  if streaming:
@@ -966,9 +973,18 @@ class _Proxy:
966
973
  "agentcap: upstream request failed; reservation released.",
967
974
  "agentcap_upstream_error",
968
975
  )
976
+ except BaseException:
977
+ # The client went away (or the handler was cancelled) while waiting
978
+ # for the upstream: charge only the input the provider may already
979
+ # have billed, then let the cancellation propagate.
980
+ await self._abort_response(response)
981
+ self._settle_client_disconnect(
982
+ reservation_id, reserved, settlement, model, provider
983
+ )
984
+ raise
969
985
  finally:
970
986
  if response is not None:
971
- with contextlib.suppress(Exception):
987
+ with contextlib.suppress(BaseException):
972
988
  await response.aclose()
973
989
 
974
990
  assert response is not None
@@ -1006,6 +1022,7 @@ class _Proxy:
1006
1022
  kind: str,
1007
1023
  provider: str,
1008
1024
  ) -> Response:
1025
+ response: httpx.Response | None = None
1009
1026
  try:
1010
1027
  response = await self._client.send(upstream, stream=True)
1011
1028
  except httpx.HTTPError:
@@ -1015,6 +1032,14 @@ class _Proxy:
1015
1032
  "agentcap: upstream request failed; reservation released.",
1016
1033
  "agentcap_upstream_error",
1017
1034
  )
1035
+ except BaseException:
1036
+ # Cancelled (or the client disconnected) before the upstream
1037
+ # produced headers. Abort and charge the estimated input only.
1038
+ await self._abort_response(response)
1039
+ self._settle_client_disconnect(
1040
+ reservation_id, reserved, settlement, model, provider
1041
+ )
1042
+ raise
1018
1043
 
1019
1044
  if response.status_code >= 400:
1020
1045
  # Errors are not streams; drain, release, and replay as a response.
@@ -1027,8 +1052,14 @@ class _Proxy:
1027
1052
  "agentcap: upstream request failed; reservation released.",
1028
1053
  "agentcap_upstream_error",
1029
1054
  )
1055
+ except BaseException:
1056
+ await self._abort_response(response)
1057
+ self._settle_client_disconnect(
1058
+ reservation_id, reserved, settlement, model, provider
1059
+ )
1060
+ raise
1030
1061
  finally:
1031
- with contextlib.suppress(Exception):
1062
+ with contextlib.suppress(BaseException):
1032
1063
  await response.aclose()
1033
1064
  self._release_quietly(reservation_id)
1034
1065
  return Response(
@@ -1105,6 +1136,71 @@ class _Proxy:
1105
1136
  except Exception:
1106
1137
  pass
1107
1138
 
1139
+ @staticmethod
1140
+ async def _abort_response(response: httpx.Response | None) -> None:
1141
+ """Close an in-flight upstream response without hiding cancellation."""
1142
+
1143
+ if response is None:
1144
+ return
1145
+ with contextlib.suppress(BaseException):
1146
+ await response.aclose()
1147
+
1148
+ def _input_only_cost(self, settlement: _Settlement) -> Decimal | None:
1149
+ """Cost of the prompt alone: provider may have billed it before close."""
1150
+
1151
+ try:
1152
+ charged = pricing_cost(
1153
+ settlement.price,
1154
+ settlement.input_tokens,
1155
+ 0,
1156
+ 0,
1157
+ now=settlement.now,
1158
+ cache_write=settlement.cache_write,
1159
+ )
1160
+ if settlement.multiplier != 1:
1161
+ charged = (charged * settlement.multiplier).quantize(
1162
+ MICRO_USD, rounding=ROUND_CEILING
1163
+ )
1164
+ return charged
1165
+ except Exception:
1166
+ return None
1167
+
1168
+ def _settle_client_disconnect(
1169
+ self,
1170
+ reservation_id: str,
1171
+ reserved: Decimal,
1172
+ settlement: _Settlement,
1173
+ model: str,
1174
+ provider: str,
1175
+ ) -> None:
1176
+ """Settle a reservation after a client disconnect / cancellation.
1177
+
1178
+ Output is assumed to be zero because the connection was closed; the
1179
+ provider may still have billed the input. The reservation must never
1180
+ be left open, so a fallback charge (the full reservation) is used if
1181
+ the input cost cannot be computed, and a failed settle is released.
1182
+ """
1183
+
1184
+ charged = self._input_only_cost(settlement)
1185
+ if charged is None:
1186
+ charged = reserved
1187
+ try:
1188
+ self.ledger.settle(reservation_id, charged)
1189
+ except Exception:
1190
+ self._release_quietly(reservation_id)
1191
+ self._emit(
1192
+ {
1193
+ "type": "client_disconnect",
1194
+ "reserved_usd": str(reserved),
1195
+ "charged_usd": str(charged),
1196
+ }
1197
+ )
1198
+ try:
1199
+ self._check_thresholds()
1200
+ except Exception:
1201
+ pass
1202
+ self._trip_if_cap_reached(model, provider)
1203
+
1108
1204
  def _settle_actual(
1109
1205
  self,
1110
1206
  reservation_id: str,
@@ -192,6 +192,9 @@ class Runner:
192
192
  self.kill_reason: str | None = None
193
193
  self._kill_lock = threading.Lock()
194
194
  self.child: subprocess.Popen | None = None
195
+ #: Process-group id captured at spawn, so the group can still be
196
+ #: signalled after the leader has exited (orphaned grandchildren).
197
+ self._pgid: int | None = None
195
198
  self.summary: RunSummary | None = None
196
199
 
197
200
  self.alerts = alerts or dispatcher_from_env(
@@ -361,28 +364,41 @@ class Runner:
361
364
  def _spawn(
362
365
  self, argv: Sequence[str] | None = None, env: Mapping[str, str] | None = None
363
366
  ) -> subprocess.Popen:
364
- return self.popen_factory(
367
+ child = self.popen_factory(
365
368
  self.argv if argv is None else argv,
366
369
  self.child_env() if env is None else env,
367
370
  )
371
+ # Capture the group now: once the leader exits, os.getpgid(pid) fails
372
+ # even though the group (and its children) may still be alive.
373
+ try:
374
+ self._pgid = os.getpgid(child.pid)
375
+ except (ProcessLookupError, OSError):
376
+ self._pgid = None
377
+ return child
368
378
 
369
379
  def _signal_group(self, sig: int) -> None:
370
380
  child = self.child
371
- if child is None or child.poll() is not None:
372
- return
373
- try:
374
- pgid = os.getpgid(child.pid)
375
- except (ProcessLookupError, OSError):
376
- pgid = None
377
- try:
378
- if pgid is not None and pgid != os.getpgrp():
381
+ pgid = self._pgid
382
+ if pgid is None and child is not None and child.poll() is None:
383
+ try:
384
+ pgid = os.getpgid(child.pid)
385
+ except (ProcessLookupError, OSError):
386
+ pgid = None
387
+
388
+ if pgid is not None and pgid != os.getpgrp():
389
+ try:
379
390
  os.killpg(pgid, sig)
380
- else:
381
- # A custom popen factory may not have created a new session;
382
- # never signal our own process group.
391
+ return
392
+ except (ProcessLookupError, PermissionError, OSError):
393
+ pass
394
+
395
+ # A custom popen factory may not have created a new session; never
396
+ # signal our own process group, and only signal a live leader.
397
+ if child is not None and child.poll() is None:
398
+ try:
383
399
  child.send_signal(sig)
384
- except (ProcessLookupError, PermissionError, OSError):
385
- pass
400
+ except (ProcessLookupError, PermissionError, OSError):
401
+ pass
386
402
 
387
403
  def _terminate(self) -> None:
388
404
  self._signal_group(signal.SIGTERM)
@@ -391,9 +407,10 @@ class Runner:
391
407
  return
392
408
  try:
393
409
  child.wait(timeout=self.term_timeout)
394
- return
395
410
  except subprocess.TimeoutExpired:
396
411
  pass
412
+ # Even if the leader exited within the grace period, grandchildren may
413
+ # still be running in its process group: SIGKILL the saved group too.
397
414
  self._signal_group(signal.SIGKILL)
398
415
  try:
399
416
  child.wait(timeout=5.0)
@@ -69,16 +69,22 @@ def test_run_cap_comes_from_env(fake_runner, monkeypatch):
69
69
  assert fake_runner.instances[1].kwargs["cap"] == Decimal("0.25")
70
70
 
71
71
 
72
- def test_free_tier_ignores_hour_and_day_caps_with_one_line_notice(fake_runner, capsys):
72
+ def test_free_tier_refuses_hour_and_day_caps_without_starting(fake_runner, capsys):
73
73
  assert cli.main(
74
74
  ["run", "--hour-cap", "1", "--day-cap", "2", "--", "true"]
75
- ) == 0
76
- runner = fake_runner.instances[0]
77
- assert runner.kwargs["hour_cap"] is None
78
- assert runner.kwargs["day_cap"] is None
75
+ ) == 2
76
+ assert fake_runner.instances == []
79
77
  err = capsys.readouterr().err
80
- assert "--hour-cap requires AgentCap Pro" in err
81
- assert "--day-cap requires AgentCap Pro" in err
78
+ assert "--hour-cap/--day-cap require an active Pro license" in err
79
+ assert "command was not started" in err
80
+ assert 'agentcap license activate "YOUR_LICENSE_KEY"' in err
81
+
82
+
83
+ @pytest.mark.parametrize("flag", ["--hour-cap", "--day-cap"])
84
+ def test_free_tier_refuses_each_pro_cap_individually(fake_runner, capsys, flag):
85
+ assert cli.main(["run", flag, "1", "--", "true"]) == 2
86
+ assert fake_runner.instances == []
87
+ assert "require an active Pro license" in capsys.readouterr().err
82
88
 
83
89
 
84
90
  def test_pro_license_enables_hour_and_day_caps(fake_runner, tmp_path, capsys):
@@ -234,7 +240,127 @@ def test_license_deactivate_clears_local_cache(monkeypatch, tmp_path, capsys):
234
240
  def test_license_activate_without_key_fails(monkeypatch, tmp_path, capsys):
235
241
  monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(tmp_path / "license.json"))
236
242
  assert cli.main(["license", "activate"]) == 2
237
- assert "requires a key" in capsys.readouterr().err
243
+ err = capsys.readouterr().err
244
+ assert 'Usage: agentcap license activate "YOUR_LICENSE_KEY"' in err
245
+ assert "Polar purchase / customer portal" in err
246
+
247
+
248
+ def test_license_activate_invalid_key_prints_actionable_message(
249
+ monkeypatch, tmp_path, capsys
250
+ ):
251
+ monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(tmp_path / "license.json"))
252
+
253
+ class DenyingPolar:
254
+ persist = True
255
+
256
+ def activate(self, key):
257
+ return LicenseStatus(False, "free", "license key not found"), None
258
+
259
+ def validate(self, key, activation_id=None):
260
+ return LicenseStatus(False, "free", "license key not found")
261
+
262
+ monkeypatch.setattr(license_module, "PolarLicenseValidator", DenyingPolar)
263
+
264
+ assert cli.main(["license", "activate", "wrong-key"]) == 1
265
+ captured = capsys.readouterr()
266
+ assert "tier: free" in captured.out
267
+ assert "License key not found or no longer valid" in captured.err
268
+ assert "kutaykarahan6@gmail.com" in captured.err
269
+ assert "wrong-key" not in captured.out + captured.err
270
+
271
+
272
+ def test_license_status_prints_cache_path_but_never_the_key(
273
+ monkeypatch, tmp_path, capsys
274
+ ):
275
+ path = tmp_path / "license.json"
276
+ write_pro_cache(path)
277
+ monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
278
+
279
+ assert cli.main(["license", "status"]) == 0
280
+ out = capsys.readouterr().out
281
+ assert f"cache path: {path}" in out
282
+ assert "polar-key" not in out
283
+
284
+
285
+ def test_license_activate_save_error_is_reported_without_traceback(
286
+ monkeypatch, tmp_path, capsys
287
+ ):
288
+ path = tmp_path / "license.json"
289
+ monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
290
+
291
+ class GrantingPolar:
292
+ persist = True
293
+
294
+ def activate(self, key):
295
+ return LicenseStatus(True, "pro", "license valid"), "act_123"
296
+
297
+ def validate(self, key, activation_id=None):
298
+ return LicenseStatus(True, "pro", "license valid")
299
+
300
+ monkeypatch.setattr(license_module, "PolarLicenseValidator", GrantingPolar)
301
+
302
+ def fail_write(*args, **kwargs):
303
+ raise OSError(28, "No space left on device")
304
+
305
+ monkeypatch.setattr(license_module.LicenseManager, "_write", fail_write)
306
+
307
+ assert cli.main(["license", "activate", "polar-key-secret"]) == 1
308
+ captured = capsys.readouterr()
309
+ assert captured.err.startswith("agentcap: license validated, but could not save"
310
+ " activation to ")
311
+ assert str(path) in captured.err
312
+ assert "No space left on device" in captured.err
313
+ assert "Check write permission and free disk space." in captured.err
314
+ assert "Traceback" not in captured.err
315
+ assert "polar-key-secret" not in captured.out + captured.err
316
+
317
+
318
+ def test_license_deactivate_clear_error_is_reported_without_traceback(
319
+ monkeypatch, tmp_path, capsys
320
+ ):
321
+ path = tmp_path / "license.json"
322
+ monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
323
+
324
+ def fail_clear(self):
325
+ raise OSError(13, "Permission denied")
326
+
327
+ monkeypatch.setattr(license_module.LicenseManager, "clear", fail_clear)
328
+
329
+ assert cli.main(["license", "deactivate"]) == 1
330
+ captured = capsys.readouterr()
331
+ assert "license validated, but could not save activation" in captured.err
332
+ assert str(path) in captured.err
333
+ assert "Permission denied" in captured.err
334
+ assert "Traceback" not in captured.err
335
+
336
+
337
+ @pytest.mark.parametrize("platform_name", ["nt"])
338
+ def test_run_refuses_windows_before_starting(
339
+ monkeypatch, fake_runner, capsys, platform_name
340
+ ):
341
+ monkeypatch.setattr(cli.os, "name", platform_name)
342
+ assert cli.main(["run", "--", "true"]) == 2
343
+ assert fake_runner.instances == []
344
+ err = capsys.readouterr().err
345
+ assert "Windows is not supported yet" in err
346
+ assert "Use WSL2, macOS or Linux." in err
347
+
348
+
349
+ def test_run_refuses_windows_when_only_sys_platform_is_win(
350
+ monkeypatch, fake_runner, capsys
351
+ ):
352
+ monkeypatch.setattr(cli.sys, "platform", "win32")
353
+ assert cli.main(["run", "--", "true"]) == 2
354
+ assert fake_runner.instances == []
355
+ assert "Windows is not supported yet" in capsys.readouterr().err
356
+
357
+
358
+ def test_demo_refuses_windows_before_starting(monkeypatch, capsys):
359
+ monkeypatch.setattr(cli.os, "name", "nt")
360
+ assert cli.main(["demo"]) == 2
361
+ captured = capsys.readouterr()
362
+ assert "Windows is not supported yet" in captured.err
363
+ assert "SIMULATED DEMO" not in captured.out
238
364
 
239
365
 
240
366
  def test_license_activate_network_failure_exits_one_without_traceback(
@@ -810,6 +810,45 @@ def test_clear_removes_license(tmp_path):
810
810
  assert manager.tier() == "free"
811
811
 
812
812
 
813
+ def test_activate_propagates_oserror_when_activation_cannot_be_saved(
814
+ tmp_path, monkeypatch
815
+ ):
816
+ class PersistingValidator:
817
+ persist = True
818
+
819
+ def activate(self, key):
820
+ return LicenseStatus(True, "pro", "static license accepted"), None
821
+
822
+ def validate(self, key, activation_id=None):
823
+ return LicenseStatus(True, "pro", "static license accepted")
824
+
825
+ manager = LicenseManager(
826
+ PersistingValidator(),
827
+ tmp_path / "license.json",
828
+ clock=FakeClock(),
829
+ )
830
+
831
+ def fail_write(*args, **kwargs):
832
+ raise OSError(28, "No space left on device")
833
+
834
+ monkeypatch.setattr(manager, "_write", fail_write)
835
+ with pytest.raises(OSError) as excinfo:
836
+ manager.activate("secret-key")
837
+ assert excinfo.value.strerror == "No space left on device"
838
+
839
+
840
+ def test_clear_propagates_oserror(tmp_path):
841
+ # A directory cannot be unlinked with Path.unlink(), which raises OSError
842
+ # (IsADirectoryError/PermissionError depending on platform).
843
+ manager = LicenseManager(
844
+ StaticValidator(LicenseStatus(True, "pro", "static license accepted")),
845
+ tmp_path,
846
+ clock=FakeClock(),
847
+ )
848
+ with pytest.raises(OSError):
849
+ manager.clear()
850
+
851
+
813
852
  def test_key_never_appears_in_reasons():
814
853
  clock = FakeClock()
815
854
  scenarios = [
@@ -0,0 +1,98 @@
1
+ """Client disconnects must never leave a reservation open."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from decimal import Decimal
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from agentcap.estimate import (
12
+ apply_input_safety_factor,
13
+ estimate_input_tokens,
14
+ )
15
+ from agentcap.ledger import Caps, Ledger
16
+ from agentcap.pricing import cost as pricing_cost
17
+ from agentcap.pricing import load_pricing
18
+ from agentcap.proxy import ProxyConfig, create_app
19
+
20
+ from proxy_support import D, costs, openai_chat_body
21
+
22
+ STORE = load_pricing()
23
+ CHAT_URL = "/openai/v1/chat/completions"
24
+
25
+
26
+ class SlowTransport(httpx.AsyncBaseTransport):
27
+ """Blocks after the reservation is made, before upstream headers arrive."""
28
+
29
+ def __init__(self) -> None:
30
+ self.started = asyncio.Event()
31
+ self.release = asyncio.Event()
32
+
33
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
34
+ self.started.set()
35
+ await self.release.wait()
36
+ return httpx.Response(
37
+ 200,
38
+ json={
39
+ "choices": [],
40
+ "usage": {"prompt_tokens": 1000, "completion_tokens": 500},
41
+ },
42
+ )
43
+
44
+
45
+ def _expected_input_only(body: dict) -> Decimal:
46
+ price = STORE.get("openai/gpt-4o-mini")
47
+ input_tokens = apply_input_safety_factor(
48
+ estimate_input_tokens(body), Decimal("1.25")
49
+ )
50
+ return pricing_cost(price, input_tokens, 0, 0)
51
+
52
+
53
+ @pytest.mark.parametrize("stream", [False, True])
54
+ def test_cancelled_request_settles_input_only_and_leaves_no_reservation(
55
+ tmp_path, stream
56
+ ):
57
+ body = openai_chat_body(max_completion_tokens=200, stream=stream)
58
+ expected = _expected_input_only(body)
59
+
60
+ async def scenario():
61
+ transport = SlowTransport()
62
+ ledger = Ledger(tmp_path / f"cancel-{stream}.db")
63
+ events: list[dict] = []
64
+ config = ProxyConfig(run_id="run-1", caps=Caps(run_usd=D("1")))
65
+ app = create_app(
66
+ config,
67
+ ledger,
68
+ upstream_transport=transport,
69
+ events=events.append,
70
+ )
71
+ proxy = app.state.proxy
72
+ async with httpx.AsyncClient(
73
+ transport=httpx.ASGITransport(app=app), base_url="http://proxy"
74
+ ) as client:
75
+ task = asyncio.create_task(client.post(CHAT_URL, json=body))
76
+ await asyncio.wait_for(transport.started.wait(), timeout=5)
77
+ task.cancel()
78
+ with pytest.raises(asyncio.CancelledError):
79
+ await task
80
+ await app.state.http_client.aclose()
81
+ return ledger, events, proxy
82
+
83
+ ledger, events, proxy = asyncio.run(scenario())
84
+
85
+ summary = ledger.summary("run-1")
86
+ assert summary["open"] == 0
87
+ assert summary["settled"] == 1
88
+ assert summary["actual_usd"] == expected
89
+ assert ledger.spent("run-1") == expected
90
+
91
+ disconnect = costs(events, "client_disconnect")
92
+ assert len(disconnect) == 1
93
+ assert Decimal(disconnect[0]["charged_usd"]) == expected
94
+ assert Decimal(disconnect[0]["reserved_usd"]) >= expected
95
+
96
+ # The input-only charge is far below the cap: no false kill.
97
+ assert proxy.killed is False
98
+ assert "cap_exceeded" not in [event["type"] for event in events]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import io
6
6
  import json
7
+ import os
7
8
  import sys
8
9
  import time
9
10
  from decimal import Decimal
@@ -372,3 +373,56 @@ def test_cap_from_env(monkeypatch):
372
373
  assert cap_from_env() == Decimal("0.25")
373
374
  monkeypatch.setenv("AGENTCAP_CAP", "not-money")
374
375
  assert cap_from_env() == Decimal("5")
376
+
377
+
378
+ def _process_alive(pid: int) -> bool:
379
+ try:
380
+ os.kill(pid, 0)
381
+ except ProcessLookupError:
382
+ return False
383
+ except PermissionError:
384
+ return True
385
+ # A zombie is already dead for our purposes; the important thing is that
386
+ # it no longer runs (and will be reaped by init/subreaper).
387
+ try:
388
+ stat = Path(f"/proc/{pid}/stat").read_text()
389
+ except OSError:
390
+ return True
391
+ return stat.split()[2] != "Z"
392
+
393
+
394
+ @pytest.mark.skipif(os.name == "nt", reason="process groups are POSIX-only")
395
+ def test_terminate_kills_grandchild_after_leader_already_exited(tmp_path):
396
+ """The saved PGID must still kill descendants when the leader is gone."""
397
+
398
+ pidfile = tmp_path / "grandchild.pid"
399
+ leader = (
400
+ "import subprocess, sys, time\n"
401
+ "child = subprocess.Popen([\n"
402
+ " sys.executable, '-c',\n"
403
+ " 'import signal, time; "
404
+ "signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)',\n"
405
+ "])\n"
406
+ f"open({str(pidfile)!r}, 'w').write(str(child.pid))\n"
407
+ "time.sleep(0.3)\n"
408
+ "sys.exit(0)\n"
409
+ )
410
+ runner, _ = _runner(tmp_path, [sys.executable, "-c", "pass"])
411
+ runner.child = runner._spawn([sys.executable, "-c", leader], env=dict(os.environ))
412
+
413
+ runner.child.wait(timeout=5)
414
+ assert runner.child.poll() is not None
415
+ assert runner._pgid is not None
416
+
417
+ deadline = time.monotonic() + 5
418
+ while time.monotonic() < deadline and not pidfile.exists():
419
+ time.sleep(0.02)
420
+ grandchild = int(pidfile.read_text())
421
+ assert _process_alive(grandchild)
422
+
423
+ runner._terminate()
424
+
425
+ deadline = time.monotonic() + 5
426
+ while time.monotonic() < deadline and _process_alive(grandchild):
427
+ time.sleep(0.02)
428
+ assert not _process_alive(grandchild)
File without changes
File without changes