@passioncode-ai/passioncode 0.1.19 → 0.1.20

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20 - 2026-10-02
4
+
5
+ ### Changed
6
+
7
+ - **Fabric Agent Adapter is pinned at `v0.6.0`:** `building-fabric-services` also makes an online
8
+ agent or dashboard (an `https` origin on a platform) a Fabric service — the remote placement of
9
+ `fabric-service/0.1`, Fabric Agent Contract DEC-0019 — with Node and Python kits, a TLS sample
10
+ online service and a probe that checks remote services over verified TLS.
11
+
3
12
  ## 0.1.19 - 2026-10-02
4
13
 
5
14
  ### Changed
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.5.7",
11
+ "ref": "v0.6.0",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
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.5.7",
14
+ "version": "0.6.0",
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.19",
38
+ "version": "0.1.20",
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",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
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.5.7",
11
- "commit": "de21d9548bc836fe09ca6049afb647b11450dda8",
12
- "version": "0.5.7",
10
+ "ref": "v0.6.0",
11
+ "commit": "447b55819b83d25f168afa7abd99cafa0ab29812",
12
+ "version": "0.6.0",
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:bebf6fe75b96b059558f0ad4f771db6784b07f429f88db2b5e285f3420284d5a",
25
+ "contentHash": "sha256:f714e1620093378f3c790287270608ad8b6084109e813eead03dea9c159e964e",
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.19",
64
+ "ref": "v0.1.20",
65
65
  "commit": null,
66
- "version": "0.1.19",
66
+ "version": "0.1.20",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:1ae5a6355e3e3692bc95172ba297cbb7ed1d048dae8410c6766f376e6336f320",
73
+ "contentHash": "sha256:b230828150120df33b6c48eccf6a6d4e20e35f0dff8089df4d9554c29957016a",
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.5.7",
6
+ "version": "0.6.0",
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.5.7"
8
+ version: "0.6.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
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 `74d3852f122f5ca5cbc4138a201483531dfa5006`.
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 = "74d3852f122f5ca5cbc4138a201483531dfa5006"
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, or building a local agent service on
5
- a macOS machine — «сделай агенту дашборд», «локальный сервис агента», «дашборд должен всегда
6
- работать и не плодить копии», «где агенту хранить настройки», «подключи агента к Fabric
7
- Dashboards», "make this agent a local service", "always-on dashboard", "fabric-service
8
- protocol", "add the well-known endpoint", "migrate a service to fabric-service". Covers the
9
- fabric-service/0.1 extension: which surface to expose (MCP streamable HTTP, CLI, A2A), the token
10
- and one-time login, where state, config, logs and cache live, one copy per machine, launchd, the
11
- descriptor, the well-known document, the events feed and notifications; ships Python and Node
12
- reference kits and a live conformance probe. NOT for a one-off script or cron job, a hosted
13
- SaaS, the provider manifest itself (adapting-projects-to-fabric), or building Fabric Dashboards.
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.5.7"
18
+ version: "0.6.0"
19
19
  contract-version: "0.1.0"
20
20
  extension: "fabric-service/0.1"
21
- extension-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
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 SaaS — the protocol is loopback-only by design;
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
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `74d3852f122f5ca5cbc4138a201483531dfa5006`
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,63 @@
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
+ ## What a host does and will not do
48
+
49
+ | Situation | Host |
50
+ |---|---|
51
+ | `401` on the well-known document | `down` — the service refused the token; never `foreign` |
52
+ | answer names another `id.instance` | `foreign`; the token is not sent again until the descriptor changes |
53
+ | certificate invalid or for another name | `down`, the reason names TLS |
54
+ | redirect | not followed; `down` |
55
+ | one probe missed | not an outage; the last state holds until the threshold |
56
+ | start, stop, restart, update | not offered |
57
+ | a host older than the remote placement | shows the service as invalid and never contacts it |
58
+
59
+ ## Rotation
60
+
61
+ Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
62
+ operator's computer with it; the old token stops working the moment the platform restarts with the
63
+ 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,25 @@ def priority_problems(plist: dict) -> list:
69
79
  return out
70
80
 
71
81
 
82
+ class _PinnedHTTPS(http.client.HTTPSConnection):
83
+ """HTTPS to the origin's name (SNI, certificate, Host) over a socket dialled elsewhere."""
84
+
85
+ def __init__(self, name: str, port: int, dial: Tuple[str, int], context: ssl.SSLContext, timeout: float):
86
+ super().__init__(name, port, context=context, timeout=timeout)
87
+ self._dial = dial
88
+ self._ctx = context
89
+
90
+ def connect(self) -> None:
91
+ raw = socket.create_connection(self._dial, timeout=self.timeout)
92
+ self.sock = self._ctx.wrap_socket(raw, server_hostname=self.host)
93
+
94
+
72
95
  class Probe:
73
- def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool):
96
+ def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool,
97
+ ca_file: Optional[str] = None, connect: Optional[str] = None):
98
+ self.remote = fs.placement_of(descriptor) == "remote"
99
+ self.ca_file = ca_file
100
+ self.connect = connect
74
101
  self.path = descriptor_path
