@passioncode-ai/fabric-agent-adapter 0.16.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 +72 -0
- package/README.md +2 -2
- 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 +7 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +59 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +132 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +342 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +308 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
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 ?? (
|
|
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 (
|
|
225
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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 (
|
|
290
|
-
for (const field of ['label', '
|
|
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 (
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|