@jacebenson/jsn 4.3.13-dev.93.1 → 4.3.13-dev.95.1

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/README.md CHANGED
@@ -78,3 +78,17 @@ npm test
78
78
  ## License
79
79
 
80
80
  MIT
81
+
82
+
83
+ ### Commands for the connected account
84
+
85
+ Login and OAuth refresh check for the current account's `admin` role. Profiles
86
+ without that role show a compact command list focused on tickets, generic
87
+ records, people/groups, documentation, and connection management. If the role
88
+ check is unavailable, the profile also uses compact help; this does not assert
89
+ that the account lacks admin. Existing profiles keep full help until renewed.
90
+
91
+ This applies to root help, root JSON, `discover`, and root shell completion.
92
+ Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
93
+ every registered command. Explicit command paths remain available and ServiceNow
94
+ ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.93.1",
3
+ "version": "4.3.13-dev.95.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -332,6 +332,10 @@ export class LocalCLI {
332
332
  }
333
333
 
334
334
  _help(node, path = []) {
335
+ if (!path.length && this.rootHelpRenderer) {
336
+ process.stdout.write(this.rootHelpRenderer() + "\n");
337
+ return;
338
+ }
335
339
  const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
336
340
  if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
337
341
  if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));
package/src/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { visibleCommandRegistry } from './command-audience.js';
1
2
  // Root CLI using the local yargs-compatible adapter
2
3
 
3
4
  import yargs, { hideBin } from './cli-adapter.js';
@@ -148,6 +149,7 @@ export function buildCLI() {
148
149
  },
149
150
  ])
150
151
  .options({
152
+ 'all-commands': { type: 'boolean', describe: 'Show the full command list, including admin and developer tools' },
151
153
  reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
152
154
 
153
155
  force: {
@@ -432,7 +434,7 @@ export function buildCLI() {
432
434
  .command(skillCmd(wrap))
433
435
  .command(docsCmd(wrap))
434
436
  .command(versionCmd(wrap))
435
- .command(discoverCmd(wrap, () => cliInstance.commandRegistry()))
437
+ .command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
436
438
  .demandCommand(1, 'You must specify a command')
437
439
  // Completion plumbing under a hidden command name, so the explicit
438
440
  // 'completion' command below owns the UX. (#176)
@@ -452,6 +454,12 @@ export function buildCLI() {
452
454
  if (current && !current.startsWith('-') && depth === 0) {
453
455
  out = [...new Set([...out, ...rootCommands])];
454
456
  }
457
+ const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
458
+ const visible = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(options), options);
459
+ if (!depth) {
460
+ const names = new Set(visible.commands.flatMap(command => [command.name, ...command.aliases]));
461
+ out = out.filter(value => value.startsWith('-') || names.has(value));
462
+ }
455
463
  done([...new Set(out)]);
456
464
  });
457
465
  })
@@ -557,7 +565,7 @@ export function buildCLI() {
557
565
  if (typeof formatArgv?.get === 'string' && formatArgv.get) {
558
566
  app.output.setJqFilter(formatArgv.get);
559
567
  }
560
- const { commands } = cliInstance.commandRegistry();
568
+ const { commands } = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv);
561
569
  try {
562
570
  app.ok({ commands }, {
563
571
  summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
@@ -567,7 +575,7 @@ export function buildCLI() {
567
575
  }
568
576
  process.exit(0);
569
577
  }
570
- process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
578
+ process.stdout.write(renderHelp(loadConfig(formatArgv), visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv)) + '\n');
571
579
  process.exit(0);
572
580
  }
573
581
  // Every other failure here (unknown command, missing required argument,
@@ -587,6 +595,12 @@ export function buildCLI() {
587
595
  exitWithError(new AppError(CodeUsage, msg, hint), { app });
588
596
  });
589
597
 
598
+ cliInstance.rootHelpRenderer = () => {
599
+ const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
600
+ const config = loadConfig(options);
601
+ return renderHelp(config, visibleCommandRegistry(cliInstance.commandRegistry(), config, options));
602
+ };
603
+
590
604
  // Snapshot the root command names (plus aliases) for the completion
591
605
  // filter. Done before any parse — command builders mutate the registry.
592
606
  rootCommands = cliInstance.getInternalMethods().getCommandInstance().getCommands();
@@ -0,0 +1,35 @@
1
+ // Presentation policy only. ServiceNow ACLs still authorize every request.
2
+ // Explicit command paths and --all-commands remain available to every account.
3
+ const BASIC_COMMANDS = new Set([
4
+ 'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
5
+ 'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
6
+ 'version', 'docs', 'history', 'discover', 'completion',
7
+ ]);
8
+
9
+ export async function detectCommandAudience(sdk, username, instanceURL = sdk?.baseURL) {
10
+ if (!username || typeof sdk?.listStrict !== 'function') return undefined;
11
+ try {
12
+ const roles = await sdk.listStrict('sys_user_has_role', {
13
+ sysparm_query: 'user=javascript:gs.getUserID()^role.name=admin',
14
+ sysparm_fields: 'sys_id', sysparm_limit: '1',
15
+ }, { touchLastSeen: false });
16
+ return { username, instance_url: instanceURL, mode: roles.length ? 'admin' : 'basic', role_check: 'verified' };
17
+ } catch {
18
+ // Missing role access must not expand help. This is a presentation fallback,
19
+ // not a claim that the role query succeeded or an authorization boundary.
20
+ return { username, instance_url: instanceURL, mode: 'basic', role_check: 'unavailable' };
21
+ }
22
+ }
23
+
24
+ export function commandAudience(config, { instance, allCommands = false } = {}) {
25
+ if (allCommands || instance) return 'all';
26
+ const name = config?.activeProfile || config?.defaultProfile;
27
+ const profile = config?.profiles?.[name];
28
+ const cached = profile?.command_audience;
29
+ return cached?.username === profile?.username && cached?.instance_url === profile?.instance_url && cached?.mode === 'basic' ? 'basic' : 'all';
30
+ }
31
+
32
+ export function visibleCommandRegistry(registry, config, options = {}) {
33
+ if (commandAudience(config, options) !== 'basic') return registry;
34
+ return { ...registry, commands: registry.commands.filter(command => BASIC_COMMANDS.has(command.name)) };
35
+ }
@@ -1,3 +1,4 @@
1
+ import { detectCommandAudience } from '../command-audience.js';
1
2
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
2
3
  import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
3
4
  import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
@@ -710,6 +711,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
710
711
  // profile exists; preserve it when verification is unavailable.
711
712
  username = username || targetUsername;
712
713
 
714
+ const commandAudience = await detectCommandAudience(app.sdk, username, instanceURL);
715
+
713
716
  // Save to profiles — deduplicate by instance URL
714
717
  if (!app.config.profiles) {
715
718
  app.config.profiles = {};
@@ -740,7 +743,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
740
743
  read_only: preferences.read_only,
741
744
  skip_confirmations: preferences.skip_confirmations,
742
745
  include_counts: preferences.include_counts,
746
+ ...(commandAudience ? { command_audience: commandAudience } : {}),
743
747
  };
748
+ if (!commandAudience) delete app.config.profiles[profileName].command_audience;
744
749
  if (!targetProfile && interactive) {
745
750
  try {
746
751
  const domains = await import('./domains.js');
@@ -1050,12 +1055,22 @@ Examples:
1050
1055
  const name = target.name || app.config.activeProfile || app.config.defaultProfile;
1051
1056
  if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
1052
1057
  app.config.profiles[name].domain_separation = hasDS;
1058
+
1053
1059
  await saveConfig(app.config);
1054
1060
  }
1055
1061
  } catch {
1056
1062
  // Non-fatal — capability probe is best-effort
1057
1063
  }
1058
1064
 
