@passioncode-ai/fabric-agent-adapter 0.21.0 → 0.23.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 +112 -0
- package/README.md +38 -6
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/plugins/fabric-agent-adapter/hooks/hooks.json +43 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +46 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +167 -36
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +270 -49
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +217 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +99 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +111 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1265 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -139,7 +139,7 @@ function keyFor(jwks, kid) {
|
|
|
139
139
|
if (raw.length !== 32) return { problem: 'unusable-key' };
|
|
140
140
|
if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
|
|
141
141
|
try {
|
|
142
|
-
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw };
|
|
142
|
+
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw, x: jwk.x };
|
|
143
143
|
} catch {
|
|
144
144
|
return { problem: 'unusable-key' };
|
|
145
145
|
}
|
|
@@ -175,7 +175,7 @@ function authentic(token, jwks, typ) {
|
|
|
175
175
|
// Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
|
|
176
176
|
if ('problem' in found) return found.problem === 'unknown-key' ? { problem: found.problem } : { problem: found.problem, issuer: true };
|
|
177
177
|
if (compact.signature.length !== 64 || isSmallOrderKey(found.raw) || !crypto.verify(null, compact.signingInput, found.key, compact.signature)) return { problem: 'bad-signature' };
|
|
178
|
-
return { compact, kid };
|
|
178
|
+
return { compact, kid, x: found.x };
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** Depth of an actor chain; Infinity for a chain that is not one (a missing or non-string sub at any level). */
|
|
@@ -249,33 +249,47 @@ function approvalShape(payload) {
|
|
|
249
249
|
return payload;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/** ECMAScript's time range in seconds (±8.64e15 ms): a NumericDate outside it is no instant a Date can hold. */
|
|
253
|
+
const MAX_CLOCK_SECONDS = 8.64e12;
|
|
254
|
+
/** A clock the time rows can compare (DEC-0060): a finite number of seconds within a Date's range. NaN or a string would pass every row. */
|
|
255
|
+
const readableClock = (now) => typeof now === 'number' && Number.isFinite(now) && Math.abs(now) <= MAX_CLOCK_SECONDS;
|
|
256
|
+
|
|
252
257
|
/**
|
|
253
|
-
* DL-
|
|
254
|
-
* the
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* verifyEstateRecord) or a function returning the current one; undefined skips the rule. `[kind, detail]` or null.
|
|
258
|
+
* DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the token's
|
|
259
|
+
* `kid` and the published key must be certified by that revision. `estate` is a revision the caller verified
|
|
260
|
+
* (fabric-estate.mjs verifyEstateRecord) and its machine applied, or a function returning it; only an absent `estate`
|
|
261
|
+
* skips the rule (null is an unreadable source). `estateApplied` is the highest revision the machine applied,
|
|
262
|
+
* `estateRevocations` the revocation delta's entries since (an array or a function). A source that throws is
|
|
263
|
+
* unreadable, never a pass. `[kind, detail]` or null.
|
|
260
264
|
*/
|
|
261
|
-
function estateProblem(estate,
|
|
262
|
-
if (estate === undefined
|
|
265
|
+
function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now) {
|
|
266
|
+
if (estate === undefined) return null;
|
|
263
267
|
let record;
|
|
268
|
+
let extra;
|
|
264
269
|
try {
|
|
265
270
|
record = typeof estate === 'function' ? estate() : estate;
|
|
271
|
+
extra = typeof estateRevocations === 'function' ? estateRevocations() : estateRevocations;
|
|
266
272
|
} catch {
|
|
267
|
-
return ['issuer', 'estate-unreadable'];
|
|
273
|
+
return ['issuer', 'estate-unreadable'];
|
|
268
274
|
}
|
|
269
|
-
const
|
|
270
|
-
|
|
275
|
+
const verdict = hubKeyCertified(record, kid, x, now, estateApplied, extra === undefined ? undefined : { extraRevocations: extra });
|
|
276
|
+
// The clock was refused before row 1, so a verdict here is the token's or the trust source's.
|
|
277
|
+
return verdict.certified ? null : [verdict.fault === 'token' ? 'token' : 'issuer', verdict.detail];
|
|
271
278
|
}
|
|
272
279
|
|
|
273
|
-
|
|
280
|
+
/**
|
|
281
|
+
* DL-13, in its fixed order: nothing about a token is believed before its signature is. `now` is seconds since
|
|
282
|
+
* the epoch; a clock that is not one is refused `unknown-state` `no-clock` before row 1. Answers {ok: true, claims,
|
|
283
|
+
* principal, caller, chain, kid} or {ok: false, code, detail}.
|
|
284
|
+
*/
|
|
285
|
+
export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations } = {}) {
|
|
286
|
+
// Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
|
|
287
|
+
if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
|
|
274
288
|
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
275
289
|
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
276
290
|
const checked = authentic(token, jwks, DELEGATION_TYP);
|
|
277
291
|
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
|
|
278
|
-
const uncertified = estateProblem(estate,
|
|
292
|
+
const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
|
|
279
293
|
if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'unauthenticated', uncertified[1]);
|
|
280
294
|
const shaped = delegationShape(checked.compact.payload);
|
|
281
295
|
if ('problem' in shaped) return refuse('unauthenticated', shaped.problem);
|
|
@@ -295,14 +309,15 @@ export function verifyDelegation(token, { audience, jwks, now, capability, effec
|
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
/** DL-18: approval-required carries the digest; any fault is approval-invalid; the jti is consumed last. */
|
|
298
|
-
export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore, estate } = {}) {
|
|
312
|
+
export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore, estate, estateApplied, estateRevocations } = {}) {
|
|
299
313
|
if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
|
|
300
314
|
if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
|
|
315
|
+
if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
|
|
301
316
|
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
302
317
|
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
303
318
|
const checked = authentic(approval, jwks, APPROVAL_TYP);
|
|
304
319
|
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
|
|
305
|
-
const uncertified = estateProblem(estate,
|
|
320
|
+
const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
|
|
306
321
|
if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'approval-invalid', uncertified[1]);
|
|
307
322
|
const claims = approvalShape(checked.compact.payload);
|
|
308
323
|
if (!claims) return refuse('approval-invalid', 'malformed-claims');
|
|
@@ -469,7 +484,7 @@ export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
|
|
|
469
484
|
* relayed from off the machine). A T0 or T1 caller is never required to present a token: with none, the call passes
|
|
470
485
|
* with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
|
|
471
486
|
*/
|
|
472
|
-
export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate }) {
|
|
487
|
+
export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate, estateApplied, estateRevocations }) {
|
|
473
488
|
if (!isString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
|
|
474
489
|
return async (tool, headers, tier = 'T2') => {
|
|
475
490
|
const effect = effects?.[tool];
|
|
@@ -480,7 +495,7 @@ export function delegationGate({ audience, jwks, effects, issuer, readOnly = fal
|
|
|
480
495
|
return refuse('unauthenticated', 'no-token');
|
|
481
496
|
}
|
|
482
497
|
const keys = jwks instanceof JwksCache ? await jwks.get(kidOf(carried.token)) : jwks;
|
|
483
|
-
const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate });
|
|
498
|
+
const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate, estateApplied, estateRevocations });
|
|
484
499
|
if (verdict.ok) verdict.tier = TIERS.includes(tier) ? tier : 'T2';
|
|
485
500
|
return verdict;
|
|
486
501
|
};
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs
CHANGED
|
@@ -17,6 +17,8 @@ import { secretValue } from './fabric-secrets.mjs';
|
|
|
17
17
|
export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
|
|
18
18
|
export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
|
|
19
19
|
export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
|
|
20
|
+
/** How far ahead of the verifier's clock a signed `issuedAt` may be before it is `not-yet-valid` (DEC-0066). */
|
|
21
|
+
export const CLOCK_SKEW_SECONDS = 60;
|
|
20
22
|
export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
|
|
21
23
|
export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
|
|
22
24
|
|
|
@@ -153,6 +155,34 @@ export function estateTime(value) {
|
|
|
153
155
|
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
|
|
154
156
|
return Date.parse(value);
|
|
155
157
|
}
|
|
158
|
+
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
159
|
+
/** `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063). */
|
|
160
|
+
const SIGNING_RP = new RegExp(`^sign(?:\\.${LABEL}){2,}$`);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether an RP ID is a dedicated signing RP (v4 §7, DEC-0063): first label `sign`, then the organization's or
|
|
164
|
+
* PassionCode's domain, lower case. A credential under any other RP ID is a sign-in credential and never signs.
|
|
165
|
+
*/
|
|
166
|
+
export const isSigningRpId = (rpId) => isString(rpId) && rpId.length <= 253 && SIGNING_RP.test(rpId);
|
|
167
|
+
|
|
168
|
+
/** Whether a pinned origin may run a signing ceremony for `rpId`: https, no port, no path, the RP ID or a host under it. */
|
|
169
|
+
export function originWithin(origin, rpId) {
|
|
170
|
+
const host = isString(origin) ? /^https:\/\/([a-z0-9.-]{1,253})$/.exec(origin)?.[1] : undefined;
|
|
171
|
+
return host !== undefined && (host === rpId || host.endsWith(`.${rpId}`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** FAC-SEM-092 on one signing credential: a signing RP ID, and every pinned origin within it. */
|
|
175
|
+
function signingCredential(credential, path, flag) {
|
|
176
|
+
const value = isObject(credential) ? credential : {};
|
|
177
|
+
if (!isSigningRpId(value.rpId)) {
|
|
178
|
+
flag(`${path}/rpId`, 'a passkey signs only under a dedicated signing RP, sign.<domain>, never under the RP ID it signs in with');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
(Array.isArray(value.origins) ? value.origins : []).forEach((origin, index) => {
|
|
182
|
+
if (!originWithin(origin, value.rpId)) flag(`${path}/origins/${index}`, `a signing ceremony runs only on ${value.rpId} or a host under it, over https without a port`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
156
186
|
const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
|
|
157
187
|
const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
|
|
158
188
|
|
|
@@ -206,13 +236,18 @@ export function verifyEstateRecord(record, trust) {
|
|
|
206
236
|
credentialId: String(authority.credentialId), alg: String(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {},
|
|
207
237
|
rpId: String(authority.rpId), origins: Array.isArray(authority.origins) ? authority.origins.filter(isString) : [],
|
|
208
238
|
};
|
|
239
|
+
// DEC-0063: the estate passkey is a signing credential. An RP ID that is not sign.<domain> is wrong-rp before the
|
|
240
|
+
// assertion is read; an origin outside the RP ID is not pinned, so a ceremony that ran there is wrong-origin.
|
|
241
|
+
if (!isSigningRpId(authority.rpId)) return { ok: false, problem: 'wrong-rp' };
|
|
242
|
+
credential.origins = credential.origins.filter((origin) => originWithin(origin, credential.rpId));
|
|
209
243
|
const assertion = verifyAssertion(signature, credential, estateChallenge(digest));
|
|
210
244
|
if (!assertion.ok) return { ok: false, problem: assertion.problem };
|
|
211
245
|
|
|
212
246
|
const issuedAt = estateTime(record.issuedAt);
|
|
213
247
|
const validUntil = estateTime(record.validUntil);
|
|
214
248
|
if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) return { ok: false, problem: 'too-long-lived' };
|
|
215
|
-
|
|
249
|
+
// A revision issued a moment ahead of this clock is admitted within the skew every relay time allows (DEC-0066).
|
|
250
|
+
if (at < issuedAt - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
|
|
216
251
|
if (at > validUntil) return { ok: false, problem: 'expired' };
|
|
217
252
|
const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
|
|
218
253
|
if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
|
|
@@ -227,19 +262,50 @@ export function placementTrust(record, machineId, agentRef) {
|
|
|
227
262
|
return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
|
|
228
263
|
}
|
|
229
264
|
|
|
265
|
+
const MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000;
|
|
266
|
+
const isRevision = (value) => Number.isSafeInteger(value) && value >= 1;
|
|
267
|
+
const refused = (fault, detail) => ({ certified: false, fault, detail });
|
|
268
|
+
|
|
230
269
|
/**
|
|
231
|
-
* DL-14
|
|
232
|
-
*
|
|
270
|
+
* DL-14 (DEC-0055, DEC-0060): whether `record` certifies the delegation key `kid` with the published key's `x` at `at`
|
|
271
|
+
* (a Date or epoch seconds), for a verifier whose machine applied `applied` (`{revision, digest?}`). `extraRevocations`
|
|
272
|
+
* are the revocation delta's entries applied since the revision (DEC-0054). The contract's `hubKeyCertified`, in its
|
|
273
|
+
* order; never throws. Answers `{certified: true, machine, revision}` or `{certified: false, fault, detail}`: fault
|
|
274
|
+
* `token` (`kid-not-certified`), `estate` (`estate-unreadable`, `estate-outside-window`, `estate-rolled-back`,
|
|
275
|
+
* `estate-forked`) or `clock` (`no-clock`).
|
|
233
276
|
*/
|
|
234
|
-
export function hubKeyCertified(record, kid, x, at) {
|
|
277
|
+
export function hubKeyCertified(record, kid, x, at, applied, options) {
|
|
235
278
|
const now = clockOf(at);
|
|
236
|
-
if (
|
|
279
|
+
if (Number.isNaN(now)) return refused('clock', 'no-clock');
|
|
280
|
+
if (!isObject(record) || !isRevision(record.revision) || !isString(record.digest) || !Array.isArray(record.machines)) return refused('estate', 'estate-unreadable');
|
|
281
|
+
if (record.revocations !== undefined && !Array.isArray(record.revocations)) return refused('estate', 'estate-unreadable');
|
|
282
|
+
const extra = options?.extraRevocations;
|
|
283
|
+
if (extra !== undefined && !Array.isArray(extra)) return refused('estate', 'estate-unreadable');
|
|
237
284
|
const issuedAt = estateTime(record.issuedAt);
|
|
238
285
|
const validUntil = estateTime(record.validUntil);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
286
|
+
// A window that is not a time, not positive or longer than 30 days is no window: NaN fails every comparison below.
|
|
287
|
+
if (Number.isNaN(issuedAt) || Number.isNaN(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_WINDOW_MS) return refused('estate', 'estate-unreadable');
|
|
288
|
+
if (applied !== undefined) {
|
|
289
|
+
if (!isObject(applied) || !isRevision(applied.revision) || (applied.digest !== undefined && !isString(applied.digest))) return refused('estate', 'estate-unreadable');
|
|
290
|
+
// An older revision may still be inside its window and list a key the applied one revokes (a rollback).
|
|
291
|
+
if (record.revision < applied.revision) return refused('estate', 'estate-rolled-back');
|
|
292
|
+
if (record.revision === applied.revision && applied.digest !== undefined && record.digest !== applied.digest) return refused('estate', 'estate-forked');
|
|
293
|
+
}
|
|
294
|
+
// The window verifyEstateRecord admits the revision in: from 60 s before issuedAt to validUntil (DEC-0066).
|
|
295
|
+
if (now < issuedAt - CLOCK_SKEW_SECONDS * 1000 || now > validUntil) return refused('estate', 'estate-outside-window');
|
|
296
|
+
|
|
297
|
+
const revocations = [...(record.revocations ?? []), ...(extra ?? [])];
|
|
298
|
+
// A ref is compared exactly, never as a string conversion.
|
|
299
|
+
const revokes = (kind, ref) => revocations.some((entry) => isObject(entry) && entry.kind === kind && entry.ref === ref);
|
|
300
|
+
if (!isString(kid) || revokes('hub-key', kid)) return refused('token', 'kid-not-certified');
|
|
301
|
+
const holders = record.machines.filter((machine) => isObject(machine) && isObject(machine.hubKey) && machine.hubKey.kid === kid);
|
|
302
|
+
// Two machines under one kid break FAC-SEM-073; such a revision certifies neither.
|
|
303
|
+
if (holders.length !== 1) return refused('token', 'kid-not-certified');
|
|
304
|
+
const [machine] = holders;
|
|
305
|
+
// A missing x never equals a missing x: the published key must be the certified one.
|
|
306
|
+
if (!isString(x) || machine.hubKey.x !== x) return refused('token', 'kid-not-certified');
|
|
307
|
+
if (revokes('machine', machine.id)) return refused('token', 'kid-not-certified');
|
|
308
|
+
return { certified: true, machine: String(machine.id), revision: record.revision };
|
|
243
309
|
}
|
|
244
310
|
|
|
245
311
|
// ---- FAC-SEM-072…076 ----
|
|
@@ -396,6 +462,8 @@ function recordFindings(value) {
|
|
|
396
462
|
placements(value, flagger(findings, 'FAC-SEM-074'));
|
|
397
463
|
secretReferences(value, flagger(findings, 'FAC-SEM-075'));
|
|
398
464
|
grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
|
|
465
|
+
// FAC-SEM-092 (DEC-0063) on each passkey the record lists.
|
|
466
|
+
list(value.authorities).forEach((authority, index) => signingCredential(authority, `/authorities/${index}`, flagger(findings, 'FAC-SEM-092')));
|
|
399
467
|
return findings;
|
|
400
468
|
}
|
|
401
469
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -52,7 +52,8 @@ const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
|
|
|
52
52
|
const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
|
|
53
53
|
const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
|
|
54
54
|
// DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
|
|
55
|
-
|
|
55
|
+
// DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
|
|
56
|
+
const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
|
|
56
57
|
export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
|
|
57
58
|
const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
|
|
58
59
|
const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
|
|
@@ -63,7 +64,8 @@ export const DEFAULT_EXIT_TIMEOUT = 15;
|
|
|
63
64
|
export class ServiceError extends Error {}
|
|
64
65
|
export class AlreadyRunning extends ServiceError {
|
|
65
66
|
constructor(holderPid, lockPath) {
|
|
66
|
-
|
|
67
|
+
// FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
|
|
68
|
+
super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'an unknown process'} holds ${lockPath}).`);
|
|
67
69
|
this.holderPid = holderPid;
|
|
68
70
|
this.lockPath = lockPath;
|
|
69
71
|
}
|
|
@@ -209,25 +211,92 @@ function readPid(file) {
|
|
|
209
211
|
}
|
|
210
212
|
}
|
|
211
213
|
|
|
214
|
+
/** The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node. */
|
|
215
|
+
export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node']);
|
|
216
|
+
/** The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action). */
|
|
217
|
+
export const SUPERVISOR_ARG = '--fabric-supervisor';
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The marker values an argument vector carries after the executable and before `--`. A bare `--fabric-supervisor`
|
|
221
|
+
* followed by nothing, by `--` or by another `--…` flag is a marker with an empty value and consumes nothing.
|
|
222
|
+
*/
|
|
223
|
+
function markerArguments(argv) {
|
|
224
|
+
const values = [];
|
|
225
|
+
if (!Array.isArray(argv)) return values;
|
|
226
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
227
|
+
const item = argv[index];
|
|
228
|
+
if (item === '--') break;
|
|
229
|
+
if (item === SUPERVISOR_ARG) {
|
|
230
|
+
const next = argv[index + 1];
|
|
231
|
+
if (typeof next === 'string' && !next.startsWith('--')) {
|
|
232
|
+
values.push(next);
|
|
233
|
+
index += 1;
|
|
234
|
+
} else {
|
|
235
|
+
values.push('');
|
|
236
|
+
}
|
|
237
|
+
} else if (typeof item === 'string' && item.startsWith(`${SUPERVISOR_ARG}=`)) {
|
|
238
|
+
values.push(item.slice(SUPERVISOR_ARG.length + 1));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return values;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How this copy was started (DEC-0057), from `FABRIC_SERVICE_SUPERVISOR=<manager>` and `--fabric-supervisor <manager>` /
|
|
246
|
+
* `--fabric-supervisor=<manager>` (`env` and `argv` default to this process's; `argv[0]` is the executable or script):
|
|
247
|
+
* `{kind: 'supervised', manager, forms}` when every marker present names the same manager of SUPERVISORS;
|
|
248
|
+
* `{kind: 'conflict', values, forms}` when they disagree (an empty or bare argument beside a variable disagrees with it):
|
|
249
|
+
* the copy exits 75 at once, before it takes the lock; `{kind: 'manual', forms, unreadable?}` with no marker (only an
|
|
250
|
+
* empty variable counts as none) or one that names no manager. Never inferred from the parent process. The contract's
|
|
251
|
+
* `supervisorMarker`.
|
|
252
|
+
*/
|
|
253
|
+
export function supervisorMarker({ env = process.env, argv = process.argv.slice(1) } = {}) {
|
|
254
|
+
const variable = env?.[SUPERVISOR_ENV];
|
|
255
|
+
const fromEnv = typeof variable === 'string' && variable !== '' ? [variable] : [];
|
|
256
|
+
const fromArgs = markerArguments(argv);
|
|
257
|
+
const forms = [...(fromEnv.length ? ['environment'] : []), ...(fromArgs.length ? ['argument'] : [])];
|
|
258
|
+
const values = [...new Set([...fromEnv, ...fromArgs])];
|
|
259
|
+
if (values.length > 1) return { kind: 'conflict', values, forms };
|
|
260
|
+
if (values.length === 0) return { kind: 'manual', forms };
|
|
261
|
+
return SUPERVISORS.includes(values[0]) ? { kind: 'supervised', manager: values[0], forms } : { kind: 'manual', unreadable: values[0], forms };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** How long a copy started so may wait for the lock, in seconds: 300 when supervised, else not at all. */
|
|
265
|
+
export const lockWaitBudgetSeconds = (marker) => (marker?.kind === 'supervised' ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
|
|
266
|
+
|
|
267
|
+
/** The one sentence a copy whose markers disagree exits with, naming the markers present. */
|
|
268
|
+
export function markerConflictMessage(marker) {
|
|
269
|
+
const shown = marker.values.map((v) => JSON.stringify(v)).join(', ');
|
|
270
|
+
return marker.forms.length === 2
|
|
271
|
+
? `${SUPERVISOR_ENV} and ${SUPERVISOR_ARG} disagree (${shown}); not starting unsupervised.`
|
|
272
|
+
: `${SUPERVISOR_ARG} is given with different values (${shown}); not starting unsupervised.`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** True when a supervisor that restarts on exit started this process and said so with the marker. */
|
|
276
|
+
export const supervised = (start) => supervisorMarker(start).kind === 'supervised';
|
|
212
277
|
/** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
|
|
213
|
-
export const supervisedByLaunchd = () =>
|
|
214
|
-
export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler']);
|
|
215
|
-
/** True when a supervisor that restarts on exit started this process and said so (launchd, systemd). */
|
|
216
|
-
export const supervised = () => SUPERVISORS.includes(process.env[SUPERVISOR_ENV]);
|
|
278
|
+
export const supervisedByLaunchd = (start) => supervisorMarker(start).manager === 'launchd';
|
|
217
279
|
|
|
218
280
|
const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
|
|
219
281
|
|
|
220
282
|
/**
|
|
221
|
-
* Take the lock or exit 75 with one sentence naming the holder
|
|
222
|
-
* 75
|
|
283
|
+
* Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078). Markers that
|
|
284
|
+
* disagree exit 75 before the lock. Started by hand, a held lock exits 75 at once. Under a
|
|
285
|
+
* supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
|
|
223
286
|
* forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
|
|
224
287
|
* LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
|
|
225
288
|
* the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
|
|
226
289
|
*/
|
|
227
290
|
export async function holdSingleInstance(dataDir, {
|
|
228
|
-
waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
|
|
291
|
+
waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
|
|
229
292
|
} = {}) {
|
|
230
|
-
|
|
293
|
+
// DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
|
|
294
|
+
const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
|
|
295
|
+
if (marker.kind === 'conflict') {
|
|
296
|
+
process.stderr.write(`${markerConflictMessage(marker)}\n`);
|
|
297
|
+
return exit(EXIT_ALREADY_RUNNING);
|
|
298
|
+
}
|
|
299
|
+
const wait = waitSeconds ?? lockWaitBudgetSeconds(marker);
|
|
231
300
|
const deadline = clock() + Math.max(0, wait);
|
|
232
301
|
let delay = LOCK_BACKOFF_FIRST_SECONDS;
|
|
233
302
|
let announced = false;
|
|
@@ -263,9 +332,8 @@ export function ensureToken(file) {
|
|
|
263
332
|
|
|
264
333
|
/**
|
|
265
334
|
* Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
|
|
266
|
-
* service.md "Windows token files" (DEC-0032, DEC-0033):
|
|
267
|
-
*
|
|
268
|
-
* those three; a refusal names the SID, never the contents.
|
|
335
|
+
* service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057): windowsTokenAclProblem over the
|
|
336
|
+
* file's flags, owner and ACEs; a refusal names the SID, never the contents.
|
|
269
337
|
*/
|
|
270
338
|
export function readToken(file) {
|
|
271
339
|
const info = fs.lstatSync(file);
|
|
@@ -286,30 +354,68 @@ export function readToken(file) {
|
|
|
286
354
|
// Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
|
|
287
355
|
// and icacls writes it; both get the path through the environment, never argv, and print no contents.
|
|
288
356
|
|
|
357
|
+
export const SYSTEM_SID = 'S-1-5-18';
|
|
358
|
+
export const ADMINISTRATORS_SID = 'S-1-5-32-544';
|
|
359
|
+
/** A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each. */
|
|
360
|
+
const SID_TEXT = /^S-1-\d{1,15}(-\d{1,10}){0,15}$/i;
|
|
361
|
+
/** A readable SID in upper case (SIDs compare case-insensitively on their S), or undefined. */
|
|
362
|
+
const sidOf = (value) => (typeof value === 'string' && SID_TEXT.test(value) && value.split('-').slice(3).every((part) => Number(part) <= 0xffffffff) ? value.toUpperCase() : undefined);
|
|
363
|
+
|
|
289
364
|
/**
|
|
290
|
-
* service.md "Windows token files",
|
|
291
|
-
*
|
|
292
|
-
*
|
|
365
|
+
* service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read: `file` is
|
|
366
|
+
* `{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}`, the mask the ACE's unsigned
|
|
367
|
+
* 32-bit access mask. The first refusal `{problem, at, sid?, message}`, or null when the file may be read: a regular
|
|
368
|
+
* file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every allow ACE with a
|
|
369
|
+
* non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything unreadable refused.
|
|
370
|
+
* The contract's `windowsTokenAclProblem`; a refusal names the SID, never the contents.
|
|
293
371
|
*/
|
|
294
|
-
export function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
372
|
+
export function windowsTokenAclProblem(file, currentUserSid) {
|
|
373
|
+
const refuse = (problem, at, message, sid) => (sid === undefined ? { problem, at, message } : { problem, at, sid, message });
|
|
374
|
+
const user = sidOf(currentUserSid);
|
|
375
|
+
if (user === undefined) return refuse('unreadable', '/currentUserSid', "the current user's SID is unreadable");
|
|
376
|
+
if (typeof file !== 'object' || file === null || Array.isArray(file)) return refuse('unreadable', '/file', "the token file's metadata is unreadable");
|
|
377
|
+
for (const flag of ['isRegularFile', 'isReparsePoint', 'realPathInProfile']) {
|
|
378
|
+
if (typeof file[flag] !== 'boolean') return refuse('unreadable', `/file/${flag}`, `${flag} is unreadable`);
|
|
379
|
+
}
|
|
380
|
+
if (file.isReparsePoint) return refuse('reparse-point', '/file/isReparsePoint', 'the token file is a reparse point (a symlink or a junction)');
|
|
381
|
+
if (!file.isRegularFile) return refuse('not-regular-file', '/file/isRegularFile', 'the token file is not a regular file');
|
|
382
|
+
if (!file.realPathInProfile) return refuse('outside-profile', '/file/realPathInProfile', "the token file's real path lies outside the user's profile");
|
|
383
|
+
const trusted = new Set([user, SYSTEM_SID, ADMINISTRATORS_SID]);
|
|
384
|
+
const owner = sidOf(file.ownerSid);
|
|
385
|
+
if (owner === undefined) return refuse('unreadable', '/file/ownerSid', 'the owner SID is unreadable');
|
|
386
|
+
if (!trusted.has(owner)) return refuse('owner', '/file/ownerSid', `the token file is owned by ${owner}, not by the current user, SYSTEM or Administrators`, owner);
|
|
387
|
+
if (!Array.isArray(file.aces)) return refuse('unreadable', '/file/aces', 'the ACL is unreadable');
|
|
388
|
+
for (const [index, ace] of file.aces.entries()) {
|
|
389
|
+
const at = `/file/aces/${index}`;
|
|
390
|
+
if (typeof ace !== 'object' || ace === null || Array.isArray(ace)) return refuse('unreadable', at, `ACE ${index} is unreadable`);
|
|
391
|
+
if (typeof ace.type !== 'string') return refuse('unreadable', `${at}/type`, `ACE ${index} has an unreadable type`);
|
|
392
|
+
const trustee = sidOf(ace.sid);
|
|
393
|
+
if (trustee === undefined) return refuse('unreadable', `${at}/sid`, `ACE ${index} names an unreadable SID`);
|
|
394
|
+
if (typeof ace.mask !== 'number' || !Number.isInteger(ace.mask) || ace.mask < 0 || ace.mask > 0xffffffff) return refuse('unreadable', `${at}/mask`, `ACE ${index} has an unreadable access mask`);
|
|
395
|
+
if (ace.type === 'deny') continue;
|
|
396
|
+
if (ace.type !== 'allow') return refuse('ace-type', `${at}/type`, `ACE ${index} for ${trustee} is of a type the rule does not read`, trustee);
|
|
397
|
+
if (ace.mask === 0) continue;
|
|
398
|
+
if (!trusted.has(trustee)) return refuse('ace-sid', `${at}/sid`, `the token file grants access to ${trustee}; only the current user, SYSTEM and Administrators may hold any`, trustee);
|
|
300
399
|
}
|
|
301
400
|
return null;
|
|
302
401
|
}
|
|
303
402
|
|
|
403
|
+
/** windowsTokenAclProblem as one sentence naming the file and the refusal, or null. */
|
|
404
|
+
export function tokenFileProblem(file, metadata, user) {
|
|
405
|
+
const problem = windowsTokenAclProblem(metadata, user);
|
|
406
|
+
return problem ? `Token file ${file} refused: ${problem.message}.` : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// The raw descriptor, through .NET: every ACE's type, its trustee's SID and its access mask as read (a signed int;
|
|
410
|
+
// parseAclReport converts it to the unsigned mask). An inherit-only ACE applies to children only and is left out.
|
|
304
411
|
const ACL_SCRIPT = [
|
|
305
412
|
"$ErrorActionPreference = 'Stop'",
|
|
306
|
-
'$sid = [System.Security.Principal.SecurityIdentifier]',
|
|
307
413
|
'$acl = [System.IO.File]::GetAccessControl($env:FABRIC_ACL_PATH)',
|
|
308
|
-
"$
|
|
309
|
-
|
|
310
|
-
"$
|
|
414
|
+
"$raw = New-Object System.Security.AccessControl.RawSecurityDescriptor($acl.GetSecurityDescriptorSddlForm('Owner, Access'))",
|
|
415
|
+
'$dacl = $raw.DiscretionaryAcl',
|
|
416
|
+
"$aces = @(if ($dacl) { $dacl | Where-Object { -not ([int]$_.AceFlags -band 8) } | ForEach-Object { @{ type = [string]$_.AceType; sid = $(if ($_.SecurityIdentifier) { $_.SecurityIdentifier.Value } else { '' }); mask = [int64]$_.AccessMask } } })",
|
|
311
417
|
'$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
|
|
312
|
-
'[Console]::Out.Write((ConvertTo-Json -Compress @{ owner = $
|
|
418
|
+
'[Console]::Out.Write((ConvertTo-Json -Compress -Depth 4 @{ owner = $raw.Owner.Value; aces = $aces; nullDacl = [bool](-not $dacl); user = $user }))',
|
|
313
419
|
].join('; ');
|
|
314
420
|
|
|
315
421
|
function windowsHelper(command, args, extraEnv = {}) {
|
|
@@ -323,14 +429,24 @@ function windowsHelper(command, args, extraEnv = {}) {
|
|
|
323
429
|
return result.stdout;
|
|
324
430
|
}
|
|
325
431
|
|
|
326
|
-
/**
|
|
432
|
+
/** .NET's AceType names as the rule's ACE type: allow and deny are read, every other type is refused (DEC-0057). */
|
|
433
|
+
const ACE_TYPES = { AccessAllowed: 'allow', AccessDenied: 'deny', AccessAllowedObject: 'allow-object', AccessDeniedObject: 'deny-object',
|
|
434
|
+
AccessAllowedCallback: 'allow-callback', AccessDeniedCallback: 'deny-callback', AccessAllowedCallbackObject: 'allow-object-callback',
|
|
435
|
+
AccessDeniedCallbackObject: 'deny-object-callback' };
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The ACL report PowerShell prints, as (owner SID, ACEs `{type, sid, mask}`, current user). The mask arrives signed
|
|
439
|
+
* (GENERIC_READ 0x80000000 is negative in .NET) and is read as the unsigned 32-bit mask; a NULL DACL grants everyone
|
|
440
|
+
* everything and is read as an allow ACE for Everyone (S-1-1-0).
|
|
441
|
+
*/
|
|
327
442
|
export function parseAclReport(text) {
|
|
328
443
|
const report = JSON.parse(text);
|
|
329
|
-
const
|
|
330
|
-
|
|
444
|
+
const aces = report.nullDacl ? [{ type: 'allow', sid: 'S-1-1-0', mask: 0x1f01ff }]
|
|
445
|
+
: [].concat(report.aces ?? []).map((ace) => ({ type: ACE_TYPES[ace.type] ?? String(ace.type), sid: ace.sid, mask: Number.isInteger(ace.mask) ? ace.mask >>> 0 : ace.mask }));
|
|
446
|
+
return { owner: report.owner ?? '', aces, user: report.user };
|
|
331
447
|
}
|
|
332
448
|
|
|
333
|
-
/** (owner SID,
|
|
449
|
+
/** (owner SID, the ACEs on the file itself, the current user's SID). */
|
|
334
450
|
export function windowsAcl(file) {
|
|
335
451
|
return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
|
|
336
452
|
}
|
|
@@ -356,13 +472,14 @@ export function windowsProtect(target, { folder }) {
|
|
|
356
472
|
}
|
|
357
473
|
|
|
358
474
|
function windowsTokenProblem(file, info) {
|
|
359
|
-
if (!info.isFile()) return `Token file ${file} is not a regular file (a link or junction); refusing it.`;
|
|
360
475
|
const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
|
|
361
476
|
const real = fs.realpathSync.native(file).toLowerCase();
|
|
362
477
|
const inside = path.win32.relative(profile, real);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
478
|
+
const { owner, aces, user } = windowsAcl(file);
|
|
479
|
+
return tokenFileProblem(file, {
|
|
480
|
+
isRegularFile: info.isFile(), isReparsePoint: info.isSymbolicLink(),
|
|
481
|
+
realPathInProfile: Boolean(inside) && !inside.startsWith('..') && !path.win32.isAbsolute(inside), ownerSid: owner, aces,
|
|
482
|
+
}, user);
|
|
366
483
|
}
|
|
367
484
|
|
|
368
485
|
export function tokenMatches(presented, token, scheme = 'Bearer') {
|
|
@@ -431,6 +548,10 @@ export function validateDescriptor(d) {
|
|
|
431
548
|
problems.push('a launchd service declares label and plist');
|
|
432
549
|
}
|
|
433
550
|
if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
|
|
551
|
+
// DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
552
|
+
for (const field of ['label', 'plist']) {
|
|
553
|
+
if (life[field] !== undefined && ['systemd', 'task-scheduler'].includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
|
|
554
|
+
}
|
|
434
555
|
// DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
435
556
|
for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
|
|
436
557
|
if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
|
|
@@ -1564,6 +1685,11 @@ function plain(value, what) {
|
|
|
1564
1685
|
return value;
|
|
1565
1686
|
}
|
|
1566
1687
|
|
|
1688
|
+
/** The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it. */
|
|
1689
|
+
function noMarkerArgument(argv) {
|
|
1690
|
+
if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1567
1693
|
function noSecretInSupervisorFile(argv, environment) {
|
|
1568
1694
|
// No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
|
|
1569
1695
|
for (const arg of argv) {
|
|
@@ -1592,7 +1718,8 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
|
|
|
1592
1718
|
const env = { ...environment };
|
|
1593
1719
|
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.`);
|
|
1594
1720
|
noSecretInSupervisorFile(argv, env);
|
|
1595
|
-
|
|
1721
|
+
noMarkerArgument(programArguments.map(String));
|
|
1722
|
+
env[SUPERVISOR_ENV] = 'systemd'; // the unit's writer writes the marker, whatever the caller passed
|
|
1596
1723
|
const cwd = plain(String(workingDirectory), 'The working directory');
|
|
1597
1724
|
if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
|
|
1598
1725
|
return [
|
|
@@ -1666,6 +1793,10 @@ export function taskXml(task, programArguments, { workingDirectory, description,
|
|
|
1666
1793
|
plain(String(workingDirectory), 'The working directory');
|
|
1667
1794
|
plain(description ?? task, 'The description');
|
|
1668
1795
|
noSecretInSupervisorFile(argv, {});
|
|
1796
|
+
noMarkerArgument(argv);
|
|
1797
|
+
// A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
|
|
1798
|
+
const cut = argv.includes('--') ? argv.indexOf('--') : argv.length;
|
|
1799
|
+
argv.splice(cut, 0, SUPERVISOR_ARG, 'task-scheduler');
|
|
1669
1800
|
const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
|
|
1670
1801
|
if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
|
|
1671
1802
|
plain(who, 'The user');
|