@passioncode-ai/passioncode 0.1.19 → 0.1.21
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 +18 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +44 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +71 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +113 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +113 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +137 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +79 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.21 - 2026-10-03
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Fabric Agent Adapter is pinned at `v0.6.1`:** `check_service.py` no longer fails an online
|
|
8
|
+
service hosted behind a platform router. Heroku, Fly, Render and CDNs answer a foreign `Host`
|
|
9
|
+
themselves (`404`/`421`), and that now counts as the refusal it is, unless the body is the
|
|
10
|
+
well-known document.
|
|
11
|
+
|
|
12
|
+
## 0.1.20 - 2026-10-02
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Fabric Agent Adapter is pinned at `v0.6.0`:** `building-fabric-services` also makes an online
|
|
17
|
+
agent or dashboard (an `https` origin on a platform) a Fabric service — the remote placement of
|
|
18
|
+
`fabric-service/0.1`, Fabric Agent Contract DEC-0019 — with Node and Python kits, a TLS sample
|
|
19
|
+
online service and a probe that checks remote services over verified TLS.
|
|
20
|
+
|
|
3
21
|
## 0.1.19 - 2026-10-02
|
|
4
22
|
|
|
5
23
|
### Changed
|
package/family.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/passioncode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"passioncode": "bin/passioncode.js"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"name": "fabric-agent-adapter",
|
|
12
12
|
"displayName": "Fabric Agent Adapter",
|
|
13
13
|
"source": "./plugins/fabric-agent-adapter",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.1",
|
|
15
15
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "PassionCode.ai",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"name": "passioncode",
|
|
36
36
|
"displayName": "PassionCode.ai",
|
|
37
37
|
"source": "./plugins/passioncode",
|
|
38
|
-
"version": "0.1.
|
|
38
|
+
"version": "0.1.21",
|
|
39
39
|
"description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
|
|
40
40
|
"author": {
|
|
41
41
|
"name": "PassionCode.ai",
|
package/payload/manifest.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"family": "passioncode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"release": true,
|
|
5
5
|
"members": [
|
|
6
6
|
{
|
|
7
7
|
"name": "fabric-agent-adapter",
|
|
8
8
|
"displayName": "Fabric Agent Adapter",
|
|
9
9
|
"repo": "passioncode-ai/fabric-agent-adapter",
|
|
10
|
-
"ref": "v0.
|
|
11
|
-
"commit": "
|
|
12
|
-
"version": "0.
|
|
10
|
+
"ref": "v0.6.1",
|
|
11
|
+
"commit": "d3a4c67262e242b0a4422ecdcf99a7d892db6caa",
|
|
12
|
+
"version": "0.6.1",
|
|
13
13
|
"via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
|
|
14
14
|
"skills": [
|
|
15
15
|
"adapting-projects-to-fabric",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"legacyMarketplaces": [
|
|
23
23
|
"fabric-agent-adapter"
|
|
24
24
|
],
|
|
25
|
-
"contentHash": "sha256:
|
|
25
|
+
"contentHash": "sha256:654511f4332892aa9a709e1946566d017179edc294e136fbe5c6466c5c32b5c8",
|
|
26
26
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
|
|
27
27
|
"author": {
|
|
28
28
|
"name": "PassionCode.ai",
|
|
@@ -61,16 +61,16 @@
|
|
|
61
61
|
"name": "passioncode",
|
|
62
62
|
"displayName": "PassionCode.ai",
|
|
63
63
|
"repo": "passioncode-ai/passioncode",
|
|
64
|
-
"ref": "v0.1.
|
|
64
|
+
"ref": "v0.1.21",
|
|
65
65
|
"commit": null,
|
|
66
|
-
"version": "0.1.
|
|
66
|
+
"version": "0.1.21",
|
|
67
67
|
"via": "this repository",
|
|
68
68
|
"skills": [
|
|
69
69
|
"working-in-passioncode"
|
|
70
70
|
],
|
|
71
71
|
"legacyPluginIds": [],
|
|
72
72
|
"legacyMarketplaces": [],
|
|
73
|
-
"contentHash": "sha256:
|
|
73
|
+
"contentHash": "sha256:63c4bc3c353c3e7b3762c4ab20a50e4684504c142a378c988c73d86049892a50",
|
|
74
74
|
"description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
|
|
75
75
|
"author": {
|
|
76
76
|
"name": "PassionCode.ai",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.6.1",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.6.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,7 @@ Use exactly:
|
|
|
70
70
|
|
|
71
71
|
- contract version `0.1.0`;
|
|
72
72
|
- repository `https://github.com/passioncode-ai/fabric-agent-contract`;
|
|
73
|
-
- commit `
|
|
73
|
+
- commit `2ce392291c6668598d12cd38327e24696b5ca15c`.
|
|
74
74
|
|
|
75
75
|
Read the pinned contract's guide
|
|
76
76
|
`docs/guides/connecting-compatible-agents.md`, the selected profile specification, and
|
|
@@ -16,7 +16,7 @@ from urllib.parse import urlparse
|
|
|
16
16
|
|
|
17
17
|
CONTRACT_VERSION = "0.1.0"
|
|
18
18
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
19
|
-
CONTRACT_COMMIT = "
|
|
19
|
+
CONTRACT_COMMIT = "2ce392291c6668598d12cd38327e24696b5ca15c"
|
|
20
20
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
21
21
|
MCP_REVISION = "2026-07-28"
|
|
22
22
|
A2A_VERSION = "1.0"
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: building-fabric-services
|
|
3
3
|
description: >-
|
|
4
|
-
Use when handing out or opening a Fabric service dashboard,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Dashboards», "make this agent a local service", "
|
|
8
|
-
protocol", "add the well-known endpoint"
|
|
9
|
-
fabric-service/0.1
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
Use when handing out or opening a Fabric service dashboard, building a local agent service on a
|
|
5
|
+
macOS machine, or making an ONLINE agent or dashboard (https origin, the remote placement) a
|
|
6
|
+
Fabric service — «сделай агенту дашборд», «локальный сервис агента», «онлайн-дашборд в Fabric»,
|
|
7
|
+
«подключи агента к Fabric Dashboards», "make this agent a local service", "make an online
|
|
8
|
+
dashboard a Fabric service", "fabric-service protocol", "add the well-known endpoint". Covers
|
|
9
|
+
fabric-service/0.1: surfaces, token and one-time login, state, one copy, launchd, descriptor,
|
|
10
|
+
well-known document, events; online: the https guard, the token-gated well-known document, the
|
|
11
|
+
__Host- cookie, registering it on the operator's computer; ships Python and Node kits, a TLS
|
|
12
|
+
sample and a live probe. NOT for a one-off script or cron job, a hosted product with no agent
|
|
13
|
+
behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
|
|
14
14
|
license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
15
15
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
16
16
|
metadata:
|
|
17
17
|
author: PassionCode.ai
|
|
18
|
-
version: "0.
|
|
18
|
+
version: "0.6.1"
|
|
19
19
|
contract-version: "0.1.0"
|
|
20
20
|
extension: "fabric-service/0.1"
|
|
21
|
-
extension-commit: "
|
|
21
|
+
extension-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
|
|
22
22
|
---
|
|
23
23
|
|
|
24
24
|
# Building Fabric services
|
|
@@ -40,7 +40,8 @@ to review one. Do not use it for:
|
|
|
40
40
|
|
|
41
41
|
- a script, a cron job or a one-shot CLI — nothing stays running, so there is nothing
|
|
42
42
|
to supervise; a launchd `StartInterval` job needs no descriptor;
|
|
43
|
-
- a hosted
|
|
43
|
+
- a hosted product with no agent behind it. An online agent or dashboard that should appear in
|
|
44
|
+
Fabric IS in scope: it is the remote placement — read [Online services](#online-services--the-remote-placement);
|
|
44
45
|
- the provider manifest and admission bundle — that is `adapting-projects-to-fabric`
|
|
45
46
|
(a service that is also a provider does both);
|
|
46
47
|
- the Fabric Dashboards app itself.
|
|
@@ -235,6 +236,36 @@ someone builds for themselves is not a PassionCode.ai repository:** its licence
|
|
|
235
236
|
choice, nothing about it is published or listed by the organization, and none of these files is
|
|
236
237
|
required of it — though the verified MCP quick start is still how anyone learns to drive it.
|
|
237
238
|
|
|
239
|
+
## Online services — the remote placement
|
|
240
|
+
|
|
241
|
+
An agent or a dashboard that runs online — on a platform, a server, a hosted app — becomes a
|
|
242
|
+
Fabric service with the same four routes and one descriptor on the operator's computer
|
|
243
|
+
(`placement: "remote"`, DEC-0019). Nothing about launchd, the lock or loopback applies; three
|
|
244
|
+
things change and the kits implement each:
|
|
245
|
+
|
|
246
|
+
| | What the online service does | Kit |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| Guard | refuse a foreign `Host`/`Origin` and `cross-site`; behind a TLS router refuse a forwarded scheme that is not `https` | `checkRemoteRequest` / `check_remote_request` |
|
|
249
|
+
| Well-known | only for the token; otherwise `401` with an **empty** body | `wellKnownAllowed('remote', …)` / `well_known_allowed` |
|
|
250
|
+
| Session | `__Host-fabric_session`, `Secure`; codes in memory, the key from a platform secret so sessions survive a deploy | `remoteSessionCookieHeader`, `new LoginCodes(null, 120, { store: new MemoryCodeStore(), key })` |
|
|
251
|
+
|
|
252
|
+
1. **Serve the four routes** in the app that already exists — not beside it. The complete worked
|
|
253
|
+
example is [`scripts/sample-remote-service.mjs`](scripts/sample-remote-service.mjs): it runs
|
|
254
|
+
with its own TLS or behind a platform router.
|
|
255
|
+
2. **Secrets live on the platform:** the service token and the session key (32 bytes) are
|
|
256
|
+
platform secrets, never in the repository, a URL or an argument vector.
|
|
257
|
+
3. **Register it on the operator's computer** — the installer side, run there:
|
|
258
|
+
`registerRemote({ id, name, origin, token })` / `register_remote(...)` writes the token file
|
|
259
|
+
(0600) and the descriptor; pass the token from a file or stdin, never in argv. Public names
|
|
260
|
+
only in public artifacts: a private service is registered by a local descriptor and nowhere
|
|
261
|
+
else.
|
|
262
|
+
4. **Verify** with the probe: `check_service.py <id>` checks TLS, the token-gated well-known
|
|
263
|
+
document, the guards, events, the single-use login and the host-bound cookie; launchd, lock
|
|
264
|
+
and loopback rules are `NOT_RUN` with that reason.
|
|
265
|
+
|
|
266
|
+
Read [the remote placement reference](references/remote-placement.md) for the failure
|
|
267
|
+
behaviour a host shows, rotation, a multi-process platform, and what a host will not do.
|
|
268
|
+
|
|
238
269
|
## Migrating an existing service
|
|
239
270
|
|
|
240
271
|
Read [the migration reference](references/migrating-a-service.md) — the order of
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `2ce392291c6668598d12cd38327e24696b5ca15c`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015). The contract's schemas are normative;
|
|
5
5
|
this page is the working summary. Extension key:
|
|
6
6
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# The remote placement — online agents and dashboards
|
|
2
|
+
|
|
3
|
+
Normative source: `fabric-agent-contract` `docs/specification/service.md` → *Remote placement*,
|
|
4
|
+
DEC-0019, at the commit this skill pins. This page is the how-to.
|
|
5
|
+
|
|
6
|
+
## Is it a remote service?
|
|
7
|
+
|
|
8
|
+
It is, when it runs outside the operator's computer and the operator wants it in Fabric beside
|
|
9
|
+
the local services: a hosted dashboard, an agent on a server, a bot's web console. It keeps the
|
|
10
|
+
same objects — well-known document, events feed, login code — at the same paths.
|
|
11
|
+
|
|
12
|
+
## The descriptor
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"protocol": "fabric-service/0.1",
|
|
17
|
+
"id": "example-agent",
|
|
18
|
+
"instance": "default",
|
|
19
|
+
"name": "Example Agent",
|
|
20
|
+
"placement": "remote",
|
|
21
|
+
"origin": "https://agent.example.com",
|
|
22
|
+
"auth": { "tokenFile": "~/Library/Application Support/ai.passioncode.fabric/tokens/example-agent.default.token" },
|
|
23
|
+
"lifecycle": { "manager": "none" },
|
|
24
|
+
"installedAt": "2026-10-02T18:00:00Z",
|
|
25
|
+
"installedBy": "fabric-service register-remote"
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `origin`: `https://` + a public DNS name + an optional port. No path, query, userinfo or IP
|
|
30
|
+
literal; not `localhost`, `*.local`, `*.internal`, `*.home.arpa`, `*.lan`, `*.localdomain`.
|
|
31
|
+
- `lifecycle.manager` is `none`, with no `label` or `plist`. `paths` is optional.
|
|
32
|
+
- `commands` may carry `doctor` (a local executable); never `update`.
|
|
33
|
+
|
|
34
|
+
## On the platform
|
|
35
|
+
|
|
36
|
+
- **Token**: a random value of at least 16 characters (32 bytes recommended), set as a secret;
|
|
37
|
+
the same value is written on the operator's computer by `registerRemote`. Compare in constant
|
|
38
|
+
time (`tokenMatches`).
|
|
39
|
+
- **Session key**: 32 random bytes, a separate secret. With it, sessions survive a deploy; without
|
|
40
|
+
it, every deploy signs the operator out.
|
|
41
|
+
- **Codes**: `MemoryCodeStore` — a restart forgets every outstanding code, so none can be replayed.
|
|
42
|
+
Several processes behind one origin need a shared store (a database row with the same
|
|
43
|
+
"used before honoured" write); memory works for one process.
|
|
44
|
+
- **Behind a router**: check the platform-set forwarded scheme; `Host` is preserved by common
|
|
45
|
+
platforms and must equal the origin's host.
|
|
46
|
+
|
|
47
|
+
## Checking it
|
|
48
|
+
|
|
49
|
+
`check_service.py <id>` probes a remote placement over verified TLS. Behind a platform router
|
|
50
|
+
(Heroku, Fly, Render, a CDN) a request that names a foreign `Host` never reaches the process: the
|
|
51
|
+
router answers it, usually `404` or `421`. The probe counts that as the refusal it is, as long as
|
|
52
|
+
the body is not the well-known document. The Origin and cross-site checks name the right `Host`,
|
|
53
|
+
reach the service, and still need its own `403`.
|
|
54
|
+
|
|
55
|
+
## What a host does and will not do
|
|
56
|
+
|
|
57
|
+
| Situation | Host |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `401` on the well-known document | `down` — the service refused the token; never `foreign` |
|
|
60
|
+
| answer names another `id.instance` | `foreign`; the token is not sent again until the descriptor changes |
|
|
61
|
+
| certificate invalid or for another name | `down`, the reason names TLS |
|
|
62
|
+
| redirect | not followed; `down` |
|
|
63
|
+
| one probe missed | not an outage; the last state holds until the threshold |
|
|
64
|
+
| start, stop, restart, update | not offered |
|
|
65
|
+
| a host older than the remote placement | shows the service as invalid and never contacts it |
|
|
66
|
+
|
|
67
|
+
## Rotation
|
|
68
|
+
|
|
69
|
+
Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
|
|
70
|
+
operator's computer with it; the old token stops working the moment the platform restarts with the
|
|
71
|
+
new value. Rotate the session key on the platform to sign every operator out.
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
check_service.py <id>[.<instance>] # find the descriptor in the services directory
|
|
5
5
|
check_service.py --descriptor PATH
|
|
6
6
|
options: --services-dir DIR --json --skip-login
|
|
7
|
+
--ca-file PEM --connect HOST:PORT (remote placement: trust a test CA; dial another address)
|
|
8
|
+
|
|
9
|
+
A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
|
|
10
|
+
TLS with the certificate verified: the well-known document must refuse a request without the
|
|
11
|
+
token (401, empty body), the guards must refuse a foreign Host/Origin and cross-site requests,
|
|
12
|
+
and the session cookie must be __Host- and Secure. Loopback, launchd, lock and state rules do
|
|
13
|
+
not apply to it and are reported NOT_RUN with that reason.
|
|
7
14
|
|
|
8
15
|
Every rule gets PASS, FAIL or NOT_RUN with its evidence. Exit 0 when nothing
|
|
9
16
|
FAILs, 1 when something does, 2 on a usage error. The probe only reads, except
|
|
@@ -29,7 +36,10 @@ import shutil
|
|
|
29
36
|
import stat
|
|
30
37
|
import subprocess
|
|
31
38
|
import sys
|
|
39
|
+
import socket
|
|
40
|
+
import ssl
|
|
32
41
|
import time
|
|
42
|
+
import urllib.parse
|
|
33
43
|
from typing import Any, Dict, List, Optional, Tuple
|
|
34
44
|
|
|
35
45
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
@@ -69,8 +79,42 @@ def priority_problems(plist: dict) -> list:
|
|
|
69
79
|
return out
|
|
70
80
|
|
|
71
81
|
|
|
82
|
+
# A platform router (Heroku, Fly, Render, a CDN) routes by Host, so a request naming a foreign
|
|
83
|
+
# Host never reaches an online service: the router itself answers, typically 404 or 421. That is
|
|
84
|
+
# still a refusal — the service disclosed nothing — so for a remote placement the host check
|
|
85
|
+
# accepts it, as long as the body is not the well-known document. Every other guard request names
|
|
86
|
+
# the right Host, reaches the service, and must be the service's own 403.
|
|
87
|
+
PLATFORM_HOST_REFUSALS = (400, 404, 421)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
|
|
91
|
+
if status == 403:
|
|
92
|
+
return "PASS", "HTTP 403"
|
|
93
|
+
disclosed = b"fabric-service/" in (body or b"")
|
|
94
|
+
if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
|
|
95
|
+
return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
|
|
96
|
+
return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _PinnedHTTPS(http.client.HTTPSConnection):
|
|
100
|
+
"""HTTPS to the origin's name (SNI, certificate, Host) over a socket dialled elsewhere."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, name: str, port: int, dial: Tuple[str, int], context: ssl.SSLContext, timeout: float):
|
|
103
|
+
super().__init__(name, port, context=context, timeout=timeout)
|
|
104
|
+
self._dial = dial
|
|
105
|
+
self._ctx = context
|
|
106
|
+
|
|
107
|
+
def connect(self) -> None:
|
|
108
|
+
raw = socket.create_connection(self._dial, timeout=self.timeout)
|
|
109
|
+
self.sock = self._ctx.wrap_socket(raw, server_hostname=self.host)
|
|
110
|
+
|
|
111
|
+
|
|
72
112
|
class Probe:
|
|
73
|
-
def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool
|
|
113
|
+
def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool,
|
|
114
|
+
ca_file: Optional[str] = None, connect: Optional[str] = None):
|
|
115
|
+
self.remote = fs.placement_of(descriptor) == "remote"
|
|
116
|
+
self.ca_file = ca_file
|
|
117
|
+
self.connect = connect
|
|
74
118
|
self.path = descriptor_path
|
|
75
119
|
self.d = descriptor
|
|
76
120
|
self.dir = services_dir
|
|
@@ -80,6 +124,13 @@ class Probe:
|
|
|
80
124
|
self.token: Optional[str] = None
|
|
81
125
|
self.events: Optional[List[Dict[str, Any]]] = None
|
|
82
126
|
self.sent_traceparent: Optional[str] = None
|
|
127
|
+
self.netloc = ""
|
|
128
|
+
if self.remote:
|
|
129
|
+
parts = urllib.parse.urlsplit(str(descriptor.get("origin", "")))
|
|
130
|
+
self.netloc = parts.netloc
|
|
131
|
+
self.host_name = parts.hostname or ""
|
|
132
|
+
self.port = (parts.port or 443) if not fs.remote_origin_problems(descriptor.get("origin")) else 0
|
|
133
|
+
return
|
|
83
134
|
try:
|
|
84
135
|
self.port = fs.port_of(str(descriptor.get("origin", "")))
|
|
85
136
|
except fs.ServiceError:
|
|
@@ -90,8 +141,17 @@ class Probe:
|
|
|
90
141
|
|
|
91
142
|
def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
|
|
92
143
|
body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
|
|
93
|
-
|
|
94
|
-
|
|
144
|
+
if self.remote:
|
|
145
|
+
context = ssl.create_default_context(cafile=self.ca_file) if self.ca_file else ssl.create_default_context()
|
|
146
|
+
if self.connect:
|
|
147
|
+
host, _, port = self.connect.rpartition(":")
|
|
148
|
+
conn = _PinnedHTTPS(self.host_name, self.port, (host, int(port)), context, 8)
|
|
149
|
+
else:
|
|
150
|
+
conn = http.client.HTTPSConnection(self.host_name, self.port, context=context, timeout=8)
|
|
151
|
+
base = {"Host": self.netloc}
|
|
152
|
+
else:
|
|
153
|
+
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
|
154
|
+
base = {"Host": "127.0.0.1:%d" % self.port}
|
|
95
155
|
base.update(headers or {})
|
|
96
156
|
try:
|
|
97
157
|
conn.request(method, path, body=body, headers=base)
|
|
@@ -120,17 +180,32 @@ class Probe:
|
|
|
120
180
|
continue
|
|
121
181
|
if key == me:
|
|
122
182
|
clashes.append("%s declared again in %s" % (me, path.name))
|
|
123
|
-
elif other.get("origin") == self.d.get("origin"):
|
|
183
|
+
elif not self.remote and fs.placement_of(other) != "remote" and other.get("origin") == self.d.get("origin"):
|
|
124
184
|
clashes.append("port %d also claimed by %s" % (self.port, key))
|
|
125
|
-
|
|
185
|
+
unique = "a remote origin claims no port here" if self.remote else "port %d is unique" % self.port
|
|
186
|
+
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
|
|
126
187
|
|
|
127
188
|
# well-known ----------------------------------------------------------------
|
|
128
189
|
def well_known_rules(self) -> None:
|
|
190
|
+
protected: Dict[str, str] = {}
|
|
191
|
+
if self.remote:
|
|
192
|
+
try:
|
|
193
|
+
status, _, body = self.request("GET", "/.well-known/fabric-service")
|
|
194
|
+
except (OSError, ssl.SSLError) as exc:
|
|
195
|
+
self.add("well-known.answers", "FAIL", "no TLS answer on %s: %s" % (self.d.get("origin"), exc))
|
|
196
|
+
return
|
|
197
|
+
self.add("well-known.requires-token", "PASS" if status == 401 and not body else "FAIL",
|
|
198
|
+
"HTTP %d without a token, %d byte(s)" % (status, len(body)))
|
|
199
|
+
self.read_token()
|
|
200
|
+
if not self.token:
|
|
201
|
+
self.add("well-known.answers", "NOT_RUN", "no readable token to ask with")
|
|
202
|
+
return
|
|
203
|
+
protected = self.auth_headers()
|
|
129
204
|
try:
|
|
130
205
|
timings = []
|
|
131
206
|
for _ in range(3):
|
|
132
207
|
started = time.perf_counter()
|
|
133
|
-
status, headers, body = self.request("GET", "/.well-known/fabric-service")
|
|
208
|
+
status, headers, body = self.request("GET", "/.well-known/fabric-service", protected)
|
|
134
209
|
timings.append((time.perf_counter() - started) * 1000)
|
|
135
210
|
except OSError as exc:
|
|
136
211
|
self.add("well-known.answers", "FAIL", "no answer on %s: %s" % (self.d.get("origin"), exc))
|
|
@@ -145,7 +220,10 @@ class Probe:
|
|
|
145
220
|
return
|
|
146
221
|
self.add("well-known.answers", "PASS", "HTTP 200")
|
|
147
222
|
median = sorted(timings)[1]
|
|
148
|
-
self.
|
|
223
|
+
if self.remote:
|
|
224
|
+
self.add("well-known.fast", "PASS" if median < 8000 else "FAIL", "median %.1f ms (remote: no 100 ms budget)" % median)
|
|
225
|
+
else:
|
|
226
|
+
self.add("well-known.fast", "PASS" if median < 100 else "FAIL", "median %.1f ms" % median)
|
|
149
227
|
wk = self.wk
|
|
150
228
|
problems = []
|
|
151
229
|
if wk.get("protocol") != fs.PROTOCOL:
|
|
@@ -177,10 +255,19 @@ class Probe:
|
|
|
177
255
|
("network.cross-site-check", {"Sec-Fetch-Site": "cross-site"}),
|
|
178
256
|
):
|
|
179
257
|
try:
|
|
180
|
-
|
|
181
|
-
self.
|
|
182
|
-
|
|
258
|
+
sent = dict(headers)
|
|
259
|
+
if self.remote:
|
|
260
|
+
sent.update(self.auth_headers())
|
|
261
|
+
if "Origin" in sent:
|
|
262
|
+
sent["Origin"] = "https://evil.example"
|
|
263
|
+
status, _, body = self.request("GET", "/.well-known/fabric-service", sent)
|
|
264
|
+
verdict, evidence = guard_verdict(rule, self.remote, status, body)
|
|
265
|
+
self.add(rule, verdict, evidence)
|
|
266
|
+
except (OSError, ssl.SSLError) as exc:
|
|
183
267
|
self.add(rule, "NOT_RUN", str(exc))
|
|
268
|
+
if self.remote:
|
|
269
|
+
self.add("network.loopback-only", "NOT_RUN", "a remote placement is reached over https, not loopback")
|
|
270
|
+
return
|
|
184
271
|
if not shutil.which("lsof"):
|
|
185
272
|
self.add("network.loopback-only", "NOT_RUN", "lsof is not installed")
|
|
186
273
|
return
|
|
@@ -191,13 +278,18 @@ class Probe:
|
|
|
191
278
|
", ".join(names) or "nothing listens on %d" % self.port)
|
|
192
279
|
|
|
193
280
|
# auth, events, login ----------------------------------------------------------
|
|
194
|
-
def
|
|
281
|
+
def read_token(self) -> None:
|
|
282
|
+
if any(r["rule"] == "auth.token-file" for r in self.results):
|
|
283
|
+
return
|
|
195
284
|
token_file = str((self.d.get("auth") or {}).get("tokenFile", ""))
|
|
196
285
|
try:
|
|
197
286
|
self.token = fs.read_token(fs.expand(token_file))
|
|
198
287
|
self.add("auth.token-file", "PASS", "%s is 0600 and owned by you" % token_file)
|
|
199
288
|
except (fs.ServiceError, OSError) as exc:
|
|
200
289
|
self.add("auth.token-file", "FAIL", str(exc))
|
|
290
|
+
|
|
291
|
+
def auth_rules(self) -> None:
|
|
292
|
+
self.read_token()
|
|
201
293
|
events_path = ((self.wk or {}).get("surfaces") or {}).get("events", {}).get("path", "/fabric/v1/events")
|
|
202
294
|
try:
|
|
203
295
|
status, _, _ = self.request("GET", events_path + "?limit=1")
|
|
@@ -251,6 +343,10 @@ class Probe:
|
|
|
251
343
|
return
|
|
252
344
|
cookie = headers1.get("set-cookie", "")
|
|
253
345
|
ok = status1 in (302, 303) and "HttpOnly" in cookie and "SameSite=Strict" in cookie and status2 not in (302, 303)
|
|
346
|
+
if self.remote:
|
|
347
|
+
host_bound = cookie.startswith("__Host-") and "Secure" in cookie and "Path=/" in cookie and "domain=" not in cookie.lower()
|
|
348
|
+
self.add("login.cookie-host-bound", "PASS" if host_bound else "FAIL",
|
|
349
|
+
"the session cookie is __Host-, Secure, Path=/, no Domain" if host_bound else "cookie: %s" % cookie.split(";", 1)[0].split("=")[0])
|
|
254
350
|
self.add("login.single-use", "PASS" if ok else "FAIL",
|
|
255
351
|
"first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
|
|
256
352
|
|
|
@@ -432,6 +528,9 @@ class Probe:
|
|
|
432
528
|
|
|
433
529
|
# lifecycle ----------------------------------------------------------------
|
|
434
530
|
def lifecycle_rules(self) -> None:
|
|
531
|
+
if self.remote:
|
|
532
|
+
self.add("lifecycle.platform", "NOT_RUN", "a remote placement is supervised by its platform; launchd, lock and state rules do not apply")
|
|
533
|
+
return
|
|
435
534
|
life = self.d.get("lifecycle") or {}
|
|
436
535
|
data = fs.expand(str((self.d.get("paths") or {}).get("data", "~/")))
|
|
437
536
|
inside = subprocess.run(["git", "-C", str(data), "rev-parse", "--show-toplevel"], capture_output=True, text=True) if data.is_dir() and shutil.which("git") else None
|
|
@@ -555,6 +654,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
555
654
|
parser.add_argument("--services-dir")
|
|
556
655
|
parser.add_argument("--json", action="store_true")
|
|
557
656
|
parser.add_argument("--skip-login", action="store_true")
|
|
657
|
+
parser.add_argument("--ca-file", help="remote placement: trust this CA bundle instead of the system store (tests)")
|
|
658
|
+
parser.add_argument("--connect", help="remote placement: dial HOST:PORT while speaking TLS to the origin's name (tests)")
|
|
558
659
|
args = parser.parse_args(argv)
|
|
559
660
|
if not args.target and not args.descriptor:
|
|
560
661
|
parser.print_usage(sys.stderr)
|
|
@@ -566,7 +667,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
566
667
|
except (OSError, ValueError) as exc:
|
|
567
668
|
print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
|
|
568
669
|
return 1
|
|
569
|
-
results = Probe(path, descriptor, services_dir, args.skip_login).run()
|
|
670
|
+
results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect).run()
|
|
570
671
|
failed = sum(r["verdict"] == "FAIL" for r in results)
|
|
571
672
|
if args.json:
|
|
572
673
|
print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Reference kit for the fabric-service/0.1 local service extension — Node.js 20+, no dependencies.
|
|
2
2
|
// The Node twin of fabric_service.py: same rules, same file formats, interoperable locks.
|
|
3
|
-
// Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015
|
|
3
|
+
// Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
|
|
4
|
+
// placement — an online agent or dashboard at an https origin — DEC-0019).
|
|
4
5
|
|
|
5
6
|
import crypto from 'node:crypto';
|
|
6
7
|
import fs from 'node:fs';
|
|
@@ -16,11 +17,16 @@ export const STATUSES = ['starting', 'ready', 'degraded', 'stopping'];
|
|
|
16
17
|
export const EVENTS_DEFAULT_LIMIT = 50;
|
|
17
18
|
export const EVENTS_MAX_LIMIT = 200;
|
|
18
19
|
export const SESSION_COOKIE = 'fabric_session';
|
|
20
|
+
// DEC-0019: a remote placement's cookie is host-bound and HTTPS-only.
|
|
21
|
+
export const REMOTE_SESSION_COOKIE = '__Host-fabric_session';
|
|
22
|
+
export const PLACEMENTS = ['local', 'remote'];
|
|
19
23
|
|
|
20
24
|
const ID = /^[a-z][a-z0-9-]{1,62}$/;
|
|
21
25
|
const INSTANCE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
22
26
|
const KIND = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$/;
|
|
23
27
|
const ORIGIN = /^http:\/\/127\.0\.0\.1:([0-9]{3,5})$/;
|
|
28
|
+
const REMOTE_ORIGIN = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?$/;
|
|
29
|
+
const RESERVED_HOST = /(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)$/;
|
|
24
30
|
const CODE = /^[A-Za-z0-9_-]{16,256}$/;
|
|
25
31
|
const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
|
|
26
32
|
const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
|
|
@@ -212,14 +218,18 @@ export function readDescriptors(dir = servicesDir()) {
|
|
|
212
218
|
|
|
213
219
|
export function validateDescriptor(d) {
|
|
214
220
|
const problems = [];
|
|
215
|
-
|
|
221
|
+
const remote = placementOf(d) === 'remote';
|
|
222
|
+
if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
|
|
223
|
+
const required = ['protocol', 'id', 'instance', 'name', 'origin', 'auth', 'lifecycle', 'installedAt', 'installedBy'];
|
|
224
|
+
for (const key of remote ? required : [...required, 'paths']) {
|
|
216
225
|
if (!(key in d)) problems.push(`missing ${key}`);
|
|
217
226
|
}
|
|
218
227
|
if (problems.length) return problems;
|
|
219
228
|
if (d.protocol !== PROTOCOL) problems.push(`protocol must be ${PROTOCOL}`);
|
|
220
229
|
if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
|
|
221
230
|
if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
|
|
222
|
-
if (
|
|
231
|
+
if (remote) problems.push(...remoteOriginProblems(d.origin));
|
|
232
|
+
else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
223
233
|
if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
|
|
224
234
|
if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
|
|
225
235
|
problems.push('a custom auth header carries the raw token: scheme must be none');
|
|
@@ -228,6 +238,11 @@ export function validateDescriptor(d) {
|
|
|
228
238
|
if (d.lifecycle?.manager === 'launchd' && !(d.lifecycle.label && String(d.lifecycle.plist ?? '').endsWith('.plist'))) {
|
|
229
239
|
problems.push('a launchd service declares label and plist');
|
|
230
240
|
}
|
|
241
|
+
if (remote) {
|
|
242
|
+
if (d.lifecycle?.manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
|
|
243
|
+
for (const field of ['label', 'plist']) if (d.lifecycle?.[field] !== undefined) problems.push(`a remote service has no launchd ${field}`);
|
|
244
|
+
if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
|
|
245
|
+
}
|
|
231
246
|
for (const [name, argv] of Object.entries(d.commands ?? {})) {
|
|
232
247
|
if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
|
|
233
248
|
else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
|
|
@@ -236,6 +251,20 @@ export function validateDescriptor(d) {
|
|
|
236
251
|
return problems;
|
|
237
252
|
}
|
|
238
253
|
|
|
254
|
+
/** DEC-0019: `local` unless the descriptor says `remote`. */
|
|
255
|
+
export function placementOf(d) {
|
|
256
|
+
return d?.placement === 'remote' ? 'remote' : 'local';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Problems with a remote origin: https, a public DNS name, an optional port, nothing else. */
|
|
260
|
+
export function remoteOriginProblems(origin) {
|
|
261
|
+
const m = REMOTE_ORIGIN.exec(String(origin ?? ''));
|
|
262
|
+
if (!m) return ['a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal'];
|
|
263
|
+
if (RESERVED_HOST.test(m[1])) return [`a remote service cannot live on the reserved name ${m[1]}`];
|
|
264
|
+
if (m[2] !== undefined && (Number(m[2]) < 1 || Number(m[2]) > 65535)) return ['the origin port is out of range'];
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
239
268
|
export function portOf(origin) {
|
|
240
269
|
const m = ORIGIN.exec(origin);
|
|
241
270
|
if (!m) throw new ServiceError(`Origin ${origin} is not http://127.0.0.1:<port>.`);
|
|
@@ -246,10 +275,12 @@ export function writeDescriptor(d, dir = servicesDir()) {
|
|
|
246
275
|
const problems = validateDescriptor(d);
|
|
247
276
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
248
277
|
const me = `${d.id}.${d.instance}`;
|
|
249
|
-
|
|
278
|
+
// DEC-0019: only a local placement claims a port on this computer.
|
|
279
|
+
const port = placementOf(d) === 'remote' ? null : portOf(d.origin);
|
|
250
280
|
for (const [file, other] of readDescriptors(dir)) {
|
|
251
281
|
const key = `${other.id}.${other.instance ?? 'default'}`;
|
|
252
282
|
if (key === me) continue;
|
|
283
|
+
if (port === null) continue; // a remote origin's port is another computer's
|
|
253
284
|
let otherPort = null;
|
|
254
285
|
try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
|
|
255
286
|
if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
|
|
@@ -318,25 +349,39 @@ export async function eventsPage(fetch, after, limit) {
|
|
|
318
349
|
// --- operator login -------------------------------------------------------------------
|
|
319
350
|
|
|
320
351
|
export class LoginCodes {
|
|
321
|
-
|
|
322
|
-
|
|
352
|
+
// `stateDir` — a local service keeps codes and the session key in files. An online service
|
|
353
|
+
// (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store`
|
|
354
|
+
// like `new MemoryCodeStore()` (a restart forgets every code, so none can be replayed) and
|
|
355
|
+
// `key` a Buffer from a platform secret, so sessions survive a deploy.
|
|
356
|
+
constructor(stateDir, ttlSeconds = 120, { store = null, key = null } = {}) {
|
|
323
357
|
this.ttl = Math.min(ttlSeconds, 120);
|
|
324
|
-
this.
|
|
325
|
-
this.
|
|
358
|
+
this.store = store;
|
|
359
|
+
this.fixedKey = key;
|
|
360
|
+
if (stateDir) {
|
|
361
|
+
this.dir = ensurePrivateDir(stateDir);
|
|
362
|
+
this.keyPath = path.join(this.dir, 'session.key');
|
|
363
|
+
this.codesPath = path.join(this.dir, 'login-codes.json');
|
|
364
|
+
} else if (!store || !key) {
|
|
365
|
+
throw new ServiceError('LoginCodes without a state directory needs { store, key }.');
|
|
366
|
+
}
|
|
367
|
+
if (key && Buffer.from(key).length < 32) throw new ServiceError('the session key must be at least 32 bytes.');
|
|
326
368
|
}
|
|
327
369
|
|
|
328
370
|
key() {
|
|
371
|
+
if (this.fixedKey) return Buffer.from(this.fixedKey);
|
|
329
372
|
if (!fs.existsSync(this.keyPath)) atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
330
373
|
return fs.readFileSync(this.keyPath);
|
|
331
374
|
}
|
|
332
375
|
|
|
333
376
|
load() {
|
|
377
|
+
if (this.store) return this.store.load();
|
|
334
378
|
try { return JSON.parse(fs.readFileSync(this.codesPath, 'utf8')); } catch { return {}; }
|
|
335
379
|
}
|
|
336
380
|
|
|
337
381
|
save(codes) {
|
|
338
382
|
const horizon = Date.now() / 1000 - 3600;
|
|
339
383
|
const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
|
|
384
|
+
if (this.store) { this.store.save(kept); return; }
|
|
340
385
|
atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
|
|
341
386
|
}
|
|
342
387
|
|
|
@@ -372,11 +417,71 @@ export class LoginCodes {
|
|
|
372
417
|
}
|
|
373
418
|
|
|
374
419
|
revokeAll() {
|
|
420
|
+
if (this.fixedKey) throw new ServiceError('a platform-held session key is rotated on the platform, not here.');
|
|
375
421
|
atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
376
422
|
}
|
|
377
423
|
}
|
|
378
424
|
|
|
425
|
+
/** DEC-0019: a code store held in memory — for an online service with no durable disk. */
|
|
426
|
+
export class MemoryCodeStore {
|
|
427
|
+
constructor() { this.codes = {}; }
|
|
428
|
+
load() { return JSON.parse(JSON.stringify(this.codes)); }
|
|
429
|
+
save(codes) { this.codes = JSON.parse(JSON.stringify(codes)); }
|
|
430
|
+
}
|
|
431
|
+
|
|
379
432
|
export const sessionCookieHeader = (value, maxAge = 30 * 86400) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
433
|
+
/** DEC-0019: the remote cookie — `__Host-` name, Secure, no Domain, Path=/. */
|
|
434
|
+
export const remoteSessionCookieHeader = (value, maxAge = 30 * 86400) => `${REMOTE_SESSION_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* DEC-0019: the request guard of an online service. `origin` is the service's own origin
|
|
438
|
+
* (`https://agent.example.com`). `forwardedProto` is the platform-set scheme where TLS ends
|
|
439
|
+
* before the process (`x-forwarded-proto`); pass `undefined` when the process terminates TLS.
|
|
440
|
+
* Returns the refusal sentence, or null.
|
|
441
|
+
*/
|
|
442
|
+
export function checkRemoteRequest(origin, host, requestOrigin, secFetchSite, forwardedProto) {
|
|
443
|
+
const own = new URL(origin);
|
|
444
|
+
if (String(host ?? '').toLowerCase() !== own.host) return `Host ${host} is not this service.`;
|
|
445
|
+
if (forwardedProto !== undefined && forwardedProto !== null && String(forwardedProto).split(',')[0].trim() !== 'https') return 'This service answers over https only.';
|
|
446
|
+
if (requestOrigin !== undefined && requestOrigin !== null && requestOrigin !== own.origin) return `Origin ${requestOrigin} is not this service.`;
|
|
447
|
+
if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* DEC-0019: may this request read the well-known document? A local service answers anyone (the
|
|
453
|
+
* loopback guard already ran); a remote one only the bearer of the service token. A `false`
|
|
454
|
+
* answer is a `401` with an EMPTY body — nothing about the service is disclosed.
|
|
455
|
+
*/
|
|
456
|
+
export function wellKnownAllowed(placement, authorization, token, scheme = 'Bearer') {
|
|
457
|
+
return placement !== 'remote' || tokenMatches(authorization, token, scheme);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The token directory a host-side installer uses for remote services on this computer. */
|
|
461
|
+
export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
|
|
462
|
+
return path.join(path.dirname(dir), 'tokens', `${id}.${instance}.token`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* DEC-0019, installer side: register an online service on THIS computer. Writes the token file
|
|
467
|
+
* (0600, never printed) and the descriptor; the same token must be set on the hosting platform as
|
|
468
|
+
* a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
|
|
469
|
+
* never from an argument vector.
|
|
470
|
+
*/
|
|
471
|
+
export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(), installedBy = 'fabric-service register-remote' }) {
|
|
472
|
+
if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
|
|
473
|
+
const tokenFile = remoteTokenFile(id, instance, dir);
|
|
474
|
+
const d = {
|
|
475
|
+
protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
|
|
476
|
+
auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
|
|
477
|
+
installedAt: nowIso(), installedBy,
|
|
478
|
+
};
|
|
479
|
+
const problems = validateDescriptor(d);
|
|
480
|
+
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
481
|
+
ensurePrivateDir(path.dirname(tokenFile));
|
|
482
|
+
atomicWrite(tokenFile, String(token).trim(), 0o600);
|
|
483
|
+
return writeDescriptor(d, dir);
|
|
484
|
+
}
|
|
380
485
|
|
|
381
486
|
export function cookieValue(header, name = SESSION_COOKIE) {
|
|
382
487
|
for (const part of String(header ?? '').split(';')) {
|
|
@@ -30,6 +30,7 @@ import tempfile
|
|
|
30
30
|
import time
|
|
31
31
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
32
32
|
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
33
34
|
import urllib.request
|
|
34
35
|
|
|
35
36
|
PROTOCOL = "fabric-service/0.1"
|
|
@@ -45,6 +46,10 @@ _ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
|
45
46
|
_INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
46
47
|
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
|
|
47
48
|
_ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
|
|
49
|
+
# DEC-0019: a remote placement — an online agent or dashboard — lives at an https DNS name.
|
|
50
|
+
_REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?$")
|
|
51
|
+
_RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)$")
|
|
52
|
+
PLACEMENTS = ("local", "remote")
|
|
48
53
|
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
|
49
54
|
_TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
|
|
50
55
|
_SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
@@ -275,7 +280,12 @@ def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[
|
|
|
275
280
|
def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
276
281
|
"""Structural checks mirroring service-descriptor.schema.json (the schema stays normative)."""
|
|
277
282
|
problems: List[str] = []
|
|
278
|
-
|
|
283
|
+
remote = placement_of(descriptor) == "remote"
|
|
284
|
+
if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
|
|
285
|
+
problems.append("placement must be local or remote")
|
|
286
|
+
required = ("protocol", "id", "instance", "name", "origin", "auth", "lifecycle", "installedAt", "installedBy")
|
|
287
|
+
if not remote:
|
|
288
|
+
required = required + ("paths",)
|
|
279
289
|
for key in required:
|
|
280
290
|
if key not in descriptor:
|
|
281
291
|
problems.append("missing %s" % key)
|
|
@@ -287,7 +297,9 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
287
297
|
problems.append("id must match %s" % _ID.pattern)
|
|
288
298
|
if not _INSTANCE.match(str(descriptor["instance"])):
|
|
289
299
|
problems.append("instance must match %s" % _INSTANCE.pattern)
|
|
290
|
-
if
|
|
300
|
+
if remote:
|
|
301
|
+
problems.extend(remote_origin_problems(descriptor["origin"]))
|
|
302
|
+
elif not _ORIGIN.match(str(descriptor["origin"])):
|
|
291
303
|
problems.append("origin must be http://127.0.0.1:<port>")
|
|
292
304
|
auth = descriptor.get("auth") or {}
|
|
293
305
|
if "tokenFile" not in auth:
|
|
@@ -301,6 +313,14 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
301
313
|
problems.append("lifecycle.manager must be launchd or none")
|
|
302
314
|
if life.get("manager") == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
303
315
|
problems.append("a launchd service declares label and plist")
|
|
316
|
+
if remote:
|
|
317
|
+
if life.get("manager") != "none":
|
|
318
|
+
problems.append("a remote service is supervised by its platform: lifecycle.manager must be none")
|
|
319
|
+
for field in ("label", "plist"):
|
|
320
|
+
if field in life:
|
|
321
|
+
problems.append("a remote service has no launchd %s" % field)
|
|
322
|
+
if "update" in (descriptor.get("commands") or {}):
|
|
323
|
+
problems.append("a remote service declares no update command")
|
|
304
324
|
for name, argv in (descriptor.get("commands") or {}).items():
|
|
305
325
|
if name not in ("doctor", "update"):
|
|
306
326
|
problems.append("unknown command %s" % name)
|
|
@@ -311,6 +331,23 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
311
331
|
return problems
|
|
312
332
|
|
|
313
333
|
|
|
334
|
+
def placement_of(descriptor: Dict[str, Any]) -> str:
|
|
335
|
+
"""DEC-0019: ``local`` unless the descriptor says ``remote``."""
|
|
336
|
+
return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def remote_origin_problems(origin: Any) -> List[str]:
|
|
340
|
+
"""Problems with a remote origin: https, a public DNS name, an optional port, nothing else."""
|
|
341
|
+
match = _REMOTE_ORIGIN.match(str(origin or ""))
|
|
342
|
+
if not match:
|
|
343
|
+
return ["a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal"]
|
|
344
|
+
if _RESERVED_HOST.search(match.group(1)):
|
|
345
|
+
return ["a remote service cannot live on the reserved name %s" % match.group(1)]
|
|
346
|
+
if match.group(2) is not None and not 1 <= int(match.group(2)) <= 65535:
|
|
347
|
+
return ["the origin port is out of range"]
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
|
|
314
351
|
def port_of(origin: str) -> int:
|
|
315
352
|
match = _ORIGIN.match(origin)
|
|
316
353
|
if not match:
|
|
@@ -325,10 +362,11 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
|
|
|
325
362
|
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
326
363
|
root = directory or services_dir()
|
|
327
364
|
me = "%s.%s" % (descriptor["id"], descriptor["instance"])
|
|
328
|
-
port
|
|
365
|
+
# DEC-0019: only a local placement claims a port on this computer.
|
|
366
|
+
port = None if placement_of(descriptor) == "remote" else port_of(descriptor["origin"])
|
|
329
367
|
for path, other in read_descriptors(root):
|
|
330
368
|
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
331
|
-
if key == me:
|
|
369
|
+
if key == me or port is None: # a remote origin's port is another computer's
|
|
332
370
|
continue
|
|
333
371
|
try:
|
|
334
372
|
other_port = port_of(str(other.get("origin", "")))
|
|
@@ -501,18 +539,33 @@ class LoginCodes:
|
|
|
501
539
|
"""Single-use login codes (<=120 s), recorded as used BEFORE they are honoured,
|
|
502
540
|
and HMAC-signed session cookies revoked by rotating the key."""
|
|
503
541
|
|
|
504
|
-
def __init__(self, state_dir: Path, ttl: int = LOGIN_CODE_TTL_SECONDS
|
|
505
|
-
|
|
542
|
+
def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
|
|
543
|
+
store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None):
|
|
544
|
+
# A local service keeps codes and the key in files. An online one (DEC-0019) usually has no
|
|
545
|
+
# durable disk: pass state_dir=None with store=MemoryCodeStore() (a restart forgets every
|
|
546
|
+
# code, so none can be replayed) and key= bytes from a platform secret (sessions survive a deploy).
|
|
506
547
|
self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
|
|
507
|
-
self.
|
|
508
|
-
self.
|
|
548
|
+
self._store = store
|
|
549
|
+
self._fixed_key = key
|
|
550
|
+
if state_dir is not None:
|
|
551
|
+
self.dir = ensure_private_dir(Path(state_dir))
|
|
552
|
+
self._key_path = self.dir / "session.key"
|
|
553
|
+
self._codes_path = self.dir / "login-codes.json"
|
|
554
|
+
elif store is None or key is None:
|
|
555
|
+
raise ServiceError("LoginCodes without a state directory needs store= and key=.")
|
|
556
|
+
if key is not None and len(key) < 32:
|
|
557
|
+
raise ServiceError("the session key must be at least 32 bytes.")
|
|
509
558
|
|
|
510
559
|
def _key(self) -> bytes:
|
|
560
|
+
if self._fixed_key is not None:
|
|
561
|
+
return bytes(self._fixed_key)
|
|
511
562
|
if not self._key_path.exists():
|
|
512
563
|
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
513
564
|
return self._key_path.read_bytes()
|
|
514
565
|
|
|
515
566
|
def _load(self) -> Dict[str, Any]:
|
|
567
|
+
if self._store is not None:
|
|
568
|
+
return self._store.load()
|
|
516
569
|
try:
|
|
517
570
|
return json.loads(self._codes_path.read_text())
|
|
518
571
|
except (OSError, ValueError):
|
|
@@ -521,6 +574,9 @@ class LoginCodes:
|
|
|
521
574
|
def _save(self, codes: Dict[str, Any]) -> None:
|
|
522
575
|
horizon = time.time() - 3600
|
|
523
576
|
codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
|
|
577
|
+
if self._store is not None:
|
|
578
|
+
self._store.save(codes)
|
|
579
|
+
return
|
|
524
580
|
atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
|
|
525
581
|
|
|
526
582
|
def issue(self) -> Dict[str, str]:
|
|
@@ -559,16 +615,89 @@ class LoginCodes:
|
|
|
559
615
|
return hmac.compare_digest(mac, expected)
|
|
560
616
|
|
|
561
617
|
def revoke_all(self) -> None:
|
|
618
|
+
if self._fixed_key is not None:
|
|
619
|
+
raise ServiceError("a platform-held session key is rotated on the platform, not here.")
|
|
562
620
|
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
563
621
|
|
|
564
622
|
|
|
623
|
+
class MemoryCodeStore:
|
|
624
|
+
"""DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
|
|
625
|
+
|
|
626
|
+
def __init__(self) -> None:
|
|
627
|
+
self._codes: Dict[str, Any] = {}
|
|
628
|
+
|
|
629
|
+
def load(self) -> Dict[str, Any]:
|
|
630
|
+
return json.loads(json.dumps(self._codes))
|
|
631
|
+
|
|
632
|
+
def save(self, codes: Dict[str, Any]) -> None:
|
|
633
|
+
self._codes = json.loads(json.dumps(codes))
|
|
634
|
+
|
|
635
|
+
|
|
565
636
|
SESSION_COOKIE = "fabric_session"
|
|
637
|
+
REMOTE_SESSION_COOKIE = "__Host-fabric_session"
|
|
566
638
|
|
|
567
639
|
|
|
568
640
|
def session_cookie_header(value: str, max_age: int = 30 * 86400) -> str:
|
|
569
641
|
return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
|
|
570
642
|
|
|
571
643
|
|
|
644
|
+
def remote_session_cookie_header(value: str, max_age: int = 30 * 86400) -> str:
|
|
645
|
+
"""DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
|
|
646
|
+
return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
|
|
650
|
+
sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
|
|
651
|
+
"""DEC-0019: the request guard of an online service; returns the refusal sentence or None.
|
|
652
|
+
``forwarded_proto`` is the platform-set scheme where TLS ends before the process; pass None
|
|
653
|
+
when the process terminates TLS itself."""
|
|
654
|
+
own = urllib.parse.urlsplit(origin)
|
|
655
|
+
own_host = own.netloc.lower()
|
|
656
|
+
if str(host or "").lower() != own_host:
|
|
657
|
+
return "Host %s is not this service." % host
|
|
658
|
+
if forwarded_proto is not None and str(forwarded_proto).split(",")[0].strip() != "https":
|
|
659
|
+
return "This service answers over https only."
|
|
660
|
+
if request_origin is not None and request_origin != "%s://%s" % (own.scheme, own_host):
|
|
661
|
+
return "Origin %s is not this service." % request_origin
|
|
662
|
+
if sec_fetch_site == "cross-site":
|
|
663
|
+
return "Cross-site requests are refused."
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def well_known_allowed(placement: str, authorization: Optional[str], token: str, scheme: str = "Bearer") -> bool:
|
|
668
|
+
"""DEC-0019: a local service answers anyone (the loopback guard already ran); a remote one only
|
|
669
|
+
the bearer of the service token. False is a 401 with an EMPTY body."""
|
|
670
|
+
return placement != "remote" or token_matches(authorization, token, scheme)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def remote_token_file(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
674
|
+
return (directory or services_dir()).parent / "tokens" / ("%s.%s.token" % (service_id, instance))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
|
|
678
|
+
summary: Optional[str] = None, doctor: Optional[List[str]] = None,
|
|
679
|
+
directory: Optional[Path] = None, installed_by: str = "fabric-service register-remote") -> Path:
|
|
680
|
+
"""DEC-0019, installer side: register an online service on THIS computer — the token file
|
|
681
|
+
(0600, never printed) and the descriptor. The same token is set on the platform as a secret."""
|
|
682
|
+
if not token or len(token.strip()) < 16:
|
|
683
|
+
raise ServiceError("the service token must be at least 16 characters.")
|
|
684
|
+
root = directory or services_dir()
|
|
685
|
+
token_file = remote_token_file(service_id, instance, root)
|
|
686
|
+
descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
|
|
687
|
+
"placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
|
|
688
|
+
"lifecycle": {"manager": "none"}, "installedAt": now_iso(), "installedBy": installed_by}
|
|
689
|
+
if summary:
|
|
690
|
+
descriptor["summary"] = summary
|
|
691
|
+
if doctor:
|
|
692
|
+
descriptor["commands"] = {"doctor": list(doctor)}
|
|
693
|
+
problems = validate_descriptor(descriptor)
|
|
694
|
+
if problems:
|
|
695
|
+
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
696
|
+
ensure_private_dir(token_file.parent)
|
|
697
|
+
atomic_write(token_file, token.strip().encode(), 0o600)
|
|
698
|
+
return write_descriptor(descriptor, root)
|
|
699
|
+
|
|
700
|
+
|
|
572
701
|
def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
|
|
573
702
|
for part in (cookie_header or "").split(";"):
|
|
574
703
|
key, _, value = part.strip().partition("=")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A complete ONLINE Fabric service (fabric-service/0.1, remote placement — DEC-0019): the four
|
|
3
|
+
// protocol routes and a dashboard page, Node.js 20+, no dependencies beside the kit.
|
|
4
|
+
//
|
|
5
|
+
// It runs two ways, the two an online service meets in practice:
|
|
6
|
+
// - terminating TLS itself: --tls-cert cert.pem --tls-key key.pem
|
|
7
|
+
// - behind a platform router that ends TLS (a PaaS): no TLS flags; the router's
|
|
8
|
+
// `x-forwarded-proto` must say https or the request is refused.
|
|
9
|
+
//
|
|
10
|
+
// node sample-remote-service.mjs --origin https://agent.example.com --port 8443 \
|
|
11
|
+
// --token-file token --session-key-file session.key [--tls-cert c.pem --tls-key k.pem]
|
|
12
|
+
//
|
|
13
|
+
// Secrets come from files (or from the platform as FABRIC_SERVICE_TOKEN / FABRIC_SESSION_KEY),
|
|
14
|
+
// never from an argument vector.
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import https from 'node:https';
|
|
19
|
+
import * as k from './fabric-service.mjs';
|
|
20
|
+
|
|
21
|
+
const args = Object.fromEntries(process.argv.slice(2).reduce((acc, a, i, all) => (a.startsWith('--') ? [...acc, [a.slice(2), all[i + 1]]] : acc), []));
|
|
22
|
+
const origin = args.origin ?? process.env.FABRIC_SERVICE_ORIGIN;
|
|
23
|
+
const port = Number(args.port ?? process.env.PORT ?? 8443);
|
|
24
|
+
const token = args['token-file'] ? fs.readFileSync(args['token-file'], 'utf8').trim() : String(process.env.FABRIC_SERVICE_TOKEN ?? '').trim();
|
|
25
|
+
const key = args['session-key-file'] ? fs.readFileSync(args['session-key-file']) : Buffer.from(String(process.env.FABRIC_SESSION_KEY ?? ''), 'base64');
|
|
26
|
+
if (!origin || k.remoteOriginProblems(origin).length) { console.error(`origin: ${k.remoteOriginProblems(origin).join('; ') || 'missing'}`); process.exit(2); }
|
|
27
|
+
if (token.length < 16) { console.error('the service token is missing or shorter than 16 characters'); process.exit(2); }
|
|
28
|
+
|
|
29
|
+
const startedAt = k.nowIso();
|
|
30
|
+
const codes = new k.LoginCodes(null, 120, { store: new k.MemoryCodeStore(), key });
|
|
31
|
+
const events = [k.makeEvent('1', startedAt, 'service.started', 'info', 'The example agent started.')];
|
|
32
|
+
const tlsSelf = Boolean(args['tls-cert']);
|
|
33
|
+
|
|
34
|
+
function send(res, status, body, headers = {}) {
|
|
35
|
+
const payload = body === null ? '' : (typeof body === 'string' ? body : JSON.stringify(body));
|
|
36
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...(body === null || typeof body === 'string' ? {} : { 'Content-Type': 'application/json' }), ...headers });
|
|
37
|
+
res.end(payload);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handler(req, res) {
|
|
41
|
+
const url = new URL(req.url, origin);
|
|
42
|
+
const refusal = k.checkRemoteRequest(origin, req.headers.host, req.headers.origin, req.headers['sec-fetch-site'], tlsSelf ? undefined : (req.headers['x-forwarded-proto'] ?? 'http'));
|
|
43
|
+
if (refusal) return send(res, 403, refusal, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
44
|
+
const authorized = k.tokenMatches(req.headers.authorization, token);
|
|
45
|
+
|
|
46
|
+
if (req.method === 'GET' && url.pathname === '/.well-known/fabric-service') {
|
|
47
|
+
if (!k.wellKnownAllowed('remote', req.headers.authorization, token)) return send(res, 401, null);
|
|
48
|
+
return send(res, 200, k.buildWellKnown({
|
|
49
|
+
id: 'example-agent', instance: 'default', name: 'Example Agent', version: '0.1.0', build: { commit: '0000000' },
|
|
50
|
+
startedAt, status: 'ready', degraded: [],
|
|
51
|
+
surfaces: { dashboard: { path: '/', login: true }, events: { path: '/fabric/v1/events' } },
|
|
52
|
+
summary: [{ label: 'Jobs today', value: events.length }],
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
if (req.method === 'GET' && url.pathname === '/fabric/v1/events') {
|
|
56
|
+
if (!authorized) return send(res, 401, null);
|
|
57
|
+
return k.eventsPage(async (after, limit) => events.filter((e) => after === null || Number(e.id) > Number(after)).slice(-limit), url.searchParams.get('after'), k.parseLimit(url.searchParams.get('limit'))).then((page) => send(res, 200, page));
|
|
58
|
+
}
|
|
59
|
+
if (req.method === 'POST' && url.pathname === '/fabric/v1/login-code') {
|
|
60
|
+
if (!authorized) return send(res, 401, null);
|
|
61
|
+
return send(res, 200, codes.issue());
|
|
62
|
+
}
|
|
63
|
+
if (req.method === 'GET' && url.pathname === '/fabric/v1/login') {
|
|
64
|
+
const session = codes.redeem(url.searchParams.get('code'));
|
|
65
|
+
if (!session) return send(res, 403, 'This login link has expired or was used. Open the dashboard from Fabric again.', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
66
|
+
return send(res, 302, null, { Location: '/', 'Set-Cookie': k.remoteSessionCookieHeader(session) });
|
|
67
|
+
}
|
|
68
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
69
|
+
if (!codes.sessionValid(k.cookieValue(req.headers.cookie, k.REMOTE_SESSION_COOKIE))) return send(res, 401, 'Sign in from Fabric Dashboards.', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
70
|
+
return send(res, 200, '<!doctype html><meta charset="utf-8"><title>Example Agent</title><h1>Example Agent</h1>', { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'" });
|
|
71
|
+
}
|
|
72
|
+
return send(res, 404, null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const server = tlsSelf
|
|
76
|
+
? https.createServer({ cert: fs.readFileSync(args['tls-cert']), key: fs.readFileSync(args['tls-key']) }, handler)
|
|
77
|
+
: http.createServer(handler);
|
|
78
|
+
server.listen(port, tlsSelf ? '127.0.0.1' : '0.0.0.0', () => console.log(`example agent on ${origin} (listening on ${port}${tlsSelf ? ', TLS' : ', behind a TLS router'})`));
|
|
79
|
+
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => server.close(() => process.exit(0)));
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.6.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -112,7 +112,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
112
112
|
--schema-base <immutable-base-uri>
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
Pin exactly contract `0.1.0` at commit `
|
|
115
|
+
Pin exactly contract `0.1.0` at commit `2ce392291c6668598d12cd38327e24696b5ca15c` and
|
|
116
116
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
117
117
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
118
118
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "passioncode",
|
|
4
4
|
"displayName": "PassionCode.ai",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.21",
|
|
6
6
|
"description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|