@passioncode-ai/fabric-agent-adapter 0.17.0 → 0.18.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 CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 - 2026-10-12
4
+
5
+ Services on Linux and Windows in both kits (contract DEC-0032, DEC-0033; FAA-21): the supervisor files the
6
+ contract names, and the Node kit on Windows.
7
+
8
+ ### Added
9
+
10
+ - **The supervisor writers, in both kits.** They write byte-identical files, and a test holds them to that.
11
+ - **Linux — a systemd user unit.** `systemd_unit` / `systemdUnit` writes `Restart=always`, `RestartSec=10`,
12
+ `TimeoutStopSec` above the drain, `WantedBy=default.target` and `FABRIC_SERVICE_SUPERVISOR=systemd`.
13
+ `systemd_install` / `systemdInstall` runs `daemon-reload` and `enable --now`. `systemd_uninstall` /
14
+ `systemdUninstall` runs `disable --now`, removes the unit, then runs `daemon-reload`.
15
+ - **Windows — a per-user Scheduled Task.** `task_xml` / `taskXml` writes a logon trigger, least privilege,
16
+ restart every minute on failure, no time limit and `IgnoreNew`. `task_install` / `taskInstall` runs
17
+ `schtasks /Create` from a UTF-16 file it then removes, then `/Run`. `task_uninstall` / `taskUninstall` runs
18
+ `/End`, then `/Delete`.
19
+ - **No secret in a supervisor file.** The writers refuse a credential-shaped argument, environment name or
20
+ value, and any control character.
21
+ - **An upgrade restarts what runs; the operator's off stays off (LC-14).** A unit that `is-enabled` reports
22
+ disabled, or a task whose settings say `Enabled false`, is rewritten and left off unless `force_enable`.
23
+ - **Checked by the real readers:** `systemd-analyze verify` on the Linux job (a quoted `WorkingDirectory=` is
24
+ refused there, so it is written unquoted), and `schtasks /Create` of a throwaway task on the Windows job.
25
+ - **The Node kit on Windows and Linux:**
26
+ - `servicesDir` and `serviceDirs` use `%LOCALAPPDATA%`.
27
+ - `expand` and `validateDescriptor` match the Python kit: all four managers, `unit` and `task` paired with
28
+ their manager, Windows paths, never a network share.
29
+ - The token file gets a protected ACL: written with `icacls`, read with Windows PowerShell 5.1. A refusal
30
+ names the SID.
31
+ - The instance lock is a named pipe on Windows (an abstract socket on Linux, as before).
32
+ - **One copy across the kits.** The Python kit holds the Node kit's lock name (`lock_name` / `lockName`)
33
+ beside its file lock, so the two kits exclude each other on every system. `check_service.py` now reports the
34
+ lock as held when a Node service holds it. Before this release it failed every Node service on Linux.
35
+ - **A copy under systemd backs off on a held lock** as one under launchd does (`supervised()`).
36
+ - **The Windows CI job** runs the Node kit's platform, supervisor and service tests and the Python supervisor
37
+ tests.
38
+ - **Docs:**
39
+ - `references/lifecycle.md`, *Linux and Windows*: the verbs per system and the Windows directories.
40
+ - `SKILL.md` step 4.
41
+ - `protocol.md`.
42
+
43
+ The scenario eval `linux-and-windows-supervision` and two triggers came first.
44
+ - **The probe only looks** (`/proc/net/unix`, `WaitNamedPipeW`) and never takes the lock's name.
45
+ - **An independent review** found 2 critical and 9 moderate defects before release; each is fixed with a test, except
46
+ that a Scheduled Task still cannot tell the service it is supervised (a task sets no environment; documented).
47
+ - **Mutation testing:** 14 planted defects in the Python writers, 23 in the Node port, 7 in the lock name
48
+ and probe and a quoted `WorkingDirectory=` against `systemd-analyze` were all caught.
49
+
3
50
  ## 0.17.0 - 2026-10-12
4
51
 
5
52
  The organisation's lifecycle rules LC-19 and LC-21 apply to what the adapter ships and checks (FAA-22, FAA-23):
