u-chan-secrets 3.0.2 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A local MCP server that signs into approved websites with Bitwarden credentials. A privileged service performs credential entry in an isolated browser and transfers filtered session state into a dedicated user Chrome profile.
4
4
 
5
- V3 adds diagnostics, temporary approvals, explicit login outcomes, and a native management window. The source version is `3.0.2`; merging this repository does not publish an npm package or update an installed service.
5
+ V3 adds diagnostics, temporary approvals, explicit login outcomes, and a native management window. The source version is `3.0.3`; merging this repository does not publish an npm package or update an installed service.
6
6
 
7
7
  **Verification:** Windows/macOS build and test CI is separate from installed-service security verification. Native process identity, input isolation, permissions, and installation rehearsals remain [release gates](docs/attack-checklist.md). See [validation evidence](docs/superpowers/research/2026-10-29-v3-validation.md) and [SECURITY.md](SECURITY.md).
8
8
 
@@ -200,6 +200,8 @@ Supported V3 tooling refuses V2 downgrade after migration. Historical V2 adminis
200
200
 
201
201
  Package updates verify registry signatures and integrity and retain private dependencies. Changed dependencies or native helpers require a trusted administrator reinstall. Expired/exhausted grants and already transferred website sessions are separate from vault lock and upgrade state.
202
202
 
203
+ Versions 3.0.0–3.0.2 have an npm signature-verification defect and cannot reliably bootstrap their own update. Install the matching corrected package from a trusted shell, then use the administrator reinstall above. Version 3.0.3 requires SHA-512 integrity and a valid signature from its pinned npm key; unsigned repository-attestation JSON cannot replace that signature. Future signing-key rotation fails closed and requires a trusted verifier upgrade.
204
+
203
205
  For V1 data, use `u-chan import-v1 [path]`: each entry requires protected approval. Retire user-side Bitwarden sessions and the old `bw-session` file through a trusted migration process; never copy them into the service as trusted authorization.
204
206
 
205
207
  ## Validation and security
