@passioncode-ai/passioncode 0.1.40 → 0.1.42
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 +12 -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 +7 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +59 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +132 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -19
- 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/surfaces-and-auth.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +342 -20
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +308 -7
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.42 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Fabric Agent Adapter is pinned at `v0.18.0`** (it was `v0.17.0`). Fabric Agent Adapter 0.18.0 (FAA-21): both service kits write and install the Linux and Windows supervisors — a systemd user unit and a per-user Scheduled Task, byte-identical between the kits, never carrying a secret, an upgrade restarting what runs while the operator's off stays off. The Node kit runs on Windows: the folders, descriptor validation at parity with Python, the token file's protected ACL and a named-pipe instance lock; the two kits now exclude each other on every system and the conformance probe sees a Node service's lock.
|
|
8
|
+
|
|
9
|
+
## 0.1.41 - 2026-10-12
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Fabric Agent Adapter is pinned at `v0.17.0`** (it was `v0.16.0`). The organisation's lifecycle rules LC-19 and LC-21 are now enforced by the adapter's checks. No plugin may start a stdio MCP server in every session, and no credential may appear in argv or a URL query. The checks are in adapt_project.py check, fabric_provider.py and the adapter's own validate gate.
|
|
14
|
+
|
|
3
15
|
## 0.1.40 - 2026-10-12
|
|
4
16
|
|
|
5
17
|
### 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.42",
|
|
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.18.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.
|
|
38
|
+
"version": "0.1.42",
|
|
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.42",
|
|
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.18.0",
|
|
11
|
+
"commit": "10a8d65bb28c3eb6d0bf2d839b53efb340e42f9d",
|
|
12
|
+
"version": "0.18.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:
|
|
25
|
+
"contentHash": "sha256:bf6c40c50d4c5c4ca165191ae0ce0c5edfcb7744729b8384fd75c5b921331b05",
|
|
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.42",
|
|
65
65
|
"commit": null,
|
|
66
|
-
"version": "0.1.
|
|
66
|
+
"version": "0.1.42",
|
|
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:44d408186f1bbeec38fd7d033b0141ee9b581cdc4dc9dc64a96938df288c15e9",
|
|
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.18.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ 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.18.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
11
11
|
---
|
|
@@ -173,6 +173,12 @@ python3 <skill-dir>/scripts/adapt_project.py check <project-root> \
|
|
|
173
173
|
--contract /path/to/fabric-agent-contract --json
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
+
The local check also refuses two lifecycle faults (LC-19, LC-21): a Claude Code plugin in
|
|
177
|
+
the project that declares a stdio MCP server — it starts in every session that enables the
|
|
178
|
+
plugin; serve it over HTTP from one resident process or register it at project scope — and a
|
|
179
|
+
credential in a launch argument or a URL query, where every process and every log can read it.
|
|
180
|
+
Fix both at their cause; never weaken the check.
|
|
181
|
+
|
|
176
182
|
Load [verification](references/verification.md) before interpreting or publishing the
|
|
177
183
|
result. Fix every local error. Fix every placeholder warning or record why the bundle is
|
|
178
184
|
still a draft. Do not convert `NOT_RUN` or `NOT_VERIFIED` to `PASS` without the named
|
|
@@ -35,7 +35,8 @@ python3 scripts/fabric_provider.py remove example-agent # the uninstaller
|
|
|
35
35
|
`--stdio` takes the rest of the line as the argument array; there is no shell string. An entry may carry
|
|
36
36
|
`run.mcp.stdio.scope` (DEC-0044): `project` (started for the sessions of a project that binds it) or `on-demand`;
|
|
37
37
|
`session` — a server in every coding session — is refused (FAC-SEM-056, LC-19). The validator accepts the field; the
|
|
38
|
-
writer does not set it until Fabric reads it.
|
|
38
|
+
writer does not set it until Fabric reads it. An argument that carries a credential is refused
|
|
39
|
+
(LC-21, FAC-SEM-057): a key goes in `env` as a `secret-ref:<name>`, never in argv.
|
|
39
40
|
|
|
40
41
|
It refuses, with one sentence that never quotes a value:
|
|
41
42
|
|
|
@@ -15,6 +15,9 @@ import sys
|
|
|
15
15
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
16
16
|
from urllib.parse import urlparse
|
|
17
17
|
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import fabric_secrets as _secrets # noqa: E402 - the contract's credential detector (DEC-0044, LC-21), beside this file
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
CONTRACT_VERSION = "0.1.0"
|
|
20
23
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
@@ -230,6 +233,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
|
|
|
230
233
|
lowered = runtime_arg.lower().replace("_", "").replace("-", "")
|
|
231
234
|
if any(term in lowered for term in ("password", "apikey", "accesstoken", "clientsecret")):
|
|
232
235
|
raise AdaptationError("runtime arguments must not contain secret-like values or names")
|
|
236
|
+
if _secrets.secret_argument(runtime_arg):
|
|
237
|
+
raise AdaptationError("a runtime argument carries a credential; argv is visible to every process (LC-21): read it from a secret reference instead")
|
|
233
238
|
|
|
234
239
|
created_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
235
240
|
manifest = {
|
|
@@ -465,6 +470,58 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
|
|
|
465
470
|
return "PASS", ["manifest validated by selected contract schema at %s" % selected_commit]
|
|
466
471
|
|
|
467
472
|
|
|
473
|
+
def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
|
|
474
|
+
"""LC-21 (FAA-23): no credential in a launch argument or in a URL the manifest names."""
|
|
475
|
+
problems: List[str] = []
|
|
476
|
+
for index, capability in enumerate(manifest.get("capabilities") or []):
|
|
477
|
+
connection = ((capability or {}).get("profile") or {}).get("connection") if isinstance(capability, dict) else None
|
|
478
|
+
if not isinstance(connection, dict):
|
|
479
|
+
continue
|
|
480
|
+
for arg in connection.get("args") or []:
|
|
481
|
+
if isinstance(arg, str) and _secrets.secret_argument(arg):
|
|
482
|
+
problems.append("LC-21: capability[%d] passes a credential in a launch argument; argv is visible to every process" % index)
|
|
483
|
+
for field in ("url", "agentCardUrl"):
|
|
484
|
+
if isinstance(connection.get(field), str) and _secrets.secret_url(connection[field]):
|
|
485
|
+
problems.append("LC-21: capability[%d].%s carries a credential in the URL; a query lands in every log" % (index, field))
|
|
486
|
+
return problems
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
_PLUGIN_SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".worktrees"}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _plugin_problems(root: Path) -> List[str]:
|
|
493
|
+
"""LC-19 (FAA-22) and LC-21 for the Claude Code plugins in the project: a plugin's stdio MCP server starts in
|
|
494
|
+
every session that enables the plugin; serve it over HTTP from one resident process, or register it at project
|
|
495
|
+
scope instead. No server entry carries a credential in its argv, URL or env."""
|
|
496
|
+
problems: List[str] = []
|
|
497
|
+
for top, dirs, files in os.walk(root):
|
|
498
|
+
dirs[:] = [d for d in dirs if d not in _PLUGIN_SKIP]
|
|
499
|
+
here = Path(top)
|
|
500
|
+
if here.name != ".claude-plugin" or "plugin.json" not in files:
|
|
501
|
+
continue
|
|
502
|
+
plugin = here.parent
|
|
503
|
+
name = plugin.relative_to(root).as_posix() or "."
|
|
504
|
+
servers: Dict[str, Any] = {}
|
|
505
|
+
for source in (here / "plugin.json", plugin / ".mcp.json"):
|
|
506
|
+
try:
|
|
507
|
+
doc = json.loads(source.read_text(encoding="utf-8")) if source.is_file() else {}
|
|
508
|
+
except (OSError, ValueError):
|
|
509
|
+
continue
|
|
510
|
+
block = doc.get("mcpServers") if isinstance(doc, dict) else None
|
|
511
|
+
if isinstance(block, dict):
|
|
512
|
+
servers.update({k: v for k, v in block.items() if isinstance(v, dict)})
|
|
513
|
+
for server, entry in sorted(servers.items()):
|
|
514
|
+
if "command" in entry:
|
|
515
|
+
problems.append("LC-19: plugin %s declares the stdio MCP server %s, which starts in every session that enables the plugin; serve it over HTTP from one resident process or register it at project scope" % (name, server))
|
|
516
|
+
argv = [entry.get("command")] + list(entry.get("args") or [])
|
|
517
|
+
if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _secrets.secret_url(entry["url"])):
|
|
518
|
+
problems.append("LC-21: plugin %s's MCP server %s carries a credential in its argv or URL" % (name, server))
|
|
519
|
+
env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
|
|
520
|
+
if any(isinstance(v, str) and _secrets.secret_value(v) for v in env.values()):
|
|
521
|
+
problems.append("LC-21: plugin %s's MCP server %s writes a credential value in env; reference it instead" % (name, server))
|
|
522
|
+
return problems
|
|
523
|
+
|
|
524
|
+
|
|
468
525
|
def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
469
526
|
errors: List[str] = []
|
|
470
527
|
warnings: List[str] = []
|
|
@@ -534,6 +591,7 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
|
534
591
|
errors.append("secret-like field is forbidden in manifest: %s" % key)
|
|
535
592
|
if isinstance(value, str) and ("replace-me" in value or ".invalid" in value):
|
|
536
593
|
warnings.append("placeholder:%s" % key)
|
|
594
|
+
errors.extend(_credential_problems(manifest))
|
|
537
595
|
provider = manifest.get("provider")
|
|
538
596
|
if isinstance(provider, dict) and provider.get("contentHash") == "sha256:" + ("0" * 64):
|
|
539
597
|
warnings.append("placeholder:provider.contentHash")
|
|
@@ -545,6 +603,7 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
|
545
603
|
elif contract is not None and manifest_path.is_file():
|
|
546
604
|
shape_status, shape_receipts = _contract_shape(manifest_path, contract, lock["commit"])
|
|
547
605
|
|
|
606
|
+
errors.extend(_plugin_problems(root))
|
|
548
607
|
local_status = "PASS" if not errors else "FAIL"
|
|
549
608
|
ready = local_status == "PASS" and shape_status == "PASS" and not warnings
|
|
550
609
|
return {
|
|
@@ -29,6 +29,9 @@ import sys
|
|
|
29
29
|
import tempfile
|
|
30
30
|
from typing import Any, Dict, List, Optional
|
|
31
31
|
|
|
32
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
33
|
+
import fabric_secrets as _secrets # noqa: E402 - the contract's credential detector (DEC-0044, LC-21), beside this file
|
|
34
|
+
|
|
32
35
|
PROTOCOL = "fabric-provider/0.1"
|
|
33
36
|
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
34
37
|
_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
|
@@ -124,6 +127,8 @@ def _run_problems(run: Any) -> List[str]:
|
|
|
124
127
|
command = stdio["command"]
|
|
125
128
|
if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
|
|
126
129
|
problems.append("run.mcp.stdio.command is an argument array, never a shell string")
|
|
130
|
+
elif any(_secrets.secret_argument(a) for a in command):
|
|
131
|
+
problems.append("run.mcp.stdio.command carries a credential in an argument; argv is visible to every process (LC-21, FAC-SEM-057): pass it as a secret reference in env")
|
|
127
132
|
env = stdio.get("env", {})
|
|
128
133
|
if not isinstance(env, dict):
|
|
129
134
|
return problems + ["run.mcp.stdio.env is an object"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
|
|
3
|
+
that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
|
|
4
|
+
(LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. Standard library
|
|
5
|
+
only. The dashboard kit's fabric-button.js carries its own copy of the name and value checks (FL-4)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
import urllib.parse
|
|
12
|
+
|
|
13
|
+
CREDENTIAL_WORDS = (
|
|
14
|
+
"token", "secret", "password", "passwd", "pass", "passphrase", "credential", "credentials", "cookie", "authorization",
|
|
15
|
+
"bearer", "jwt", "otp", "apikey", "signature", "sig",
|
|
16
|
+
"accesstoken", "idtoken", "authtoken", "refreshtoken", "sessionid", "sessiontoken", "logincode", "authcode", "privatekey",
|
|
17
|
+
"clientsecret", "secretkey", "accesskey",
|
|
18
|
+
)
|
|
19
|
+
CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
20
|
+
"key": ("private", "api", "access", "secret", "session", "auth", "signing", "encryption", "master", "client", "login", "refresh"),
|
|
21
|
+
"auth": ("token", "code", "header"),
|
|
22
|
+
"session": ("token", "id"),
|
|
23
|
+
"code": ("login", "auth", "access", "session"),
|
|
24
|
+
}
|
|
25
|
+
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
26
|
+
SECRET_VALUE = re.compile(r"(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})",
|
|
27
|
+
re.IGNORECASE | re.DOTALL)
|
|
28
|
+
_LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
|
|
29
|
+
_BEARER_INSIDE = re.compile(r"\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*", re.IGNORECASE)
|
|
30
|
+
_PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE)
|
|
31
|
+
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def name_words(name: str) -> List[str]:
|
|
35
|
+
"""A name's words: split at `.`, `_`, `-` and camelCase humps, lowercased (`APIKey` is `api key`)."""
|
|
36
|
+
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
37
|
+
spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
|
|
38
|
+
return [w.lower() for w in re.split(r"[\s._-]+", spaced) if w]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _with_singulars(words: List[str]) -> List[str]:
|
|
42
|
+
return words + [w[:-1] for w in words if len(w) > 2 and w.endswith("s")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def credential_name(name: str) -> bool:
|
|
46
|
+
"""Whether a name — a parameter, a header, an environment variable, a config key — can only carry a credential."""
|
|
47
|
+
split = name_words(name)
|
|
48
|
+
words = _with_singulars(split)
|
|
49
|
+
if any(w in CREDENTIAL_WORDS for w in words):
|
|
50
|
+
return True
|
|
51
|
+
if len(split) == 1 and any(w in CREDENTIAL_PAIRS for w in words):
|
|
52
|
+
return True
|
|
53
|
+
return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _long_secret(value: str) -> bool:
|
|
57
|
+
return any(re.search(r"[a-z]", run) and re.search(r"[A-Z]", run) and re.search(r"[0-9]", run) for run in _LONG_RUN.findall(value))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def secret_value(value: str) -> bool:
|
|
61
|
+
"""A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
|
|
62
|
+
return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _long_secret(value))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _path_like(piece: str) -> bool:
|
|
66
|
+
return bool(_PATH_LIKE.match(piece)) or (
|
|
67
|
+
bool(re.search(r"[\\/]", piece))
|
|
68
|
+
and (bool(re.search(r"\.[A-Za-z0-9]{1,8}$", piece)) or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", s) for s in re.split(r"[\\/]", piece))))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def secret_argument(value: str) -> bool:
|
|
72
|
+
"""The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
|
|
73
|
+
so a folder path holding a session id is not taken for a key."""
|
|
74
|
+
if JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
|
|
75
|
+
return True
|
|
76
|
+
for piece in re.split(r"[\s=]", value):
|
|
77
|
+
if _path_like(piece):
|
|
78
|
+
if any(_long_secret(part) for part in re.split(r"[\\/]", piece)):
|
|
79
|
+
return True
|
|
80
|
+
elif _long_secret(piece):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode_keeping_plus(raw: str) -> str:
|
|
86
|
+
"""decodeURIComponent: a malformed escape or bytes that are not UTF-8 leave the raw text."""
|
|
87
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", raw):
|
|
88
|
+
return raw
|
|
89
|
+
try:
|
|
90
|
+
return urllib.parse.unquote_to_bytes(raw).decode("utf-8")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
return raw
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _form_name(text: str) -> str:
|
|
96
|
+
pairs = urllib.parse.parse_qsl(text, keep_blank_values=True)
|
|
97
|
+
return pairs[0][0] if pairs else ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def raw_values(query: str) -> Dict[str, List[str]]:
|
|
101
|
+
"""Each query or fragment piece's raw value by its decoded name, with `+` kept."""
|
|
102
|
+
values: Dict[str, List[str]] = {}
|
|
103
|
+
for piece in query.split("&"):
|
|
104
|
+
if piece == "":
|
|
105
|
+
continue
|
|
106
|
+
at = piece.find("=")
|
|
107
|
+
name = _form_name(piece if at < 0 else piece[:at])
|
|
108
|
+
values.setdefault(name, []).append("" if at < 0 else _decode_keeping_plus(piece[at + 1:]))
|
|
109
|
+
return values
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def secret_url(value: str) -> bool:
|
|
113
|
+
"""Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
|
|
114
|
+
password in its user information, or a query or fragment parameter whose name can only carry a credential and has
|
|
115
|
+
a value, or whose value is shaped like one. A string that is not an absolute URL is checked as an argument only."""
|
|
116
|
+
if secret_argument(value):
|
|
117
|
+
return True
|
|
118
|
+
if not _SCHEME.match(value.strip()):
|
|
119
|
+
return False # not an absolute URL (WHATWG URL parsing refuses it)
|
|
120
|
+
try:
|
|
121
|
+
parts = urllib.parse.urlsplit(value.strip())
|
|
122
|
+
except ValueError:
|
|
123
|
+
return False
|
|
124
|
+
if not parts.scheme:
|
|
125
|
+
return False
|
|
126
|
+
if parts.password:
|
|
127
|
+
return True
|
|
128
|
+
for part in (parts.query, parts.fragment):
|
|
129
|
+
for name, raws in raw_values(part).items():
|
|
130
|
+
if any((raw != "" and credential_name(name)) or secret_value(raw) for raw in raws):
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
@@ -13,10 +13,10 @@ description: >-
|
|
|
13
13
|
kits, a TLS sample, live and layout probes. NOT for a one-off script or cron job, a hosted product with no agent
|
|
14
14
|
behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
|
|
15
15
|
license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
16
|
-
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only
|
|
16
|
+
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
17
17
|
metadata:
|
|
18
18
|
author: PassionCode.ai
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.18.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
22
|
extension-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
@@ -190,10 +190,14 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
|
|
|
190
190
|
4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
|
|
191
191
|
and the one before it.
|
|
192
192
|
|
|
193
|
+
On Linux the supervisor is a systemd user unit (`systemd_unit` + `systemd_install`), on
|
|
194
|
+
Windows a per-user Scheduled Task (`task_xml` + `task_install`); the Node kit has the same
|
|
195
|
+
writers (`systemdUnit`, `taskXml`, …) and both kits write identical files.
|
|
196
|
+
|
|
193
197
|
Code runs from an immutable release directory; an upgrade writes a new release,
|
|
194
198
|
rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
|
|
195
199
|
for the plist fields, the release layout, log rotation, behaving under a host lifecycle broker
|
|
196
|
-
and the Linux
|
|
200
|
+
and the Linux and Windows supervisors.
|
|
197
201
|
|
|
198
202
|
## Step 5 — the dashboard and the events
|
|
199
203
|
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -8,23 +8,23 @@ wins and this page is the defect. The rule each section serves:
|
|
|
8
8
|
| Contract rule | Here | Kit (Python / Node) |
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
|
|
11
|
-
| LC-03 a job is bounded and exclusive | [one copy under
|
|
12
|
-
| LC-09 the footprint is declared | the descriptor and the plist; the service's own `AGENTS.md` names its label and port | `write_descriptor`, `launchd_plist` |
|
|
11
|
+
| LC-03 a job is bounded and exclusive | [one copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
|
|
12
|
+
| LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
|
|
13
13
|
| LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
14
14
|
| LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
|
|
15
|
-
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `launchd_install`, `launchd_uninstall` (Python) |
|
|
15
|
+
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install`, `launchd_uninstall` (Python); `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
|
|
16
16
|
| LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
|
|
17
17
|
|
|
18
18
|
## Contents
|
|
19
19
|
|
|
20
20
|
- [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
|
|
21
21
|
- [Install, upgrade, uninstall](#install-upgrade-uninstall)
|
|
22
|
-
- [One copy under
|
|
22
|
+
- [One copy under a supervisor](#one-copy-under-a-supervisor)
|
|
23
23
|
- [Directories](#directories)
|
|
24
24
|
- [Logs](#logs)
|
|
25
25
|
- [Shutdown](#shutdown)
|
|
26
26
|
- [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
|
|
27
|
-
- [Linux](#linux)
|
|
27
|
+
- [Linux and Windows](#linux-and-windows)
|
|
28
28
|
|
|
29
29
|
## launchd plist (generate it with `launchd_plist`)
|
|
30
30
|
|
|
@@ -70,24 +70,24 @@ that deletes an override, and a leftover `disabled` would make a later fresh ins
|
|
|
70
70
|
off. Tests use a throwaway label or a fake `launchctl` first on `PATH` — never the
|
|
71
71
|
operator's real jobs.
|
|
72
72
|
|
|
73
|
-
## One copy under
|
|
73
|
+
## One copy under a supervisor
|
|
74
74
|
|
|
75
75
|
Started by hand, a copy that finds `service.lock` held prints one sentence naming the
|
|
76
76
|
holder and exits 75 at once. Under launchd that exit is a respawn every `ThrottleInterval`
|
|
77
|
-
(10 s), forever
|
|
78
|
-
off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
|
|
77
|
+
(10 s), forever, and under systemd one every `RestartSec`. So a copy whose plist or unit
|
|
78
|
+
carries `FABRIC_SERVICE_SUPERVISOR=launchd` (or `systemd`) backs off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
|
|
79
79
|
and exits 75 only when the wait runs out: a few starts an hour instead of 360. It touches
|
|
80
80
|
nothing while it waits. Two installs sharing one data directory are still the defect to
|
|
81
81
|
remove; the back-off only stops the loop from costing the machine.
|
|
82
82
|
|
|
83
83
|
## Directories
|
|
84
84
|
|
|
85
|
-
| What | macOS | Linux |
|
|
86
|
-
|
|
87
|
-
| data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` |
|
|
88
|
-
| logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` |
|
|
89
|
-
| cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` |
|
|
90
|
-
| code | `~/.local/share/<id>/releases/…` | same |
|
|
85
|
+
| What | macOS | Linux | Windows (DEC-0032) |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` | `%LOCALAPPDATA%\<id>\` |
|
|
88
|
+
| logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` | `%LOCALAPPDATA%\<id>\Logs\` |
|
|
89
|
+
| cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` | `%LOCALAPPDATA%\<id>\Cache\` |
|
|
90
|
+
| code | `~/.local/share/<id>/releases/…` | same | under `%LOCALAPPDATA%` too, never a network share |
|
|
91
91
|
|
|
92
92
|
A repository that versions the data itself (a registry, a plan) may hold the data; the
|
|
93
93
|
service's own code checkout and its releases may not. Directories 0700, secret files 0600. A store carries a schema version and the service
|
|
@@ -140,8 +140,58 @@ has to survive being driven from outside:
|
|
|
140
140
|
well-known document for a service, a process-level answer for a plain app — and keep it honest
|
|
141
141
|
while the app is still starting.
|
|
142
142
|
|
|
143
|
-
## Linux
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
## Linux and Windows
|
|
144
|
+
|
|
145
|
+
Both kits write and install the supervisor file the contract names (DEC-0032, service.md
|
|
146
|
+
*Supervisor*); the two kits write byte-identical files. A supervisor file never carries a
|
|
147
|
+
secret: the writers refuse a credential-shaped argument, environment name or value, and any
|
|
148
|
+
control character (a line break would end a unit line early).
|
|
149
|
+
|
|
150
|
+
**Linux — a systemd user unit.** `systemd_unit(unit, argv, working_directory=…)` /
|
|
151
|
+
`systemdUnit(unit, argv, {workingDirectory})` writes `Restart=always`, `RestartSec=10`,
|
|
152
|
+
`TimeoutStopSec` above the drain (15 s default), `KillMode=mixed`, `WantedBy=default.target`
|
|
153
|
+
and `FABRIC_SERVICE_SUPERVISOR=systemd`, quoting every argument (`%` → `%%`, `$` → `$$`).
|
|
154
|
+
`WorkingDirectory=` is written unquoted: systemd reads it as the rest of the line, so quotes would
|
|
155
|
+
become part of the path and the unit would be refused (`systemd-analyze verify` checks it in CI). The
|
|
156
|
+
descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.service"}`.
|
|
157
|
+
|
|
158
|
+
| Verb | Command |
|
|
159
|
+
|---|---|
|
|
160
|
+
| install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
|
|
161
|
+
| stop, keep off | `systemctl --user disable --now <unit>` |
|
|
162
|
+
| uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
|
|
163
|
+
|
|
164
|
+
A user unit runs while the person is logged in; one that must run without a session needs
|
|
165
|
+
`loginctl enable-linger <user>`, which is the operator's decision, not the installer's.
|
|
166
|
+
|
|
167
|
+
**Windows — a per-user Scheduled Task.** `task_xml(task, argv, working_directory=…)` /
|
|
168
|
+
`taskXml(task, argv, {workingDirectory})` writes a task with a logon trigger for the current
|
|
169
|
+
user, `InteractiveToken` and `LeastPrivilege` (no administrator rights), restart on failure
|
|
170
|
+
every minute, no execution time limit and `MultipleInstancesPolicy` `IgnoreNew`, its settings in the
|
|
171
|
+
order `schtasks` exports them; the arguments are quoted the way the C runtime parses them. The Windows
|
|
172
|
+
CI job has the real `schtasks` create, read back and delete such a task. The descriptor says
|
|
173
|
+
`"lifecycle": {"manager": "task-scheduler", "task": "\\PassionCode\\<id>.<instance>"}`.
|
|
174
|
+
|
|
175
|
+
| Verb | Command |
|
|
176
|
+
|---|---|
|
|
177
|
+
| install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
|
|
178
|
+
| stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
|
|
179
|
+
| uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
|
|
180
|
+
|
|
181
|
+
A task cannot set an environment variable, so a copy Task Scheduler restarts does not back off
|
|
182
|
+
in-process; `IgnoreNew` keeps the scheduler from starting a second copy, and a hand-started copy
|
|
183
|
+
holding the lock makes the scheduled one exit 75 once a minute until it leaves.
|
|
184
|
+
|
|
185
|
+
**One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
|
|
186
|
+
(Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
|
|
187
|
+
a named pipe on Windows (`lock_name` / `lockName`), each gone with the process. The Python kit
|
|
188
|
+
holds the same name beside its file lock, so the two kits exclude each other everywhere, and
|
|
189
|
+
`check_service.py` reports the lock held when either is. The probe only looks (`/proc/net/unix`,
|
|
190
|
+
`WaitNamedPipeW`); it never takes the name, so it cannot make a service that is starting fail.
|
|
191
|
+
|
|
192
|
+
**Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
|
|
193
|
+
token files*): the kits write a protected list — the user, SYSTEM and Administrators, set before the
|
|
194
|
+
token's bytes go in, any entry already there dropped — and refuse
|
|
195
|
+
on read a link, a file outside the profile, another owner, or any other granted SID, naming the
|
|
196
|
+
SID. The Node kit reads the list with Windows PowerShell 5.1 (.NET, no modules) and writes it
|
|
197
|
+
with `icacls`; the path goes through the environment, never a command line.
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md
CHANGED
|
@@ -31,7 +31,7 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
31
31
|
| `name`, `summary` | ≤ 80 and ≤ 200 characters |
|
|
32
32
|
| `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
|
|
33
33
|
| `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
|
|
34
|
-
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032).
|
|
34
|
+
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). Both kits and the probe accept all four; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`) |
|
|
35
35
|
| `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
|
|
36
36
|
| `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
|
|
37
37
|
| `source.repository`, `fabricManifest` | optional |
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
(`<tool> mcp-register`), which writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
|
|
22
22
|
atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
|
|
23
23
|
goes in `headers`, never in the URL — every log that records a URL prints its query
|
|
24
|
-
string.
|
|
24
|
+
string. An HTTP registration starts nothing per session: every client session talks to the
|
|
25
|
+
one resident service (LC-19). A stdio server, by contrast, is a process per session — keep
|
|
26
|
+
one only at project scope.
|
|
25
27
|
- Ship server `instructions` that tell a caller where to start and that every answer
|
|
26
28
|
carries `degraded`.
|
|
27
29
|
|
|
@@ -730,7 +730,7 @@ class Probe:
|
|
|
730
730
|
self.add("lifecycle.instance-lock", "PASS", "held by the running service")
|
|
731
731
|
else:
|
|
732
732
|
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
733
|
-
self.
|
|
733
|
+
self.lock_free(lock)
|
|
734
734
|
finally:
|
|
735
735
|
os.close(fd)
|
|
736
736
|
return
|
|
@@ -747,10 +747,17 @@ class Probe:
|
|
|
747
747
|
self.add("lifecycle.instance-lock", "PASS" if held else "FAIL", "held by the running service" if held else str(exc))
|
|
748
748
|
else:
|
|
749
749
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
750
|
-
self.
|
|
750
|
+
self.lock_free(lock)
|
|
751
751
|
finally:
|
|
752
752
|
os.close(fd)
|
|
753
753
|
|
|
754
|
+
def lock_free(self, lock: Path) -> None:
|
|
755
|
+
"""The file lock is free; a Node kit service holds the lock's name instead (fs.lock_name)."""
|
|
756
|
+
if fs.lock_name_held(lock):
|
|
757
|
+
self.add("lifecycle.instance-lock", "PASS", "held by the running service (the lock's name, %s)" % ("a named pipe" if fs.WINDOWS else "an abstract socket"))
|
|
758
|
+
else:
|
|
759
|
+
self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
|
|
760
|
+
|
|
754
761
|
def run(self) -> List[Result]:
|
|
755
762
|
self.descriptor_rules()
|
|
756
763
|
if not self.port:
|