@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.32

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 (187) hide show
  1. package/README.md +95 -89
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +374 -367
  4. package/dist/bin/manage-relay-admin.mjs +20 -20
  5. package/dist/bin/migrate-relay-db.mjs +109 -109
  6. package/dist/bin/publish-android-release.mjs +63 -58
  7. package/dist/bin/publish-harmony-release.mjs +46 -0
  8. package/dist/bin/release-upload-server.mjs +174 -0
  9. package/dist/bin/self-relay-admin.mjs +8 -8
  10. package/dist/bin/self-relay-demo.mjs +33 -33
  11. package/dist/bin/self-relay-server.mjs +66 -66
  12. package/dist/build-platform.json +1 -1
  13. package/dist/get-lan-networks.ps1 +40 -40
  14. package/dist/get-shared-service-url.ps1 +6 -6
  15. package/dist/shared/app-server-events.cjs +422 -412
  16. package/dist/shared/code-browser.cjs +159 -159
  17. package/dist/shared/file-changes.cjs +31 -31
  18. package/dist/shared/file-citations.cjs +117 -117
  19. package/dist/shared/file-mentions.cjs +92 -92
  20. package/dist/shared/message-delivery.cjs +54 -54
  21. package/dist/shared/p2p-data-channel.cjs +72 -72
  22. package/dist/shared/p2p-diagnostics.cjs +28 -28
  23. package/dist/shared/p2p-file-channel.cjs +78 -74
  24. package/dist/shared/plan-pricing.cjs +32 -32
  25. package/dist/shared/question-attention.cjs +69 -0
  26. package/dist/shared/server-requests.cjs +219 -219
  27. package/dist/shared/timeline-sync.cjs +173 -173
  28. package/dist/skills/code-relax/SKILL.md +22 -22
  29. package/dist/src/archived-threads.mjs +22 -0
  30. package/dist/src/background-process.mjs +18 -18
  31. package/dist/src/bounded-process-log.mjs +63 -63
  32. package/dist/src/celsius-client.mjs +107 -107
  33. package/dist/src/code-browser.mjs +100 -100
  34. package/dist/src/desktop-launcher.mjs +46 -46
  35. package/dist/src/dots-approval-timeline.mjs +99 -99
  36. package/dist/src/dots-avatar.mjs +42 -42
  37. package/dist/src/dots-chat-projection.mjs +50 -50
  38. package/dist/src/dots-chat-server.mjs +461 -461
  39. package/dist/src/dots-message-images.mjs +319 -319
  40. package/dist/src/dots-profile.mjs +77 -77
  41. package/dist/src/dots-room-activity.mjs +94 -94
  42. package/dist/src/dots-room-client.mjs +135 -135
  43. package/dist/src/dots-room-projection.mjs +94 -94
  44. package/dist/src/dots-schedules.mjs +66 -66
  45. package/dist/src/dots-summary-tasks.mjs +32 -32
  46. package/dist/src/dots-task-catalog.mjs +430 -430
  47. package/dist/src/dots-task-files.mjs +52 -52
  48. package/dist/src/execution-health.mjs +26 -26
  49. package/dist/src/file-transfer-range.mjs +22 -22
  50. package/dist/src/file-upload-session.mjs +130 -130
  51. package/dist/src/hosted-history-app-server.mjs +103 -103
  52. package/dist/src/hosted-history-cache.mjs +113 -113
  53. package/dist/src/lan-socket-server.mjs +265 -263
  54. package/dist/src/managed-app-server.mjs +244 -244
  55. package/dist/src/message-images.mjs +9 -9
  56. package/dist/src/message-upload-files.mjs +74 -74
  57. package/dist/src/onboarding.mjs +12 -12
  58. package/dist/src/plan-pricing.mjs +47 -47
  59. package/dist/src/platform/README.md +12 -12
  60. package/dist/src/platform/codex-protocol.mjs +46 -46
  61. package/dist/src/platform/codex-runtime.mjs +3 -3
  62. package/dist/src/platform/credential-store.mjs +3 -3
  63. package/dist/src/platform/desktop-host.mjs +3 -3
  64. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  65. package/dist/src/platform/desktop-project-store.mjs +208 -149
  66. package/dist/src/platform/file-opener.mjs +9 -0
  67. package/dist/src/platform/linux/README.md +3 -3
  68. package/dist/src/platform/linux/codex-runtime.mjs +56 -0
  69. package/dist/src/platform/linux/credential-store.mjs +28 -0
  70. package/dist/src/platform/linux/desktop-host.mjs +83 -0
  71. package/dist/src/platform/linux/desktop-installation.mjs +36 -0
  72. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
  73. package/dist/src/platform/linux/network-info.mjs +11 -0
  74. package/dist/src/platform/linux/process.mjs +42 -0
  75. package/dist/src/platform/linux/project-locations.mjs +17 -0
  76. package/dist/src/platform/linux/resident-startup.mjs +52 -0
  77. package/dist/src/platform/linux/retained-runtime.mjs +76 -0
  78. package/dist/src/platform/linux/service-host.mjs +18 -0
  79. package/dist/src/platform/linux/system.mjs +49 -0
  80. package/dist/src/platform/linux/user-environment.mjs +7 -0
  81. package/dist/src/platform/macos/README.md +3 -3
  82. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  83. package/dist/src/platform/macos/credential-store.mjs +10 -10
  84. package/dist/src/platform/macos/desktop-host.mjs +82 -82
  85. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
  86. package/dist/src/platform/macos/network-info.mjs +8 -28
  87. package/dist/src/platform/macos/project-locations.mjs +8 -8
  88. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  89. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  90. package/dist/src/platform/macos/service-host.mjs +18 -18
  91. package/dist/src/platform/macos/system.mjs +29 -29
  92. package/dist/src/platform/macos/user-environment.mjs +7 -7
  93. package/dist/src/platform/native-package.mjs +58 -46
  94. package/dist/src/platform/network-info.mjs +3 -3
  95. package/dist/src/platform/path-identity.mjs +5 -5
  96. package/dist/src/platform/posix/network-candidates.mjs +23 -0
  97. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  98. package/dist/src/platform/project-locations.mjs +3 -3
  99. package/dist/src/platform/runtime-paths.mjs +8 -8
  100. package/dist/src/platform/select.mjs +5 -5
  101. package/dist/src/platform/service-host.mjs +3 -3
  102. package/dist/src/platform/user-environment.mjs +3 -3
  103. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  104. package/dist/src/platform/windows/activate-desktop.ps1 +48 -48
  105. package/dist/src/platform/windows/background-process.mjs +30 -30
  106. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  107. package/dist/src/platform/windows/credential-store.mjs +27 -27
  108. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  109. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  110. package/dist/src/platform/windows/desktop-host.mjs +58 -58
  111. package/dist/src/platform/windows/desktop-info.ps1 +14 -14
  112. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  113. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  114. package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
  115. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  116. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  117. package/dist/src/platform/windows/network-info.mjs +34 -34
  118. package/dist/src/platform/windows/project-locations.mjs +16 -16
  119. package/dist/src/platform/windows/resident-start.js +20 -20
  120. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  121. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  122. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  123. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  124. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  125. package/dist/src/platform/windows/service-host.mjs +31 -31
  126. package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
  127. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  128. package/dist/src/platform/windows/user-environment.mjs +50 -50
  129. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  130. package/dist/src/project-archive.mjs +30 -0
  131. package/dist/src/project-create.mjs +121 -119
  132. package/dist/src/project-management.mjs +59 -69
  133. package/dist/src/project-remove.mjs +55 -0
  134. package/dist/src/project-update.mjs +56 -0
  135. package/dist/src/runtime-discovery.mjs +47 -47
  136. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  137. package/dist/src/self-relay/account-connections.mjs +52 -52
  138. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  139. package/dist/src/self-relay/admin-page.mjs +80 -80
  140. package/dist/src/self-relay/admin-server.mjs +74 -74
  141. package/dist/src/self-relay/admin-state.mjs +127 -127
  142. package/dist/src/self-relay/admin-style.mjs +14 -14
  143. package/dist/src/self-relay/android-releases.mjs +61 -61
  144. package/dist/src/self-relay/client.mjs +61 -61
  145. package/dist/src/self-relay/connector.mjs +439 -438
  146. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  147. package/dist/src/self-relay/demo.mjs +37 -37
  148. package/dist/src/self-relay/device-info.mjs +27 -0
  149. package/dist/src/self-relay/device-store.mjs +92 -84
  150. package/dist/src/self-relay/diagnostics.mjs +54 -54
  151. package/dist/src/self-relay/email-accounts.mjs +183 -183
  152. package/dist/src/self-relay/file-wire.mjs +13 -13
  153. package/dist/src/self-relay/lan-authorizations.mjs +71 -42
  154. package/dist/src/self-relay/lifecycle.mjs +264 -250
  155. package/dist/src/self-relay/long-pairing.mjs +40 -40
  156. package/dist/src/self-relay/mysql-state.mjs +118 -105
  157. package/dist/src/self-relay/p2p-probe.mjs +159 -159
  158. package/dist/src/self-relay/privacy-page.mjs +26 -26
  159. package/dist/src/self-relay/public-releases.mjs +52 -0
  160. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  161. package/dist/src/self-relay/server.mjs +1006 -932
  162. package/dist/src/self-relay/usage-daily.mjs +55 -55
  163. package/dist/src/self-relay/usage-meter.mjs +141 -141
  164. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  165. package/dist/src/self-relay/wire.mjs +36 -36
  166. package/dist/src/server.mjs +1861 -1778
  167. package/dist/src/service-control.mjs +82 -82
  168. package/dist/src/service-doctor.mjs +110 -110
  169. package/dist/src/service-health.mjs +33 -33
  170. package/dist/src/service-install.mjs +91 -91
  171. package/dist/src/service-lifecycle.mjs +138 -138
  172. package/dist/src/service-restart.mjs +81 -81
  173. package/dist/src/shared-app-server.mjs +615 -611
  174. package/dist/src/shared-catalog.mjs +132 -132
  175. package/dist/src/shared-recovery.mjs +146 -146
  176. package/dist/src/shared-thread-stream.mjs +164 -164
  177. package/dist/src/skill-install.mjs +51 -51
  178. package/dist/src/thread-title-generation.mjs +150 -150
  179. package/dist/src/turn-model-history.mjs +47 -47
  180. package/dist/src/usage-reset.mjs +17 -17
  181. package/dist/src/workstation-downloads.mjs +120 -120
  182. package/dist/tools/rtc-route-helper.ps1 +99 -99
  183. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  184. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  185. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  186. package/package.json +7 -6
  187. package/tools/postinstall.mjs +127 -127
