@ctrl-spc/cs 0.7.14 → 0.7.16

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.
@@ -0,0 +1,312 @@
1
+ import { execFile, execFileSync } from 'node:child_process';
2
+ import { existsSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { ensureLifecycleDir } from './config.js';
7
+ const execute = promisify(execFile);
8
+ /** A Job Object owns descendants even after an intermediate .cmd wrapper exits.
9
+ * Windows 10's JOB_LIST creation attribute puts the first instruction inside the
10
+ * job atomically. Create-suspended/assign/resume leaves an orphan window if the
11
+ * helper dies between those calls. This executable uses only the Windows/.NET
12
+ * runtime already used by our PowerShell helpers; no downloaded native binary. */
13
+ const source = String.raw `
14
+ using System;
15
+ using System.IO;
16
+ using System.Text;
17
+ using System.Threading;
18
+ using System.ComponentModel;
19
+ using System.Globalization;
20
+ using System.Runtime.InteropServices;
21
+ using System.Security.Principal;
22
+ using System.Runtime.Serialization;
23
+ using System.Runtime.Serialization.Json;
24
+ public class CtrlSpcOwnedJob {
25
+ [StructLayout(LayoutKind.Sequential)] struct SA { public int size; public IntPtr descriptor; public int inherit; }
26
+ [StructLayout(LayoutKind.Sequential)] struct BasicLimits { public long processTime, jobTime; public uint flags; public UIntPtr minWorking, maxWorking; public uint activeLimit; public UIntPtr affinity; public uint priority, scheduling; }
27
+ [StructLayout(LayoutKind.Sequential)] struct IO { public ulong readOps, writeOps, otherOps, readBytes, writeBytes, otherBytes; }
28
+ [StructLayout(LayoutKind.Sequential)] struct Limits { public BasicLimits basic; public IO io; public UIntPtr processMemory, jobMemory, peakProcess, peakJob; }
29
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct SI { public int size; public string reserved, desktop, title; public int x,y,xSize,ySize,xChars,yChars,fill,flags; public short show,reservedSize; public IntPtr reservedBytes,input,output,error; }
30
+ [StructLayout(LayoutKind.Sequential)] struct SIEX { public SI startup; public IntPtr attributes; }
31
+ [StructLayout(LayoutKind.Sequential)] struct PI { public IntPtr process,thread; public uint pid,tid; }
32
+ [DataContract] public class Launch { [DataMember] public string application; [DataMember] public string command; [DataMember] public string cwd; }
33
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(ref SA sa, string name);
34
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr OpenJobObject(uint rights, bool inherit, string name);
35
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref Limits limits, uint length);
36
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, IntPtr data, uint size, out uint written);
37
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code);
38
+ [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint rights, bool inherit, uint pid);
39
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetProcessTimes(IntPtr process, out long created, out long exited, out long kernel, out long user);
40
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr process, uint code);
41
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool assigned);
42
+ [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
43
+ [DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint rights, out IntPtr token);
44
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr bytes);
45
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr kind, IntPtr value, IntPtr size, IntPtr previous, IntPtr returned);
46
+ [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list);
47
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string app, StringBuilder command, IntPtr processSecurity, IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string cwd, ref SIEX startup, out PI info);
48
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool ReadFile(IntPtr file, byte[] bytes, uint count, out uint read, IntPtr overlapped);
49
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref SA security, uint size);
50
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateFile(string path, uint access, uint share, ref SA security, uint creation, uint flags, IntPtr template);
51
+ [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind);
52
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags);
53
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint ms);
54
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code);
55
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
56
+ [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
57
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string value, uint revision, out IntPtr descriptor, IntPtr size);
58
+ static void Check(bool result) { if (!result) throw new Win32Exception(Marshal.GetLastWin32Error()); }
59
+ static string Argument(string value) {
60
+ StringBuilder result=new StringBuilder("\""); int slashes=0;
61
+ foreach(char c in value) {
62
+ if(c=='\\') { slashes++; continue; }
63
+ result.Append('\\',c=='\"'?slashes*2+1:slashes).Append(c); slashes=0;
64
+ }
65
+ return result.Append('\\',slashes*2).Append('"').ToString();
66
+ }
67
+ static string Command(params string[] args) { return String.Join(" ",Array.ConvertAll(args,Argument)); }
68
+ static PI CreateWithHandles(string app,string command,string cwd,IntPtr input,IntPtr output,IntPtr error,IntPtr job,uint flags) {
69
+ SIEX startup=new SIEX(); startup.startup.size=Marshal.SizeOf(typeof(SIEX)); startup.startup.flags=0x100;
70
+ startup.startup.input=input; startup.startup.output=output; startup.startup.error=error;
71
+ var standard=new System.Collections.Generic.List<IntPtr>();
72
+ foreach(IntPtr handle in new IntPtr[]{input,output,error}) if(!standard.Contains(handle)) standard.Add(handle);
73
+ int count=job==IntPtr.Zero?1:2; IntPtr bytes=IntPtr.Zero; InitializeProcThreadAttributeList(IntPtr.Zero,count,0,ref bytes);
74
+ startup.attributes=Marshal.AllocHGlobal(bytes); IntPtr jobs=Marshal.AllocHGlobal(IntPtr.Size), handles=Marshal.AllocHGlobal(IntPtr.Size*standard.Count); bool initialized=false;
75
+ try {
76
+ Check(InitializeProcThreadAttributeList(startup.attributes,count,0,ref bytes)); initialized=true;
77
+ if(job!=IntPtr.Zero) {
78
+ Marshal.WriteIntPtr(jobs,job);
79
+ Check(UpdateProcThreadAttribute(startup.attributes,0,new IntPtr(0x2000D),jobs,new IntPtr(IntPtr.Size),IntPtr.Zero,IntPtr.Zero));
80
+ }
81
+ for(int i=0;i<standard.Count;i++) { Check(SetHandleInformation(standard[i],1,1)); Marshal.WriteIntPtr(handles,i*IntPtr.Size,standard[i]); }
82
+ Check(UpdateProcThreadAttribute(startup.attributes,0,new IntPtr(0x20002),handles,new IntPtr(IntPtr.Size*standard.Count),IntPtr.Zero,IntPtr.Zero));
83
+ PI child;
84
+ Check(CreateProcess(app,new StringBuilder(command),IntPtr.Zero,IntPtr.Zero,true,flags|0x80000,IntPtr.Zero,cwd,ref startup,out child));
85
+ return child;
86
+ } finally { if(initialized) DeleteProcThreadAttributeList(startup.attributes); Marshal.FreeHGlobal(startup.attributes); Marshal.FreeHGlobal(jobs); Marshal.FreeHGlobal(handles); }
87
+ }
88
+ static IntPtr ServiceFile(string path,uint access,uint creation) {
89
+ SA security=new SA { size=Marshal.SizeOf(typeof(SA)), inherit=1 };
90
+ IntPtr handle=CreateFile(path,access,3,ref security,creation,0x80,IntPtr.Zero);
91
+ if(handle==new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error());
92
+ return handle;
93
+ }
94
+ static void RequireIndependentService(IntPtr process) {
95
+ bool assigned; Check(IsProcessInJob(process,IntPtr.Zero,out assigned));
96
+ if(assigned) throw new Exception("This terminal still owns the replacement. Run cs restart from another terminal on this computer; no independent service was started.");
97
+ }
98
+ static int Service(string[] args) {
99
+ if(args.Length!=5 || !System.Text.RegularExpressions.Regex.IsMatch(args[3],@"^--lifecycle-handover=[0-9a-f-]{36}$") || !Path.IsPathRooted(args[1]) || !Path.IsPathRooted(args[2]) || !Path.IsPathRooted(args[4])) throw new Exception("Invalid installed service handover.");
100
+ bool detached=args[0]=="daemon-detached";
101
+ // Check before Node exists: libuv can later add Node to its own Job, which
102
+ // is independent of the terminal and must not be mistaken for its owner.
103
+ if(detached) RequireIndependentService(GetCurrentProcess());
104
+ IntPtr input=IntPtr.Zero,log=IntPtr.Zero,read=IntPtr.Zero,write=IntPtr.Zero;
105
+ try {
106
+ input=ServiceFile("NUL",0x80000000,3); log=ServiceFile(args[4],4,4);
107
+ if(detached) {
108
+ PI service=CreateWithHandles(args[1],Command(args[1],args[2],"start",args[3]),null,input,log,log,IntPtr.Zero,0x08000000);
109
+ CloseHandle(service.thread); CloseHandle(service.process);
110
+ Console.WriteLine("{\"pid\":"+service.pid+"}"); return 0;
111
+ }
112
+ SA security=new SA { size=Marshal.SizeOf(typeof(SA)), inherit=1 };
113
+ Check(CreatePipe(out read,out write,ref security,0)); Check(SetHandleInformation(read,1,0));
114
+ string executable=System.Reflection.Assembly.GetExecutingAssembly().Location;
115
+ PI stage;
116
+ try { stage=CreateWithHandles(executable,Command(executable,"daemon-detached",args[1],args[2],args[3],args[4]),null,input,write,log,IntPtr.Zero,0x09000000); }
117
+ catch(Win32Exception e) { throw new Exception("This terminal cannot launch an independent background service (Windows "+e.NativeErrorCode+"). Run cs restart from another terminal on this computer. Saved work remains.",e); }
118
+ CloseHandle(stage.thread); CloseHandle(write); write=IntPtr.Zero;
119
+ try {
120
+ RequireIndependentService(stage.process);
121
+ byte[] buffer=new byte[256]; StringBuilder response=new StringBuilder(); uint count;
122
+ while(true) {
123
+ bool ok=ReadFile(read,buffer,(uint)buffer.Length,out count,IntPtr.Zero);
124
+ if(!ok) { int error=Marshal.GetLastWin32Error(); if(error==109) break; throw new Win32Exception(error); }
125
+ if(count==0) break;
126
+ response.Append(Encoding.UTF8.GetString(buffer,0,(int)count));
127
+ if(response.Length>256) throw new Exception("Invalid installed service launch receipt.");
128
+ }
129
+ if(WaitForSingleObject(stage.process,0xFFFFFFFF)!=0) throw new Exception("Could not observe the detached launcher exit.");
130
+ uint code; Check(GetExitCodeProcess(stage.process,out code));
131
+ if(code!=0) throw new Exception("The detached launcher failed. Check cs status and this computer's service log; saved work remains.");
132
+ Console.Write(response.ToString()); return 0;
133
+ } finally { CloseHandle(stage.process); }
134
+ } finally { foreach(IntPtr handle in new IntPtr[]{input,log,read,write}) if(handle!=IntPtr.Zero) CloseHandle(handle); }
135
+ }
136
+ static int Process(string[] args) {
137
+ if(args.Length!=4) throw new Exception("Invalid process lifetime.");
138
+ uint pid; DateTime birth;
139
+ if(!UInt32.TryParse(args[1],out pid) || pid==0 || !DateTime.TryParseExact(args[3],"O",CultureInfo.InvariantCulture,DateTimeStyles.RoundtripKind,out birth) || birth.Kind!=DateTimeKind.Utc)
140
+ throw new Exception("Invalid process lifetime.");
141
+ string sid=WindowsIdentity.GetCurrent().User.Value;
142
+ if(args[2]!=sid) throw new Exception("Process ownership belongs to another user.");
143
+ bool terminate=args[0]=="process-terminate";
144
+ // Query-only never requests termination rights. The opened HANDLE pins the
145
+ // lifetime even if its PID is reused between verification and termination.
146
+ IntPtr process=OpenProcess(0x101000u | (terminate?1u:0u),false,pid);
147
+ if(process==IntPtr.Zero) {
148
+ int error=Marshal.GetLastWin32Error();
149
+ if(error!=87) throw new Win32Exception(error);
150
+ Console.WriteLine("{\"exists\":false,\"matches\":false}"); return 0;
151
+ }
152
+ try {
153
+ uint state=WaitForSingleObject(process,0);
154
+ if(state==0) { Console.WriteLine("{\"exists\":false,\"matches\":false}"); return 0; }
155
+ if(state!=258) throw new Win32Exception(Marshal.GetLastWin32Error());
156
+ long created,exited,kernel,user; Check(GetProcessTimes(process,out created,out exited,out kernel,out user));
157
+ IntPtr token; Check(OpenProcessToken(process,8,out token));
158
+ string owner;
159
+ try { using(WindowsIdentity identity=new WindowsIdentity(token)) owner=identity.User.Value; }
160
+ finally { CloseHandle(token); }
161
+ // Win32_Process CreationDate uses CIM_DATETIME's microsecond precision.
162
+ // Compare kernel FILETIME at that precision, retaining all available digits.
163
+ bool matches=owner==sid && created/10==birth.ToFileTimeUtc()/10;
164
+ if(matches && terminate && !TerminateProcess(process,1)) {
165
+ int error=Marshal.GetLastWin32Error();
166
+ if(WaitForSingleObject(process,0)!=0) throw new Win32Exception(error);
167
+ }
168
+ Console.WriteLine("{\"exists\":true,\"matches\":"+(matches?"true":"false")+"}"); return 0;
169
+ } finally { CloseHandle(process); }
170
+ }
171
+ static uint[] Members(IntPtr job) {
172
+ // A growing process list returns ERROR_MORE_DATA. Re-read the authoritative
173
+ // Job membership; never substitute a parent-PID snapshot for this boundary.
174
+ int capacity=64;
175
+ while (true) {
176
+ int size=8+capacity*IntPtr.Size; IntPtr data=Marshal.AllocHGlobal(size);
177
+ try {
178
+ uint written; bool ok=QueryInformationJobObject(job,3,data,(uint)size,out written);
179
+ if (!ok) { int error=Marshal.GetLastWin32Error(); if(error==234 && capacity<1048576) { capacity*=2; continue; } throw new Win32Exception(error); }
180
+ int count=Marshal.ReadInt32(data,4); if(count<0 || count>capacity) throw new Exception("Invalid Job membership.");
181
+ uint[] ids=new uint[count]; for(int i=0;i<count;i++) ids[i]=checked((uint)Marshal.ReadIntPtr(data,8+i*IntPtr.Size).ToInt64());
182
+ return ids;
183
+ } finally { Marshal.FreeHGlobal(data); }
184
+ }
185
+ }
186
+ static int Run(string name,string file) {
187
+ Launch launch; using(FileStream input=File.OpenRead(file)) launch=(Launch)new DataContractJsonSerializer(typeof(Launch)).ReadObject(input); File.Delete(file);
188
+ IntPtr descriptor; string sid=WindowsIdentity.GetCurrent().User.Value;
189
+ Check(ConvertStringSecurityDescriptorToSecurityDescriptor("D:P(A;;GA;;;"+sid+")",1,out descriptor,IntPtr.Zero));
190
+ IntPtr job=IntPtr.Zero;
191
+ try {
192
+ SA security=new SA { size=Marshal.SizeOf(typeof(SA)), descriptor=descriptor, inherit=0 };
193
+ job=CreateJobObject(ref security,name); int error=Marshal.GetLastWin32Error();
194
+ if(job==IntPtr.Zero) throw new Win32Exception(error);
195
+ if(error==183) throw new Exception("Execution boundary already exists.");
196
+ Limits limits=new Limits(); limits.basic.flags=0x2000; // KILL_ON_JOB_CLOSE; no breakaway.
197
+ Check(SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf(typeof(Limits))));
198
+ byte[] grant=new byte[1]; uint read;
199
+ // Exactly one byte. Buffered Console input can swallow prompt bytes which
200
+ // must remain in the inherited pipe for the actual harness.
201
+ bool received=ReadFile(GetStdHandle(-10),grant,1,out read,IntPtr.Zero);
202
+ if(!received) { int e=Marshal.GetLastWin32Error(); if(e==109) return 0; throw new Win32Exception(e); }
203
+ if(read!=1 || grant[0]!=1) return 0;
204
+ PI child=CreateWithHandles(launch.application,launch.command,launch.cwd,GetStdHandle(-10),GetStdHandle(-11),GetStdHandle(-12),job,0x08000000);
205
+ CloseHandle(child.thread);
206
+ try {
207
+ if(WaitForSingleObject(child.process,0xFFFFFFFF)!=0) throw new Exception("Could not observe harness exit.");
208
+ uint code; Check(GetExitCodeProcess(child.process,out code));
209
+ while(Members(job).Length!=0) Thread.Sleep(25);
210
+ return unchecked((int)code);
211
+ } finally { CloseHandle(child.process); }
212
+ } finally { if(job!=IntPtr.Zero) CloseHandle(job); LocalFree(descriptor); }
213
+ }
214
+ public static int Main(string[] args) {
215
+ try {
216
+ if(args.Length>0 && (args[0]=="service-launch" || args[0]=="daemon-detached")) return Service(args);
217
+ if(args.Length>0 && (args[0]=="process-query" || args[0]=="process-terminate")) return Process(args);
218
+ if(args.Length<2 || !System.Text.RegularExpressions.Regex.IsMatch(args[1],@"^Global\\CTRLSPCExecution-[0-9a-f-]{36}$")) throw new Exception("Invalid execution boundary.");
219
+ if(args[0]=="run" && args.Length==3) return Run(args[1],args[2]);
220
+ if(args.Length!=2 || (args[0]!="query" && args[0]!="terminate")) throw new Exception("Invalid execution boundary operation.");
221
+ IntPtr job=OpenJobObject(args[0]=="terminate" ? 0xCu : 0x4u,false,args[1]);
222
+ if(job==IntPtr.Zero) { int error=Marshal.GetLastWin32Error(); if(error!=2) throw new Win32Exception(error); Console.WriteLine("{\"exists\":false,\"pids\":[]}"); return 0; }
223
+ try {
224
+ if(args[0]=="terminate") Check(TerminateJobObject(job,1));
225
+ Console.WriteLine("{\"exists\":true,\"pids\":["+String.Join(",",Members(job))+"]}"); return 0;
226
+ } finally { CloseHandle(job); }
227
+ } catch(Exception e) { Console.Error.WriteLine("Windows execution boundary: "+e.Message); return 1; }
228
+ }
229
+ }
230
+ `;
231
+ function remaining(deadline) {
232
+ const ms = deadline - Date.now();
233
+ if (ms <= 0)
234
+ throw new Error('Service operation exceeded its deadline.');
235
+ return Math.min(ms, 5000);
236
+ }
237
+ export function windowsJobName(reservationId) {
238
+ if (!/^[0-9a-f-]{36}$/i.test(reservationId))
239
+ throw new Error('Invalid execution reservation.');
240
+ return `Global\\CTRLSPCExecution-${reservationId.toLowerCase()}`;
241
+ }
242
+ /** Build once in the private lifecycle directory, then run/query without a
243
+ * PowerShell startup on every process inspection. Racing builders publish only
244
+ * a complete executable; compilation failures are actionable failures. */
245
+ export function windowsJobExecutable(deadline = Date.now() + 5000) {
246
+ const directory = ensureLifecycleDir();
247
+ const target = join(directory, `owned-job-${createHash('sha256').update(source).digest('hex').slice(0, 16)}.exe`);
248
+ if (existsSync(target))
249
+ return target;
250
+ const temporary = join(directory, `owned-job-build-${randomUUID()}`);
251
+ writeFileSync(temporary + '.cs', source, { mode: 0o600 });
252
+ try {
253
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop'; Add-Type -Path $env:CTRL_SPC_JOB_SOURCE -OutputAssembly $env:CTRL_SPC_JOB_OUTPUT -OutputType ConsoleApplication -ReferencedAssemblies System.dll,System.Core.dll,System.Xml.dll,System.Runtime.Serialization.dll"], {
254
+ env: { ...process.env, CTRL_SPC_JOB_SOURCE: temporary + '.cs', CTRL_SPC_JOB_OUTPUT: temporary + '.exe' }, windowsHide: true, timeout: remaining(deadline), stdio: ['ignore', 'ignore', 'pipe'],
255
+ });
256
+ try {
257
+ renameSync(temporary + '.exe', target);
258
+ }
259
+ catch (error) {
260
+ if (!existsSync(target))
261
+ throw error;
262
+ }
263
+ return target;
264
+ }
265
+ finally {
266
+ rmSync(temporary + '.cs', { force: true });
267
+ rmSync(temporary + '.exe', { force: true });
268
+ }
269
+ }
270
+ /** The launcher has already been compiled and lifecycle ownership rechecked. */
271
+ export async function launchWindowsService(executable, entry, nonce, log, deadline) {
272
+ if (!/^[0-9a-f-]{36}$/.test(nonce))
273
+ throw new Error('Invalid installed service handover.');
274
+ const { stdout } = await execute(executable, ['service-launch', process.execPath, entry, '--lifecycle-handover=' + nonce, log], {
275
+ env: { ...process.env, CTRL_SPC_LIFECYCLE_HANDOVER: nonce }, windowsHide: true,
276
+ timeout: remaining(deadline), maxBuffer: 4096,
277
+ });
278
+ const receipt = JSON.parse(stdout);
279
+ if (!receipt || typeof receipt !== 'object' || !('pid' in receipt) || !Number.isSafeInteger(receipt.pid) || receipt.pid <= 0 || receipt.pid > 0xffffffff)
280
+ throw new Error('Windows returned an unreadable installed service launch receipt. Run cs status before retrying.');
281
+ remaining(deadline);
282
+ return receipt.pid;
283
+ }
284
+ export async function inspectWindowsJob(name, deadline = Date.now() + 5000, terminate = false) {
285
+ if (!/^Global\\CTRLSPCExecution-[0-9a-f-]{36}$/.test(name))
286
+ throw new Error('Invalid execution boundary.');
287
+ const executable = windowsJobExecutable(deadline);
288
+ const { stdout } = await execute(executable, [terminate ? 'terminate' : 'query', name], { windowsHide: true, timeout: remaining(deadline), maxBuffer: 8 * 1024 * 1024 });
289
+ const value = JSON.parse(stdout);
290
+ if (!value || typeof value !== 'object' || !('exists' in value) || typeof value.exists !== 'boolean' || !('pids' in value) || !Array.isArray(value.pids) || !value.pids.every((pid) => Number.isSafeInteger(pid) && pid > 0) || (!value.exists && value.pids.length))
291
+ throw new Error('Windows returned an unreadable execution boundary.');
292
+ remaining(deadline);
293
+ return { exists: value.exists, pids: value.pids };
294
+ }
295
+ /** Verify and optionally terminate the same kernel process handle, never a
296
+ * separately resolved PID after inspecting its creation time. */
297
+ export async function inspectWindowsProcess(identity, deadline = Date.now() + 5000, terminate = false) {
298
+ if (!Number.isSafeInteger(identity.pid) || identity.pid <= 0 || identity.pid > 0xffffffff
299
+ || !/^S-\d+(?:-\d+)+$/.test(identity.owner) || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$/.test(identity.birth)) {
300
+ throw new Error('Invalid Windows process lifetime.');
301
+ }
302
+ const executable = windowsJobExecutable(deadline);
303
+ const { stdout } = await execute(executable, [terminate ? 'process-terminate' : 'process-query',
304
+ String(identity.pid), identity.owner, identity.birth], { windowsHide: true, timeout: remaining(deadline), maxBuffer: 4096 });
305
+ const value = JSON.parse(stdout);
306
+ if (!value || typeof value !== 'object' || !('exists' in value) || typeof value.exists !== 'boolean'
307
+ || !('matches' in value) || typeof value.matches !== 'boolean' || (!value.exists && value.matches)) {
308
+ throw new Error('Windows returned an unreadable process lifetime.');
309
+ }
310
+ remaining(deadline);
311
+ return { exists: value.exists, matches: value.matches };
312
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -16,9 +16,10 @@
16
16
  "access": "public"
17
17
  },
18
18
  "scripts": {
19
- "build": "tsc",
19
+ "build": "tsc && node src/native/build-darwin-helper.mjs",
20
20
  "start": "npm run build && node dist/index.js",
21
- "test": "npm run build && node --test --test-concurrency=1 test/*.test.mjs"
21
+ "test": "npm run build && node --test --test-concurrency=1 test/*.test.mjs",
22
+ "postinstall": "node dist/daemon-lifecycle.js --installation-checkpoint"
22
23
  },
23
24
  "license": "UNLICENSED",
24
25
  "dependencies": {