package/README.md CHANGED
@@ -230,8 +230,8 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
230
230
  ```
231
231
 
232
232
  Without both environment variables, those tests explicitly skip as `NOT_RUN`.
233
- This source checkout is 0.17.0. 0.16.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
234
- were never released); 0.17.0 is published by its `v0.17.0` tag. Source delivery does not update installed skills or live sessions.
233
+ This source checkout is 0.18.0. 0.17.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
234
+ were never released); 0.18.0 is published by its `v0.18.0` tag. Source delivery does not update installed skills or live sessions.
235
235
 
236
236
  ## License
237
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.",
6
- "version": "0.17.0",
6
+ "version": "0.18.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.17.0"
8
+ version: "0.18.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---
@@ -13,10 +13,10 @@ description: >-
13
13
  kits, a TLS sample, live and layout probes. NOT for a one-off script or cron job, a hosted product with no agent
14
14
  behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
15
15
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
- compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
16
+ compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.17.0"
19
+ version: "0.18.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
22
  extension-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
@@ -190,10 +190,14 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
190
190
  4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
191
191
  and the one before it.
192
192
 
193
+ On Linux the supervisor is a systemd user unit (`systemd_unit` + `systemd_install`), on
194
+ Windows a per-user Scheduled Task (`task_xml` + `task_install`); the Node kit has the same
195
+ writers (`systemdUnit`, `taskXml`, …) and both kits write identical files.
196
+
193
197
  Code runs from an immutable release directory; an upgrade writes a new release,
194
198
  rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
195
199
  for the plist fields, the release layout, log rotation, behaving under a host lifecycle broker
196
- and the Linux case.
200
+ and the Linux and Windows supervisors.
197
201
 
198
202
  ## Step 5 — the dashboard and the events
199
203
 
@@ -8,23 +8,23 @@ wins and this page is the defect. The rule each section serves:
8
8
  | Contract rule | Here | Kit (Python / Node) |
9
9
  |---|---|---|
10
10
  | LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
11
- | LC-03 a job is bounded and exclusive | [one copy under launchd](#one-copy-under-launchd) | `hold_single_instance` / `holdSingleInstance` |
12
- | LC-09 the footprint is declared | the descriptor and the plist; the service's own `AGENTS.md` names its label and port | `write_descriptor`, `launchd_plist` |
11
+ | LC-03 a job is bounded and exclusive | [one copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
12
+ | LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
13
13
  | LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
14
14
  | LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
15
- | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `launchd_install`, `launchd_uninstall` (Python) |
15
+ | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install`, `launchd_uninstall` (Python); `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
16
16
  | LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
17
17
 
18
18
  ## Contents
19
19
 
20
20
  - [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
21
21
  - [Install, upgrade, uninstall](#install-upgrade-uninstall)
22
- - [One copy under launchd](#one-copy-under-launchd)
22
+ - [One copy under a supervisor](#one-copy-under-a-supervisor)
23
23
  - [Directories](#directories)
24
24
  - [Logs](#logs)
25
25
  - [Shutdown](#shutdown)
26
26
  - [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
27
- - [Linux](#linux)
27
+ - [Linux and Windows](#linux-and-windows)
28
28
 
29
29
  ## launchd plist (generate it with `launchd_plist`)
30
30
 
@@ -70,24 +70,24 @@ that deletes an override, and a leftover `disabled` would make a later fresh ins
70
70
  off. Tests use a throwaway label or a fake `launchctl` first on `PATH` — never the
71
71
  operator's real jobs.
72
72
 
73
- ## One copy under launchd
73
+ ## One copy under a supervisor
74
74
 
75
75
  Started by hand, a copy that finds `service.lock` held prints one sentence naming the
76
76
  holder and exits 75 at once. Under launchd that exit is a respawn every `ThrottleInterval`
77
- (10 s), forever. So a copy whose plist carries `FABRIC_SERVICE_SUPERVISOR=launchd` backs
78
- off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
77
+ (10 s), forever, and under systemd one every `RestartSec`. So a copy whose plist or unit
78
+ carries `FABRIC_SERVICE_SUPERVISOR=launchd` (or `systemd`) backs off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
79
79
  and exits 75 only when the wait runs out: a few starts an hour instead of 360. It touches
80
80
  nothing while it waits. Two installs sharing one data directory are still the defect to
81
81
  remove; the back-off only stops the loop from costing the machine.
82
82
 
83
83
  ## Directories
84
84
 
85
- | What | macOS | Linux |
86
- |---|---|---|
87
- | data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` |
88
- | logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` |
89
- | cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` |
90
- | code | `~/.local/share/<id>/releases/…` | same |
85
+ | What | macOS | Linux | Windows (DEC-0032) |
86
+ |---|---|---|---|
87
+ | data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` | `%LOCALAPPDATA%\<id>\` |
88
+ | logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` | `%LOCALAPPDATA%\<id>\Logs\` |
89
+ | cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` | `%LOCALAPPDATA%\<id>\Cache\` |
90
+ | code | `~/.local/share/<id>/releases/…` | same | under `%LOCALAPPDATA%` too, never a network share |
91
91
 
92
92
  A repository that versions the data itself (a registry, a plan) may hold the data; the
93
93
  service's own code checkout and its releases may not. Directories 0700, secret files 0600. A store carries a schema version and the service
@@ -140,8 +140,58 @@ has to survive being driven from outside:
140
140
  well-known document for a service, a process-level answer for a plain app — and keep it honest
141
141
  while the app is still starting.
142
142
 
143
- ## Linux
144
-
145
- Until a systemd adapter exists, declare `lifecycle.manager: "none"`. A `systemd --user`
146
- unit with `Restart=always` gives the same guarantees; the host will control it once the
147
- adapter ships. Everything else in this skill applies unchanged.
143
+ ## Linux and Windows
144
+
145
+ Both kits write and install the supervisor file the contract names (DEC-0032, service.md
146
+ *Supervisor*); the two kits write byte-identical files. A supervisor file never carries a
147
+ secret: the writers refuse a credential-shaped argument, environment name or value, and any
148
+ control character (a line break would end a unit line early).
149
+
150
+ **Linux — a systemd user unit.** `systemd_unit(unit, argv, working_directory=…)` /
151
+ `systemdUnit(unit, argv, {workingDirectory})` writes `Restart=always`, `RestartSec=10`,
152
+ `TimeoutStopSec` above the drain (15 s default), `KillMode=mixed`, `WantedBy=default.target`
153
+ and `FABRIC_SERVICE_SUPERVISOR=systemd`, quoting every argument (`%` → `%%`, `$` → `$$`).
154
+ `WorkingDirectory=` is written unquoted: systemd reads it as the rest of the line, so quotes would
155
+ become part of the path and the unit would be refused (`systemd-analyze verify` checks it in CI). The
156
+ descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.service"}`.
157
+
158
+ | Verb | Command |
159
+ |---|---|
160
+ | install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
161
+ | stop, keep off | `systemctl --user disable --now <unit>` |
162
+ | uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
163
+
164
+ A user unit runs while the person is logged in; one that must run without a session needs
165
+ `loginctl enable-linger <user>`, which is the operator's decision, not the installer's.
166
+
167
+ **Windows — a per-user Scheduled Task.** `task_xml(task, argv, working_directory=…)` /
168
+ `taskXml(task, argv, {workingDirectory})` writes a task with a logon trigger for the current
169
+ user, `InteractiveToken` and `LeastPrivilege` (no administrator rights), restart on failure
170
+ every minute, no execution time limit and `MultipleInstancesPolicy` `IgnoreNew`, its settings in the
171
+ order `schtasks` exports them; the arguments are quoted the way the C runtime parses them. The Windows
172
+ CI job has the real `schtasks` create, read back and delete such a task. The descriptor says
173
+ `"lifecycle": {"manager": "task-scheduler", "task": "\\PassionCode\\<id>.<instance>"}`.
174
+
175
+ | Verb | Command |
176
+ |---|---|
177
+ | install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
178
+ | stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
179
+ | uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
180
+
181
+ A task cannot set an environment variable, so a copy Task Scheduler restarts does not back off
182
+ in-process; `IgnoreNew` keeps the scheduler from starting a second copy, and a hand-started copy
183
+ holding the lock makes the scheduled one exit 75 once a minute until it leaves.
184
+
185
+ **One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
186
+ (Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
187
+ a named pipe on Windows (`lock_name` / `lockName`), each gone with the process. The Python kit
188
+ holds the same name beside its file lock, so the two kits exclude each other everywhere, and
189
+ `check_service.py` reports the lock held when either is. The probe only looks (`/proc/net/unix`,
190
+ `WaitNamedPipeW`); it never takes the name, so it cannot make a service that is starting fail.
191
+
192
+ **Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
193
+ token files*): the kits write a protected list — the user, SYSTEM and Administrators, set before the
194
+ token's bytes go in, any entry already there dropped — and refuse
195
+ on read a link, a file outside the profile, another owner, or any other granted SID, naming the
196
+ SID. The Node kit reads the list with Windows PowerShell 5.1 (.NET, no modules) and writes it
197
+ with `icacls`; the path goes through the environment, never a command line.
@@ -31,7 +31,7 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
31
31
  | `name`, `summary` | ≤ 80 and ≤ 200 characters |
32
32
  | `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
33
33
  | `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
34
- | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). The Python kit and the probe accept all four; the Node kit still accepts only `launchd` and `none` (FAA-16) |
34
+ | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). Both kits and the probe accept all four; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`) |
35
35
  | `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
