@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.
Files changed (73) hide show
  1. package/dist/cli.js +220 -108
  2. package/dist/cli.js.map +1 -1
  3. package/dist/client/assets/{CodeViewer-mEiBZFA5.js → CodeViewer-BMWwxG7q.js} +1 -1
  4. package/dist/client/assets/{TerminalPanel-O9381Eme.js → TerminalPanel-CacQDIYn.js} +1 -1
  5. package/dist/client/assets/{index-uI2AGFUy.js → index-DUW2xnoV.js} +486 -298
  6. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
  7. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
  8. package/dist/client/index.html +3 -3
  9. package/dist/config.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/environment.js +4 -0
  12. package/dist/environment.js.map +1 -0
  13. package/dist/nativeServices/installedServiceDefinitions.js +602 -0
  14. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
  15. package/dist/nativeServices/serviceAction.js +174 -0
  16. package/dist/nativeServices/serviceAction.js.map +1 -0
  17. package/dist/nativeServices/serviceDoctor.js +374 -71
  18. package/dist/nativeServices/serviceDoctor.js.map +1 -1
  19. package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
  20. package/dist/pi-packages/relays/package.json +11 -0
  21. package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +1 -1
  22. package/dist/pi-packages/relays/prompts/relay-worktree.md +157 -0
  23. package/dist/pi-packages/relays/prompts/relay.md +152 -0
  24. package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +1 -1
  25. package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +1 -1
  26. package/dist/pi-packages/relays/skills/relay/SKILL.md +123 -0
  27. package/dist/pi-web-plugins/info/infoInternals.js +15 -0
  28. package/dist/piWebVersionReport.js +63 -10
  29. package/dist/piWebVersionReport.js.map +1 -1
  30. package/dist/server/app.js +6 -0
  31. package/dist/server/app.js.map +1 -1
  32. package/dist/server/knownAutoInstallPiPackages.js +30 -0
  33. package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
  34. package/dist/server/machines/machineService.js +1 -0
  35. package/dist/server/machines/machineService.js.map +1 -1
  36. package/dist/server/piPackageIdentity.js +25 -0
  37. package/dist/server/piPackageIdentity.js.map +1 -0
  38. package/dist/server/piPackageService.js +69 -8
  39. package/dist/server/piPackageService.js.map +1 -1
  40. package/dist/server/piWebStatus.js +13 -2
  41. package/dist/server/piWebStatus.js.map +1 -1
  42. package/dist/server/projectTrustRoutes.js +80 -0
  43. package/dist/server/projectTrustRoutes.js.map +1 -0
  44. package/dist/server/projects/projectService.js +3 -1
  45. package/dist/server/projects/projectService.js.map +1 -1
  46. package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
  47. package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
  48. package/dist/server/sessiond.js +21 -1
  49. package/dist/server/sessiond.js.map +1 -1
  50. package/dist/server/sessions/piSessionService.js +358 -12
  51. package/dist/server/sessions/piSessionService.js.map +1 -1
  52. package/dist/server/sessions/sessionModelScope.js +160 -0
  53. package/dist/server/sessions/sessionModelScope.js.map +1 -0
  54. package/dist/server/sessions/sessionNameGenerator.js +6 -6
  55. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  56. package/dist/server/sessions/sessionRoutes.js +41 -0
  57. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  58. package/dist/server/storage/piPackageDismissalStore.js +78 -0
  59. package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
  60. package/dist/shared/apiTypes.js.map +1 -1
  61. package/dist/shared/federatedRoutes.js +8 -0
  62. package/dist/shared/federatedRoutes.js.map +1 -1
  63. package/dist/shared/piWebStatusParsing.js +4 -0
  64. package/dist/shared/piWebStatusParsing.js.map +1 -1
  65. package/dist/shared/pluginApiTypes.d.ts +2 -0
  66. package/docs/config.md +16 -1
  67. package/docs/plugins.md +20 -10
  68. package/package.json +2 -2
  69. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
  70. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
  71. package/dist/pi-web-plugins/relays/package.json +0 -9
  72. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
  73. /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 { createDevelopmentNativeServicePlan, nativeServicePrerequisiteNeedsPathAdvice, resolveProductionNativeServicePlan, validateNativeServicePlan, } from "./servicePlan.js";
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
- const trimmed = line.trim();
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
- const match = /^\s*([A-Za-z][A-Za-z0-9]*)=(.*)$/u.exec(line);
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
- const value = match?.[2];
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
- ? /^(?:\/usr\/bin\/env )?(.+?) -lc (.+)$/u.exec(execStarts[0]?.value ?? "")
231
- : null;
232
- if (execStart?.[1] === undefined || execStart[2] === undefined) {
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[1]);
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[2]);
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 (!/^"(?:\\.|[^"])*"$/u.test(rawValue)) {
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 || Object.hasOwn(environment, key)) {
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[key] = assignment?.slice(separator + 1) ?? "";
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: { id: definition.id, shell: shell.value, environment, workingDirectory: workingDirectory ?? null, shellCommand },
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 argumentsMatches = [...definition.contents.matchAll(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/gu)];
281
- const arguments_ = argumentsMatches.length === 1
282
- ? parseXmlStringSequence(argumentsMatches[0]?.[1] ?? "")
283
- : undefined;
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 environmentMatches = [...definition.contents.matchAll(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/gu)];
291
- const environmentKeyCount = [...definition.contents.matchAll(/<key>EnvironmentVariables<\/key>/gu)].length;
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 contentsWithoutEnvironment = environmentMatches[0]?.[0] === undefined
302
- ? definition.contents
303
- : definition.contents.replace(environmentMatches[0][0], "");
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 decoded = parseSystemdEscapedValue(value);
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 systemdUnescape(quoted ? value.slice(1, -1) : value);
466
+ return decodeSystemdEscapes(quoted ? value.slice(1, -1) : value);
349
467
  }
350
- function systemdUnescape(value) {
351
- let result = "";
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
- result += character ?? "";
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
- result += simple;
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 codePoint = Number.parseInt(encoded, 16);
388
- if (codePoint === 0 || codePoint > 0x10ffff)
522
+ const decoded = Number.parseInt(encoded, 16);
523
+ if (decoded === 0 || decoded > 0x10ffff || (decoded >= 0xd800 && decoded <= 0xdfff))
389
524
  return undefined;
390
- result += String.fromCodePoint(codePoint);
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
- return result;
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
- return decodeSystemdSubstitutions(unquoted, true);
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
- function parseXmlStringSequence(contents) {
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
- let cursor = 0;
436
- for (const match of contents.matchAll(/<string>([\s\S]*?)<\/string>/gu)) {
437
- if (contents.slice(cursor, match.index).trim() !== "")
438
- return undefined;
439
- const value = xmlUnescapeStrict(match[1] ?? "");
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 parseXmlStringDictionary(contents) {
448
- const values = {};
449
- let cursor = 0;
450
- for (const match of contents.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)) {
451
- if (contents.slice(cursor, match.index).trim() !== "")
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 key = xmlUnescapeStrict(match[1] ?? "");
454
- const value = xmlUnescapeStrict(match[2] ?? "");
455
- if (key === undefined || value === undefined || Object.hasOwn(values, key))
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
- values[key] = value;
458
- cursor = match.index + match[0].length;
761
+ entries.push([key, item.value]);
459
762
  }
460
- return contents.slice(cursor).trim() === "" ? values : undefined;
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");