@jmfederico/pi-web 1.202608.1 → 1.202608.2
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/dist/cli.js +220 -108
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{CodeViewer-mEiBZFA5.js → CodeViewer-BMWwxG7q.js} +1 -1
- package/dist/client/assets/{TerminalPanel-O9381Eme.js → TerminalPanel-CacQDIYn.js} +1 -1
- package/dist/client/assets/{index-uI2AGFUy.js → index-DUW2xnoV.js} +486 -298
- package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
- package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
- package/dist/client/index.html +3 -3
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/environment.js +4 -0
- package/dist/environment.js.map +1 -0
- package/dist/nativeServices/installedServiceDefinitions.js +602 -0
- package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
- package/dist/nativeServices/serviceAction.js +174 -0
- package/dist/nativeServices/serviceAction.js.map +1 -0
- package/dist/nativeServices/serviceDoctor.js +374 -71
- package/dist/nativeServices/serviceDoctor.js.map +1 -1
- package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
- package/dist/pi-packages/relays/package.json +11 -0
- package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +1 -1
- package/dist/pi-packages/relays/prompts/relay-worktree.md +157 -0
- package/dist/pi-packages/relays/prompts/relay.md +152 -0
- package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +1 -1
- package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +1 -1
- package/dist/pi-packages/relays/skills/relay/SKILL.md +123 -0
- package/dist/pi-web-plugins/info/infoInternals.js +15 -0
- package/dist/piWebVersionReport.js +63 -10
- package/dist/piWebVersionReport.js.map +1 -1
- package/dist/server/app.js +6 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/knownAutoInstallPiPackages.js +30 -0
- package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
- package/dist/server/machines/machineService.js +1 -0
- package/dist/server/machines/machineService.js.map +1 -1
- package/dist/server/piPackageIdentity.js +25 -0
- package/dist/server/piPackageIdentity.js.map +1 -0
- package/dist/server/piPackageService.js +69 -8
- package/dist/server/piPackageService.js.map +1 -1
- package/dist/server/piWebStatus.js +13 -2
- package/dist/server/piWebStatus.js.map +1 -1
- package/dist/server/projectTrustRoutes.js +80 -0
- package/dist/server/projectTrustRoutes.js.map +1 -0
- package/dist/server/projects/projectService.js +3 -1
- package/dist/server/projects/projectService.js.map +1 -1
- package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
- package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
- package/dist/server/sessiond.js +21 -1
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/piSessionService.js +358 -12
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionModelScope.js +160 -0
- package/dist/server/sessions/sessionModelScope.js.map +1 -0
- package/dist/server/sessions/sessionNameGenerator.js +6 -6
- package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
- package/dist/server/sessions/sessionRoutes.js +41 -0
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/storage/piPackageDismissalStore.js +78 -0
- package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/federatedRoutes.js +8 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/dist/shared/piWebStatusParsing.js +4 -0
- package/dist/shared/piWebStatusParsing.js.map +1 -1
- package/dist/shared/pluginApiTypes.d.ts +2 -0
- package/docs/config.md +16 -1
- package/docs/plugins.md +20 -10
- package/package.json +2 -2
- package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
- package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
- package/dist/pi-web-plugins/relays/package.json +0 -9
- /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
- /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/marked.esm.js +0 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { posix as posixPath } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { TextDecoder, TextEncoder } from "node:util";
|
|
3
|
+
import { ownEnvironmentValue } from "../environment.js";
|
|
4
|
+
import { createDevelopmentNativeServicePlan, nativeServiceManagerRefs, nativeServicePrerequisiteNeedsPathAdvice, resolveProductionNativeServicePlan, validateNativeServicePlan, } from "./servicePlan.js";
|
|
3
5
|
export function inferInstalledNativeServiceMode(serviceIds) {
|
|
4
6
|
if (serviceIds.size === 0)
|
|
5
7
|
return "none";
|
|
@@ -11,6 +13,42 @@ export function inferInstalledNativeServiceMode(serviceIds) {
|
|
|
11
13
|
return "development";
|
|
12
14
|
return "ambiguous";
|
|
13
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Select the config path for a caller that is about to probe managed native
|
|
18
|
+
* services. A nonempty caller value is an explicit override and deliberately
|
|
19
|
+
* avoids interpreting installed files. Otherwise every relevant definition
|
|
20
|
+
* must parse to one consistent environment before its persisted path is used.
|
|
21
|
+
*/
|
|
22
|
+
export function selectManagedNativeServiceConfig(backend, definitions, callerConfigPath) {
|
|
23
|
+
if (callerConfigPath !== undefined && callerConfigPath !== "") {
|
|
24
|
+
return { ok: true, value: { source: "caller", configPath: callerConfigPath } };
|
|
25
|
+
}
|
|
26
|
+
if (definitions.length === 0)
|
|
27
|
+
return { ok: true, value: { source: "default" } };
|
|
28
|
+
const parsed = parseConsistentDefinitions(backend, definitions);
|
|
29
|
+
if (!parsed.ok)
|
|
30
|
+
return parsed;
|
|
31
|
+
const firstEnvironment = parsed.value[0]?.environment;
|
|
32
|
+
const installedConfigPath = firstEnvironment === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: ownEnvironmentValue(firstEnvironment, "PI_WEB_CONFIG");
|
|
35
|
+
if (installedConfigPath === undefined || installedConfigPath === "") {
|
|
36
|
+
return { ok: true, value: { source: "default" } };
|
|
37
|
+
}
|
|
38
|
+
if (!posixPath.isAbsolute(installedConfigPath)) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
message: `Installed service definitions declare relative PI_WEB_CONFIG ${JSON.stringify(installedConfigPath)}; managed config paths must be absolute.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, value: { source: "installed", configPath: installedConfigPath } };
|
|
45
|
+
}
|
|
46
|
+
export function inspectInstalledNativeServiceDefinitionEnvironment(backend, definition) {
|
|
47
|
+
const parsed = backend.kind === "systemd"
|
|
48
|
+
? parseSystemdDefinition(definition)
|
|
49
|
+
: parseLaunchdDefinition(definition);
|
|
50
|
+
return parsed.ok ? { ok: true, value: parsed.value.environment } : parsed;
|
|
51
|
+
}
|
|
14
52
|
export function inspectInstalledProductionServiceContext(backend, definitions) {
|
|
15
53
|
const parsed = parseConsistentDefinitions(backend, definitions);
|
|
16
54
|
if (!parsed.ok)
|
|
@@ -203,7 +241,9 @@ function systemdServiceDirectives(contents) {
|
|
|
203
241
|
let inServiceSection = false;
|
|
204
242
|
let foundServiceSection = false;
|
|
205
243
|
for (const line of contents.split(/\r?\n/u)) {
|
|
206
|
-
|
|
244
|
+
// systemd syntax trims ASCII spaces and tabs here; JavaScript's trim/\s
|
|
245
|
+
// would also consume Unicode whitespace that systemd treats as key text.
|
|
246
|
+
const trimmed = line.replace(/^[ \t]+|[ \t]+$/gu, "");
|
|
207
247
|
if (/^\[[^\]]+\]$/u.test(trimmed)) {
|
|
208
248
|
inServiceSection = trimmed === "[Service]";
|
|
209
249
|
foundServiceSection ||= inServiceSection;
|
|
@@ -211,51 +251,123 @@ function systemdServiceDirectives(contents) {
|
|
|
211
251
|
}
|
|
212
252
|
if (!inServiceSection || trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";"))
|
|
213
253
|
continue;
|
|
214
|
-
|
|
254
|
+
// Slice after the recognized prefix so JavaScript-only line separators remain directive data.
|
|
255
|
+
const match = /^[ \t]*([A-Za-z][A-Za-z0-9]*)=/u.exec(line);
|
|
215
256
|
const name = match?.[1];
|
|
216
|
-
|
|
217
|
-
if (name === undefined || value === undefined || !allowed.has(name))
|
|
257
|
+
if (match === null || name === undefined || !allowed.has(name))
|
|
218
258
|
return undefined;
|
|
219
|
-
directives.push({ name, value });
|
|
259
|
+
directives.push({ name, value: line.slice(match[0].length) });
|
|
220
260
|
}
|
|
221
261
|
return foundServiceSection ? directives : undefined;
|
|
222
262
|
}
|
|
263
|
+
function hasSystemdPhysicalLineContinuation(line) {
|
|
264
|
+
let trailingBackslashes = 0;
|
|
265
|
+
for (let index = line.length - 1; index >= 0 && line[index] === "\\"; index -= 1)
|
|
266
|
+
trailingBackslashes += 1;
|
|
267
|
+
return trailingBackslashes % 2 === 1;
|
|
268
|
+
}
|
|
269
|
+
/** Parse PI WEB's bounded ExecStart argument structure, including persisted legacy encodings. */
|
|
270
|
+
function parseSystemdExecStart(value) {
|
|
271
|
+
const environmentPrefix = "/usr/bin/env ";
|
|
272
|
+
const shellOffset = value.startsWith(environmentPrefix) ? environmentPrefix.length : 0;
|
|
273
|
+
const shell = parseSystemdExecStartArgument(value, shellOffset);
|
|
274
|
+
if (shell === undefined)
|
|
275
|
+
return undefined;
|
|
276
|
+
const loginShellSeparator = " -lc ";
|
|
277
|
+
if (!value.startsWith(loginShellSeparator, shell.endOffset))
|
|
278
|
+
return undefined;
|
|
279
|
+
const shellCommandOffset = shell.endOffset + loginShellSeparator.length;
|
|
280
|
+
if (shellCommandOffset >= value.length)
|
|
281
|
+
return undefined;
|
|
282
|
+
return {
|
|
283
|
+
shellArgument: shell.value,
|
|
284
|
+
shellCommandArgument: value.slice(shellCommandOffset),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** Scan one systemd argument linearly so delimiters inside quoted values remain data. */
|
|
288
|
+
function parseSystemdExecStartArgument(value, offset) {
|
|
289
|
+
if (offset >= value.length || value[offset] === " " || value[offset] === "\t")
|
|
290
|
+
return undefined;
|
|
291
|
+
let quote = null;
|
|
292
|
+
for (let index = offset; index < value.length; index += 1) {
|
|
293
|
+
const character = value[index];
|
|
294
|
+
if (character === "\\") {
|
|
295
|
+
index += 1;
|
|
296
|
+
if (index >= value.length)
|
|
297
|
+
return undefined;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (quote === null && (character === " " || character === "\t")) {
|
|
301
|
+
return { value: value.slice(offset, index), endOffset: index };
|
|
302
|
+
}
|
|
303
|
+
if (character !== "\"" && character !== "'")
|
|
304
|
+
continue;
|
|
305
|
+
if (quote === null)
|
|
306
|
+
quote = character;
|
|
307
|
+
else if (character === quote)
|
|
308
|
+
quote = null;
|
|
309
|
+
}
|
|
310
|
+
return quote === null ? { value: value.slice(offset), endOffset: value.length } : undefined;
|
|
311
|
+
}
|
|
312
|
+
// Keep malformed installed input linear; overlapping escaped/raw regex alternatives can backtrack exponentially.
|
|
313
|
+
function isSingleSystemdQuotedValue(value) {
|
|
314
|
+
if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"'))
|
|
315
|
+
return false;
|
|
316
|
+
const closingQuoteIndex = value.length - 1;
|
|
317
|
+
for (let index = 1; index < closingQuoteIndex; index += 1) {
|
|
318
|
+
const character = value[index];
|
|
319
|
+
if (character === '"')
|
|
320
|
+
return false;
|
|
321
|
+
if (character !== "\\")
|
|
322
|
+
continue;
|
|
323
|
+
index += 1;
|
|
324
|
+
if (index >= closingQuoteIndex)
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
223
329
|
function parseSystemdDefinition(definition) {
|
|
330
|
+
if (definition.contents.split(/\r?\n/u).some(hasSystemdPhysicalLineContinuation)) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
message: `Installed ${definition.id} systemd unit uses physical-line continuation, which cannot be inspected safely.`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
224
336
|
const directives = systemdServiceDirectives(definition.contents);
|
|
225
337
|
if (directives === undefined) {
|
|
226
338
|
return { ok: false, message: `Installed ${definition.id} systemd unit has unrecognized service directives.` };
|
|
227
339
|
}
|
|
228
340
|
const execStarts = directives.filter((directive) => directive.name === "ExecStart");
|
|
229
341
|
const execStart = execStarts.length === 1
|
|
230
|
-
?
|
|
231
|
-
:
|
|
232
|
-
if (execStart
|
|
342
|
+
? parseSystemdExecStart(execStarts[0]?.value ?? "")
|
|
343
|
+
: undefined;
|
|
344
|
+
if (execStart === undefined) {
|
|
233
345
|
return { ok: false, message: `Installed ${definition.id} systemd unit must have exactly one recognized ExecStart.` };
|
|
234
346
|
}
|
|
235
|
-
const shellExecutable = parseSystemdExecArgument(execStart
|
|
347
|
+
const shellExecutable = parseSystemdExecArgument(execStart.shellArgument);
|
|
236
348
|
if (shellExecutable === undefined) {
|
|
237
349
|
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized login shell argument.` };
|
|
238
350
|
}
|
|
239
351
|
const shell = installedShell(shellExecutable);
|
|
240
352
|
if (!shell.ok)
|
|
241
353
|
return shell;
|
|
242
|
-
const shellCommand = parseSystemdShellCommand(shell.value.name, execStart
|
|
354
|
+
const shellCommand = parseSystemdShellCommand(shell.value.name, execStart.shellCommandArgument);
|
|
243
355
|
if (shellCommand === undefined) {
|
|
244
356
|
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized shell command.` };
|
|
245
357
|
}
|
|
246
|
-
const environment =
|
|
358
|
+
const environment = new Map();
|
|
247
359
|
for (const directive of directives.filter((item) => item.name === "Environment")) {
|
|
248
360
|
const rawValue = directive.value;
|
|
249
|
-
if (
|
|
361
|
+
if (!isSingleSystemdQuotedValue(rawValue)) {
|
|
250
362
|
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized environment entry.` };
|
|
251
363
|
}
|
|
252
364
|
const assignment = parseSystemdDirectiveValue(rawValue);
|
|
253
365
|
const separator = assignment?.indexOf("=") ?? -1;
|
|
254
366
|
const key = assignment?.slice(0, separator) ?? "";
|
|
255
|
-
if (separator <= 0 ||
|
|
367
|
+
if (separator <= 0 || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || environment.has(key)) {
|
|
256
368
|
return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed environment entry.` };
|
|
257
369
|
}
|
|
258
|
-
environment
|
|
370
|
+
environment.set(key, assignment?.slice(separator + 1) ?? "");
|
|
259
371
|
}
|
|
260
372
|
const workingDirectories = directives.filter((directive) => directive.name === "WorkingDirectory");
|
|
261
373
|
if (workingDirectories.length > 1) {
|
|
@@ -273,43 +385,46 @@ function parseSystemdDefinition(definition) {
|
|
|
273
385
|
}
|
|
274
386
|
return {
|
|
275
387
|
ok: true,
|
|
276
|
-
value: {
|
|
388
|
+
value: {
|
|
389
|
+
id: definition.id,
|
|
390
|
+
shell: shell.value,
|
|
391
|
+
environment: Object.fromEntries(environment),
|
|
392
|
+
workingDirectory: workingDirectory ?? null,
|
|
393
|
+
shellCommand,
|
|
394
|
+
},
|
|
277
395
|
};
|
|
278
396
|
}
|
|
279
397
|
function parseLaunchdDefinition(definition) {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
398
|
+
const plist = parseLaunchdPlistDocument(definition.contents);
|
|
399
|
+
if (plist === undefined) {
|
|
400
|
+
return { ok: false, message: `Installed ${definition.id} LaunchAgent is not a structurally valid property list.` };
|
|
401
|
+
}
|
|
402
|
+
const label = plistStringValue(plist.get("Label"));
|
|
403
|
+
if (label === undefined) {
|
|
404
|
+
return { ok: false, message: `Installed ${definition.id} LaunchAgent has an unrecognized Label.` };
|
|
405
|
+
}
|
|
406
|
+
const expectedLabel = nativeServiceManagerRefs[definition.id].launchdLabel;
|
|
407
|
+
if (label !== expectedLabel) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
message: `Installed ${definition.id} LaunchAgent declares Label ${JSON.stringify(label)} instead of ${JSON.stringify(expectedLabel)}.`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const arguments_ = plistStringArray(plist.get("ProgramArguments"));
|
|
284
414
|
if (arguments_?.length !== 4 || arguments_[0] !== "/usr/bin/env" || arguments_[2] !== "-lc") {
|
|
285
415
|
return { ok: false, message: `Installed ${definition.id} LaunchAgent has unrecognized ProgramArguments.` };
|
|
286
416
|
}
|
|
287
417
|
const shell = installedShell(arguments_[1] ?? "");
|
|
288
418
|
if (!shell.ok)
|
|
289
419
|
return shell;
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
if (environmentMatches.length > 1 || environmentKeyCount !== environmentMatches.length) {
|
|
293
|
-
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` };
|
|
294
|
-
}
|
|
295
|
-
const environment = environmentMatches.length === 0
|
|
296
|
-
? {}
|
|
297
|
-
: parseXmlStringDictionary(environmentMatches[0]?.[1] ?? "");
|
|
420
|
+
const rawEnvironment = plist.get("EnvironmentVariables");
|
|
421
|
+
const environment = rawEnvironment === undefined ? {} : plistStringDictionary(rawEnvironment);
|
|
298
422
|
if (environment === undefined) {
|
|
299
423
|
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` };
|
|
300
424
|
}
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const workingDirectoryMatches = [...contentsWithoutEnvironment.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/gu)];
|
|
305
|
-
const workingDirectoryKeyCount = [...contentsWithoutEnvironment.matchAll(/<key>WorkingDirectory<\/key>/gu)].length;
|
|
306
|
-
if (workingDirectoryMatches.length > 1 || workingDirectoryKeyCount !== workingDirectoryMatches.length) {
|
|
307
|
-
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` };
|
|
308
|
-
}
|
|
309
|
-
const workingDirectory = workingDirectoryMatches[0]?.[1] === undefined
|
|
310
|
-
? null
|
|
311
|
-
: xmlUnescapeStrict(workingDirectoryMatches[0][1]);
|
|
312
|
-
if (workingDirectoryMatches.length === 1 && workingDirectory === undefined) {
|
|
425
|
+
const rawWorkingDirectory = plist.get("WorkingDirectory");
|
|
426
|
+
const workingDirectory = rawWorkingDirectory === undefined ? null : plistStringValue(rawWorkingDirectory);
|
|
427
|
+
if (rawWorkingDirectory !== undefined && workingDirectory === undefined) {
|
|
313
428
|
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` };
|
|
314
429
|
}
|
|
315
430
|
return {
|
|
@@ -334,7 +449,10 @@ function installedShell(executable) {
|
|
|
334
449
|
};
|
|
335
450
|
}
|
|
336
451
|
function parseSystemdExecArgument(value) {
|
|
337
|
-
const
|
|
452
|
+
const quoted = value.startsWith('"') || value.endsWith('"');
|
|
453
|
+
if (quoted ? !isSingleSystemdQuotedValue(value) : /["']/u.test(value))
|
|
454
|
+
return undefined;
|
|
455
|
+
const decoded = decodeSystemdEscapes(quoted ? value.slice(1, -1) : value);
|
|
338
456
|
return decoded === undefined ? undefined : decodeSystemdSubstitutions(decoded, true);
|
|
339
457
|
}
|
|
340
458
|
function parseSystemdDirectiveValue(value) {
|
|
@@ -345,14 +463,20 @@ function parseSystemdEscapedValue(value) {
|
|
|
345
463
|
const quoted = value.startsWith('"') || value.endsWith('"');
|
|
346
464
|
if (quoted && (!value.startsWith('"') || !value.endsWith('"')))
|
|
347
465
|
return undefined;
|
|
348
|
-
return
|
|
466
|
+
return decodeSystemdEscapes(quoted ? value.slice(1, -1) : value);
|
|
349
467
|
}
|
|
350
|
-
function
|
|
351
|
-
|
|
468
|
+
export function decodeSystemdEscapes(value) {
|
|
469
|
+
const bytes = [];
|
|
470
|
+
const encoder = new TextEncoder();
|
|
352
471
|
for (let index = 0; index < value.length; index += 1) {
|
|
353
472
|
const character = value[index];
|
|
354
473
|
if (character !== "\\") {
|
|
355
|
-
|
|
474
|
+
const codePoint = value.codePointAt(index);
|
|
475
|
+
if (codePoint === undefined || codePoint === 0 || (codePoint >= 0xd800 && codePoint <= 0xdfff))
|
|
476
|
+
return undefined;
|
|
477
|
+
bytes.push(...encoder.encode(String.fromCodePoint(codePoint)));
|
|
478
|
+
if (codePoint > 0xffff)
|
|
479
|
+
index += 1;
|
|
356
480
|
continue;
|
|
357
481
|
}
|
|
358
482
|
const escape = value[index + 1];
|
|
@@ -374,23 +498,45 @@ function systemdUnescape(value) {
|
|
|
374
498
|
};
|
|
375
499
|
const simple = simpleEscapes[escape];
|
|
376
500
|
if (simple !== undefined) {
|
|
377
|
-
|
|
501
|
+
bytes.push(...encoder.encode(simple));
|
|
378
502
|
index += 1;
|
|
379
503
|
continue;
|
|
380
504
|
}
|
|
505
|
+
if (/^[0-7]$/u.test(escape)) {
|
|
506
|
+
const encoded = value.slice(index + 1, index + 4);
|
|
507
|
+
if (!/^[0-7]{3}$/u.test(encoded))
|
|
508
|
+
return undefined;
|
|
509
|
+
const decoded = Number.parseInt(encoded, 8);
|
|
510
|
+
if (decoded === 0 || decoded > 0xff)
|
|
511
|
+
return undefined;
|
|
512
|
+
bytes.push(decoded);
|
|
513
|
+
index += 3;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
381
516
|
const length = escape === "x" ? 2 : escape === "u" ? 4 : escape === "U" ? 8 : 0;
|
|
382
517
|
if (length === 0)
|
|
383
518
|
return undefined;
|
|
384
519
|
const encoded = value.slice(index + 2, index + 2 + length);
|
|
385
520
|
if (encoded.length !== length || !new RegExp(`^[0-9a-fA-F]{${String(length)}}$`, "u").test(encoded))
|
|
386
521
|
return undefined;
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
522
|
+
const decoded = Number.parseInt(encoded, 16);
|
|
523
|
+
if (decoded === 0 || decoded > 0x10ffff || (decoded >= 0xd800 && decoded <= 0xdfff))
|
|
389
524
|
return undefined;
|
|
390
|
-
|
|
525
|
+
if (escape === "x")
|
|
526
|
+
bytes.push(decoded);
|
|
527
|
+
else
|
|
528
|
+
bytes.push(...encoder.encode(String.fromCodePoint(decoded)));
|
|
391
529
|
index += length + 1;
|
|
392
530
|
}
|
|
393
|
-
|
|
531
|
+
try {
|
|
532
|
+
// Keep a BOM produced by an escape sequence as U+FEFF. The default
|
|
533
|
+
// decoder behavior strips leading UTF-8 BOM bytes, which could turn an
|
|
534
|
+
// invalid BOM-prefixed environment key into PI_WEB_CONFIG.
|
|
535
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Uint8Array.from(bytes));
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
394
540
|
}
|
|
395
541
|
function decodeSystemdSubstitutions(value, decodeDollars) {
|
|
396
542
|
let result = "";
|
|
@@ -414,7 +560,17 @@ function parseSystemdShellCommand(shell, value) {
|
|
|
414
560
|
return undefined;
|
|
415
561
|
const inner = value.slice(1, -1);
|
|
416
562
|
const unquoted = shell === "fish" ? fishSingleQuoteUnescape(inner) : inner.replaceAll("'\\''", "'");
|
|
417
|
-
|
|
563
|
+
if (legacySystemdShellQuote(shell, unquoted) !== value)
|
|
564
|
+
return undefined;
|
|
565
|
+
// The pre-hardening renderer doubled percent specifiers, but its
|
|
566
|
+
// replacement-string "$$" left dollar characters unchanged.
|
|
567
|
+
return decodeSystemdSubstitutions(unquoted, false);
|
|
568
|
+
}
|
|
569
|
+
function legacySystemdShellQuote(shell, value) {
|
|
570
|
+
const escaped = shell === "fish"
|
|
571
|
+
? value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")
|
|
572
|
+
: value.replaceAll("'", "'\\''");
|
|
573
|
+
return `'${escaped}'`;
|
|
418
574
|
}
|
|
419
575
|
function fishSingleQuoteUnescape(value) {
|
|
420
576
|
let result = "";
|
|
@@ -430,34 +586,181 @@ function fishSingleQuoteUnescape(value) {
|
|
|
430
586
|
}
|
|
431
587
|
return result;
|
|
432
588
|
}
|
|
433
|
-
|
|
589
|
+
const plistXmlDeclaration = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
590
|
+
const plistDoctype = '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">';
|
|
591
|
+
const maximumPlistDepth = 32;
|
|
592
|
+
/** Parse the complete XML plist subset emitted by PI WEB, rejecting fragments and duplicate dictionary keys. */
|
|
593
|
+
function parseLaunchdPlistDocument(contents) {
|
|
594
|
+
const normalizedContents = normalizePlistXml(contents);
|
|
595
|
+
if (normalizedContents === undefined)
|
|
596
|
+
return undefined;
|
|
597
|
+
const cursor = {
|
|
598
|
+
contents: normalizedContents,
|
|
599
|
+
offset: normalizedContents.startsWith("\uFEFF") ? 1 : 0,
|
|
600
|
+
};
|
|
601
|
+
skipPlistWhitespace(cursor);
|
|
602
|
+
if (consumePlistToken(cursor, plistXmlDeclaration))
|
|
603
|
+
skipPlistWhitespace(cursor);
|
|
604
|
+
else if (cursor.contents.startsWith("<?xml", cursor.offset))
|
|
605
|
+
return undefined;
|
|
606
|
+
if (consumePlistToken(cursor, plistDoctype))
|
|
607
|
+
skipPlistWhitespace(cursor);
|
|
608
|
+
else if (cursor.contents.startsWith("<!DOCTYPE", cursor.offset))
|
|
609
|
+
return undefined;
|
|
610
|
+
if (!consumePlistToken(cursor, '<plist version="1.0">'))
|
|
611
|
+
return undefined;
|
|
612
|
+
const root = parsePlistValue(cursor, 0);
|
|
613
|
+
if (root?.kind !== "dictionary")
|
|
614
|
+
return undefined;
|
|
615
|
+
skipPlistWhitespace(cursor);
|
|
616
|
+
if (!consumePlistToken(cursor, "</plist>"))
|
|
617
|
+
return undefined;
|
|
618
|
+
skipPlistWhitespace(cursor);
|
|
619
|
+
return cursor.offset === cursor.contents.length ? root.value : undefined;
|
|
620
|
+
}
|
|
621
|
+
function parsePlistValue(cursor, depth) {
|
|
622
|
+
if (depth > maximumPlistDepth)
|
|
623
|
+
return undefined;
|
|
624
|
+
skipPlistWhitespace(cursor);
|
|
625
|
+
if (cursor.contents.startsWith("<string>", cursor.offset)) {
|
|
626
|
+
const value = parsePlistTextElement(cursor, "string");
|
|
627
|
+
return value === undefined ? undefined : { kind: "string", value };
|
|
628
|
+
}
|
|
629
|
+
for (const tag of ["data", "date", "integer", "real"]) {
|
|
630
|
+
if (!cursor.contents.startsWith(`<${tag}>`, cursor.offset))
|
|
631
|
+
continue;
|
|
632
|
+
const value = parsePlistTextElement(cursor, tag);
|
|
633
|
+
return value === undefined || !isValidPlistScalarValue(tag, value)
|
|
634
|
+
? undefined
|
|
635
|
+
: { kind: "scalar", value };
|
|
636
|
+
}
|
|
637
|
+
if (cursor.contents.startsWith("<array", cursor.offset))
|
|
638
|
+
return parsePlistArray(cursor, depth);
|
|
639
|
+
if (cursor.contents.startsWith("<dict", cursor.offset))
|
|
640
|
+
return parsePlistDictionary(cursor, depth);
|
|
641
|
+
if (consumePlistToken(cursor, "<true/>"))
|
|
642
|
+
return { kind: "boolean", value: true };
|
|
643
|
+
if (consumePlistToken(cursor, "<false/>"))
|
|
644
|
+
return { kind: "boolean", value: false };
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
function parsePlistArray(cursor, depth) {
|
|
648
|
+
if (consumePlistToken(cursor, "<array/>"))
|
|
649
|
+
return { kind: "array", value: [] };
|
|
650
|
+
if (!consumePlistToken(cursor, "<array>"))
|
|
651
|
+
return undefined;
|
|
434
652
|
const values = [];
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
return
|
|
439
|
-
const value =
|
|
653
|
+
for (;;) {
|
|
654
|
+
skipPlistWhitespace(cursor);
|
|
655
|
+
if (consumePlistToken(cursor, "</array>"))
|
|
656
|
+
return { kind: "array", value: values };
|
|
657
|
+
const value = parsePlistValue(cursor, depth + 1);
|
|
440
658
|
if (value === undefined)
|
|
441
659
|
return undefined;
|
|
442
660
|
values.push(value);
|
|
443
|
-
cursor = match.index + match[0].length;
|
|
444
661
|
}
|
|
445
|
-
return contents.slice(cursor).trim() === "" ? values : undefined;
|
|
446
662
|
}
|
|
447
|
-
function
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
663
|
+
function parsePlistDictionary(cursor, depth) {
|
|
664
|
+
if (consumePlistToken(cursor, "<dict/>"))
|
|
665
|
+
return { kind: "dictionary", value: new Map() };
|
|
666
|
+
if (!consumePlistToken(cursor, "<dict>"))
|
|
667
|
+
return undefined;
|
|
668
|
+
const values = new Map();
|
|
669
|
+
for (;;) {
|
|
670
|
+
skipPlistWhitespace(cursor);
|
|
671
|
+
if (consumePlistToken(cursor, "</dict>"))
|
|
672
|
+
return { kind: "dictionary", value: values };
|
|
673
|
+
const key = parsePlistTextElement(cursor, "key");
|
|
674
|
+
if (key === undefined || values.has(key))
|
|
452
675
|
return undefined;
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
676
|
+
const value = parsePlistValue(cursor, depth + 1);
|
|
677
|
+
if (value === undefined)
|
|
678
|
+
return undefined;
|
|
679
|
+
values.set(key, value);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function parsePlistTextElement(cursor, tag) {
|
|
683
|
+
const openingTag = `<${tag}>`;
|
|
684
|
+
const closingTag = `</${tag}>`;
|
|
685
|
+
if (!consumePlistToken(cursor, openingTag))
|
|
686
|
+
return undefined;
|
|
687
|
+
const closingOffset = cursor.contents.indexOf(closingTag, cursor.offset);
|
|
688
|
+
if (closingOffset < 0)
|
|
689
|
+
return undefined;
|
|
690
|
+
const encoded = cursor.contents.slice(cursor.offset, closingOffset);
|
|
691
|
+
cursor.offset = closingOffset + closingTag.length;
|
|
692
|
+
return xmlUnescapeStrict(encoded);
|
|
693
|
+
}
|
|
694
|
+
function normalizePlistXml(contents) {
|
|
695
|
+
for (const character of contents) {
|
|
696
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
697
|
+
const isXmlCharacter = codePoint === 0x09
|
|
698
|
+
|| codePoint === 0x0a
|
|
699
|
+
|| codePoint === 0x0d
|
|
700
|
+
|| (codePoint >= 0x20 && codePoint <= 0xd7ff)
|
|
701
|
+
|| (codePoint >= 0xe000 && codePoint <= 0xfffd)
|
|
702
|
+
|| (codePoint >= 0x10000 && codePoint <= 0x10ffff);
|
|
703
|
+
if (!isXmlCharacter)
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
return contents.replace(/\r\n?/gu, "\n");
|
|
707
|
+
}
|
|
708
|
+
function isValidPlistScalarValue(tag, value) {
|
|
709
|
+
if (tag === "data") {
|
|
710
|
+
const compact = value.replace(/[\t\n\r ]/gu, "");
|
|
711
|
+
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(compact);
|
|
712
|
+
}
|
|
713
|
+
if (tag === "date") {
|
|
714
|
+
const match = /^(\d{4})-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.exec(value);
|
|
715
|
+
if (match?.[1] === undefined || Number(match[1]) === 0)
|
|
716
|
+
return false;
|
|
717
|
+
const timestamp = Date.parse(value);
|
|
718
|
+
return Number.isFinite(timestamp)
|
|
719
|
+
&& new Date(timestamp).toISOString() === `${value.slice(0, -1)}.000Z`;
|
|
720
|
+
}
|
|
721
|
+
if (tag === "integer") {
|
|
722
|
+
if (!/^[+-]?(?:0|[1-9][0-9]*)$/u.test(value))
|
|
723
|
+
return false;
|
|
724
|
+
const parsed = BigInt(value);
|
|
725
|
+
return parsed >= -(2n ** 63n) && parsed <= (2n ** 63n) - 1n;
|
|
726
|
+
}
|
|
727
|
+
return /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/u.test(value)
|
|
728
|
+
&& Number.isFinite(Number(value));
|
|
729
|
+
}
|
|
730
|
+
function skipPlistWhitespace(cursor) {
|
|
731
|
+
while (/[\t\n\r ]/u.test(cursor.contents[cursor.offset] ?? ""))
|
|
732
|
+
cursor.offset += 1;
|
|
733
|
+
}
|
|
734
|
+
function consumePlistToken(cursor, token) {
|
|
735
|
+
if (!cursor.contents.startsWith(token, cursor.offset))
|
|
736
|
+
return false;
|
|
737
|
+
cursor.offset += token.length;
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
function plistStringValue(value) {
|
|
741
|
+
return value?.kind === "string" ? value.value : undefined;
|
|
742
|
+
}
|
|
743
|
+
function plistStringArray(value) {
|
|
744
|
+
if (value?.kind !== "array")
|
|
745
|
+
return undefined;
|
|
746
|
+
const strings = [];
|
|
747
|
+
for (const item of value.value) {
|
|
748
|
+
if (item.kind !== "string")
|
|
749
|
+
return undefined;
|
|
750
|
+
strings.push(item.value);
|
|
751
|
+
}
|
|
752
|
+
return strings;
|
|
753
|
+
}
|
|
754
|
+
function plistStringDictionary(value) {
|
|
755
|
+
if (value.kind !== "dictionary")
|
|
756
|
+
return undefined;
|
|
757
|
+
const entries = [];
|
|
758
|
+
for (const [key, item] of value.value) {
|
|
759
|
+
if (item.kind !== "string")
|
|
456
760
|
return undefined;
|
|
457
|
-
|
|
458
|
-
cursor = match.index + match[0].length;
|
|
761
|
+
entries.push([key, item.value]);
|
|
459
762
|
}
|
|
460
|
-
return
|
|
763
|
+
return Object.fromEntries(entries);
|
|
461
764
|
}
|
|
462
765
|
function xmlUnescapeStrict(value) {
|
|
463
766
|
if (/[<>]/u.test(value) || /&(?!(?:apos|quot|gt|lt|amp);)/u.test(value))
|
|
@@ -472,7 +775,7 @@ function xmlUnescapeStrict(value) {
|
|
|
472
775
|
function recordsEqual(left, right) {
|
|
473
776
|
const leftEntries = Object.entries(left);
|
|
474
777
|
return leftEntries.length === Object.keys(right).length
|
|
475
|
-
&& leftEntries.every(([key, value]) => right[key] === value);
|
|
778
|
+
&& leftEntries.every(([key, value]) => Object.hasOwn(right, key) && right[key] === value);
|
|
476
779
|
}
|
|
477
780
|
function impossibleMissingDefinition() {
|
|
478
781
|
throw new Error("Expected at least one installed native service definition");
|