1065
+ const audienceName = target.name || app.config.activeProfile || app.config.defaultProfile;
1066
+ const audienceProfile = app.config.profiles?.[audienceName];
1067
+ if (audienceProfile?.instance_url === instanceURL) {
1068
+ const sdk = app.getSDKForProfile
1069
+ ? app.getSDKForProfile(instanceURL, { ...targetAuthOptions, profile: audienceProfile }) : app.sdk;
1070
+ audienceProfile.command_audience = await detectCommandAudience(sdk, audienceProfile.username, instanceURL);
1071
+ await saveConfig(app.config);
1072
+ }
1073
+
1059
1074
  app.ok({
1060
1075
  refreshed: true,
1061
1076
  instance: instanceURL,
@@ -35,7 +35,7 @@ export function discoverCmd(wrap, getRegistry) {
35
35
  'Use --json for structured results or --json --get data.commands for only matches.',
36
36
  ].join('\n')),
37
37
  handler: wrap(async (argv, app) => {
38
- const commands = discoverCommands(getRegistry(), { query: argv.query, table: argv.table });
38
+ const commands = discoverCommands(getRegistry(argv), { query: argv.query, table: argv.table });
39
39
  const lines = commands.flatMap(command => [
40
40
  `${command.name}: ${command.description}`,
41
41
  ...(command.discovery ? [
@@ -1,8 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
- import { input, password as passwordPrompt, select } from '../prompts.js';
4
+ import { input, password as passwordPrompt, select, confirm } from '../prompts.js';
5
5
  import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
6
+ import { AppError, CodeConfirmationRequired } from '../errors.js';
7
+ import { prepareRemoteMidServer, installRemoteMidServer, startRemoteMidServer, stopRemoteMidServer, logsRemoteMidServer, followRemoteMidServer, removeRemoteMidServer } from '../midserver-remote.js';
8
+ import { prepareWindowsRemoteMidServer, installWindowsRemoteMidServer, startWindowsRemoteMidServer, stopWindowsRemoteMidServer, logsWindowsRemoteMidServer, removeWindowsRemoteMidServer } from '../midserver-windows-remote.js';
9
+ import { prepareLinuxRemoteMidServer, installLinuxRemoteMidServer, startLinuxRemoteMidServer, stopLinuxRemoteMidServer, logsLinuxRemoteMidServer, removeLinuxRemoteMidServer } from '../midserver-linux-remote.js';
10
+ import { downloadRemoteMidRecipe } from '../midserver-remote-recipe.js';
6
11
  import { FormatAuto } from '../output.js';
7
12
  import { declareCapabilities } from '../capabilities.js';
8
13
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
@@ -15,7 +20,7 @@ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'ver
15
20
  const DEFAULT_TIMEOUT = 120000;
16
21
  const DEFAULT_INTERVAL = 2000;
17
22
 
18
- declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
23
+ declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop', 'validate'] });
19
24
 
20
25
  function agentName(record) {
21
26
  const name = getStringField(record, 'name');
@@ -274,6 +279,13 @@ export function assertMidServerReady(record) {
274
279
  }
275
280
 
276
281
  async function promptMidCredentials(app, argv) {
282
+ if (argv.passwordStdin) {
283
+ if (!argv.username) throw new Error('--password-stdin requires --username');
284
+ const password = fs.readFileSync(0, 'utf8').replace(/\r?\n$/, '');
285
+ if (!password || Buffer.byteLength(password) > 65536 || password.includes('\0')) throw new Error('Invalid password input');
286
+ const validation = await validateMidCredentials(app.getEffectiveInstance(), argv.username, password);
287
+ return { username: argv.username, password, releaseName: validation.releaseName };
288
+ }
277
289
  if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
278
290
  let username = argv.username || '';
279
291
  while (true) {
@@ -344,7 +356,31 @@ function removeLocalMidServer(instanceURL, name, directory) {
344
356
  return true;
345
357
  }
346
358
 
359
+ function remoteDirectory(app, argv, name) {
360
+ const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
361
+ const file = ['.jsn-mid-windows-remote.json', '.jsn-mid-linux-remote.json', '.jsn-mid-remote.json'].find(name => fs.existsSync(path.join(directory, name)));
362
+ if (!file) throw new Error('No owned remote MID metadata found');
363
+ const metadata = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
364
+ if (metadata.target !== argv.ssh || metadata.instanceURL !== app.getEffectiveInstance().replace(/\/$/, '') || metadata.name !== name) {
365
+ throw new Error('Remote MID target, instance or name does not match owned local metadata');
366
+ }
367
+ return directory;
368
+ }
369
+
370
+ function nativeRemote(directory) {
371
+ if (fs.existsSync(path.join(directory, '.jsn-mid-windows-remote.json'))) return { start: startWindowsRemoteMidServer, stop: stopWindowsRemoteMidServer, logs: logsWindowsRemoteMidServer, remove: removeWindowsRemoteMidServer };
372
+ if (fs.existsSync(path.join(directory, '.jsn-mid-linux-remote.json'))) return { start: startLinuxRemoteMidServer, stop: stopLinuxRemoteMidServer, logs: logsLinuxRemoteMidServer, remove: removeLinuxRemoteMidServer };
373
+ return null;
374
+ }
375
+
347
376
  async function showMidServerLogs(app, identifier, argv) {
377
+ if (argv.ssh) {
378
+ const directory = remoteDirectory(app, argv, identifier);
379
+ if (argv.follow && nativeRemote(directory)) throw new Error('Remote native logs currently support bounded --tail only');
380
+ if (argv.follow) return followRemoteMidServer({ directory, tail: argv.tail });
381
+ const text = (nativeRemote(directory)?.logs || logsRemoteMidServer)({ directory, tail: argv.tail });
382
+ return app.ok({ target: argv.ssh, text, _formatted: text }, { summary: 'Remote MID logs' });
383
+ }
348
384
  const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
349
385
  const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
350
386
  if (direct) {
@@ -420,7 +456,8 @@ export function midserversCmd(wrap) {
420
456
  builder: (b) => b
421
457
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
422
458
  .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
423
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
459
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
460
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
424
461
  handler: wrap(async (argv, app) => {
425
462
  await showMidServerLogs(app, argv.identifier, argv);
426
463
  }),
@@ -431,16 +468,19 @@ export function midserversCmd(wrap) {
431
468
  builder: (b) => b
432
469
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
433
470
  .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
434
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
471
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
472
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
435
473
  handler: wrap(async (argv, app) => {
436
474
  await showMidServerLogs(app, argv.identifier, argv);
437
475
  }),
438
476
  })
439
477
  .command({
440
478
  command: 'start <identifier>',
441
- describe: 'Start an owned direct MID (Windows: foreground console, no service)',
442
- builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
479
+ describe: 'Start an owned MID locally or over SSH (remote Windows uses a service)',
480
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
481
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
443
482
  handler: wrap(async (argv, app) => {
483
+ if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.start || startRemoteMidServer)({ directory }), { summary: 'Remote MID start command completed' }); }
444
484
  const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
445
485
  if (!metadata) throw new Error('No owned direct MID installation found');
446
486
  if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
@@ -450,9 +490,11 @@ export function midserversCmd(wrap) {
450
490
  })
451
491
  .command({
452
492
  command: 'stop <identifier>',
453
- describe: 'Stop an owned Linux direct MID; Windows console uses Ctrl+C',
454
- builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
493
+ describe: 'Stop an owned MID locally or over SSH (local Windows console uses Ctrl+C)',
494
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
495
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
455
496
  handler: wrap(async (argv, app) => {
497
+ if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.stop || stopRemoteMidServer)({ directory }), { summary: 'Remote MID stop command completed' }); }
456
498
  const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
457
499
  if (!metadata) throw new Error('No owned direct MID installation found');
458
500
  app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
@@ -463,13 +505,45 @@ export function midserversCmd(wrap) {
463
505
  describe: 'Install and verify a MID Server',
464
506
  builder: (b) => b
465
507
  .option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
466
- .option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
508
+ .option('username', { type: 'string', describe: 'MID Server username' })
509
+ .option('password-stdin', { type: 'boolean', describe: 'Read MID password from stdin instead of a terminal prompt' })
510
+ .option('ssh', { type: 'string', describe: 'Install MID on a configured SSH target (Docker, native Windows service or Linux background process)' })
511
+ .option('native-platform', { type: 'string', choices: ['windows', 'linux'], default: 'windows', describe: 'Remote direct ZIP platform (Linux uses a user background process and needs Python 3)' })
467
512
  .option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
468
513
  .option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
469
514
  .option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
470
515
  .option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
471
516
  handler: wrap(async (argv, app) => {
472
517
  app.requireInstance();
518
+ if (argv.ssh) {
519
+ if (!argv.name) throw new Error('Remote installation requires --name');
520
+ const name = normalizeMidName(argv.name);
521
+ const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${name}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }));
522
+ if (existing.length) throw new Error('MID name already exists; refusing to adopt an existing MID');
523
+ const credentials = await promptMidCredentials(app, argv);
524
+ const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
525
+ fs.mkdirSync(path.dirname(directory), { recursive: true, mode: 0o700 });
526
+ const native = (argv.installMethod || argv['install-method']) === 'direct';
527
+ let installation;
528
+ if (native) {
529
+ const linux = argv.nativePlatform === 'linux';
530
+ const prepare = linux ? prepareLinuxRemoteMidServer : prepareWindowsRemoteMidServer;
531
+ const install = linux ? installLinuxRemoteMidServer : installWindowsRemoteMidServer;
532
+ const start = linux ? startLinuxRemoteMidServer : startWindowsRemoteMidServer;
533
+ prepare({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
534
+ install({ directory, releaseName: credentials.releaseName, username: credentials.username, password: credentials.password });
535
+ installation = start({ directory });
536
+ } else {
537
+ prepareRemoteMidServer({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
538
+ const context = await downloadRemoteMidRecipe(credentials.releaseName);
539
+ installation = installRemoteMidServer({ directory, context, username: credentials.username, password: credentials.password });
540
+ }
541
+ const kind = native ? (argv.nativePlatform === 'linux' ? 'remote-linux-background' : 'remote-windows-service') : 'remote-docker';
542
+ let record;
543
+ try { record = await waitForMidServerUp(app, name, Math.max(1, argv.waitTimeout), Math.max(500, argv.waitInterval)); }
544
+ catch { return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'pending' }, { summary: 'Remote MID started; registration is pending. Runtime and ownership metadata retained for logs or explicit teardown.' }); }
545
+ return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'up', validated: queueValue(record.validated) === 'true', record, runtime_state: installation.state }, { summary: 'Remote MID is Up; validation is a separate explicit operation' });
546
+ }
473
547
  const credentials = await promptMidCredentials(app, argv);
474
548
  const name = argv.name || (await input({
475
549
  message: 'MID Server name:',
@@ -580,8 +654,19 @@ export function midserversCmd(wrap) {
580
654
  aliases: ['delete'],
581
655
  describe: 'Remove a MID Server record (interactive when no identifier is given)',
582
656
  builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
583
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
657
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
658
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
584
659
  handler: wrap(async (argv, app) => {
660
+ if (argv.ssh) {
661
+ if (!argv.identifier) throw new Error('Remote removal requires the owned MID name');
662
+ const directory = remoteDirectory(app, argv, argv.identifier);
663
+ await confirmDelete(app, argv, `Remove owned MID '${argv.identifier}' from SSH target '${argv.ssh}' and delete its MID record`);
664
+ const removed = (nativeRemote(directory)?.remove || removeRemoteMidServer)({ directory });
665
+ const records = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${argv.identifier}`, sysparm_limit: '2', sysparm_fields: 'sys_id,name' }));
666
+ if (records.length > 1) throw new Error('Remote runtime removed; multiple MID records match. Inspect records before deleting any.');
667
+ if (records.length) await app.sdk.delete(AGENT_TABLE, unwrapSysId(records[0]));
668
+ return app.ok({ name: argv.identifier, target: argv.ssh, remote_runtime_removed: removed.state === 'removed', record_deleted: records.length === 1, metadata_directory: directory }, { summary: 'Owned remote MID runtime removed; nonsecret ownership receipt retained' });
669
+ }
585
670
  let record;
586
671
  if (argv.identifier) {
587
672
  try {
@@ -621,6 +706,28 @@ export function midserversCmd(wrap) {
621
706
  app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved && !direct, local_installation_removed: localRemoved, local_installation_type: direct ? 'direct' : 'docker' }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its owned local runtime' : ''}` });
622
707
  }),
623
708
  })
709
+ .command({
710
+ command: 'validate <identifier>',
711
+ describe: 'Explicitly trust a MID through the platform Validate action; never writes the validated flag directly',
712
+ builder: b => b.option('timeout', { type: 'number', default: 300000, describe: 'Validation verification timeout in milliseconds' }),
713
+ handler: wrap(async (argv, app) => {
714
+ const record = await resolveMidServer(app.sdk, argv.identifier);
715
+ assertMidServerUp(record);
716
+ if (!argv.force) {
717
+ if (!canPrompt() || app.output.getFormat() !== FormatAuto) throw new AppError(CodeConfirmationRequired, 'MID trust validation requires approval.', 'After approval, rerun with --force.');
718
+ if (!await confirm({ message: `Trust MID '${getStringField(record, 'name')}' using the platform Validate action?`, default: false })) return;
719
+ }
720
+ const cookies = await app.sdk._warmSession(), csrf = await app.sdk._getScriptsPageCSRF(cookies);
721
+ await app.sdk.rawRequest(`${app.sdk.baseURL}/xmlhttp.do`, { method: 'POST', headers: { Cookie: cookies, 'X-UserToken': csrf }, body: new URLSearchParams({ sysparm_processor: 'MIDServerAjax', sysparm_name: 'validateMID', sysparm_agent: unwrapSysId(record), sysparm_ck: csrf }).toString() });
722
+ const started = Date.now();
723
+ while (Date.now() - started < argv.timeout) {
724
+ const current = await resolveMidServer(app.sdk, argv.identifier);
725
+ if (queueValue(current.validated) === 'true') return app.ok({ name: getStringField(current, 'name'), validated: true }, { summary: 'MID trust validation verified' });
726
+ await new Promise(resolve => setTimeout(resolve, 2000));
727
+ }
728
+ throw new Error('MID validation was requested but not verified; inspect MID issues before retrying');
729
+ }),
730
+ })
624
731
  .command({
625
732
  command: 'execute [identifier]',
626
733
  describe: 'Execute a shell command through a MID Server',
package/src/help.js CHANGED
@@ -203,6 +203,8 @@ function renderFlags(options) {
203
203
  export function renderTips() {
204
204
  return `
205
205
  TIPS
206
+ Show every command, including admin and developer tools: "jsn --all-commands --help"
207
+ Command visibility follows the selected profile at authentication; explicit commands still use ServiceNow ACLs.
206
208
  Find commands by task words: "jsn discover catalog"; by configured table: "jsn discover --table incident"
207
209
  Cross-table task searches and custom tables: "jsn records list --help"
208
210
  Specialized commands offer configured columns, lookup, and table-specific views; inspect discovery results for details
@@ -0,0 +1,210 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { validateRemoteTarget } from './midserver-remote.js';
7
+ import { buildMidZipUrl } from './midserver-direct.js';
8
+
9
+ const FILE = '.jsn-mid-linux-remote.json';
10
+ // Static program only in argv; all deployment data and credentials travel on stdin.
11
+ const PROGRAM = String.raw`
12
+ import os,sys,json,stat,platform,shutil,subprocess,time,zipfile,urllib.request,hashlib,re,xml.etree.ElementTree as ET
13
+ p=json.load(sys.stdin)
14
+ def fail(): raise RuntimeError('Remote native MID safety check failed')
15
+ def result(v): print('JSN_RESULT:'+json.dumps(v))
16
+ def private(d):
17
+ s=os.lstat(d)
18
+ if not stat.S_ISDIR(s.st_mode) or s.st_uid!=os.getuid() or s.st_mode&0o077: fail()
19
+ def ancestors(d):
20
+ while d!='/':
21
+ s=os.lstat(d)
22
+ if not stat.S_ISDIR(s.st_mode) or (s.st_mode&0o022 and not s.st_mode&stat.S_ISVTX): fail()
23
+ d=os.path.dirname(d)
24
+ def owned():
25
+ private(p['remoteDirectory']); ancestors(p['remoteDirectory'])
26
+ f=os.path.join(p['remoteDirectory'],'.jsn-owner.json'); s=os.lstat(f)
27
+ if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_mode&0o077: fail()
28
+ m=json.load(open(f))
29
+ if any(m.get(k)!=p.get(k) for k in ['owner','instanceURL','name','remoteDirectory']): fail()
30
+ def processes():
31
+ found=[]; agent=os.path.join(p['remoteDirectory'],'agent')
32
+ for entry in os.listdir('/proc'):
33
+ if not entry.isdigit(): continue
34
+ try:
35
+ proc='/proc/'+entry
36
+ if os.stat(proc).st_uid!=os.getuid(): continue
37
+ exe=os.readlink(proc+'/exe'); cwd=os.readlink(proc+'/cwd')
38
+ if exe in [agent+'/bin/wrapper-linux-x86-64',agent+'/jre/bin/java'] and cwd==agent: found.append(int(entry))
39
+ except (FileNotFoundError,PermissionError,ProcessLookupError): continue
40
+ return found
41
+ def safe_tree():
42
+ for root,dirs,files in os.walk(p['remoteDirectory'],followlinks=False):
43
+ for name in dirs+files:
44
+ s=os.lstat(os.path.join(root,name))
45
+ if not (stat.S_ISREG(s.st_mode) or stat.S_ISDIR(s.st_mode)) or s.st_uid!=os.getuid(): fail()
46
+ def launcher(action):
47
+ owned(); safe_tree()
48
+ if action=='stop':
49
+ pidfile=os.path.join(p['remoteDirectory'],'agent','work','jsn_mid_'+p['owner'].replace('-','')+'.pid')
50
+ s=os.lstat(pidfile)
51
+ if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_size>64: fail()
52
+ value=open(pidfile).read().strip()
53
+ if not re.fullmatch('[1-9][0-9]*',value) or int(value) not in processes(): fail()
54
+ subprocess.run(['sh','bin/mid.sh',action],cwd=os.path.join(p['remoteDirectory'],'agent'),stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=90,start_new_session=True)
55
+ if platform.system()!='Linux' or platform.machine() not in ['x86_64','amd64'] or os.getuid()==0: fail()
56
+ action=p['action']; d=p.get('remoteDirectory')
57
+ if action=='prepare':
58
+ home=os.path.expanduser('~'); ancestors(home)
59
+ if os.lstat(home).st_uid!=os.getuid(): fail()
60
+ d=os.path.join(home,'.jsn-mid',p['owner'])
61
+ if os.path.lexists(d): fail()
62
+ result(dict(remoteDirectory=d,platform='linux-x64',elevated=False,requiresElevation=False,runtime='user-background',freeBytes=shutil.disk_usage(home).free,python=platform.python_version()))
63
+ elif action=='install':
64
+ if os.path.lexists(d): fail()
65
+ parent=os.path.dirname(d)
66
+ if not os.path.exists(parent): os.mkdir(parent,0o700)
67
+ private(parent); ancestors(parent)
68
+ if shutil.disk_usage(parent).free<3*1024**3: fail()
69
+ os.mkdir(d,0o700); os.umask(0o077)
70
+ marker={k:p[k] for k in ['owner','instanceURL','name','remoteDirectory']}
71
+ with open(os.path.join(d,'.jsn-owner.json'),'x') as f: json.dump(marker,f)
72
+ archive=os.path.join(d,'.download.zip')
73
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
74
+ def redirect_request(self,*args): fail()
75
+ opener=urllib.request.build_opener(NoRedirect)
76
+ total=0; digest=hashlib.sha256()
77
+ with opener.open(p['archiveURL'],timeout=600) as source,open(archive,'xb') as dest:
78
+ while True:
79
+ chunk=source.read(1024*1024)
80
+ if not chunk: break
81
+ total+=len(chunk)
82
+ if total>1024**3: fail()
83
+ digest.update(chunk); dest.write(chunk)
84
+ with zipfile.ZipFile(archive) as z:
85
+ entries=z.infolist(); seen=set(); expanded=0
86
+ if len(entries)>20000: fail()
87
+ for e in entries:
88
+ n=e.filename; parts=n.rstrip('/').split('/'); mode=e.external_attr>>16; kind=stat.S_IFMT(mode)
89
+ if not parts or parts[0] not in ['agent','META-INF'] or '\\' in n or any(x in ['','.','..'] or any(ord(c)<32 for c in x) for x in parts) or kind not in [0,stat.S_IFDIR,stat.S_IFREG]: fail()
90
+ if n.rstrip('/') in seen: fail()
91
+ seen.add(n.rstrip('/')); expanded+=e.file_size
92
+ if expanded>2*1024**3: fail()
93
+ for e in entries:
94
+ dest=os.path.join(d,e.filename)
95
+ if e.is_dir(): os.makedirs(dest,mode=0o700,exist_ok=True); continue
96
+ os.makedirs(os.path.dirname(dest),mode=0o700,exist_ok=True)
97
+ with z.open(e) as source,open(dest,'xb') as target: shutil.copyfileobj(source,target,1024*1024)
98
+ os.chmod(dest,0o700 if (e.external_attr>>16)&0o111 else 0o600)
99
+ agent=os.path.join(d,'agent')
100
+ for name in ['config.xml','conf/wrapper.conf','conf/wrapper-override.conf','bin/mid.sh','bin/wrapper-linux-x86-64','jre/bin/java']:
101
+ if not os.path.isfile(os.path.join(agent,name)): fail()
102
+ wrapper=open(os.path.join(agent,'conf/wrapper.conf')).read()
103
+ script=open(os.path.join(agent,'bin/mid.sh')).read()
104
+ if 'wrapper.java.command=jre/bin/java' not in wrapper or 'wrapper-override.conf' not in wrapper or 'SERVICE_MANAGEMENT_TOOL' not in script: fail()
105
+ config=os.path.join(agent,'config.xml'); tree=ET.parse(config,parser=ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))); root=tree.getroot()
106
+ if root.tag!='parameters': fail()
107
+ for key,value in {'url':p['instanceURL']+'/','name':p['name'],'mid.instance.username':p['username'],'mid.instance.password':p['password']}.items():
108
+ nodes=[e for e in root.findall('parameter') if e.get('name')==key]
109
+ if len(nodes)>1: fail()
110
+ e=nodes[0] if nodes else ET.SubElement(root,'parameter',{'name':key}); e.set('value',value)
111
+ if key=='mid.instance.password': e.set('secure','true')
112
+ tree.write(config,encoding='utf-8',xml_declaration=True)
113
+ token='jsn_mid_'+p['owner'].replace('-','')
114
+ with open(os.path.join(agent,'conf/wrapper-override.conf'),'a') as f: f.write('\nwrapper.name='+token+'\nwrapper.displayname=ServiceNow MID '+token+'\n')
115
+ with open(os.path.join(agent,'bin/mid.shconf_override'),'a') as f: f.write('\nAPP_NAME="'+token+'"\nAPP_LONG_NAME="ServiceNow MID '+token+'"\nSERVICE_MANAGEMENT_TOOL=initd\n')
116
+ os.unlink(archive); result(dict(installed=True,archiveSha256=digest.hexdigest(),status='stopped'))
117
+ else:
118
+ owned()
119
+ if action=='start':
120
+ if processes(): fail()
121
+ launcher('start'); time.sleep(2)
122
+ if not processes(): fail()
123
+ elif action=='stop':
124
+ if processes(): launcher('stop')
125
+ for attempt in range(60):
126
+ if not processes(): break
127
+ time.sleep(1)
128
+ if processes(): fail()
129
+ elif action=='remove':
130
+ if processes(): fail()
131
+ safe_tree(); shutil.rmtree(d); result(dict(removed=True)); sys.exit(0)
132
+ elif action=='logs':
133
+ safe_tree()
134
+ directory=os.path.join(d,'agent','logs'); candidates=[]
135
+ if os.path.isdir(directory):
136
+ for n in os.listdir(directory):
137
+ if re.fullmatch(r'agent0\.log(?:\.[0-9]+)?',n) or n=='wrapper.log':
138
+ f=os.path.join(directory,n); s=os.lstat(f)
139
+ if stat.S_ISREG(s.st_mode) and s.st_uid==os.getuid(): candidates.append((n!='wrapper.log',s.st_mtime,f))
140
+ text=''
141
+ if candidates:
142
+ with open(max(candidates)[2],'rb') as f:
143
+ f.seek(max(0,os.fstat(f.fileno()).st_size-1024*1024)); text=f.read().decode('utf-8','replace')
144
+ result(dict(logs='\n'.join(text.splitlines()[-p['tail']:]))); sys.exit(0)
145
+ elif action!='inspect': fail()
146
+ ids=processes(); result(dict(status='running' if ids else 'stopped',pids=ids,runtime='user-background',requiresElevation=False))
147
+ `;
148
+
149
+ function remote(target, payload, { run = execFileSync, timeout = 30000 } = {}) {
150
+ validateRemoteTarget(target);
151
+ const encoded = Buffer.from(PROGRAM).toString('base64');
152
+ const command = `python3 -c 'import base64;exec(base64.b64decode("${encoded}"))'`;
153
+ let output;
154
+ try { output = String(run('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--', target, command], { input: JSON.stringify(payload), timeout, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] })); }
155
+ catch { throw new Error('Remote Linux MID operation failed; Python 3 and a non-root Linux x64 account are required. Inspect owned installation before retrying.'); }
156
+ const line = output.split(/\r?\n/).findLast(value => value.startsWith('JSN_RESULT:'));
157
+ try { if (!line) throw new Error(); return JSON.parse(line.slice(11)); }
158
+ catch { throw new Error('Remote Linux MID returned no verified result'); }
159
+ }
160
+ function save(directory, m) { fs.writeFileSync(path.join(directory, FILE), JSON.stringify(m, null, 2), { mode: 0o600 }); fs.chmodSync(path.join(directory, FILE), 0o600); }
161
+ function load(directory) {
162
+ const dir = fs.lstatSync(directory), file = fs.lstatSync(path.join(directory, FILE));
163
+ if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Linux MID metadata must be private');
164
+ const m = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8')); validateRemoteTarget(m.target);
165
+ if (m.version !== 1 || !/^[a-f0-9-]{36}$/.test(m.owner) || !m.remoteDirectory?.startsWith('/') || m.remoteDirectory !== path.posix.normalize(m.remoteDirectory) || !m.remoteDirectory.endsWith(`/.jsn-mid/${m.owner}`)) throw new Error('Invalid Linux MID ownership metadata');
166
+ return m;
167
+ }
168
+ export function prepareLinuxRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
169
+ if (platform === 'win32') throw new Error('A POSIX controller is required for private local metadata');
170
+ validateRemoteTarget(target);
171
+ const url = new URL(instanceURL);
172
+ if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Valid HTTPS origin and MID name required');
173
+ if (fs.existsSync(directory)) throw new Error('Local deployment directory exists; no adoption');
174
+ const owner = randomUUID();
175
+ const report = remote(target, { action: 'prepare', owner }, options);
176
+ if (report.platform !== 'linux-x64' || report.requiresElevation !== false || report.freeBytes < 3 * 1024 ** 3 || !report.remoteDirectory?.endsWith(`/.jsn-mid/${owner}`)) throw new Error('Linux preflight failed');
177
+ const m = { version: 1, target, owner, instanceURL: url.origin, name, ...report, state: 'prepared' };
178
+ fs.mkdirSync(directory, { mode: 0o700 }); fs.chmodSync(directory, 0o700); save(directory, m); return m;
179
+ }
180
+ export function installLinuxRemoteMidServer({ directory, releaseName, username, password, ...options }) {
181
+ const m = load(directory);
182
+ if (m.state !== 'prepared' || !username || !password || [...String(username) + String(password)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Fresh deployment and valid credentials required');
183
+ const archiveURL = buildMidZipUrl(releaseName, 'linux');
184
+ m.state = 'installing'; save(directory, m);
185
+ try {
186
+ const result = remote(m.target, { ...m, action: 'install', archiveURL, username, password }, { ...options, timeout: 1200000 });
187
+ if (!result.installed || !/^[a-f0-9]{64}$/i.test(result.archiveSha256 || '')) throw new Error('Installation unverified');
188
+ Object.assign(m, { state: 'installed', releaseName, archiveSha256: result.archiveSha256 }); save(directory, m); return m;
189
+ } catch (error) { m.state = 'uncertain'; save(directory, m); throw error; }
190
+ }
191
+ function operation(action, { directory, ...options }) {
192
+ const m = load(directory);
193
+ try {
194
+ const result = remote(m.target, { ...m, action }, { ...options, timeout: action === 'start' || action === 'stop' ? 180000 : 30000 });
195
+ if ((action === 'start' && result.status !== 'running') || (action === 'stop' && result.status !== 'stopped')) throw new Error('Runtime state unverified');
196
+ if (action === 'remove' && result.removed !== true) throw new Error('Removal unverified');
197
+ m.state = action === 'remove' ? 'removed' : result.status; save(directory, m); return { ...m, ...result };
198
+ } catch (error) { m.state = 'uncertain'; save(directory, m); throw error; }
199
+ }
200
+ export const inspectLinuxRemoteMidServer = options => operation('inspect', options);
201
+ export const startLinuxRemoteMidServer = options => operation('start', options);
202
+ export const stopLinuxRemoteMidServer = options => operation('stop', options);
203
+ export function removeLinuxRemoteMidServer(options) {
204
+ stopLinuxRemoteMidServer(options);
205
+ return operation('remove', options);
206
+ }
207
+ export function logsLinuxRemoteMidServer({ directory, tail = 100, ...options }) {
208
+ if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be 1–10000');
209
+ const m = load(directory); return remote(m.target, { ...m, action: 'logs', tail }, options).logs;
210
+ }
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { buildMidRecipeUrl } from './midserver-install.js';
5
+ import { inspectMidZip, extractMidZip } from './midserver-zip.js';
6
+
7
+ const LIMIT = 64 * 1024 * 1024;
8
+ export function recipeTar(directory) {
9
+ const chunks = [];
10
+ let total = 0;
11
+ function add(folder, prefix = '') {
12
+ for (const name of fs.readdirSync(folder).sort()) {
13
+ const filename = path.join(folder, name), relative = prefix + name, stat = fs.lstatSync(filename);
14
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw new Error('Recipe contains a link or special file');
15
+ if (stat.isDirectory()) { add(filename, `${relative}/`); continue; }
16
+ const split = relative.lastIndexOf('/');
17
+ const leaf = Buffer.byteLength(relative) <= 100 ? relative : relative.slice(split + 1);
18
+ const parent = Buffer.byteLength(relative) <= 100 ? '' : relative.slice(0, split);
19
+ if (Buffer.byteLength(leaf) > 100 || Buffer.byteLength(parent) > 155 || split < 0 && Buffer.byteLength(relative) > 100) throw new Error('Recipe path exceeds tar limits');
20
+ total += stat.size; if (total > LIMIT) throw new Error('MID recipe exceeds 64 MiB');
21
+ const header = Buffer.alloc(512);
22
+ const write = (value, offset, length) => header.write(String(value), offset, length, 'utf8');
23
+ const octal = (value, offset, length) => write(value.toString(8).padStart(length - 1, '0') + '\0', offset, length);
24
+ write(leaf, 0, 100); octal(stat.mode & 0o111 ? 0o755 : 0o644, 100, 8); octal(0, 108, 8); octal(0, 116, 8);
25
+ octal(stat.size, 124, 12); octal(0, 136, 12); header.fill(32, 148, 156); write('0', 156, 1); write('ustar\0', 257, 6); write('00', 263, 2); write(parent, 345, 155);
26
+ const checksum = header.reduce((sum, byte) => sum + byte, 0); write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8);
27
+ chunks.push(header, fs.readFileSync(filename), Buffer.alloc((512 - stat.size % 512) % 512));
28
+ }
29
+ }
30
+ add(directory); chunks.push(Buffer.alloc(1024)); return Buffer.concat(chunks);
31
+ }
32
+
33
+ export async function downloadRemoteMidRecipe(releaseName, { fetchImpl = fetch } = {}) {
34
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'jsn-mid-recipe-'));
35
+ try {
36
+ const response = await fetchImpl(buildMidRecipeUrl(releaseName), { redirect: 'error', signal: AbortSignal.timeout(300000) });
37
+ if (!response.ok || !response.body) throw new Error(`MID recipe download failed (HTTP ${response.status})`);
38
+ const archive = path.join(directory, 'recipe.zip'), fd = fs.openSync(archive, 'wx', 0o600); let bytes = 0;
39
+ try { for await (const chunk of response.body) { bytes += chunk.length; if (bytes > LIMIT) throw new Error('MID recipe download exceeds 64 MiB'); fs.writeSync(fd, chunk); } } finally { fs.closeSync(fd); }
40
+ const entries = inspectMidZip(archive, { allowedRoots: null });
41
+ if (entries.reduce((sum, entry) => sum + entry.uncompressedSize, 0) > LIMIT) throw new Error('Expanded MID recipe exceeds 64 MiB');
42
+ const recipe = path.join(directory, 'recipe'); await extractMidZip(archive, recipe, entries);
43
+ // Distribution archives can wrap the Dockerfile in one enclosing directory.
44
+ const candidates = [];
45
+ function find(folder) { for (const name of fs.readdirSync(folder)) { const file = path.join(folder, name); if (fs.statSync(file).isDirectory()) find(file); else if (name === 'Dockerfile') candidates.push(path.dirname(file)); } }
46
+ find(recipe); if (candidates.length !== 1) throw new Error('Official recipe must contain exactly one Dockerfile');
47
+ return recipeTar(candidates[0]);
48
+ } finally { fs.rmSync(directory, { recursive: true, force: true }); }
49
+ }
@@ -0,0 +1,221 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
5
+ import process from 'node:process';
6
+
7
+ const FILE = '.jsn-mid-remote.json';
8
+ const PREFIX = 'io.jsn.mid';
9
+ const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--'];
10
+
11
+ export function validateRemoteTarget(target) {
12
+ if (typeof target !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/.test(target)) throw new Error('Use a configured SSH alias containing only letters, numbers, dots, underscores or dashes');
13
+ return target;
14
+ }
15
+
16
+ function defaultRun(command, args, options) {
17
+ const { mergeStderr, ...settings } = options;
18
+ const execution = { ...settings, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 4 * 1024 * 1024 };
19
+ if (!mergeStderr) return execFileSync(command, args, execution);
20
+ const result = spawnSync(command, args, execution);
21
+ if (result.error || result.status !== 0) throw new Error('Remote log snapshot failed');
22
+ return `${result.stdout || ''}${result.stderr || ''}`;
23
+ }
24
+
25
+ function sshArgs(target, args) {
26
+ validateRemoteTarget(target);
27
+ // OpenSSH joins the command for the remote shell. Every argument is constrained,
28
+ // including format expressions; secrets and manifests travel only on stdin.
29
+ if (args.some(arg => typeof arg !== 'string' || !/^[A-Za-z0-9_./:{}=,@+-]+$/.test(arg))) throw new Error('Unsafe remote Docker argument');
30
+ return [...SSH_OPTIONS, target, `docker ${args.join(' ')}`];
31
+ }
32
+
33
+ function docker(target, args, { run = defaultRun, input, timeout = 30000, signal, mergeStderr = false } = {}) {
34
+ const commandArgs = sshArgs(target, args);
35
+ try { return String(run('ssh', commandArgs, { input, timeout, signal, mergeStderr }) || '').trim(); }
36
+ catch { throw new Error(`Remote Docker ${args[0]} failed; inspect the selected target and retained local metadata`); }
37
+ }
38
+
39
+ function probe(target, options) {
40
+ const platform = docker(target, ['info', '--format', '{{.OSType}}/{{.Architecture}}'], options);
41
+ if (!/^linux\/(amd64|x86_64)$/.test(platform)) throw new Error('Remote MID containers require a Linux amd64 Docker engine');
42
+ docker(target, ['compose', 'version'], options);
43
+ const engineId = docker(target, ['info', '--format', '{{.ID}}'], options);
44
+ if (!engineId) throw new Error('Remote Docker did not report an engine ID');
45
+ return engineId;
46
+ }
47
+
48
+ function save(directory, metadata) {
49
+ fs.writeFileSync(path.join(directory, FILE), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
50
+ fs.chmodSync(path.join(directory, FILE), 0o600);
51
+ }
52
+
53
+ function load(directory) {
54
+ const dir = fs.lstatSync(directory);
55
+ const file = fs.lstatSync(path.join(directory, FILE));
56
+ if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Remote MID metadata directory and file must be private and must not be symlinks');
57
+ const metadata = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8'));
58
+ validateRemoteTarget(metadata.target);
59
+ if (metadata.version !== 1 || !/^[a-f0-9-]{36}$/.test(metadata.owner)) throw new Error('Invalid remote MID ownership metadata');
60
+ for (const key of ['project', 'container', 'volume', 'image', ...(metadata.network ? ['network'] : [])]) if (!/^[a-z0-9][a-z0-9_.:-]+$/.test(metadata[key])) throw new Error('Invalid remote MID resource metadata');
61
+ return metadata;
62
+ }
63
+
64
+ function labels(metadata) {
65
+ return { [`${PREFIX}.owner`]: metadata.owner, [`${PREFIX}.instance`]: metadata.instanceURL, [`${PREFIX}.target`]: metadata.target, [`${PREFIX}.name`]: metadata.name };
66
+ }
67
+
68
+ function checkEngine(metadata, options) {
69
+ if (probe(metadata.target, options) !== metadata.engineId) throw new Error('SSH target now selects a different Docker engine; refusing lifecycle operation');
70
+ }
71
+
72
+ function inspect(metadata, kind, resource, options) {
73
+ let value;
74
+ try { value = JSON.parse(docker(metadata.target, [kind, 'inspect', resource], options))[0]; } catch { throw new Error(`Cannot verify owned remote ${kind}; no destructive action taken`); }
75
+ const actual = value?.Config?.Labels || value?.Labels;
76
+ if (!actual || Object.entries(labels(metadata)).some(([key, expected]) => actual[key] !== expected)) throw new Error(`Remote ${kind} ownership mismatch; refusing operation`);
77
+ return value;
78
+ }
79
+
80
+ export function prepareRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
81
+ if (platform === 'win32') throw new Error('Remote MID management requires a POSIX controller to enforce private local metadata permissions; Windows Docker hosts remain supported over SSH');
82
+ validateRemoteTarget(target);
83
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Invalid MID Server name');
84
+ const url = new URL(instanceURL);
85
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Use an HTTPS instance origin without credentials');
86
+ if (fs.existsSync(directory)) throw new Error('Remote MID local directory already exists; refusing to overwrite or adopt');
87
+ const owner = randomUUID();
88
+ const project = `jsn-mid-${owner}`;
89
+ const metadata = { version: 1, target, instanceURL: url.origin, name, owner, project, container: project, image: `${project}:local`, volume: `${project}-export`, network: `${project}-network`, engineId: probe(target, options), state: 'prepared' };
90
+ fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
91
+ fs.chmodSync(directory, 0o700);
92
+ save(directory, metadata);
93
+ return metadata;
94
+ }
95
+
96
+ export function buildRemoteMidManifest(metadata, { username, password }) {
97
+ if (!username || !password) throw new Error('MID username and password are required');
98
+ const ownership = labels(metadata);
99
+ const literal = value => String(value).replaceAll('$', () => '$$');
100
+ return { services: { mid: { container_name: metadata.container, image: metadata.image, pull_policy: 'never', restart: 'unless-stopped', labels: ownership, environment: { MID_INSTANCE_URL: `${metadata.instanceURL}/`, MID_INSTANCE_USERNAME: literal(username), MID_INSTANCE_PASSWORD: literal(password), MID_SERVER_NAME: literal(metadata.name) }, volumes: [`${metadata.volume}:/opt/snc_mid_server/agent/export`], networks: ['mid'] } }, volumes: { [metadata.volume]: { name: metadata.volume, labels: ownership } }, networks: { mid: { name: metadata.network, labels: ownership } } };
101
+ }
102
+
103
+ function resources(metadata, options) {
104
+ const checks = [ ['container', ['container', 'ls', '-a', '--filter', `name=${metadata.container}`, '--format', '{{.Names}}'], metadata.container], ['volume', ['volume', 'ls', '--filter', `name=${metadata.volume}`, '--format', '{{.Name}}'], metadata.volume], ['image', ['image', 'ls', '--filter', `reference=${metadata.image}`, '--format', '{{.Repository}}:{{.Tag}}'], metadata.image] ];
105
+ if (metadata.network) checks.push(['network', ['network', 'ls', '--filter', `name=${metadata.network}`, '--format', '{{.Name}}'], metadata.network]);
106
+ return checks.filter(([, args, resource]) => docker(metadata.target, args, options).split(/\r?\n/).includes(resource)).map(([kind]) => kind);
107
+ }
108
+
109
+ function absent(metadata, options) {
110
+ if (resources(metadata, options).length) throw new Error('Remote resources already exist; refusing to overwrite or adopt');
111
+ }
112
+
113
+ export function installRemoteMidServer({ directory, context, username, password, ...options }) {
114
+ const metadata = load(directory);
115
+ if (metadata.state !== 'prepared') throw new Error('Install requires a fresh prepared deployment; retained partial deployments must be inspected and removed explicitly');
116
+ if (!Buffer.isBuffer(context) || context.length === 0) throw new Error('An official recipe tar build context is required');
117
+ const manifest = buildRemoteMidManifest(metadata, { username, password });
118
+ checkEngine(metadata, options);
119
+ absent(metadata, options);
120
+ metadata.state = 'building'; save(directory, metadata);
121
+ try {
122
+ const buildLabels = Object.entries(labels(metadata)).flatMap(([key, value]) => ['--label', `${key}=${value}`]);
123
+ docker(metadata.target, ['build', ...buildLabels, '--tag', metadata.image, '-'], { ...options, input: context, timeout: 1200000 });
124
+ inspect(metadata, 'image', metadata.image, options);
125
+ metadata.state = 'deploying'; save(directory, metadata);
126
+ docker(metadata.target, ['compose', '-p', metadata.project, '-f', '-', 'up', '-d'], { ...options, input: JSON.stringify(manifest), timeout: 300000 });
127
+ inspect(metadata, 'container', metadata.container, options);
128
+ inspect(metadata, 'volume', metadata.volume, options);
129
+ inspect(metadata, 'network', metadata.network, options);
130
+ metadata.state = 'running'; save(directory, metadata);
131
+ return metadata;
132
+ } catch (error) { metadata.state = 'uncertain'; save(directory, metadata); throw error; }
133
+ }
134
+
135
+ export function inspectRemoteMidServer({ directory, ...options }) {
136
+ const metadata = load(directory); checkEngine(metadata, options);
137
+ const container = inspect(metadata, 'container', metadata.container, options);
138
+ return { ...metadata, status: container.State?.Status || 'unknown' };
139
+ }
140
+
141
+ function lifecycle(operation, { directory, ...options }) {
142
+ const metadata = load(directory); checkEngine(metadata, options);
143
+ inspect(metadata, 'container', metadata.container, options);
144
+ try { docker(metadata.target, ['container', operation, metadata.container], options); }
145
+ catch (error) { metadata.state = 'uncertain'; save(directory, metadata); throw error; }
146
+ metadata.state = operation === 'start' ? 'running' : 'stopped'; save(directory, metadata);
147
+ return metadata;
148
+ }
149
+ export const startRemoteMidServer = options => lifecycle('start', options);
150
+ export const stopRemoteMidServer = options => lifecycle('stop', options);
151
+
152
+ export function logsRemoteMidServer({ directory, tail = 100, ...options }) {
153
+ if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be between 1 and 10000');
154
+ const metadata = load(directory); checkEngine(metadata, options);
155
+ inspect(metadata, 'container', metadata.container, options);
156
+ return docker(metadata.target, ['container', 'logs', '--tail', String(tail), metadata.container], { ...options, mergeStderr: true });
157
+ }
158
+
159
+ export async function followRemoteMidServer({ directory, tail = 100, spawnImpl = spawn, stdout = process.stdout, stderr = process.stderr, signalSource = process, ...options }) {
160
+ if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be between 1 and 10000');
161
+ const metadata = load(directory); checkEngine(metadata, options);
162
+ inspect(metadata, 'container', metadata.container, options);
163
+ const args = sshArgs(metadata.target, ['container', 'logs', '--follow', '--tail', String(tail), metadata.container]);
164
+ return new Promise((resolve, reject) => {
165
+ let child;
166
+ let cancelled = false;
167
+ let timer;
168
+ let settled = false;
169
+ const cleanup = () => {
170
+ signalSource.removeListener('SIGINT', cancel);
171
+ clearTimeout(timer);
172
+ child?.stdout?.unpipe(stdout);
173
+ child?.stderr?.unpipe(stderr);
174
+ child?.removeListener('error', failed);
175
+ child?.removeListener('close', closed);
176
+ };
177
+ const finish = error => {
178
+ if (settled) return;
179
+ settled = true; cleanup();
180
+ if (error) reject(new Error('Remote Docker log viewer failed; the container was not stopped'));
181
+ else resolve({ cancelled });
182
+ };
183
+ const failed = () => finish(true);
184
+ const closed = code => finish(!cancelled && code !== 0);
185
+ const cancel = () => {
186
+ if (cancelled || settled) return;
187
+ cancelled = true;
188
+ child.kill('SIGINT');
189
+ timer = setTimeout(() => { child.kill('SIGKILL'); finish(false); }, 2000);
190
+ timer.unref?.();
191
+ };
192
+ try {
193
+ child = spawnImpl('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
194
+ child.on('error', failed);
195
+ child.on('close', closed);
196
+ child.stdout?.pipe(stdout, { end: false });
197
+ child.stderr?.pipe(stderr, { end: false });
198
+ signalSource.on('SIGINT', cancel);
199
+ } catch { finish(true); }
200
+ });
201
+ }
202
+
203
+ export function removeRemoteMidServer({ directory, ...options }) {
204
+ const metadata = load(directory); checkEngine(metadata, options);
205
+ // A partial install may have only an image or volume. Verify every present
206
+ // resource before deleting any, and preserve metadata for retries/diagnosis.
207
+ const present = resources(metadata, options);
208
+ for (const kind of present) inspect(metadata, kind, metadata[kind], options);
209
+ metadata.state = 'removing'; save(directory, metadata);
210
+ try {
211
+ if (present.includes('container')) {
212
+ docker(metadata.target, ['container', 'stop', metadata.container], options);
213
+ docker(metadata.target, ['container', 'rm', metadata.container], options);
214
+ }
215
+ if (present.includes('volume')) docker(metadata.target, ['volume', 'rm', metadata.volume], options);
216
+ if (present.includes('network')) docker(metadata.target, ['network', 'rm', metadata.network], options);
217
+ if (present.includes('image')) docker(metadata.target, ['image', 'rm', metadata.image], options);
218
+ } catch (error) { metadata.state = 'uncertain'; save(directory, metadata); throw error; }
219
+ metadata.state = 'removed'; save(directory, metadata);
220
+ return metadata;
221
+ }
@@ -0,0 +1,318 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { validateRemoteTarget } from './midserver-remote.js';
7
+ import { buildMidZipUrl } from './midserver-direct.js';
8
+
9
+ const FILE = '.jsn-mid-windows-remote.json';
10
+ const SSH = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--'];
11
+ const PHASES = new Set(['initialization', 'validate', 'reserve-directory', 'protect-directory', 'download', 'verify-archive', 'inspect-archive', 'extract', 'vendor-contract', 'configure', 'register-service', 'verify-service', 'enforce-permissions', 'remove-files', 'recover-file-acl', 'stop-owned-processes']);
12
+ const HEADER = `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; $phase='initialization';
13
+ trap { [Console]::Error.WriteLine(('JSN_PHASE_FAILED:' + $phase)); exit 1 }
14
+ $p=([Console]::In.ReadToEnd() | ConvertFrom-Json)
15
+ function Result($v) { [Console]::Out.WriteLine(('JSN_RESULT:' + ($v | ConvertTo-Json -Compress -Depth 8))) }
16
+ function Assert-Admin { if ($env:OS -ne 'Windows_NT') { throw 'Windows required' }; $i=[Security.Principal.WindowsIdentity]::GetCurrent(); $principal=New-Object Security.Principal.WindowsPrincipal($i); if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Elevated service installation rights required' } }
17
+ function Assert-Owned {
18
+ Assert-Admin
19
+ if (!(Test-Path -LiteralPath $p.remoteDirectory -PathType Container)) { throw 'Owned directory missing' }
20
+ $item=Get-Item -LiteralPath $p.remoteDirectory
21
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse point forbidden' }
22
+ $marker=Get-Content -Raw -LiteralPath (Join-Path $p.remoteDirectory '.jsn-owner.json') | ConvertFrom-Json
23
+ if ($marker.owner -ne $p.owner -or $marker.serviceName -ne $p.serviceName -or $marker.instanceURL -ne $p.instanceURL -or $marker.name -ne $p.name) { throw 'Ownership mismatch' }
24
+ }
25
+ function Owned-Service {
26
+ $s=Get-CimInstance Win32_Service -Filter ('Name="' + $p.serviceName + '"')
27
+ if ($s) {
28
+ $expected=[IO.Path]::GetFullPath((Join-Path $p.remoteDirectory 'agent\\bin\\wrapper-windows-x86-64.exe'))
29
+ $command=[regex]::Match($s.PathName,'^\\s*(?:"([^"\\r\\n]+)"|(.+?\\.exe))(?:\\s|$)',[Text.RegularExpressions.RegexOptions]::IgnoreCase)
30
+ if (!$command.Success) { throw 'Service binary ownership mismatch' }
31
+ $binary=$command.Groups[1].Value; if (!$binary) { $binary=$command.Groups[2].Value }
32
+ if ([IO.Path]::GetFullPath($binary) -ne $expected) { throw 'Service binary ownership mismatch' }
33
+ if ($s.StartName -ne 'NT AUTHORITY\\LocalService' -and $s.StartName -ne 'LocalService') { throw 'Unexpected runtime service account' }
34
+ }
35
+ return $s
36
+ }
37
+ `;
38
+
39
+ function remote(target, script, payload, { run = execFileSync, timeout = 30000 } = {}) {
40
+ validateRemoteTarget(target);
41
+ const bootstrap = `$ErrorActionPreference='Stop'; try { [Console]::InputEncoding=New-Object Text.UTF8Encoding($false); [Console]::OutputEncoding=New-Object Text.UTF8Encoding($false); $source=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadLine())); & ([ScriptBlock]::Create($source)) } catch { [Console]::Error.WriteLine('Remote MID bootstrap failed'); exit 1 }`;
42
+ const encoded = Buffer.from(bootstrap, 'utf16le').toString('base64');
43
+ const input = `${Buffer.from(HEADER + script, 'utf8').toString('base64')}\n${JSON.stringify(payload)}`;
44
+ let output;
45
+ try { output = String(run('ssh', [...SSH, target, `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`], { input, timeout, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] })); }
46
+ catch (error) {
47
+ const candidate = String(error.stderr || '').split(/\r?\n/).findLast(line => /^JSN_PHASE_FAILED:[a-z-]+$/.test(line));
48
+ const phase = candidate?.slice('JSN_PHASE_FAILED:'.length);
49
+ const failure = new Error(`Remote Windows MID operation failed${PHASES.has(phase) ? ` during ${phase}` : ''}; inspect retained ownership metadata and service status`);
50
+ if (PHASES.has(phase)) failure.phase = phase;
51
+ throw failure;
52
+ }
53
+ const line = output.split(/\r?\n/).findLast(value => value.startsWith('JSN_RESULT:'));
54
+ if (!line) throw new Error('Remote Windows MID returned no verified result');
55
+ try { return JSON.parse(line.slice('JSN_RESULT:'.length)); } catch { throw new Error('Invalid remote Windows MID result'); }
56
+ }
57
+
58
+ function save(directory, metadata) {
59
+ fs.writeFileSync(path.join(directory, FILE), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
60
+ fs.chmodSync(path.join(directory, FILE), 0o600);
61
+ }
62
+ function load(directory) {
63
+ const dir = fs.lstatSync(directory); const file = fs.lstatSync(path.join(directory, FILE));
64
+ if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Windows MID local metadata must be private');
65
+ const m = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8'));
66
+ validateRemoteTarget(m.target);
67
+ if (m.version !== 1 || !/^[a-f0-9-]{36}$/.test(m.owner) || m.serviceName !== `jsn-mid-${m.owner}` || !/^[A-Z]:\\[A-Za-z0-9 _.-]+(?:\\[A-Za-z0-9 _.-]+)*$/i.test(m.remoteDirectory) || !m.remoteDirectory.endsWith(`\\${m.owner}`)) throw new Error('Invalid Windows MID ownership metadata');
68
+ return m;
69
+ }
70
+
71
+ export function prepareWindowsRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
72
+ if (platform === 'win32') throw new Error('A POSIX controller is required for private local metadata');
73
+ validateRemoteTarget(target);
74
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Invalid MID name');
75
+ const url = new URL(instanceURL);
76
+ if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Use an HTTPS instance origin');
77
+ if (fs.existsSync(directory)) throw new Error('Local deployment directory already exists; no adoption or overwrite');
78
+ const owner = randomUUID(); const serviceName = `jsn-mid-${owner}`;
79
+ const probe = remote(target, `Assert-Admin
80
+ if ([Environment]::Is64BitOperatingSystem -ne $true) { throw 'x64 Windows required' }
81
+ $base=Join-Path $env:ProgramData 'jsn-mid'
82
+ $remoteDirectory=Join-Path $base $p.owner
83
+ if (Test-Path -LiteralPath $remoteDirectory) { throw 'Deployment directory exists' }
84
+ if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service already exists' }
85
+ Result @{remoteDirectory=$remoteDirectory; elevated=$true; platform='windows-x64'}`, { owner, serviceName }, options);
86
+ if (!probe.elevated || probe.platform !== 'windows-x64' || !probe.remoteDirectory?.endsWith(`\\${owner}`)) throw new Error('Windows preflight failed');
87
+ const metadata = { version: 1, target, owner, serviceName, instanceURL: url.origin, name, remoteDirectory: probe.remoteDirectory, state: 'prepared', runtimeAccount: 'NT AUTHORITY\\LocalService' };
88
+ fs.mkdirSync(directory, { mode: 0o700 }); fs.chmodSync(directory, 0o700); save(directory, metadata);
89
+ return metadata;
90
+ }
91
+
92
+ const INSTALL = String.raw`$phase='validate'; Assert-Admin
93
+ if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service already exists; no adoption' }
94
+ if (Test-Path -LiteralPath $p.remoteDirectory) { throw 'Directory already exists; no adoption' }
95
+ $phase='reserve-directory'
96
+ $parent=Split-Path -Parent $p.remoteDirectory
97
+ if (!(Test-Path -LiteralPath $parent)) { $null=New-Item -ItemType Directory -Path $parent }
98
+ $ancestor=Get-Item -LiteralPath $parent
99
+ while ($ancestor) { if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse ancestor forbidden' }; $ancestor=$ancestor.Parent }
100
+ $null=New-Item -ItemType Directory -Path $p.remoteDirectory
101
+ $phase='protect-directory'
102
+ $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
103
+ $acl=New-Object Security.AccessControl.DirectorySecurity
104
+ $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
105
+ foreach ($id in @($sid.Value,'S-1-5-18','S-1-5-32-544') | Select-Object -Unique) {
106
+ $who=New-Object Security.Principal.SecurityIdentifier($id)
107
+ $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($who,'FullControl','ContainerInherit,ObjectInherit','None','Allow')))
108
+ }
109
+ $localService=New-Object Security.Principal.SecurityIdentifier('S-1-5-19')
110
+ $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($localService,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
111
+ Set-Acl -LiteralPath $p.remoteDirectory -AclObject $acl
112
+ $actual=Get-Acl -LiteralPath $p.remoteDirectory
113
+ if (!$actual.AreAccessRulesProtected) { throw 'ACL protection failed' }
114
+ $allowed=@($sid.Value,'S-1-5-18','S-1-5-32-544','S-1-5-19')
115
+ foreach ($r in $actual.Access) { if ($r.AccessControlType -ne 'Allow' -or $r.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin $allowed) { throw 'Unexpected ACL rule' } }
116
+ $marker=@{owner=$p.owner;serviceName=$p.serviceName;instanceURL=$p.instanceURL;name=$p.name}
117
+ $marker | ConvertTo-Json -Compress | Set-Content -Encoding UTF8 -LiteralPath (Join-Path $p.remoteDirectory '.jsn-owner.json')
118
+ $phase='download'
119
+ $archive=Join-Path $p.remoteDirectory '.download.zip'
120
+ if ($p.archivePath) { Copy-Item -LiteralPath $p.archivePath -Destination $archive } else { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing -Uri $p.archiveURL -OutFile $archive -TimeoutSec 600 }
121
+ $phase='verify-archive'
122
+ if ((Get-Item -LiteralPath $archive).Length -gt 1GB) { throw 'Archive too large' }
123
+ $archiveHash=(Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash
124
+ if ($p.archiveSha256 -and $archiveHash -ne $p.archiveSha256) { throw 'Archive hash mismatch' }
125
+ $phase='inspect-archive'
126
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
127
+ $zip=[IO.Compression.ZipFile]::OpenRead($archive)
128
+ try {
129
+ if ($zip.Entries.Count -gt 20000) { throw 'Too many archive entries' }
130
+ $total=0; $seen=@{}
131
+ foreach ($entry in $zip.Entries) {
132
+ $entryName=$entry.FullName
133
+ if ($entryName.Contains('\') -or $entryName.StartsWith('/') -or $entryName.Contains(':')) { throw 'Unsafe archive path' }
134
+ $parts=$entryName.TrimEnd('/').Split('/')
135
+ if ($parts[0] -notin @('agent','META-INF')) { throw 'Unexpected archive root' }
136
+ foreach ($part in $parts) { if (!$part -or $part -eq '.' -or $part -eq '..' -or $part -match '[<>"|?*\x00-\x1f]' -or $part -match '[. ]$' -or $part -match '^(?i:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)') { throw 'Unsafe archive entry' } }
137
+ $key=$entryName.TrimEnd('/').ToLowerInvariant(); if ($seen.ContainsKey($key)) { throw 'Duplicate archive entry' }; $seen[$key]=$true
138
+ $mode=($entry.ExternalAttributes -shr 16) -band 0xF000
139
+ if ($mode -ne 0 -and $mode -ne 0x8000 -and $mode -ne 0x4000) { throw 'Archive links forbidden' }
140
+ $total+=$entry.Length; if ($total -gt 2GB) { throw 'Expanded archive too large' }
141
+ }
142
+ } finally { $zip.Dispose() }
143
+ $phase='extract'
144
+ Expand-Archive -LiteralPath $archive -DestinationPath $p.remoteDirectory
145
+ Remove-Item -LiteralPath $archive
146
+ $phase='vendor-contract'
147
+ $agent=Join-Path $p.remoteDirectory 'agent'
148
+ foreach ($relative in @('config.xml','conf\wrapper.conf','conf\wrapper-override.conf','bin\mid.bat','bin\wrapper-windows-x86-64.exe','jre\bin\java.exe')) { if (!(Test-Path -LiteralPath (Join-Path $agent $relative) -PathType Leaf)) { throw 'Unsupported vendor ZIP layout' } }
149
+ $batch=Get-Content -Raw -LiteralPath (Join-Path $agent 'bin\mid.bat')
150
+ $wrapper=Get-Content -Raw -LiteralPath (Join-Path $agent 'conf\wrapper.conf')
151
+ if ($batch -notmatch '\[install\]' -or $batch -notmatch '\[remove\]' -or $batch -notmatch '(?m)^\s*%_WRAPPER_EXE% -i ' -or $batch -notmatch '(?m)^\s*%_WRAPPER_EXE% -r ' -or $wrapper -notmatch '(?m)^wrapper.java.command=jre/bin/java' -or $wrapper -notmatch '(?m)^#include ../conf/wrapper-override.conf') { throw 'Unsupported vendor service contract' }
152
+ $modules=@(Get-ChildItem -LiteralPath $agent -Filter 'EnforceFilePermissions.psm1' -File -Recurse)
153
+ if ($modules.Count -ne 1 -or (Get-Content -Raw -LiteralPath $modules[0].FullName) -notmatch 'function\s+enforcePerms\s*\{') { throw 'Unsupported vendor permission enforcement contract' }
154
+ $phase='configure'
155
+ $config=Join-Path $agent 'config.xml'
156
+ $xml=New-Object Xml.XmlDocument; $xml.XmlResolver=$null; $xml.PreserveWhitespace=$true; $xml.Load($config)
157
+ if ($xml.DocumentElement.Name -ne 'parameters') { throw 'Unsupported configuration XML root' }
158
+ $values=@{url=($p.instanceURL+'/');'mid.instance.username'=$p.username;'mid.instance.password'=$p.password;name=$p.name;'mid.windows_host.file_permissions.allow_list'='S-1-5-19'}
159
+ foreach ($key in @($values.Keys)) {
160
+ $nodes=@($xml.SelectNodes('/parameters/parameter') | Where-Object { $_.GetAttribute('name') -eq $key })
161
+ if ($nodes.Count -gt 1) { throw 'Duplicate configuration parameter' }
162
+ if ($nodes.Count -eq 0) { $node=$xml.CreateElement('parameter'); $node.SetAttribute('name',$key); $null=$xml.DocumentElement.AppendChild($node) } else { $node=$nodes[0] }
163
+ if ($key -eq 'mid.windows_host.file_permissions.allow_list') {
164
+ $entries=@($node.GetAttribute('value').Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
165
+ $values[$key]=(@($entries + 'S-1-5-19') | Select-Object -Unique) -join ','
166
+ }
167
+ $node.SetAttribute('value',[string]$values[$key])
168
+ if ($key -eq 'mid.instance.password' -and !$node.HasAttribute('secure')) { $node.SetAttribute('secure','true') }
169
+ }
170
+ $xml.Save($config)
171
+ $override=Join-Path $agent 'conf\wrapper-override.conf'
172
+ $text=Get-Content -Raw -LiteralPath $override
173
+ $text=[regex]::Replace($text,'(?m)^wrapper\.(?:name|displayname|ntservice\.(?:account|account\.prompt|password|password\.prompt|starttype|permissions\.1\.(?:account|allow|service)))=.*\r?$','')
174
+ $text+="
175
+ wrapper.name=$($p.serviceName)
176
+ wrapper.displayname=ServiceNow MID $($p.serviceName)
177
+ wrapper.ntservice.account=NT AUTHORITY\LocalService
178
+ wrapper.ntservice.password=
179
+ wrapper.ntservice.account.prompt=FALSE
180
+ wrapper.ntservice.password.prompt=FALSE
181
+ wrapper.ntservice.starttype=AUTO_START
182
+ wrapper.ntservice.permissions.1.account=NT AUTHORITY\LocalService
183
+ wrapper.ntservice.permissions.1.allow=START, STOP
184
+ wrapper.ntservice.permissions.1.service=$($p.serviceName)
185
+ "
186
+ [IO.File]::WriteAllText($override,$text,(New-Object Text.UTF8Encoding($false)))
187
+ $phase='register-service'
188
+ Push-Location $agent
189
+ try { & cmd.exe /d /s /c 'bin\mid.bat install' 2>&1 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Vendor install failed' } } finally { Pop-Location }
190
+ $phase='verify-service'
191
+ Assert-Owned; $s=Owned-Service; if (!$s) { throw 'Service registration missing' }
192
+ if ($s.StartMode -ne 'Auto') { throw 'Service automatic startup was not configured' }
193
+ $phase='enforce-permissions'
194
+ Import-Module -Name $modules[0].FullName -Force -ErrorAction Stop
195
+ $enforce=Get-Command -Name enforcePerms -CommandType Function -ErrorAction Stop
196
+ if ($enforce.Module.Path -ne $modules[0].FullName) { throw 'Wrong enforcement module' }
197
+ # The vendor allow-list includes a second service principal when USERDOMAIN
198
+ # resolves it. Match the LocalService runtime context so its saved list/hash is
199
+ # identical on startup; let the unmodified vendor module compute the hash.
200
+ $previousUserDomain=$env:USERDOMAIN
201
+ try {
202
+ $env:USERDOMAIN=($s.StartName -split '\\',2)[0]
203
+ enforcePerms -folder $agent -serviceName $p.serviceName -debug $false -additionWhiteListedGroups $values['mid.windows_host.file_permissions.allow_list'] | Out-Null
204
+ } finally { $env:USERDOMAIN=$previousUserDomain }
205
+ $serviceRules=@((Get-Acl -LiteralPath $agent).Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -eq 'S-1-5-19' -and ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl })
206
+ if (!$serviceRules.Count) { throw 'Runtime account lacks enforced permissions' }
207
+ foreach ($receipt in @('fileperm.aclsave','fileperm.grphash')) { if (!(Test-Path -LiteralPath (Join-Path (Join-Path $agent 'etc') $receipt) -PathType Leaf)) { throw 'Vendor enforcement receipt missing' } }
208
+ Result @{status=$s.State;runtimeAccount=$s.StartName;serviceRegistered=$true;archiveSha256=$archiveHash;startMode=$s.StartMode}
209
+ `;
210
+
211
+ export function installWindowsRemoteMidServer({ directory, releaseName, username, password, archivePath, archiveSha256, ...options }) {
212
+ const m = load(directory);
213
+ if (m.state !== 'prepared') throw new Error('Install requires a fresh prepared deployment');
214
+ if (!username || !password || [...String(username) + String(password)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Valid MID credentials are required');
215
+ const archiveURL = buildMidZipUrl(releaseName, 'win32');
216
+ if (archiveSha256 && !/^[a-f0-9]{64}$/i.test(archiveSha256)) throw new Error('Invalid archive SHA256');
217
+ if (archivePath && (!archiveSha256 || !/^[A-Z]:\\[A-Za-z0-9 _.-]+(?:\\[A-Za-z0-9 _.-]+)*$/i.test(archivePath))) throw new Error('Cached remote archive requires an absolute safe path and inspected SHA256');
218
+ m.state = 'installing'; m.releaseName = releaseName; save(directory, m);
219
+ try {
220
+ const result = remote(m.target, INSTALL, { ...m, archiveURL, username, password, archivePath, archiveSha256 }, { ...options, timeout: 1200000 });
221
+ if (!result.serviceRegistered || !['NT AUTHORITY\\LocalService', 'LocalService'].includes(result.runtimeAccount)) throw new Error('Service installation was not verified under LocalService');
222
+ if (result.startMode !== 'Auto') throw new Error('Service automatic startup was not verified');
223
+ if (!/^[a-f0-9]{64}$/i.test(result.archiveSha256 || '')) throw new Error('Archive hash verification result missing');
224
+ m.startMode = result.startMode; m.archiveSha256 = result.archiveSha256.toLowerCase(); m.state = 'installed'; save(directory, m); return m;
225
+ } catch (error) { m.state = 'uncertain'; if (error.phase) m.failedPhase = error.phase; save(directory, m); throw error; }
226
+ }
227
+
228
+ export function removeWindowsRemoteMidServer({ directory, ...options }) {
229
+ const m = load(directory);
230
+ m.state = 'removing'; save(directory, m);
231
+ try {
232
+ remote(m.target, String.raw`Assert-Owned; $s=Owned-Service
233
+ if ($s) {
234
+ Stop-Service -Name $p.serviceName -ErrorAction Stop
235
+ (Get-Service -Name $p.serviceName).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(60))
236
+ $null=Owned-Service
237
+ $agent=Join-Path $p.remoteDirectory 'agent'
238
+ Push-Location $agent
239
+ try { & cmd.exe /d /s /c 'bin\mid.bat remove' 2>&1 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Vendor removal failed' } } finally { Pop-Location }
240
+ $deadline=(Get-Date).AddSeconds(30)
241
+ while (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { if ((Get-Date) -gt $deadline) { throw 'Service still registered' }; Start-Sleep -Milliseconds 500 }
242
+ }
243
+ Assert-Owned
244
+ $links=@(Get-ChildItem -LiteralPath $p.remoteDirectory -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
245
+ if ($links.Count) { throw 'Refusing recursive cleanup through reparse points' }
246
+ $phase='stop-owned-processes'
247
+ if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service still registered; refusing process cleanup' }
248
+ $ownedBinaries=@('agent\bin\wrapper-windows-x86-64.exe','agent\bin\wrapper.exe','agent\jre\bin\java.exe') | ForEach-Object { [IO.Path]::GetFullPath((Join-Path $p.remoteDirectory $_)) }
249
+ $candidates=@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -in $ownedBinaries })
250
+ foreach ($candidate in $candidates) {
251
+ $current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + [uint32]$candidate.ProcessId)
252
+ if (!$current) { continue }
253
+ if (!$current.ExecutablePath -or [IO.Path]::GetFullPath($current.ExecutablePath) -notin $ownedBinaries -or $current.ExecutablePath -ne $candidate.ExecutablePath -or $current.CreationDate -ne $candidate.CreationDate) { throw 'Owned process identity changed; refusing to kill reused PID' }
254
+ $process=Get-Process -Id $current.ProcessId -ErrorAction Stop
255
+ try {
256
+ $null=$process.Handle
257
+ if (!$process.Path -or [IO.Path]::GetFullPath($process.Path) -ne [IO.Path]::GetFullPath($current.ExecutablePath)) { throw 'Process executable changed' }
258
+ $process.Kill()
259
+ if (!$process.WaitForExit(30000)) { throw 'Owned process did not exit' }
260
+ } finally { $process.Dispose() }
261
+ }
262
+ $remaining=@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -in $ownedBinaries })
263
+ if ($remaining.Count) { throw 'Owned runtime process remains; files retained' }
264
+ function Remove-OwnedFiles {
265
+ # Keep the owner receipt until all other children are gone, allowing a failed
266
+ # teardown to be safely retried without adopting an unmarked directory.
267
+ Get-ChildItem -LiteralPath $p.remoteDirectory -Force | Where-Object { $_.Name -ne '.jsn-owner.json' } | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop }
268
+ $receipt=Join-Path $p.remoteDirectory '.jsn-owner.json'
269
+ if (Test-Path -LiteralPath $receipt) { Remove-Item -LiteralPath $receipt -Force -ErrorAction Stop }
270
+ Remove-Item -LiteralPath $p.remoteDirectory -Force -ErrorAction Stop
271
+ }
272
+ $phase='remove-files'
273
+ try { Remove-OwnedFiles } catch [UnauthorizedAccessException] {
274
+ $phase='recover-file-acl'
275
+ # Service absence and the full tree scan were verified above. Do not expand
276
+ # recovery to any parent, sibling, global policy or unowned service.
277
+ if (Get-Service -Name $p.serviceName -ErrorAction SilentlyContinue) { throw 'Service reappeared; refusing ACL recovery' }
278
+ $remainingLinks=@(Get-ChildItem -LiteralPath $p.remoteDirectory -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
279
+ if ($remainingLinks.Count) { throw 'Refusing ACL recovery through reparse points' }
280
+ & takeown.exe /F $p.remoteDirectory /R /D Y 2>&1 | Out-Null
281
+ if ($LASTEXITCODE -ne 0) { throw 'Scoped ownership recovery failed; localized takeown prompts may require manual recovery' }
282
+ $controllerSid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
283
+ $grant='*' + $controllerSid + ':(OI)(CI)F'
284
+ & icacls.exe $p.remoteDirectory /grant $grant /T /C 2>&1 | Out-Null
285
+ if ($LASTEXITCODE -ne 0) { throw 'Scoped controller ACL recovery failed' }
286
+ $phase='remove-files'
287
+ Remove-OwnedFiles
288
+ }
289
+ if (Test-Path -LiteralPath $p.remoteDirectory) { throw 'Owned directory remains' }
290
+ Result @{removed=$true}`, m, { ...options, timeout: 180000 });
291
+ m.state = 'removed'; save(directory, m); return m;
292
+ } catch (error) { m.state = 'uncertain'; if (error.phase) m.failedPhase = error.phase; save(directory, m); throw error; }
293
+ }
294
+
295
+ export function inspectWindowsRemoteMidServer({ directory, ...options }) {
296
+ const m = load(directory);
297
+ return { ...m, ...remote(m.target, `Assert-Owned; $s=Owned-Service; if (!$s) { throw 'Service missing' }; Result @{status=$s.State; runtimeAccount=$s.StartName; startMode=$s.StartMode}`, m, options) };
298
+ }
299
+
300
+ function lifecycle(operation, { directory, ...options }) {
301
+ const m = load(directory);
302
+ try {
303
+ remote(m.target, `Assert-Owned; $s=Owned-Service; if (!$s) { throw 'Service missing' }; ${operation}-Service -Name $p.serviceName -ErrorAction Stop
304
+ (Get-Service -Name $p.serviceName).WaitForStatus('${operation === 'Start' ? 'Running' : 'Stopped'}',[TimeSpan]::FromSeconds(60))
305
+ Result @{status='${operation === 'Start' ? 'Running' : 'Stopped'}'}`, m, { ...options, timeout: 90000 });
306
+ m.state = operation === 'Start' ? 'running' : 'stopped'; save(directory, m); return m;
307
+ } catch (error) { m.state = 'uncertain'; if (error.phase) m.failedPhase = error.phase; save(directory, m); throw error; }
308
+ }
309
+ export const startWindowsRemoteMidServer = options => lifecycle('Start', options);
310
+ export const stopWindowsRemoteMidServer = options => lifecycle('Stop', options);
311
+
312
+ export function logsWindowsRemoteMidServer({ directory, tail = 100, ...options }) {
313
+ if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be between 1 and 10000');
314
+ const m = load(directory);
315
+ return remote(m.target, `Assert-Owned; $null=Owned-Service
316
+ $logs=@(Get-ChildItem -LiteralPath (Join-Path $p.remoteDirectory 'agent\\logs') -File | Where-Object { $_.Name -match '^agent.*\\.log(?:\\.[0-9]+)?$' } | Sort-Object LastWriteTime -Descending)
317
+ $text=''; if ($logs.Count -gt 0) { $text=(Get-Content -LiteralPath $logs[0].FullName -Tail $p.tail) -join [Environment]::NewLine }; Result @{logs=$text}`, { ...m, tail }, options).logs;
318
+ }
@@ -17,7 +17,7 @@ function read(fd, size, offset) {
17
17
 
18
18
  // Inspect every entry before extracting anything. ZIP64, split ZIPs, links and
19
19
  // Windows aliases are deliberately unsupported, not delegated to a shell tool.
20
- export function inspectMidZip(archive) {
20
+ export function inspectMidZip(archive, { allowedRoots = ['agent', 'META-INF'] } = {}) {
21
21
  const fd = fs.openSync(archive, 'r');
22
22
  try {
23
23
  const size = fs.fstatSync(fd).size;
@@ -44,7 +44,7 @@ export function inspectMidZip(archive) {
44
44
  const localOffset = directory.readUInt32LE(pos + 42);
45
45
  const folder = name.endsWith('/');
46
46
  const parts = name.replace(/\/$/, '').split('/');
47
- if (!['agent', 'META-INF'].includes(parts[0]) || parts.some(part => !part || part === '.' || part === '..' || /[\\:<>"|?*]/.test(part) || [...part].some(char => char.codePointAt(0) < 32) || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe ZIP path');
47
+ if ((allowedRoots && !allowedRoots.includes(parts[0])) || parts.some(part => !part || part === '.' || part === '..' || /[\\:<>"|?*]/.test(part) || [...part].some(char => char.codePointAt(0) < 32) || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe ZIP path');
48
48
  const type = mode & 0o170000;
49
49
  if (type && type !== 0o100000 && type !== 0o040000) throw new Error('ZIP links and special files are forbidden');
50
50
  if ((flags & 1) || ![0, 8].includes(method) || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) throw new Error('Unsupported ZIP encoding');