mcp-pacemaker 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/CONTRIBUTING.md +39 -13
  3. package/README.md +255 -8
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-pacemaker",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Keeps your MCP servers alive across editor restarts, reboots, and token expiry — pluggable auth, cross-platform auto-start, one-command setup. Zero-dependency bridge; a small CLI for setup.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,15 +12,21 @@
12
12
  "autostart/",
13
13
  "supervisor/",
14
14
  "tools/windows-security-helper/",
15
+ "tools/windows-process-lifetime/",
16
+ "tools/windows-legacy/",
17
+ "tools/windows-task-channel/",
18
+ "tools/account-upgrade/",
15
19
  "examples/",
16
20
  "ui/dist/",
17
21
  "README.md",
18
22
  "CHANGELOG.md",
19
23
  "SECURITY.md",
20
24
  "docs/shared-sessions.md",
25
+ "docs/account-upgrades.md",
21
26
  "LICENSE",
22
27
  "THIRD_PARTY_NOTICES.txt",
23
- "CONTRIBUTING.md"
28
+ "CONTRIBUTING.md",
29
+ "docs/execution-context.md"
24
30
  ],
25
31
  "engines": {
26
32
  "node": ">=20"
@@ -56,14 +62,15 @@
56
62
  "compat:clean": "node tools/compatibility/prepare.mjs --clean",
57
63
  "test:compat": "node --test --test-concurrency=1 test/compat/gates.test.mjs test/compat/cli-api.test.mjs",
58
64
  "test:compat:browser": "node --test --test-concurrency=1 test/compat/browser.test.mjs",
59
- "test": "node --test test/bridge.test.mjs test/bridge-controls.test.mjs test/kill-tree.test.mjs test/keepalive.test.mjs test/auth-token.test.mjs test/cold-start.test.mjs test/crash-report.test.mjs test/resume.test.mjs test/config-checks.test.mjs test/config-reload.test.mjs test/health.test.mjs test/cli.test.mjs test/logs.test.mjs test/prewarm.test.mjs test/pooling-batch-scheduler.test.mjs test/pooling-batch-api.test.mjs test/pooling-batch-store.test.mjs test/pooling-batches.test.mjs test/pooling-stage-publication.test.mjs test/pooling-batch-startup.test.mjs test/pooling-postcommit-recovery.test.mjs test/pooling-config.test.mjs test/pooling-writer.test.mjs test/pooling-api.test.mjs test/pooling-deadlines.test.mjs test/pooling-queue-budget.test.mjs test/windows-security-helper.test.mjs test/windows-batch-security.test.mjs test/tui.test.mjs test/warm-pool.test.mjs test/server-metrics.test.mjs test/admission.test.mjs test/shared-sessions.test.mjs test/shared-http.test.mjs test/cross-host.test.mjs test/suite-integrity.test.mjs test/consumer-package.test.mjs test/notices.test.mjs test/notices-runtime.test.mjs test/npm-publication.test.mjs test/npm-publication-proof.test.mjs test/update-check.test.mjs test/service-lifecycle.test.mjs test/service-supervisor.test.mjs test/npm-publication-gates.test.mjs test/npm-publication-matrix.test.mjs test/publication-scanners-advisories.test.mjs test/publication-scanners.test.mjs test/service-replacement.test.mjs test/consumer-environment.test.mjs test/npm-publication-external.test.mjs test/npm-publication-peer.test.mjs test/publication-scanners-diagnostics.test.mjs test/publication-scanners-publication.test.mjs test/publication-scanners-paths.test.mjs test/npm-publication-local-enforcement.test.mjs test/publication-scanners-admission.test.mjs",
65
+ "test": "node --test --test-concurrency=4 test/bridge.test.mjs test/bridge-controls.test.mjs test/kill-tree.test.mjs test/keepalive.test.mjs test/auth-token.test.mjs test/cold-start.test.mjs test/crash-report.test.mjs test/resume.test.mjs test/config-checks.test.mjs test/config-reload.test.mjs test/health.test.mjs test/cli.test.mjs test/logs.test.mjs test/prewarm.test.mjs test/pooling-batch-scheduler.test.mjs test/pooling-batch-api.test.mjs test/pooling-batch-store.test.mjs test/pooling-batches.test.mjs test/pooling-stage-publication.test.mjs test/pooling-batch-startup.test.mjs test/pooling-postcommit-recovery.test.mjs test/pooling-config.test.mjs test/pooling-writer.test.mjs test/pooling-api.test.mjs test/pooling-deadlines.test.mjs test/pooling-queue-budget.test.mjs test/windows-security-helper.test.mjs test/windows-batch-security.test.mjs test/tui.test.mjs test/warm-pool.test.mjs test/server-metrics.test.mjs test/admission.test.mjs test/shared-sessions.test.mjs test/shared-http.test.mjs test/cross-host.test.mjs test/suite-integrity.test.mjs test/consumer-package.test.mjs test/notices.test.mjs test/notices-runtime.test.mjs test/npm-publication.test.mjs test/npm-publication-proof.test.mjs test/update-check.test.mjs test/service-lifecycle.test.mjs test/service-supervisor.test.mjs test/npm-publication-gates.test.mjs test/npm-publication-matrix.test.mjs test/publication-scanners-advisories.test.mjs test/publication-scanners.test.mjs test/service-replacement.test.mjs test/consumer-environment.test.mjs test/npm-publication-external.test.mjs test/npm-publication-peer.test.mjs test/publication-scanners-diagnostics.test.mjs test/publication-scanners-publication.test.mjs test/publication-scanners-paths.test.mjs test/npm-publication-local-enforcement.test.mjs test/publication-scanners-admission.test.mjs test/windows-process-lifetime.test.mjs test/windows-lifetime-startup.test.mjs test/managed-upgrade.test.mjs test/windows-lifetime-identity.test.mjs test/windows-legacy-process.test.mjs test/legacy-upgrade.test.mjs test/legacy-task-security.test.mjs test/windows-task-channel.test.mjs test/task-controller.test.mjs test/account-task-adapter.test.mjs test/account-upgrade.test.mjs test/account-rollback-launch.test.mjs test/account-recovery.test.mjs test/runtime-context.test.mjs test/ci-contracts.test.mjs",
60
66
  "notices:check": "node tools/third-party-notices/check.mjs",
61
67
  "publication:source-gates": "node tools/npm-publication/source-gates.mjs",
62
68
  "publication:artifact-gates": "node tools/npm-publication/artifact-gates.mjs",
63
69
  "service:replacement:check": "node tools/service-replacement/check.mjs",
64
70
  "publication:external-gates": "node tools/npm-publication/external-gates.mjs",
65
71
  "publication:local-gate": "node tools/npm-publication/local-windows.mjs",
66
- "publication:verify-local": "node tools/npm-publication/verify-local.mjs"
72
+ "publication:verify-local": "node tools/npm-publication/verify-local.mjs",
73
+ "test:contract": "node tools/ci/run-tests.mjs"
67
74
  },