36
36
  | `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
37
37
  | `source.repository`, `fabricManifest` | optional |
@@ -730,7 +730,7 @@ class Probe:
730
730
  self.add("lifecycle.instance-lock", "PASS", "held by the running service")
731
731
  else:
732
732
  msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
733
- self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
733
+ self.lock_free(lock)
734
734
  finally:
735
735
  os.close(fd)
736
736
  return
@@ -747,10 +747,17 @@ class Probe:
747
747
  self.add("lifecycle.instance-lock", "PASS" if held else "FAIL", "held by the running service" if held else str(exc))
748
748
  else:
749
749
  fcntl.flock(fd, fcntl.LOCK_UN)
750
- self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
750
+ self.lock_free(lock)
751
751
  finally:
752
752
  os.close(fd)
753
753
 
754
+ def lock_free(self, lock: Path) -> None:
755
+ """The file lock is free; a Node kit service holds the lock's name instead (fs.lock_name)."""
756
+ if fs.lock_name_held(lock):
757
+ self.add("lifecycle.instance-lock", "PASS", "held by the running service (the lock's name, %s)" % ("a named pipe" if fs.WINDOWS else "an abstract socket"))
758
+ else:
759
+ self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
760
+
754
761
  def run(self) -> List[Result]:
755
762
  self.descriptor_rules()
756
763
  if not self.port:
@@ -3,11 +3,13 @@
3
3
  // Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
4
4
  // placement — an online agent or dashboard at an https origin — DEC-0019; the usage report DEC-0021).
5
5
 
6
+ import { spawnSync } from 'node:child_process';
6
7
  import crypto from 'node:crypto';
7
8
  import fs from 'node:fs';
8
9
  import net from 'node:net';
9
10
  import os from 'node:os';
10
11
  import path from 'node:path';
12
+ import { credentialName, secretArgument, secretValue } from './fabric-secrets.mjs';
11
13
 
12
14
  export const PROTOCOL = 'fabric-service/0.1';
13
15
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
@@ -49,6 +51,13 @@ const CODE = /^[A-Za-z0-9_-]{16,256}$/;
49
51
  const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
50
52
  const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
51
53
  const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
54
+ // DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
55
+ const LOCAL_PATH = /^(~[\\/]|\/(?!\/)|[A-Za-z]:[\\/])/;
56
+ export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
57
+ const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
58
+ const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
59
+ export const WINDOWS_TRUSTED = Object.freeze(['S-1-5-18', 'S-1-5-32-544']);
60
+ export const DEFAULT_EXIT_TIMEOUT = 15;
52
61
 
53
62
  export class ServiceError extends Error {}
54
63
  export class AlreadyRunning extends ServiceError {
@@ -61,29 +70,42 @@ export class AlreadyRunning extends ServiceError {
61
70
 
62
71
  export const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
63
72
 
64
- export function expand(p) {
65
- const out = p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
66
- if (!path.isAbsolute(out)) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
73
+ /** Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be absolute,
74
+ * and a network share is never a local path (DEC-0032). */
75
+ export function expand(p, platform = process.platform) {
76
+ if (!LOCAL_PATH.test(String(p))) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
77
+ const out = /^~[\\/]/.test(p) ? path.join(os.homedir(), p.slice(2)) : p;
78
+ // On Windows an absolute path names its drive: `/x` is relative to the current drive (the Python kit refuses it too).
79
+ const absolute = platform === 'win32' ? /^[A-Za-z]:[\\/]/.test(out) : path.posix.isAbsolute(out);
80
+ if (!absolute) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
67
81
  return out;
68
82
  }
69
83
 
70
- export function servicesDir() {
84
+ // DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
85
+ const localAppData = () => process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
86
+
87
+ export function servicesDir(platform = process.platform) {
71
88
  if (process.env.FABRIC_SERVICES_DIR) return expand(process.env.FABRIC_SERVICES_DIR);
72
89
  const home = os.homedir();
73
- if (process.platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
90
+ if (platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
91
+ if (platform === 'win32') return path.join(localAppData(), 'passioncode-fabric', 'services');
74
92
  return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric/services');
75
93
  }
76
94
 
77
- export function serviceDirs(id) {
95
+ export function serviceDirs(id, platform = process.platform) {
78
96
  if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
79
97
  const home = os.homedir();
80
- if (process.platform === 'darwin') {
98
+ if (platform === 'darwin') {
81
99
  return {
82
100
  data: path.join(home, 'Library/Application Support', id),
83
101
  logs: path.join(home, 'Library/Logs', id),
84
102
  cache: path.join(home, 'Library/Caches', id),
85
103
  };
86
104
  }
105
+ if (platform === 'win32') {
106
+ const data = path.join(localAppData(), id);
107
+ return { data, logs: path.join(data, 'Logs'), cache: path.join(data, 'Cache') };
108
+ }
87
109
  return {
88
110
  data: path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), id),
89
111
  logs: path.join(process.env.XDG_STATE_HOME || path.join(home, '.local/state'), id, 'logs'),
@@ -93,7 +115,8 @@ export function serviceDirs(id) {
93
115
 
94
116
  export function ensurePrivateDir(dir) {
95
117
  fs.mkdirSync(dir, { recursive: true });
96
- fs.chmodSync(dir, 0o700);
118
+ if (process.platform === 'win32') windowsProtect(dir, { folder: true });
119
+ else fs.chmodSync(dir, 0o700);
97
120
  return dir;
98
121
  }
99
122
 
@@ -102,13 +125,16 @@ export function atomicWrite(file, data, mode = 0o600) {
102
125
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
103
126
  const fd = fs.openSync(tmp, 'wx', mode);
104
127
  try {
128
+ // On Windows the file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in —
129
+ // so it does not depend on what the folder hands down. A rename keeps it.
130
+ if (process.platform === 'win32') windowsProtect(tmp, { folder: false });
105
131
  fs.writeSync(fd, data);
106
132
  fs.fsyncSync(fd);
107
133
  } finally {
108
134
  fs.closeSync(fd);
109
135
  }
110
136
  try {
111
- fs.chmodSync(tmp, mode);
137
+ if (process.platform !== 'win32') fs.chmodSync(tmp, mode);
112
138
  fs.renameSync(tmp, file);
113
139
  } catch (error) {
114
140
  fs.rmSync(tmp, { force: true });
@@ -140,11 +166,15 @@ export class InstanceLock {
140
166
  fs.fsyncSync(this.fd);
141
167
  return this;
142
168
  }
143
- // Linux: an abstract unix socket is exclusive, has no file, and dies with the process.
144
- const name = `\0fabric-service-${crypto.createHash('sha256').update(path.resolve(this.path)).digest('hex').slice(0, 32)}`;
169
+ // Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
170
+ // with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
171
+ const name = lockName(this.path);
172
+ if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
145
173
  this.server = net.createServer();
146
174
  await new Promise((resolve, reject) => {
147
- this.server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new AlreadyRunning(readPid(this.path), this.path) : error));
175
+ // Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
176
+ const held = (code) => code === 'EADDRINUSE' || (process.platform === 'win32' && code === 'EBUSY');
177
+ this.server.once('error', (error) => reject(held(error.code) ? new AlreadyRunning(readPid(this.path), this.path) : error));
148
178
  this.server.listen(name, resolve);
149
179
  });
150
180
  this.server.unref();
@@ -160,6 +190,15 @@ export class InstanceLock {
160
190
  }
161
191
  }
162
192
 
193
+ /** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
194
+ export function lockName(lockPath, platform = process.platform) {
195
+ const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
196
+ if (platform === 'linux') return `\0fabric-service-${digest(path.posix.resolve(lockPath))}`;
197
+ // Windows paths compare without case: C:\Users and c:\users are one lock.
198
+ if (platform === 'win32') return `\\\\.\\pipe\\fabric-service-${digest(path.win32.resolve(lockPath).toLowerCase())}`;
199
+ return null;
200
+ }
201
+
163
202
  function readPid(file) {
164
203
  try {
165
204
  const text = fs.readFileSync(file, 'utf8').trim();
@@ -171,6 +210,9 @@ function readPid(file) {
171
210
 
172
211
  /** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
173
212
  export const supervisedByLaunchd = () => process.env[SUPERVISOR_ENV] === 'launchd';
213
+ export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler']);
214
+ /** True when a supervisor that restarts on exit started this process and said so (launchd, systemd). */
215
+ export const supervised = () => SUPERVISORS.includes(process.env[SUPERVISOR_ENV]);
174
216
 
175
217
  const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
176
218
 
@@ -184,7 +226,7 @@ const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 100
184
226
  export async function holdSingleInstance(dataDir, {
185
227
  waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
186
228
  } = {}) {
187
- const wait = waitSeconds ?? (supervisedByLaunchd() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
229
+ const wait = waitSeconds ?? (supervised() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
188
230
  const deadline = clock() + Math.max(0, wait);
189
231
  let delay = LOCK_BACKOFF_FIRST_SECONDS;
190
232
  let announced = false;
@@ -218,16 +260,110 @@ export function ensureToken(file) {
218
260
  return token;
219
261
  }
220
262
 
263
+ /**
264
+ * Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
265
+ * service.md "Windows token files" (DEC-0032, DEC-0033): a regular file whose real path is in the
266
+ * profile, owned by the current user, SYSTEM or Administrators, every granting ACE naming one of
267
+ * those three; a refusal names the SID, never the contents.
268
+ */
221
269
  export function readToken(file) {
222
270
  const info = fs.lstatSync(file);
223
271
  if (info.isSymbolicLink()) throw new ServiceError(`Token file ${file} is a symlink; refusing it.`);
224
- if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
225
- if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
272
+ if (process.platform === 'win32') {
273
+ const problem = windowsTokenProblem(file, info);
274
+ if (problem) throw new ServiceError(problem);
275
+ } else {
276
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
277
+ if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
278
+ }
226
279
  const token = fs.readFileSync(file, 'utf8').trim();
227
280
  if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
228
281
  return token;
229
282
  }
230
283
 
284
+ // --- Windows ACLs (DEC-0032) ------------------------------------------------------
285
+ // Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
286
+ // and icacls writes it; both get the path through the environment, never argv, and print no contents.
287
+
288
+ /**
289
+ * service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the current
290
+ * user, SYSTEM or Administrators (DEC-0033), and every SID an allow ACE grants anything to is one of
291
+ * those three. Deny ACEs are not passed in: they do not change the decision.
292
+ */
293
+ export function tokenAclProblem(file, owner, granting, user) {
294
+ if (owner !== user && !WINDOWS_TRUSTED.includes(owner)) {
295
+ return `Token file ${file} is owned by ${owner || 'nobody'}, not by this user (${user}), SYSTEM or Administrators.`;
296
+ }
297
+ for (const sid of granting) {
298
+ if (sid !== user && !WINDOWS_TRUSTED.includes(sid)) return `Token file ${file} grants access to ${sid}; only this user, SYSTEM and Administrators may hold any.`;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ const ACL_SCRIPT = [
304
+ "$ErrorActionPreference = 'Stop'",
305
+ '$sid = [System.Security.Principal.SecurityIdentifier]',
306
+ '$acl = [System.IO.File]::GetAccessControl($env:FABRIC_ACL_PATH)',
307
+ "$null_dacl = $acl.GetSecurityDescriptorSddlForm('Access') -match 'NO_ACCESS_CONTROL' -or $acl.GetSecurityDescriptorSddlForm('Access') -eq ''",
308
+ // InheritOnly (2) applies to children, not to the file itself.
309
+ "$granting = @($acl.GetAccessRules($true, $true, $sid) | Where-Object { $_.AccessControlType -eq 'Allow' -and -not ([int]$_.PropagationFlags -band 2) } | ForEach-Object { $_.IdentityReference.Value })",
310
+ '$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
311
+ '[Console]::Out.Write((ConvertTo-Json -Compress @{ owner = $acl.GetOwner($sid).Value; granting = $granting; nullDacl = [bool]$null_dacl; user = $user }))',
312
+ ].join('; ');
313
+
314
+ function windowsHelper(command, args, extraEnv = {}) {
315
+ // No PSModulePath: the script uses .NET only, and a module path inherited from PowerShell 7 makes
316
+ // Windows PowerShell load the wrong modules.
317
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toLowerCase() !== 'psmodulepath'));
318
+ const result = spawnSync(command, args, { env: { ...env, ...extraEnv }, encoding: 'utf8', windowsHide: true, timeout: 30_000 });
319
+ if (result.error || result.status !== 0) {
320
+ throw new ServiceError(`${command} failed: ${(result.error?.message || result.stderr || result.stdout || '').trim().slice(0, 200)}`);
321
+ }
322
+ return result.stdout;
323
+ }
324
+
325
+ /** The ACL report PowerShell prints, reduced to (owner, granting SIDs, current user). */
326
+ export function parseAclReport(text) {
327
+ const report = JSON.parse(text);
328
+ const granting = report.nullDacl ? ['S-1-1-0'] : [].concat(report.granting ?? []);
329
+ return { owner: report.owner ?? '', granting, user: report.user };
330
+ }
331
+
332
+ /** (owner SID, SIDs of every allow ACE on the file itself, the current user's SID). */
333
+ export function windowsAcl(file) {
334
+ return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
335
+ }
336
+
337
+ let userSid = null;
338
+ export function windowsUserSid() {
339
+ if (!userSid) {
340
+ const line = windowsHelper('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim();
341
+ userSid = (line.match(/"(S-1-[0-9-]+)"\s*$/) ?? [])[1];
342
+ if (!userSid) throw new ServiceError('whoami /user printed no SID.');
343
+ }
344
+ return userSid;
345
+ }
346
+
347
+ /**
348
+ * A protected list (inheritance off): this user, SYSTEM, Administrators, full control; a folder hands it down.
349
+ * `/reset` first drops any explicit entry already there (`/inheritance:r` keeps those), so the three are all that remain.
350
+ */
351
+ export function windowsProtect(target, { folder }) {
352
+ const grant = folder ? '(OI)(CI)F' : 'F';
353
+ windowsHelper('icacls.exe', [target, '/reset']);
354
+ windowsHelper('icacls.exe', [target, '/inheritance:r', '/grant:r', ...[windowsUserSid(), ...WINDOWS_TRUSTED].map((sid) => `*${sid}:${grant}`)]);
355
+ }
356
+
357
+ function windowsTokenProblem(file, info) {
358
+ if (!info.isFile()) return `Token file ${file} is not a regular file (a link or junction); refusing it.`;
359
+ const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
360
+ const real = fs.realpathSync.native(file).toLowerCase();
361
+ const inside = path.win32.relative(profile, real);
362
+ if (!inside || inside.startsWith('..') || path.win32.isAbsolute(inside)) return `Token file ${file} lies outside the user's profile; refusing it.`;
363
+ const { owner, granting, user } = windowsAcl(file);
364
+ return tokenAclProblem(file, owner, granting, user);
365
+ }
366
+
231
367
  export function tokenMatches(presented, token, scheme = 'Bearer') {
232
368
  if (!presented) return false;
233
369
  let value = presented;
@@ -278,22 +414,37 @@ export function validateDescriptor(d) {
278
414
  if (remote) problems.push(...remoteOriginProblems(d.origin));
279
415
  else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
280
416
  if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
417
+ else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
418
+ for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
419
+ if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
420
+ }
281
421
  if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
282
422
  problems.push('a custom auth header carries the raw token: scheme must be none');
283
423
  }
