@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.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/CHANGELOG.md +122 -3
- package/README.md +6 -3
- package/SECURITY.md +4 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Operations digests: a service's own hourly and daily counts
|
|
2
|
+
|
|
3
|
+
The contract's DEC-0153 ([ops-digest.md at the pin](https://github.com/passioncode-ai/fabric-agent-contract/blob/93e7db164c7829e2783b4b7705c4113bb5201131/docs/specification/ops-digest.md))
|
|
4
|
+
asks every product to count its own operations log and keep two kinds of lines on its own machine: one
|
|
5
|
+
`ops.digest.hourly@1` line for each hour it observed itself running in, an empty one included, and one
|
|
6
|
+
`ops.digest.daily@1` line for each closed UTC day that has hourly lines. No model reads the log; the fold is
|
|
7
|
+
arithmetic. The kit's module is `fabric_ops_digest.py` and its Node twin `fabric-ops-digest.mjs` (FAA-206 part 2).
|
|
8
|
+
|
|
9
|
+
## Use it
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from fabric_ops_digest import OpsDigest, ops_digest_extension, outcome_of
|
|
13
|
+
|
|
14
|
+
digests = OpsDigest(paths["data"] / "digests") # a folder inside the data folder, made 0700
|
|
15
|
+
digests.start() # writes the hour the last run kept, folds missed days
|
|
16
|
+
digests.record("mcp.tools/call", "ok") # each record of the log, before the log samples it (OD-3)
|
|
17
|
+
digests.record("board.tick", outcome_of(word, {"timeout": "failed", "denied": "unknown"}))
|
|
18
|
+
digests.tick() # once a minute or so: an hour with a tick is written, empty if quiet
|
|
19
|
+
digests.flush() # now and then: writes a closed hour and a closed day
|
|
20
|
+
digests.stop() # at the drain: keeps the open hour for the next run
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Node: `new OpsDigest(dir)`, then `start()`, `record(op, outcome)`, `tick()`, `flush()`, `stop()`; `outcomeOf`,
|
|
24
|
+
`opsDigestExtension`.
|
|
25
|
+
|
|
26
|
+
- **Names (OD-2).** `op` names the operation, never a value of it: a lower-case letter, then letters, digits and `_`
|
|
27
|
+
in segments joined by `.`, `:`, `/` or `-`, at most 64 characters (`board.tick`, `mcp.tools/list`). A name the
|
|
28
|
+
grammar refuses, or one shaped like a credential or holding a home folder (FAC-SEM-167), raises `OpsDigestError` at
|
|
29
|
+
`record`, so no line every reader skips is ever written. The error names the place, never the value.
|
|
30
|
+
- **Outcomes (OD-3).** `ok` and `failed` count as themselves, every other word as `unknown`. A log with its own words
|
|
31
|
+
maps them with `outcome_of(word, mapping)` and publishes that map in its own documentation; a setup answer (a
|
|
32
|
+
missing slot, DEC-0149), a refusal or a skip is `unknown`, never `failed`, so an unfinished setup makes no rise.
|
|
33
|
+
- **The overflow name.** A day keeps at most 4095 named operations; the rest are counted under `ops.overflow` (pass
|
|
34
|
+
`overflow=` to choose another and publish it with the outcome map).
|
|
35
|
+
- **Hours (OD-1).** An hour is written when it closes. Hours the service did not observe (a laptop asleep) are never
|
|
36
|
+
filled. The hour the service stops in is kept in `kept-hour.json` and written at the next run, compared with the
|
|
37
|
+
hour before only when that one is the last written; a restart within the same hour resumes it, so no hour has two
|
|
38
|
+
lines.
|
|
39
|
+
- **Days (OD-5).** Each closed UTC day with hourly lines gets one daily line, at the next run if the service was not
|
|
40
|
+
running when the day closed (catch-up).
|
|
41
|
+
- **Files (OD-6).** `hourly-YYYY-MM.jsonl` and `daily-YYYY-MM.jsonl`, one JSON line each, files 0600 in a 0700
|
|
42
|
+
folder. `read_digests(folder, kind)` reads them as a reader does: a bad line skipped, the first line of an hour or a
|
|
43
|
+
day kept.
|
|
44
|
+
- **Rises (OD-4, OD-8).** A rise is at least 10 failures in an hour and at least twice the hour before's (none counts
|
|
45
|
+
as one); it is in the hourly line's `risen`. A service that shows findings shows each rise of its newest closed hour
|
|
46
|
+
with the operation, the failures and the hour before's, never as the cause of a failure.
|
|
47
|
+
- **Nothing but counts (OD-7).** No log line, path, identifier or text ever enters a digest.
|
|
48
|
+
|
|
49
|
+
## Tell a collector where the daily lines are (OD-9)
|
|
50
|
+
|
|
51
|
+
A local descriptor that names `paths.data` declares the folder under its `extensions`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
descriptor["extensions"] = ops_digest_extension([{"dir": "digests", "source": "app"}])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`ops_digest_extension` checks the grammar of `dir` (relative to the data folder, forward slashes, no `.`, `..`, `:`,
|
|
58
|
+
`~` or drive), home folders and credential shapes, and that each `dir` and `source` is named once. The contract's
|
|
59
|
+
FAC-SEM-168 (its caseless fold of `dir` among other things) stays the authority; a remote descriptor never carries the
|
|
60
|
+
key. A collector in the same OS account reads the daily lines and sends them on unchanged; who collects and how is
|
|
61
|
+
Project Observatory's, not the kit's.
|
|
62
|
+
|
|
63
|
+
## The retro after work
|
|
64
|
+
|
|
65
|
+
A host that hands sessions the `retro-after-work` obligation counts each ask as one record of the operation
|
|
66
|
+
`retro-after-work` (RW-4): `ok` when it saw a lesson or an `insight/1` line written, `failed` when such a write was
|
|
67
|
+
refused, `unknown` otherwise. working-with-fabric's hooks remind a Claude Code session; the error classes the hooks
|
|
68
|
+
would count wait on FAA-202.
|
|
69
|
+
|
|
70
|
+
## Checks
|
|
71
|
+
|
|
72
|
+
`fold_hour` and `fold_day` are the contract's reference folds, run in `test/test_fabric_ops_digest.py` and
|
|
73
|
+
`test/fabric-ops-digest.test.mjs` against the contract's vectors (`test/fixtures/ops-digest/vectors.json`);
|
|
74
|
+
`ops_digest_rules` is FAC-SEM-166 and FAC-SEM-167, run against the contract's positive and semantic fixtures
|
|
75
|
+
(`test/fixtures/ops-digest/rules.json`).
|
|
@@ -29,7 +29,7 @@ The person writes the descriptive part; `pack` fills in the rest.
|
|
|
29
29
|
| You write | `pack` writes |
|
|
30
30
|
|---|---|
|
|
31
31
|
| `schema` (`fabric-package/1`), `kind` (`agent` or `skill-set`), `id` (kebab-case, the plugin name), `version` (semver), `publisher` (a name, never a key), `licence` (SPDX) | `agentRef` = `urn:fabric:agent:<publisher>:<id>`, after `publisher` (an agent only; a hand-written one that differs is refused) |
|
|
32
|
-
| an agent: `resultSignal {name, says}`, `evals {suite, receipt {ranAt, suite, passed, failed, skipped?}}`, and as needed `provider`, `service`, `secrets[]` (slots: name and purpose, never a value), `requires`, `costPresets`, `state[]`, `dependencies[]` | `files[]`: every file but `fabric.package.json`, in UTF-16 order, each with `sha256:` and its hex digest |
|
|
32
|
+
| an agent: `resultSignal {name, says}`, `evals {suite, receipt {ranAt, suite, passed, failed, skipped?}}`, and as needed `provider`, `service`, `secrets[]` (slots: name and purpose, never a value), `requires`, `costPresets`, `state[]`, `dependencies[]`; an agent with no `provider` (a consumer agent, DEC-0071) must carry `skills` and `requires` with `requires.mcp`, which the schema refuses it without (FAA-190) | `files[]`: every file but `fabric.package.json`, in UTF-16 order, each with `sha256:` and its hex digest |
|
|
33
33
|
| a skill set: `layer`, `skills` | `digest` |
|
|
34
34
|
|
|
35
35
|
The evals receipt's `suite` is the SHA-256 of the suite **as packed**. When the suite changes, run it again and write that
|
|
@@ -66,8 +66,9 @@ file names needs an ignore line of its own at the new path); a link whose target
|
|
|
66
66
|
the way to it (an ancestor, or a folder a link above it led into) is removed, so the copy ends; a target that leaves
|
|
67
67
|
that tree or lies in what `.gitignore` keeps out, such as an instance folder in the checkout, is removed, since it is
|
|
68
68
|
the instance or another project. Source the agent shares from outside the tree (a monorepo's common code, linked or
|
|
69
|
-
imported by a relative path) is copied into the agent's tree
|
|
70
|
-
|
|
69
|
+
imported by a relative path) is copied into the agent's tree and committed before packing, and every import that
|
|
70
|
+
reached it by a relative path leaving the tree (`../../common/x`) is changed to the copied path, or the package
|
|
71
|
+
imports a path that is not in it (FAA-190). List each link removed and review it: a missing file is not seen by `pack` or `verify`. Leave out dependency
|
|
71
72
|
folders (`node_modules`, virtual environments, which hold install paths and binaries), what `.gitignore` keeps out as
|
|
72
73
|
instance (the real configuration, an instance folder in the checkout) and the files `pack` refuses by name (a
|
|
73
74
|
credential-free `.npmrc` that `.fabric-source-ignore` names for the check included). `pack` does not read
|
|
@@ -76,8 +77,9 @@ and a private `*.jwk` by their names: keep them in the instance.
|
|
|
76
77
|
|
|
77
78
|
It refuses to list what must not ship, and names each: a symlink, a special file, `.git`, `__pycache__`, `.DS_Store`,
|
|
78
79
|
`*.pyc`, and files that are credentials by name (`.env` and `.env.*` but `.env.example|sample|template`, `*.pem`, `*.key`,
|
|
79
|
-
`*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `id_rsa`-style keys, `.npmrc`, `.netrc`, `.pypirc`).
|
|
80
|
-
|
|
80
|
+
`*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `id_rsa`-style keys, `.npmrc`, `.netrc`, `.pypirc`). Pack a clean copy (above);
|
|
81
|
+
remove in place only litter (`__pycache__`, `.DS_Store`, `*.pyc`) and a credential file the instance keeps elsewhere, never
|
|
82
|
+
`.git`: a folder packed after deleting it would carry what `.gitignore` kept out, an instance folder among it (FAA-190). A member named twice in `fabric.package.json` is refused too. A folder swapped for a link while it is read is refused as a
|
|
81
83
|
symlink. The Python kit reads each folder through an open handle, so a swap cannot redirect it. The Node kit has no
|
|
82
84
|
such call: it checks each folder before listing it, after each file it reads and after listing it (FAA-91), and does not
|
|
83
85
|
see a swap made and undone between two of those checks. Pack from a folder only you can write to.
|
|
@@ -118,7 +118,7 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
|
|
|
118
118
|
DEC-0122 refuses the member. The kits build it with `a2a_surface()` / `a2aSurface()` (`fabric_a2a`), read it with
|
|
119
119
|
`a2a_view` / `a2aView`, and `build_well_known(a2a=...)` / `buildWellKnown({a2a})` refuse one that is not the
|
|
120
120
|
schema's, passed either way; `agent_card_findings` / `agentCardFindings` say what a public card must not carry
|
|
121
|
-
([surfaces and auth](surfaces-and-auth.md#a2a-for-remote-agents)).
|
|
121
|
+
([surfaces and auth](surfaces-and-auth.md#a2a-for-remote-agents)). Since 0.83.0 `fabric_a2a_server` / `fabric-a2a-server.mjs` serve the card and the A2A JSON-RPC binding ([A2A](a2a.md), FAA-10).
|
|
122
122
|
- `extensions` MAY carry the embedded dashboard declaration under
|
|
123
123
|
`https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1` (`fabric-dashboard/0.1`,
|
|
124
124
|
ED-9): `minWidth` 360…1280 (required), `appearance` ⊆ `scheme`, `theme`, `themes`, `kit`
|
|
@@ -230,23 +230,36 @@ and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's toke
|
|
|
230
230
|
on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID from PowerShell `GetOwnerSid` against `whoami /user` on Windows (SIDs, never names). Fail closed: everything but
|
|
231
231
|
`this-account` refuses, `unknown` included.
|
|
232
232
|
|
|
233
|
-
macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python, measured; a Homebrew Node is ad-hoc-signed too
|
|
233
|
+
macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python, measured; a Homebrew Node is ad-hoc-signed too) only its own sockets: sysctl lists this
|
|
234
234
|
process alone and netstat prints nothing. The Python calibration therefore also asks for a child process's listener,
|
|
235
235
|
and a restricted table is `unknown` with a reason beginning `table-restricted` (never another account's): DEC-0155 RT-1
|
|
236
236
|
reads so a netstat that prints nothing or only its header, one that fails or exits non-zero, a table holding no row of
|
|
237
237
|
another uid (sysctl) or of another process (netstat), and a macOS table without the reader's own connection (FAA-312;
|
|
238
238
|
Linux and Windows keep `no-row`). Under it `listener(port)` reads this account's listener as its own only on exactly
|
|
239
239
|
`127.0.0.1` (RT-4). `fabric_peer_owner.py listener <port>` and `fabric-peer-owner.mjs listener <port>` print the kind
|
|
240
|
-
(`--json` the whole owner; FAA-315).
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
is
|
|
240
|
+
(`--json` the whole owner; FAA-315).
|
|
241
|
+
|
|
242
|
+
Under a restricted table the kit asks this account's own processes, which the restriction leaves readable
|
|
243
|
+
(`fabric_socket_proof`, beside `fabric_peer_owner` in every kit install; FAA-292, FAA-272). A server end is this
|
|
244
|
+
account's when one of its processes holds the very socket that accepted the connection (DEC-0155 RT-2, the
|
|
245
|
+
accepted-socket proof; read for up to 0.5 s until the server has called `accept()`), and a listener when one of its
|
|
246
|
+
processes listens on exactly `127.0.0.1:<port>`. Python reads them through libproc (`proc_listpids`, `PROC_PIDLISTFDS`,
|
|
247
|
+
`PROC_PIDFDSOCKETINFO`, Project Observatory's `peer_owner` 1.3.1 reader, calibrated on a pair of its own) and through
|
|
248
|
+
`lsof` when libproc is unavailable; Node through `lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`, the exact
|
|
249
|
+
address pair and the numeric uid. The proof is "a process of this uid holds the descriptor": a socket another account
|
|
250
|
+
accepted and passed with SCM_RIGHTS to one of this account's processes reads as this account's, which `expect_pid`
|
|
251
|
+
closes; one socket several of its processes hold is proved with no pid (`several-processes`), which skips an
|
|
252
|
+
`expect_pid` check, as a Linux row does. It never answers another account's, root's or nobody's, a set-uid reader gets
|
|
253
|
+
none, and a project that vendors `fabric_peer_owner` alone answers as before. After it, a caller that started the
|
|
254
|
+
server itself may vouch with that pid: `lsof -p <pid>` asks the process, and a listener of this account's pid on the
|
|
255
|
+
port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node `checkedSocket({vouchPid})`), taken only
|
|
256
|
+
for a connection that reached `127.0.0.1` (one a caller spelled `::1` or `localhost` that reached `::1` is not; FAA-273);
|
|
257
|
+
the launchd installers vouch with their own job's pid. The Node layout probe (`check_dashboard_layout.mjs`) on such a
|
|
258
|
+
host reads this account's dashboard listener through the own-listener proof (`lsof -u`, FAA-272). What neither proves refuses (the probe, the hub client and `fabric_http`
|
|
259
|
+
included, FAA-272), and Apple's `/usr/bin/python3` sees the whole table (FAA-267). A vouch is weaker than RT-2: it
|
|
260
|
+
shows the pid listens on exactly `127.0.0.1` on the port, not that it accepted this connection. Since FAA-312 every
|
|
261
|
+
vouch follows DEC-0155 RT-3: a wildcard or `::1` listener is never vouched for (`lsof` prints an IPv6-only wildcard as
|
|
262
|
+
`*:<port>` too), and a reader whose real and effective uid differ vouches for nothing for its own account.
|
|
250
263
|
|
|
251
264
|
Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
|
|
252
265
|
a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md
CHANGED
|
@@ -17,7 +17,9 @@ DEC-0019, at the commit this skill pins. This page is the how-to.
|
|
|
17
17
|
|
|
18
18
|
It is, when it runs outside the operator's computer and the operator wants it in Fabric beside
|
|
19
19
|
the local services: a hosted dashboard, an agent on a server, a bot's web console. It keeps the
|
|
20
|
-
same objects (well-known document, events feed, login code) at the same paths.
|
|
20
|
+
same objects (well-known document, events feed, login code) at the same paths. An uptime A agent (one that answers
|
|
21
|
+
while the owner's computer is off, creating-fabric-agents' grill) is one from its first release: its keys are the
|
|
22
|
+
platform's secrets, set from Observatory or the person's secret manager, never Observatory's local vault at run time.
|
|
21
23
|
|
|
22
24
|
## The descriptor
|
|
23
25
|
|
|
@@ -73,6 +75,13 @@ watch the gate fail.
|
|
|
73
75
|
time (`tokenMatches`). A service registered from more than one host holds one token per host
|
|
74
76
|
([Registered from several hosts](#registered-from-several-hosts)).
|
|
75
77
|
- **Session key**: 32 random bytes, a separate secret; rotating it signs every operator out.
|
|
78
|
+
- **The data folder on a disk that does not persist**: set `FABRIC_DATA_HOME` to an absolute folder on the platform's
|
|
79
|
+
durable volume, and `service_dirs` / `serviceDirs` put the data folder there (`<home>/<id>`, an instance
|
|
80
|
+
`<home>/<id>.<instance>`); logs and cache stay on the local disk. With no durable volume, keep the data in a
|
|
81
|
+
durable store (a database, an object store) and nothing of value in the folder. A relative value is refused.
|
|
82
|
+
- **A slow resolver**: `http.server.HTTPServer` resolves its host's FQDN between `bind()` and `listen()`, so on a host
|
|
83
|
+
with a slow or missing resolver the port is bound and connects time out; serve with the kit's server, never a bare
|
|
84
|
+
`HTTPServer`.
|
|
76
85
|
- **Codes and sessions**: `MemoryCodeStore`: a restart forgets every outstanding code, so none
|
|
77
86
|
can be replayed, and every session record, so a deploy signs the operator out (DEC-0039: the
|
|
78
87
|
server keeps each session's last use and logout). The host signs in again on the page's `401`.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- [CLI for people and scripts](#cli-for-people-and-scripts)
|
|
7
7
|
- [A2A for remote agents](#a2a-for-remote-agents)
|
|
8
8
|
- [Behind tailscale serve](#behind-tailscale-serve)
|
|
9
|
+
- [A groups file at every door](#a-groups-file-at-every-door)
|
|
9
10
|
- [Token](#token)
|
|
10
11
|
- [Dashboard session](#dashboard-session)
|
|
11
12
|
- [Headers every response carries](#headers-every-response-carries)
|
|
@@ -124,6 +125,75 @@ A tagged node (a server, not a person) gets no identity header from serve and is
|
|
|
124
125
|
machine caller uses the estate's delegation tokens instead. The serve listener is an outward surface: watch it like
|
|
125
126
|
the A2A tunnel above.
|
|
126
127
|
|
|
128
|
+
### A groups file at every door
|
|
129
|
+
|
|
130
|
+
`fabric_tailnet_identity.py` / `fabric-tailnet-identity.mjs` (FAA-324) are the simpler sibling for a service whose
|
|
131
|
+
every door (its dashboard, its MCP endpoint, its own API) sits behind `tailscale serve` on one loopback listener, with
|
|
132
|
+
access kept in a file the organization writes rather than a generated perimeter and personal keys.
|
|
133
|
+
|
|
134
|
+
- **The header and where it is believed.** Serve sets `Tailscale-User-Login` and removes any copy a client sent (also
|
|
135
|
+
the `_` spelling); it sets none for a tagged node or for Funnel traffic, which carries `Tailscale-Funnel-Request`
|
|
136
|
+
instead ([Tailscale: identity headers](https://tailscale.com/kb/1312/serve)). The module believes the header only
|
|
137
|
+
when the service listens on a loopback address and the request's peer is loopback; a service bound to `0.0.0.0` or
|
|
138
|
+
a LAN address, or a request from another peer, is refused, since there the header comes from the network. Another
|
|
139
|
+
process of the machine can still reach the loopback port: the kit's loopback server refuses other OS accounts
|
|
140
|
+
(FAA-227), and a listener of serve's own (above) closes the rest where that matters.
|
|
141
|
+
- **The groups file.** JSON the organization writes, at a path the service takes from its own setting (an
|
|
142
|
+
environment variable or a config key it names), checked against `tailnet-groups.schema.json` beside the module:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{"schema": "tailnet-groups/1",
|
|
146
|
+
"about": "Who may open example-agent's doors.",
|
|
147
|
+
"groups": {"ops": ["ana@example.com", "bo@example.com"], "viewers": ["cy@example.com"]},
|
|
148
|
+
"rules": [{"group": "ops", "door": "dashboard", "capabilities": "*"},
|
|
149
|
+
{"group": "ops", "door": "mcp", "capabilities": ["cve.scan", "vitals.read"]},
|
|
150
|
+
{"group": "ops", "door": "service", "capabilities": "*"},
|
|
151
|
+
{"group": "viewers", "door": "dashboard", "capabilities": ["vitals"]}]}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A door is `dashboard`, `mcp` or `service`; a capability is the name the service asks for there (a page, a tool, an
|
|
155
|
+
action), or `"*"` for all of them. The module also reads the agents base's `door-groups/1` file, written once for
|
|
156
|
+
every service of a machine, for the service it is given (`TailnetIdentity(path, service="example-agent")`, Node
|
|
157
|
+
`{service}`):
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{"format": "door-groups/1", "version": 3, "generated_at": "2026-11-12T22:00:00Z",
|
|
161
|
+
"groups": {"ops": {"people": ["ana@example.com"], "doors": {"example-agent": ["dashboard", "mcp"]}},
|
|
162
|
+
"callers": {"people": ["dee@example.org"], "doors": {"example-agent": ["vitals.read"]}}},
|
|
163
|
+
"retired": {"old-ops": {"at": "2026-11-01", "why": "merged into ops"}}}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
For that service `*` opens every door, `dashboard` the dashboard, `mcp` every MCP tool, and a tool name that tool at
|
|
167
|
+
the MCP door and at the service's own door; a group with no entry for the service, and a retired group, open
|
|
168
|
+
nothing, and without a service every door stays closed (`groups-invalid` at `service`). Logins are compared as ASCII names lowercased whole after trimming spaces and
|
|
169
|
+
tabs, with no other folding; a non-ASCII login (serve RFC 2047-encodes one) is refused as malformed.
|
|
170
|
+
- **Wiring.** Make one `TailnetIdentity(path)` at start and ask it at each door: in Python
|
|
171
|
+
`decision = identity.decide_request(self, door="mcp", capability=tool)` in an `http.server` handler, then
|
|
172
|
+
`send_refusal(self, decision)` when `decision["allowed"]` is false; in Node
|
|
173
|
+
`identity.decideRequest(req, {door, capability})` and `sendRefusal(res, decision)`. Behind the kit's
|
|
174
|
+
`loopbackServer` gate the request's own server never listened, so pass `bound: gate.address().address` (Python:
|
|
175
|
+
`bound=` when the handler's server is not the listening one); an unknown bound address is refused. The decision
|
|
176
|
+
carries `login`, `name` (from `Tailscale-User-Name`, for display only) and `groups`.
|
|
177
|
+
- **Reload and failure.** The file is looked at on every request (one `stat`; `recheck_seconds` / `recheckSeconds`
|
|
178
|
+
makes it at most that often) and read again when its inode, size, mtime or ctime change, with no restart. While it
|
|
179
|
+
is missing, unreadable or invalid, every door answers `groups-unreadable` or `groups-invalid`; one line per change
|
|
180
|
+
goes to the log (stderr by default), naming the field and never the file's content.
|
|
181
|
+
|
|
182
|
+
| Code | When |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `bind-not-loopback` | the service does not listen on a loopback address, or the address is unknown |
|
|
185
|
+
| `not-loopback` | the request's peer is not loopback: it did not come through the local serve |
|
|
186
|
+
| `groups-unreadable`, `groups-invalid` | the groups file cannot be read, or breaks its schema; every door is shut |
|
|
187
|
+
| `funnel-request` | the request came through Funnel from the public internet |
|
|
188
|
+
| `no-login` | no `Tailscale-User-Login`, or an empty one |
|
|
189
|
+
| `tagged-node` | no login, and serve's `X-Forwarded-For` is a tailnet address: a node without a user |
|
|
190
|
+
| `login-ambiguous`, `login-malformed` | two login headers, or one that is no login name |
|
|
191
|
+
| `unknown-login` | the login is in no group |
|
|
192
|
+
| `not-allowed` | no group of the login has a rule for this door and capability |
|
|
193
|
+
|
|
194
|
+
Every refusal is 403 with `{"error": {"code", "message"}}`, the message a fixed sentence; `tagged-node` and `no-login`
|
|
195
|
+
differ only in their label, since a direct local call can send any `X-Forwarded-For`.
|
|
196
|
+
|
|
127
197
|
## Token
|
|
128
198
|
|
|
129
199
|
- Created once with `ensure_token` at first start, after the instance lock.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The A2A rules of the probe (FAA-10): what a service that declares `surfaces.a2a` (DEC-0122) serves, read from the
|
|
3
|
+
outside. `a2a_rules(wk, origin, ...)` returns (rule, verdict, evidence) rows, verdicts PASS, FAIL or NOT_RUN:
|
|
4
|
+
|
|
5
|
+
- `a2a.surface`: `surfaces.a2a` is the schema's (`fabric_a2a.a2a_view`); NOT_RUN when the service declares none;
|
|
6
|
+
- `a2a.card`: the card at `surfaces.a2a.path` answers 200 with a JSON object `agent_card_findings` finds nothing in;
|
|
7
|
+
- `a2a.interface`: the card names an A2A 1.0 JSON-RPC interface on the service origin (local) or on https (remote);
|
|
8
|
+
- `a2a.task-not-found`: `GetTask` of an id nobody made answers JSON-RPC -32001 (TaskNotFound);
|
|
9
|
+
- `a2a.cancel-not-found`: `CancelTask` of that id answers -32001 too;
|
|
10
|
+
- `a2a.method-not-found`: an unknown method answers -32601;
|
|
11
|
+
- `a2a.task-states`: with `probe_text` (a message the service names as safe to send), `SendMessage` answers a message,
|
|
12
|
+
or a task whose state is an A2A 1.0 state that `GetTask` reads back and `CancelTask` moves to TASK_STATE_CANCELED
|
|
13
|
+
(or refuses with -32002 when it has already ended); NOT_RUN without one, since a message may start real work.
|
|
14
|
+
|
|
15
|
+
An interface that answers HTTP 401 makes the binding rules NOT_RUN with the reason: it takes its own key, never the
|
|
16
|
+
descriptor's token (DEC-0122 item 3); give the key through `--key-env` under `use_secret.py run`. It is a command of
|
|
17
|
+
its own beside check_service.py, whose `a2a.card` never calls the card's interfaces (D5); the two say one verdict for the
|
|
18
|
+
card: an unreadable `surfaces.a2a`, and a remote card that answers 401 or 403, are NOT_RUN, never a failure (DEC-0122
|
|
19
|
+
item 2). Standard library only.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import secrets
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
32
|
+
from fabric_a2a import A2A_PROTOCOL_VERSION, a2a_view, agent_card_findings # noqa: E402
|
|
33
|
+
from fabric_http import HttpError, request as http_request # noqa: E402
|
|
34
|
+
|
|
35
|
+
STATES = ("TASK_STATE_SUBMITTED", "TASK_STATE_WORKING", "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED",
|
|
36
|
+
"TASK_STATE_INPUT_REQUIRED", "TASK_STATE_REJECTED", "TASK_STATE_AUTH_REQUIRED")
|
|
37
|
+
Row = Tuple[str, str, str]
|
|
38
|
+
Fetch = Callable[[str, str, Mapping[str, str], Optional[Dict[str, Any]]], Tuple[int, Any]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def http_fetch(method: str, url: str, headers: Mapping[str, str], body: Optional[Dict[str, Any]]) -> Tuple[int, Any]:
|
|
42
|
+
"""One bounded request (10 s, 1 MiB, no retry); the answer's JSON or None."""
|
|
43
|
+
response = http_request(method, url, headers=dict(headers), body=body, deadline_s=10, retries=0, max_bytes=1 << 20)
|
|
44
|
+
try:
|
|
45
|
+
return response.status, response.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
return response.status, None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rpc(fetch: Fetch, url: str, headers: Mapping[str, str], method: str, params: Dict[str, Any]) -> Tuple[int, Any]:
|
|
51
|
+
return fetch("POST", url, dict(headers, **{"A2A-Version": A2A_PROTOCOL_VERSION, "Content-Type": "application/json"}),
|
|
52
|
+
{"jsonrpc": "2.0", "id": secrets.token_hex(4), "method": method, "params": params})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _code(answer: Any) -> Optional[int]:
|
|
56
|
+
return answer["error"].get("code") if isinstance(answer, dict) and isinstance(answer.get("error"), dict) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def a2a_rules(wk: Any, origin: str, *, fetch: Fetch = http_fetch, headers: Optional[Mapping[str, str]] = None,
|
|
60
|
+
remote: bool = False, descriptor_header: str = "Authorization", probe_text: Optional[str] = None) -> List[Row]:
|
|
61
|
+
"""The A2A rows for one service read at `origin`; `headers` carries the interface's own key, never the token."""
|
|
62
|
+
rows: List[Row] = []
|
|
63
|
+
view = a2a_view(wk)
|
|
64
|
+
if not view["declared"]:
|
|
65
|
+
return [("a2a.surface", "NOT_RUN", "the service declares no surfaces.a2a")]
|
|
66
|
+
if not view["valid"]: # as check_service_rules.a2a_card_verdict: a host does not know where the card is
|
|
67
|
+
return [("a2a.surface", "NOT_RUN", "surfaces.a2a is not readable (well-known.a2a): "
|
|
68
|
+
+ "; ".join(f["message"] for f in view["findings"]))]
|
|
69
|
+
rows.append(("a2a.surface", "PASS", "surfaces.a2a at %s" % view["path"]))
|
|
70
|
+
try:
|
|
71
|
+
status, card = fetch("GET", origin.rstrip("/") + view["path"], {"Accept": "application/json"}, None)
|
|
72
|
+
except (HttpError, OSError) as exc:
|
|
73
|
+
return rows + [("a2a.card", "FAIL", "the card did not answer: %s" % exc)]
|
|
74
|
+
if remote and status in (401, 403):
|
|
75
|
+
return rows + [("a2a.card", "NOT_RUN", "HTTP %d: a card that is not open, gated by the service's own credentials "
|
|
76
|
+
"or an identity proxy; a refusal is not nonconformance (DEC-0122 item 2)" % status)]
|
|
77
|
+
if status != 200 or not isinstance(card, dict):
|
|
78
|
+
return rows + [("a2a.card", "FAIL", "the card answered HTTP %d%s" % (status, "" if isinstance(card, dict) else ", not a JSON object"))]
|
|
79
|
+
findings = agent_card_findings(card, origin=None if remote else origin.rstrip("/"), remote=remote,
|
|
80
|
+
descriptor_header=descriptor_header)
|
|
81
|
+
rows.append(("a2a.card", "FAIL" if findings else "PASS", "; ".join(f["message"] for f in findings) or "%d skills" % len(card.get("skills") or [])))
|
|
82
|
+
url = next((i.get("url") for i in card.get("supportedInterfaces") or [] if isinstance(i, dict)
|
|
83
|
+
and i.get("protocolBinding") == "JSONRPC" and i.get("protocolVersion") == A2A_PROTOCOL_VERSION
|
|
84
|
+
and isinstance(i.get("url"), str)), None)
|
|
85
|
+
if url is None:
|
|
86
|
+
return rows + [("a2a.interface", "FAIL", "the card names no A2A %s JSON-RPC interface" % A2A_PROTOCOL_VERSION)]
|
|
87
|
+
rows.append(("a2a.interface", "PASS", url))
|
|
88
|
+
sent = dict(headers or {})
|
|
89
|
+
nobody = "probe-" + secrets.token_hex(12)
|
|
90
|
+
checks = (("a2a.task-not-found", "GetTask", {"id": nobody}, -32001), ("a2a.cancel-not-found", "CancelTask", {"id": nobody}, -32001),
|
|
91
|
+
("a2a.method-not-found", "ProbeNoSuchMethod", {}, -32601))
|
|
92
|
+
for rule, method, params, want in checks:
|
|
93
|
+
try:
|
|
94
|
+
status, answer = _rpc(fetch, url, sent, method, params)
|
|
95
|
+
except (HttpError, OSError) as exc:
|
|
96
|
+
rows.append((rule, "FAIL", "%s did not answer: %s" % (method, exc)))
|
|
97
|
+
continue
|
|
98
|
+
if status in (401, 403):
|
|
99
|
+
rows.append((rule, "NOT_RUN", "the interface answered HTTP %d: it takes its own key (DEC-0122 item 3); pass it with --key-env" % status))
|
|
100
|
+
else:
|
|
101
|
+
got = _code(answer)
|
|
102
|
+
rows.append((rule, "PASS" if got == want else "FAIL", "%s answered %s, A2A 1.0 says %d" % (method, got if got is not None else "HTTP %d" % status, want)))
|
|
103
|
+
rows.append(_task_states(fetch, url, sent, probe_text))
|
|
104
|
+
return rows
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _task_states(fetch: Fetch, url: str, headers: Mapping[str, str], probe_text: Optional[str]) -> Row:
|
|
108
|
+
if not probe_text:
|
|
109
|
+
return ("a2a.task-states", "NOT_RUN", "no probe message: a message may start real work; pass --probe-text the service names as safe")
|
|
110
|
+
message = {"role": "ROLE_USER", "parts": [{"text": probe_text}], "messageId": "probe-" + secrets.token_hex(8)}
|
|
111
|
+
status, answer = _rpc(fetch, url, headers, "SendMessage", {"message": message})
|
|
112
|
+
result = answer.get("result") if isinstance(answer, dict) else None
|
|
113
|
+
if status != 200 or not isinstance(result, dict) or not ({"task", "message"} & set(result)):
|
|
114
|
+
return ("a2a.task-states", "FAIL", "SendMessage answered neither a task nor a message (HTTP %d, code %s)" % (status, _code(answer)))
|
|
115
|
+
if "message" in result:
|
|
116
|
+
return ("a2a.task-states", "PASS", "SendMessage answered a message with no task")
|
|
117
|
+
task = result["task"]
|
|
118
|
+
if not isinstance(task, dict) or (task.get("status") or {}).get("state") not in STATES:
|
|
119
|
+
return ("a2a.task-states", "FAIL", "the task's state is not an A2A 1.0 TaskState")
|
|
120
|
+
read = _rpc(fetch, url, headers, "GetTask", {"id": task.get("id")})[1]
|
|
121
|
+
if not isinstance(read, dict) or ((read.get("result") or {}).get("status") or {}).get("state") not in STATES:
|
|
122
|
+
return ("a2a.task-states", "FAIL", "GetTask did not read the task back")
|
|
123
|
+
cancelled = _rpc(fetch, url, headers, "CancelTask", {"id": task.get("id")})[1]
|
|
124
|
+
state = (((cancelled or {}).get("result") or {}).get("status") or {}).get("state") if isinstance(cancelled, dict) else None
|
|
125
|
+
if state == "TASK_STATE_CANCELED" or _code(cancelled) == -32002:
|
|
126
|
+
return ("a2a.task-states", "PASS", "SendMessage, GetTask and CancelTask agree")
|
|
127
|
+
return ("a2a.task-states", "FAIL", "CancelTask answered neither TASK_STATE_CANCELED nor -32002")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
131
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
132
|
+
parser.add_argument("origin", help="the service origin, http://127.0.0.1:<port> or its https origin")
|
|
133
|
+
parser.add_argument("--remote", action="store_true")
|
|
134
|
+
parser.add_argument("--key-env", help="an environment variable holding the A2A interface's own key (use_secret.py run fills it)")
|
|
135
|
+
parser.add_argument("--header", default="Authorization")
|
|
136
|
+
parser.add_argument("--scheme", default="Bearer")
|
|
137
|
+
parser.add_argument("--probe-text")
|
|
138
|
+
parser.add_argument("--json", action="store_true")
|
|
139
|
+
args = parser.parse_args(argv)
|
|
140
|
+
status, wk = http_fetch("GET", args.origin.rstrip("/") + "/.well-known/fabric-service", {"Accept": "application/json"}, None)
|
|
141
|
+
headers: Dict[str, str] = {}
|
|
142
|
+
if args.key_env:
|
|
143
|
+
value = os.environ.get(args.key_env)
|
|
144
|
+
if not value:
|
|
145
|
+
print("%s is empty: run under use_secret.py run" % args.key_env, file=sys.stderr)
|
|
146
|
+
return 2
|
|
147
|
+
headers[args.header] = "%s %s" % (args.scheme, value) if args.scheme else value
|
|
148
|
+
rows = a2a_rules(wk if status == 200 else None, args.origin, headers=headers, remote=args.remote, probe_text=args.probe_text)
|
|
149
|
+
if args.json:
|
|
150
|
+
print(json.dumps([{"rule": r, "verdict": v, "evidence": e} for r, v, e in rows], indent=1))
|
|
151
|
+
else:
|
|
152
|
+
for r, v, e in rows:
|
|
153
|
+
print("%-8s %-22s %s" % (v, r, e))
|
|
154
|
+
return 1 if any(v == "FAIL" for _, v, _ in rows) else 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|