68
75
  "dependencies": {
69
76
  "@clack/prompts": "^1.7.0",
@@ -72,6 +79,6 @@
72
79
  "picocolors": "^1.1.1",
73
80
  "react": "^19.2.7",
74
81
  "semver": "^7.7.3",
75
- "smol-toml": "^1.8.0"
82
+ "smol-toml": "^1.9.0"
76
83
  }
77
84
  }
@@ -1,34 +1,38 @@
1
1
  import { fork } from 'node:child_process';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, resolve, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { parseArgs } from 'node:util';
9
9
  import { servicePaths, assertDurableRoot } from '../bin/service-control.mjs';
10
+ import { requireUserRuntimeContext } from '../bin/runtime-context.mjs';
10
11
 
11
12
  const { values } = parseArgs({
12
- options: { port: { type: 'string', default: '8791' }, config: { type: 'string' } },
13
+ options: { port: { type: 'string', default: '8791' }, config: { type: 'string' }, cwd: { type: 'string' } },
13
14
  });
14
- const port = Number(values.port);
15
+ let port = Number(values.port);
15
16
  const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
16
17
  const config = resolve(values.config || join(homedir(), '.mcp-pacemaker', 'servers.json'));
17
- const paths = servicePaths(config, port);
18
+ let paths = port === 0 && process.connected && process.env.MCP_PACEMAKER_MANAGED_INSTANCE
19
+ ? undefined : servicePaths(config, port);
18
20
  assertDurableRoot(root);