284
- if (!['launchd', 'none'].includes(d.lifecycle?.manager)) problems.push('lifecycle.manager must be launchd or none');
285
- if (d.lifecycle?.manager === 'launchd' && !(d.lifecycle.label && String(d.lifecycle.plist ?? '').endsWith('.plist'))) {
424
+ const life = d.lifecycle ?? {};
425
+ const manager = life.manager;
426
+ if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
427
+ if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
286
428
  problems.push('a launchd service declares label and plist');
287
429
  }
430
+ if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
431
+ // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
432
+ for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
433
+ if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
434
+ if (life[field] !== undefined && manager !== owner) problems.push(`lifecycle.${field} belongs to manager ${owner} only`);
435
+ if (life[field] !== undefined && !pattern.test(String(life[field]))) problems.push(`lifecycle.${field} must match ${pattern.source}`);
436
+ }
288
437
  if (remote) {
289
- if (d.lifecycle?.manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
290
- for (const field of ['label', 'plist']) if (d.lifecycle?.[field] !== undefined) problems.push(`a remote service has no launchd ${field}`);
438
+ if (manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
439
+ for (const [field, of] of [['label', 'launchd'], ['plist', 'launchd'], ['unit', 'systemd'], ['task', 'Task Scheduler']]) {
440
+ if (life[field] !== undefined) problems.push(`a remote service has no ${of} ${field}`);
441
+ }
291
442
  if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
292
443
  }
293
444
  for (const [name, argv] of Object.entries(d.commands ?? {})) {
294
445
  if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
295
446
  else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
296
- else if (!/^(~\/|\/)/.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
447
+ else if (!LOCAL_PATH.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
297
448
  }
298
449
  return problems;
299
450
  }
@@ -959,3 +1110,174 @@ export function cookieValue(header, name = SESSION_COOKIE) {
959
1110
  }
960
1111
  return null;
961
1112
  }
1113
+
1114
+ // --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") -------------------------
1115
+ // The twins of fabric_service.systemd_unit / task_xml and their install and uninstall. `run(argv)`
1116
+ // returns spawnSync's {status, stdout, stderr}; the tests pass a recorder instead of the real tools.
1117
+
1118
+ const defaultRun = (argv) => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', windowsHide: true });
1119
+
1120
+ function plain(value, what) {
1121
+ // A supervisor file is line- and XML-structured: no control character in anything written into it.
1122
+ if (/[\x00-\x1f\x7f]/.test(value)) throw new ServiceError(`${what} holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it.`);
1123
+ return value;
1124
+ }
1125
+
1126
+ function noSecretInSupervisorFile(argv, environment) {
1127
+ // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
1128
+ for (const arg of argv) {
1129
+ 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).');
1130
+ }
1131
+ for (const [name, value] of Object.entries(environment ?? {})) {
1132
+ if (credentialName(name) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
1133
+ }
1134
+ }
1135
+
1136
+ function check(result, what) {
1137
+ if (result?.status !== 0) throw new ServiceError(`${what} failed: ${String(result?.stderr || result?.stdout || result?.error?.message || '').trim().slice(0, 200)}`);
1138
+ }
1139
+
1140
+ const systemdQuote = (value) => `"${plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
1141
+
1142
+ /**
1143
+ * A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
1144
+ * WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd).
1145
+ */
1146
+ export function systemdUnit(unit, programArguments, { workingDirectory, environment = {}, description, stopTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
1147
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1148
+ const argv = programArguments.map(String);
1149
+ if (!argv.length || !argv[0].startsWith('/')) throw new ServiceError('A systemd unit starts an absolute executable.');
1150
+ if (!(stopTimeout > DRAIN_SECONDS)) throw new ServiceError(`TimeoutStopSec must outlast the ${DRAIN_SECONDS} s drain.`);
1151
+ const env = { ...environment };
1152
+ for (const name of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new ServiceError(`Environment name ${JSON.stringify(name)} is not a variable name.`);
1153
+ noSecretInSupervisorFile(argv, env);
1154
+ env[SUPERVISOR_ENV] ??= 'systemd';
1155
+ const cwd = plain(String(workingDirectory), 'The working directory');
1156
+ if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
1157
+ return [
1158
+ '[Unit]', `Description=${plain(description ?? unit, 'The description').replace(/%/g, '%%')}`, '',
1159
+ '[Service]', 'Type=simple', `ExecStart=${argv.map(systemdQuote).join(' ')}`,
1160
+ // Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
1161
+ `WorkingDirectory=${cwd.replace(/%/g, '%%')}`,
1162
+ ...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
1163
+ 'Restart=always', 'RestartSec=10', `TimeoutStopSec=${Math.trunc(stopTimeout)}`, 'KillMode=mixed', '',
1164
+ '[Install]', 'WantedBy=default.target', '',
1165
+ ].join('\n');
1166
+ }
1167
+
1168
+ const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
1169
+
1170
+ /**
1171
+ * Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
1172
+ * The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
1173
+ * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
1174
+ */
1175
+ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
1176
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
1177
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1178
+ fs.mkdirSync(unitDir, { recursive: true });
1179
+ const file = path.join(unitDir, unit);
1180
+ const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
1181
+ atomicWrite(file, text, 0o644);
1182
+ check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
1183
+ if (state === 'disabled' && !forceEnable) return file;
1184
+ check(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
1185
+ check(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
1186
+ return file;
1187
+ }
1188
+
1189
+ /** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
1190
+ export function systemdUninstall(unit, { unitDir = systemdDir(), run = defaultRun, platform = process.platform } = {}) {
1191
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
1192
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1193
+ run(['systemctl', '--user', 'disable', '--now', unit]);
1194
+ fs.rmSync(path.join(unitDir, unit), { force: true });
1195
+ check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
1196
+ }
1197
+
1198
+ /** One command line from arguments, quoted the way the Microsoft C runtime parses it (Python's list2cmdline). */
1199
+ export function windowsCommandLine(args) {
1200
+ return args.map((arg) => {
1201
+ const quote = arg === '' || /[ \t]/.test(arg);
1202
+ let out = quote ? '"' : '';
1203
+ let slashes = 0;
1204
+ for (const c of arg) {
1205
+ if (c === '\\') { slashes += 1; continue; }
1206
+ if (c === '"') { out += '\\'.repeat(slashes * 2 + 1) + '"'; slashes = 0; continue; }
1207
+ out += '\\'.repeat(slashes) + c;
1208
+ slashes = 0;
1209
+ }
1210
+ return out + '\\'.repeat(quote ? slashes * 2 : slashes) + (quote ? '"' : '');
1211
+ }).join(' ');
1212
+ }
1213
+
1214
+ const xml = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1215
+
1216
+ /**
1217
+ * A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution
1218
+ * time limit, run only when the user is logged on, created without administrator rights.
1219
+ */
1220
+ export function taskXml(task, programArguments, { workingDirectory, description, user } = {}) {
1221
+ if (!TASK.test(task)) throw new ServiceError(`task must match ${TASK.source}`);
1222
+ const argv = programArguments.map(String);
1223
+ if (!argv.length || !/^[A-Za-z]:[\\/]/.test(argv[0])) throw new ServiceError('A task starts a drive-absolute executable (C:\\...).');
1224
+ for (const arg of argv) plain(arg, 'An argument');
1225
+ plain(String(workingDirectory), 'The working directory');
1226
+ plain(description ?? task, 'The description');
1227
+ noSecretInSupervisorFile(argv, {});
1228
+ const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
1229
+ if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
1230
+ plain(who, 'The user');
1231
+ return `<?xml version="1.0" encoding="UTF-16"?>
1232
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1233
+ <RegistrationInfo><Description>${xml(description ?? task)}</Description><URI>${xml(task)}</URI></RegistrationInfo>
1234
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml(who)}</UserId></LogonTrigger></Triggers>
1235
+ <Principals><Principal id="Author"><UserId>${xml(who)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
1236
+ <Settings>
1237
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1238
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1239
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1240
+ <StartWhenAvailable>true</StartWhenAvailable>
1241
+ <Enabled>true</Enabled>
1242
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1243
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
1244
+ </Settings>
1245
+ <Actions Context="Author"><Exec><Command>${xml(argv[0])}</Command><Arguments>${xml(windowsCommandLine(argv.slice(1)))}</Arguments><WorkingDirectory>${xml(workingDirectory)}</WorkingDirectory></Exec></Actions>
1246
+ </Task>
1247
+ `;
1248
+ }
1249
+
1250
+ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Enabled>/;
1251
+
1252
+ /**
1253
+ * Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
1254
+ * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
1255
+ */
1256
+ export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
1257
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
1258
+ const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
1259
+ const existed = query?.status === 0;
1260
+ const keepOff = existed && !forceEnable && TASK_DISABLED.test(String(query?.stdout ?? ''));
1261
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-task-'));
1262
+ const file = path.join(dir, 'task.xml');
1263
+ try {
1264
+ // schtasks reads the encoding the declaration names: UTF-16 little-endian with its byte order mark.
1265
+ fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xmlText, 'utf16le')]));
1266
+ check(run(['schtasks', '/Create', '/TN', task, '/XML', file, '/F']), 'schtasks /Create');
1267
+ } finally {
1268
+ fs.rmSync(dir, { recursive: true, force: true });
1269
+ }
1270
+ if (keepOff) {
1271
+ check(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
1272
+ return;
1273
+ }
1274
+ if (existed) run(['schtasks', '/End', '/TN', task]);
1275
+ check(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
1276
+ }
1277
+
1278
+ /** End the running task, then delete it. */
1279
+ export function taskUninstall(task, { run = defaultRun, platform = process.platform } = {}) {
1280
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
1281
+ run(['schtasks', '/End', '/TN', task]);
1282
+ check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
1283
+ }
@@ -18,13 +18,16 @@ import hashlib
18
18
  import hmac
19
19
  import http.server
20
20
  import json
21
+ import ntpath
21
22
  import os
22
- from pathlib import Path
23
+ from pathlib import Path, PurePath
23
24
  import plistlib
25
+ import posixpath
24
26
  import re
25
27
  import secrets
26
28
  import shutil
27
29
  import signal
30
+ import socket
28
31
  import socketserver
29
32
  import stat
30
33
  import subprocess
@@ -185,15 +188,15 @@ def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
185
188
  path.parent.mkdir(parents=True, exist_ok=True)
186
189
  fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
187
190
  try:
191
+ if WINDOWS:
192
+ # The file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in — so
193
+ # it does not depend on what the folder hands down. A rename keeps it.
194
+ _win_protect(Path(tmp), folder=False)
188
195
  with os.fdopen(fd, "wb") as handle:
189
196
  handle.write(data)
190
197
  handle.flush()
191
198
  os.fsync(handle.fileno())
192
- if WINDOWS:
193
- # The file's ACL is its privacy (DEC-0032): set explicitly and protected, so it does
194
- # not depend on what the folder hands down. A rename keeps it.
195
- _win_protect(Path(tmp), folder=False)
196
- else:
199
+ if not WINDOWS:
197
200
  os.chmod(tmp, mode)
198
201
  os.replace(tmp, path)
199
202
  except BaseException:
@@ -217,6 +220,7 @@ class InstanceLock:
217
220
  def __init__(self, data_dir: Path):
218
221
  self.path = Path(data_dir) / "service.lock"
219
222
  self._fd: Optional[int] = None
223
+ self._name: Any = None
220
224
 
221
225
  #: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
222
226
  #: holder may rewrite the pid and another process may still read it (locking past the end of
@@ -242,6 +246,11 @@ class InstanceLock:
242
246
  if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
243
247
  raise AlreadyRunning(_read_pid(self.path), self.path) from None
244
248
  raise
249
+ try:
250
+ self._name = _hold_lock_name(self.path)
251
+ except BaseException:
252
+ os.close(fd)
253
+ raise
245
254
  os.ftruncate(fd, 0)
246
255
  os.write(fd, str(os.getpid()).encode())
247
256
  os.fsync(fd)
@@ -252,6 +261,70 @@ class InstanceLock:
252
261
  if self._fd is not None:
253
262
  os.close(self._fd)
254
263
  self._fd = None
264
+ if self._name is not None:
265
+ _drop_lock_name(self._name)
266
+ self._name = None
267
+
268
+
269
+ # The Node kit cannot flock on Linux or lock a byte range on Windows, so it holds a name instead: an
270
+ # abstract unix socket on Linux, a named pipe on Windows — exclusive, no file, gone with the process.
271
+ # This kit holds the same name beside its file lock, so the two kits exclude each other everywhere
272
+ # and the probe sees either one (`lock_name_held`).
273
+
274
+ def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
275
+ """The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
276
+ if platform.startswith("linux"):
277
+ digest = hashlib.sha256(posixpath.abspath(str(lock_path)).encode()).hexdigest()[:32]
278
+ return "\0fabric-service-" + digest
279
+ if platform == "win32":
280
+ # Windows paths compare without case: C:\Users and c:\users are one lock.
281
+ digest = hashlib.sha256(ntpath.normcase(ntpath.abspath(str(lock_path))).encode()).hexdigest()[:32]
282
+ return "\\\\.\\pipe\\fabric-service-" + digest
283
+ return None
284
+
285
+
286
+ def _hold_lock_name(lock_path: Path) -> Any:
287
+ name = lock_name(lock_path)
288
+ if name is None:
289
+ return None
290
+ if WINDOWS:
291
+ handle = _create_first_pipe(name)
292
+ if handle is None:
293
+ raise AlreadyRunning(_read_pid(lock_path), lock_path)
294
+ return handle
295
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
296
+ try:
297
+ sock.bind(name)
298
+ except OSError as exc:
299
+ sock.close()
300
+ if exc.errno == errno.EADDRINUSE:
301
+ raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
302
+ raise
303
+ # Held by its descriptor, like the file lock: a socket object would close when collected.
304
+ return sock.detach()
305
+
306
+
307
+ def _drop_lock_name(held: Any) -> None:
308
+ if WINDOWS:
309
+ _close_handle(held)
310
+ else:
311
+ os.close(held)
312
+
313
+
314
+ def lock_name_held(lock_path: Path) -> Optional[bool]:
315
+ """True when some process holds the lock's name (a running service of either kit); None where there is no name
316
+ to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
317
+ probe cannot make a service that is starting at that moment fail."""
318
+ name = lock_name(lock_path)
319
+ if name is None:
320
+ return None
321
+ if WINDOWS:
322
+ return _pipe_exists(name)
323
+ try:
324
+ with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
325
+ return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
326
+ except OSError:
327
+ return None
255
328
 
256
329
 
257
330
  def _read_pid(path: Path) -> Optional[int]:
@@ -262,6 +335,15 @@ def _read_pid(path: Path) -> Optional[int]:
262
335
  return None
263
336
 
264
337
 
338
+ SUPERVISORS = ("launchd", "systemd", "task-scheduler")
339
+
340
+
341
+ def supervised() -> bool:
342
+ """True when a supervisor that restarts on exit started this process and said so in
343
+ ``FABRIC_SERVICE_SUPERVISOR`` (``launchd_plist`` and ``systemd_unit`` set it)."""
344
+ return os.environ.get(SUPERVISOR_ENV) in SUPERVISORS
345
+
346
+
265
347
  def supervised_by_launchd() -> bool:
266
348
  """True when launchd started this process from a plist written by ``launchd_plist``.
267
349
 
@@ -281,7 +363,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
281
363
  leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
282
364
  ``wait_seconds`` overrides the choice (0 = never wait)."""
283
365
  if wait_seconds is None:
284
- wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised_by_launchd() else 0.0
366
+ wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised() else 0.0
285
367
  deadline = clock() + max(0.0, wait_seconds)
286
368
  delay = LOCK_BACKOFF_FIRST_SECONDS
287
369
  announced = False
@@ -440,6 +522,35 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
440
522
  finally:
441
523
  _kernel32.LocalFree(sd)
442
524
 
525
+ _kernel32.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR] + [wintypes.DWORD] * 6 + [_PVOID]
526
+ _kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
527
+ _INVALID_HANDLE = wintypes.HANDLE(-1).value
528
+
529
+ def _create_first_pipe(name: str) -> Any:
530
+ """The pipe's first instance, or None when another process holds it (ERROR_ACCESS_DENIED)."""
531
+ # PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE; PIPE_REJECT_REMOTE_CLIENTS; unlimited instances,
532
+ # as libuv creates them: then a second first-instance attempt, ours or the Node kit's, is refused with
533
+ # ERROR_ACCESS_DENIED (Node: EADDRINUSE), never ERROR_PIPE_BUSY.
534
+ handle = _kernel32.CreateNamedPipeW(name, 0x1 | 0x00080000, 0x8, 255, 0, 0, 0, None)
535
+ if handle == _INVALID_HANDLE:
536
+ code = ctypes.get_last_error()
537
+ if code in (5, 231): # ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY: another process holds it
538
+ return None
539
+ raise _win_error("CreateNamedPipeW", code)
540
+ return handle
541
+
542
+ def _close_handle(handle: Any) -> None:
543
+ _kernel32.CloseHandle(handle)
544
+
545
+ _kernel32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
546
+ _kernel32.WaitNamedPipeW.restype = wintypes.BOOL
547
+
548
+ def _pipe_exists(name: str) -> bool:
549
+ """Some process holds the pipe: waiting for it does not fail with ERROR_FILE_NOT_FOUND. Nothing connects."""
550
+ if _kernel32.WaitNamedPipeW(name, 1):
551
+ return True
552
+ return ctypes.get_last_error() != 2
553
+
443
554
  def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
444
555
  if getattr(info, "st_file_attributes", 0) & _REPARSE_POINT or not stat.S_ISREG(info.st_mode):
445
556
  return "Token file %s is not a regular file (a link or junction); refusing it." % path
@@ -450,6 +561,15 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
450
561
  owner, granting = windows_acl(path)
451
562
  return token_acl_problem(path, owner, granting, windows_user_sid())
452
563
  else:
564
+ def _create_first_pipe(name: str) -> Any: # noqa: ARG001
565
+ raise ServiceError("named pipes exist only on Windows")
566
+
567
+ def _close_handle(handle: Any) -> None: # noqa: ARG001
568
+ raise ServiceError("handles exist only on Windows")
569
+
570
+ def _pipe_exists(name: str) -> bool: # noqa: ARG001
571
+ raise ServiceError("named pipes exist only on Windows")
572
+
453
573
  def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
454
574
  raise ServiceError("Windows ACLs exist only on Windows")
455
575
 
@@ -1516,6 +1636,187 @@ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Op
1516
1636
 
1517
1637
  # --- launchd -----------------------------------------------------------------
1518
1638
 
1639
+ # --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
1640
+
1641
+ def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
1642
+ """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
1643
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
1644
+ for arg in argv:
1645
+ if fabric_secrets.secret_argument(arg):
1646
+ raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
1647
+ for name, value in (environment or {}).items():
1648
+ if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
1649
+ raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
1650
+
1651
+
1652
+ def _plain(value: str, what: str) -> str:
1653
+ """A supervisor file is line- and XML-structured: no control character in anything written into it."""
1654
+ if re.search(r"[\x00-\x1f\x7f]", value):
1655
+ raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
1656
+ return value
1657
+
1658
+
1659
+ def _systemd_quote(value: str) -> str:
1660
+ _plain(value, "an argument")
1661
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
1662
+ return '"%s"' % escaped
1663
+
1664
+
1665
+ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
1666
+ environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
1667
+ stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
1668
+ """A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
1669
+ WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
1670
+ if not _UNIT.match(unit):
1671
+ raise ServiceError("unit must match %s" % _UNIT.pattern)
1672
+ argv = [str(a) for a in program_arguments]
1673
+ if not argv or not argv[0].startswith("/"):
1674
+ raise ServiceError("a systemd unit starts an absolute executable")
1675
+ if stop_timeout <= DRAIN_SECONDS:
1676
+ raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
1677
+ env = dict(environment or {})
1678
+ _no_secret_in_supervisor_file(argv, env)
1679
+ env.setdefault(SUPERVISOR_ENV, "systemd")
1680
+ # A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
1681
+ cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
1682
+ "the working directory")
1683
+ if not cwd.startswith("/"):
1684
+ raise ServiceError("a systemd unit's working directory is absolute")
1685
+ lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
1686
+ "[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
1687
+ # Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
1688
+ "WorkingDirectory=%s" % cwd.replace("%", "%%")]
1689
+ for name in env:
1690
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
1691
+ raise ServiceError("environment name %r is not a variable name" % name)
1692
+ lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
1693
+ lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
1694
+ "[Install]", "WantedBy=default.target", ""]
1695
+ return "\n".join(lines)
1696
+
1697
+
1698
+ def _systemd_dir() -> Path:
1699
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
1700
+
1701
+
1702
+ def _check(result: Any, what: str) -> None:
1703
+ if getattr(result, "returncode", 1) != 0:
1704
+ raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
1705
+
1706
+
1707
+ def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
1708
+ system: str = sys.platform, force_enable: bool = False) -> Path:
1709
+ """Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
1710
+
1711
+ The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
1712
+ the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
1713
+ if not system.startswith("linux"):
1714
+ raise ServiceError("systemd is the supervisor on Linux only")
1715
+ if not _UNIT.match(unit):
1716
+ raise ServiceError("unit must match %s" % _UNIT.pattern)
1717
+ folder = unit_dir or _systemd_dir()
1718
+ folder.mkdir(parents=True, exist_ok=True)
1719
+ path = folder / unit
1720
+ first = not path.exists()
1721
+ state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
1722
+ "stdout", "") or "").strip()
1723
+ atomic_write(path, text.encode("utf-8"), 0o644)
1724
+ _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
1725
+ if state == "disabled" and not force_enable:
1726
+ return path
1727
+ _check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
1728
+ _check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
1729
+ return path
1730
+
1731
+
1732
+ def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
1733
+ system: str = sys.platform) -> None:
1734
+ """Stop it and keep it off across logins (`disable --now`), then remove the unit."""
1735
+ if not system.startswith("linux"):
1736
+ raise ServiceError("systemd is the supervisor on Linux only")
1737
+ run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
1738
+ path = (unit_dir or _systemd_dir()) / unit
1739
+ if path.exists():
1740
+ path.unlink()
1741
+ _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
1742
+
1743
+
1744
+ def _xml(text: str) -> str:
1745
+ return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))
1746
+
1747
+
1748
+ def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
1749
+ user: Optional[str] = None) -> str:
1750
+ """A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
1751
+ run only when the user is logged on, created without administrator rights."""
1752
+ if not _TASK.match(task):
1753
+ raise ServiceError("task must match %s" % _TASK.pattern)
1754
+ argv = [str(a) for a in program_arguments]
1755
+ if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
1756
+ raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
1757
+ for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
1758
+ (description or task, "the description")]:
1759
+ _plain(value, what)
1760
+ _no_secret_in_supervisor_file(argv, None)
1761
+ who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
1762
+ if not who:
1763
+ raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
1764
+ _plain(who, "the user")
1765
+ body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1766
+ <RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
1767
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
1768
+ <Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
1769
+ <Settings>
1770
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1771
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1772
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1773
+ <StartWhenAvailable>true</StartWhenAvailable>
1774
+ <Enabled>true</Enabled>
1775
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1776
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
1777
+ </Settings>
1778
+ <Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
1779
+ </Task>
1780
+ """.format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
1781
+ arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
1782
+ return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
1783
+
1784
+
1785
+ _TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
1786
+
1787
+
1788
+ def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
1789
+ force_enable: bool = False) -> None:
1790
+ """Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
1791
+ definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
1792
+ if system != "win32":
1793
+ raise ServiceError("Task Scheduler is the supervisor on Windows only")
1794
+ query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
1795
+ existed = getattr(query, "returncode", 1) == 0
1796
+ keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
1797
+ fd, name = tempfile.mkstemp(suffix=".xml")
1798
+ try:
1799
+ with os.fdopen(fd, "wb") as handle:
1800
+ handle.write(xml_text.encode("utf-16"))
1801
+ _check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
1802
+ finally:
1803
+ os.unlink(name)
1804
+ if keep_off:
1805
+ _check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
1806
+ return
1807
+ if existed:
1808
+ run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
1809
+ _check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
1810
+
1811
+
1812
+ def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
1813
+ """End the running task, then delete it."""
1814
+ if system != "win32":
1815
+ raise ServiceError("Task Scheduler is the supervisor on Windows only")
1816
+ run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
1817
+ _check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
1818
+
1819
+
1519
1820
  def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
1520
1821
  stdout_path: Path, environment: Optional[Dict[str, str]] = None,
1521
1822
  exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
@@ -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. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.17.0"
8
+ version: "0.18.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---