@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2
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 +256 -0
- package/README.md +14 -7
- package/bin/fabric-agent-adapter.js +26 -4
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -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 +34 -8
- 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/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md
CHANGED
|
@@ -98,6 +98,15 @@ gate = fd.delegation_gate(audience=..., jwks=keys, issuer=pin["iss"], effects=..
|
|
|
98
98
|
estate_context="org:acme-rehearsal") # the context fabric_context started it for
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
`fabric_context.context_gate_options(info, record)` / `contextGateOptions(info, record)` hands both back from the
|
|
102
|
+
instance `hold_context_instance` started (FAA-150): `estate_context` always, and `issuer` when the record pins that
|
|
103
|
+
organization; spread it after the estate issuer, so a personal instance keeps the estate's:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
gate = fd.delegation_gate(audience=..., jwks=keys, effects=..., estate=lambda: current,
|
|
107
|
+
**{"issuer": estate_issuer, **fabric_context.context_gate_options(info, current)})
|
|
108
|
+
```
|
|
109
|
+
|
|
101
110
|
The `kid` is then certified only when exactly one machine lists it for that context with the published key and the
|
|
102
111
|
revision pins the organization; omitted or `personal`, the personal `hubKey` is read. A context key never verifies a
|
|
103
112
|
personal token, the personal key never an organization's, and a context that is neither (`Org:x`, a trailing space, a
|
|
@@ -218,7 +227,9 @@ const { dirs, instance, lock } = await holdContextInstance('example-agent', 'org
|
|
|
218
227
|
kept; another context or instance, or unreadable, releases the lock and refuses. The folder never changes context.
|
|
219
228
|
|
|
220
229
|
A refusal is one sentence on stderr naming the code and the context, never a record or grant value, and exit 78
|
|
221
|
-
(`EXIT_CONTEXT_REFUSED`, EX_CONFIG; 75 stays "already running")
|
|
230
|
+
(`EXIT_CONTEXT_REFUSED`, EX_CONFIG; 75 stays "already running"); a copy its supervisor started waits the lock's
|
|
231
|
+
back-off (300 s in all) before that exit, so `KeepAlive` does not restart it every 10 s (FAA-279). `ContextRefused`
|
|
232
|
+
carries the code:
|
|
222
233
|
|
|
223
234
|
| Code | When |
|
|
224
235
|
|---|---|
|
|
@@ -125,6 +125,7 @@ action it also carries the operator's decision, [Actions](#actions)), act on eve
|
|
|
125
125
|
(`jobs.quotaExhausted(id, message, {retryable, resetsAt})`, `jobCallerAction`, `intentIdempotencyKey`). The guidance
|
|
126
126
|
for an agent's own design is in `creating-fabric-agents/references/resilience-and-autonomy.md`.
|
|
127
127
|
|
|
128
|
+
<a id="a-nodes-call-queue"></a>
|
|
128
129
|
**A node's call queue (DEC-0104).** A provider that queues refuses a start past its queue with `queue-full`: an `isError`
|
|
129
130
|
result with one text item whose JSON is `{error: {code: "queue-full", message, data: {retryable, retryAfterSeconds}}}`,
|
|
130
131
|
never `structuredContent`; the caller starts again after `retryAfterSeconds` under the same key. Read that refusal
|
|
@@ -132,9 +133,31 @@ from the start's own `isError` result, before any answer reaches `job_caller_act
|
|
|
132
133
|
job, and `queue-full` never ends one, so they answer a job it ended `invalid` (`QUEUE_FULL` names the code, and the
|
|
133
134
|
kit's job store refuses to end a job with it). The kit has no reader for the refusal yet (FAA-103). Through Fabric's hub
|
|
134
135
|
a caller today sees `product-error` instead, kept under the key for 24 hours, until the hub relays `queue-full`
|
|
135
|
-
(Fabric CO-376). The kit does not queue: it declares no load extension (`extensions/load/0.1`)
|
|
136
|
-
`fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above.
|
|
137
|
-
|
|
136
|
+
(Fabric CO-376). The kit's job store does not queue by itself: it declares no load extension (`extensions/load/0.1`)
|
|
137
|
+
and serves no `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above.
|
|
138
|
+
|
|
139
|
+
A node that queues takes the contract's reference functions from the kit instead of copying them (since 0.80.0,
|
|
140
|
+
FAA-289): `fabric_load.py` and `fabric-load.mjs`, exported by both facades, held to the contract's
|
|
141
|
+
`fixtures/vectors/agent-network.json` and its `job-queue`, `service-load` and `service-well-known-load` fixtures at the
|
|
142
|
+
pin (vendored as `test/fixtures/contract/load-vectors.json` by `make_load_vectors.mjs`):
|
|
143
|
+
|
|
144
|
+
| Python | Node | What |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `job_caller_key(accepted, credential)` | `jobCallerKey` | the caller's opaque key: an accepted delegation's `principal`, `caller` and a partner organization ending the chain, else the credential; `None` / `undefined` for a refused or unreadable call |
|
|
147
|
+
| `next_queued_job(queue, running, limits)` | `nextQueuedJob` | the id to start next per (agentRef, context): nothing at `concurrency`, a caller at `perCaller` waits, the caller running fewest first, then the oldest arrival, ties by id |
|
|
148
|
+
| `admit_job(queue, running, job, limits, retry_after_seconds)` | `admitJob` | `{admit: True}` when the job would start at once or fewer than `queueMax` wait, else the `queue-full` error with `retryAfterSeconds` (1 to 3600, else 60) |
|
|
149
|
+
| `queue_status_message(position, length)` | `queueStatusMessage` | a waiting job's `statusMessage`, `queued <position> of <length>` |
|
|
150
|
+
| `read_queue_position(status_message)` | `readQueuePosition` | `{position, length}` from that message, or none |
|
|
151
|
+
| `job_queue_rules(value)` | `jobQueueRules` | FAC-SEM-131 on `{queue, running, limits, started}` |
|
|
152
|
+
| `load_report_rules(report)` | `loadReportRules` | FAC-SEM-132 on a `service-load/1` report: windows one `windowSeconds` apart, none after `generatedAt`, one row per context, p50 at most p95, a run time only where a job ended |
|
|
153
|
+
| `load_report_problems(report)` | `loadReportProblems` | the report against `service-load.schema.json` (no caller, no job id, counts and durations only) |
|
|
154
|
+
| `load_view(well_known)`, `load_extension_rules(well_known)` | `loadView`, `loadExtensionRules` | the declaration as a reader reads it, and FAC-SEM-133: an invalid one is treated as not declared and reported |
|
|
155
|
+
| `load_extension(path, window_seconds, limits)` | `loadExtension({path, windowSeconds, limits})` | the `extensions` entry a service declares; raises `ServiceError` on one a reader would hide |
|
|
156
|
+
|
|
157
|
+
Queue times are UTC instants (`T`, `Z`, at most three fraction digits); report times are RFC 3339 read strictly. A
|
|
158
|
+
node keys every per-caller rule with `job_caller_key`, counts every started job that has not ended (`input_required`
|
|
159
|
+
and paused ones too) as running, and answers another caller's job id `unknown-job`. The text-only `queue-full` result
|
|
160
|
+
and `fabric.job.list` are still the node's own (FAA-103).
|
|
138
161
|
|
|
139
162
|
<a id="actions"></a>
|
|
140
163
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
59
59
|
| L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
|
|
60
60
|
| L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
|
|
61
61
|
| L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
|
|
62
|
-
| L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.
|
|
62
|
+
| L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.4`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
|
|
63
63
|
| L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
|
|
64
64
|
| L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
|
|
65
65
|
| L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
|
|
@@ -78,6 +78,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
78
78
|
| L3 | `fabric_connections` | what an agent uses, `fabric-connections/0.1`: the reference reading and the declaration (DEC-0096, FAC-SEM-127) |
|
|
79
79
|
| L3 | `fabric_a2a` | the reading side of an A2A Agent Card: `surfaces.a2a` as the schema reads it, and what a public card must not carry (DEC-0122) |
|
|
80
80
|
| L3 | `fabric_page_links` | a service's pages as Fabric Dashboards' open_link: build and read it, a raw local address, FAC-SEM-143 on a link and on a URL-mode input request (DEC-0123); the Python twin reads URLs as WHATWG does by hand |
|
|
81
|
+
| L3 | `fabric_load` | the call queue and the load report of DEC-0104 (FAA-289): `job_caller_key`, `next_queued_job`, `admit_job`, `queue_status_message`, `read_queue_position`, FAC-SEM-131 to 133, the `service-load/1` and `extensions/load/0.1` shapes, `load_extension`; imports `fabric_connections` for ajv's `date-time` ([interop](interop.md#a-nodes-call-queue)) |
|
|
81
82
|
| L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
|
|
82
83
|
| L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
|
|
83
84
|
| L3 | `fabric_backups` | settings backups (DEC-0025) |
|
|
@@ -105,7 +106,8 @@ Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service
|
|
|
105
106
|
`check_service_channel_agent.py`, `check_service_channel_agent_schema.py` and the
|
|
106
107
|
`service-channel-agent-extension.schema.json` it reads at run time), the sample
|
|
107
108
|
(`sample_service.py`, `sample-remote-service.mjs`), `fabric_kit.py` itself and the dashboard tools. Recipes built on
|
|
108
|
-
the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md)
|
|
109
|
+
the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md), [a service that is only a
|
|
110
|
+
dashboard](../recipes/dashboard-only/README.md), [a key-delivery door](../recipes/key-delivery-door/README.md)).
|
|
109
111
|
|
|
110
112
|
## What 0.39 code keeps
|
|
111
113
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -45,13 +45,19 @@ Starting, stopping and restarting go through the supervisor and nothing else:
|
|
|
45
45
|
atomic, mode 0600) and the uninstaller removes it; a running service never writes its own, except an app
|
|
46
46
|
([below](#an-apps-own-descriptor-and-the-node)). It names the supervisor and its file (`label` and `plist`, `unit`,
|
|
47
47
|
or `task`; none for `node` and `person`), the `origin` `http://127.0.0.1:<port>` (a port is a claim: an installer
|
|
48
|
-
refuses a port another instance declares
|
|
48
|
+
refuses a port another instance declares, and one another OS account listens on, read with the kit's
|
|
49
|
+
`fabric_peer_owner`, FAA-225; the recorded port per account and its claim file wait on contract DEC-0160), the service's directories, and commands as argument arrays that never
|
|
49
50
|
carry a credential. Only `doctor` and `update` are commands a host may run.
|
|
50
51
|
|
|
51
52
|
**Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
|
|
52
53
|
authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's,
|
|
53
54
|
on a listener of this OS account (DEC-0154 HA-2: another account's listener is `foreign-user` whatever it answers; a
|
|
54
|
-
LaunchDaemon's root installer reads it as *The system domain* says).
|
|
55
|
+
LaunchDaemon's root installer reads it as *The system domain* says). `well_known_of(origin)` / `wellKnownOf(origin)` read
|
|
56
|
+
it so a status tells HA-2's order apart: None / null when nothing answers (`down`), a `ForeignUserAnswer` whose
|
|
57
|
+
`reason` starts `foreign-user` or `table-restricted`, else the document; `fetch_well_known` keeps answering None for
|
|
58
|
+
both (FAA-259). Every loopback spelling of an origin (`127.0.0.1`, another `127/8` address, `localhost`, `[::1]`) is read
|
|
59
|
+
on a checked connection (FAA-257). `launchd_install` takes an injected reader, `fetch_well_known=` and
|
|
60
|
+
`fetch_job_well_known=`, as the Node `launchdInstall` takes `fetchWellKnown` and `fetchJobWellKnown`.
|
|
55
61
|
`status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
|
|
56
62
|
full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
|
|
57
63
|
that document until `service.id` matches (at most 40 s) before it calls the service started (the launchd installers;
|
|
@@ -200,6 +206,10 @@ if the holder leaves, and exits 75 only when the wait runs out: a few starts an
|
|
|
200
206
|
360. It touches nothing while it waits. Two installs sharing one data directory are still the
|
|
201
207
|
defect to remove; the back-off only stops the loop from costing the machine.
|
|
202
208
|
|
|
209
|
+
A context refusal (`fabric_context`, exit 78: no record, a revoked grant, a folder of another context) is the same
|
|
210
|
+
loop under `KeepAlive`, so a supervised copy waits the same back-off, holding nothing and with the lock released,
|
|
211
|
+
before it exits 78 (FAA-279); started by hand it exits 78 at once.
|
|
212
|
+
|
|
203
213
|
The marker (contract DEC-0057, CT-01) is the variable `FABRIC_SERVICE_SUPERVISOR=<manager>` or
|
|
204
214
|
the argument `--fabric-supervisor <manager>` (also `--fabric-supervisor=<manager>`), after the
|
|
205
215
|
executable and before any `--`; `<manager>` is `launchd`, `systemd`, `task-scheduler` or `node`
|
|
@@ -318,7 +328,7 @@ descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.se
|
|
|
318
328
|
|
|
319
329
|
| Verb | Command |
|
|
320
330
|
|---|---|
|
|
321
|
-
| install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
|
|
331
|
+
| install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable`. With `origin` and `service_id` it then polls the well-known answer as `launchd_install` does until this account's listener answers (another account's is refused, DEC-0154 HA-2; FAA-253); Node's `systemdInstall` then returns a promise |
|
|
322
332
|
| stop, keep off | `systemctl --user disable --now <unit>` |
|
|
323
333
|
| uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
|
|
324
334
|
|
|
@@ -335,7 +345,7 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
|
|
|
335
345
|
|
|
336
346
|
| Verb | Command |
|
|
337
347
|
|---|---|
|
|
338
|
-
| install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
|
|
348
|
+
| install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable`. With `origin` and `service_id` it then polls as `launchd_install` does and returns the answer (FAA-253); Node's `taskInstall` then returns a promise |
|
|
339
349
|
| stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
|
|
340
350
|
| uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
|
|
341
351
|
|
|
@@ -157,6 +157,9 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
|
|
|
157
157
|
`auth.delegation {issuer, jwks}` is then required. With `own` or `delegation` the descriptor's
|
|
158
158
|
token is not an MCP credential: a host and the probe never call `/mcp` with it as a caller.
|
|
159
159
|
The kits verify them with `fabric_delegation` / `fabric-delegation.mjs` ([delegation](delegation.md)).
|
|
160
|
+
- `surfaces.mcp.capabilities` and an action's `tool` never name `project.work` (`PRIMARY_CAPABILITY`): DEC-0140 PR-1
|
|
161
|
+
reserves it for a Project's primary responder, a session, and no service serves or declares it. `build_well_known`
|
|
162
|
+
/ `buildWellKnown` refuse it with `ServiceError` (FAA-244); a longer name such as `project.workspace` is not reserved.
|
|
160
163
|
|
|
161
164
|
## Events page: `GET /fabric/v1/events?after=<cursor>&limit=<n>`
|
|
162
165
|
|
|
@@ -238,8 +241,9 @@ IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDae
|
|
|
238
241
|
the contract's ruling on vouching is CT-261.
|
|
239
242
|
|
|
240
243
|
Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
|
|
241
|
-
a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user
|
|
242
|
-
`
|
|
244
|
+
a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
|
|
245
|
+
`data.reason` and sentence the owner's reason: `table-restricted`, then not retryable, or the owner's kind or `no-row`, FAA-272),
|
|
246
|
+
`fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user` with the same `reason`, never retried),
|
|
243
247
|
`check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
|
|
244
248
|
is never a successful install, except a LaunchDaemon's job as its root installer proves it; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
|
|
245
249
|
session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md
CHANGED
|
@@ -10,6 +10,7 @@ DEC-0019, at the commit this skill pins. This page is the how-to.
|
|
|
10
10
|
- [On the platform](#on-the-platform)
|
|
11
11
|
- [Checking it](#checking-it)
|
|
12
12
|
- [What a host does and will not do](#what-a-host-does-and-will-not-do)
|
|
13
|
+
- [Registered from several hosts](#registered-from-several-hosts)
|
|
13
14
|
- [Rotation](#rotation)
|
|
14
15
|
|
|
15
16
|
## Is it a remote service?
|
|
@@ -68,8 +69,9 @@ watch the gate fail.
|
|
|
68
69
|
## On the platform
|
|
69
70
|
|
|
70
71
|
- **Token**: a random value of at least 16 characters (32 bytes recommended), set as a secret;
|
|
71
|
-
the same value is written on the
|
|
72
|
-
time (`tokenMatches`).
|
|
72
|
+
the same value is written on the registering host by `registerRemote`. Compare in constant
|
|
73
|
+
time (`tokenMatches`). A service registered from more than one host holds one token per host
|
|
74
|
+
([Registered from several hosts](#registered-from-several-hosts)).
|
|
73
75
|
- **Session key**: 32 random bytes, a separate secret; rotating it signs every operator out.
|
|
74
76
|
- **Codes and sessions**: `MemoryCodeStore`: a restart forgets every outstanding code, so none
|
|
75
77
|
can be replayed, and every session record, so a deploy signs the operator out (DEC-0039: the
|
|
@@ -122,10 +124,49 @@ The login rules keep every `Set-Cookie` of the answer and judge the session cook
|
|
|
122
124
|
| start, stop, restart, update | not offered |
|
|
123
125
|
| a host older than the remote placement | shows the service as invalid and never contacts it |
|
|
124
126
|
|
|
127
|
+
## Registered from several hosts
|
|
128
|
+
|
|
129
|
+
One cloud service is often registered from more than one host: the operator's two computers, or an organization's
|
|
130
|
+
space beside a personal one (FAA-280). Each registering host gets **its own token**; a token is never copied from one
|
|
131
|
+
host to another, so one host can be revoked or rotated without touching the others.
|
|
132
|
+
|
|
133
|
+
- **On the platform**, keep one secret per host (`FABRIC_HOST_TOKEN_<HOST>`, each at least 16 characters) and accept
|
|
134
|
+
a request whose bearer matches any of them. Compare it with every token, each with `tokenMatches` /
|
|
135
|
+
`token_matches`, with no early return, so the answer's timing does not say which host's token matched:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
def host_of(authorization, tokens): # tokens: {host name: token}; the kit's comparison, once per token
|
|
139
|
+
matched = [host for host, token in sorted(tokens.items()) if fs.token_matches(authorization, token)]
|
|
140
|
+
return matched[0] if matched else None # None: a 401 with an empty body, as with one token
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`well_known_allowed` / `wellKnownAllowed` take one token: call it once per token the same way, or gate the route on
|
|
144
|
+
`host_of(...) is not None`.
|
|
145
|
+
- **On each host**, run `registerRemote` / `register_remote` with that host's token only. The descriptor is the same
|
|
146
|
+
on every host but its `tokenFile`, which holds that host's value and nothing else (0600, never printed).
|
|
147
|
+
- **Sign-in**: `LoginCodes` binds a session to one token's revision. Give each host its own `LoginCodes` with
|
|
148
|
+
`token=` that host's token and its own store (`MemoryCodeStore`, or a durable row per host); the session key may be
|
|
149
|
+
shared. Issue the login code from the instance whose token the bearer matched; redeem a code and check a session by
|
|
150
|
+
trying each instance in turn (a code another instance issued is not in its store, and a session cookie carries
|
|
151
|
+
another token's revision, so neither changes anything there). Removing or rotating one host's token then ends that
|
|
152
|
+
host's sessions only. One `LoginCodes` whose `token=` is a callable returning every token joined also works, and
|
|
153
|
+
signs every host out when any one rotates.
|
|
154
|
+
- **The probe**: `check_service.py <id>` run on a host sends that host's token, so `well-known.requires-token` passes
|
|
155
|
+
on each; the request without a token, or with a value no host holds, still gets the `401` with an empty body.
|
|
156
|
+
- **Revoking a host**: delete its secret on the platform and restart; that host then sees `401` and shows the service
|
|
157
|
+
`down`, never `foreign`. Remove its descriptor there with `remove_descriptor` / `removeDescriptor`.
|
|
158
|
+
- **Adding a host**: create its secret, restart, then `registerRemote` on the new host. The other hosts' tokens and
|
|
159
|
+
sessions do not change.
|
|
160
|
+
|
|
161
|
+
A token per host still gives each host the descriptor token's whole authority over the service. A remote surface no
|
|
162
|
+
host token reaches (callers with their own delegation, the page opened only with Fabric Dashboards' one-time code)
|
|
163
|
+
is the "local and cloud" form, FAA-204, which waits on the contract recording that form.
|
|
164
|
+
|
|
125
165
|
## Rotation
|
|
126
166
|
|
|
127
167
|
Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
|
|
128
|
-
|
|
168
|
+
registering host with it (on a service registered from several hosts, only that host's token,
|
|
169
|
+
[above](#registered-from-several-hosts)); the old token stops working the moment the platform restarts with the
|
|
129
170
|
new value, and so does every session begun under it when `LoginCodes` has `token=`. The re-run keeps the
|
|
130
171
|
installed `source` when it passes none (one the kit refuses, edited by hand, is dropped with the warning); pass
|
|
131
172
|
`runner`, `peer`, `summary`, `doctor` and `installedBy` / `installed_by` again, since a re-run writes only what it is
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py
CHANGED
|
@@ -133,12 +133,20 @@ def check(data):
|
|
|
133
133
|
return {'ok': True, 'kind': kind}
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def read_input(limit=65536):
|
|
137
|
+
# The handoff's bytes, decoded as UTF-8 with a byte order mark dropped: Windows before Python 3.15 decodes a text
|
|
138
|
+
# stdin with its code page, which misreads a Cyrillic handoff (FAA-210). A stream without bytes is read as text.
|
|
139
|
+
stream = getattr(sys.stdin, 'buffer', None)
|
|
140
|
+
raw = stream.read(limit + 1) if stream is not None else sys.stdin.read(limit + 1)
|
|
141
|
+
require(len(raw) <= limit, 'input_too_large')
|
|
142
|
+
return raw.decode('utf-8-sig') if isinstance(raw, bytes) else raw.lstrip('\ufeff')
|
|
143
|
+
|
|
144
|
+
|
|
136
145
|
def main():
|
|
137
146
|
try:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
except (ValueError, TypeError, KeyError, AttributeError):
|
|
147
|
+
result = check(json.loads(read_input()))
|
|
148
|
+
# RecursionError: Python 3.9 to 3.13 raise it on a deeply nested document under the cap
|
|
149
|
+
except (ValueError, TypeError, KeyError, AttributeError, RecursionError):
|
|
142
150
|
# Fixed reasons only: never echo a submitted URL, token or JSON blob.
|
|
143
151
|
result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
|
|
144
152
|
print(json.dumps(result))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -673,12 +673,14 @@ class Probe:
|
|
|
673
673
|
self.events_trace_rule(self.events)
|
|
674
674
|
|
|
675
675
|
def channel_agent_rule(self) -> None:
|
|
676
|
-
"""DEC-0150 (FAA-197): the
|
|
677
|
-
|
|
678
|
-
shaped like a credential in it or in a copy the probe reads beside it (CA-5)."""
|
|
676
|
+
"""DEC-0150 (FAA-197): the declaration, from the manifest the descriptor names, else the well-known answer (CA-1); one
|
|
677
|
+
read as not declared is NOT_RUN, never a FAIL (D3), except a credential's shape in it or a copy beside it (CA-5)."""
|
|
679
678
|
manifest, _, where = self.load_manifest()
|
|
680
|
-
|
|
681
|
-
manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None))
|
|
679
|
+
try: # a copy of the probe without its schema file is NOT_RUN, and the later rules run (FAA-250)
|
|
680
|
+
verdict = cac.declaration_verdict(manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None))
|
|
681
|
+
except (OSError, ValueError) as exc:
|
|
682
|
+
verdict = ("NOT_RUN", "the probe cannot read %s beside it (%s)" % (cac.cas.SCHEMA_FILE.name, type(exc).__name__))
|
|
683
|
+
self.add("channel-agent.declaration", *verdict)
|
|
682
684
|
|
|
683
685
|
def configuration_rule(self) -> None:
|
|
684
686
|
"""DEC-0046: a well-known `configuration` is {slots, resolved} as the schema types it; absent is NOT_RUN."""
|
|
@@ -18,7 +18,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
18
18
|
|
|
19
19
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
20
|
from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
|
|
21
|
-
from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE,
|
|
21
|
+
from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, raw_values, secret_argument, secret_url, secret_value # noqa: E402
|
|
22
|
+
from fabric_secrets import url_parts as _whatwg_url_parts # noqa: E402 - public since FAA-250
|
|
22
23
|
import check_service_channel_agent_schema as cas # noqa: E402
|
|
23
24
|
|
|
24
25
|
KEY = "https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1"
|
|
@@ -62,6 +63,9 @@ _IGNORABLE = ((0x00AD, 0x00AD), (0x034F, 0x034F), (0x061C, 0x061C), (0x115F, 0x1
|
|
|
62
63
|
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
|
|
63
64
|
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF))
|
|
64
65
|
_FILLERS = frozenset("\u115f\u1160\u3164\uffa0\u2800\ufffc")
|
|
66
|
+
#: Format characters (\p{Cf}) assigned after Unicode 13, which Python 3.9's unicodedata reads as unassigned (FAA-250):
|
|
67
|
+
#: U+0890..0891 (Unicode 14) and U+13439..1343F (Unicode 15); the white-space and control categories gained none since.
|
|
68
|
+
_LATER_FORMATS = ((0x0890, 0x0891), (0x13439, 0x1343F))
|
|
65
69
|
|
|
66
70
|
|
|
67
71
|
def utf16(text: str) -> str:
|
|
@@ -75,7 +79,7 @@ def utf16(text: str) -> str:
|
|
|
75
79
|
def _blank_char(char: str) -> bool:
|
|
76
80
|
code = ord(char)
|
|
77
81
|
return (char in JS_SPACE or char in _FILLERS or unicodedata.category(char) in ("Cf", "Cc", "Zs", "Zl", "Zp")
|
|
78
|
-
or any(low <= code <= high for low, high in _IGNORABLE))
|
|
82
|
+
or any(low <= code <= high for low, high in _IGNORABLE + _LATER_FORMATS))
|
|
79
83
|
|
|
80
84
|
|
|
81
85
|
def blank(text: str) -> bool:
|
|
@@ -397,7 +401,7 @@ def url_parts(text: str) -> Optional[Tuple[str, str]]:
|
|
|
397
401
|
return None
|
|
398
402
|
if host and not _host_ok(host, special):
|
|
399
403
|
return None
|
|
400
|
-
_, query, fragment =
|
|
404
|
+
_, query, fragment = _whatwg_url_parts(text)
|
|
401
405
|
has_query = "?" in rest.partition("#")[0]
|
|
402
406
|
has_fragment = "#" in rest
|
|
403
407
|
return ("?" + query if has_query and query else "", "#" + fragment if has_fragment and fragment else "")
|
|
@@ -823,8 +827,9 @@ def declaration_verdict(manifest: Optional[Dict[str, Any]], manifest_named: bool
|
|
|
823
827
|
where = "the manifest's provider.extensions" if site == "manifest" else "the well-known answer's extensions"
|
|
824
828
|
shapes = _credential_shapes(others + copy)
|
|
825
829
|
if shapes: # a credential's shape in a copy no reader reads still sits in a document the service publishes (CA-5)
|
|
826
|
-
|
|
827
|
-
|
|
830
|
+
also = ("; on the read declaration itself, %d finding(s): %s" % (len(own), _shown(own))) if own else ""
|
|
831
|
+
return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s%s" % (
|
|
832
|
+
len(shapes), _shown(shapes), also, tail)
|
|
828
833
|
if declared and own:
|
|
829
834
|
if not channel_agent_view(doc, site)["declared"] and not _credential_shapes(own):
|
|
830
835
|
# D3: what the contract's reader reads as not declared (an older form, a repeat, a dangling reference) is
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
CHANGED
|
@@ -3,6 +3,7 @@ network guard's verdict, the board responder's fixture conversation, a session c
|
|
|
3
3
|
whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
|
|
4
4
|
opens its pages in a browser (FAA-118), and its A2A member and public Agent Card (DEC-0122). ``check_service.py`` runs them against a live service.
|
|
5
5
|
Standard library only, Python 3.9+."""
|
|
6
|
+
# Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import email.utils
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs
CHANGED
|
@@ -11,6 +11,11 @@ export const PROTOCOL = 'fabric-service/0.1';
|
|
|
11
11
|
|
|
12
12
|
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
|
|
13
13
|
|
|
14
|
+
// DEC-0140 PR-1 (contract project-comms.md, interop.md's C3.1 amendment): the capability of a Project's primary
|
|
15
|
+
// responder, a session's. No service serves it as a tool or declares it (surfaces.mcp.capabilities, an action's tool),
|
|
16
|
+
// and the board kit never enrolls a service's responder for it (FAA-244).
|
|
17
|
+
export const PRIMARY_CAPABILITY = 'project.work';
|
|
18
|
+
|
|
14
19
|
// Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
|
|
15
20
|
// contract's service specification, Lifecycle), the same values as fabric_service.py.
|
|
16
21
|
// A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs
CHANGED
|
@@ -30,6 +30,8 @@ export class BoardSession {
|
|
|
30
30
|
try {
|
|
31
31
|
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
32
32
|
} catch (e) {
|
|
33
|
+
// FAA-252: another OS account's process holds the port; nothing was sent
|
|
34
|
+
if (e instanceof HubError && e.code === 'foreign-user') throw new BoardError('foreign-user', 'The hub\'s port is held by another OS account\'s process; nothing was sent.', true);
|
|
33
35
|
if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
|
|
34
36
|
throw e;
|
|
35
37
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_session.py. Node 20+, no dependency.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
|
|
6
|
-
import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
|
|
6
|
+
import { PRIMARY_CAPABILITY, acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
|
|
7
7
|
import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
|
|
8
8
|
import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
|
|
9
9
|
import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
|
|
@@ -87,6 +87,8 @@ export class Responder extends SlotKeeper {
|
|
|
87
87
|
if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
|
|
88
88
|
const caps = [...(capabilities ?? [])];
|
|
89
89
|
if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
|
|
90
|
+
// FAA-244: DEC-0140 PR-1, a session's slot, never a service's
|
|
91
|
+
if (caps.includes(PRIMARY_CAPABILITY)) throw new BoardError('invalid_arguments', `${PRIMARY_CAPABILITY} is a Project's primary responder's, a session's: a service answers the capabilities it declares (DEC-0140 PR-1).`);
|
|
90
92
|
Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
|
|
91
93
|
this.project = normalProject(projectId);
|
|
92
94
|
// FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
|
|
@@ -102,6 +104,8 @@ export class Responder extends SlotKeeper {
|
|
|
102
104
|
this.state = 'idle';
|
|
103
105
|
this.waitUntil = 0;
|
|
104
106
|
this.stopping = false;
|
|
107
|
+
/** Whether shutdown found work to drain, published before it waits; null before shutdown starts (FAA-186). */
|
|
108
|
+
this.draining = null;
|
|
105
109
|
this.claimKeys = {};
|
|
106
110
|
this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
|
|
107
111
|
this.said = {};
|
|
@@ -196,7 +200,7 @@ export class Responder extends SlotKeeper {
|
|
|
196
200
|
this.session = session;
|
|
197
201
|
const status = await this.status();
|
|
198
202
|
if (missing.length) {
|
|
199
|
-
this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (
|
|
203
|
+
this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Settings → Agent access).`, { notify: true, episode: missing.join(','), capabilities: missing });
|
|
200
204
|
}
|
|
201
205
|
this.slots = {};
|
|
202
206
|
await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
|
|
@@ -375,7 +379,8 @@ export class Responder extends SlotKeeper {
|
|
|
375
379
|
try {
|
|
376
380
|
this.receipt('com.reply', await this.call('com.reply', { ...this.replyExtra(entry), fence: this.fence({ ...entry, generation, attempt, digest, revision }, key) }));
|
|
377
381
|
} catch (e) {
|
|
378
|
-
|
|
382
|
+
// foreign-user: nothing was sent now, so whether the old reply was stored is unknown (0.79.0 seam reading)
|
|
383
|
+
if (['unreachable', 'foreign-user', 'session-ended', 'authority_changed', 'not_authorized', 'not_available', 'capacity_exceeded'].includes(e.code) || !(e instanceof BoardError)) throw e;
|
|
379
384
|
replied = false;
|
|
380
385
|
}
|
|
381
386
|
if (replied) entry.replied = true;
|
|
@@ -496,7 +501,7 @@ export class Responder extends SlotKeeper {
|
|
|
496
501
|
if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
|
|
497
502
|
this.state = 'not-authorized';
|
|
498
503
|
await this.dropSession();
|
|
499
|
-
this.emit('board.access.needed', 'warning', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (
|
|
504
|
+
this.emit('board.access.needed', 'warning', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Settings → Agent access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
|
|
500
505
|
} else if (code === 'held') {
|
|
501
506
|
this.state = 'paused';
|
|
502
507
|
this.emit('board.held', 'info', 'The operator holds new work on this Project; the responder waits and asks again with a growing pause.', { episode: 'held', reason: code });
|
|
@@ -508,8 +513,11 @@ export class Responder extends SlotKeeper {
|
|
|
508
513
|
} else {
|
|
509
514
|
this.state = 'down';
|
|
510
515
|
// A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
|
|
511
|
-
if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
|
|
512
|
-
|
|
516
|
+
if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'foreign-user'].includes(code)) && !keep) await this.dropSession();
|
|
517
|
+
// FAA-252: a port another OS account holds is said as such; nothing was sent there
|
|
518
|
+
this.emit('board.down', 'warning', code === 'foreign-user'
|
|
519
|
+
? 'Fabric\'s board port is held by another OS account\'s process; nothing was sent, and the responder tries again with a growing pause.'
|
|
520
|
+
: 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
|
|
513
521
|
}
|
|
514
522
|
return wait;
|
|
515
523
|
}
|
|
@@ -933,6 +941,7 @@ export class Responder extends SlotKeeper {
|
|
|
933
941
|
async shutdown(drain = DRAIN_SECONDS) {
|
|
934
942
|
this.stopping = true;
|
|
935
943
|
const work = this.current;
|
|
944
|
+
this.draining = Boolean(work); // what this shutdown waits for (FAA-186)
|
|
936
945
|
if (work && !work.done) {
|
|
937
946
|
await Promise.race([work.promise, new Promise((resolve) => setTimeout(resolve, drain * 1000).unref?.())]);
|
|
938
947
|
if (!work.done) work.ctx.stopped = true;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export const HOLD_RETRY_MAX_SECONDS = 900;
|
|
|
14
14
|
|
|
15
15
|
/** Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault. */
|
|
16
16
|
export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
|
|
17
|
-
'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
|
|
17
|
+
'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available', 'foreign-user']));
|
|
18
18
|
|
|
19
19
|
const sameRecord = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
20
20
|
|
|
@@ -83,7 +83,7 @@ export class SlotKeeper {
|
|
|
83
83
|
const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
|
|
84
84
|
if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
|
|
85
85
|
} catch (e) {
|
|
86
|
-
if (LOST.includes(e.code)) throw e;
|
|
86
|
+
if (LOST.includes(e.code) || e.code === 'foreign-user') throw e; // foreign-user: the earlier ask is unknown
|
|
87
87
|
if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -254,6 +254,7 @@ export class SlotKeeper {
|
|
|
254
254
|
if (!(e instanceof BoardError)) throw e;
|
|
255
255
|
this.retryLater(capability, entries, e);
|
|
256
256
|
if (SESSION_CODES.has(e.code)) throw e;
|
|
257
|
+
if (e.code === 'idempotency_window_expired') await this.status(); // the epoch moved: read it before the retry (FAA-186)
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// An organization's instance whose context the record does not pin (`organizations`, DEC-0127) still starts: the helper
|
|
7
7
|
// checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
|
|
8
8
|
// hubKeyCertified reads a context's hub keys only while the record pins that organization (DL-14a, DEC-0128), so the
|
|
9
|
-
// service passes
|
|
9
|
+
// service passes contextGateOptions(info, record) (its context and its pin's issuer, FAA-150) to its delegation gate. A refusal at start would stop every organization
|
|
10
10
|
// instance on today's records, which pin none until every reader is pinned at or after DEC-0127.
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import fs from 'node:fs';
|
|
@@ -14,7 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import { INSTANCE, ServiceError } from './fabric-base.mjs';
|
|
15
15
|
import { descriptorPath } from './fabric-descriptor.mjs';
|
|
16
16
|
import { parseContext } from './fabric-estate.mjs';
|
|
17
|
-
import { InstanceLock, holdSingleInstance } from './fabric-lock.mjs';
|
|
17
|
+
import { InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, holdSingleInstance, lockWaitBudgetSeconds, supervisorMarker } from './fabric-lock.mjs';
|
|
18
18
|
import { atomicWrite, instanceDirs, servicesDir } from './fabric-paths.mjs';
|
|
19
19
|
import { instanceShareGrant } from './fabric-share.mjs';
|
|
20
20
|
import { clockMs } from './fabric-time.mjs';
|
|
@@ -112,9 +112,29 @@ function markerMatches(file, expected) {
|
|
|
112
112
|
return found !== null && typeof found === 'object' && Object.entries(expected).every(([key, value]) => found[key] === value);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* What delegationGate takes from this instance's context (FAA-150): `estateContext`, and for an organization whose
|
|
117
|
+
* context `record` pins, `issuer`: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance refuses a
|
|
118
|
+
* token the personal hub key signed. `info` is contextInstance's (or holdContextInstance's) answer; spread it after the
|
|
119
|
+
* service's own estate issuer: `delegationGate({ ..., issuer: estateIssuer, ...contextGateOptions(info, record) })`.
|
|
120
|
+
* Throws ContextRefused (`context-invalid`) for an answer with no context.
|
|
121
|
+
*/
|
|
122
|
+
export function contextGateOptions(info, record) {
|
|
123
|
+
const context = info !== null && typeof info === 'object' ? info.context : undefined;
|
|
124
|
+
const parsed = parseContext(context);
|
|
125
|
+
if (!parsed) throw new ContextRefused('context-invalid');
|
|
126
|
+
const options = { estateContext: context };
|
|
127
|
+
const pins = record !== null && typeof record === 'object' && !Array.isArray(record) ? record.organizations : undefined;
|
|
128
|
+
if (parsed.kind === 'org' && Array.isArray(pins)) {
|
|
129
|
+
const pin = pins.find((p) => p !== null && typeof p === 'object' && p.org === context && typeof p.iss === 'string');
|
|
130
|
+
if (pin) options.issuer = pin.iss;
|
|
131
|
+
}
|
|
132
|
+
return options;
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
/**
|
|
116
136
|
* Run as `context`'s one instance, before any side effect (brief D17): contextInstance (a refusal writes one sentence
|
|
117
|
-
* naming the code and exits 78, nothing created); then holdSingleInstance on the instance's data folder (its markers,
|
|
137
|
+
* naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then holdSingleInstance on the instance's data folder (its markers,
|
|
118
138
|
* its supervised wait, its exit 75); then `<data>/fabric-context.json`: absent, written (0600, atomic); equal, kept;
|
|
119
139
|
* another context or instance, or unreadable, releases the lock and exits 78 (`context-mismatch`). Resolves to the
|
|
120
140
|
* instance with its held `lock`; `exit` is injectable as holdSingleInstance has it, and its answer is returned.
|
|
@@ -123,8 +143,20 @@ export async function holdContextInstance(id, context, {
|
|
|
123
143
|
record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
|
|
124
144
|
waitSeconds, sleep, clock, exit = (code) => process.exit(code), env, argv,
|
|
125
145
|
} = {}) {
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
// FAA-279: under a supervisor that restarts on exit an exit 78 at once is a respawn every few seconds, forever; a
|
|
147
|
+
// supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing.
|
|
148
|
+
const refuse = async (code) => {
|
|
149
|
+
const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
|
|
150
|
+
const wait = Math.max(0, waitSeconds ?? lockWaitBudgetSeconds(marker));
|
|
151
|
+
const tail = wait > 0 ? ` Waiting ${Math.round(wait)} s before exiting, so its supervisor does not restart it every few seconds.` : '';
|
|
152
|
+
process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.${tail}\n`);
|
|
153
|
+
const now = clock ?? (() => performance.now() / 1000);
|
|
154
|
+
const pause = sleep ?? ((seconds) => new Promise((resolve) => { setTimeout(resolve, seconds * 1000); }));
|
|
155
|
+
const deadline = now() + wait;
|
|
156
|
+
for (let delay = LOCK_BACKOFF_FIRST_SECONDS, remaining = deadline - now(); remaining > 0; remaining = deadline - now()) {
|
|
157
|
+
await pause(Math.min(delay, remaining));
|
|
158
|
+
delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
|
|
159
|
+
}
|
|
128
160
|
return exit(EXIT_CONTEXT_REFUSED);
|
|
129
161
|
};
|
|
130
162
|
let info;
|