19
21
  if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js >=20 is required.');
20
22
 
21
- if (existsSync(paths.hold)) {
23
+ if (paths && existsSync(paths.hold)) {
22
24
  console.log(`Service :${port} is held stopped. Use the matching CLI start --port ${port} to resume deliberately.`);
23
25
  } else {
24
26
  await supervise();
25
27
  }
26
28
 
27
29
  async function supervise() {
30
+ await requireUserRuntimeContext();
28
31
  const identity = {
29
- protocol: 1, root, config, port, socket: paths.socket, pid: process.pid, execPath: process.execPath,
32
+ protocol: 1, root, config, port, socket: paths?.socket, pid: process.pid, execPath: process.execPath,
30
33
  id: randomUUID(), token: randomBytes(32).toString('hex'),
31
34
  version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version,
35
+ upgradeProtocol: 1, managedInstance: process.env.MCP_PACEMAKER_MANAGED_INSTANCE,
32
36
  };
33
37
  let child;
34
38
  let childExit;
@@ -37,6 +41,14 @@ async function supervise() {
37
41
  let stopRequested = false;
38
42
  let holdRequested = false;
39
43
  const clients = new Set();
44
+ const pending = new Map();
45
+ let readiness;
46
+ const failStartup = process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
47
+ let initialStartup = failStartup;
48
+ let pendingStart = failStartup || Boolean(paths && existsSync(paths.admissionHold));
49
+ let quiescedInstanceId;
50
+ let stopAuthorization;
51
+ const lifetimes = [];
40
52
  const server = createServer((socket) => {
41
53
  clients.add(socket);
42
54
  socket.on('close', () => clients.delete(socket));
@@ -55,12 +67,47 @@ async function supervise() {
55
67
  catch { socket.destroy(); return; }
56
68
  if (request.id !== identity.id ||
57
69
  request.token !== identity.token ||
58
- request.action !== 'stop') {
70
+ !['stop', 'inspect', 'quiesce', 'activate', 'resume'].includes(request.action)) {
59
71
  socket.destroy();
60
72
  return;
61
73
  }
74
+ if (request.action !== 'stop') {
75
+ if (request.action === 'quiesce') {
76
+ pendingStart = true;
77
+ writeFileSync(paths.admissionHold, JSON.stringify({ id: identity.id, root, config, port }) + '\n',
78
+ { mode: 0o600, flush: true });
79
+ }
80
+ bridgeRequest(request).then((result) => {
81
+ if (result.ok &&
82
+ result.instanceId === readiness?.instanceId) {
83
+ if (request.action === 'quiesce' &&
84
+ result.held) quiescedInstanceId = result.instanceId;
85
+ if (['activate', 'resume'].includes(request.action)) {
86
+ if (existsSync(paths.admissionHold)) unlinkSync(paths.admissionHold);
87
+ pendingStart = false;
88
+ initialStartup = false;
89
+ quiescedInstanceId = undefined;
90
+ }
91
+ }
92
+ socket.end(JSON.stringify({ ...result, id: identity.id }) + '\n');
93
+ }, (error) => socket.end(JSON.stringify({ id: identity.id, ok: false, error: error.message }) + '\n'));
94
+ return;
95
+ }
96
+ if (request.quiescedInstanceId !== undefined) {
97
+ const heldGeneration = !child ||
98
+ readiness?.instanceId === quiescedInstanceId ||
99
+ readiness?.held === true;
100
+ if (!pendingStart ||
101
+ request.quiescedInstanceId !== quiescedInstanceId ||
102
+ !heldGeneration) {
103
+ socket.end(JSON.stringify({ id: identity.id, stopped: false }) + '\n');
104
+ return;
105
+ }
106
+ stopAuthorization = request.quiescedInstanceId;
107
+ }
62
108
  stop().then(() => {
63
- socket.end(JSON.stringify({ id: identity.id, stopped: true, autostartHeld: true }) + '\n');
109
+ socket.end(JSON.stringify({ id: identity.id, stopped: true, autostartHeld: true,
110
+ ...(stopAuthorization ? { quiescedInstanceId: stopAuthorization } : {}) }) + '\n');
64
111
  }, (error) => {
65
112
  console.error(error.message);
66
113
  socket.end(JSON.stringify({ id: identity.id, stopped: false }) + '\n');
@@ -69,14 +116,11 @@ async function supervise() {
69
116
  });
70
117
 
71
118
  mkdirSync(dirname(config), { recursive: true, mode: 0o700 });
72
- await new Promise((resolveListen, reject) => {
73
- server.once('error', reject);
74
- server.listen(paths.socket, resolveListen);
75
- });
119
+ if (paths) await listen();
76
120
  server.on('error', (error) => { console.error(error.message); process.exitCode = 1; });
77
121
  try {
78
- writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
79
- if (existsSync(paths.hold)) {
122
+ if (paths) writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
123
+ if (paths && existsSync(paths.hold)) {
80
124
  server.close();
81
125
  return;
82
126
  }
@@ -87,23 +131,152 @@ async function supervise() {
87
131
  }
88
132
  process.on('SIGINT', onSignal);
89
133
  process.on('SIGTERM', onSignal);
134
+ process.on('message', (message) => { if (message === 'stop') onSignal(); });
135
+ process.on('disconnect', onSignal);
136
+
137
+ function listen() {
138
+ return new Promise((resolveListen, reject) => {
139
+ server.once('error', reject);
140
+ server.listen(paths.socket, resolveListen);
141
+ });
142
+ }
143
+
144
+ function bridgeRequest(request) {
145
+ if (!child?.connected ||
146
+ !readiness) return Promise.reject(new Error('Bridge has no verified managed readiness.'));
147
+ const requestId = randomUUID();
148
+ const requestedChild = child;
149
+ return new Promise((resolveRequest, reject) => {
150
+ const timer = setTimeout(() => {
151
+ pending.delete(requestId);
152
+ reject(new Error('Bridge admission result is uncertain; recovery required.'));
153
+ }, 11000);
154
+ pending.set(requestId, (message) => {
155
+ clearTimeout(timer);
156
+ if (requestedChild !== child) {
157
+ reject(new Error('Bridge generation changed during admission; hold remains in effect.'));
158
+ return;
159
+ }
160
+ resolveRequest(message);
161
+ });
162
+ child.send({
163
+ type: 'upgrade-control', requestId, action: request.action, timeoutMs: 8000,
164
+ expectedAuthority: request.expectedAuthority, expectedSessionState: request.expectedSessionState,
165
+ });
166
+ });
167
+ }
168
+
169
+ function observeLifetime(identity) {
170
+ return (async () => {
171
+ const { observeWindowsProcessLifetime } = await import('../bin/windows-process-lifetime.mjs');
172
+ return observeWindowsProcessLifetime(identity);
173
+ })();
174
+ }
90
175
 
91
176
  function launch() {
92
177
  if (stopRequested ||
93
- existsSync(paths.hold)) {
178
+ (paths && existsSync(paths.hold))) {
94
179
  server.close();
95
180
  return;
96
181
  }
97
- child = fork(join(root, 'supervisor', 'bridge-child.mjs'), ['--port', String(port), '--config', config], {
182
+ let completeLifetime;
183
+ let failLifetime;
184
+ let lifetimeArmed;
185
+ if (process.platform === 'win32') {
186
+ const requiredLifetime = new Promise((resolveLifetime, rejectLifetime) => {
187
+ completeLifetime = resolveLifetime;
188
+ failLifetime = rejectLifetime;
189
+ });
190
+ const proof = requiredLifetime.then(observer => observer.done);
191
+ proof.catch(() => {});
192
+ lifetimes.push(proof);
193
+ }
194
+ const armLifetime = identity => {
195
+ lifetimeArmed = observeLifetime(identity);
196
+ if (completeLifetime) lifetimeArmed.then(completeLifetime, failLifetime);
197
+ return lifetimeArmed;
198
+ };
199
+ readiness = undefined;
200
+ const launched = fork(join(root, 'supervisor', 'bridge-child.mjs'), [
201
+ '--port', String(port), '--config', config, ...(values.cwd ? ['--cwd', values.cwd] : []),
202
+ ], {
98
203
  execPath: process.execPath, execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true,
204
+ env: { ...process.env, MCP_PACEMAKER_UPGRADE_PENDING: pendingStart ? '1' : '0' },
205
+ });
206
+ child = launched;
207
+ launched.on('message', async (message) => {
208
+ if (message?.type === 'upgrade-lifetime') {
209
+ if (lifetimeArmed) return;
210
+ armLifetime(message.lifetime);
211
+ lifetimeArmed.then(() => {
212
+ if (launched.connected) launched.send('upgrade-lifetime-observed');
213
+ }, error => {
214
+ console.error(error.message);
215
+ onSignal();
216
+ });
217
+ return;
218
+ }
219
+ if (message?.type === 'upgrade-result') {
220
+ pending.get(message.requestId)?.(message);
221
+ pending.delete(message.requestId);
222
+ }
223
+ if (message?.type !== 'upgrade-ready') return;
224
+ try {
225
+ if (process.platform === 'win32' &&
226
+ !lifetimeArmed &&
227
+ message.lifetime) armLifetime(message.lifetime);
228
+ if (process.platform === 'win32' &&
229
+ !lifetimeArmed) throw new Error('Windows backend lacks verified process containment observation.');
230
+ if (lifetimeArmed) await lifetimeArmed;
231
+ if (stopRequested ||
232
+ launched !== child) return;
233
+ if (message.protocol !== 1 ||
234
+ message.version !== identity.version ||
235
+ !Number.isInteger(message.port) ||
236
+ message.port < 1 ||
237
+ (port !== 0 && port !== message.port)) throw new Error('Bridge readiness identity mismatch.');
238
+ if (port === 0) {
239
+ port = message.port;
240
+ paths = servicePaths(config, port);
241
+ identity.port = port;
242
+ identity.socket = paths.socket;
243
+ await listen();
244
+ }
245
+ readiness = message;
246
+ identity.instanceId = message.instanceId;
247
+ writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
248
+ if (process.connected) process.send({ ...message, type: 'supervisor-ready', supervisorId: identity.id });
249
+ } catch (error) {
250
+ console.error(error.message);
251
+ await stop(false);
252
+ }
99
253
  });
100
254
  childExit = new Promise((resolveExit, reject) => {
101
- child.once('error', reject);
102
- child.once('exit', (code) => {
255
+ launched.once('error', error => {
256
+ failLifetime?.(error);
257
+ reject(error);
258
+ });
259
+ launched.once('exit', (code) => {
103
260
  child = undefined;
104
261
  resolveExit();
262
+ if (process.platform === 'win32' &&
263
+ !lifetimeArmed) {
264
+ failLifetime(new Error('Current Windows bridge exited without lifetime ownership proof. Older unsupported protocols require separately verified shutdown; no completed receipt is available.'));
265
+ if (!stopRequested) {
266
+ process.exitCode = 1;
267
+ server.close();
268
+ if (process.connected) process.disconnect();
269
+ }
270
+ return;
271
+ }
105
272
  if (stopRequested) return;
106
- if (code === 3) server.close();
273
+ if (code === 3 ||
274
+ initialStartup) {
275
+ process.exitCode = code || 1;
276
+ stop(false).then(() => {
277
+ if (process.connected) process.disconnect();
278
+ }, error => { console.error(error.message); process.exitCode = 1; });
279
+ }
107
280
  else retry = setTimeout(launch, 2000);
108
281
  });
109
282
  });
@@ -125,29 +298,42 @@ async function supervise() {
125
298
  stopRequested = true;
126
299
  clearTimeout(retry);
127
300
  stopping = (async () => {
128
- if (child) {
129
- if (child.connected) {
130
- try {
131
- await new Promise((resolveSend, reject) => child.send('stop', (error) => error ? reject(error) : resolveSend()));
132
- } catch (error) {
133
- if (error.code !== 'ERR_IPC_CHANNEL_CLOSED' &&
134
- error.code !== 'EPIPE') throw error;
135
- }
136
- }
137
- let timeout;
138
- try {
139
- await Promise.race([
140
- childExit,
141
- new Promise((_, reject) => {
142
- timeout = setTimeout(() => reject(new Error('Bridge did not exit; do not replace its root. No force-kill attempted.')), 10000);
143
- }),
144
- ]);
145
- } finally {
146
- clearTimeout(timeout);
147
- }
301
+ let timeout;
302
+ try {
303
+ await Promise.race([
304
+ (async () => {
305
+ if (child?.connected) {
306
+ try {
307
+ await new Promise((resolveSend, reject) => child.send('stop', error => error ? reject(error) : resolveSend()));
308
+ } catch (error) {
309
+ if (error.code !== 'ERR_IPC_CHANNEL_CLOSED' &&
310
+ error.code !== 'EPIPE') throw error;
311
+ }
312
+ }
313
+ const [, ...receipts] = await Promise.all([childExit, ...lifetimes]);
314
+ for (const receipt of receipts) {
315
+ if (receipt?.verified !== true ||
316
+ receipt.ownerExited !== true ||
317
+ receipt.activeProcesses !== 0) throw new Error('Windows descendant teardown was not verified; retain the installation.');
318
+ }
319
+ })(),
320
+ new Promise((_, reject) => {
321
+ timeout = setTimeout(() => reject(new Error('Bridge did not exit; do not replace its root. No force-kill attempted.')), 10000);
322
+ }),
323
+ ]);
324
+ } finally {
325
+ clearTimeout(timeout);
148
326
  }
149
327
  if (holdRequested) {
150
- writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port, stopped: true }) + '\n', { mode: 0o600 });
328
+ writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port, stopped: true,
329
+ ...(stopAuthorization ? { quiescedInstanceId: stopAuthorization } : {}) }) + '\n', { mode: 0o600, flush: true });
330
+ }
331
+ if (process.connected) {
332
+ await new Promise((resolveSent, reject) => {
333
+ process.send({ type: 'supervisor-stopped', id: identity.id, stopped: true },
334
+ error => error ? reject(error) : resolveSent());
335
+ });
336
+ process.disconnect();
151
337
  }
152
338
  server.close();
153
339
  process.removeListener('SIGINT', onSignal);
@@ -0,0 +1,66 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Runs an explicitly authorized two-account Windows upgrade qualification.
4
+ .DESCRIPTION
5
+ Requires an existing elevated controller, existing logged-on target account and
6
+ existing target task. Does not create accounts, request elevation, change ACLs or
7
+ install toolchains. Without Execute it runs only the product's read-only plan.
8
+ .PARAMETER TrustedRoot
9
+ Existing protected, target-readable controller package directory.
10
+ .PARAMETER NodePath
11
+ Existing trusted Node executable.
12
+ .PARAMETER TargetSid
13
+ Canonical SID of the different existing target account.
14
+ .PARAMETER TaskPath
15
+ Exact existing per-user task.
16
+ .PARAMETER Version
17
+ Exact target version approved for this qualification.
18
+ .PARAMETER ControllerRoot
19
+ Existing protected operation-store parent, readable/traversable by the target.
20
+ .PARAMETER Registry
21
+ Explicit approved registry; use an owned registry for fixture qualification.
22
+ .PARAMETER Execute
23
+ Runs the interactive product transaction after its read-only plan succeeds.
24
+ .OUTPUTS
25
+ Product plan and operation results. This script does not attest readiness by itself.
26
+ .EXAMPLE
27
+ .\qualify-two-account.ps1 -TrustedRoot C:\ExistingProtectedTools\Pacemaker -NodePath C:\ExistingNode\node.exe -TargetSid S-1-5-21-1-2-3-1001 -TaskPath \OwnedPacemakerTask -Version 2.1.0 -ControllerRoot C:\ExistingProtectedOperations -Registry https://approved.example.test/npm/
28
+ #>
29
+ [CmdletBinding()]
30
+ param(
31
+ [Parameter(Mandatory)][string]$TrustedRoot,
32
+ [Parameter(Mandatory)][string]$NodePath,
33
+ [Parameter(Mandatory)][string]$TargetSid,
34
+ [Parameter(Mandatory)][string]$TaskPath,
35
+ [Parameter(Mandatory)][string]$Version,
36
+ [Parameter(Mandatory)][string]$ControllerRoot,
37
+ [Parameter(Mandatory)][string]$Registry,
38
+ [switch]$Execute
39
+ )
40
+ $ErrorActionPreference = 'Stop'
41
+ if (-not $IsWindows) { throw 'Windows qualification only.' }
42
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
43
+ $principal = [Security.Principal.WindowsPrincipal]::new($identity)
44
+ if ($identity.User.Value -ceq $TargetSid -or
45
+ -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
46
+ throw 'Use an already-elevated different controller account. This script never elevates.'
47
+ }
48
+ foreach ($path in @($TrustedRoot, $ControllerRoot)) {
49
+ if (-not (Test-Path -LiteralPath $path -PathType Container)) { throw 'Required existing protected directory is absent.' }
50
+ }
51
+ if (-not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw 'Required existing Node executable is absent.' }
52
+ $cli = Join-Path $TrustedRoot 'bin\cli.mjs'
53
+ $arguments = @(
54
+ $cli, '--user', $TargetSid, '--task', $TaskPath, 'upgrade', '--to', $Version,
55
+ '--controller-root', $ControllerRoot, '--registry', $Registry
56
+ )
57
+ & $NodePath @arguments --plan
58
+ if ($LASTEXITCODE -ne 0) { throw 'Read-only account/task/path/session eligibility did not pass.' }
59
+ if (-not $Execute) {
60
+ Write-Output 'Plan only. Actual two-account task mutation, original-account runtime and rollback remain NOT RUN.'
61
+ return
62
+ }
63
+ if ([Console]::IsInputRedirected) { throw 'Interactive confirmation is required; no unattended approval fallback.' }
64
+ & $NodePath @arguments
65
+ if ($LASTEXITCODE -ne 0) { throw 'Operation did not complete. Preserve both journals; do not force cleanup or replay calls.' }
66
+ Write-Output 'Inspect recorded worker/backend SID, session, version, task full-SD continuity, protected state and cleanup receipts before accepting qualification.'