@@ -1,215 +1,215 @@
1
- import fs from 'node:fs';
2
- import { compareVersions, verifyProtocol } from '../codex-protocol.mjs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { createRequire } from 'node:module';
6
- import { execFile } from 'node:child_process';
7
- import { createHash, randomUUID } from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
- import { promisify } from 'node:util';
10
- import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
11
-
12
- const exec = promisify(execFile);
13
- export const codeModeHostPath = executable => path.join(path.dirname(executable), 'codex-code-mode-host.exe');
14
- export const runtimeFilesAvailable = executable => fs.existsSync(executable) && fs.existsSync(codeModeHostPath(executable));
15
- const fingerprint = executable => [executable, codeModeHostPath(executable)]
16
- .map(file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; }).join('|');
17
- export const runtimeFingerprint = fingerprint;
18
- const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
19
- const desktopRuntimeScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'desktop-runtime.ps1');
20
-
21
- async function fileHash(file) {
22
- const hash = createHash('sha256');
23
- for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
24
- return hash.digest('hex');
25
- }
26
-
27
- async function installedDesktopRuntime() {
28
- const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
29
- '-File', desktopRuntimeScript], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
30
- return JSON.parse(stdout.trim());
31
- }
32
-
33
- async function matchesInstalledDesktop(executable, installed) {
34
- if (!installed || !runtimeFilesAvailable(executable) || !runtimeFilesAvailable(installed.executable)) return false;
35
- const files = [[executable, installed.executable], [codeModeHostPath(executable), installed.codeModeHost]];
36
- for (const [candidate, bundled] of files) {
37
- if (fs.statSync(candidate).size !== fs.statSync(bundled).size || await fileHash(candidate) !== await fileHash(bundled)) return false;
38
- }
39
- return true;
40
- }
41
-
42
- function runtimeFiles(directory, relative = '') {
43
- const files = [];
44
- for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) {
45
- const name = path.join(relative, entry.name);
46
- if (entry.isDirectory()) files.push(...runtimeFiles(directory, name));
47
- else if (entry.isFile()) files.push(name);
48
- else throw new Error(`Codex 内核目录包含不支持的文件类型:${name}`);
49
- }
50
- return files.sort();
51
- }
52
-
53
- function runtimeDirectories(files) {
54
- const directories = new Set();
55
- for (const file of files) {
56
- let directory = path.dirname(file);
57
- while (directory !== '.') { directories.add(directory); directory = path.dirname(directory); }
58
- }
59
- return [...directories].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
60
- }
61
-
62
- async function retainDesktopRuntime(executable, installed, stateRoot) {
63
- if (!/^[\w.-]+$/.test(installed.package)) throw new Error('Codex Desktop 安装包名称无效。');
64
- const source = path.dirname(executable);
65
- const files = runtimeFiles(source);
66
- if (files.includes('runtime.json')) throw new Error('Codex 内核目录与保留清单文件名冲突。');
67
- const directories = runtimeDirectories(files);
68
- const root = path.join(stateRoot, 'desktop-runtimes');
69
- const target = path.join(root, installed.package);
70
- const retained = path.join(target, 'codex.exe');
71
- if (fs.existsSync(target)) {
72
- const manifest = readRecord(path.join(target, 'runtime.json'));
73
- if (manifest?.package !== installed.package || JSON.stringify(manifest.files) !== JSON.stringify(files)
74
- || JSON.stringify(runtimeFiles(target)) !== JSON.stringify([...files, 'runtime.json'].sort())
75
- || !await matchesInstalledDesktop(retained, installed)) {
76
- throw new Error(`已保留的 Codex 内核文件不完整:${target}`);
77
- }
78
- return retained;
79
- }
80
- fs.mkdirSync(root, { recursive: true });
81
- const staging = path.join(root, `.${installed.package}.${randomUUID()}.tmp`);
82
- fs.mkdirSync(staging);
83
- const created = [];
84
- try {
85
- for (const directory of directories) fs.mkdirSync(path.join(staging, directory));
86
- for (const name of files) {
87
- const from = path.join(source, name), to = path.join(staging, name);
88
- created.push(to);
89
- try { fs.linkSync(from, to); }
90
- catch (error) {
91
- if (error.code !== 'EXDEV') throw error;
92
- fs.copyFileSync(from, to);
93
- }
94
- }
95
- if (!await matchesInstalledDesktop(path.join(staging, 'codex.exe'), installed)) {
96
- throw new Error('保留的 Codex 内核与当前安装包不一致。');
97
- }
98
- const manifestPath = path.join(staging, 'runtime.json');
99
- writeRecord(manifestPath, { package: installed.package, files });
100
- created.push(manifestPath);
101
- fs.renameSync(staging, target);
102
- return retained;
103
- } catch (error) {
104
- for (const file of created) fs.rmSync(file, { force: true });
105
- for (const directory of directories.reverse()) {
106
- const nested = path.join(staging, directory);
107
- if (fs.existsSync(nested)) fs.rmdirSync(nested);
108
- }
109
- fs.rmdirSync(staging);
110
- throw error;
111
- }
112
- }
113
-
114
- export function pruneRetainedDesktopRuntimes(stateRoot, keepExecutable) {
115
- const root = path.join(stateRoot, 'desktop-runtimes');
116
- if (!fs.existsSync(root)) return;
117
- const keep = path.resolve(path.dirname(keepExecutable)).toLowerCase();
118
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
119
- if (!entry.isDirectory() || !/^OpenAI\.Codex_[\w.-]+$/.test(entry.name)) continue;
120
- const directory = path.join(root, entry.name);
121
- if (path.resolve(directory).toLowerCase() === keep || fs.lstatSync(directory).isSymbolicLink()) continue;
122
- const manifest = readRecord(path.join(directory, 'runtime.json'));
123
- const files = manifest?.files;
124
- if (manifest?.package !== entry.name || !Array.isArray(files) || !files.includes('codex.exe')
125
- || !files.includes('codex-code-mode-host.exe') || new Set(files).size !== files.length
126
- || !files.every(name => typeof name === 'string' && name !== 'runtime.json'
127
- && path.resolve(directory, name).toLowerCase().startsWith(`${path.resolve(directory).toLowerCase()}${path.sep}`))) continue;
128
- const expected = [...files, 'runtime.json'].sort();
129
- if (JSON.stringify(runtimeFiles(directory)) !== JSON.stringify(expected)) continue;
130
- if (!fs.lstatSync(path.join(directory, 'runtime.json')).isFile()
131
- || !files.every(name => fs.lstatSync(path.join(directory, name)).isFile())) continue;
132
- for (const name of files) fs.unlinkSync(path.join(directory, name));
133
- fs.unlinkSync(path.join(directory, 'runtime.json'));
134
- for (const name of runtimeDirectories(files).reverse()) fs.rmdirSync(path.join(directory, name));
135
- fs.rmdirSync(directory);
136
- }
137
- }
138
-
139
- // Resolve native binaries, never execute a shell shim from a background task.
140
- export function discoverCodex(environment = process.env) {
141
- const found = new Map();
142
- const add = (file, source) => {
143
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return;
144
- const executable = fs.realpathSync(file);
145
- if (!found.has(executable.toLowerCase()) || source === 'desktop') found.set(executable.toLowerCase(), { executable, source });
146
- };
147
- if (environment.CODE_RELAX_CODEX_PATH) {
148
- const file = environment.CODE_RELAX_CODEX_PATH;
149
- if (!path.isAbsolute(file) || path.basename(file).toLowerCase() !== 'codex.exe') throw new Error('CODE_RELAX_CODEX_PATH 必须是 codex.exe 的绝对路径。');
150
- add(file, 'explicit');
151
- if (!found.size) throw new Error('CODE_RELAX_CODEX_PATH 指定的程序不存在。');
152
- return [...found.values()];
153
- }
154
- const local = environment.LOCALAPPDATA || path.join(os.homedir(), 'AppData/Local');
155
- const home = environment.CODEX_HOME || path.join(os.homedir(), '.codex');
156
- const directories = [...String(environment.PATH || environment.Path || '').split(';').filter(Boolean),
157
- environment.CODEX_INSTALL_DIR, path.join(local, 'Programs/OpenAI/Codex/bin'),
158
- path.join(home, 'packages/standalone/current/bin')].filter(Boolean);
159
- for (const directory of directories) {
160
- add(path.join(directory, 'codex.exe'), 'cli');
161
- const packageFile = path.join(directory, 'node_modules/@openai/codex/package.json');
162
- if (!fs.existsSync(packageFile)) continue;
163
- const root = path.dirname(fs.realpathSync(packageFile));
164
- let vendor = path.join(root, 'vendor');
165
- try { vendor = path.join(path.dirname(createRequire(packageFile).resolve('@openai/codex-win32-x64/package.json')), 'vendor'); }
166
- catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
167
- add(path.join(vendor, 'x86_64-pc-windows-msvc/bin/codex.exe'), 'npm');
168
- }
169
- const desktopRoot = path.join(local, 'OpenAI/Codex/bin');
170
- add(path.join(desktopRoot, 'codex.exe'), 'desktop');
171
- if (fs.existsSync(desktopRoot)) for (const entry of fs.readdirSync(desktopRoot, { withFileTypes: true })) {
172
- if (entry.isDirectory()) add(path.join(desktopRoot, entry.name, 'codex.exe'), 'desktop');
173
- }
174
- return [...found.values()];
175
- }
176
-
177
- export async function resolveCodexRuntime(stateRoot, { refresh = false, environment = process.env } = {}) {
178
- fs.mkdirSync(stateRoot, { recursive: true });
179
- const recordFile = path.join(stateRoot, 'codex-runtime.json');
180
- const saved = readRecord(recordFile);
181
- environment = { ...environment,
182
- CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
183
- CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
184
- if (!refresh && saved?.executable && runtimeFilesAvailable(saved.executable)
185
- && saved.fingerprint === fingerprint(saved.executable)) return saved;
186
- const candidates = [], rejected = [];
187
- let installed = null;
188
- try { installed = await installedDesktopRuntime(); }
189
- catch (error) { rejected.push(`无法读取当前 Codex Desktop 安装包:${error.message}`); }
190
- for (const candidate of discoverCodex(environment)) {
191
- try {
192
- if (!runtimeFilesAvailable(candidate.executable)) throw new Error('缺少同目录的 codex-code-mode-host.exe');
193
- if (candidate.source === 'desktop' && !await matchesInstalledDesktop(candidate.executable, installed)) {
194
- throw new Error('不是当前 Codex Desktop 安装包配套的 CLI');
195
- }
196
- const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
197
- const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
198
- if (!version) throw new Error('无法识别版本');
199
- candidates.push({ ...candidate, version });
200
- } catch (error) { rejected.push(`${candidate.executable}: ${error.message}`); }
201
- }
202
- candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
203
- for (const candidate of candidates) {
204
- try {
205
- const executable = candidate.source === 'desktop'
206
- ? await retainDesktopRuntime(candidate.executable, installed, stateRoot) : candidate.executable;
207
- await verifyProtocol(executable, stateRoot);
208
- const result = { ...candidate, executable, fingerprint: fingerprint(executable),
209
- codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
210
- writeRecord(recordFile, result);
211
- return result;
212
- } catch (error) { rejected.push(`${candidate.executable}: ${error.message}`); }
213
- }
214
- throw new Error(`未找到兼容的 Codex CLI,请安装或更新 Codex CLI 后执行 relax setup。${rejected.length ? '\n' + rejected.join('\n') : ''}`);
215
- }
1
+ import fs from 'node:fs';
2
+ import { compareVersions, verifyProtocol } from '../codex-protocol.mjs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { execFile } from 'node:child_process';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { promisify } from 'node:util';
10
+ import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
11
+
12
+ const exec = promisify(execFile);
13
+ export const codeModeHostPath = executable => path.join(path.dirname(executable), 'codex-code-mode-host.exe');
14
+ export const runtimeFilesAvailable = executable => fs.existsSync(executable) && fs.existsSync(codeModeHostPath(executable));
15
+ const fingerprint = executable => [executable, codeModeHostPath(executable)]
16
+ .map(file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; }).join('|');
17
+ export const runtimeFingerprint = fingerprint;
18
+ const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
19
+ const desktopRuntimeScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'desktop-runtime.ps1');
20
+
21
+ async function fileHash(file) {
22
+ const hash = createHash('sha256');
23
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
24
+ return hash.digest('hex');
25
+ }
26
+
27
+ async function installedDesktopRuntime() {
28
+ const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
29
+ '-File', desktopRuntimeScript], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
30
+ return JSON.parse(stdout.trim());
31
+ }
32
+
33
+ async function matchesInstalledDesktop(executable, installed) {
34
+ if (!installed || !runtimeFilesAvailable(executable) || !runtimeFilesAvailable(installed.executable)) return false;
35
+ const files = [[executable, installed.executable], [codeModeHostPath(executable), installed.codeModeHost]];
36
+ for (const [candidate, bundled] of files) {
37
+ if (fs.statSync(candidate).size !== fs.statSync(bundled).size || await fileHash(candidate) !== await fileHash(bundled)) return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ function runtimeFiles(directory, relative = '') {
43
+ const files = [];
44
+ for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) {
45
+ const name = path.join(relative, entry.name);
46
+ if (entry.isDirectory()) files.push(...runtimeFiles(directory, name));
47
+ else if (entry.isFile()) files.push(name);
48
+ else throw new Error(`Codex 内核目录包含不支持的文件类型:${name}`);
49
+ }
50
+ return files.sort();
51
+ }
52
+
53
+ function runtimeDirectories(files) {
54
+ const directories = new Set();
55
+ for (const file of files) {
56
+ let directory = path.dirname(file);
57
+ while (directory !== '.') { directories.add(directory); directory = path.dirname(directory); }
58
+ }
59
+ return [...directories].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
60
+ }
61
+
62
+ async function retainDesktopRuntime(executable, installed, stateRoot) {
63
+ if (!/^[\w.-]+$/.test(installed.package)) throw new Error('Codex Desktop 安装包名称无效。');
64
+ const source = path.dirname(executable);
65
+ const files = runtimeFiles(source);
66
+ if (files.includes('runtime.json')) throw new Error('Codex 内核目录与保留清单文件名冲突。');
67
+ const directories = runtimeDirectories(files);
68
+ const root = path.join(stateRoot, 'desktop-runtimes');
69
+ const target = path.join(root, installed.package);
70
+ const retained = path.join(target, 'codex.exe');
71
+ if (fs.existsSync(target)) {
72
+ const manifest = readRecord(path.join(target, 'runtime.json'));
73
+ if (manifest?.package !== installed.package || JSON.stringify(manifest.files) !== JSON.stringify(files)
74
+ || JSON.stringify(runtimeFiles(target)) !== JSON.stringify([...files, 'runtime.json'].sort())
75
+ || !await matchesInstalledDesktop(retained, installed)) {
76
+ throw new Error(`已保留的 Codex 内核文件不完整:${target}`);
77
+ }
78
+ return retained;
79
+ }
80
+ fs.mkdirSync(root, { recursive: true });
81
+ const staging = path.join(root, `.${installed.package}.${randomUUID()}.tmp`);
82
+ fs.mkdirSync(staging);
83
+ const created = [];
84
+ try {
85
+ for (const directory of directories) fs.mkdirSync(path.join(staging, directory));
86
+ for (const name of files) {
87
+ const from = path.join(source, name), to = path.join(staging, name);
88
+ created.push(to);
89
+ try { fs.linkSync(from, to); }
90
+ catch (error) {
91
+ if (error.code !== 'EXDEV') throw error;
92
+ fs.copyFileSync(from, to);
93
+ }
94
+ }
95
+ if (!await matchesInstalledDesktop(path.join(staging, 'codex.exe'), installed)) {
96
+ throw new Error('保留的 Codex 内核与当前安装包不一致。');
97
+ }
98
+ const manifestPath = path.join(staging, 'runtime.json');
99
+ writeRecord(manifestPath, { package: installed.package, files });
100
+ created.push(manifestPath);
101
+ fs.renameSync(staging, target);
102
+ return retained;
103
+ } catch (error) {
104
+ for (const file of created) fs.rmSync(file, { force: true });
105
+ for (const directory of directories.reverse()) {
106
+ const nested = path.join(staging, directory);
107
+ if (fs.existsSync(nested)) fs.rmdirSync(nested);
108
+ }
109
+ fs.rmdirSync(staging);
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ export function pruneRetainedDesktopRuntimes(stateRoot, keepExecutable) {
115
+ const root = path.join(stateRoot, 'desktop-runtimes');
116
+ if (!fs.existsSync(root)) return;
117
+ const keep = path.resolve(path.dirname(keepExecutable)).toLowerCase();
118
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
119
+ if (!entry.isDirectory() || !/^OpenAI\.Codex_[\w.-]+$/.test(entry.name)) continue;
120
+ const directory = path.join(root, entry.name);
121
+ if (path.resolve(directory).toLowerCase() === keep || fs.lstatSync(directory).isSymbolicLink()) continue;
122
+ const manifest = readRecord(path.join(directory, 'runtime.json'));
123
+ const files = manifest?.files;
124
+ if (manifest?.package !== entry.name || !Array.isArray(files) || !files.includes('codex.exe')
125
+ || !files.includes('codex-code-mode-host.exe') || new Set(files).size !== files.length
126
+ || !files.every(name => typeof name === 'string' && name !== 'runtime.json'
127
+ && path.resolve(directory, name).toLowerCase().startsWith(`${path.resolve(directory).toLowerCase()}${path.sep}`))) continue;
128
+ const expected = [...files, 'runtime.json'].sort();
129
+ if (JSON.stringify(runtimeFiles(directory)) !== JSON.stringify(expected)) continue;
130
+ if (!fs.lstatSync(path.join(directory, 'runtime.json')).isFile()
131
+ || !files.every(name => fs.lstatSync(path.join(directory, name)).isFile())) continue;
132
+ for (const name of files) fs.unlinkSync(path.join(directory, name));
133
+ fs.unlinkSync(path.join(directory, 'runtime.json'));
134
+ for (const name of runtimeDirectories(files).reverse()) fs.rmdirSync(path.join(directory, name));
135
+ fs.rmdirSync(directory);
136
+ }
137
+ }
138
+
139
+ // Resolve native binaries, never execute a shell shim from a background task.
140
+ export function discoverCodex(environment = process.env) {
141
+ const found = new Map();
142
+ const add = (file, source) => {
143
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return;
144
+ const executable = fs.realpathSync(file);
145
+ if (!found.has(executable.toLowerCase()) || source === 'desktop') found.set(executable.toLowerCase(), { executable, source });
146
+ };
147
+ if (environment.CODE_RELAX_CODEX_PATH) {
148
+ const file = environment.CODE_RELAX_CODEX_PATH;
149
+ if (!path.isAbsolute(file) || path.basename(file).toLowerCase() !== 'codex.exe') throw new Error('CODE_RELAX_CODEX_PATH 必须是 codex.exe 的绝对路径。');
150
+ add(file, 'explicit');
151
+ if (!found.size) throw new Error('CODE_RELAX_CODEX_PATH 指定的程序不存在。');
152
+ return [...found.values()];
153
+ }
154
+ const local = environment.LOCALAPPDATA || path.join(os.homedir(), 'AppData/Local');
155
+ const home = environment.CODEX_HOME || path.join(os.homedir(), '.codex');
156
+ const directories = [...String(environment.PATH || environment.Path || '').split(';').filter(Boolean),
157
+ environment.CODEX_INSTALL_DIR, path.join(local, 'Programs/OpenAI/Codex/bin'),
158
+ path.join(home, 'packages/standalone/current/bin')].filter(Boolean);
159
+ for (const directory of directories) {
160
+ add(path.join(directory, 'codex.exe'), 'cli');
161
+ const packageFile = path.join(directory, 'node_modules/@openai/codex/package.json');
162
+ if (!fs.existsSync(packageFile)) continue;
163
+ const root = path.dirname(fs.realpathSync(packageFile));
164
+ let vendor = path.join(root, 'vendor');
165
+ try { vendor = path.join(path.dirname(createRequire(packageFile).resolve('@openai/codex-win32-x64/package.json')), 'vendor'); }
166
+ catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
167
+ add(path.join(vendor, 'x86_64-pc-windows-msvc/bin/codex.exe'), 'npm');
168
+ }
169
+ const desktopRoot = path.join(local, 'OpenAI/Codex/bin');
170
+ add(path.join(desktopRoot, 'codex.exe'), 'desktop');
171
+ if (fs.existsSync(desktopRoot)) for (const entry of fs.readdirSync(desktopRoot, { withFileTypes: true })) {
172
+ if (entry.isDirectory()) add(path.join(desktopRoot, entry.name, 'codex.exe'), 'desktop');
173
+ }
174
+ return [...found.values()];
175
+ }
176
+
177
+ export async function resolveCodexRuntime(stateRoot, { refresh = false, environment = process.env } = {}) {
178
+ fs.mkdirSync(stateRoot, { recursive: true });
179
+ const recordFile = path.join(stateRoot, 'codex-runtime.json');
180
+ const saved = readRecord(recordFile);
181
+ environment = { ...environment,
182
+ CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
183
+ CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
184
+ if (!refresh && saved?.executable && runtimeFilesAvailable(saved.executable)
185
+ && saved.fingerprint === fingerprint(saved.executable)) return saved;
186
+ const candidates = [], rejected = [];
187
+ let installed = null;
188
+ try { installed = await installedDesktopRuntime(); }
189
+ catch (error) { rejected.push(`无法读取当前 Codex Desktop 安装包:${error.message}`); }
190
+ for (const candidate of discoverCodex(environment)) {
191
+ try {
192
+ if (!runtimeFilesAvailable(candidate.executable)) throw new Error('缺少同目录的 codex-code-mode-host.exe');
193
+ if (candidate.source === 'desktop' && !await matchesInstalledDesktop(candidate.executable, installed)) {
194
+ throw new Error('不是当前 Codex Desktop 安装包配套的 CLI');
195
+ }
196
+ const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
197
+ const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
198
+ if (!version) throw new Error('无法识别版本');
199
+ candidates.push({ ...candidate, version });
200
+ } catch (error) { rejected.push(`${candidate.executable}: ${error.message}`); }
201
+ }
202
+ candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
203
+ for (const candidate of candidates) {
204
+ try {
205
+ const executable = candidate.source === 'desktop'
206
+ ? await retainDesktopRuntime(candidate.executable, installed, stateRoot) : candidate.executable;
207
+ await verifyProtocol(executable, stateRoot);
208
+ const result = { ...candidate, executable, fingerprint: fingerprint(executable),
209
+ codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
210
+ writeRecord(recordFile, result);
211
+ return result;
212
+ } catch (error) { rejected.push(`${candidate.executable}: ${error.message}`); }
213
+ }
214
+ throw new Error(`未找到兼容的 Codex CLI,请安装或更新 Codex CLI 后执行 relax setup。${rejected.length ? '\n' + rejected.join('\n') : ''}`);
215
+ }
@@ -1,27 +1,27 @@
1
- import { spawn } from 'node:child_process';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { runtimePaths } from './runtime-paths.mjs';
5
-
6
- // Secrets travel over stdin/stdout only; never PowerShell arguments or errors.
7
- export function credentialStore(action, record, root = runtimePaths().credentialsRoot) {
8
- if (process.platform !== 'win32') throw new Error('Credential platform adapter unavailable');
9
- return new Promise((resolve, reject) => {
10
- const child = spawn(path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe'),
11
- ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
12
- fileURLToPath(new URL('./credential-store.ps1', import.meta.url))],
13
- { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
14
- let output = '', diagnostic = '';
15
- const timer = setTimeout(() => child.kill(), 15000);
16
- child.stdout.setEncoding('utf8'); child.stdout.on('data', data => { output += data; });
17
- child.stderr.setEncoding('utf8'); child.stderr.on('data', data => { diagnostic += data; }); child.stdin.on('error', () => {});
18
- child.once('error', () => { clearTimeout(timer); reject(new Error('无法启动 Windows 凭据保护组件')); });
19
- child.once('close', code => {
20
- clearTimeout(timer);
21
- if (code !== 0) return reject(new Error('Windows 凭据保护失败,未使用明文替代;请检查目录权限或 Windows 用户是否一致;' + diagnostic.trim()));
22
- try { resolve(JSON.parse(output.replace(/^\uFEFF/, ''))); }
23
- catch { reject(new Error('Windows 凭据保护返回无效结果')); }
24
- });
25
- child.stdin.end(JSON.stringify({ action, root, record }));
26
- });
27
- }
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { runtimePaths } from './runtime-paths.mjs';
5
+
6
+ // Secrets travel over stdin/stdout only; never PowerShell arguments or errors.
7
+ export function credentialStore(action, record, root = runtimePaths().credentialsRoot) {
8
+ if (process.platform !== 'win32') throw new Error('Credential platform adapter unavailable');
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn(path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe'),
11
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
12
+ fileURLToPath(new URL('./credential-store.ps1', import.meta.url))],
13
+ { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
14
+ let output = '', diagnostic = '';
15
+ const timer = setTimeout(() => child.kill(), 15000);
16
+ child.stdout.setEncoding('utf8'); child.stdout.on('data', data => { output += data; });
17
+ child.stderr.setEncoding('utf8'); child.stderr.on('data', data => { diagnostic += data; }); child.stdin.on('error', () => {});
18
+ child.once('error', () => { clearTimeout(timer); reject(new Error('无法启动 Windows 凭据保护组件')); });
19
+ child.once('close', code => {
20
+ clearTimeout(timer);
21
+ if (code !== 0) return reject(new Error('Windows 凭据保护失败,未使用明文替代;请检查目录权限或 Windows 用户是否一致;' + diagnostic.trim()));
22
+ try { resolve(JSON.parse(output.replace(/^\uFEFF/, ''))); }
23
+ catch { reject(new Error('Windows 凭据保护返回无效结果')); }
24
+ });
25
+ child.stdin.end(JSON.stringify({ action, root, record }));
26
+ });
27
+ }
@@ -1,49 +1,49 @@
1
- $ErrorActionPreference = 'Stop'
2
- [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
3
- [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
- try {
5
- Add-Type -AssemblyName System.Security
6
- $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
7
- $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
8
- $directory = Join-Path ([System.IO.Path]::GetFullPath($request.root)) $sid.Value
9
- if (!(Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
10
- if ((Get-Item -LiteralPath $directory).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Invalid credential directory' }
11
- $acl = New-Object System.Security.AccessControl.DirectorySecurity
12
- $acl.SetAccessRuleProtection($true, $false)
13
- foreach ($principal in @($sid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
14
- $identity = New-Object System.Security.Principal.SecurityIdentifier($principal)
15
- $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
16
- $acl.AddAccessRule($rule)
17
- }
18
- [System.IO.Directory]::SetAccessControl($directory, $acl)
19
- $file = Join-Path $directory 'self-relay.json'
20
- if (Test-Path -LiteralPath $file) {
21
- if ((Get-Item -LiteralPath $file).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Invalid credential file' }
22
- }
23
- if ($request.action -eq 'save') {
24
- $plain = [System.Text.Encoding]::UTF8.GetBytes(($request.record | ConvertTo-Json -Depth 12 -Compress))
25
- $encrypted = [System.Security.Cryptography.ProtectedData]::Protect($plain, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
26
- $json = @{ version = 1; protection = 'DPAPI-CurrentUser'; data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
27
- $temporary = Join-Path $directory ([Guid]::NewGuid().ToString() + '.tmp')
28
- $backup = $temporary + '.bak'
29
- try {
30
- $stream = [System.IO.File]::Open($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
31
- try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($json); $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
32
- if (Test-Path -LiteralPath $file) { [System.IO.File]::Replace($temporary, $file, $backup) } else { [System.IO.File]::Move($temporary, $file) }
33
- } finally {
34
- if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force }
35
- if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force }
36
- }
37
- } elseif ($request.action -ne 'load') { throw 'Invalid operation' }
38
- $record = $null
39
- if (Test-Path -LiteralPath $file) {
40
- $envelope = [System.IO.File]::ReadAllText($file, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
41
- if ($envelope.version -ne 1 -or $envelope.protection -ne 'DPAPI-CurrentUser') { throw 'Invalid credential envelope' }
42
- $plain = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($envelope.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
43
- $record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
44
- }
45
- [Console]::Write((@{ path = $file; record = $record } | ConvertTo-Json -Depth 12 -Compress))
46
- } catch {
47
- [Console]::Error.WriteLine('Credential protection failed: ' + $_.Exception.GetType().Name + ' at line ' + $_.InvocationInfo.ScriptLineNumber)
48
- exit 1
49
- }
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
3
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ try {
5
+ Add-Type -AssemblyName System.Security
6
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
7
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
8
+ $directory = Join-Path ([System.IO.Path]::GetFullPath($request.root)) $sid.Value
9
+ if (!(Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
10
+ if ((Get-Item -LiteralPath $directory).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Invalid credential directory' }
11
+ $acl = New-Object System.Security.AccessControl.DirectorySecurity
12
+ $acl.SetAccessRuleProtection($true, $false)
13
+ foreach ($principal in @($sid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
14
+ $identity = New-Object System.Security.Principal.SecurityIdentifier($principal)
15
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
16
+ $acl.AddAccessRule($rule)
17
+ }
18
+ [System.IO.Directory]::SetAccessControl($directory, $acl)
19
+ $file = Join-Path $directory 'self-relay.json'
20
+ if (Test-Path -LiteralPath $file) {
21
+ if ((Get-Item -LiteralPath $file).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Invalid credential file' }
22
+ }
23
+ if ($request.action -eq 'save') {
24
+ $plain = [System.Text.Encoding]::UTF8.GetBytes(($request.record | ConvertTo-Json -Depth 12 -Compress))
25
+ $encrypted = [System.Security.Cryptography.ProtectedData]::Protect($plain, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
26
+ $json = @{ version = 1; protection = 'DPAPI-CurrentUser'; data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
27
+ $temporary = Join-Path $directory ([Guid]::NewGuid().ToString() + '.tmp')
28
+ $backup = $temporary + '.bak'
29
+ try {
30
+ $stream = [System.IO.File]::Open($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
31
+ try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($json); $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
32
+ if (Test-Path -LiteralPath $file) { [System.IO.File]::Replace($temporary, $file, $backup) } else { [System.IO.File]::Move($temporary, $file) }
33
+ } finally {
34
+ if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force }
35
+ if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force }
36
+ }
37
+ } elseif ($request.action -ne 'load') { throw 'Invalid operation' }
38
+ $record = $null
39
+ if (Test-Path -LiteralPath $file) {
40
+ $envelope = [System.IO.File]::ReadAllText($file, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
41
+ if ($envelope.version -ne 1 -or $envelope.protection -ne 'DPAPI-CurrentUser') { throw 'Invalid credential envelope' }
42
+ $plain = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($envelope.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
43
+ $record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
44
+ }
45
+ [Console]::Write((@{ path = $file; record = $record } | ConvertTo-Json -Depth 12 -Compress))
46
+ } catch {
47
+ [Console]::Error.WriteLine('Credential protection failed: ' + $_.Exception.GetType().Name + ' at line ' + $_.InvocationInfo.ScriptLineNumber)
48
+ exit 1
49
+ }