75
102
  self.d = descriptor
76
103
  self.dir = services_dir
@@ -80,6 +107,13 @@ class Probe:
80
107
  self.token: Optional[str] = None
81
108
  self.events: Optional[List[Dict[str, Any]]] = None
82
109
  self.sent_traceparent: Optional[str] = None
110
+ self.netloc = ""
111
+ if self.remote:
112
+ parts = urllib.parse.urlsplit(str(descriptor.get("origin", "")))
113
+ self.netloc = parts.netloc
114
+ self.host_name = parts.hostname or ""
115
+ self.port = (parts.port or 443) if not fs.remote_origin_problems(descriptor.get("origin")) else 0
116
+ return
83
117
  try:
84
118
  self.port = fs.port_of(str(descriptor.get("origin", "")))
85
119
  except fs.ServiceError:
@@ -90,8 +124,17 @@ class Probe:
90
124
 
91
125
  def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
92
126
  body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
93
- conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
94
- base = {"Host": "127.0.0.1:%d" % self.port}
127
+ if self.remote:
128
+ context = ssl.create_default_context(cafile=self.ca_file) if self.ca_file else ssl.create_default_context()
129
+ if self.connect:
130
+ host, _, port = self.connect.rpartition(":")
131
+ conn = _PinnedHTTPS(self.host_name, self.port, (host, int(port)), context, 8)
132
+ else:
133
+ conn = http.client.HTTPSConnection(self.host_name, self.port, context=context, timeout=8)
134
+ base = {"Host": self.netloc}
135
+ else:
136
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
137
+ base = {"Host": "127.0.0.1:%d" % self.port}
95
138
  base.update(headers or {})
96
139
  try:
97
140
  conn.request(method, path, body=body, headers=base)
@@ -120,17 +163,32 @@ class Probe:
120
163
  continue
121
164
  if key == me:
122
165
  clashes.append("%s declared again in %s" % (me, path.name))
123
- elif other.get("origin") == self.d.get("origin"):
166
+ elif not self.remote and fs.placement_of(other) != "remote" and other.get("origin") == self.d.get("origin"):
124
167
  clashes.append("port %d also claimed by %s" % (self.port, key))
125
- self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or "port %d is unique" % self.port)
168
+ unique = "a remote origin claims no port here" if self.remote else "port %d is unique" % self.port
169
+ self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
126
170
 
127
171
  # well-known ----------------------------------------------------------------
128
172
  def well_known_rules(self) -> None:
173
+ protected: Dict[str, str] = {}
174
+ if self.remote:
175
+ try:
176
+ status, _, body = self.request("GET", "/.well-known/fabric-service")
177
+ except (OSError, ssl.SSLError) as exc:
178
+ self.add("well-known.answers", "FAIL", "no TLS answer on %s: %s" % (self.d.get("origin"), exc))
179
+ return
180
+ self.add("well-known.requires-token", "PASS" if status == 401 and not body else "FAIL",
181
+ "HTTP %d without a token, %d byte(s)" % (status, len(body)))
182
+ self.read_token()
183
+ if not self.token:
184
+ self.add("well-known.answers", "NOT_RUN", "no readable token to ask with")
185
+ return
186
+ protected = self.auth_headers()
129
187
  try:
130
188
  timings = []
131
189
  for _ in range(3):
132
190
  started = time.perf_counter()
133
- status, headers, body = self.request("GET", "/.well-known/fabric-service")
191
+ status, headers, body = self.request("GET", "/.well-known/fabric-service", protected)
134
192
  timings.append((time.perf_counter() - started) * 1000)
135
193
  except OSError as exc:
136
194
  self.add("well-known.answers", "FAIL", "no answer on %s: %s" % (self.d.get("origin"), exc))
@@ -145,7 +203,10 @@ class Probe:
145
203
  return
146
204
  self.add("well-known.answers", "PASS", "HTTP 200")
147
205
  median = sorted(timings)[1]
148
- self.add("well-known.fast", "PASS" if median < 100 else "FAIL", "median %.1f ms" % median)
206
+ if self.remote:
207
+ self.add("well-known.fast", "PASS" if median < 8000 else "FAIL", "median %.1f ms (remote: no 100 ms budget)" % median)
208
+ else:
209
+ self.add("well-known.fast", "PASS" if median < 100 else "FAIL", "median %.1f ms" % median)
149
210
  wk = self.wk
150
211
  problems = []
151
212
  if wk.get("protocol") != fs.PROTOCOL:
@@ -177,10 +238,18 @@ class Probe:
177
238
  ("network.cross-site-check", {"Sec-Fetch-Site": "cross-site"}),
178
239
  ):
179
240
  try:
180
- status, _, _ = self.request("GET", "/.well-known/fabric-service", headers)
241
+ sent = dict(headers)
242
+ if self.remote:
243
+ sent.update(self.auth_headers())
244
+ if "Origin" in sent:
245
+ sent["Origin"] = "https://evil.example"
246
+ status, _, _ = self.request("GET", "/.well-known/fabric-service", sent)
181
247
  self.add(rule, "PASS" if status == 403 else "FAIL", "HTTP %d" % status)
