@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.81.0
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 +79 -0
- package/README.md +13 -2
- package/SECURITY.md +23 -8
- package/bin/fabric-agent-adapter.js +24 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a Fabric agent project with the contract built in, in one command (FAA-270, North Star G21).
|
|
3
|
+
|
|
4
|
+
python3 new_agent.py <folder> --id <agent-id> [--profile mcp|a2a|local-runner] [--service]
|
|
5
|
+
[--capability NAME] [--effect CLASS] [--force] [--json]
|
|
6
|
+
|
|
7
|
+
It writes, into an empty (or new) folder:
|
|
8
|
+
|
|
9
|
+
- the provider bundle `adapt_project.py scaffold` writes, pinned to the contract (`fabric-agent.json`,
|
|
10
|
+
`fabric-contract.lock.json`, `fabric/`), every value it invents a sentinel `adapt_project.py check` names;
|
|
11
|
+
- `fabric.source.json`, the agent's source manifest (DEC-0149, `fabric-source/1`): what is source, default deny, and the
|
|
12
|
+
slots an installation fills (the publisher's URI behind `provider.createdBy`, the placement record and, for a
|
|
13
|
+
service, its supervisor label and port), never a value;
|
|
14
|
+
- the placement guard (`src/placement.py`, with its tests in `tests/`): at start the agent reads Project Observatory's
|
|
15
|
+
placement record (`observatory-placement/1`) and refuses to start where the record places it on another machine,
|
|
16
|
+
starts degraded when it cannot read it, and starts when there is none; `config/placement.example.json` shows the
|
|
17
|
+
record's shape on neutral values;
|
|
18
|
+
- `README.md`, `LICENSE` (all rights reserved until the owner names the terms), `.gitignore`;
|
|
19
|
+
- with `--service`: `service/`, the service kit's sample (`service.py`, a fabric-service/0.1 service with one capability
|
|
20
|
+
and a job) and the kit modules it imports, copied by `fabric_kit.py install`, whose digests `fabric_kit.py status`
|
|
21
|
+
checks later.
|
|
22
|
+
|
|
23
|
+
A repeat with the same arguments in a folder this command made changes nothing and answers the same line. A folder that
|
|
24
|
+
holds anything else is refused unless `--force`, which writes the planned files over it and leaves every other file as
|
|
25
|
+
it is. The last stdout line is `{"folder": "<path>", "id": "<id>", "profile": "<profile>", "service": <bool>,
|
|
26
|
+
"created": [<paths>], "repeated": <bool>}` (`--json` prints only it). Exit 0 made or already made, 1 refused (the
|
|
27
|
+
reason on stderr), 2 usage. Writes nothing outside the folder; reads no key and no instance data. Standard library only.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import sys
|
|
38
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
39
|
+
|
|
40
|
+
HERE = Path(__file__).resolve().parent
|
|
41
|
+
sys.path.insert(0, str(HERE))
|
|
42
|
+
import adapt_project as ap # noqa: E402 - the scaffolder and the source manifest, beside this file
|
|
43
|
+
|
|
44
|
+
VERSION = "0.1.0"
|
|
45
|
+
RECORD = "fabric/new-agent.json"
|
|
46
|
+
#: The agent's id: the package's `name` grammar (`fabric-package.schema.json#/$defs/name`), kebab case.
|
|
47
|
+
AGENT_ID = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z")
|
|
48
|
+
CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}\Z")
|
|
49
|
+
KIT = HERE.parent.parent / "building-fabric-services" / "scripts"
|
|
50
|
+
#: What the sample service imports; `fabric_kit.py install` copies each one's closure.
|
|
51
|
+
SERVICE_MODULES = ("fabric_service", "fabric_interop", "fabric_board", "fabric_hub")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Refused(Exception):
|
|
55
|
+
"""The folder cannot be made as asked; the reason is printed, never a value."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
PLACEMENT_PY = '''"""The placement guard: at start, before the agent polls, schedules or listens, it reads Project Observatory's
|
|
59
|
+
placement record and runs only on the machine that record names (creating-fabric-agents, where-agents-run.md).
|
|
60
|
+
|
|
61
|
+
verdict(service_id, label) -> {"state": "ok" | "placed-elsewhere" | "placement-unreadable", "machine"?}
|
|
62
|
+
|
|
63
|
+
`placed-elsewhere`: the record's `elsewhere` list names this agent (its `serviceId` or its supervisor `label`) on a
|
|
64
|
+
machine that is not this one: refuse to start, log one line, report it as degraded. `placement-unreadable`: the file is
|
|
65
|
+
there and cannot be read, or is of another schema: start, and report it as degraded. No file: start.
|
|
66
|
+
"""
|
|
67
|
+
import json
|
|
68
|
+
import os
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from typing import Any, Dict, Optional
|
|
71
|
+
|
|
72
|
+
SCHEMA = "observatory-placement/1"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def placement_path(env: Optional[Dict[str, str]] = None) -> Path:
|
|
76
|
+
env = os.environ if env is None else env
|
|
77
|
+
base = env.get("XDG_DATA_HOME") or ""
|
|
78
|
+
root = Path(base) if os.path.isabs(base) else Path.home() / ".local" / "share"
|
|
79
|
+
return root / "project-observatory" / "placement.json"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verdict(service_id: str, label: str, path: Optional[Path] = None) -> Dict[str, Any]:
|
|
83
|
+
path = placement_path() if path is None else path
|
|
84
|
+
try:
|
|
85
|
+
data = path.read_bytes()
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
return {"state": "ok"}
|
|
88
|
+
except OSError:
|
|
89
|
+
return {"state": "placement-unreadable"}
|
|
90
|
+
try:
|
|
91
|
+
doc = json.loads(data.decode("utf-8"))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return {"state": "placement-unreadable"}
|
|
94
|
+
if not isinstance(doc, dict) or doc.get("schema") != SCHEMA or not isinstance(doc.get("elsewhere"), list):
|
|
95
|
+
return {"state": "placement-unreadable"}
|
|
96
|
+
for entry in doc["elsewhere"]:
|
|
97
|
+
if not isinstance(entry, dict):
|
|
98
|
+
continue
|
|
99
|
+
ours = (service_id and entry.get("serviceId") == service_id) or (label and entry.get("label") == label)
|
|
100
|
+
if ours and entry.get("placedOn") and entry.get("placedOn") != doc.get("machine"):
|
|
101
|
+
return {"state": "placed-elsewhere", "machine": entry.get("placedOn")}
|
|
102
|
+
return {"state": "ok"}
|
|
103
|
+
'''
|
|
104
|
+
|
|
105
|
+
PLACEMENT_TEST = '''"""The placement guard: one copy placed elsewhere refuses, one with no file and one with a malformed file start."""
|
|
106
|
+
import json
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
import sys
|
|
109
|
+
import tempfile
|
|
110
|
+
import unittest
|
|
111
|
+
|
|
112
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
113
|
+
import placement # noqa: E402
|
|
114
|
+
|
|
115
|
+
EXAMPLE = Path(__file__).resolve().parents[1] / "config" / "placement.example.json"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PlacementTests(unittest.TestCase):
|
|
119
|
+
def setUp(self):
|
|
120
|
+
self.folder = tempfile.TemporaryDirectory()
|
|
121
|
+
self.addCleanup(self.folder.cleanup)
|
|
122
|
+
self.path = Path(self.folder.name) / "placement.json"
|
|
123
|
+
|
|
124
|
+
def test_placed_elsewhere_refuses(self):
|
|
125
|
+
self.path.write_text(EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8")
|
|
126
|
+
found = placement.verdict("{service_id}", "{label}", self.path)
|
|
127
|
+
self.assertEqual(found["state"], "placed-elsewhere")
|
|
128
|
+
|
|
129
|
+
def test_no_file_starts(self):
|
|
130
|
+
self.assertEqual(placement.verdict("{service_id}", "{label}", self.path), {{"state": "ok"}})
|
|
131
|
+
|
|
132
|
+
def test_a_malformed_file_starts_degraded(self):
|
|
133
|
+
self.path.write_text("{{not json", encoding="utf-8")
|
|
134
|
+
self.assertEqual(placement.verdict("{service_id}", "{label}", self.path), {{"state": "placement-unreadable"}})
|
|
135
|
+
self.path.write_text(json.dumps({{"schema": "another/1"}}), encoding="utf-8")
|
|
136
|
+
self.assertEqual(placement.verdict("{service_id}", "{label}", self.path)["state"], "placement-unreadable")
|
|
137
|
+
|
|
138
|
+
def test_placed_on_this_machine_starts(self):
|
|
139
|
+
doc = json.loads(EXAMPLE.read_text(encoding="utf-8"))
|
|
140
|
+
doc["machine"] = doc["elsewhere"][0]["placedOn"]
|
|
141
|
+
self.path.write_text(json.dumps(doc), encoding="utf-8")
|
|
142
|
+
self.assertEqual(placement.verdict("{service_id}", "{label}", self.path), {{"state": "ok"}})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
unittest.main()
|
|
147
|
+
'''
|
|
148
|
+
|
|
149
|
+
README = '''# {agent_id}
|
|
150
|
+
|
|
151
|
+
A Fabric agent made with the Fabric Agent Adapter's `new` command, contract `{contract}` at `{commit}`.
|
|
152
|
+
|
|
153
|
+
## Layout
|
|
154
|
+
|
|
155
|
+
- `fabric-agent.json`, `fabric-contract.lock.json`, `fabric/`: the provider bundle, pinned to the contract. Every value
|
|
156
|
+
the scaffold invented is a sentinel: `adapt_project.py check` names each one until it is replaced.
|
|
157
|
+
- `fabric.source.json`: the agent's source manifest (DEC-0149): what is source, and the slots an installation fills,
|
|
158
|
+
never a value. `fabric_source_check.py check .` reads the source under it (exit 0 before every release).
|
|
159
|
+
- `src/placement.py`: the placement guard the agent runs at start, before it polls, schedules or listens.
|
|
160
|
+
{service_line}
|
|
161
|
+
## Configure
|
|
162
|
+
|
|
163
|
+
Keys are read by name from the environment or the secret store, never from a file of this repository. Slots:
|
|
164
|
+
{slots}
|
|
165
|
+
|
|
166
|
+
## Data
|
|
167
|
+
|
|
168
|
+
The agent's data lives apart from this source, in its data home (`<agent>-data`, or the service kit's folders), from
|
|
169
|
+
the first commit.
|
|
170
|
+
'''
|
|
171
|
+
|
|
172
|
+
GITIGNORE = """# the instance: real configuration, keys and what running the agent leaves
|
|
173
|
+
config/*.json
|
|
174
|
+
!config/*.example.json
|
|
175
|
+
.env
|
|
176
|
+
.env.*
|
|
177
|
+
!.env.example
|
|
178
|
+
*.token
|
|
179
|
+
*.credential
|
|
180
|
+
__pycache__/
|
|
181
|
+
*.pyc
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _placement_example(service_id: str, label: str) -> str:
|
|
186
|
+
return ap._json_text({"schema": "observatory-placement/1", "machine": "example-machine-a", "updatedAt": "2026-01-01T00:00:00Z",
|
|
187
|
+
"elsewhere": [{"label": label, "serviceId": service_id, "placedOn": "example-machine-b",
|
|
188
|
+
"since": "2026-01-01T00:00:00Z", "by": "full machine-move leave"}]})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def plan(args: argparse.Namespace) -> Dict[str, str]:
|
|
192
|
+
"""Every file the command writes, by its path, with its text (the scaffold's own included)."""
|
|
193
|
+
agent_id = args.id
|
|
194
|
+
capability = args.capability or "%s.run" % agent_id.replace("-", "_")
|
|
195
|
+
if not CAPABILITY.match(capability):
|
|
196
|
+
raise Refused("--capability %r is not a capability name (lower case, `domain.action`)" % capability)
|
|
197
|
+
scaffold = argparse.Namespace(
|
|
198
|
+
profile=args.profile, provider_id="urn:fabric:provider:%s" % agent_id, provider_name=agent_id,
|
|
199
|
+
capability_id="urn:fabric:capability:%s" % capability, capability_name=capability,
|
|
200
|
+
schema_base="https://replace.invalid/%s/schemas" % agent_id, mcp_mode="streamable-http",
|
|
201
|
+
mcp_url="https://replace.invalid/mcp", agent_card_url="https://replace.invalid/.well-known/agent-card.json",
|
|
202
|
+
runner_kind="replace-me", executable_ref="urn:executable:replace-me", runtime_arg=[], effect=args.effect,
|
|
203
|
+
idempotency="none", cost_preset=[], default_cost_preset=None, job=False, created_by=None, identity_method=None,
|
|
204
|
+
data_class=[], probe_assertion=[])
|
|
205
|
+
files = dict(ap._generated_files(scaffold))
|
|
206
|
+
service_id = "%s.default" % agent_id
|
|
207
|
+
label = "com.example.%s" % agent_id
|
|
208
|
+
include = ["README.md", "LICENSE", ".gitignore", "src/", "tests/", "config/placement.example.json",
|
|
209
|
+
"fabric-agent.json", "fabric-contract.lock.json", "fabric/"] + (["service/"] if args.service else [])
|
|
210
|
+
manifest = ap.source_manifest(include, "%s, a Fabric agent" % agent_id, service=args.service)
|
|
211
|
+
slots = "\n".join("- `%s` (%s%s): %s" % (s["name"], s["kind"], ", optional" if s.get("optional") else "", s["purpose"])
|
|
212
|
+
for s in manifest["slots"])
|
|
213
|
+
files.update({
|
|
214
|
+
ap.SOURCE_FILE: ap._json_text(manifest),
|
|
215
|
+
"README.md": README.format(agent_id=agent_id, contract=ap.CONTRACT_VERSION, commit=ap.CONTRACT_COMMIT[:12], slots=slots,
|
|
216
|
+
service_line="- `service/`: the service kit's sample, run as `python3 service/service.py "
|
|
217
|
+
"serve --id %s --port <port> --data-dir <data>`, and the kit modules it imports.\n" % agent_id
|
|
218
|
+
if args.service else ""),
|
|
219
|
+
"LICENSE": "Copyright (c) the owner of %s. All rights reserved, until the owner names other terms.\n" % agent_id,
|
|
220
|
+
".gitignore": GITIGNORE,
|
|
221
|
+
"src/placement.py": PLACEMENT_PY,
|
|
222
|
+
"tests/test_placement.py": PLACEMENT_TEST.format(service_id=service_id, label=label),
|
|
223
|
+
"config/placement.example.json": _placement_example(service_id, label),
|
|
224
|
+
})
|
|
225
|
+
record = {"tool": "fabric-agent-adapter new %s" % VERSION, "id": agent_id, "profile": args.profile, "service": args.service,
|
|
226
|
+
"capability": capability, "effect": args.effect, "contract": ap.CONTRACT_COMMIT}
|
|
227
|
+
files[RECORD] = ap._json_text(record)
|
|
228
|
+
return files
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _service_files(folder: Path) -> List[str]:
|
|
232
|
+
"""Copy the kit's sample and the closure of what it imports into `service/` (fabric_kit.py install)."""
|
|
233
|
+
import importlib.util
|
|
234
|
+
found = importlib.util.spec_from_file_location("fabric_kit", KIT / "fabric_kit.py") # the kit's own installer
|
|
235
|
+
fabric_kit = importlib.util.module_from_spec(found)
|
|
236
|
+
found.loader.exec_module(fabric_kit) # type: ignore[union-attr]
|
|
237
|
+
target = folder / "service"
|
|
238
|
+
result = fabric_kit.install(target, "python", list(SERVICE_MODULES))
|
|
239
|
+
if result["refused"]:
|
|
240
|
+
raise Refused("service/ holds kit modules this kit does not recognise: %s" % ", ".join(result["refused"]))
|
|
241
|
+
shutil.copyfile(KIT / "sample_service.py", target / "service.py")
|
|
242
|
+
return ["service/" + name for name in result["copied"] + result["kept"]] + ["service/service.py"]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def make(folder: Path, args: argparse.Namespace) -> Dict[str, Any]:
|
|
246
|
+
if not AGENT_ID.match(args.id) or len(args.id) > 64:
|
|
247
|
+
raise Refused("--id %r is not an agent id (lower case words joined by -, at most 64 characters)" % args.id)
|
|
248
|
+
files = plan(args)
|
|
249
|
+
answer = {"folder": str(folder), "id": args.id, "profile": args.profile, "service": args.service}
|
|
250
|
+
if folder.exists() and not folder.is_dir():
|
|
251
|
+
raise Refused("%s is not a folder" % folder.name)
|
|
252
|
+
present = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*")) if folder.is_dir() else []
|
|
253
|
+
if present:
|
|
254
|
+
record = folder / RECORD
|
|
255
|
+
try:
|
|
256
|
+
same = record.is_file() and json.loads(record.read_text(encoding="utf-8")) == json.loads(files[RECORD])
|
|
257
|
+
except ValueError:
|
|
258
|
+
same = False
|
|
259
|
+
if same and all((folder / path).is_file() for path in files) and not args.force:
|
|
260
|
+
return dict(answer, created=[], repeated=True)
|
|
261
|
+
if not args.force:
|
|
262
|
+
raise Refused("the folder is not empty and was not made by this command with these arguments: "
|
|
263
|
+
"name another folder, or pass --force to write the planned files over it")
|
|
264
|
+
for path, text in files.items():
|
|
265
|
+
target = folder / path
|
|
266
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
target.write_text(text, encoding="utf-8")
|
|
268
|
+
created = sorted(files)
|
|
269
|
+
if args.service:
|
|
270
|
+
created = sorted(created + _service_files(folder))
|
|
271
|
+
return dict(answer, created=created, repeated=False)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
275
|
+
parser = argparse.ArgumentParser(prog="new_agent.py", description="Create a Fabric agent project with the contract built in.")
|
|
276
|
+
parser.add_argument("folder", type=Path)
|
|
277
|
+
parser.add_argument("--id", required=True, help="the agent's id, kebab case (example-agent)")
|
|
278
|
+
parser.add_argument("--profile", choices=("mcp", "a2a", "local-runner"), default="mcp")
|
|
279
|
+
parser.add_argument("--service", action="store_true", help="also wire the service kit's sample (fabric-service/0.1)")
|
|
280
|
+
parser.add_argument("--capability", help="its capability, `domain.action` (default <id>.run)")
|
|
281
|
+
parser.add_argument("--effect", choices=ap.EFFECT_CLASSES, default="none",
|
|
282
|
+
help="the capability's effect class (default none: declare money, deletion or publication now)")
|
|
283
|
+
parser.add_argument("--force", action="store_true", help="write the planned files into a folder that holds others")
|
|
284
|
+
parser.add_argument("--json", action="store_true", help="print only the last line")
|
|
285
|
+
return parser
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
289
|
+
try:
|
|
290
|
+
args = build_parser().parse_args(argv)
|
|
291
|
+
except SystemExit as exc:
|
|
292
|
+
return exc.code if isinstance(exc.code, int) else 2
|
|
293
|
+
try:
|
|
294
|
+
answer = make(args.folder.expanduser().resolve(), args)
|
|
295
|
+
except (Refused, ap.AdaptationError, OSError) as exc:
|
|
296
|
+
print("new_agent.py: %s" % (exc if not isinstance(exc, OSError) else "the folder cannot be written (%s)"
|
|
297
|
+
% type(exc).__name__), file=sys.stderr)
|
|
298
|
+
return 1
|
|
299
|
+
if not args.json:
|
|
300
|
+
print("%s %s: %d file(s)%s" % ("already made" if answer["repeated"] else "made", args.id, len(answer["created"]),
|
|
301
|
+
"" if answer["repeated"] else "; next: replace the sentinels `adapt_project.py check` "
|
|
302
|
+
"names, then run fabric_source_check.py check on the folder"))
|
|
303
|
+
print(json.dumps(answer, ensure_ascii=False))
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
raise SystemExit(main())
|
|
@@ -9,10 +9,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
9
9
|
compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); 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.
|
|
10
10
|
metadata:
|
|
11
11
|
author: PassionCode.ai
|
|
12
|
-
version: "0.
|
|
12
|
+
version: "0.81.0"
|
|
13
13
|
contract-version: "0.1.0"
|
|
14
14
|
extension: "fabric-service/0.1"
|
|
15
|
-
extension-commit: "
|
|
15
|
+
extension-commit: "93e7db164c7829e2783b4b7705c4113bb5201131"
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
# Building Fabric services
|
|
@@ -82,7 +82,9 @@ MCP registration into a client config, the login flow or the CSRF header.
|
|
|
82
82
|
When other agents or Fabric call the service over MCP, it follows `fabric-interop/0.1`:
|
|
83
83
|
each manifest capability is the tool of its name, long work is a job with a stable id,
|
|
84
84
|
a question for a person is an elicitation, and every call carries one trace. A job a limit stops or pauses, and an
|
|
85
|
-
effect nobody saw, have their own codes and a key per caller (DEC-0087)
|
|
85
|
+
effect nobody saw, have their own codes and a key per caller (DEC-0087); work held by the service's own limits is
|
|
86
|
+
said on the job, pushed to the operator once per hold and lifted by their scoped answer (`scripts/fabric_holds.py`,
|
|
87
|
+
FAA-298). Read
|
|
86
88
|
[the interop reference](references/interop.md) before writing an MCP tool; the helpers
|
|
87
89
|
are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
|
|
88
90
|
[`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs). When the callers are agents of the estate, the
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A service answers one inbound question from its own knowledge file, or hands it to a person. It is a step of an
|
|
4
4
|
agent that answers people, not that agent: see the paragraph after the table. Under the creating-fabric-agents
|
|
5
|
-
template (`communication-agent-template.md`) the plan-and-draft call writes the draft, not this recipe's `answer()`:
|
|
5
|
+
template (`templates/communication-agent/`, read through `references/communication-agent-template.md`) the plan-and-draft call writes the draft, not this recipe's `answer()`:
|
|
6
6
|
the draft carries a `[KB:<id>]` marker for each knowledge id the plan used, and the agent runs `check_reply(draft,
|
|
7
7
|
question, prompt_ids, max_chars=..., sources=...)` on it for the code checks. `prompt_ids` are the ids of the items
|
|
8
8
|
placed in the plan's prompt, never the plan's own list (an id the plan names outside them is held as a suspected
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md
CHANGED
|
@@ -65,8 +65,8 @@ host's `open` with `fallback=never`. Never `open <url>`, `open -a Safari`,
|
|
|
65
65
|
agent's pages. A browser only when `host_status` confirms `not_installed`, and
|
|
66
66
|
a raw `http://127.0.0.1:<port>` address only after the listener on that port is
|
|
67
67
|
this OS account's (DEC-0154 PL-3 and HA-2; the adapter's `building-fabric-services/scripts/fabric_peer_owner.py`: `server_end` of a connection it opens and sends nothing on; `check_service.py <id>.<instance>` prints it as `listener.owner`): a
|
|
68
|
-
`foreign-user` listener is reported, never opened (the link gate below
|
|
69
|
-
|
|
68
|
+
`foreign-user` listener is reported, never opened (the link gate below takes
|
|
69
|
+
that owner as `listener_owner`). An installed host that failed
|
|
70
70
|
to open is reported, not replaced by HTTP. Links
|
|
71
71
|
carry no token or login code. Every completion and notification action passes
|
|
72
72
|
the Fabric Agent Adapter's link gate before delivery: `check_dashboard_link.py`
|
|
@@ -119,7 +119,12 @@ Its trusted context is supplied by the host, not by untrusted model output:
|
|
|
119
119
|
|
|
120
120
|
`native_link` must equal the resolved open_link, with an available host on the
|
|
121
121
|
viewer's device. `browser_link` must equal http_url, with confirmed not_installed,
|
|
122
|
-
if_absent policy
|
|
122
|
+
if_absent policy, the same device and `"listener_owner": "this-account"`: the kind
|
|
123
|
+
the host read for the listener on http_url's port (`fabric_peer_owner`'s
|
|
124
|
+
`this-account`, `another-account`, `system`, `unknown`, `none`, or
|
|
125
|
+
`check_service.py`'s `foreign-user`); any other, or none given, refuses the
|
|
126
|
+
browser (DEC-0154 PL-3, FAA-251). The native link does not take it: Fabric
|
|
127
|
+
Dashboards checks its own listener. Contract DEC-0123 PL-3 also allows a browser
|
|
123
128
|
on `unsupported` (a system the host does not ship for); the gate treats
|
|
124
129
|
`unsupported` as no fallback, by the operator's rule that a browser opens only on a
|
|
125
130
|
confirmed `not_installed`, and macOS never reports it (FAA-137). `remote_open` instead contains `device_id`,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md
CHANGED
|
@@ -53,8 +53,11 @@ the same owner; **T2** everyone else, and any call relayed onto loopback from of
|
|
|
53
53
|
hub forwarding a T2 caller). DL-26: a T0 or T1 caller is never required to present a token; pass the tier,
|
|
54
54
|
`gate(tool, headers, tier="T0")` or `server.handle(message, headers, tier="T0")`, and the call passes with no
|
|
55
55
|
delegation. On a computer with more than one counted OS account, a loopback caller with no token is T0 only after the
|
|
56
|
-
door checks the peer's account (the pinned DEC-0154 HA-3 and DL-25
|
|
57
|
-
`
|
|
56
|
+
door checks the peer's account (the pinned DEC-0154 HA-3 and DL-25): the kit's `LoopbackHTTPServer` with `peer_check="strict"` (Node
|
|
57
|
+
`loopbackServer(app, {peerCheck: 'strict'})`, [protocol.md](protocol.md#the-listeners-owner-dec-0154-ha-2)) is that check,
|
|
58
|
+
for loopback peers: pass `tier="T0"` only when the handler's `self.server.peer_owner()` (`connectionOwner(req.socket)`)
|
|
59
|
+
names an owner, since a peer off loopback is served without one and is T2;
|
|
60
|
+
otherwise pass `tier="T0"` there only for a caller the descriptor token authenticated. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
|
|
58
61
|
profile. Money limits and human gates bind at every tier.
|
|
59
62
|
|
|
60
63
|
**The family and the agent network** (DEC-0130, wording settled by DEC-0137). The operator's family is every agent
|
|
@@ -159,6 +159,33 @@ node keys every per-caller rule with `job_caller_key`, counts every started job
|
|
|
159
159
|
and paused ones too) as running, and answers another caller's job id `unknown-job`. The text-only `queue-full` result
|
|
160
160
|
and `fabric.job.list` are still the node's own (FAA-103).
|
|
161
161
|
|
|
162
|
+
### Work held by the service's own limits
|
|
163
|
+
|
|
164
|
+
A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
|
|
165
|
+
says so, so a hold is never seen only by a caller who waits (FAA-298). `fabric_holds` (Node `fabric-holds.mjs`) does
|
|
166
|
+
the three parts:
|
|
167
|
+
|
|
168
|
+
- **On the job.** `hold_job(jobs, job_id, reason, since, until, message)` (Node `holdJob`) pauses the job with the
|
|
169
|
+
statusMessage `waiting_message` writes: `quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`,
|
|
170
|
+
`reason` one of `daily-quota`, `caller-share`, `concurrency`, `money`. The pinned `interop-job` schema is closed
|
|
171
|
+
(`additionalProperties: false`), so `waiting {reason, since, until, message}` is carried in `statusMessage`, the
|
|
172
|
+
contract's pause grammar, never as a field of the job: every caller already reads it as wait (until `until` when
|
|
173
|
+
given), and `read_waiting(answer)` (Node `readWaiting`) gives the four parts back. A job waiting its normal turn in
|
|
174
|
+
the call queue keeps `queued <position> of <length>`; a hold is the service's own limit.
|
|
175
|
+
- **One push per hold.** `HoldBook(path, events=log)` (Node `new HoldBook(file, {events})`) opens a hold per reason and
|
|
176
|
+
caller: `hold(caller=, work=, reason=, until=, job_id=)` returns `{hold, statusMessage, push}`, and only the call that
|
|
177
|
+
opens the hold pushes: one `limit.held` event, level `notice`, `notify: true`, naming the caller, the work, the reason
|
|
178
|
+
and when it frees by itself, and asking whether to raise the limit. More work held by the same limit for the same
|
|
179
|
+
caller joins the hold silently. A hold closes by `release`, when its `until` passes, or by a lift that covers it;
|
|
180
|
+
the next hold pushes again. Names that read as a credential are refused.
|
|
181
|
+
- **The operator's answer as a scoped lift.** `lift(reason=, decision=, caller=, job_id=, jobs= | usd= | limit=,
|
|
182
|
+
until=)` records the operator's words (`fabric_actions.operator_decision("limit.lift", words)`) with its scope (this
|
|
183
|
+
order, one caller, or every caller) and its size: a number of jobs, an amount of money, or a raised limit for a window
|
|
184
|
+
(100 a day for today: `limit=100, until=<midnight>`), and writes `limit.lifted`. `admit(reason=, caller=, job_id=,
|
|
185
|
+
cost_usd=)` spends a jobs or money lift (the narrowest scope first) and answers whether the work may pass the limit;
|
|
186
|
+
pass its answer to `jobs.create(..., limit_holds=not admitted)` for a repeated key. `limit(reason=, base=, caller=)`
|
|
187
|
+
is the limit in force. A lift falls back by itself when its window ends or its amount is spent.
|
|
188
|
+
|
|
162
189
|
<a id="actions"></a>
|
|
163
190
|
|
|
164
191
|
## Actions
|
|
@@ -412,5 +439,6 @@ your own record, keep it (OA-6).
|
|
|
412
439
|
| `interop.events-trace` | every event with a trace carries both `traceId` and `spanId`, well formed | no events page was read |
|
|
413
440
|
| `interop.action-tools` | every action the well-known `extensions/actions/0.1` declares names a tool `tools/list` serves (DEC-0121 AP-1); one it does not serve is a `FAIL`, and so is a local service that declares actions with no MCP surface (beyond FAC-SEM-142, which reads only a present list) | no declaration, an unreadable one (`well-known.actions` reports it), no listing was read, or a remote service with no MCP surface (its twin may be on A2A or behind a gateway) |
|
|
414
441
|
| `channel-agent.declaration` | a channel agent's declaration (DEC-0150) passes the contract's FAC-SEM-162 to 165 at the pin, read from `provider.extensions` of the manifest the descriptor names, else from `extensions` of the well-known answer (CA-1); a `FAIL` on the obligations of a declaration the reader still reads (FAC-SEM-164) or a string shaped like a credential (FAC-SEM-165, not a declaration too large to read for credentials) in the read declaration or in any copy beside it, the first five findings shown as code and path, never a declared value | nothing declared, the named manifest unreadable (`interop.manifest-link` fails it), or a key read as not declared (a manifest's own `extensions`, a capability's, another version's key, a well-known copy beside a manifest, a form the schema or FAC-SEM-163 refuses), each named in the evidence, never a failure while producers move to this revision unless a copy holds a string shaped like a credential (that fails in any copy the probe reads: a manifest's own `extensions`, a capability's, the well-known copy, CA-5); no line when the well-known answer is not read; credentials are read as the contract reads them, within its reading budget and under the `0.1` key only (a key of another version is named, not read); a well-known answer carrying the declaration at 75 % of the 2 MiB cap or over is named, never a failure (until the contract's CT-223) |
|
|
442
|
+
| `limits.holds` | a service that declares limits (a load declaration's `limits`, the usage report's `budget` or `budgets`) holds work through `fabric_holds` (`HoldBook`, `waiting_message`, `hold_job`) in a tracked code file (`.py`, `.mjs`, `.js`, `.ts`) of its checkout outside the kit's copy, a vendored `check_service_rules.py` and the tests (FAA-298) | no limits declared, or no checkout found (`--repo` names it), git missing or out of time; and, since 0.81.0, a service that declares limits and holds none through the kit: a migration note naming `fabric_holds`, never a `FAIL`, since services built before the module could not have adopted it |
|
|
415
443
|
|
|
416
444
|
`python3 scripts/check_service.py <id>` prints them with the rest; `--json` for a machine.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
59
59
|
| L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
|
|
60
60
|
| L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
|
|
61
61
|
| L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
|
|
62
|
-
| L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0
|
|
62
|
+
| L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries), with the readings its header names (`1.2.0+kit.5`; Observatory's 1.3.x libproc proof is FAA-292), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
|
|
63
63
|
| L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
|
|
64
64
|
| L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
|
|
65
65
|
| L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
|
|
@@ -82,7 +82,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
82
82
|
| L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
|
|
83
83
|
| L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
|
|
84
84
|
| L3 | `fabric_backups` | settings backups (DEC-0025) |
|
|
85
|
-
| L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document |
|
|
85
|
+
| L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document, the loopback HTTP server that refuses another OS account's peer before reading it (`LoopbackHTTPServer`, Node `loopbackServer`; FAA-227) |
|
|
86
86
|
| L3 | `fabric_context` | one instance per context (FAA-125, N-11): a context's instance, folders, descriptor, lock and marker; not in the facade, installed with `--module fabric_context` ([contexts](estate-record.md#one-instance-per-context-the-helper)) |
|
|
87
87
|
| L3 | `fabric_auth` | the token, the network guard, login codes |
|
|
88
88
|
| L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
|
|
@@ -92,6 +92,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
92
92
|
| L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
|
|
93
93
|
| L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92), a job's approval step under the mandate (`job_step`, C3.3) |
|
|
94
94
|
| L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
|
|
95
|
+
| L3 | `fabric_holds` | work a service holds by its own limits (FAA-298): the held job's `statusMessage` in the pause grammar (`waiting_message`, `read_waiting`, `hold_job`), one push per hold and the operator's scoped lift (`HoldBook`: `hold`, `release`, `lift`, `admit`, `limit`) ([interop](interop.md#work-held-by-the-services-own-limits)) |
|
|
95
96
|
| L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
|
|
96
97
|
| L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
|
|
97
98
|
| L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data, and the operator's mandate a request carries (FAA-135, imports `fabric_mandate` and `fabric_interop_rules` for the FAA-139 reading) |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -145,27 +145,34 @@ it at boot, before any login, as the account its `UserName` names.
|
|
|
145
145
|
install requires (without it the job would run as root, which every client refuses); the file goes in
|
|
146
146
|
`/Library/LaunchDaemons/<label>.plist`. A plist there is in the system domain by its path (the folder's case is
|
|
147
147
|
ignored); `domain="system"` (Node: `domain: 'system'`) says so for any path, and an explicit domain wins over the
|
|
148
|
-
path
|
|
148
|
+
path, except that an install refuses a pair launchd would contradict: `domain="system"` with a plist in a
|
|
149
|
+
`LaunchAgents` folder (launchd also loads that into `gui/<uid>` at login) and `domain="gui"` with a plist in
|
|
150
|
+
`/Library/LaunchDaemons` (launchd loads it at boot as root). A domain name given as the plist path (`"system"`,
|
|
151
|
+
`"gui"`) is refused, never read as a relative path (FAA-286). Everything else is a LaunchAgent's.
|
|
149
152
|
`UserName` is the account whose Fabric and agents call the service: clients of another account refuse it (HA-2).
|
|
150
153
|
- **Who runs which step.** The release, the descriptor and the service token are written as the `UserName` (steps 1 and
|
|
151
154
|
2 above, for example with `sudo -u <user> -H`), so they live in that user's home with its modes, and the plist's
|
|
152
155
|
working directory and log paths name that home; only the plist, `launchd_install` and `launchd_uninstall` run as root,
|
|
153
156
|
and at uninstall `remove_descriptor` runs as the `UserName` again. A service moved from a LaunchAgent is uninstalled
|
|
154
|
-
as one first, so launchd never holds both
|
|
157
|
+
as one first, so launchd never holds both: the install refuses a plist of the same label left in the `UserName`'s
|
|
158
|
+
`~/Library/LaunchAgents`, and the probe's `lifecycle.one-copy` fails a label loaded in both domains (FAA-286). The descriptor's `lifecycle.plist` names the path exactly,
|
|
155
159
|
`/Library/LaunchDaemons/<label>.plist`: a host that reads the folder by its spelling then knows the domain too.
|
|
156
160
|
- **The label is the machine's.** `/Library/LaunchDaemons` and the system domain are shared by every account of the
|
|
157
161
|
Mac, so a LaunchDaemon's label is unique on the machine, not per account (put the account in it on a Mac with a
|
|
158
162
|
personal and an organization account); an install refuses, before writing anything, a label another plist holds
|
|
159
163
|
(the one launchd loaded the job from, or any plist of that label in the same folder) and the plist at its path when it
|
|
160
164
|
names another `UserName` or another label; an uninstall refuses a label another plist holds before its `bootout`,
|
|
161
|
-
which would stop that plist's job, so pass it the label the plist at its path names
|
|
165
|
+
which would stop that plist's job, so pass it the label the plist at its path names; in either domain an uninstall
|
|
166
|
+
refuses a plist at its path that names another label, before its `bootout` (FAA-286).
|
|
162
167
|
- **Root installs and removes it.** `launchd_install` and `launchd_uninstall` in the system domain refuse, before writing
|
|
163
168
|
anything, in a process that is not root, and name the command to run as root (`launchctl bootstrap system <plist>`,
|
|
164
169
|
`launchctl bootout system/<label>`, removing the plist and `launchctl enable system/<label>`, as `launchd_uninstall`
|
|
165
170
|
does). `purge` is refused: the data belongs to the `UserName`, so remove it as that user. Upgrade is the same install
|
|
166
171
|
again as root, so a descriptor's `commands.update` for a LaunchDaemon runs as root, writes the new release (and any
|
|
167
172
|
descriptor it rewrites) as the `UserName` as an install does, and runs only the plist and `launchd_install` as root; a
|
|
168
|
-
host without root reports it as the operator's.
|
|
173
|
+
host without root reports it as the operator's. Its commands name absolute paths: `~` would be root's home, and the
|
|
174
|
+
probe's `lifecycle.plist` fails a LaunchDaemon command that starts with `~`, as it fails a LaunchDaemon plist without
|
|
175
|
+
`UserName` (FAA-286). A host's verbs need root too and keep LC-14 (off stays off, across a boot as well as a login): stop is
|
|
169
176
|
`bootout` + `disable system/<label>`, start is `enable` + `bootstrap system <plist>`, restart is
|
|
170
177
|
`kickstart -k system/<label>`; a host or broker without root reports the job read-only.
|
|
171
178
|
- **Status and pid read without root**: `launchd_loaded(label, "system")` and `launchd_pid(label, "system")` ask
|
|
@@ -177,23 +184,32 @@ it at boot, before any login, as the account its `UserName` names.
|
|
|
177
184
|
that pid accepted, while the pid runs as the `UserName`'s uid (`ps`) and listens on exactly `127.0.0.1` (`lsof -p`);
|
|
178
185
|
an install refuses a `UserName` that is not a short name, no account of the Mac or root, and an origin other than
|
|
179
186
|
`http://127.0.0.1:<port>`, where nothing could prove it. Where the socket table is restricted it accepts a port that
|
|
180
|
-
pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (
|
|
187
|
+
pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (DEC-0155 RT-3). A `ProgramArguments` wrapper that
|
|
181
188
|
starts the server as a child cannot pass either check, and the refusal says so: start the server with `exec`. An
|
|
182
189
|
install refused after its bootstrap leaves the job loaded, and its plist starts it again at boot; the error says so,
|
|
183
|
-
and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away.
|
|
190
|
+
and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away. A
|
|
191
|
+
bootstrap that fails all five tries removes the plist the call wrote, and names one that was there before, which
|
|
192
|
+
would load at the next boot (FAA-286). A plist that does not parse is refused as such before anything is written,
|
|
193
|
+
never as a missing `UserName`; the Node installer refuses a binary plist and reads only the root dict's keys. For hosts and the probe the domain comes from the plist's folder alone, so a LaunchDaemon's plist must live
|
|
184
194
|
in `/Library/LaunchDaemons` even when the installer is given `domain="system"`: the installer neither moves nor checks
|
|
185
195
|
it. A descriptor field for the domain is the contract's CT-234, open.
|
|
186
196
|
- **A contract exception, accepted in principle.** At the pin, HA-2 reads a launch daemon's listener as another
|
|
187
|
-
account's and nothing it answers as the service's; the
|
|
197
|
+
account's and nothing it answers as the service's; the pin's RT-3 (DEC-0155) vouches only for the caller's own uid. The
|
|
188
198
|
root installer reads its own job's token-free well-known answer anyway, proved by launchd's pid, that pid's uid and its
|
|
189
199
|
bind on exactly `127.0.0.1` (`lsof -p`, never a wildcard), and sends no credential; under a restricted socket table the
|
|
190
|
-
first proof is the
|
|
200
|
+
first proof is the RT-3 weaker vouch (the pid listens on the port; that it accepted the connection is not shown). The contract owner accepted this in
|
|
191
201
|
principle as DEC-0161 (RT-3 extended to launchd's system domain, the restricted-table vouch included; proposed, its
|
|
192
202
|
text after its readings), so it stays a departure from the pin until the next repin (FAA-288).
|
|
193
203
|
Every other client keeps HA-2 as written.
|
|
194
204
|
- **Hosts.** A host or broker that reads launchd only in `gui/<uid>` sees a LaunchDaemon as not loaded and must not load
|
|
195
205
|
its plist into `gui/<uid>`. Fabric Dashboards from 0.8.39 takes the domain from the plist's folder and offers no verbs
|
|
196
|
-
for a system job; the lifecycle broker is still asked (FAA-287).
|
|
206
|
+
for a system job; the lifecycle broker is still asked (FAA-287). A product with its own launchd reader (a status
|
|
207
|
+
page's `launchd_pid`, an installer of its own in another language) that asks only `gui/<uid>` reports a LaunchDaemon
|
|
208
|
+
as not loaded: move that reader to the plist's domain before the product moves to the system domain (FAA-286).
|
|
209
|
+
- **Off macOS the twins differ, on purpose.** Python's `launchd_domain()` answers `gui/<uid>` on Linux (it refuses only
|
|
210
|
+
Windows), so the kit's launchd tests run on Linux CI against a fake `launchctl`; Node's `launchdDomain()` refuses off
|
|
211
|
+
`darwin` unless a test passes `platform: 'darwin'`. Neither runs launchd off macOS: install with
|
|
212
|
+
`systemd_install` / `task_install` there (FAA-286 item 5, recorded rather than aligned).
|
|
197
213
|
|
|
198
214
|
## One copy under a supervisor
|
|
199
215
|
|
|
@@ -31,4 +31,5 @@ Order matters: each step leaves the service answering.
|
|
|
31
31
|
| Data, logs or caches inside the repository | the OS data, log and cache directories; the old tree read-only for one release |
|
|
32
32
|
| `KeepAlive {SuccessfulExit:false}`, a plist pointing at a checkout or a worktree | a plist generated by `launchd_plist`, pointing at a release |
|
|
33
33
|
| A port another service already uses by default | the port claim at install; move the newcomer |
|
|
34
|
+
| A port held by socket activation (launchd `Sockets`, a systemd `.socket` unit) or by a root proxy in front of the service | the service binds `127.0.0.1:<port>` itself as its own account; launchd's or root's socket reads as `system` and the hosts' and the probe's `listener.owner` refuse it (DEC-0154 HA-2) |
|
|
34
35
|
| An event log in a private shape | the events view over it, with one sentence per event |
|
|
@@ -42,6 +42,16 @@ python3 scripts/fabric_package.py pack <folder> # a dry run: the packa
|
|
|
42
42
|
python3 scripts/fabric_package.py pack <folder> --apply # writes fabric.package.json when nothing is wrong
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
When the folder carries the agent's source manifest, `fabric.source.json` (contract DEC-0149, `fabric-source/1`), `pack`
|
|
46
|
+
writes `secrets[]` from its `secret` slots (name, purpose and `optional`) and `state[]` from its `store` slots' paths,
|
|
47
|
+
says so in its notes, and lists the manifest in `files[]` like any other file; `pack`, `sign` and `verify` refuse a
|
|
48
|
+
package whose `secrets[]` or `state[]` differ from the slots (FAC-SEM-160). A hand-written `secrets[]` naming a secret
|
|
49
|
+
the manifest has no slot for is never dropped: `pack` refuses (rule `source`, `/secrets`) and names each one, so add
|
|
50
|
+
it as a `secret` slot (or take it out) and pack again. An author's `state: []` is a copy packed
|
|
51
|
+
without state and is kept. A manifest that is no `fabric-source/1` object is refused. The manifest's own rules and the
|
|
52
|
+
tree check under it are the source check's (`references/source-and-instance.md` of adapting-projects-to-fabric, *The
|
|
53
|
+
source manifest*).
|
|
54
|
+
|
|
45
55
|
Run `adapting-projects-to-fabric/scripts/fabric_source_check.py check <folder> --every-file` on the folder you pack first (a floor under your own
|
|
46
56
|
review, not a secret scanner): a package carries
|
|
47
57
|
the source, and the instance (the owner's paths, keys, strings) stays out of it (`references/source-and-instance.md` of
|