dsh-remote 0.8.72 → 0.8.74

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 (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +470 -389
  3. package/README.md +529 -462
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +4039 -4022
  7. package/lib/credential.js +263 -263
  8. package/lib/errors.js +141 -141
  9. package/lib/exec-job.js +235 -235
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/host-api.js +808 -0
  13. package/lib/hostkey.js +108 -108
  14. package/lib/http-transport.js +196 -196
  15. package/lib/ignore.js +202 -202
  16. package/lib/index.js +4923 -4541
  17. package/lib/instructions-dynamic.js +415 -415
  18. package/lib/openssh.js +879 -879
  19. package/lib/paths.js +252 -252
  20. package/lib/pool.js +963 -893
  21. package/lib/registry.js +296 -293
  22. package/lib/remote-fs.js +173 -173
  23. package/lib/remote-sandbox.js +183 -183
  24. package/lib/remote-shell-child.cjs +344 -305
  25. package/lib/remote-shell.js +542 -518
  26. package/lib/routes-fs.js +245 -245
  27. package/lib/routes-term.js +169 -169
  28. package/lib/search.js +235 -235
  29. package/lib/sftp-surface.js +119 -119
  30. package/lib/sshconfig.js +779 -705
  31. package/lib/sync.js +275 -275
  32. package/lib/tasks.js +95 -95
  33. package/lib/telemetry.js +213 -213
  34. package/lib/terminal.js +205 -205
  35. package/lib/tui/flow.js +130 -0
  36. package/lib/tui/format.js +332 -0
  37. package/lib/tui/i18n.js +344 -0
  38. package/lib/tui/index.js +322 -0
  39. package/lib/tui/provider.js +327 -0
  40. package/lib/tui/scene.js +996 -0
  41. package/lib/update.js +472 -472
  42. package/lib/web-attach.js +850 -850
  43. package/lib/workspace-cleanup.js +337 -0
  44. package/package.json +100 -99
package/lib/credential.js CHANGED
@@ -1,263 +1,263 @@
1
- // dsh-remote — optional OS-keychain password storage.
2
- // Machines keep their password in the OS credential store instead of the
3
- // plaintext machines.json when the user enables "加密保存密码" per machine.
4
- // Every backend is best-effort: any failure resolves to {ok:false} and the
5
- // caller (persistPassword) falls back to plaintext + a user-visible warning
6
- // (the feature must never block connecting, and must never silently drop the
7
- // credential — issue #30).
8
- //
9
- // Backends:
10
- // darwin → `security` (login keychain, generic password)
11
- // win32 → DPAPI via PowerShell (CurrentUser scope), files under
12
- // $DSH_HOME/remote-workspaces/.secrets/
13
- // linux → `secret-tool` (libsecret / gnome-keyring), optional
14
- // else → unsupported (plain)
15
- import { spawn } from 'node:child_process'
16
- import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
17
- import { join as joinPath } from 'node:path'
18
-
19
- const SERVICE = 'dsh-remote'
20
- const TIMEOUT = 8000
21
-
22
- // Windows PowerShell 5.1 does not preload System.Security, so
23
- // [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
24
- // is added (issue #30: without this the whole DPAPI call failed and the caller
25
- // silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
26
- // harmless on PowerShell 7, where the type is already available.
27
- const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
28
-
29
- /**
30
- * Run a helper and resolve with its trimmed stdout.
31
- *
32
- * WHY `spawn` and not `execFile(..., { input })`: measured on a real Ubuntu box
33
- * with a real gnome-keyring, `secret-tool store` given its password through
34
- * `execFile`'s `input` option **never saw EOF**, blocked on the read, and was
35
- * killed by this timeout — so on Linux "encrypt the password" always failed after
36
- * 8 seconds with the useless message "Command failed: secret-tool store …" (empty
37
- * stderr) and the credential was dropped. The same command with an explicitly
38
- * ended stdin (`spawn` + `stdin.end()`, or `sh -c '… < file'`) finishes in ~20ms.
39
- * Nothing in this module needs the extra `execFile` machinery, so stdin is now
40
- * always closed explicitly and the child's stderr is carried on the error (the old
41
- * path dropped it, which is why the failure had no reason attached).
42
- */
43
- function run(bin, args, input) {
44
- return new Promise((resolve, reject) => {
45
- let child
46
- try {
47
- child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
48
- } catch (err) {
49
- reject(err)
50
- return
51
- }
52
- let out = ''
53
- let errOut = ''
54
- let settled = false
55
- const timer = setTimeout(() => {
56
- if (settled) return
57
- settled = true
58
- try { child.kill('SIGTERM') } catch { /* already gone */ }
59
- const err = new Error(`${bin} timed out after ${TIMEOUT}ms`)
60
- err.stderr = errOut
61
- err.code = 'ETIMEDOUT'
62
- reject(err)
63
- }, TIMEOUT)
64
- const done = (err, value) => {
65
- if (settled) return
66
- settled = true
67
- clearTimeout(timer)
68
- if (err) reject(err); else resolve(value)
69
- }
70
- child.on('error', (err) => done(err))
71
- child.on('close', (code) => {
72
- if (code === 0) return done(null, out.trim())
73
- const err = new Error(`${bin} exited with ${code}${errOut.trim() ? ': ' + errOut.trim().slice(0, 300) : ''}`)
74
- err.code = code
75
- err.stderr = errOut
76
- done(err)
77
- })
78
- child.stdout.on('data', (d) => { out += d })
79
- child.stderr.on('data', (d) => { errOut += d })
80
- // Always end stdin: a helper that reads a secret from stdin needs EOF, and one
81
- // that does not must not inherit an open pipe.
82
- try { child.stdin.end(input == null ? '' : String(input)) } catch { /* closed early */ }
83
- })
84
- }
85
-
86
- export function platformBackend() {
87
- if (process.platform === 'darwin') return 'keychain'
88
- if (process.platform === 'win32') return 'windows'
89
- return 'secret' // linux (secret-tool may be absent → falls back)
90
- }
91
-
92
- function account(machineId) {
93
- // Only machine ids are stored; sanitize just in case.
94
- return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
95
- }
96
-
97
- /** Redact a failed command's diagnostics: execFile puts the FULL command line
98
- * (which carries the password as an argv) into `err.message`, and this string is
99
- * shown in the settings UI — so the secret must never survive it. */
100
- function safeReason(err, password) {
101
- const raw = [err && err.stderr, err && err.message, err && err.code]
102
- .filter(Boolean).map(String).join(' ')
103
- const secret = String(password == null ? '' : password)
104
- const scrubbed = secret ? raw.split(secret).join('***') : raw
105
- return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
106
- }
107
-
108
- /** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
109
- * Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
110
- * observable here — a Windows host cannot be exercised from the test suite. */
111
- export function dpapiProtectScript(password) {
112
- return PS_SECURITY_ASSEMBLY +
113
- `$p='${String(password).replace(/'/g, "''")}';` +
114
- `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
115
- `[Convert]::ToBase64String($s)`
116
- }
117
-
118
- /** PowerShell one-liner that reverses dpapiProtectScript(). */
119
- export function dpapiUnprotectScript(b64) {
120
- return PS_SECURITY_ASSEMBLY +
121
- `$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
122
- `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
123
- }
124
-
125
- /** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
126
- * any failure (caller falls back to plaintext). `error` is a password-free
127
- * diagnostic for the UI. */
128
- export async function saveSecret(machineId, password, secretsDir) {
129
- const acc = account(machineId)
130
- try {
131
- if (process.platform === 'darwin') {
132
- await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
133
- return { ok: true, backend: 'keychain', error: '' }
134
- }
135
- if (process.platform === 'win32') {
136
- // DPAPI-encrypt and store under the harness home.
137
- const script = dpapiProtectScript(password)
138
- const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
139
- if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
140
- mkdirSync(secretsDir || '.', { recursive: true })
141
- writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
142
- return { ok: true, backend: 'windows', error: '' }
143
- }
144
- if (process.platform === 'linux') {
145
- await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
146
- return { ok: true, backend: 'secret', error: '' }
147
- }
148
- } catch (err) {
149
- return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
150
- }
151
- return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
152
- }
153
-
154
- /** Decide where a machine password is persisted (issue #30).
155
- *
156
- * The contract the settings UI relies on: a password is NEVER silently dropped.
157
- * Historically that guarantee was upheld by falling back to PLAINTEXT in
158
- * machines.json, which is a bad trade in one specific way: the user asked for
159
- * encryption, the store failed, and the secret ended up less protected than if
160
- * they had never asked. Worse, a locked keychain / missing secret-tool is
161
- * exactly the moment when quietly writing a password to a world-readable file
162
- * on disk is most likely to go unnoticed.
163
- *
164
- * So the fallback is a POLICY, not a fixed behaviour (`onStoreFailure`):
165
- *
166
- * `refuse` (default) — do not store the password at all. The machine is
167
- * saved WITHOUT a credential and the caller reports how to fix the
168
- * store; the user retypes (or connects) and nothing lands in
169
- * plaintext by accident. `stored: 'none'` makes that explicit.
170
- * `plain` — the legacy behaviour: write it to machines.json and raise
171
- * `warning: 'secret-store-failed'`. Opt-in only, for users who
172
- * genuinely prefer a working machine over the storage guarantee.
173
- *
174
- * An explicit `backend: 'plain'` still means plain by REQUEST (the user turned
175
- * encryption off), and is unaffected by this policy.
176
- *
177
- * `save` is injectable so both paths are unit-testable on any platform.
178
- *
179
- * @param {object} arg
180
- * @param {string} arg.backend - the platform backend name ('keychain'|'windows'|'secret'|'plain').
181
- * @param {string} arg.machineId
182
- * @param {string} arg.password
183
- * @param {string} [arg.secretsDir]
184
- * @param {'refuse'|'plain'} [arg.onStoreFailure] - see above.
185
- * @param {Function} [arg.save] - injectable secure-store writer.
186
- * @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
187
- */
188
- export async function persistPassword({
189
- backend, machineId, password, secretsDir,
190
- onStoreFailure = 'refuse',
191
- save = saveSecret,
192
- }) {
193
- const pw = password == null ? '' : String(password)
194
- const want = backend && backend !== 'plain' ? backend : 'plain'
195
- if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
196
- if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
197
- let res
198
- try {
199
- res = await save(machineId, pw, secretsDir)
200
- } catch (err) {
201
- res = { ok: false, error: safeReason(err, pw) }
202
- }
203
- if (res && res.ok) {
204
- return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
205
- }
206
- const error = String((res && res.error) || '') || 'secret store write failed'
207
- if (onStoreFailure === 'plain') {
208
- return {
209
- stored: 'plain',
210
- credentialBackend: 'plain',
211
- password: pw,
212
- warning: 'secret-store-failed',
213
- error,
214
- }
215
- }
216
- // Default: refuse. Nothing is written, and the caller surfaces the store
217
- // failure plus the platform-specific repair hint instead of a silent downgrade.
218
- return {
219
- stored: 'none',
220
- credentialBackend: want,
221
- password: '',
222
- warning: 'secret-store-refused',
223
- error,
224
- }
225
- }
226
-
227
- /** Fetch a stored password. Resolves string|null. */
228
- export async function getSecret(machineId, secretsDir) {
229
- const acc = account(machineId)
230
- try {
231
- if (process.platform === 'darwin') {
232
- return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
233
- }
234
- if (process.platform === 'win32') {
235
- const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
236
- if (!b64) return null
237
- const script = dpapiUnprotectScript(b64)
238
- return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
239
- }
240
- if (process.platform === 'linux') {
241
- return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
242
- }
243
- } catch { /* fall through */ }
244
- return null
245
- }
246
-
247
- /** Delete a stored password (idempotent). */
248
- export async function deleteSecret(machineId, secretsDir) {
249
- const acc = account(machineId)
250
- try {
251
- if (process.platform === 'darwin') {
252
- await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
253
- return
254
- }
255
- if (process.platform === 'win32') {
256
- try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
257
- return
258
- }
259
- if (process.platform === 'linux') {
260
- await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
261
- }
262
- } catch { /* best effort */ }
263
- }
1
+ // dsh-remote — optional OS-keychain password storage.
2
+ // Machines keep their password in the OS credential store instead of the
3
+ // plaintext machines.json when the user enables "加密保存密码" per machine.
4
+ // Every backend is best-effort: any failure resolves to {ok:false} and the
5
+ // caller (persistPassword) falls back to plaintext + a user-visible warning
6
+ // (the feature must never block connecting, and must never silently drop the
7
+ // credential — issue #30).
8
+ //
9
+ // Backends:
10
+ // darwin → `security` (login keychain, generic password)
11
+ // win32 → DPAPI via PowerShell (CurrentUser scope), files under
12
+ // $DSH_HOME/remote-workspaces/.secrets/
13
+ // linux → `secret-tool` (libsecret / gnome-keyring), optional
14
+ // else → unsupported (plain)
15
+ import { spawn } from 'node:child_process'
16
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
17
+ import { join as joinPath } from 'node:path'
18
+
19
+ const SERVICE = 'dsh-remote'
20
+ const TIMEOUT = 8000
21
+
22
+ // Windows PowerShell 5.1 does not preload System.Security, so
23
+ // [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
24
+ // is added (issue #30: without this the whole DPAPI call failed and the caller
25
+ // silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
26
+ // harmless on PowerShell 7, where the type is already available.
27
+ const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
28
+
29
+ /**
30
+ * Run a helper and resolve with its trimmed stdout.
31
+ *
32
+ * WHY `spawn` and not `execFile(..., { input })`: measured on a real Ubuntu box
33
+ * with a real gnome-keyring, `secret-tool store` given its password through
34
+ * `execFile`'s `input` option **never saw EOF**, blocked on the read, and was
35
+ * killed by this timeout — so on Linux "encrypt the password" always failed after
36
+ * 8 seconds with the useless message "Command failed: secret-tool store …" (empty
37
+ * stderr) and the credential was dropped. The same command with an explicitly
38
+ * ended stdin (`spawn` + `stdin.end()`, or `sh -c '… < file'`) finishes in ~20ms.
39
+ * Nothing in this module needs the extra `execFile` machinery, so stdin is now
40
+ * always closed explicitly and the child's stderr is carried on the error (the old
41
+ * path dropped it, which is why the failure had no reason attached).
42
+ */
43
+ function run(bin, args, input) {
44
+ return new Promise((resolve, reject) => {
45
+ let child
46
+ try {
47
+ child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
48
+ } catch (err) {
49
+ reject(err)
50
+ return
51
+ }
52
+ let out = ''
53
+ let errOut = ''
54
+ let settled = false
55
+ const timer = setTimeout(() => {
56
+ if (settled) return
57
+ settled = true
58
+ try { child.kill('SIGTERM') } catch { /* already gone */ }
59
+ const err = new Error(`${bin} timed out after ${TIMEOUT}ms`)
60
+ err.stderr = errOut
61
+ err.code = 'ETIMEDOUT'
62
+ reject(err)
63
+ }, TIMEOUT)
64
+ const done = (err, value) => {
65
+ if (settled) return
66
+ settled = true
67
+ clearTimeout(timer)
68
+ if (err) reject(err); else resolve(value)
69
+ }
70
+ child.on('error', (err) => done(err))
71
+ child.on('close', (code) => {
72
+ if (code === 0) return done(null, out.trim())
73
+ const err = new Error(`${bin} exited with ${code}${errOut.trim() ? ': ' + errOut.trim().slice(0, 300) : ''}`)
74
+ err.code = code
75
+ err.stderr = errOut
76
+ done(err)
77
+ })
78
+ child.stdout.on('data', (d) => { out += d })
79
+ child.stderr.on('data', (d) => { errOut += d })
80
+ // Always end stdin: a helper that reads a secret from stdin needs EOF, and one
81
+ // that does not must not inherit an open pipe.
82
+ try { child.stdin.end(input == null ? '' : String(input)) } catch { /* closed early */ }
83
+ })
84
+ }
85
+
86
+ export function platformBackend() {
87
+ if (process.platform === 'darwin') return 'keychain'
88
+ if (process.platform === 'win32') return 'windows'
89
+ return 'secret' // linux (secret-tool may be absent → falls back)
90
+ }
91
+
92
+ function account(machineId) {
93
+ // Only machine ids are stored; sanitize just in case.
94
+ return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
95
+ }
96
+
97
+ /** Redact a failed command's diagnostics: execFile puts the FULL command line
98
+ * (which carries the password as an argv) into `err.message`, and this string is
99
+ * shown in the settings UI — so the secret must never survive it. */
100
+ function safeReason(err, password) {
101
+ const raw = [err && err.stderr, err && err.message, err && err.code]
102
+ .filter(Boolean).map(String).join(' ')
103
+ const secret = String(password == null ? '' : password)
104
+ const scrubbed = secret ? raw.split(secret).join('***') : raw
105
+ return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
106
+ }
107
+
108
+ /** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
109
+ * Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
110
+ * observable here — a Windows host cannot be exercised from the test suite. */
111
+ export function dpapiProtectScript(password) {
112
+ return PS_SECURITY_ASSEMBLY +
113
+ `$p='${String(password).replace(/'/g, "''")}';` +
114
+ `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
115
+ `[Convert]::ToBase64String($s)`
116
+ }
117
+
118
+ /** PowerShell one-liner that reverses dpapiProtectScript(). */
119
+ export function dpapiUnprotectScript(b64) {
120
+ return PS_SECURITY_ASSEMBLY +
121
+ `$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
122
+ `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
123
+ }
124
+
125
+ /** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
126
+ * any failure (caller falls back to plaintext). `error` is a password-free
127
+ * diagnostic for the UI. */
128
+ export async function saveSecret(machineId, password, secretsDir) {
129
+ const acc = account(machineId)
130
+ try {
131
+ if (process.platform === 'darwin') {
132
+ await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
133
+ return { ok: true, backend: 'keychain', error: '' }
134
+ }
135
+ if (process.platform === 'win32') {
136
+ // DPAPI-encrypt and store under the harness home.
137
+ const script = dpapiProtectScript(password)
138
+ const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
139
+ if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
140
+ mkdirSync(secretsDir || '.', { recursive: true })
141
+ writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
142
+ return { ok: true, backend: 'windows', error: '' }
143
+ }
144
+ if (process.platform === 'linux') {
145
+ await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
146
+ return { ok: true, backend: 'secret', error: '' }
147
+ }
148
+ } catch (err) {
149
+ return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
150
+ }
151
+ return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
152
+ }
153
+
154
+ /** Decide where a machine password is persisted (issue #30).
155
+ *
156
+ * The contract the settings UI relies on: a password is NEVER silently dropped.
157
+ * Historically that guarantee was upheld by falling back to PLAINTEXT in
158
+ * machines.json, which is a bad trade in one specific way: the user asked for
159
+ * encryption, the store failed, and the secret ended up less protected than if
160
+ * they had never asked. Worse, a locked keychain / missing secret-tool is
161
+ * exactly the moment when quietly writing a password to a world-readable file
162
+ * on disk is most likely to go unnoticed.
163
+ *
164
+ * So the fallback is a POLICY, not a fixed behaviour (`onStoreFailure`):
165
+ *
166
+ * `refuse` (default) — do not store the password at all. The machine is
167
+ * saved WITHOUT a credential and the caller reports how to fix the
168
+ * store; the user retypes (or connects) and nothing lands in
169
+ * plaintext by accident. `stored: 'none'` makes that explicit.
170
+ * `plain` — the legacy behaviour: write it to machines.json and raise
171
+ * `warning: 'secret-store-failed'`. Opt-in only, for users who
172
+ * genuinely prefer a working machine over the storage guarantee.
173
+ *
174
+ * An explicit `backend: 'plain'` still means plain by REQUEST (the user turned
175
+ * encryption off), and is unaffected by this policy.
176
+ *
177
+ * `save` is injectable so both paths are unit-testable on any platform.
178
+ *
179
+ * @param {object} arg
180
+ * @param {string} arg.backend - the platform backend name ('keychain'|'windows'|'secret'|'plain').
181
+ * @param {string} arg.machineId
182
+ * @param {string} arg.password
183
+ * @param {string} [arg.secretsDir]
184
+ * @param {'refuse'|'plain'} [arg.onStoreFailure] - see above.
185
+ * @param {Function} [arg.save] - injectable secure-store writer.
186
+ * @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
187
+ */
188
+ export async function persistPassword({
189
+ backend, machineId, password, secretsDir,
190
+ onStoreFailure = 'refuse',
191
+ save = saveSecret,
192
+ }) {
193
+ const pw = password == null ? '' : String(password)
194
+ const want = backend && backend !== 'plain' ? backend : 'plain'
195
+ if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
196
+ if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
197
+ let res
198
+ try {
199
+ res = await save(machineId, pw, secretsDir)
200
+ } catch (err) {
201
+ res = { ok: false, error: safeReason(err, pw) }
202
+ }
203
+ if (res && res.ok) {
204
+ return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
205
+ }
206
+ const error = String((res && res.error) || '') || 'secret store write failed'
207
+ if (onStoreFailure === 'plain') {
208
+ return {
209
+ stored: 'plain',
210
+ credentialBackend: 'plain',
211
+ password: pw,
212
+ warning: 'secret-store-failed',
213
+ error,
214
+ }
215
+ }
216
+ // Default: refuse. Nothing is written, and the caller surfaces the store
217
+ // failure plus the platform-specific repair hint instead of a silent downgrade.
218
+ return {
219
+ stored: 'none',
220
+ credentialBackend: want,
221
+ password: '',
222
+ warning: 'secret-store-refused',
223
+ error,
224
+ }
225
+ }
226
+
227
+ /** Fetch a stored password. Resolves string|null. */
228
+ export async function getSecret(machineId, secretsDir) {
229
+ const acc = account(machineId)
230
+ try {
231
+ if (process.platform === 'darwin') {
232
+ return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
233
+ }
234
+ if (process.platform === 'win32') {
235
+ const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
236
+ if (!b64) return null
237
+ const script = dpapiUnprotectScript(b64)
238
+ return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
239
+ }
240
+ if (process.platform === 'linux') {
241
+ return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
242
+ }
243
+ } catch { /* fall through */ }
244
+ return null
245
+ }
246
+
247
+ /** Delete a stored password (idempotent). */
248
+ export async function deleteSecret(machineId, secretsDir) {
249
+ const acc = account(machineId)
250
+ try {
251
+ if (process.platform === 'darwin') {
252
+ await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
253
+ return
254
+ }
255
+ if (process.platform === 'win32') {
256
+ try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
257
+ return
258
+ }
259
+ if (process.platform === 'linux') {
260
+ await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
261
+ }
262
+ } catch { /* best effort */ }
263
+ }