182
- except OSError as exc:
248
+ except (OSError, ssl.SSLError) as exc:
183
249
  self.add(rule, "NOT_RUN", str(exc))
250
+ if self.remote:
251
+ self.add("network.loopback-only", "NOT_RUN", "a remote placement is reached over https, not loopback")
252
+ return
184
253
  if not shutil.which("lsof"):
185
254
  self.add("network.loopback-only", "NOT_RUN", "lsof is not installed")
186
255
  return
@@ -191,13 +260,18 @@ class Probe:
191
260
  ", ".join(names) or "nothing listens on %d" % self.port)
192
261
 
193
262
  # auth, events, login ----------------------------------------------------------
194
- def auth_rules(self) -> None:
263
+ def read_token(self) -> None:
264
+ if any(r["rule"] == "auth.token-file" for r in self.results):
265
+ return
195
266
  token_file = str((self.d.get("auth") or {}).get("tokenFile", ""))
196
267
  try:
197
268
  self.token = fs.read_token(fs.expand(token_file))
198
269
  self.add("auth.token-file", "PASS", "%s is 0600 and owned by you" % token_file)
199
270
  except (fs.ServiceError, OSError) as exc:
200
271
  self.add("auth.token-file", "FAIL", str(exc))
272
+
273
+ def auth_rules(self) -> None:
274
+ self.read_token()
201
275
  events_path = ((self.wk or {}).get("surfaces") or {}).get("events", {}).get("path", "/fabric/v1/events")
202
276
  try:
203
277
  status, _, _ = self.request("GET", events_path + "?limit=1")
@@ -251,6 +325,10 @@ class Probe:
251
325
  return
252
326
  cookie = headers1.get("set-cookie", "")
253
327
  ok = status1 in (302, 303) and "HttpOnly" in cookie and "SameSite=Strict" in cookie and status2 not in (302, 303)
328
+ if self.remote:
329
+ host_bound = cookie.startswith("__Host-") and "Secure" in cookie and "Path=/" in cookie and "domain=" not in cookie.lower()
330
+ self.add("login.cookie-host-bound", "PASS" if host_bound else "FAIL",
331
+ "the session cookie is __Host-, Secure, Path=/, no Domain" if host_bound else "cookie: %s" % cookie.split(";", 1)[0].split("=")[0])
254
332
  self.add("login.single-use", "PASS" if ok else "FAIL",
255
333
  "first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
256
334
 
@@ -432,6 +510,9 @@ class Probe:
432
510
 
433
511
  # lifecycle ----------------------------------------------------------------
434
512
  def lifecycle_rules(self) -> None:
513
+ if self.remote:
514
+ self.add("lifecycle.platform", "NOT_RUN", "a remote placement is supervised by its platform; launchd, lock and state rules do not apply")
515
+ return
435
516
  life = self.d.get("lifecycle") or {}
436
517
  data = fs.expand(str((self.d.get("paths") or {}).get("data", "~/")))
437
518
  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 +636,8 @@ def main(argv: Optional[List[str]] = None) -> int:
555
636
  parser.add_argument("--services-dir")
556
637
  parser.add_argument("--json", action="store_true")
557
638
  parser.add_argument("--skip-login", action="store_true")
639
+ parser.add_argument("--ca-file", help="remote placement: trust this CA bundle instead of the system store (tests)")
640
+ parser.add_argument("--connect", help="remote placement: dial HOST:PORT while speaking TLS to the origin's name (tests)")
558
641
  args = parser.parse_args(argv)
559
642
  if not args.target and not args.descriptor:
560
643
  parser.print_usage(sys.stderr)
@@ -566,7 +649,7 @@ def main(argv: Optional[List[str]] = None) -> int:
566
649
  except (OSError, ValueError) as exc:
567
650
  print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
568
651
  return 1
569
- results = Probe(path, descriptor, services_dir, args.skip_login).run()
652
+ results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect).run()
570
653
  failed = sum(r["verdict"] == "FAIL" for r in results)
571
654
  if args.json:
572
655
  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
- for (const key of ['protocol', 'id', 'instance', 'name', 'origin', 'auth', 'lifecycle', 'paths', 'installedAt', 'installedBy']) {
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 (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
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
- const port = portOf(d.origin);
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
- constructor(stateDir, ttlSeconds = 120) {
322
- this.dir = ensurePrivateDir(stateDir);
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.keyPath = path.join(this.dir, 'session.key');
325
- this.codesPath = path.join(this.dir, 'login-codes.json');
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
- required = ("protocol", "id", "instance", "name", "origin", "auth", "lifecycle", "paths", "installedAt", "installedBy")
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 not _ORIGIN.match(str(descriptor["origin"])):
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 = port_of(descriptor["origin"])
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
- self.dir = ensure_private_dir(Path(state_dir))
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._key_path = self.dir / "session.key"
508
- self._codes_path = self.dir / "login-codes.json"
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.5.7"
8
+ version: "0.6.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
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 `74d3852f122f5ca5cbc4138a201483531dfa5006` and
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.19",
5
+ "version": "0.1.20",
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",