failproofai 1.0.9-beta.2 → 1.0.10-beta.1
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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +1 -1
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ox4w55._.js → [root-of-the-server]__0a2-9ht._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0n6zrjv._.js → _0krq5f0._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1pa3k5k._.js → _1n-mym7._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
- package/.next/standalone/.next/static/chunks/0s4cko_weh6pi.js +1 -0
- package/.next/standalone/.next/static/chunks/0yclxmfppc_8e.js +1 -0
- package/.next/standalone/.next/static/chunks/{30z-240_lrczt.js → 1-k37ijadiv9h.js} +1 -1
- package/.next/standalone/.next/static/chunks/{28cmd5molgwa3.js → 18reoqya39rov.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2b59i-n9l-4of.js → 2cvvec76j9bwq.js} +1 -1
- package/.next/standalone/.next/static/chunks/2o9-3ff26v4yd.js +1 -0
- package/.next/standalone/.next/static/chunks/3q8vesnwk_r0i.js +1 -0
- package/.next/standalone/.next/static/chunks/3tj1zi4xv-bll.js +1 -0
- package/.next/standalone/.next/static/chunks/3vk6jdkxvb7yk.js +6 -0
- package/.next/standalone/.next/static/chunks/{1srecm9k-z_i1.js → 3z7mx6me_j7d3.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2hg_bp8z_vm2g.js → 3ztf2h_fv6_t9.js} +1 -1
- package/.next/standalone/fp-cloud-cli/fp_cli/app.py +4 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +29 -4
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +57 -14
- package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +17 -3
- package/.next/standalone/fp-cloud-cli/tests/test_request_id.py +111 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +3 -3
- package/.next/standalone/hermes-plugin/README.md +83 -10
- package/.next/standalone/hermes-plugin/__init__.py +762 -28
- package/.next/standalone/hermes-plugin/client.py +4 -2
- package/.next/standalone/hermes-plugin/ledger.py +9 -3
- package/.next/standalone/hermes-plugin/plugin.yaml +2 -2
- package/.next/standalone/package.json +10 -10
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +8 -1
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +82 -7
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +23 -4
- package/.next/standalone/sdk/python/tests/test_evaluator_request_id.py +197 -0
- package/.next/standalone/sdk/python/uv.lock +9 -9
- package/.next/standalone/sdk/typescript/CHANGELOG.md +39 -0
- package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package-lock.json +3 -3
- package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package.json +1 -1
- package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package-lock.json +16 -113
- package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package.json +3 -1
- package/.next/standalone/server.js +1 -1
- package/dist/cli.mjs +235 -117
- package/dist/worker.mjs +5 -2
- package/hermes-plugin/README.md +83 -10
- package/hermes-plugin/__init__.py +762 -28
- package/hermes-plugin/client.py +4 -2
- package/hermes-plugin/ledger.py +9 -3
- package/hermes-plugin/plugin.yaml +2 -2
- package/package.json +10 -10
- package/src/hooks/daemon-service.ts +14 -1
- package/src/hooks/integrations.ts +108 -26
- package/src/hooks/safe-config-write.ts +80 -7
- package/src/hooks/types.ts +7 -0
- package/.next/standalone/.next/static/chunks/0h--izcpk__m9.js +0 -1
- package/.next/standalone/.next/static/chunks/2k5vhij_w1g82.js +0 -1
- package/.next/standalone/.next/static/chunks/2o1fy-waqn1he.js +0 -6
- package/.next/standalone/.next/static/chunks/38gpf5tb_rd0m.js +0 -1
- package/.next/standalone/.next/static/chunks/3nyf50dub1vuk.js +0 -1
- package/.next/standalone/.next/static/chunks/42-l25bbfo0ec.js +0 -1
- /package/.next/standalone/.next/static/{D1BjwOkHNPetPo47DhV4_ → OJTzlkhxVSWnwhlF2Q7m3}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{D1BjwOkHNPetPo47DhV4_ → OJTzlkhxVSWnwhlF2Q7m3}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{D1BjwOkHNPetPo47DhV4_ → OJTzlkhxVSWnwhlF2Q7m3}/_ssgManifest.js +0 -0
|
@@ -19,6 +19,7 @@ Two auth modes, and they never mix:
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import json as _json
|
|
22
|
+
import re
|
|
22
23
|
import uuid
|
|
23
24
|
from dataclasses import dataclass
|
|
24
25
|
from enum import Enum
|
|
@@ -264,8 +265,43 @@ def _path(ctx: ClientContext, path: str) -> str:
|
|
|
264
265
|
return path
|
|
265
266
|
|
|
266
267
|
|
|
268
|
+
# A request id as the server may echo it: our own 32 hex, or a dashed UUID from
|
|
269
|
+
# an older server. Anything else is not put on an error a person will paste.
|
|
270
|
+
_SANE_REQUEST_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _new_request_id() -> str:
|
|
274
|
+
"""One id per HTTP request — a W3C trace id, the shape the server keeps as-is."""
|
|
275
|
+
return uuid.uuid4().hex
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _request_id_of(response: httpx.Response) -> Optional[str]:
|
|
279
|
+
"""The failed request's id: the server's echo, else the one this CLI sent.
|
|
280
|
+
|
|
281
|
+
The server echoes ours back, so the two normally agree. The fallback matters
|
|
282
|
+
when something in front of the API answered instead — a proxy, a front door —
|
|
283
|
+
and a ref is still worth printing: our id is in the dashboard's access logs.
|
|
284
|
+
"""
|
|
285
|
+
echoed = (response.headers.get("x-request-id") or "").strip()
|
|
286
|
+
if _SANE_REQUEST_ID.match(echoed):
|
|
287
|
+
return echoed
|
|
288
|
+
try:
|
|
289
|
+
return response.request.headers.get("x-request-id")
|
|
290
|
+
except RuntimeError: # a Response built without a request (tests)
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _sent_request_id(exc: httpx.RequestError) -> Optional[str]:
|
|
295
|
+
"""The id of a request that never got an answer."""
|
|
296
|
+
try:
|
|
297
|
+
return exc.request.headers.get("x-request-id")
|
|
298
|
+
except RuntimeError:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
267
302
|
def _client(ctx: ClientContext, *, timeout: Any = None) -> httpx.Client:
|
|
268
|
-
|
|
303
|
+
# One id per client, and every call site opens a fresh client per request.
|
|
304
|
+
headers = {"x-request-id": _new_request_id()}
|
|
269
305
|
cookies = None
|
|
270
306
|
# Bearer XOR cookie — an `else`, never two independent `if`s. Sending both
|
|
271
307
|
# would hand a human's `ae_session` to `/v1` alongside the key, and every
|
|
@@ -355,7 +391,7 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
|
|
|
355
391
|
"the dashboard's login page, so it landed on the web app instead of "
|
|
356
392
|
"the API.",
|
|
357
393
|
status=response.status_code,
|
|
358
|
-
request_id=response
|
|
394
|
+
request_id=_request_id_of(response),
|
|
359
395
|
hint="point --base-url at the server itself, e.g. http://localhost:8080",
|
|
360
396
|
)
|
|
361
397
|
# Session mode is deliberately left alone for the /login case: that 3xx is
|
|
@@ -375,22 +411,23 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
|
|
|
375
411
|
"The request was redirected and did not reach the API, so it had no "
|
|
376
412
|
"effect. Nothing was changed.",
|
|
377
413
|
status=response.status_code,
|
|
378
|
-
request_id=response
|
|
414
|
+
request_id=_request_id_of(response),
|
|
379
415
|
hint=(
|
|
380
416
|
"check --base-url: a redirect here usually means http:// where the "
|
|
381
417
|
"server wants https://, or a front door in front of the API"
|
|
382
418
|
),
|
|
383
419
|
)
|
|
384
420
|
return
|
|
385
|
-
request_id = response
|
|
421
|
+
request_id = _request_id_of(response)
|
|
386
422
|
message = _extract_error(response)
|
|
387
423
|
if response.status_code == 401:
|
|
388
424
|
if key_mode:
|
|
389
425
|
raise AuthError(
|
|
390
426
|
"The API key was rejected. It may be revoked, mistyped, or issued by a "
|
|
391
|
-
"different deployment than --base-url points at."
|
|
427
|
+
"different deployment than --base-url points at.",
|
|
428
|
+
request_id=request_id,
|
|
392
429
|
)
|
|
393
|
-
raise AuthError("Session expired or not logged in. Run fp login.")
|
|
430
|
+
raise AuthError("Session expired or not logged in. Run fp login.", request_id=request_id)
|
|
394
431
|
if response.status_code == 403:
|
|
395
432
|
needed = _required_permission(response)
|
|
396
433
|
if key_mode:
|
|
@@ -406,10 +443,13 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
|
|
|
406
443
|
raise ForbiddenError(
|
|
407
444
|
f"{what}, or it cannot act for this org — the server answers 403 for both.",
|
|
408
445
|
hint="check the key's grants, and the org you targeted with --org / FP_ORG",
|
|
446
|
+
request_id=request_id,
|
|
409
447
|
)
|
|
410
448
|
if needed:
|
|
411
|
-
raise ForbiddenError(f"you don't have the {needed} permission")
|
|
412
|
-
raise ForbiddenError(
|
|
449
|
+
raise ForbiddenError(f"you don't have the {needed} permission", request_id=request_id)
|
|
450
|
+
raise ForbiddenError(
|
|
451
|
+
message or "you don't have permission for this action", request_id=request_id
|
|
452
|
+
)
|
|
413
453
|
if response.status_code == 404:
|
|
414
454
|
# In key mode a 404 has TWO very different causes, and the wrong reading
|
|
415
455
|
# sends people hunting for a server bug that isn't there:
|
|
@@ -434,7 +474,7 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
|
|
|
434
474
|
request_id=request_id,
|
|
435
475
|
hint="point --base-url at the server itself, e.g. http://localhost:8080",
|
|
436
476
|
)
|
|
437
|
-
raise NotFoundError(message or "Not found.")
|
|
477
|
+
raise NotFoundError(message or "Not found.", request_id=request_id)
|
|
438
478
|
if response.status_code == 429:
|
|
439
479
|
retry_after = response.headers.get("retry-after")
|
|
440
480
|
wait = f" Retry after {retry_after}s." if retry_after else " Please wait a moment and try again."
|
|
@@ -458,7 +498,8 @@ def _get_json(ctx: ClientContext, path: str, params: Optional[Dict[str, Any]] =
|
|
|
458
498
|
response = client.get(url, params=clean)
|
|
459
499
|
except httpx.RequestError as exc:
|
|
460
500
|
raise NetworkError(
|
|
461
|
-
f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
|
|
501
|
+
f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}",
|
|
502
|
+
request_id=_sent_request_id(exc),
|
|
462
503
|
)
|
|
463
504
|
_raise_for_status(response, ctx)
|
|
464
505
|
# A 2xx with an empty or non-JSON body is anomalous for a read (e.g. a proxy or
|
|
@@ -470,7 +511,7 @@ def _get_json(ctx: ClientContext, path: str, params: Optional[Dict[str, Any]] =
|
|
|
470
511
|
raise ApiError(
|
|
471
512
|
"The dashboard returned a malformed (non-JSON) response.",
|
|
472
513
|
status=response.status_code,
|
|
473
|
-
request_id=response
|
|
514
|
+
request_id=_request_id_of(response),
|
|
474
515
|
)
|
|
475
516
|
|
|
476
517
|
|
|
@@ -495,7 +536,8 @@ def _request_json(
|
|
|
495
536
|
response = client.request(method, url, json=json_body, params=clean)
|
|
496
537
|
except httpx.RequestError as exc:
|
|
497
538
|
raise NetworkError(
|
|
498
|
-
f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
|
|
539
|
+
f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}",
|
|
540
|
+
request_id=_sent_request_id(exc),
|
|
499
541
|
)
|
|
500
542
|
_raise_for_status(response, ctx)
|
|
501
543
|
# A genuinely empty body (204, or a 200 with no content) is a legitimate
|
|
@@ -514,7 +556,7 @@ def _request_json(
|
|
|
514
556
|
"The dashboard returned a malformed (non-JSON) response, so the request "
|
|
515
557
|
"may not have been applied.",
|
|
516
558
|
status=response.status_code,
|
|
517
|
-
request_id=response
|
|
559
|
+
request_id=_request_id_of(response),
|
|
518
560
|
)
|
|
519
561
|
|
|
520
562
|
|
|
@@ -575,7 +617,8 @@ def org_is_accessible(ctx: ClientContext, slug: str) -> bool:
|
|
|
575
617
|
response = client.get(_path(probe, "/api/access-granters"))
|
|
576
618
|
except httpx.RequestError as exc:
|
|
577
619
|
raise NetworkError(
|
|
578
|
-
f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
|
|
620
|
+
f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}",
|
|
621
|
+
request_id=_sent_request_id(exc),
|
|
579
622
|
)
|
|
580
623
|
if response.status_code == 200:
|
|
581
624
|
return True
|
|
@@ -26,9 +26,24 @@ class FpCliError(click.ClickException):
|
|
|
26
26
|
|
|
27
27
|
exit_code = 1
|
|
28
28
|
|
|
29
|
-
def __init__(
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
hint: Optional[str] = None,
|
|
34
|
+
request_id: Optional[str] = None,
|
|
35
|
+
) -> None:
|
|
30
36
|
super().__init__(message)
|
|
31
37
|
self.hint = hint
|
|
38
|
+
# The id of the HTTP request that failed — the server's echo, else the
|
|
39
|
+
# one this CLI sent. On every typed error, not only ApiError, so a 403 or
|
|
40
|
+
# a 404 is as traceable in the server's logs as a 500.
|
|
41
|
+
self.request_id = request_id
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ref(self) -> Optional[str]:
|
|
45
|
+
"""What a person reads and pastes: the first 8 chars of the request id."""
|
|
46
|
+
return self.request_id[:8] if self.request_id else None
|
|
32
47
|
|
|
33
48
|
|
|
34
49
|
class KeyModeUnsupportedError(FpCliError):
|
|
@@ -85,9 +100,8 @@ class ApiError(FpCliError):
|
|
|
85
100
|
request_id: Optional[str] = None,
|
|
86
101
|
hint: Optional[str] = None,
|
|
87
102
|
) -> None:
|
|
88
|
-
super().__init__(message, hint=hint)
|
|
103
|
+
super().__init__(message, hint=hint, request_id=request_id)
|
|
89
104
|
self.status = status
|
|
90
|
-
self.request_id = request_id
|
|
91
105
|
|
|
92
106
|
def format_message(self) -> str:
|
|
93
107
|
parts = [self.message]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Every CLI request carries its own id, and every failure names it.
|
|
2
|
+
|
|
3
|
+
A customer pastes `ref 4bf92f35`; support finds every server log line of that
|
|
4
|
+
request with one query. That only works if (a) each HTTP request has a distinct
|
|
5
|
+
id, (b) every typed error — not just ApiError — carries it, and (c) the id is
|
|
6
|
+
still there when something in front of the API answered without echoing one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
import respx
|
|
17
|
+
|
|
18
|
+
from fp_cli import client as api
|
|
19
|
+
from fp_cli.app import app
|
|
20
|
+
from fp_cli.client import ClientContext
|
|
21
|
+
from fp_cli.errors import ApiError, AuthError, ForbiddenError, NetworkError, NotFoundError
|
|
22
|
+
|
|
23
|
+
BASE = "http://dash.test"
|
|
24
|
+
TRACE_ID = re.compile(r"^[0-9a-f]{32}$")
|
|
25
|
+
SERVER_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ctx() -> ClientContext:
|
|
29
|
+
return ClientContext(base_url=BASE, token="tok")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@respx.mock
|
|
33
|
+
def test_each_request_gets_its_own_trace_id():
|
|
34
|
+
route = respx.get(f"{BASE}/api/sessions").mock(
|
|
35
|
+
return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None})
|
|
36
|
+
)
|
|
37
|
+
api.list_sessions(ctx())
|
|
38
|
+
api.list_sessions(ctx())
|
|
39
|
+
ids = [call.request.headers["x-request-id"] for call in route.calls]
|
|
40
|
+
assert len(ids) == 2
|
|
41
|
+
assert all(TRACE_ID.match(i) for i in ids), ids
|
|
42
|
+
assert ids[0] != ids[1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize(
|
|
46
|
+
("status", "error_type"),
|
|
47
|
+
[(401, AuthError), (403, ForbiddenError), (404, NotFoundError), (500, ApiError)],
|
|
48
|
+
)
|
|
49
|
+
@respx.mock
|
|
50
|
+
def test_every_typed_error_carries_the_servers_request_id(status, error_type):
|
|
51
|
+
respx.get(f"{BASE}/api/sessions").mock(
|
|
52
|
+
return_value=httpx.Response(status, json={"error": "x"}, headers={"x-request-id": SERVER_ID})
|
|
53
|
+
)
|
|
54
|
+
with pytest.raises(error_type) as excinfo:
|
|
55
|
+
api.list_sessions(ctx())
|
|
56
|
+
assert excinfo.value.request_id == SERVER_ID
|
|
57
|
+
assert excinfo.value.ref == SERVER_ID[:8]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@respx.mock
|
|
61
|
+
def test_without_an_echo_the_error_names_the_id_we_sent():
|
|
62
|
+
# A proxy or front door answered: no x-request-id on the response. The id we
|
|
63
|
+
# sent is still the one in the dashboard's access logs.
|
|
64
|
+
route = respx.get(f"{BASE}/api/sessions").mock(return_value=httpx.Response(502, text="bad gateway"))
|
|
65
|
+
with pytest.raises(ApiError) as excinfo:
|
|
66
|
+
api.list_sessions(ctx())
|
|
67
|
+
sent = route.calls.last.request.headers["x-request-id"]
|
|
68
|
+
assert excinfo.value.request_id == sent
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@respx.mock
|
|
72
|
+
def test_an_unsane_echo_is_not_put_on_the_error():
|
|
73
|
+
route = respx.get(f"{BASE}/api/sessions").mock(
|
|
74
|
+
return_value=httpx.Response(500, json={"error": "x"}, headers={"x-request-id": "a b c"})
|
|
75
|
+
)
|
|
76
|
+
with pytest.raises(ApiError) as excinfo:
|
|
77
|
+
api.list_sessions(ctx())
|
|
78
|
+
assert excinfo.value.request_id == route.calls.last.request.headers["x-request-id"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@respx.mock
|
|
82
|
+
def test_a_network_error_names_the_id_it_sent():
|
|
83
|
+
route = respx.get(f"{BASE}/api/sessions").mock(side_effect=httpx.ConnectError("refused"))
|
|
84
|
+
with pytest.raises(NetworkError) as excinfo:
|
|
85
|
+
api.list_sessions(ctx())
|
|
86
|
+
assert excinfo.value.request_id == route.calls.last.request.headers["x-request-id"]
|
|
87
|
+
# Exit code contract unchanged.
|
|
88
|
+
assert excinfo.value.exit_code == 3
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@respx.mock
|
|
92
|
+
def test_the_human_error_line_shows_the_ref(logged_in, runner):
|
|
93
|
+
respx.get(f"{BASE}/api/sessions").mock(
|
|
94
|
+
return_value=httpx.Response(403, json={"error": "forbidden"}, headers={"x-request-id": SERVER_ID})
|
|
95
|
+
)
|
|
96
|
+
result = runner.invoke(app, ["sessions"])
|
|
97
|
+
assert result.exit_code == 5
|
|
98
|
+
assert f"ref {SERVER_ID[:8]}" in result.stderr
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@respx.mock
|
|
102
|
+
def test_the_json_envelope_carries_the_full_id_for_non_api_errors(logged_in, runner):
|
|
103
|
+
respx.get(f"{BASE}/api/sessions").mock(
|
|
104
|
+
return_value=httpx.Response(403, json={"error": "forbidden"}, headers={"x-request-id": SERVER_ID})
|
|
105
|
+
)
|
|
106
|
+
result = runner.invoke(app, ["--json", "sessions"])
|
|
107
|
+
assert result.exit_code == 5
|
|
108
|
+
data = json.loads(result.stdout)
|
|
109
|
+
assert data["request_id"] == SERVER_ID
|
|
110
|
+
# The ref is for humans; the envelope's message stays clean.
|
|
111
|
+
assert "ref " not in data["error"]
|
|
@@ -541,9 +541,9 @@ wheels = [
|
|
|
541
541
|
|
|
542
542
|
[[package]]
|
|
543
543
|
name = "urllib3"
|
|
544
|
-
version = "2.
|
|
544
|
+
version = "2.8.0"
|
|
545
545
|
source = { registry = "https://pypi.org/simple" }
|
|
546
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
546
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
|
547
547
|
wheels = [
|
|
548
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
548
|
+
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
|
549
549
|
]
|
|
@@ -7,6 +7,11 @@ translates structured verdicts into Hermes-native hook behavior.
|
|
|
7
7
|
Validated contract: Hermes 0.21.3 at
|
|
8
8
|
`4d55ca91656ac5f83e1506679b7f81e0238e5e16`. The plugin uses only documented
|
|
9
9
|
manifest v2 fields, `PluginContext` methods/state, and keyword hook payloads.
|
|
10
|
+
Hermes 0.20.0 is also supported: its `PluginContext` has no `get_config` or
|
|
11
|
+
`state` (both arrived in 0.20.1), so the plugin reads the same
|
|
12
|
+
`plugins.entries.failproofai` settings through Hermes' config loader and keeps
|
|
13
|
+
its ledger in the directory `state.data_dir` would name, which a later Hermes
|
|
14
|
+
upgrade reuses.
|
|
10
15
|
|
|
11
16
|
## Installation
|
|
12
17
|
|
|
@@ -40,6 +45,14 @@ Legacy FailproofAI shell hooks are removed during migration. Operator-owned
|
|
|
40
45
|
hooks and unrelated plugin settings are preserved. No dashboard deployment or
|
|
41
46
|
backtest is part of installation.
|
|
42
47
|
|
|
48
|
+
## Supported Hermes versions
|
|
49
|
+
|
|
50
|
+
Hermes 0.20.0 and later. Tested on 0.20.0 (v2026.8.3), 0.20.1 (v2026.8.13),
|
|
51
|
+
0.20.6 (v2026.8.27), 0.21.0 (v2026.8.31), 0.21.3 (v2026.9.14), 0.21.5
|
|
52
|
+
(v2026.9.24, the latest release) and upstream main (2026-10-05).
|
|
53
|
+
Hermes 0.20.0 has no `ctx.get_config` or `ctx.state`; the plugin detects that and
|
|
54
|
+
reads the same settings and state directory another way.
|
|
55
|
+
|
|
43
56
|
## Runtime path
|
|
44
57
|
|
|
45
58
|
```text
|
|
@@ -49,6 +62,8 @@ Hermes pre_tool_call
|
|
|
49
62
|
-> warm TypeScript policy worker
|
|
50
63
|
-> structured allow | deny | instruct verdict
|
|
51
64
|
-> Hermes-native return value
|
|
65
|
+
Hermes tool_execution middleware (wraps the real call)
|
|
66
|
+
-> instruct reminder placed at the start of the call's own result
|
|
52
67
|
```
|
|
53
68
|
|
|
54
69
|
There is no cloud request and no new CLI process in the tool-call path.
|
|
@@ -57,16 +72,67 @@ There is no cloud request and no new CLI process in the tool-call path.
|
|
|
57
72
|
|
|
58
73
|
- `allow`: return no directive; Hermes runs the tool.
|
|
59
74
|
- `deny`: always return `{"action":"block","message":"..."}`.
|
|
60
|
-
- `instruct`:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
- `instruct`: the call runs, and the reminder is placed at the **start** of its
|
|
76
|
+
result, so it survives the 1,500-character preview Hermes keeps of a large
|
|
77
|
+
result:
|
|
78
|
+
- a JSON object result gets `"failproof_policy_reminder"` as its first key
|
|
79
|
+
(the rest is unchanged, so `json.loads` consumers such as `execute_code`
|
|
80
|
+
scripts keep working). A field of that name the tool returned itself is
|
|
81
|
+
moved to `"tool_failproof_policy_reminder"`, never merged into the
|
|
82
|
+
reminder;
|
|
83
|
+
- other text gets a leading `[FailproofAI policy reminder (<policies>)] ...`
|
|
84
|
+
line (an `Error...` result keeps that prefix and gets the line at its end);
|
|
85
|
+
- a list of content blocks or a multimodal envelope gets a leading text block,
|
|
86
|
+
leaving image blocks intact.
|
|
87
|
+
|
|
88
|
+
Tool calls made by an `execute_code` script hand their reminder to the outer
|
|
89
|
+
`execute_code` result, which is the one the model reads, and the script gets
|
|
90
|
+
its own result untouched, so nothing it prints, writes or sends carries the
|
|
91
|
+
reminder. (With no open outer call to carry it, the inner result keeps it.)
|
|
92
|
+
A result Hermes reports as blocked (`{"error": ...}` only) is never
|
|
93
|
+
annotated. This uses Hermes' `tool_execution` middleware
|
|
94
|
+
(`ctx.register_middleware`, present unchanged in 0.20.0, 0.21.x and main).
|
|
95
|
+
|
|
96
|
+
A policy's reminder (same policy and reason) is attached once per Hermes
|
|
97
|
+
session + turn; a later turn gets it again. When the full reminder would take
|
|
98
|
+
the result past 7,500 characters it is shortened to the policy names and the
|
|
99
|
+
first 160 characters of the reason, so it never pushes a result over Hermes'
|
|
100
|
+
8,000-character persistence threshold and never fills the 1,500-character
|
|
101
|
+
preview of a result already past it.
|
|
102
|
+
|
|
103
|
+
`instruct` therefore no longer stops the action first on Hermes: use `deny`
|
|
104
|
+
when an action must not happen at all.
|
|
105
|
+
|
|
106
|
+
Where Hermes offers no `tool_execution` middleware, `instruct` falls back to
|
|
107
|
+
holding the call once: persist delivery state, return model-visible
|
|
108
|
+
`FailproofAI policy guidance (...)` ("apply it, then continue; repeating the
|
|
109
|
+
same call is allowed"), keep the same API request held, then allow a later API
|
|
110
|
+
iteration. That fallback's scope is profile + session + task + turn + policy
|
|
111
|
+
fingerprint — not the tool, so moving the same work to another tool (for
|
|
112
|
+
example `execute_code`) is not held again.
|
|
113
|
+
The persistent SQLite ledger lives below Hermes' profile-scoped plugin data
|
|
114
|
+
directory. Two distinct instruction interruptions are allowed per turn by
|
|
115
|
+
default; after that, advisory instructions fail open so they cannot create an
|
|
116
|
+
infinite retry loop. A real deny is never bypassed by the ledger.
|
|
117
|
+
|
|
118
|
+
Every model turn also receives a short protocol note (`pre_llm_call`): a
|
|
119
|
+
`failproof_policy_reminder` is an operator rule added by FailproofAI (not tool
|
|
120
|
+
data) about a call that ran normally and is never to be quoted, repeated or
|
|
121
|
+
mentioned in replies, messages or files, a FailproofAI block means that action
|
|
122
|
+
must not run by any route, and `FailproofAI policy guidance` held a call only once.
|
|
123
|
+
|
|
124
|
+
No hook raises: `pre_tool_call` blocks on any internal error (Hermes 0.20.0 and
|
|
125
|
+
0.21.x would treat a raised exception as allow), observer hooks log and return,
|
|
126
|
+
and the middleware falls back to the plain result. One evaluation, connect
|
|
127
|
+
included, is capped at 25 seconds (`evaluation_timeout_ms`, default 12000):
|
|
128
|
+
Hermes 0.21.x abandons a `pre_tool_call` after 30 seconds and then blocks every
|
|
129
|
+
call for 60. Observer hooks wait at most 2 seconds, so against a hung daemon a
|
|
130
|
+
blocked call costs the evaluation deadline plus 2 seconds, not twice the
|
|
131
|
+
deadline.
|
|
132
|
+
|
|
133
|
+
Tool names are canonicalized in the daemon: Hermes 0.21's `process_manage`,
|
|
134
|
+
`cronjob_manage` and `todo_list` match policies written for 0.20's `process`,
|
|
135
|
+
`cronjob` and `todo`.
|
|
70
136
|
|
|
71
137
|
## Configuration
|
|
72
138
|
|
|
@@ -137,6 +203,13 @@ hermes plugins doctor ~/.hermes/plugins/failproofai --ci
|
|
|
137
203
|
hermes logs --level WARNING
|
|
138
204
|
```
|
|
139
205
|
|
|
206
|
+
Each `register()` writes `heartbeat.json` beside `instructions.db` in the
|
|
207
|
+
profile's plugin data directory (`plugin-data/agent-plugin-failproofai-5296f299/`):
|
|
208
|
+
pid, Hermes version, `HERMES_HOME`, profile, the plugin's real path and version,
|
|
209
|
+
the hooks and middleware Hermes accepted, `register_ok` and a timestamp. It is
|
|
210
|
+
the proof of what a running Hermes process actually loaded, which
|
|
211
|
+
`hermes plugins list` (config only) cannot show; Hermes' log gets one INFO line.
|
|
212
|
+
|
|
140
213
|
Use a temporary `HERMES_HOME` for development and compatibility tests. Remove
|
|
141
214
|
the integration with:
|
|
142
215
|
|