@@ -1,33 +1,33 @@
1
1
  export function buildJxaManagementScript(req) {
2
- return `ObjC.import('Cocoa');
3
- ObjC.import('stdlib');
4
- var alert = $.NSAlert.alloc.init;
5
- alert.messageText = ${JSON.stringify(req.title)};
6
- alert.informativeText = ${JSON.stringify('Phrase: ' + req.phrase)};
7
- ['Refresh','Previous','Next','Lock','Unlock','Revoke selected','Close'].forEach(function(t){ alert.addButtonWithTitle(t); });
8
- var view = $.NSView.alloc.initWithFrame($.NSMakeRect(0,0,640,420));
9
- var scroll = $.NSScrollView.alloc.initWithFrame($.NSMakeRect(0,50,640,370));
10
- scroll.hasVerticalScroller = true;
11
- var text = $.NSTextView.alloc.initWithFrame($.NSMakeRect(0,0,620,370));
12
- text.editable = false;
13
- text.string = ${JSON.stringify(req.lines.join('\n'))};
14
- scroll.documentView = text;
15
- view.addSubview(scroll);
16
- var domains = ${JSON.stringify(req.domains ?? [])};
17
- var select = $.NSPopUpButton.alloc.initWithFramePullsDown($.NSMakeRect(0,5,640,32),false);
18
- domains.forEach(function(d){ select.addItemWithTitle(d); });
19
- view.addSubview(select);
20
- alert.accessoryView = view;
21
- var response = Number(alert.runModal) - 1000;
22
- var actions = ['refresh','previous','next','lock','unlock','revoke','close'];
23
- var result = {action: actions[response] || 'close'};
24
- if (result.action === 'revoke') {
25
- var index = Number(select.indexOfSelectedItem);
26
- if (index < 0 || index >= domains.length) result.action = 'refresh';
27
- else result.domain = domains[index];
28
- }
29
- $.NSFileHandle.fileHandleWithStandardOutput.writeData(
30
- $.NSString.alloc.initWithString(JSON.stringify(result)).dataUsingEncoding($.NSUTF8StringEncoding));
31
- $.exit(0);
2
+ return `ObjC.import('Cocoa');
3
+ ObjC.import('stdlib');
4
+ var alert = $.NSAlert.alloc.init;
5
+ alert.messageText = ${JSON.stringify(req.title)};
6
+ alert.informativeText = ${JSON.stringify('Phrase: ' + req.phrase)};
7
+ ['Refresh','Previous','Next','Lock','Unlock','Revoke selected','Close'].forEach(function(t){ alert.addButtonWithTitle(t); });
8
+ var view = $.NSView.alloc.initWithFrame($.NSMakeRect(0,0,640,420));
9
+ var scroll = $.NSScrollView.alloc.initWithFrame($.NSMakeRect(0,50,640,370));
10
+ scroll.hasVerticalScroller = true;
11
+ var text = $.NSTextView.alloc.initWithFrame($.NSMakeRect(0,0,620,370));
12
+ text.editable = false;
13
+ text.string = ${JSON.stringify(req.lines.join('\n'))};
14
+ scroll.documentView = text;
15
+ view.addSubview(scroll);
16
+ var domains = ${JSON.stringify(req.domains ?? [])};
17
+ var select = $.NSPopUpButton.alloc.initWithFramePullsDown($.NSMakeRect(0,5,640,32),false);
18
+ domains.forEach(function(d){ select.addItemWithTitle(d); });
19
+ view.addSubview(select);
20
+ alert.accessoryView = view;
21
+ var response = Number(alert.runModal) - 1000;
22
+ var actions = ['refresh','previous','next','lock','unlock','revoke','close'];
23
+ var result = {action: actions[response] || 'close'};
24
+ if (result.action === 'revoke') {
25
+ var index = Number(select.indexOfSelectedItem);
26
+ if (index < 0 || index >= domains.length) result.action = 'refresh';
27
+ else result.domain = domains[index];
28
+ }
29
+ $.NSFileHandle.fileHandleWithStandardOutput.writeData(
30
+ $.NSString.alloc.initWithString(JSON.stringify(result)).dataUsingEncoding($.NSUTF8StringEncoding));
31
+ $.exit(0);
32
32
  `;
33
33
  }
@@ -1,191 +1,191 @@
1
- export const AUTH_HELPER_CSHARP = `using System;
2
- using System.Diagnostics;
3
- using System.Runtime.InteropServices;
4
- using System.Security.Principal;
5
-
6
- namespace UChan.Platform.Windows.Assets
7
- {
8
- public static class AuthHelper
9
- {
10
- private const uint PROCESS_QUERY_INFORMATION = 0x0400;
11
- private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
12
- private const uint PROCESS_DUP_HANDLE = 0x0040;
13
- private const uint TOKEN_QUERY = 0x0008;
14
- private const uint DUPLICATE_SAME_ACCESS = 0x0002;
15
-
16
- [DllImport("kernel32.dll", SetLastError = true)]
17
- private static extern IntPtr GetCurrentProcess();
18
-
19
- [DllImport("kernel32.dll", SetLastError = true)]
20
- private static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, uint processId);
21
-
22
- [DllImport("kernel32.dll", SetLastError = true)]
23
- private static extern bool DuplicateHandle(
24
- IntPtr hSourceProcessHandle,
25
- IntPtr hSourceHandle,
26
- IntPtr hTargetProcessHandle,
27
- out IntPtr lpTargetHandle,
28
- uint dwDesiredAccess,
29
- bool bInheritHandle,
30
- uint dwOptions);
31
-
32
- [DllImport("kernel32.dll", SetLastError = true)]
33
- private static extern bool GetNamedPipeClientProcessId(IntPtr pipeHandle, out uint clientProcessId);
34
-
35
- [DllImport("advapi32.dll", SetLastError = true)]
36
- private static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
37
-
38
- [DllImport("kernel32.dll", SetLastError = true)]
39
- private static extern bool CloseHandle(IntPtr hObject);
40
-
41
- private static string GetProcessUserSid(uint pid)
42
- {
43
- IntPtr hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
44
- if (hProcess == IntPtr.Zero)
45
- {
46
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, false, pid);
47
- }
48
- if (hProcess == IntPtr.Zero) return null;
49
-
50
- try
51
- {
52
- IntPtr hToken;
53
- if (!OpenProcessToken(hProcess, TOKEN_QUERY, out hToken))
54
- {
55
- return null;
56
- }
57
-
58
- try
59
- {
60
- using (WindowsIdentity identity = new WindowsIdentity(hToken))
61
- {
62
- if (identity.User != null)
63
- {
64
- return identity.User.Value;
65
- }
66
- }
67
- }
68
- finally
69
- {
70
- CloseHandle(hToken);
71
- }
72
- }
73
- finally
74
- {
75
- CloseHandle(hProcess);
76
- }
77
- return null;
78
- }
79
-
80
- public static int Main(string[] args)
81
- {
82
- if (args.Length < 1)
83
- {
84
- Console.Error.WriteLine("Usage: auth-helper <serverPid> [targetSid]");
85
- return 1;
86
- }
87
-
88
- uint serverPid;
89
- if (!uint.TryParse(args[0], out serverPid))
90
- {
91
- Console.Error.WriteLine("Invalid server PID");
92
- return 1;
93
- }
94
-
95
- string targetSid = args.Length > 1 ? args[1] : null;
96
- bool hasTargetSid = !string.IsNullOrEmpty(targetSid) && !string.Equals(targetSid, "dummy", StringComparison.OrdinalIgnoreCase);
97
-
98
- long pipeHandle = 0;
99
- if (args.Length > 2)
100
- {
101
- long.TryParse(args[2], out pipeHandle);
102
- }
103
-
104
- string matchedSid = null;
105
- string fallbackNonSystemSid = null;
106
- string anySid = null;
107
-
108
- IntPtr hServer = OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION, false, serverPid);
109
- if (hServer != IntPtr.Zero)
110
- {
111
- try
112
- {
113
- // Exact handle of this connection when the server passes it; otherwise scan.
114
- int start = pipeHandle > 0 ? (int)pipeHandle : 4;
115
- int end = pipeHandle > 0 ? (int)pipeHandle + 4 : 0x10000;
116
- for (int h = start; h < end; h += 4)
117
- {
118
- IntPtr hLocal;
119
- if (DuplicateHandle(hServer, (IntPtr)h, GetCurrentProcess(), out hLocal, 0, false, DUPLICATE_SAME_ACCESS))
120
- {
121
- try
122
- {
123
- uint candidatePid;
124
- if (GetNamedPipeClientProcessId(hLocal, out candidatePid) && candidatePid != 0 && candidatePid != serverPid)
125
- {
126
- string sid = GetProcessUserSid(candidatePid);
127
- if (!string.IsNullOrEmpty(sid))
128
- {
129
- if (hasTargetSid && string.Equals(sid, targetSid, StringComparison.OrdinalIgnoreCase))
130
- {
131
- matchedSid = sid;
132
- break;
133
- }
134
-
135
- if (!string.Equals(sid, "S-1-5-18", StringComparison.OrdinalIgnoreCase))
136
- {
137
- fallbackNonSystemSid = sid;
138
- }
139
-
140
- if (anySid == null)
141
- {
142
- anySid = sid;
143
- }
144
- }
145
- }
146
- }
147
- finally
148
- {
149
- CloseHandle(hLocal);
150
- }
151
- }
152
- }
153
- }
154
- finally
155
- {
156
- CloseHandle(hServer);
157
- }
158
- }
159
-
160
- string clientSid = matchedSid ?? fallbackNonSystemSid ?? anySid;
161
-
162
- // Fail closed: if clientSid could not be identified on any pipe handle, reject
163
- if (string.IsNullOrEmpty(clientSid))
164
- {
165
- Console.Error.WriteLine("Could not identify client process on pipe");
166
- return 1;
167
- }
168
-
169
- // Verify string sid parses as SecurityIdentifier
170
- try
171
- {
172
- SecurityIdentifier secId = new SecurityIdentifier(clientSid);
173
- }
174
- catch
175
- {
176
- Console.Error.WriteLine("Resolved SID is invalid");
177
- return 1;
178
- }
179
-
180
- if (hasTargetSid && !string.Equals(clientSid, targetSid, StringComparison.OrdinalIgnoreCase))
181
- {
182
- Console.Error.WriteLine("SID mismatch: expected " + targetSid + " but got " + clientSid);
183
- return 1;
184
- }
185
-
186
- Console.WriteLine("AUTHORIZED:" + clientSid);
187
- return 0;
188
- }
189
- }
190
- }
1
+ export const AUTH_HELPER_CSHARP = `using System;
2
+ using System.Diagnostics;
3
+ using System.Runtime.InteropServices;
4
+ using System.Security.Principal;
5
+
6
+ namespace UChan.Platform.Windows.Assets
7
+ {
8
+ public static class AuthHelper
9
+ {
10
+ private const uint PROCESS_QUERY_INFORMATION = 0x0400;
11
+ private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
12
+ private const uint PROCESS_DUP_HANDLE = 0x0040;
13
+ private const uint TOKEN_QUERY = 0x0008;
14
+ private const uint DUPLICATE_SAME_ACCESS = 0x0002;
15
+
16
+ [DllImport("kernel32.dll", SetLastError = true)]
17
+ private static extern IntPtr GetCurrentProcess();
18
+
19
+ [DllImport("kernel32.dll", SetLastError = true)]
20
+ private static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, uint processId);
21
+
22
+ [DllImport("kernel32.dll", SetLastError = true)]
23
+ private static extern bool DuplicateHandle(
24
+ IntPtr hSourceProcessHandle,
25
+ IntPtr hSourceHandle,
26
+ IntPtr hTargetProcessHandle,
27
+ out IntPtr lpTargetHandle,
28
+ uint dwDesiredAccess,
29
+ bool bInheritHandle,
30
+ uint dwOptions);
31
+
32
+ [DllImport("kernel32.dll", SetLastError = true)]
33
+ private static extern bool GetNamedPipeClientProcessId(IntPtr pipeHandle, out uint clientProcessId);
34
+
35
+ [DllImport("advapi32.dll", SetLastError = true)]
36
+ private static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
37
+
38
+ [DllImport("kernel32.dll", SetLastError = true)]
39
+ private static extern bool CloseHandle(IntPtr hObject);
40
+
41
+ private static string GetProcessUserSid(uint pid)
42
+ {
43
+ IntPtr hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
44
+ if (hProcess == IntPtr.Zero)
45
+ {
46
+ hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, false, pid);
47
+ }
48
+ if (hProcess == IntPtr.Zero) return null;
49
+
50
+ try
51
+ {
52
+ IntPtr hToken;
53
+ if (!OpenProcessToken(hProcess, TOKEN_QUERY, out hToken))
54
+ {
55
+ return null;
56
+ }
57
+
58
+ try
59
+ {
60
+ using (WindowsIdentity identity = new WindowsIdentity(hToken))
61
+ {
62
+ if (identity.User != null)
63
+ {
64
+ return identity.User.Value;
65
+ }
66
+ }
67
+ }
68
+ finally
69
+ {
70
+ CloseHandle(hToken);
71
+ }
72
+ }
73
+ finally
74
+ {
75
+ CloseHandle(hProcess);
76
+ }
77
+ return null;
78
+ }
79
+
80
+ public static int Main(string[] args)
81
+ {
82
+ if (args.Length < 1)
83
+ {
84
+ Console.Error.WriteLine("Usage: auth-helper <serverPid> [targetSid]");
85
+ return 1;
86
+ }
87
+
88
+ uint serverPid;
89
+ if (!uint.TryParse(args[0], out serverPid))
90
+ {
91
+ Console.Error.WriteLine("Invalid server PID");
92
+ return 1;
93
+ }
94
+
95
+ string targetSid = args.Length > 1 ? args[1] : null;
96
+ bool hasTargetSid = !string.IsNullOrEmpty(targetSid) && !string.Equals(targetSid, "dummy", StringComparison.OrdinalIgnoreCase);
97
+
98
+ long pipeHandle = 0;
99
+ if (args.Length > 2)
100
+ {
101
+ long.TryParse(args[2], out pipeHandle);
102
+ }
103
+
104
+ string matchedSid = null;
105
+ string fallbackNonSystemSid = null;
106
+ string anySid = null;
107
+
108
+ IntPtr hServer = OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION, false, serverPid);
109
+ if (hServer != IntPtr.Zero)
110
+ {
111
+ try
112
+ {
113
+ // Exact handle of this connection when the server passes it; otherwise scan.
114
+ int start = pipeHandle > 0 ? (int)pipeHandle : 4;
115
+ int end = pipeHandle > 0 ? (int)pipeHandle + 4 : 0x10000;
116
+ for (int h = start; h < end; h += 4)
117
+ {
118
+ IntPtr hLocal;
119
+ if (DuplicateHandle(hServer, (IntPtr)h, GetCurrentProcess(), out hLocal, 0, false, DUPLICATE_SAME_ACCESS))
120
+ {
121
+ try
122
+ {
123
+ uint candidatePid;
124
+ if (GetNamedPipeClientProcessId(hLocal, out candidatePid) && candidatePid != 0 && candidatePid != serverPid)
125
+ {
126
+ string sid = GetProcessUserSid(candidatePid);
127
+ if (!string.IsNullOrEmpty(sid))
128
+ {
129
+ if (hasTargetSid && string.Equals(sid, targetSid, StringComparison.OrdinalIgnoreCase))
130
+ {
131
+ matchedSid = sid;
132
+ break;
133
+ }
134
+
135
+ if (!string.Equals(sid, "S-1-5-18", StringComparison.OrdinalIgnoreCase))
136
+ {
137
+ fallbackNonSystemSid = sid;
138
+ }
139
+
140
+ if (anySid == null)
141
+ {
142
+ anySid = sid;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ finally
148
+ {
149
+ CloseHandle(hLocal);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ finally
155
+ {
156
+ CloseHandle(hServer);
157
+ }
158
+ }
159
+
160
+ string clientSid = matchedSid ?? fallbackNonSystemSid ?? anySid;
161
+
162
+ // Fail closed: if clientSid could not be identified on any pipe handle, reject
163
+ if (string.IsNullOrEmpty(clientSid))
164
+ {
165
+ Console.Error.WriteLine("Could not identify client process on pipe");
166
+ return 1;
167
+ }
168
+
169
+ // Verify string sid parses as SecurityIdentifier
170
+ try
171
+ {
172
+ SecurityIdentifier secId = new SecurityIdentifier(clientSid);
173
+ }
174
+ catch
175
+ {
176
+ Console.Error.WriteLine("Resolved SID is invalid");
177
+ return 1;
178
+ }
179
+
180
+ if (hasTargetSid && !string.Equals(clientSid, targetSid, StringComparison.OrdinalIgnoreCase))
181
+ {
182
+ Console.Error.WriteLine("SID mismatch: expected " + targetSid + " but got " + clientSid);
183
+ return 1;
184
+ }
185
+
186
+ Console.WriteLine("AUTHORIZED:" + clientSid);
187
+ return 0;
188
+ }
189
+ }
190
+ }
191
191
  `;
