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
@@ -0,0 +1,335 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, lstatSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const HELPER = fileURLToPath(new URL('./windows-task-channel/TaskChannelGuard.exe', import.meta.url));
7
+ const METADATA = fileURLToPath(new URL('./windows-task-channel/TaskChannelGuard.build.json', import.meta.url));
8
+ // A detached guard must not hold the caller's working directory open; that directory can be an
9
+ // instance, config or staging path the caller is entitled to replace or remove. Its own packaged
10
+ // directory is already locked while the running image is mapped, so it adds no new constraint.
11
+ const HELPER_DIRECTORY = fileURLToPath(new URL('./windows-task-channel/', import.meta.url));
12
+
13
+ function deferred() {
14
+ let resolve, reject;
15
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
16
+ promise.catch(() => {});
17
+ return { promise, resolve, reject };
18
+ }
19
+
20
+ class TaskChannel {
21
+ constructor() {
22
+ this.started = deferred();
23
+ this.worker = deferred();
24
+ this.completion = deferred();
25
+ this.pending = new Map();
26
+ this.frames = [];
27
+ this.waiters = [];
28
+ this.sequence = 0;
29
+ this.buffer = '';
30
+ this.failure = null;
31
+ this.intentionalClose = false;
32
+ }
33
+
34
+ verifyHelper() {
35
+ const metadataBytes = readFileSync(METADATA);
36
+ if (!lstatSync(METADATA).isFile() ||
37
+ lstatSync(METADATA).isSymbolicLink()) throw new Error('Invalid packaged task channel metadata path');
38
+ if (metadataBytes.length > 65536) throw new Error('Task channel metadata exceeds bounds');
39
+ const metadata = JSON.parse(metadataBytes);
40
+ const digest = metadata.binarySha256;
41
+ const valid = metadata.schemaVersion === 1 &&
42
+ metadata.binary === 'TaskChannelGuard.exe' &&
43
+ typeof digest === 'string' &&
44
+ digest.length === 64 &&
45
+ [...digest].every(character => '0123456789abcdef'.includes(character));
46
+ if (!valid) throw new Error('Invalid packaged task channel digest');
47
+ const stat = lstatSync(HELPER);
48
+ if (!stat.isFile() ||
49
+ stat.isSymbolicLink() ||
50
+ stat.size < 1 ||
51
+ stat.size > 4 * 1024 * 1024 ||
52
+ createHash('sha256').update(readFileSync(HELPER)).digest('hex') !== digest) {
53
+ throw new Error('Packaged task channel helper identity mismatch');
54
+ }
55
+ }
56
+
57
+ async start(request) {
58
+ if (process.platform !== 'win32') throw new Error('Task channel requires Windows');
59
+ this.verifyHelper();
60
+ const bytes = this.encode(request, 65536);
61
+ this.child = spawn(HELPER, [], {
62
+ cwd: HELPER_DIRECTORY, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
63
+ });
64
+ this.child.stdout.setEncoding('utf8');
65
+ this.child.stderr.setEncoding('utf8');
66
+ this.child.on('error', error => this.fail(error));
67
+ this.child.stdin.on('error', error => this.fail(error));
68
+ this.child.stdout.on('error', error => this.fail(error));
69
+ this.child.stderr.on('error', error => this.fail(error));
70
+ let diagnosticBytes = 0;
71
+ this.child.stderr.on('data', text => {
72
+ diagnosticBytes += Buffer.byteLength(text);
73
+ if (diagnosticBytes > 2048) this.fail(new Error('Unexpected task guard diagnostics'));
74
+ });
75
+ this.child.stdout.on('data', text => this.consume(text));
76
+ this.child.on('close', (code, signal) => {
77
+ clearTimeout(this.timer);
78
+ const receipt = { code, signal,
79
+ reason: this.failure?.message ?? this.closeReason ?? (code === 0 ? 'completed' : `native-exit-${code}`) };
80
+ this.exited = true;
81
+ this.exitReceipt = receipt;
82
+ this.fail(this.failure ?? new Error(`Task channel closed (native exit ${code})`));
83
+ this.completion.resolve(receipt);
84
+ });
85
+ this.timer = setTimeout(() => this.fail(new Error('Task channel ready deadline exceeded')), 15000);
86
+ this.child.stdin.write(bytes);
87
+ try {
88
+ const ready = await this.started.promise;
89
+ clearTimeout(this.timer);
90
+ this.timer = setTimeout(() => this.fail(new Error('Task channel lifetime exceeded')),
91
+ (request.lifetimeMs ?? 60000) + 3000);
92
+ return ready;
93
+ } catch (error) {
94
+ error.guardIdentity ??= structuredClone(this.guardIdentity);
95
+ try {
96
+ error.helperExit = await this.close();
97
+ error.cleanupUnverified = false;
98
+ } catch (closeError) {
99
+ error.cleanupUnverified = true;
100
+ error.cleanupError = closeError.message;
101
+ error.message += `; ${closeError.message}`;
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ encode(value, maximum = 16384) {
108
+ if (!value ||
109
+ typeof value !== 'object' ||
110
+ Array.isArray(value)) throw new Error('Task channel requires an object frame');
111
+ const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
112
+ if (bytes.length > maximum) throw new Error('Task channel frame exceeds bounds');
113
+ return bytes;
114
+ }
115
+
116
+ consume(text) {
117
+ this.buffer += text;
118
+ if (Buffer.byteLength(this.buffer) > 131072) return this.fail(new Error('Task channel output exceeds bounds'));
119
+ let newline;
120
+ while ((newline = this.buffer.indexOf('\n')) >= 0) {
121
+ const line = this.buffer.slice(0, newline);
122
+ this.buffer = this.buffer.slice(newline + 1);
123
+ try { this.event(JSON.parse(line)); }
124
+ catch (error) { this.fail(error); return; }
125
+ }
126
+ }
127
+
128
+ event(message) {
129
+ if (!message ||
130
+ typeof message.type !== 'string') throw new Error('Invalid task channel event');
131
+ if (message.type === 'started') {
132
+ const identity = message.guardIdentity;
133
+ if (this.guardIdentity ||
134
+ identity?.pid !== this.child.pid ||
135
+ typeof identity.creationTime !== 'string') throw new Error('Invalid task guard startup identity');
136
+ this.guardIdentity = structuredClone(identity);
137
+ } else if (message.type === 'failed') {
138
+ const error = new Error(`Task channel refused: ${message.reason}`);
139
+ error.code = message.reason;
140
+ error.guardIdentity = message.guardIdentity;
141
+ error.detail = message;
142
+ this.fail(error);
143
+ } else if (message.type === 'ready' ||
144
+ message.type === 'result') {
145
+ if (this.ready) throw new Error('Duplicate task channel readiness');
146
+ if (message.guardIdentity?.pid !== this.child.pid) throw new Error('Task guard process binding mismatch');
147
+ this.ready = message;
148
+ this.started.resolve(structuredClone(message));
149
+ } else if (message.type === 'worker') {
150
+ if (!this.ready ||
151
+ this.peer) throw new Error('Unexpected task channel peer');
152
+ this.peer = structuredClone(message.peer);
153
+ this.workerSession = structuredClone(message.targetSession);
154
+ this.worker.resolve(structuredClone(this.peer));
155
+ } else if (message.type === 'frame') {
156
+ if (!this.peer ||
157
+ JSON.stringify(message.peer) !== JSON.stringify(this.peer)) throw new Error('Task channel peer changed');
158
+ const received = { peer: structuredClone(this.peer), frame: message.frame };
159
+ if (this.waiters.length) this.waiters.shift().resolve(received);
160
+ else {
161
+ if (this.frames.length >= 32) throw new Error('Task channel receive queue exceeds bounds');
162
+ this.frames.push(received);
163
+ }
164
+ } else if (message.type === 'sent' ||
165
+ message.type === 'authorized') {
166
+ const pending = this.pending.get(message.id);
167
+ if (!pending) throw new Error('Unexpected task channel acknowledgement');
168
+ this.pending.delete(message.id);
169
+ pending.resolve(structuredClone(message));
170
+ } else if (message.type === 'closed') {
171
+ this.closeReason = message.reason;
172
+ } else throw new Error('Unknown task channel event');
173
+ }
174
+
175
+ fail(error) {
176
+ this.failure ??= error;
177
+ this.failure.guardIdentity ??= structuredClone(this.guardIdentity);
178
+ this.started.reject(this.failure);
179
+ this.worker.reject(this.failure);
180
+ for (const pending of this.pending.values()) pending.reject(this.failure);
181
+ this.pending.clear();
182
+ for (const waiter of this.waiters) waiter.reject(this.failure);
183
+ this.waiters = [];
184
+ this.frames = [];
185
+ if (this.child?.stdin.writable) this.child.stdin.end();
186
+ }
187
+
188
+ request(action, frame) {
189
+ if (this.failure) return Promise.reject(this.failure);
190
+ if (!this.peer &&
191
+ action !== 'authorize') return Promise.reject(new Error('Task channel peer is not verified'));
192
+ if (this.pending.size >= 32) return Promise.reject(new Error('Task channel command queue exceeds bounds'));
193
+ if (frame !== undefined) this.encode(frame);
194
+ const id = ++this.sequence;
195
+ const pending = deferred();
196
+ this.pending.set(id, pending);
197
+ this.child.stdin.write(this.encode({ action, id, ...(frame === undefined ? {} : { frame }) }, 65536));
198
+ return pending.promise;
199
+ }
200
+
201
+ receive() {
202
+ if (this.failure) return Promise.reject(this.failure);
203
+ if (this.frames.length) return Promise.resolve(this.frames.shift());
204
+ if (this.waiters.length >= 32) return Promise.reject(new Error('Task channel waiter queue exceeds bounds'));
205
+ const pending = deferred();
206
+ this.waiters.push(pending);
207
+ return pending.promise;
208
+ }
209
+
210
+ async close() {
211
+ if (!this.child) return;
212
+ if (!this.exited &&
213
+ !this.intentionalClose) {
214
+ this.intentionalClose = true;
215
+ if (this.child.stdin.writable) this.child.stdin.write('{"action":"close"}\n');
216
+ }
217
+ let timer;
218
+ try {
219
+ return await Promise.race([this.completion.promise, new Promise((_, reject) => {
220
+ timer = setTimeout(() => {
221
+ if (this.child.stdin.writable) this.child.stdin.end();
222
+ const error = new Error('Task guard exit unverified; retain owned cleanup evidence');
223
+ error.guardIdentity = structuredClone(this.guardIdentity);
224
+ error.cleanupUnverified = true;
225
+ reject(error);
226
+ }, 5000);
227
+ })]);
228
+ } finally { clearTimeout(timer); }
229
+ }
230
+ }
231
+
232
+ export async function createTaskChannel(options) {
233
+ const channel = new TaskChannel();
234
+ const { operationId, targetSid, sessionId, bootstrap, manifestParent, manifest,
235
+ expectedCreationTime, handshakeTimeoutMs = 30000, lifetimeMs = 900000 } = options;
236
+ const ready = await channel.start({
237
+ action: 'serve', operationId, targetSid, sessionId, bootstrap, manifestParent,
238
+ document: manifest, expectedCreationTime, handshakeTimeoutMs, lifetimeMs,
239
+ });
240
+ return {
241
+ ...ready,
242
+ pid: channel.child.pid,
243
+ awaitWorker: () => channel.worker.promise,
244
+ get workerSession() { return structuredClone(channel.workerSession); },
245
+ send: frame => channel.request('send', frame),
246
+ authorize: () => channel.request('authorize'),
247
+ receive: () => channel.receive(),
248
+ closed: channel.completion.promise,
249
+ close: () => channel.close(),
250
+ };
251
+ }
252
+
253
+ async function query(request) {
254
+ const channel = new TaskChannel();
255
+ const result = await channel.start(request);
256
+ const exit = await channel.completion.promise;
257
+ if (exit.code !== 0 ||
258
+ exit.signal !== null) throw new Error(`Task channel query failed: ${exit.reason}`);
259
+ return { ...result, helperExit: exit };
260
+ }
261
+
262
+ export function queryTaskChannelCaller() {
263
+ return query({ action: 'caller' });
264
+ }
265
+
266
+ export async function queryCliCallerContext() {
267
+ const result = await query({ action: 'cli-context' });
268
+ if (result.proofScope !== 'cli-effective-context-snapshot' ||
269
+ result.identity?.pid !== process.pid ||
270
+ typeof result.ordinaryEligible !== 'boolean') {
271
+ throw new Error('CLI context caller binding or proof scope is unverified');
272
+ }
273
+ return result;
274
+ }
275
+
276
+ export async function queryCurrentUserSession() {
277
+ const result = await query({ action: 'current-session' });
278
+ const session = result.session;
279
+ if (result.proofScope !== 'current-user-session-snapshot' ||
280
+ result.identity?.pid !== process.pid ||
281
+ typeof result.identity?.ownerSid !== 'string' ||
282
+ result.identity.ownerSid.length === 0 ||
283
+ !Number.isInteger(result.identity.sessionId) ||
284
+ result.identity.sessionId <= 0 ||
285
+ session?.ownerSid !== result.identity.ownerSid ||
286
+ session?.sessionId !== result.identity.sessionId ||
287
+ !['active', 'disconnected'].includes(session?.state) ||
288
+ typeof session?.logonTime !== 'string' ||
289
+ !/^[1-9][0-9]{0,18}$/.test(session.logonTime)) {
290
+ throw new Error('Current user session binding is unverified');
291
+ }
292
+ return result;
293
+ }
294
+
295
+ export async function requireOrdinaryUpgradeCaller() {
296
+ if (process.platform !== 'win32') return;
297
+ const caller = await queryCliCallerContext();
298
+ const facts = caller.actorFacts;
299
+ const observation = caller.observation;
300
+ const ordinary = caller.ordinaryEligible === true &&
301
+ facts?.ownerSid === caller.identity.ownerSid &&
302
+ facts?.sessionId === caller.identity.sessionId &&
303
+ facts?.elevated === false &&
304
+ facts?.enabledAdministrator === false &&
305
+ facts?.guardThreadImpersonating === false &&
306
+ facts?.parentThreadImpersonation === 'observed-none' &&
307
+ observation?.method === 'pss-threads-held-token-query' &&
308
+ observation?.processAccess === '0x101400' &&
309
+ observation?.captureFlags === '0x80' &&
310
+ observation?.threadContextFlags === 0 &&
311
+ observation?.completeStableThreadSet === true &&
312
+ observation?.primaryStable === true &&
313
+ observation?.atomicFutureProtection === false &&
314
+ Number.isInteger(observation?.threadCount) &&
315
+ observation.threadCount > 0 &&
316
+ observation.threadCount <= 128 &&
317
+ caller.helperExit?.code === 0 &&
318
+ caller.helperExit?.signal === null;
319
+ if (!ordinary) {
320
+ // Include the native reason: an ambiguous observation and a genuinely privileged caller are
321
+ // both refused, and without this they are indistinguishable after the fact.
322
+ const reason = typeof caller.reason === 'string' ? caller.reason : 'UNREPORTED';
323
+ throw new Error('Windows upgrade execution requires a verified ordinary caller; no elevation or runtime-authority fallback was attempted. ' +
324
+ `Observed: ${reason}.`);
325
+ }
326
+ return caller;
327
+ }
328
+
329
+ export function readProtectedManifest(path, { operationId, manifestParent }) {
330
+ return query({ action: 'read-manifest', path, operationId, manifestParent });
331
+ }
332
+
333
+ export function inspectTrustedCodeRoot(root) {
334
+ return query({ action: 'inspect-root', root });
335
+ }
@@ -0,0 +1,205 @@
1
+ # Explicit original-account upgrades on Windows
2
+
3
+ The default scope is **the current user**, even from an elevated terminal.
4
+ Scope does not authorize elevated runtime execution: without `--user`, Windows
5
+ upgrade and recovery require a freshly verified ordinary caller before staging
6
+ or mutation. Read-only planning remains available from an elevated terminal.
7
+ The shared [user-runtime contract](execution-context.md) also applies to normal
8
+ startup and to non-root Linux/macOS runtime operations.
9
+ `--all-users` is not implemented. Selecting every host in `init` is not an
10
+ all-users installation, and managing one other user's instance does not convert
11
+ it into a machine service or LocalSystem service.
12
+
13
+ An explicit account operation requires a canonical numeric SID and exactly one
14
+ existing task or managed-instance selector:
15
+
16
+ ```text
17
+ mcp-pacemaker --user <SID> --task <exact-task-path> upgrade --to <version> --plan
18
+ mcp-pacemaker --user <SID> --task <exact-task-path> upgrade --to <version>
19
+ mcp-pacemaker --user <SID> --instance <directory> upgrade --recover
20
+ ```
21
+
22
+ ### Supported option combinations
23
+
24
+ These limits apply whenever `--user` selects the task-controller path, including
25
+ an explicit selection of the current user's SID.
26
+
27
+ | Form | Explicit account behavior |
28
+ |---|---|
29
+ | `--user SID --task PATH upgrade --to VERSION [--plan]` | One existing legacy task; preserve its account and port |
30
+ | `--user SID --instance DIR upgrade --to VERSION [--plan]` | One registered managed instance |
31
+ | `--user SID --instance DIR upgrade --recover` | Recover only its recorded operation; interactive authorization remains required |
32
+ | `--sxs`, with or without `--port` | Unsupported; refuse before native/task discovery |
33
+ | `--port` without SxS | Unsupported; normal replacement preserves the selected port |
34
+ | Bare `upgrade` or `upgrade --self` | Unsupported in explicit account scope; select `--to` or `--recover` |
35
+ | Recovery with `--task`, `--to`, `--plan` or `--registry` | Unsupported; refuse rather than ignore or override an option |
36
+ | Both `--task` and `--instance`, `--config`, or `--all-users` | Refuse; no scope conversion or new registration is inferred |
37
+ | Execution with `--yes` | Refuse; it cannot replace the two interactive confirmations. With read-only `--to ... --plan`, it authorizes no write |
38
+
39
+ Selectors accept separate or equals syntax before or after `upgrade`.
40
+ Without `--user`, current-user commands include bare repair,
41
+ `--self` guidance, managed SxS with optional port, and selected-instance recovery.
42
+ No machine-wide or cross-account SxS task-creation path is added.
43
+
44
+ Another user requires an **already-elevated** administrator token, enabled
45
+ Administrators membership, and complete native authorization checks. Restricted,
46
+ AppContainer, impersonating or unverified cross-account contexts refuse.
47
+ No account label, saved administrator flag or group membership alone grants
48
+ authority. The product does not request UAC, enable privileges, impersonate,
49
+ supply passwords, create accounts or change existing permissions.
50
+
51
+ ## Where code executes
52
+
53
+ Invoke the management CLI from an independently trusted installation. The
54
+ elevated controller never imports the target's CLI or recovery code and never
55
+ runs its Node/npm, dependency hooks, authentication commands or workloads.
56
+ Target records are bounded data. Elevated code roots with untrusted writers or
57
+ redirectable ancestors refuse; a hash does not turn writable code into trusted code.
58
+
59
+ Ordinary selected-CLI commands use a fresh native effective-context observation.
60
+ Only a non-elevated, non-administrator primary token plus a complete stable set
61
+ of held parent threads with no observed impersonation token can avoid the
62
+ administrator-path recursive root checks. Primary-token elevation alone is not
63
+ enough. Unknown, denied, changed or impersonating observations retain the strict
64
+ checks with a reason; unverified helper exit is fatal. Target JSON cannot select
65
+ this path. Package inventory verification still runs, and the observation does
66
+ not promise that the process can never change context afterward.
67
+
68
+ The existing task launches a bounded worker under its **unchanged original
69
+ principal, InteractiveToken logon type and run level**. That worker performs
70
+ staging, dependency acquisition, preflight, the existing same-user stop protocol,
71
+ runtime startup and rollback. Task Scheduler success is only correlation:
72
+ disabled tasks and `AllowDemandStart=false` do not prove a worker ran.
73
+ An eligible existing interactive session and actual authenticated worker are required.
74
+ Explicit account inspection also requires the selected user's profile facts from
75
+ `Win32_UserProfile`. An unavailable provider or unreadable profile stops the operation;
76
+ the adapter does not infer another user's profile from environment variables or repair WMI.
77
+ Only non-elevated (`RunLevel=0`) InteractiveToken registrations are supported;
78
+ other logon/run-level choices refuse rather than being converted.
79
+
80
+ The guard queries only the selected session. Full account-name resolution must
81
+ match the target SID; the session must be active or disconnected but still logged
82
+ on. Stable WTS snapshots bind its logon generation before bootstrap, after worker
83
+ connection and at each authorization. Missing, denied, changed or unmapped facts
84
+ refuse. The approval prompt is followed by another check before staging.
85
+ These are **point-in-time eligibility checks**, not held session ownership:
86
+ Windows does not atomically reserve that desktop across `RunEx`. Detected drift
87
+ aborts; no session is created, switched, disconnected or impersonated.
88
+
89
+ The selected task's owner may be its runtime user or canonical
90
+ `BUILTIN\Administrators` (`S-1-5-32-544`). Other accounts called “Administrator”
91
+ are not assumed privileged. Owner, group, DACL, SACL and integrity are captured,
92
+ bound and preserved independently from run-as identity. Missing, denied or
93
+ changed full security refuses; it is never repaired or relaxed.
94
+
95
+ ## Controller capability and channel
96
+
97
+ The controller can only inspect/hold/repoint/restore/release the exact approved
98
+ registration. Paths, account, original XML/security and allowed variants are
99
+ sealed before worker requests. Worker data cannot select a different task,
100
+ executable, principal or privileged configuration write.
101
+
102
+ A separate query-only native guard owns one local pipe connection and retains
103
+ the actual worker PID/creation/token/session identity. It has no process-kill
104
+ interface. A protected per-operation manifest carries an ephemeral public key;
105
+ Node's built-in Ed25519 signatures bind a fresh challenge, operation digest,
106
+ guard-observed peer and monotonic request/response transcript. Private keys stay
107
+ in controller memory. There is no reconnect-by-PID or nonce-only fallback.
108
+ Signed replies also bind the checked session SID, ID and logon generation.
109
+ Peer identity is not loaded-code attestation; the fixed capability remains safe
110
+ even if an authenticated target-user worker lies.
111
+
112
+ Only new operation-directory/file/pipe security is established at creation.
113
+ The target must already be able to read the trusted bootstrap and traverse/read
114
+ the protected operation store. `--controller-root <existing-directory>` selects
115
+ an already suitable store; it does not install into Program Files or repair ACLs.
116
+ The default store parent is the controller's local application-data directory.
117
+ If its existing ancestors do not support the required target access/protection,
118
+ the operation refuses before stopping the backend.
119
+
120
+ Automatic triggers/restarts are held while deliberate demand start remains
121
+ possible. Manual/external task starts are not claimed suppressed. Conflicting
122
+ generations or revisions refuse. Because `IgnoreNew` can block a second task run
123
+ while the worker is active, verified legacy rollback launches the original
124
+ supervisor locally **inside the original-user worker**, not as the administrator.
125
+ Legacy captured roots and observed descendants are checked for the worker's
126
+ session before stop; native held-generation revalidation remains stop authority.
127
+ Excluded or unattributed historical processes are never session-query or kill targets.
128
+ The compact session request accepts at most 512 unique captured identities and
129
+ 128 KiB of UTF-8. PID, FILETIME and current-account/session fields are checked
130
+ before discovery. Session queries use at most 32 exact IDs per batch and four
131
+ separate local CIM sessions, within the unchanged 15-second caller bound.
132
+ Missing, duplicate, stale or unexpected results refuse the whole operation.
133
+
134
+ ## Failure and recovery
135
+
136
+ Declining the runtime-upgrade prompt after worker bootstrap restores the exact
137
+ pre-bootstrap task revision after fresh authorization. No staging or stop is
138
+ authorized by that decline. Failed authorization or restoration remains an
139
+ explicit uncertain outcome.
140
+
141
+ ### Controller-only failure before instance publication
142
+
143
+ If bootstrap happened but automatic restoration failed before any managed
144
+ instance was published, **there is no automatic CLI recovery command for that
145
+ state**. `--instance <missing-directory>` cannot work, and explicit-account
146
+ `--task ... upgrade --recover` is unsupported. Do not create an instance record,
147
+ guess an operation, or edit target configuration to make either command run.
148
+
149
+ The error and `Recovery evidence:` output name the exact protected operation
150
+ directory. Keep both files:
151
+
152
+ - `manifest.json`: `document.initialTask` is the pre-bootstrap task snapshot;
153
+ `document.binding.initialTaskRevision` binds it.
154
+ - `controller-journal.json`: `initial`, the last verified `current` revision,
155
+ full task security, and the uncertain phase.
156
+
157
+ The supported path is **manual verified restoration of that exact task** by an
158
+ authorized operator using the existing Task Scheduler COM adapter/tooling:
159
+
160
+ 1. Verify the protected evidence, task path, SID and initial revision agree.
161
+ Missing or inconsistent evidence is a refusal, not permission to reconstruct it.
162
+ 2. Re-establish the controller's required actual authority and fresh target
163
+ SID/session/logon-generation eligibility. Saved token/session facts are not
164
+ authorization; denied or changed checks remain blocking.
165
+ 3. Read the actual task XML and complete security descriptor again. Reconcile
166
+ it with the captured revisions before any write; do not overwrite an unknown
167
+ intervening change.
168
+ 4. Restore the saved initial definition using update-only registration while
169
+ preserving the principal, logon type, run level and raw full descriptor.
170
+ Re-read and verify the result. Importing XML alone or repairing permissions
171
+ is not this procedure.
172
+
173
+ This manual path restores task registration only. It does not authorize runtime
174
+ stop/start, replay, account/session changes, or administrator writes to target
175
+ state. If those checks cannot be completed, retain the evidence and leave the
176
+ operation blocked.
177
+
178
+ Controller and worker journals share an operation ID but have separate authority.
179
+ Loss of the peer/channel ends privileged RPC; it does not authorize killing an
180
+ orphan worker. Uncertain task, stop, launch or admission outcomes remain explicit.
181
+ No unknown tool outcome is replayed.
182
+
183
+ Recovery requires the exact managed instance and its protected prior controller
184
+ record, fresh actor/task/account authorization and a new bootstrap/handshake.
185
+ The initial published legacy instance already contains that operation reference;
186
+ recovery does not depend on a later HOLD acknowledgement or administrator edits
187
+ to target state. The reference, protected manifest, controller record and target
188
+ config/port must agree. Settled committed, aborted and rolled-back transactions,
189
+ including their legacy forms, return without another worker or task mutation.
190
+ An old authorization flag is never reused. Existing stale-lock and uncertain-
191
+ admission refusals still apply; the controller does not edit target configuration
192
+ or force-remove its locks.
193
+
194
+ ## Qualification boundary
195
+
196
+ Owned same-user pipe/process tests exercise the real query-only guard and worker.
197
+ The production Task Scheduler adapters are separately exercised against injected
198
+ COM objects. Neither establishes successful elevated **two-account** task/runtime
199
+ behavior. Real task security visibility does not establish mutation preservation.
200
+
201
+ `tools/account-upgrade/qualify-two-account.ps1` is a runnable qualification entry
202
+ for a separately authorized Windows environment with pre-existing accounts,
203
+ session, task, protected toolchain and operation store. It defaults to read-only
204
+ planning; `-Execute` still requires the product's interactive confirmations.
205
+ It must not be run on a live installation merely because the code is present.
@@ -0,0 +1,113 @@
1
+ # User-runtime execution contracts
2
+
3
+ The default runtime is an ordinary process belonging to its user. Starting the
4
+ bridge or supervisor, registering autostart, upgrading and recovering do not
5
+ silently elevate, demote, impersonate or select another account. An unavailable
6
+ or privileged context refuses before runtime work. Read-only planning and
7
+ installation with both `--no-start` and `--no-autostart` remain separate.
8
+
9
+ `bin/runtime-context.mjs` is the shared observation boundary:
10
+
11
+ | Platform | Required effective context |
12
+ | --- | --- |
13
+ | Windows | The existing native ordinary-caller proof: no elevation, enabled administrator membership or observed thread impersonation |
14
+ | Linux | Stable non-root real/effective IDs, matching saved/filesystem IDs, and zero inherited/permitted/effective/ambient capabilities |
15
+ | macOS | Stable non-root real/effective user IDs and matching real/effective group IDs |
16
+
17
+ These are snapshots, not a promise that a process can never change context.
18
+ The guard does not grant access to an arbitrary installation. Existing instance,
19
+ file-security, service-registration and account-authorization checks still apply.
20
+ Explicit original-account administration remains the separate Windows-only
21
+ capability described in [account upgrades](account-upgrades.md).
22
+
23
+ ## Test contracts
24
+
25
+ `tools/ci/test-contracts.mjs` declares the execution role, command and required
26
+ capabilities. It verifies that the source contract includes every registered
27
+ source test and preserves the declared test concurrency.
28
+
29
+ | Contract | Required role and coverage |
30
+ | --- | --- |
31
+ | `source` | Ordinary runtime; complete registered source suite. Windows also needs the real caller's WTS logon because its account/session tests use that API. |
32
+ | `source-ordinary` | Windows only. Ordinary runtime without a logon session; every registered file except the four in `SESSION_REQUIRED_TESTS`. The two Windows lanes must together cover every registered file exactly once. |
33
+ | `compatibility` | Ordinary runtime; complete CLI/API compatibility command |
34
+ | `browser` | Ordinary runtime; declared browser compatibility command |
35
+ | `audit` | Verified privileged Windows fixture; the unchanged mandatory audit test, including its ordinary worker and complete result checks |
36
+ | `service-context` | Read-only capability observation; **not** test coverage |
37
+
38
+ Run the same entry point locally or in CI:
39
+
40
+ ```text
41
+ npm run test:contract -- plan
42
+ npm run test:contract -- source --check
43
+ npm run test:contract -- source
44
+ npm run test:contract -- compatibility
45
+ npm run test:contract -- service-context --check
46
+ ```
47
+
48
+ The service-context observation uses the current user's WTS session on Windows,
49
+ owned `/run/user/<uid>` and user `systemd` manager on Linux, or the caller's `gui`
50
+ launchd domain on macOS. A CLI-only or browser contract does not require a desktop
51
+ merely for consistency with Windows.
52
+
53
+ No adapter creates a user or session, changes UAC, edits privileges, rewrites a
54
+ token, or falls back to another identity. Setup capability and ordinary runtime
55
+ capability are not interchangeable. A missing required capability is a blocked
56
+ qualification, not a successful skip.
57
+
58
+ Windows integration fixtures also need a private checkout, home and temporary
59
+ tree whose ancestors satisfy the existing path-security checks. A shared writable
60
+ drive can correctly fail with `UNTRUSTED_PATH_WRITER` even when the process is
61
+ ordinary. Use short, trusted profile-owned test roots: the nested upgrade packages
62
+ must remain within the native .NET executable path limits, not just Node's file
63
+ API limits. Do not change drive ACLs, enable long-path policy or relax the native
64
+ checks to make an unsuitable test root pass.
65
+
66
+ ## CI and release boundaries
67
+
68
+ The public workflow separates three roles rather than assuming an OS label implies a runtime.
69
+
70
+ | Lane | Runner | What it proves |
71
+ | --- | --- | --- |
72
+ | `test (ubuntu-latest\|macos-latest, 20\|22\|24)` | Stock hosted | Ordinary user-runtime source and compatibility suites |
73
+ | `windows-ordinary (20\|22\|24)` | Stock hosted Windows | Install, syntax, pinned native toolchain, three reproducible native builds, the actual refusal of an elevated built-in Administrator, then the `source-ordinary` and `compatibility` contracts run as a provisioned disposable standard account |
74
+ | Windows session lane | Ordinary interactive Windows session | The four `SESSION_REQUIRED_TESTS` files, through the local publication gate |
75
+
76
+ A stock hosted Windows runner signs in as the built-in Administrator with a full elevated token, and
77
+ Task Scheduler ignores `RunLevel` for that account, so that account cannot host the ordinary
78
+ user-runtime contract. The lane therefore does not try to demote it. It provisions a disposable
79
+ standard account, which is never an administrator, and runs the suite as that account. It also
80
+ proves the elevated refusal itself, and `tools/ci/windows-runner-role.mjs` fails loudly if a Windows
81
+ runner ever does satisfy the ordinary contract, so real coverage is promoted rather than left
82
+ unexecuted.
83
+
84
+ A provisioned account signs in with a password logon and has no interactive logon session, so the
85
+ four `SESSION_REQUIRED_TESTS` files that bind one stay in the session lane. They are still required,
86
+ not skipped: `tools/npm-publication/local-gate.mjs` runs the complete source suite for the current
87
+ release and requires `role: user-runtime` with `sessionVerified: true`. A Windows container without a
88
+ real logged-on account cannot satisfy that, and neither can a hosted job. `testContract` asserts the
89
+ two Windows lanes cover every registered file exactly once, so no file can fall out of both.
90
+
91
+ The CI provider bounds job lifetime and owns job teardown; the contract entry
92
+ point does not claim to provide VM or network isolation.
93
+
94
+ The local command forwards cancellation to its owned test child and waits for
95
+ exit/output closure, escalating termination after five seconds. Unverified
96
+ closure after another five seconds is an error, not successful cleanup. Source
97
+ tests retain a 60-minute command budget and compatibility tests a 45-minute budget;
98
+ the audit keeps its existing 210-second budget and a 16 MiB output limit.
99
+ Windows commands also use the existing native process-lifetime mechanism.
100
+ None of these command bounds changes application startup or shutdown deadlines.
101
+
102
+ No integration runner is provisioned by this change. An approved environment
103
+ must already supply the required ordinary identity and session. Do not attach a
104
+ personal workstation or unrestricted persistent runner to public pull requests.
105
+ Runner admission, trusted-source selection, disposable-machine isolation and
106
+ credential policy require independent infrastructure approval.
107
+
108
+ The existing exact-source CI and local publication requirements remain enforced.
109
+ The local source gate invokes the same read-only preflight and its verifier
110
+ requires that original command receipt. A Windows container without WTS cannot
111
+ stand in for an interactive user session. The privileged audit companion remains
112
+ required and cannot replace the source suite. No missing integration result is
113
+ relabelled as a pass.