@passioncode-ai/fabric-agent-adapter 0.74.0 → 0.76.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 +115 -0
- package/README.md +9 -4
- package/SECURITY.md +5 -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 +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +3 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +42 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +31 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +103 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +192 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +6 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +380 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +8 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +3 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.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/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.76.0 (2026-11-07)
|
|
4
|
+
|
|
5
|
+
The contract pin stays `18f8d487`. Kit modules change: `fabric_peer_owner` and `fabric-peer-owner.mjs` (peer_owner
|
|
6
|
+
`1.1.0+kit.2`), `fabric_supervisors` and `fabric-supervisors.mjs`, `fabric_http`, and the facades `fabric_service` and
|
|
7
|
+
`fabric-service.mjs` (one more export each).
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **The launchd installers prove their own job on a restricted socket table** (FAA-267). macOS 27.2 shows an
|
|
12
|
+
ad-hoc-signed interpreter (a Homebrew Python) only its own sockets through sysctl and nothing through netstat, so
|
|
13
|
+
`launchd_install` refused a service it had just started (`foreign-user`, `no-row`). The sysctl calibration now also
|
|
14
|
+
requires a child process's listener; an empty netstat answers `unknown` with reason `table-restricted`, never another
|
|
15
|
+
account's. `CheckedHTTPConnection(vouch_pid=...)` and the Node `checkedSocket({vouchPid})` accept a listener on the
|
|
16
|
+
port that `lsof -p` shows belongs to that pid of this account; `launchd_install` and `launchdInstall` vouch with their
|
|
17
|
+
own job's pid (`launchd_pid`, `launchdPid`, read only after a `table-restricted` answer), and a refusal that remains reads `<origin> could not be proved this service's:
|
|
18
|
+
table-restricted: ...`: the Python installer names the way round it (Apple's `/usr/bin/python3`), the Node one asks for a runtime that sees the table
|
|
19
|
+
(no Node was measured). `lsof` is asked for IPv4 alone, so a v6-only wildcard never vouches for a `127.0.0.1`
|
|
20
|
+
connection, and a vouch is an integer pid. The calibration child (one short-lived interpreter per process) is bounded
|
|
21
|
+
by the table timeout, and a child that cannot be started or does not answer proves nothing. Measured on a host
|
|
22
|
+
agent's Mac (macOS 27.2) with a pre-release build: the install passes, a root listener on another port is still
|
|
23
|
+
refused, the method is netstat under Homebrew Python and sysctl under Apple's; the final form was not rerun there
|
|
24
|
+
([record](docs/reports/2026-11-07-faa-267/README.md)).
|
|
25
|
+
- **A release folder in a plist's or a supervisor file's environment is no credential** (FAA-266): `PYTHONPATH`
|
|
26
|
+
holding a release folder under Application Support was refused as a key. An environment value that reads as a path
|
|
27
|
+
(it starts with `/`, `~`, `.` or a drive, has at least two separators and one all-lower-case part such as
|
|
28
|
+
`example-agent`) is now read by the path-aware detector (`secret_argument`, `secretArgument`); any other value keeps
|
|
29
|
+
the strict `secret_value`, so a base64 key whose `/` splits it is still refused: of 60,000 random keys 20 pass,
|
|
30
|
+
against 13 for the strict detector alone ([record](docs/reports/2026-11-07-faa-267/README.md)). `adapt_project.py`'s LC-21 check reads a plugin MCP
|
|
31
|
+
server's `env` the same way. A path whose part after a space has no all-lower-case folder and holds a digit
|
|
32
|
+
run (`.../Application Support/ExampleAgent/Releases/2026-11-07`) is still refused, as in 0.75.0 (FAA-273).
|
|
33
|
+
|
|
34
|
+
### Known limits
|
|
35
|
+
|
|
36
|
+
- Only the launchd installers vouch. On a restricted table (macOS 27.2 under an ad-hoc-signed interpreter) every
|
|
37
|
+
other caller of the owner check still refuses a loopback listener whose owner cannot be told: the probe
|
|
38
|
+
(`check_service.py`, rule `listener.owner`), the hub client and so the board responder, `fabric_http` and so the
|
|
39
|
+
OpenRouter connector, `mcp-register`, the `dashboard` command, and the layout probe. The
|
|
40
|
+
probe and `fabric_http` say `foreign-user` and carry the `table-restricted` reason; the hub client says only
|
|
41
|
+
`unknown` (FAA-272). The way round for Python is Apple's `/usr/bin/python3`; a Node service has no measured way
|
|
42
|
+
round yet.
|
|
43
|
+
- The vouch is the launchd job's own pid: a `ProgramArguments` wrapper that starts the server as a child (`sh -c`
|
|
44
|
+
without `exec`, `uv run`, `npm start`) cannot vouch for it. Start the server with `exec`, or use Apple's Python.
|
|
45
|
+
|
|
46
|
+
### Upgrading
|
|
47
|
+
|
|
48
|
+
- `wellKnownOf`'s `ForeignUserAnswer.reason` may start with `table-restricted` beside `foreign-user`. A
|
|
49
|
+
`launchd_install` or `launchdInstall` refusal on a restricted table now reads `<origin> could not be proved this
|
|
50
|
+
service's: table-restricted: ...` where it read `<origin> is not this service's: foreign-user: ...`: match the
|
|
51
|
+
reason as a substring, not the start of the message.
|
|
52
|
+
- New optional arguments: `vouch_pid` on `CheckedHTTPConnection` and `own_connection`, `vouchPid` on `checkedSocket`, `CheckedAgent` and
|
|
53
|
+
`wellKnownOf`. The vouch is consulted only on macOS when the owner reads `table-restricted`; it never overrides
|
|
54
|
+
another account's listener or another unknown reason. New names in both peer-owner twins: `TABLE_RESTRICTED` (the
|
|
55
|
+
reason to match on), `LSOF`, `pid_listens` / `pidListens` and `parse_lsof_listeners` / `parseLsofListeners`; the facades export `launchd_pid` (`fabric_service`) and
|
|
56
|
+
`launchdPid` (`fabric-service.mjs`).
|
|
57
|
+
- A service that vendored the 0.74.0 or 0.75.0 kit keeps refusing its own install on macOS 27.2 until its copies are
|
|
58
|
+
refreshed (`fabric_kit.py status` names them older).
|
|
59
|
+
|
|
60
|
+
## 0.75.0 (2026-11-07)
|
|
61
|
+
|
|
62
|
+
Guidance only: no kit module, probe rule or hook changes; the contract pin stays `18f8d487`.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- **The Fabric communication agent template** (FAA-262, the operator's decision of 2026-11-07):
|
|
67
|
+
`creating-fabric-agents/references/communication-agent-template.md`, a prompt a coding agent builds a channel agent
|
|
68
|
+
from (support, sales and leads, community, inbox, store reviews, an internal assistant). It carries the inbound
|
|
69
|
+
pipeline as one estate measured it on 56 real questions and 19 written batches: a code prefilter, a triage at low
|
|
70
|
+
thinking that answers only a batch with no ask, one call that analyses, plans and drafts (with the injection check
|
|
71
|
+
and the product-only scope), the actions, a final pass only when an action returned new facts or an injection is
|
|
72
|
+
suspected, then the pre-send gates with a checker of another vendor. It also carries the business patterns, the
|
|
73
|
+
knowledge and data connections (the handoff queue's «a person answered» signal, the account state and version facts
|
|
74
|
+
as tools of every turn, retrieval over the whole conversation), leads handed to a sales agent of their own that owns
|
|
75
|
+
the CRM, the learning loop, the model lanes, the Fabric integration and the acceptance with 18 adversarial batches, and maps its sections to the parts of an organization agent template one file can carry (fabric-workspace
|
|
76
|
+
`knowledge/system/templates.md`, S2; S2's operating rules A1 to A12 apply by reference until the folder, FAA-265). `SKILL.md` links it beside `agents-that-answer-people.md`.
|
|
77
|
+
- A scenario eval (`a-channel-agent-starts-from-the-communication-template`) and a trigger query for it (measured with a
|
|
78
|
+
real client: creating-fabric-agents 28 of 31, the new query routed right;
|
|
79
|
+
[record](docs/evidence/evals/2026-11-07-trigger-run.md)), and `test/test_communication_template_prose.py`,
|
|
80
|
+
which pins the template phrases that scenario grades and checks every event kind it names against the kit's writer.
|
|
81
|
+
|
|
82
|
+
### Changed
|
|
83
|
+
|
|
84
|
+
- `references/models-and-providers.md`: a new agent's model key gets USD 100 a month unless the operator names a higher
|
|
85
|
+
one, and the agent's own stop sits a little under the key's limit (the operator's decision of 2026-11-07).
|
|
86
|
+
It also names the template's open-vendor rule (every route of a lane reading people's words, fallbacks included) as
|
|
87
|
+
narrower than DEC-0083, and excepts a channel agent's batch triage from the volume row.
|
|
88
|
+
- The template is linked from the creating-fabric-agents grill row «Does it answer people?» (the row «Who pays?» names
|
|
89
|
+
the USD 100 default), `agents-that-answer-people.md` and `practices.md`, and named in building-fabric-services
|
|
90
|
+
`SKILL.md` and the support-agent recipe's README, which now says how the recipe serves the template: the plan's draft carries `[KB:<id>]`
|
|
91
|
+
markers and the agent runs `check_reply` on it; the checker of another vendor stays the agent's own.
|
|
92
|
+
|
|
93
|
+
- `agents-that-answer-people.md` A1: every inbound message belongs to one turn; a burst the template batches is one
|
|
94
|
+
turn listing its message ids (it said every message is a turn). The support-agent recipe's README names what goes in
|
|
95
|
+
`check_reply`'s `given`: the ids placed in the prompt, never the plan's own list, with tool results as entries of their own.
|
|
96
|
+
- The operator channel's first step (`operator-channel-and-repository.md`, in adapting-projects-to-fabric and
|
|
97
|
+
creating-fabric-agents) says an agent that answers people on Telegram gives the operator channel a bot of its own,
|
|
98
|
+
and its example slot is now `EXAMPLE_AGENT_OPERATOR_BOT_TOKEN`.
|
|
99
|
+
- The support-agent recipe's `channel-agent.example.json` final text reads as the standard's final reply (6.6: «we
|
|
100
|
+
could not confirm this»), not as an answer.
|
|
101
|
+
- `docs/backlog.md`: FAA-263 (SKILL.md word headroom), FAA-264 (the readings' follow-ups that block nothing) and
|
|
102
|
+
FAA-265 (organization agent templates as folders, W0-Q8).
|
|
103
|
+
- building-fabric-services `references/usage.md`: the every-limit example calls `usage_report` over the ledger's
|
|
104
|
+
receipts, since the Python `ledger.report` takes no `budgets` (it showed a call that raised `TypeError` since 0.19.0);
|
|
105
|
+
`test/test_usage_reference_snippet.py` runs it.
|
|
106
|
+
|
|
107
|
+
### Upgrading
|
|
108
|
+
|
|
109
|
+
- An answering agent that adopts the template moves every route that reads people's text to an open-vendor model,
|
|
110
|
+
fallbacks included (template rule 6); one that does not adopt it keeps what DEC-0083 asks. Its monthly stop uses the
|
|
111
|
+
kit's OpenRouter client with `window="month"` (the default window is a day).
|
|
112
|
+
- Adopting the template starts a new series for `nobodyDropped.turns` (batched turns) and the outcome shares
|
|
113
|
+
(`customer_silent` up, `no_reply_owed` down); say so in the owner's report, never inside `support.quality_daily`.
|
|
114
|
+
- A declaration copied from the 0.74.0 support-agent example replaces its `texts.final`, which claimed an answer.
|
|
115
|
+
- An agent whose operator channel shares its answering bot moves the operator channel to a bot of its own (a new token
|
|
116
|
+
slot and link code), and binds the operator's id on the answering bot by its own link code (CA-8).
|
|
117
|
+
|
|
3
118
|
## 0.74.0 (2026-11-07)
|
|
4
119
|
|
|
5
120
|
The contract pin moves to `18f8d487` (`v0.1.0+2026-11-07.1`) from `eac1a594` (`v0.1.0+2026-11-06.1`); between them
|
package/README.md
CHANGED
|
@@ -70,7 +70,9 @@ installed into a service with `scripts/fabric_kit.py install` and checked with `
|
|
|
70
70
|
the dates API); connectors for outbound HTTP and OpenRouter (`fabric_http`, `fabric_openrouter`: one deadline, safe
|
|
71
71
|
retries, the agent's own key, a spend ceiling before every attempt, usage receipts, a fallback model;
|
|
72
72
|
[connectors](plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md)); the draft-and-check step of a support agent as a
|
|
73
|
-
[recipe](plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md)
|
|
73
|
+
[recipe](plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md)
|
|
74
|
+
(the whole agent starts from creating-fabric-agents'
|
|
75
|
+
[communication agent template](plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md));
|
|
74
76
|
a complete `scripts/sample_service.py`; and `scripts/check_service.py`, a live probe. A service that answers
|
|
75
77
|
requests other Projects post to its Project on Fabric's project board uses the board responder
|
|
76
78
|
(`scripts/fabric_hub.py` and `scripts/fabric_board.py`, Node `fabric-hub.mjs` and `fabric-board.mjs`): the
|
|
@@ -190,6 +192,8 @@ python3 "$KIT/sample_service.py" serve --port 47190 --data-dir "$DATA" &
|
|
|
190
192
|
python3 "$KIT/sample_service.py" register --port 47190 --data-dir "$DATA" --services-dir "$SERVICES"
|
|
191
193
|
# The probe exits 0 (no FAIL); only then is the service registered for one client run (listener.owner proves the port
|
|
192
194
|
# is this account's, and the client cannot check it). The token goes in a header, never in argv.
|
|
195
|
+
# On macOS 27.2 run these with Apple's /usr/bin/python3: an ad-hoc-signed Python, such as Homebrew's, sees no other
|
|
196
|
+
# process's sockets (FAA-272).
|
|
193
197
|
python3 "$KIT/check_service.py" sample --services-dir "$SERVICES" && \
|
|
194
198
|
python3 - "$DATA/service.token" "$WORK/mcp.json" <<'EOF'
|
|
195
199
|
import json, os, sys
|
|
@@ -204,9 +208,10 @@ EOF
|
|
|
204
208
|
kill %1
|
|
205
209
|
```
|
|
206
210
|
|
|
207
|
-
Verified 2026-
|
|
208
|
-
pointing at
|
|
209
|
-
the CLI connected to the sample over streamable HTTP, called `sample.echo` and printed `ping
|
|
211
|
+
Verified 2026-11-07 from the published npm package 0.75.0 (`npm pack` into a throwaway folder, `KIT`
|
|
212
|
+
pointing at its skill) with Claude Code 2.1.299: the probe reported 44 rules, 0 FAIL, `listener.owner` PASS;
|
|
213
|
+
the CLI connected to the sample over streamable HTTP, called `sample.echo` and printed `ping`
|
|
214
|
+
([record](docs/evidence/runs/2026-11-07-0.75.0-quick-start.md)). The probe prints one line per rule
|
|
210
215
|
(`PASS`, `FAIL` or `NOT_RUN` with its evidence) and exits 1 on any `FAIL`; run it against your
|
|
211
216
|
own service by its id once its installer has written the descriptor.
|
|
212
217
|
`FABRIC_REAL_CLIENT=1 python3 -m unittest discover -s test -p test_real_client.py` repeats the connection check
|
package/SECURITY.md
CHANGED
|
@@ -41,7 +41,11 @@ bounded and non-publishing until explicit scoped grants exist in a project bindi
|
|
|
41
41
|
-anv -p tcp` and `/bin/ps -o uid= -p <pid>` (the Node twin always); Linux
|
|
42
42
|
`/proc/net/tcp` and `/proc/net/tcp6`; Windows `GetExtendedTcpTable` and the process
|
|
43
43
|
token (Python), or `netstat -ano`, `whoami /user` and one PowerShell `GetOwnerSid` query
|
|
44
|
-
per new connection (Node).
|
|
44
|
+
per new connection (Node). On macOS the Python reader calibrates once per process with a
|
|
45
|
+
short-lived child of its own interpreter listening on a random `127.0.0.1` port; when the
|
|
46
|
+
table is restricted (macOS 27.2, FAA-267) the launchd installers also run `/usr/sbin/lsof
|
|
47
|
+
-nP -a -p <pid> -i4TCP -sTCP:LISTEN -Fn` on their own job's pid. That vouch shows the job listens on the port, not that it accepted
|
|
48
|
+
the connection (a more specific bind by root over a wildcard job would pass; contract CT-261). A token-free loopback door is not yet protected against
|
|
45
49
|
another account's process (HA-3, FAA-227). An MCP client (Claude Code, Codex) sends the
|
|
46
50
|
token it was registered with on every later connection without this check (the contract's
|
|
47
51
|
residue "a coding agent's bearer"): when the port may have changed hands, rotate the token
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -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; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.76.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.76.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
|
|
11
11
|
---
|
|
@@ -12,9 +12,10 @@ its stops with buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
|
|
|
12
12
|
`docs/specification/operator-channel.md`), whose rule OC-11 this follows. Give the operator's four
|
|
13
13
|
steps:
|
|
14
14
|
|
|
15
|
-
1. create a bot with @BotFather
|
|
15
|
+
1. create a bot with @BotFather (for an agent that answers people on Telegram, a bot of its own, never
|
|
16
|
+
the bot that answers people: the contract's channel-agent.md);
|
|
16
17
|
2. put its token into the operator's secret store (Project Observatory where it is installed) under
|
|
17
|
-
the name the agent reads (for example `
|
|
18
|
+
the name the agent reads (for example `EXAMPLE_AGENT_OPERATOR_BOT_TOKEN`), never into a chat, a
|
|
18
19
|
file or the bundle;
|
|
19
20
|
3. run the agent's link command;
|
|
20
21
|
4. type the code it shows in the chat with the bot.
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -586,6 +586,15 @@ def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
|
|
|
586
586
|
_PLUGIN_SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".worktrees"}
|
|
587
587
|
|
|
588
588
|
|
|
589
|
+
def _secret_env_value(value: str) -> bool:
|
|
590
|
+
"""An MCP server's environment value: path-aware only when it reads as a path (it starts as one, has two separators
|
|
591
|
+
and a lower-case word between them), as the kit's supervisor writers read one (FAA-266); else `secret_value`."""
|
|
592
|
+
pieces = re.split(r"[\\/]", value)
|
|
593
|
+
path = bool(re.match(r"(?:[/~.]|[A-Za-z]:[\\/])", value)) and len(pieces) >= 3 and any(
|
|
594
|
+
re.fullmatch(r"[a-z][a-z0-9_-]{2,}", x) for x in pieces)
|
|
595
|
+
return _secrets.secret_argument(value) if path else _secrets.secret_value(value)
|
|
596
|
+
|
|
597
|
+
|
|
589
598
|
def _plugin_problems(root: Path) -> List[str]:
|
|
590
599
|
"""LC-19 (FAA-22) and LC-21 for the Claude Code plugins in the project: a plugin's stdio MCP server starts in
|
|
591
600
|
every session that enables the plugin; serve it over HTTP from one resident process, or register it at project
|
|
@@ -614,7 +623,7 @@ def _plugin_problems(root: Path) -> List[str]:
|
|
|
614
623
|
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"])):
|
|
615
624
|
problems.append("LC-21: plugin %s's MCP server %s carries a credential in its argv or URL" % (name, server))
|
|
616
625
|
env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
|
|
617
|
-
if any(isinstance(v, str) and
|
|
626
|
+
if any(isinstance(v, str) and _secret_env_value(v) for v in env.values()): # path-aware for a path (FAA-266)
|
|
618
627
|
problems.append("LC-21: plugin %s's MCP server %s writes a credential value in env; reference it instead" % (name, server))
|
|
619
628
|
return problems
|
|
620
629
|
|
|
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
14
14
|
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.
|
|
15
15
|
metadata:
|
|
16
16
|
author: PassionCode.ai
|
|
17
|
-
version: "0.
|
|
17
|
+
version: "0.76.0"
|
|
18
18
|
contract-version: "0.1.0"
|
|
19
19
|
extension: "fabric-service/0.1"
|
|
20
20
|
extension-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
|
|
@@ -168,8 +168,8 @@ handler.
|
|
|
168
168
|
Calling out: [the connectors reference](references/connectors.md) (`fabric_http` with one
|
|
169
169
|
deadline, safe retries and a size cap; `fabric_openrouter` with the agent's own key, a
|
|
170
170
|
spend ceiling before every attempt, receipts and a fallback). The draft-and-check step of a
|
|
171
|
-
support agent on the kit: [`recipes/support-agent/`](recipes/support-agent/README.md);
|
|
172
|
-
creating-fabric-agents' `references/agents-that-answer-people.md
|
|
171
|
+
support agent on the kit: [`recipes/support-agent/`](recipes/support-agent/README.md); the whole agent starts from
|
|
172
|
+
creating-fabric-agents' `references/communication-agent-template.md` (parts: `agents-that-answer-people.md`).
|
|
173
173
|
|
|
174
174
|
Startup order is the part that goes wrong; keep it exactly:
|
|
175
175
|
|
|
@@ -419,9 +419,10 @@ this estate needs.
|
|
|
419
419
|
|
|
420
420
|
## Gotchas
|
|
421
421
|
|
|
422
|
-
- `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong
|
|
422
|
+
- `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong.
|
|
423
423
|
- HTTP 200 alone accepts any program on the port: compare `service.id` and `service.instance`, read only from
|
|
424
|
-
this OS account's listener. On `listener.owner` FAIL (`foreign-user`) send that port nothing
|
|
424
|
+
this OS account's listener. On `listener.owner` FAIL (`foreign-user`) send that port nothing; reason
|
|
425
|
+
`table-restricted`: `references/protocol.md`.
|
|
425
426
|
- `launchctl bootstrap` right after `bootout` fails with I/O error 5: wait for the unload, then retry.
|
|
426
427
|
- Under launchd `PATH` is bare. Pin the interpreter by absolute path; set `PATH`
|
|
427
428
|
explicitly in the plist; never copy the whole shell environment into it.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# Recipe: the draft-and-check step of a support agent
|
|
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
|
-
agent that answers people, not that agent: see the paragraph after the table.
|
|
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()`:
|
|
6
|
+
the draft carries a `[KB:<id>]` marker for each knowledge id the plan used, and the agent runs `check_reply(draft,
|
|
7
|
+
question, prompt_ids, max_chars=..., sources=...)` on it for the code checks. `prompt_ids` are the ids of the items
|
|
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
|
|
9
|
+
injection); each tool result of the turn enters both under an id of its own, with its system-of-record fields only, never one
|
|
10
|
+
a customer wrote (`tool.order`; the citation takes letters,
|
|
11
|
+
digits, dot, dash and underscore, no colon; knowledge ids too, so map one that does not). Every template turn that went through the plan runs
|
|
12
|
+
`check_reply` on its final text, except an off-topic plan, a plan whose only actions are ask_clarification or handoff,
|
|
13
|
+
and a triage reply: the route decides, never the draft's markers. The final pass keeps the markers. A handle
|
|
14
|
+
(`@name`) is not one of `check_reply`'s claims: it is the agent's own gate. Repair of a plan draft, and the
|
|
15
|
+
checker of another vendor, stay the agent's own (agents-that-answer-people.md): the recipe's `repair=` reruns only its
|
|
16
|
+
own `answer()` draft, and it has no checker. Built only on the service
|
|
5
17
|
kit: `fabric_openrouter` for the model, `fabric_usage` for receipts, `fabric_paths` for private, atomic state.
|
|
6
18
|
Tests: `test/test_support_agent_recipe.py` (a fake model; no network, no key).
|
|
7
19
|
|
|
@@ -130,7 +142,7 @@ FAC-SEM-162 to 165 (rule `channel-agent.declaration`).
|
|
|
130
142
|
| `stateDir` | the decisions (`decisions.jsonl`), the handoff outbox (`handoffs.jsonl`) and the usage ledger (`usage.jsonl`) |
|
|
131
143
|
| `model`, `fallback` | two models of different vendors; a lane that reads what customers write runs on an open-vendor model (DEC-0083) |
|
|
132
144
|
| `keyEnv` | the environment variable that holds the agent's own OpenRouter key; never a value in the file |
|
|
133
|
-
| `ceilingUsd`, `window` | the spend ceiling per UTC `day` or `month` |
|
|
145
|
+
| `ceilingUsd`, `window` | the spend ceiling per UTC `day` or `month` (`month` under the communication agent template, under the key's monthly limit) |
|
|
134
146
|
| `prices` or `catalog` | USD per token per model. The example's prices are placeholders, not OpenRouter's: take them from `GET https://openrouter.ai/api/v1/models` (a saved catalog file named in `catalog` is read with `prices_from_catalog`). A model without a price is never called |
|
|
135
147
|
| `maxTokens`, `maxChars` | the output budget per call and the reply's length limit |
|
|
136
148
|
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"en": "Thank you, we are looking into it and will answer here."
|
|
60
60
|
},
|
|
61
61
|
"final": {
|
|
62
|
-
"en": "
|
|
62
|
+
"en": "We could not confirm this. Please write to the support address on our site."
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
59
59
|
| L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
|
|
60
60
|
| L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
|
|
61
61
|
| L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
|
|
62
|
-
| L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.
|
|
62
|
+
| L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.2`), 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
|
| L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
|
|
64
64
|
| L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
|
|
65
65
|
| L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -97,7 +97,11 @@ systemd and Task Scheduler do not poll yet, FAA-253).
|
|
|
97
97
|
until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
|
|
98
98
|
then poll the well-known document until it answers with this `id.instance` on a listener of this OS account
|
|
99
99
|
(DEC-0154 HA-2: another account's listener, or one whose owner cannot be told, is `foreign-user` and never a
|
|
100
|
-
successful install; the error names it).
|
|
100
|
+
successful install; the error names it). Where the socket table hides other processes (macOS 27.2 under an
|
|
101
|
+
ad-hoc-signed interpreter), the installer vouches with its own job's pid from `launchctl print`; a listener it
|
|
102
|
+
cannot prove reads `table-restricted` and the error names the way round it (FAA-267). The pid is the job's own, so a
|
|
103
|
+
`ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
|
|
104
|
+
vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
|
|
101
105
|
4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
|
|
102
106
|
again, then `prune_releases(releases_dir, current=<new release>)`: the running release
|
|
103
107
|
and the one before it stay for rollback, older ones go (LC-11, LC-15).
|
|
@@ -224,6 +224,17 @@ and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's toke
|
|
|
224
224
|
on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID from PowerShell `GetOwnerSid` against `whoami /user` on Windows (SIDs, never names). Fail closed: everything but
|
|
225
225
|
`this-account` refuses, `unknown` included.
|
|
226
226
|
|
|
227
|
+
macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python, measured; a Homebrew Node is ad-hoc-signed too and was not measured) only its own sockets: sysctl lists this
|
|
228
|
+
process alone and netstat prints nothing. The Python calibration therefore also asks for a child process's listener,
|
|
229
|
+
and an empty netstat is `unknown` with reason `table-restricted` (never another account's). A caller that started the
|
|
230
|
+
server itself may vouch with that pid: `lsof -p <pid>` asks the process, which the restriction leaves readable, and a
|
|
231
|
+
listener of this account's pid on the port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node
|
|
232
|
+
`checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
|
|
233
|
+
refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
|
|
234
|
+
whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on the port (`127.0.0.1` or the
|
|
235
|
+
IPv4 wildcard), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
|
|
236
|
+
the contract's ruling on vouching is CT-261.
|
|
237
|
+
|
|
227
238
|
Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
|
|
228
239
|
a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
|
|
229
240
|
`fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
|
|
@@ -66,10 +66,12 @@ left out.
|
|
|
66
66
|
it, and the host only shows it.
|
|
67
67
|
|
|
68
68
|
**Every limit (DEC-0027).** Pass `budgets` to list every spending limit the service applies,
|
|
69
|
-
enforced or not, so the spend view shows all of them; the operator asked for that on 2026-10-07
|
|
69
|
+
enforced or not, so the spend view shows all of them; the operator asked for that on 2026-10-07. In Python
|
|
70
|
+
`ledger.report` takes no `budgets`, so call `usage_report` over the ledger's receipts (Node's `ledger.report` passes
|
|
71
|
+
them through):
|
|
70
72
|
|
|
71
73
|
```python
|
|
72
|
-
ledger.
|
|
74
|
+
fabric_usage.usage_report(ledger.receipts(), service_id=ID, instance=INSTANCE, budget={"period": "month", "limitUsd": 100}, budgets=[
|
|
73
75
|
{"id": "machine.monthly", "scope": "machine", "kind": "monthly", "period": "month", "limitUsd": 100, "enforced": True},
|
|
74
76
|
{"id": "project.demo.daily", "scope": "project", "subject": "demo", "kind": "daily", "windowSeconds": 86400,
|
|
75
77
|
"limitUsd": 5, "spentUsd": 0.31, "enforced": True},
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs
CHANGED
|
@@ -45,7 +45,7 @@ import http from 'node:http';
|
|
|
45
45
|
import net from 'node:net';
|
|
46
46
|
import os from 'node:os';
|
|
47
47
|
|
|
48
|
-
export const PEER_OWNER_VERSION = '1.1.0+kit.
|
|
48
|
+
export const PEER_OWNER_VERSION = '1.1.0+kit.2'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
|
|
49
49
|
export const THIS_ACCOUNT = 'this-account';
|
|
50
50
|
export const ANOTHER_ACCOUNT = 'another-account';
|
|
51
51
|
export const SYSTEM = 'system';
|
|
@@ -58,6 +58,10 @@ export const WINDOWS_TIMEOUT_MS = 5000;
|
|
|
58
58
|
export const LISTENER_TTL_MS = 1000;
|
|
59
59
|
export const NETSTAT = '/usr/sbin/netstat';
|
|
60
60
|
export const PS = '/bin/ps';
|
|
61
|
+
export const LSOF = '/usr/sbin/lsof';
|
|
62
|
+
// The reason a socket table hides other processes' sockets from this one (FAA-267): macOS 27.2 gives an ad-hoc-signed
|
|
63
|
+
// runtime an empty netstat.
|
|
64
|
+
export const TABLE_RESTRICTED = 'table-restricted';
|
|
61
65
|
export const SERVER_NOT_OWN = 'server-not-own';
|
|
62
66
|
export const SERVER_UNVERIFIED = 'server-unverified';
|
|
63
67
|
export const SERVER_NOT_THIS_WORKSPACE = 'server-not-this-workspace';
|
|
@@ -143,6 +147,7 @@ function netstatAddress(text) {
|
|
|
143
147
|
// leading and the trailing columns, both counted from the header; a header without process:pid is unreadable.
|
|
144
148
|
export function parseNetstat(text) {
|
|
145
149
|
const lines = String(text).split(/\r?\n/);
|
|
150
|
+
if (!lines.some((line) => line.trim())) throw new TableUnreadable(`${TABLE_RESTRICTED}: netstat printed nothing to this process`);
|
|
146
151
|
const header = lines.findIndex((line) => line.includes('process:pid') && line.includes('Proto'));
|
|
147
152
|
if (header < 0) throw new TableUnreadable('netstat-format: no process:pid column');
|
|
148
153
|
const names = lines[header].split(/\s+/).filter(Boolean);
|
|
@@ -165,6 +170,18 @@ export function parseNetstat(text) {
|
|
|
165
170
|
return rows;
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
// `lsof -nP -a -p PID -i4TCP -sTCP:LISTEN -Fn`: the `n` lines, 127.0.0.1:47480, *:47480 or [::1]:47480.
|
|
174
|
+
export function parseLsofListeners(text) {
|
|
175
|
+
const found = [];
|
|
176
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
177
|
+
if (!line.startsWith('n') || !line.includes(':')) continue;
|
|
178
|
+
const at = line.lastIndexOf(':');
|
|
179
|
+
const port = line.slice(at + 1);
|
|
180
|
+
if (/^[0-9]+$/.test(port)) found.push([normHost(line.slice(1, at).replace(/^\[|\]$/g, '')), Number(port)]);
|
|
181
|
+
}
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
// `ps -o uid= -p PID`: one number, or nothing when the process is gone.
|
|
169
186
|
export function parsePsUid(text) {
|
|
170
187
|
const t = String(text).trim();
|
|
@@ -497,10 +514,25 @@ export function refusal(who, port, expectPid = null) {
|
|
|
497
514
|
return null;
|
|
498
515
|
}
|
|
499
516
|
|
|
517
|
+
// macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on 127.0.0.1:<port>
|
|
518
|
+
// or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection). `lsof -p` asks the process itself, which the restriction does not hide. Only a caller that started
|
|
519
|
+
// `pid` itself (a launchd job it installed) may vouch with it.
|
|
520
|
+
export async function pidListens(pid, port, { run = runProgram, platform = process.platform, ownUid: mine } = {}) {
|
|
521
|
+
if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
522
|
+
try {
|
|
523
|
+
const uid = parsePsUid(await run(PS, ['-o', 'uid=', '-p', String(pid)]));
|
|
524
|
+
if (uid === null || uid !== (mine ?? ownUid())) return false;
|
|
525
|
+
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
526
|
+
return parseLsofListeners(out).some(([host, p]) => (host === '127.0.0.1' || host === '*') && p === port);
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
500
532
|
// A connected socket to host:port whose server end is this account's (and, with expectPid, that process's). The check
|
|
501
533
|
// runs on the very socket the caller then writes on, so no listener can change between the check and the request.
|
|
502
534
|
// Rejects with ListenerRefused (the socket destroyed, nothing written) or with the connection's own error.
|
|
503
|
-
export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, ...ask } = {}) {
|
|
535
|
+
export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, vouchPid = null, ...ask } = {}) {
|
|
504
536
|
return new Promise((resolveSocket, reject) => {
|
|
505
537
|
const socket = net.connect({ host, port, noDelay: true }); // as a plain http.Agent's socket
|
|
506
538
|
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), timeoutMs);
|
|
@@ -515,6 +547,11 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
|
|
|
515
547
|
// A reader that throws (an injected one; the readers themselves answer unknown) fails closed.
|
|
516
548
|
who = owner(UNKNOWN, { method: method(), reason: `check: ${err?.name ?? 'Error'}` });
|
|
517
549
|
}
|
|
550
|
+
if (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED) && vouchPid
|
|
551
|
+
&& await pidListens(vouchPid, port, ask)) {
|
|
552
|
+
// The table hides other processes (FAA-267); the process this caller started holds the listener.
|
|
553
|
+
who = owner(THIS_ACCOUNT, { uid: ask.ownUid ?? ownUid(), pid: vouchPid, method: 'lsof-pid' });
|
|
554
|
+
}
|
|
518
555
|
const refused = refusal(who, port, expectPid);
|
|
519
556
|
if (refused) {
|
|
520
557
|
socket.destroy();
|
|
@@ -532,14 +569,15 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
|
|
|
532
569
|
// when it is made. A refusal reaches the request as its 'error', a ListenerRefused. Free sockets do not keep the process
|
|
533
570
|
// alive (Node unrefs them).
|
|
534
571
|
export class CheckedAgent extends http.Agent {
|
|
535
|
-
constructor({ expectPid = null, ask = {}, ...options } = {}) {
|
|
572
|
+
constructor({ expectPid = null, vouchPid = null, ask = {}, ...options } = {}) {
|
|
536
573
|
super(options);
|
|
537
574
|
this.expectPid = expectPid;
|
|
575
|
+
this.vouchPid = vouchPid;
|
|
538
576
|
this.ask = ask;
|
|
539
577
|
}
|
|
540
578
|
|
|
541
579
|
createConnection(options, callback) {
|
|
542
580
|
checkedSocket(Number(options.port), { host: options.host ?? '127.0.0.1', timeoutMs: options.timeout ?? 10000,
|
|
543
|
-
expectPid: this.expectPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
|
|
581
|
+
expectPid: this.expectPid, vouchPid: this.vouchPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
|
|
544
582
|
}
|
|
545
583
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabri
|
|
|
9
9
|
import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
|
|
10
10
|
import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
|
|
11
11
|
import { URI } from './fabric-backups.mjs';
|
|
12
|
-
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
12
|
+
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
|
|
13
13
|
import http from 'node:http';
|
|
14
14
|
|
|
15
15
|
export const DEFAULT_EXIT_TIMEOUT = 15;
|
|
@@ -55,13 +55,22 @@ function noMarkerArgument(argv) {
|
|
|
55
55
|
if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// An environment value: read path-aware (secretArgument) only when it reads as a path (it starts as one, has at least two
|
|
59
|
+
// separators and a lower-case word between them), so a release folder under Application Support is no key (FAA-266) while
|
|
60
|
+
// a base64 key whose `/` splits it stays one; any other value keeps the strict secretValue.
|
|
61
|
+
const secretEnvValue = (value) => {
|
|
62
|
+
const pieces = value.split(/[\\/]/);
|
|
63
|
+
const path = /^(?:[/~.]|[A-Za-z]:[\\/])/.test(value) && pieces.length >= 3 && pieces.some((x) => /^[a-z][a-z0-9_-]{2,}$/.test(x));
|
|
64
|
+
return path ? secretArgument(value) : secretValue(value);
|
|
65
|
+
};
|
|
66
|
+
|
|
58
67
|
function noSecretInSupervisorFile(argv, environment) {
|
|
59
68
|
// No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
|
|
60
69
|
for (const arg of argv) {
|
|
61
70
|
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a supervisor file never does (read it from the token file or a secret reference).');
|
|
62
71
|
}
|
|
63
72
|
for (const [name, value] of Object.entries(environment ?? {})) {
|
|
64
|
-
if (credentialName(name) ||
|
|
73
|
+
if (credentialName(name) || secretEnvValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -264,7 +273,7 @@ export function launchdPlist(label, programArguments, { workingDirectory, stdout
|
|
|
264
273
|
const env = { ...environment };
|
|
265
274
|
for (const [name, value] of Object.entries(env)) {
|
|
266
275
|
if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
|
|
267
|
-
if (
|
|
276
|
+
if (secretEnvValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
|
|
268
277
|
}
|
|
269
278
|
for (const arg of argv) {
|
|
270
279
|
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
|
|
@@ -302,6 +311,14 @@ export function launchdLoaded(label, { run = defaultRun, platform = process.plat
|
|
|
302
311
|
return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
|
|
303
312
|
}
|
|
304
313
|
|
|
314
|
+
// The running pid of this account's launchd job `label`, or null (not running, or not readable).
|
|
315
|
+
export function launchdPid(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
316
|
+
const result = run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`]);
|
|
317
|
+
if (result?.status !== 0) return null;
|
|
318
|
+
const hit = /^\s*pid\s*=\s*([0-9]+)\s*$/m.exec(String(result.stdout ?? ''));
|
|
319
|
+
return hit ? Number(hit[1]) : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
305
322
|
const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
|
|
306
323
|
|
|
307
324
|
/**
|
|
@@ -326,7 +343,7 @@ export class ForeignUserAnswer {
|
|
|
326
343
|
// The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
|
|
327
344
|
// (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
|
|
328
345
|
// account's or its owner cannot be told.
|
|
329
|
-
export async function wellKnownOf(origin) {
|
|
346
|
+
export async function wellKnownOf(origin, { vouchPid = null } = {}) {
|
|
330
347
|
const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
|
|
331
348
|
if (!m) {
|
|
332
349
|
try {
|
|
@@ -338,7 +355,7 @@ export async function wellKnownOf(origin) {
|
|
|
338
355
|
}
|
|
339
356
|
// A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
|
|
340
357
|
// answer, so a poll leaves no connection open behind it.
|
|
341
|
-
const agent = new CheckedAgent({ keepAlive: false });
|
|
358
|
+
const agent = new CheckedAgent({ keepAlive: false, vouchPid });
|
|
342
359
|
return new Promise((resolve) => {
|
|
343
360
|
const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/.well-known/fabric-service', agent,
|
|
344
361
|
timeout: 2000 }, (res) => {
|
|
@@ -350,7 +367,9 @@ export async function wellKnownOf(origin) {
|
|
|
350
367
|
res.on('error', () => resolve(null));
|
|
351
368
|
});
|
|
352
369
|
req.on('timeout', () => req.destroy());
|
|
353
|
-
req.on('error', (err) => resolve(err instanceof ListenerRefused
|
|
370
|
+
req.on('error', (err) => resolve(err instanceof ListenerRefused
|
|
371
|
+
? new ForeignUserAnswer(`${String(err.owner?.reason ?? '').startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : 'foreign-user'}: ${err.message}`)
|
|
372
|
+
: null));
|
|
354
373
|
req.end();
|
|
355
374
|
}).finally(() => agent.destroy());
|
|
356
375
|
}
|
|
@@ -390,13 +409,18 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
390
409
|
}
|
|
391
410
|
const deadline = Date.now() + timeout * 1000;
|
|
392
411
|
let foreign = null;
|
|
412
|
+
let vouchPid = null;
|
|
393
413
|
while (Date.now() < deadline) {
|
|
394
|
-
const doc = await fetchWellKnown(origin);
|
|
414
|
+
const doc = await fetchWellKnown(origin, { vouchPid });
|
|
395
415
|
foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
|
|
416
|
+
if (foreign?.startsWith(TABLE_RESTRICTED)) vouchPid = launchdPid(label, opts); // only a restricted table needs it (FAA-267)
|
|
396
417
|
if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
397
418
|
await sleep(500);
|
|
398
419
|
}
|
|
399
420
|
// DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
|
|
421
|
+
if (foreign?.startsWith(TABLE_RESTRICTED)) {
|
|
422
|
+
throw new ServiceError(`${origin} could not be proved this service's: ${foreign} (DEC-0154 HA-2); this runtime sees no other process's sockets and the job's pid did not show the listener: run the installer from a runtime that sees them, or read the job's log.`);
|
|
423
|
+
}
|
|
400
424
|
if (foreign) {
|
|
401
425
|
throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
|
|
402
426
|
}
|
|
@@ -354,6 +354,7 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
|
354
354
|
**kwargs: Any) -> None:
|
|
355
355
|
http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
|
|
356
356
|
self.expect_pid = None
|
|
357
|
+
self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
|
|
357
358
|
|
|
358
359
|
|
|
359
360
|
def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
|