@@ -1,148 +1,148 @@
1
- export const SERVICE_HOST_CSHARP = `using System;
2
- using System.Diagnostics;
3
- using System.ServiceProcess;
4
- using System.Threading;
5
-
6
- namespace UChan.Platform.Windows.Assets
7
- {
8
- public class UChanService : ServiceBase
9
- {
10
- private Process _process;
11
- private readonly string[] _args;
12
- private volatile bool _stopping;
13
- private string _targetExe;
14
- private string _targetArgs;
15
-
16
- public UChanService(string[] args)
17
- {
18
- ServiceName = "UChanSecrets";
19
- _args = args;
20
- }
21
-
22
- protected override void OnStart(string[] args)
23
- {
24
- _stopping = false;
25
- string[] effectiveArgs = (args != null && args.Length > 0) ? args : _args;
26
- string targetExe = null;
27
- string targetArgs = "";
28
-
29
- if (effectiveArgs != null && effectiveArgs.Length > 0)
30
- {
31
- targetExe = effectiveArgs[0];
32
- if (effectiveArgs.Length > 1)
33
- {
34
- string[] rest = new string[effectiveArgs.Length - 1];
35
- Array.Copy(effectiveArgs, 1, rest, 0, rest.Length);
36
- targetArgs = string.Join(" ", rest);
37
- }
38
- }
39
- else
40
- {
41
- string baseDir = AppDomain.CurrentDomain.BaseDirectory;
42
- string targetFile = System.IO.Path.Combine(baseDir, "service-target.txt");
43
- if (System.IO.File.Exists(targetFile))
44
- {
45
- string[] lines = System.IO.File.ReadAllLines(targetFile);
46
- if (lines.Length > 0)
47
- {
48
- targetExe = lines[0].Trim();
49
- if (lines.Length > 1)
50
- {
51
- targetArgs = lines[1].Trim();
52
- }
53
- }
54
- }
55
- }
56
-
57
- if (string.IsNullOrEmpty(targetExe))
58
- {
59
- throw new InvalidOperationException("No target executable specified for UChanSecrets service.");
60
- }
61
-
62
- _targetExe = targetExe;
63
- _targetArgs = targetArgs;
64
- StartChild();
65
- }
66
-
67
- private void StartChild()
68
- {
69
- if (_stopping) return;
70
- ProcessStartInfo psi = new ProcessStartInfo();
71
- psi.FileName = _targetExe;
72
- psi.Arguments = _targetArgs;
73
- psi.UseShellExecute = false;
74
- psi.CreateNoWindow = true;
75
-
76
- string baseDir = AppDomain.CurrentDomain.BaseDirectory;
77
-
78
- string logFile = System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "service.log"));
79
- psi.RedirectStandardOutput = true;
80
- psi.RedirectStandardError = true;
81
-
82
- Process proc = new Process();
83
- proc.StartInfo = psi;
84
- proc.EnableRaisingEvents = true;
85
- proc.OutputDataReceived += (s, e) => {
86
- if (!string.IsNullOrEmpty(e.Data)) {
87
- try { System.IO.File.AppendAllText(logFile, string.Format("[{0:s}] {1}", DateTime.Now, e.Data) + Environment.NewLine); } catch {}
88
- }
89
- };
90
- proc.ErrorDataReceived += (s, e) => {
91
- if (!string.IsNullOrEmpty(e.Data)) {
92
- try { System.IO.File.AppendAllText(logFile, string.Format("[{0:s}] [ERR] {1}", DateTime.Now, e.Data) + Environment.NewLine); } catch {}
93
- }
94
- };
95
- proc.Exited += OnChildExited;
96
- proc.Start();
97
- proc.BeginOutputReadLine();
98
- proc.BeginErrorReadLine();
99
- _process = proc;
100
- }
101
-
102
- private void OnChildExited(object sender, EventArgs e)
103
- {
104
- if (_stopping) return;
105
- Thread.Sleep(500);
106
- if (_stopping) return;
107
- try
108
- {
109
- StartChild();
110
- }
111
- catch
112
- {
113
- }
114
- }
115
-
116
- protected override void OnStop()
117
- {
118
- _stopping = true;
119
- if (_process != null && !_process.HasExited)
120
- {
121
- try
122
- {
123
- Process killer = Process.Start("taskkill.exe", string.Format("/F /T /PID {0}", _process.Id));
124
- if (killer != null)
125
- {
126
- killer.WaitForExit(5000);
127
- }
128
- }
129
- catch
130
- {
131
- try
132
- {
133
- _process.Kill();
134
- }
135
- catch
136
- {
137
- }
138
- }
139
- }
140
- }
141
-
142
- public static void Main(string[] args)
143
- {
144
- ServiceBase.Run(new UChanService(args));
145
- }
146
- }
147
- }
1
+ export const SERVICE_HOST_CSHARP = `using System;
2
+ using System.Diagnostics;
3
+ using System.ServiceProcess;
4
+ using System.Threading;
5
+
6
+ namespace UChan.Platform.Windows.Assets
7
+ {
8
+ public class UChanService : ServiceBase
9
+ {
10
+ private Process _process;
11
+ private readonly string[] _args;
12
+ private volatile bool _stopping;
13
+ private string _targetExe;
14
+ private string _targetArgs;
15
+
16
+ public UChanService(string[] args)
17
+ {
18
+ ServiceName = "UChanSecrets";
19
+ _args = args;
20
+ }
21
+
22
+ protected override void OnStart(string[] args)
23
+ {
24
+ _stopping = false;
25
+ string[] effectiveArgs = (args != null && args.Length > 0) ? args : _args;
26
+ string targetExe = null;
27
+ string targetArgs = "";
28
+
29
+ if (effectiveArgs != null && effectiveArgs.Length > 0)
30
+ {
31
+ targetExe = effectiveArgs[0];
32
+ if (effectiveArgs.Length > 1)
33
+ {
34
+ string[] rest = new string[effectiveArgs.Length - 1];
35
+ Array.Copy(effectiveArgs, 1, rest, 0, rest.Length);
36
+ targetArgs = string.Join(" ", rest);
37
+ }
38
+ }
39
+ else
40
+ {
41
+ string baseDir = AppDomain.CurrentDomain.BaseDirectory;
42
+ string targetFile = System.IO.Path.Combine(baseDir, "service-target.txt");
43
+ if (System.IO.File.Exists(targetFile))
44
+ {
45
+ string[] lines = System.IO.File.ReadAllLines(targetFile);
46
+ if (lines.Length > 0)
47
+ {
48
+ targetExe = lines[0].Trim();
49
+ if (lines.Length > 1)
50
+ {
51
+ targetArgs = lines[1].Trim();
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ if (string.IsNullOrEmpty(targetExe))
58
+ {
59
+ throw new InvalidOperationException("No target executable specified for UChanSecrets service.");
60
+ }
61
+
62
+ _targetExe = targetExe;
63
+ _targetArgs = targetArgs;
64
+ StartChild();
65
+ }
66
+
67
+ private void StartChild()
68
+ {
69
+ if (_stopping) return;
70
+ ProcessStartInfo psi = new ProcessStartInfo();
71
+ psi.FileName = _targetExe;
72
+ psi.Arguments = _targetArgs;
73
+ psi.UseShellExecute = false;
74
+ psi.CreateNoWindow = true;
75
+
76
+ string baseDir = AppDomain.CurrentDomain.BaseDirectory;
77
+
78
+ string logFile = System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "service.log"));
79
+ psi.RedirectStandardOutput = true;
80
+ psi.RedirectStandardError = true;
81
+
82
+ Process proc = new Process();
83
+ proc.StartInfo = psi;
84
+ proc.EnableRaisingEvents = true;
85
+ proc.OutputDataReceived += (s, e) => {
86
+ if (!string.IsNullOrEmpty(e.Data)) {
87
+ try { System.IO.File.AppendAllText(logFile, string.Format("[{0:s}] {1}", DateTime.Now, e.Data) + Environment.NewLine); } catch {}
88
+ }
89
+ };
90
+ proc.ErrorDataReceived += (s, e) => {
91
+ if (!string.IsNullOrEmpty(e.Data)) {
92
+ try { System.IO.File.AppendAllText(logFile, string.Format("[{0:s}] [ERR] {1}", DateTime.Now, e.Data) + Environment.NewLine); } catch {}
93
+ }
94
+ };
95
+ proc.Exited += OnChildExited;
96
+ proc.Start();
97
+ proc.BeginOutputReadLine();
98
+ proc.BeginErrorReadLine();
99
+ _process = proc;
100
+ }
101
+
102
+ private void OnChildExited(object sender, EventArgs e)
103
+ {
104
+ if (_stopping) return;
105
+ Thread.Sleep(500);
106
+ if (_stopping) return;
107
+ try
108
+ {
109
+ StartChild();
110
+ }
111
+ catch
112
+ {
113
+ }
114
+ }
115
+
116
+ protected override void OnStop()
117
+ {
118
+ _stopping = true;
119
+ if (_process != null && !_process.HasExited)
120
+ {
121
+ try
122
+ {
123
+ Process killer = Process.Start("taskkill.exe", string.Format("/F /T /PID {0}", _process.Id));
124
+ if (killer != null)
125
+ {
126
+ killer.WaitForExit(5000);
127
+ }
128
+ }
129
+ catch
130
+ {
131
+ try
132
+ {
133
+ _process.Kill();
134
+ }
135
+ catch
136
+ {
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ public static void Main(string[] args)
143
+ {
144
+ ServiceBase.Run(new UChanService(args));
145
+ }
146
+ }
147
+ }
148
148
  `;
@@ -1,8 +1,9 @@
1
1
  import crypto from 'node:crypto';
2
- export const DEFAULT_NPM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
3
- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3/MAFFxXKHiIkQO5cJ3Yhl5i
4
- 6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==
2
+ export const DEFAULT_NPM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
3
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+H
4
+ OKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==
5
5
  -----END PUBLIC KEY-----`;
6
+ export const DEFAULT_NPM_KEY_ID = 'SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U';
6
7
  export function parseSemver(v) {
7
8
  const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(v.trim());
8
9
  if (!m)
@@ -91,14 +92,14 @@ export class NpmPackageVerifier {
91
92
  packageName;
92
93
  registryUrl;
93
94
  npmPublicKey;
94
- requireSignature;
95
+ npmKeyId;
95
96
  expectedRepo;
96
97
  fetcher;
97
98
  constructor(opts = {}) {
98
99
  this.packageName = opts.packageName ?? 'u-chan-secrets';
99
100
  this.registryUrl = (opts.registryUrl ?? 'https://registry.npmjs.org').replace(/\/$/, '');
100
101
  this.npmPublicKey = opts.npmPublicKey ?? DEFAULT_NPM_PUBLIC_KEY;
101
- this.requireSignature = opts.npmPublicKey !== undefined;
102
+ this.npmKeyId = opts.npmPublicKey === undefined ? DEFAULT_NPM_KEY_ID : undefined;
102
103
  this.expectedRepo = opts.expectedRepo ?? 'https://github.com/anchuwithdeadline/u-chan-secrets';
103
104
  this.fetcher = opts.fetcher ?? globalThis.fetch;
104
105
  }
@@ -145,17 +146,18 @@ export class NpmPackageVerifier {
145
146
  }
146
147
  // 2. ECDSA signature check
147
148
  const hasSignatures = Array.isArray(dist.signatures) && dist.signatures.length > 0;
148
- const hasAttestations = Boolean(dist.attestations?.url);
149
- if (!hasSignatures && (this.requireSignature || !hasAttestations)) {
149
+ if (!hasSignatures) {
150
150
  return { ok: false, error: 'registry signature verification failed' };
151
151
  }
152
- if (hasSignatures && this.npmPublicKey) {
153
- const signedStr = `${this.packageName}@${targetVersion}:${dist.shasum}`;
152
+ if (hasSignatures) {
153
+ const signedStr = `${this.packageName}@${targetVersion}:${dist.integrity}`;
154
154
  const signedData = Buffer.from(signedStr, 'utf8');
155
155
  let valid = false;
156
156
  for (const s of dist.signatures) {
157
+ if (this.npmKeyId && s.keyid !== this.npmKeyId)
158
+ continue;
157
159
  try {
158
- if (crypto.verify(null, signedData, this.npmPublicKey, Buffer.from(s.sig, 'base64'))) {
160
+ if (crypto.verify('sha256', signedData, this.npmPublicKey, Buffer.from(s.sig, 'base64'))) {
159
161
  valid = true;
160
162
  break;
161
163
  }
@@ -167,6 +169,7 @@ export class NpmPackageVerifier {
167
169
  }
168
170
  }
169
171
  // 3. Provenance check
172
+ // Repository metadata is an additional policy check, never a substitute for a valid signature.
170
173
  if (dist.attestations?.url) {
171
174
  const attRes = await this.fetcher(dist.attestations.url);
172
175
  if (!attRes.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-chan-secrets",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "U-Chan Secrets: local MCP server for Bitwarden-backed logins",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,69 +1,69 @@
1
- @echo off
2
- setlocal
3
- set ROOT=%~dp0..
4
-
5
- fltmc.exe >nul 2>&1
6
- if %errorlevel% neq 0 (
7
- echo [*] Requesting Administrator elevation...
8
- powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
- exit /b
10
- )
11
-
12
- cd /d "%ROOT%"
13
- echo ========================================================
14
- echo U-Chan Secrets V2 Installation & Setup
15
- echo ========================================================
16
- echo.
17
- echo [*] Step 1: Stopping previous service (if any)...
18
- taskkill /F /IM prompt-helper.exe >nul 2>&1
19
- taskkill /F /IM auth-helper.exe >nul 2>&1
20
- call node dist\cli.js uninstall
21
- echo.
22
- echo [*] Step 2: Installing U-Chan Windows Service...
23
- call node dist\cli.js install
24
- if %errorlevel% neq 0 (
25
- echo.
26
- echo [X] Installation failed with exit code %errorlevel%.
27
- pause
28
- exit /b %errorlevel%
29
- )
30
- icacls C:\ProgramData\u-chan-secrets\bin /grant "Users:(OI)(CI)RX" >nul 2>&1
31
- echo.
32
- echo [*] Step 3: Checking service status...
33
- sc.exe query u-chan-secrets
34
- echo.
35
-
36
- echo [*] Step 4: Bitwarden Vault Login...
37
- set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
38
- set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
39
- if not exist "%BW_BIN%" (
40
- for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
41
- )
42
-
43
- if exist "%BW_BIN%" (
44
- "%BW_BIN%" status > "%TEMP%\bw_stat.json" 2>&1
45
- findstr /C:"\"unauthenticated\"" "%TEMP%\bw_stat.json" >nul 2>&1
46
- if %errorlevel% equ 0 (
47
- echo [!] Bitwarden is not logged in for the privileged service.
48
- echo [*] Please log in to your Bitwarden vault below:
49
- "%BW_BIN%" login
50
- ) else (
51
- echo [+] Bitwarden vault is already logged in.
52
- )
53
- del "%TEMP%\bw_stat.json" >nul 2>&1
54
- ) else (
55
- echo [!] Warning: bw.exe not found.
56
- )
57
-
58
- echo.
59
- copy /y C:\ProgramData\u-chan-secrets\service.log "%ROOT%\service-copy.log" >nul 2>&1
60
- copy /y C:\ProgramData\u-chan-secrets\settings.json "%ROOT%\settings-copy.json" >nul 2>&1
61
- echo ========================================================
62
- echo [+] Service and Bitwarden configuration complete!
63
- echo.
64
- echo Next: Close this window, open your normal terminal, and run:
65
- echo u-chan import-v1
66
- echo u-chan unlock
67
- echo ========================================================
68
- echo.
69
- pause
1
+ @echo off
2
+ setlocal
3
+ set ROOT=%~dp0..
4
+
5
+ fltmc.exe >nul 2>&1
6
+ if %errorlevel% neq 0 (
7
+ echo [*] Requesting Administrator elevation...
8
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
+ exit /b
10
+ )
11
+
12
+ cd /d "%ROOT%"
13
+ echo ========================================================
14
+ echo U-Chan Secrets V2 Installation & Setup
15
+ echo ========================================================
16
+ echo.
17
+ echo [*] Step 1: Stopping previous service (if any)...
18
+ taskkill /F /IM prompt-helper.exe >nul 2>&1
19
+ taskkill /F /IM auth-helper.exe >nul 2>&1
20
+ call node dist\cli.js uninstall
21
+ echo.
22
+ echo [*] Step 2: Installing U-Chan Windows Service...
23
+ call node dist\cli.js install
24
+ if %errorlevel% neq 0 (
25
+ echo.
26
+ echo [X] Installation failed with exit code %errorlevel%.
27
+ pause
28
+ exit /b %errorlevel%
29
+ )
30
+ icacls C:\ProgramData\u-chan-secrets\bin /grant "Users:(OI)(CI)RX" >nul 2>&1
31
+ echo.
32
+ echo [*] Step 3: Checking service status...
33
+ sc.exe query u-chan-secrets
34
+ echo.
35
+
36
+ echo [*] Step 4: Bitwarden Vault Login...
37
+ set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
38
+ set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
39
+ if not exist "%BW_BIN%" (
40
+ for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
41
+ )
42
+
43
+ if exist "%BW_BIN%" (
44
+ "%BW_BIN%" status > "%TEMP%\bw_stat.json" 2>&1
45
+ findstr /C:"\"unauthenticated\"" "%TEMP%\bw_stat.json" >nul 2>&1
46
+ if %errorlevel% equ 0 (
47
+ echo [!] Bitwarden is not logged in for the privileged service.
48
+ echo [*] Please log in to your Bitwarden vault below:
49
+ "%BW_BIN%" login
50
+ ) else (
51
+ echo [+] Bitwarden vault is already logged in.
52
+ )
53
+ del "%TEMP%\bw_stat.json" >nul 2>&1
54
+ ) else (
55
+ echo [!] Warning: bw.exe not found.
56
+ )
57
+
58
+ echo.
59
+ copy /y C:\ProgramData\u-chan-secrets\service.log "%ROOT%\service-copy.log" >nul 2>&1
60
+ copy /y C:\ProgramData\u-chan-secrets\settings.json "%ROOT%\settings-copy.json" >nul 2>&1
61
+ echo ========================================================
62
+ echo [+] Service and Bitwarden configuration complete!
63
+ echo.
64
+ echo Next: Close this window, open your normal terminal, and run:
65
+ echo u-chan import-v1
66
+ echo u-chan unlock
67
+ echo ========================================================
68
+ echo.
69
+ pause
@@ -1,21 +1,21 @@
1
- @echo off
2
- setlocal
3
- set ROOT=%~dp0..
4
-
5
- fltmc.exe >nul 2>&1
6
- if %errorlevel% neq 0 (
7
- echo [*] Requesting Administrator elevation...
8
- powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
- exit /b
10
- )
11
-
12
- set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
13
- set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
14
- if not exist "%BW_BIN%" (
15
- for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
16
- )
17
-
18
- echo [*] Logging in to Bitwarden for U-Chan Service Store...
19
- "%BW_BIN%" login
20
- echo.
21
- pause
1
+ @echo off
2
+ setlocal
3
+ set ROOT=%~dp0..
4
+
5
+ fltmc.exe >nul 2>&1
6
+ if %errorlevel% neq 0 (
7
+ echo [*] Requesting Administrator elevation...
8
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
+ exit /b
10
+ )
11
+
12
+ set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
13
+ set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
14
+ if not exist "%BW_BIN%" (
15
+ for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
16
+ )
17
+
18
+ echo [*] Logging in to Bitwarden for U-Chan Service Store...
19
+ "%BW_BIN%" login
20
+ echo.
21
+ pause
@@ -1,4 +1,4 @@
1
- @echo off
2
- setlocal
3
- call "%~dp0debug-install.bat" %*
4
-
1
+ @echo off
2
+ setlocal
3
+ call "%~dp0debug-install.bat" %*
4
+
@@ -1,50 +1,50 @@
1
- // Run from a trusted administrator terminal after building and installing the service.
2
- // Exercises SYSTEM relay, desktop entry, restoration and reply transport without a password UI.
3
- import fs from 'node:fs/promises'
4
- import path from 'node:path'
5
- import { randomUUID } from 'node:crypto'
6
- import { promisify } from 'node:util'
7
- import { execFile } from 'node:child_process'
8
- import { compileCsharp } from '../dist/platform/windows/assets/compiler.js'
9
- import { PROMPT_HELPER_CSHARP } from '../dist/platform/windows/assets/prompt-src.js'
10
- import { defaultAdminCheck } from '../dist/platform/windows/install.js'
11
- import { setAdminOnlyAcl } from '../dist/platform/windows/acl.js'
12
-
13
- if (process.platform !== 'win32' || !await defaultAdminCheck()) throw new Error('Use a trusted Windows administrator terminal')
14
- const home = path.join(process.env.ProgramData ?? 'C:\\ProgramData', 'u-chan-secrets')
15
- const root = path.join(home, '#0 backup', 'desktop-probe-' + randomUUID())
16
- const task = 'UChanDesktopProbe-' + randomUUID()
17
- const exec = promisify(execFile)
18
- const powershell = script => exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true })
19
- const quote = value => "'" + value.replaceAll("'", "''") + "'"
20
- let registered = false
21
- try {
22
- await fs.mkdir(root)
23
- await setAdminOnlyAcl(root)
24
- const trace = path.join(root, 'desktop-trace.txt')
25
- if (!PROMPT_HELPER_CSHARP.includes('return RunDialog(args);') || !PROMPT_HELPER_CSHARP.includes('SwitchDesktop(original);')) throw new Error('Native probe hooks no longer match; no helper was run')
26
- const source = PROMPT_HELPER_CSHARP
27
- .replace('return RunDialog(args);', `File.AppendAllText(@"${trace}", "entered"+Environment.NewLine); return 0;`)
28
- .replace('SwitchDesktop(original);', `if (SwitchDesktop(original)) File.AppendAllText(@"${trace}", "restored"+Environment.NewLine);`)
29
- const helper = path.join(root, 'probe.exe')
30
- await compileCsharp(source, helper, { outputType: 'WindowsApplication', referencedAssemblies: ['System.Windows.Forms.dll', 'System.Drawing.dll'] })
31
- const output = path.join(root, 'result.json'), runner = path.join(root, 'runner.mjs')
32
- await fs.writeFile(runner, `import{execFile}from'node:child_process';import fs from'node:fs';execFile(${JSON.stringify(helper)},['--mode','confirm','--phrase','DISPOSABLE-NATIVE-PROBE','--lines','[]'],{timeout:35000,windowsHide:true},(e,out)=>fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({exitCode:e?.code??0,stdoutBytes:Buffer.byteLength(out)})));`)
33
- await setAdminOnlyAcl(root)
34
- await powershell(`$ErrorActionPreference='Stop';$a=New-ScheduledTaskAction -Execute ${quote(path.join(home, 'bin', 'node.exe'))} -Argument ${quote('"' + runner + '"')};$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest;$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries;Register-ScheduledTask -TaskName ${quote(task)} -Action $a -Principal $p -Settings $s | Out-Null`)
35
- registered = true
36
- await powershell(`Start-ScheduledTask -TaskName ${quote(task)}`)
37
- const deadline = Date.now() + 60000
38
- let execution
39
- while (Date.now() < deadline) {
40
- try { execution = JSON.parse(await fs.readFile(output, 'utf8')); break }
41
- catch (e) { if (e.code !== 'ENOENT') throw e }
42
- await new Promise(resolve => setTimeout(resolve, 500))
43
- }
44
- const events = await fs.readFile(trace, 'utf8').catch(() => '')
45
- const ok = execution?.exitCode === 0 && execution.stdoutBytes === 0 && events.trim() === 'entered\r\nrestored'
46
- console.log(JSON.stringify({ ok, nativeDryRun: true, desktopEntered: events.includes('entered'), desktopRestored: events.includes('restored'), execution, evidence: root }))
47
- if (!ok) process.exitCode = 1
48
- } finally {
49
- if (registered) await powershell(`Stop-ScheduledTask -TaskName ${quote(task)} -ErrorAction SilentlyContinue;Unregister-ScheduledTask -TaskName ${quote(task)} -Confirm:$false`)
50
- }
1
+ // Run from a trusted administrator terminal after building and installing the service.
2
+ // Exercises SYSTEM relay, desktop entry, restoration and reply transport without a password UI.
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { promisify } from 'node:util'
7
+ import { execFile } from 'node:child_process'
8
+ import { compileCsharp } from '../dist/platform/windows/assets/compiler.js'
9
+ import { PROMPT_HELPER_CSHARP } from '../dist/platform/windows/assets/prompt-src.js'
10
+ import { defaultAdminCheck } from '../dist/platform/windows/install.js'
11
+ import { setAdminOnlyAcl } from '../dist/platform/windows/acl.js'
12
+
13
+ if (process.platform !== 'win32' || !await defaultAdminCheck()) throw new Error('Use a trusted Windows administrator terminal')
14
+ const home = path.join(process.env.ProgramData ?? 'C:\\ProgramData', 'u-chan-secrets')
15
+ const root = path.join(home, '#0 backup', 'desktop-probe-' + randomUUID())
16
+ const task = 'UChanDesktopProbe-' + randomUUID()
17
+ const exec = promisify(execFile)
18
+ const powershell = script => exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true })
19
+ const quote = value => "'" + value.replaceAll("'", "''") + "'"
20
+ let registered = false
21
+ try {
22
+ await fs.mkdir(root)
23
+ await setAdminOnlyAcl(root)
24
+ const trace = path.join(root, 'desktop-trace.txt')
25
+ if (!PROMPT_HELPER_CSHARP.includes('return RunDialog(args);') || !PROMPT_HELPER_CSHARP.includes('SwitchDesktop(original);')) throw new Error('Native probe hooks no longer match; no helper was run')
26
+ const source = PROMPT_HELPER_CSHARP
27
+ .replace('return RunDialog(args);', `File.AppendAllText(@"${trace}", "entered"+Environment.NewLine); return 0;`)
28
+ .replace('SwitchDesktop(original);', `if (SwitchDesktop(original)) File.AppendAllText(@"${trace}", "restored"+Environment.NewLine);`)
29
+ const helper = path.join(root, 'probe.exe')
30
+ await compileCsharp(source, helper, { outputType: 'WindowsApplication', referencedAssemblies: ['System.Windows.Forms.dll', 'System.Drawing.dll'] })
31
+ const output = path.join(root, 'result.json'), runner = path.join(root, 'runner.mjs')
32
+ await fs.writeFile(runner, `import{execFile}from'node:child_process';import fs from'node:fs';execFile(${JSON.stringify(helper)},['--mode','confirm','--phrase','DISPOSABLE-NATIVE-PROBE','--lines','[]'],{timeout:35000,windowsHide:true},(e,out)=>fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({exitCode:e?.code??0,stdoutBytes:Buffer.byteLength(out)})));`)
33
+ await setAdminOnlyAcl(root)
34
+ await powershell(`$ErrorActionPreference='Stop';$a=New-ScheduledTaskAction -Execute ${quote(path.join(home, 'bin', 'node.exe'))} -Argument ${quote('"' + runner + '"')};$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest;$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries;Register-ScheduledTask -TaskName ${quote(task)} -Action $a -Principal $p -Settings $s | Out-Null`)
35
+ registered = true
36
+ await powershell(`Start-ScheduledTask -TaskName ${quote(task)}`)
37
+ const deadline = Date.now() + 60000
38
+ let execution
39
+ while (Date.now() < deadline) {
40
+ try { execution = JSON.parse(await fs.readFile(output, 'utf8')); break }
41
+ catch (e) { if (e.code !== 'ENOENT') throw e }
42
+ await new Promise(resolve => setTimeout(resolve, 500))
43
+ }
44
+ const events = await fs.readFile(trace, 'utf8').catch(() => '')
45
+ const ok = execution?.exitCode === 0 && execution.stdoutBytes === 0 && events.trim() === 'entered\r\nrestored'
46
+ console.log(JSON.stringify({ ok, nativeDryRun: true, desktopEntered: events.includes('entered'), desktopRestored: events.includes('restored'), execution, evidence: root }))
47
+ if (!ok) process.exitCode = 1
48
+ } finally {
49
+ if (registered) await powershell(`Stop-ScheduledTask -TaskName ${quote(task)} -ErrorAction SilentlyContinue;Unregister-ScheduledTask -TaskName ${quote(task)} -Confirm:$false`)
50
+ }