@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * How to launch THIS machine's own ACP agent.
3
+ *
4
+ * WHY AN ARGV ARRAY AND NOT A COMMAND LINE: two quoting hazards sit on this path, and both have
5
+ * already cost time in this project.
6
+ *
7
+ * * The install directory contains a space (`...\Programs\DeepSeek Harness`). Building a
8
+ * command line and handing it to `Start-Process -ArgumentList` split it, and node reported
9
+ * `Cannot find module 'C:\Users\<user>\AppData\Local\Programs\DeepSeek'` -- an error that points
10
+ * at the module system while the actual fault is a missing quote.
11
+ * * `dsh.cmd` is a shim, and Node refuses to spawn a `.cmd` without a shell (EINVAL); asking
12
+ * `cmd /c` to run it loses quotes through the PowerShell -> node hand-off.
13
+ *
14
+ * So the shim is replicated exactly, as an array, and `dsh.cmd` is never launched.
15
+ *
16
+ * WHY cli.js IS NOT CHECKED FOR EXISTENCE: it lives INSIDE `app.asar`, which Electron can read
17
+ * and the filesystem API cannot. An existence test would report false and reject a perfectly
18
+ * good install. Only the executable is tested; the argv path is constructed.
19
+ */
20
+
21
+ import { existsSync } from 'node:fs'
22
+ import { isAbsolute } from 'node:path'
23
+
24
+ /**
25
+ * Every file-effect sandbox mode DSH understands.
26
+ *
27
+ * Mirrors `SANDBOX_MODES` in `@deepseek-ai/dsh-sandbox-policy`. It is duplicated rather than
28
+ * imported because this module runs on a worker that may have no DSH package tree of its own.
29
+ */
30
+ export const SESSION_MODES = ['read-only', 'workspace-write', 'danger-full-access']
31
+
32
+ /** Where DSH installs by default on Windows, best candidate first. */
33
+ export function installCandidates(env = process.env) {
34
+ return [
35
+ env.DSH_INSTALL_DIR,
36
+ env.LOCALAPPDATA === undefined ? undefined : `${env.LOCALAPPDATA}\\Programs\\DeepSeek Harness`,
37
+ 'C:\\Program Files\\DeepSeek Harness',
38
+ 'C:\\Program Files (x86)\\DeepSeek Harness',
39
+ ].filter((p) => typeof p === 'string' && p !== '')
40
+ }
41
+
42
+ /**
43
+ * The argv and environment for one ACP agent process.
44
+ *
45
+ * @param {object} opts
46
+ * @param {string} opts.installDir
47
+ * @param {string} [opts.profile] the DSH profile the worker runs under
48
+ * @param {string} [opts.permissionMode] overrides the sandbox/approval preset for the child
49
+ * @param {string[]} [opts.patchFiles] extra patch overlays, applied AFTER the profile layer
50
+ * @returns {{ command: string, args: string[], env: Record<string, string> }}
51
+ */
52
+ export function acpLaunchSpec(opts) {
53
+ const installDir = String(opts.installDir ?? '').replace(/[\\/]+$/u, '')
54
+ if (installDir === '') throw new Error('acpLaunchSpec: no install directory')
55
+ const profile = String(opts.profile ?? 'acp')
56
+ if (!/^[\w.-]+$/u.test(profile)) throw new Error(`acpLaunchSpec: profile "${profile}" is not a plain profile name`)
57
+ // Overlays are the plugin's only way to configure the ACP child without editing the operator's
58
+ // profile (the worker-model setting uses this). They must be absolute: the child's working
59
+ // directory is the relay's, not this process's, so a relative path would resolve elsewhere.
60
+ const patchFiles = Array.isArray(opts.patchFiles) ? opts.patchFiles : []
61
+ for (const file of patchFiles) {
62
+ if (typeof file !== 'string' || file === '' || !isAbsolute(file)) {
63
+ throw new Error(`acpLaunchSpec: patch file is not an absolute path: ${String(file)}`)
64
+ }
65
+ }
66
+ // REFUSE an unknown mode instead of passing it through. `dsh-base` validates the value at load
67
+ // and rejects the whole `sandbox-policy` row, so a typo would start a worker whose session has no
68
+ // resolved policy at all -- a failure that is far harder to notice than a failed arming.
69
+ const permissionMode = opts.permissionMode
70
+ const wantsMode = typeof permissionMode === 'string' && permissionMode !== ''
71
+ if (wantsMode && !SESSION_MODES.includes(permissionMode)) {
72
+ throw new Error(`acpLaunchSpec: "${permissionMode}" is not a sandbox mode DSH understands; expected one of ${SESSION_MODES.join(', ')}`)
73
+ }
74
+ return {
75
+ command: `${installDir}\\DeepSeek Harness.exe`,
76
+ args: [
77
+ '--expose-internals',
78
+ `${installDir}\\resources\\app.asar\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli.js`,
79
+ '--profile',
80
+ profile,
81
+ ...patchFiles.flatMap((file) => ['--patch', file]),
82
+ ],
83
+ env: {
84
+ ELECTRON_RUN_AS_NODE: '1',
85
+ ...(wantsMode ? { DSH_PERMISSION_MODE: permissionMode } : {}),
86
+ },
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolve the spec against a real machine.
92
+ *
93
+ * @param {object} [deps]
94
+ * @param {(path: string) => boolean} [deps.exists]
95
+ * @param {Record<string, string | undefined>} [deps.env]
96
+ * @param {string} [deps.installDir] explicit override, tried first
97
+ * @returns {{ ok: true, spec: object, installDir: string } | { ok: false, error: string, tried: string[] }}
98
+ */
99
+ export function resolveAcpLaunchSpec(deps = {}) {
100
+ const exists = deps.exists ?? existsSync
101
+ const env = deps.env ?? process.env
102
+ const candidates = [deps.installDir, ...installCandidates(env)].filter((p) => typeof p === 'string' && p !== '')
103
+ for (const candidate of candidates) {
104
+ if (!exists(`${candidate}\\DeepSeek Harness.exe`)) continue
105
+ try {
106
+ const spec = acpLaunchSpec({ installDir: candidate, profile: deps.profile, permissionMode: deps.permissionMode, patchFiles: deps.patchFiles })
107
+ return { ok: true, spec, installDir: candidate }
108
+ } catch (error) {
109
+ return { ok: false, error: error.message, tried: candidates }
110
+ }
111
+ }
112
+ return {
113
+ ok: false,
114
+ error: 'could not find "DeepSeek Harness.exe"; pass the install directory explicitly',
115
+ tried: candidates,
116
+ }
117
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Run the WORKER's own readiness self-check (`slave/selfcheck.ps1`) from the plugin host.
3
+ *
4
+ * WHY THE WORKER RUNS IT, RATHER THAN THE MASTER: the checks are about THIS machine's session,
5
+ * display and input (S1-S12). The master can only see the report the worker sends; running the
6
+ * script here means the "先检查" result reflects the same interactive session the relay will use,
7
+ * and the worker can hand back a fix prompt without a round trip.
8
+ *
9
+ * WHY NOT ON EVERY PAGE LOAD, AND WHY NOT AS AN ARMING GATE: `S8` moves the mouse for real, and
10
+ * the session checks depend on somebody being logged in and unlocked -- an OPERATIONAL state that
11
+ * can legitimately change after arming. So this runs on an explicit check and is ADVISORY; the
12
+ * master's dispatch gate stays the hard gate.
13
+ */
14
+ import { spawn as spawnChild } from 'node:child_process'
15
+ import { existsSync, readFileSync, statSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ /**
19
+ * The PowerShell to run the helper scripts with.
20
+ *
21
+ * `pwsh` (7) is preferred because Windows PowerShell 5.1 is blocked by a PATH-based rule on some
22
+ * volumes (see the relay's note). The FALLBACK MUST BE `powershell.exe`: returning `'pwsh'` when it
23
+ * was not found is the F46 defect -- the process failed to spawn and the missing report was then
24
+ * reported as a JSON parse error.
25
+ *
26
+ * @param {object} [deps] injectable for tests
27
+ * @returns {string}
28
+ */
29
+ export function resolvePowerShell(deps = {}) {
30
+ const platform = deps.platform ?? process.platform
31
+ if (platform !== 'win32') return 'pwsh'
32
+ const stat = deps.stat ?? statSync
33
+ const programFiles = deps.programFiles ?? process.env.ProgramFiles ?? 'C:\\Program Files'
34
+ const localAppData = deps.localAppData ?? process.env.LOCALAPPDATA ?? ''
35
+ const candidates = [
36
+ join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
37
+ localAppData === '' ? '' : join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe'),
38
+ ].filter((candidate) => candidate !== '')
39
+ for (const candidate of candidates) {
40
+ try {
41
+ const info = stat(candidate)
42
+ // A Store execution alias is a 0-byte reparse point; spawning it opens the Store.
43
+ if (info.isFile() && info.size > 0) return candidate
44
+ } catch { /* not there */ }
45
+ }
46
+ return 'powershell.exe'
47
+ }
48
+
49
+ /**
50
+ * The subset of a report the page needs: the verdict, the counts, and the checks that are not OK.
51
+ *
52
+ * @param {object} report parsed `selfcheck.json`
53
+ * @returns {{ verdict: string, blockingFailures: number, warnings: number, unchecked: number,
54
+ * generatedAt: string, checks: object[] }}
55
+ */
56
+ export function summarizeReport(report) {
57
+ const checks = Array.isArray(report?.checks) ? report.checks : []
58
+ return {
59
+ verdict: String(report?.verdict ?? 'unknown'),
60
+ blockingFailures: Number(report?.blockingFailures ?? 0),
61
+ warnings: Number(report?.warnings ?? 0),
62
+ unchecked: Number(report?.unchecked ?? 0),
63
+ generatedAt: String(report?.generatedAt ?? ''),
64
+ // Everything, not only the failures: the page can then explain "S7 unchecked" as its own
65
+ // state instead of leaving it out and looking like it passed.
66
+ checks,
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Run the script and parse the JSON it writes next to itself.
72
+ *
73
+ * The script writes `selfcheck.json` in its own directory (the relay reads it the same way), so
74
+ * stdout -- which also carries the human-readable twin -- is not the source of truth.
75
+ *
76
+ * @param {object} opts
77
+ * @param {string} opts.scriptPath
78
+ * @param {string} [opts.workspace] task directory handed to `-Workspace`
79
+ * @param {number} [opts.timeoutMs]
80
+ * @param {string} [opts.powershell]
81
+ * @param {Function} [opts.spawn] injected for tests
82
+ * @returns {Promise<{ ok: boolean, reason: string, report: object | null }>}
83
+ */
84
+ export async function runSelfcheck(opts = {}) {
85
+ const scriptPath = String(opts.scriptPath ?? '')
86
+ if (scriptPath === '' || !existsSync(scriptPath)) {
87
+ return { ok: false, reason: `selfcheck script not found at ${scriptPath || '(empty path)'}`, report: null }
88
+ }
89
+ const powershell = opts.powershell ?? resolvePowerShell()
90
+ const workspace = String(opts.workspace ?? '').trim()
91
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath]
92
+ if (workspace !== '') args.push('-Workspace', workspace)
93
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 60_000
94
+ const spawn = opts.spawn ?? spawnChild
95
+
96
+ const run = await new Promise((resolve) => {
97
+ let stdout = ''
98
+ let stderr = ''
99
+ let settled = false
100
+ let timer
101
+ const finish = (result) => {
102
+ if (settled) return
103
+ settled = true
104
+ if (timer !== undefined) clearTimeout(timer)
105
+ resolve(result)
106
+ }
107
+ let child
108
+ try {
109
+ child = spawn(powershell, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
110
+ } catch (error) {
111
+ finish({ error: error instanceof Error ? error.message : String(error), stdout, stderr })
112
+ return
113
+ }
114
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk) })
115
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
116
+ child.on('error', (error) => finish({ error: error instanceof Error ? error.message : String(error), stdout, stderr }))
117
+ child.on('close', (code) => finish({ code, stdout, stderr }))
118
+ timer = setTimeout(() => {
119
+ try { child.kill() } catch { /* already gone */ }
120
+ finish({ error: `timed out after ${timeoutMs}ms`, stdout, stderr })
121
+ }, timeoutMs)
122
+ })
123
+
124
+ if (run.error !== undefined) {
125
+ return { ok: false, reason: `could not run ${powershell}: ${run.error}`, report: null }
126
+ }
127
+
128
+ const jsonPath = join(dirname(scriptPath), 'selfcheck.json')
129
+ let text = ''
130
+ try {
131
+ text = readFileSync(jsonPath, 'utf8').replace(/^\uFEFF/u, '')
132
+ } catch (error) {
133
+ const tail = String(run.stderr ?? '').trim().split(/\r?\n/u).slice(-2).join(' / ')
134
+ return {
135
+ ok: false,
136
+ reason: `the self-check produced no report at ${jsonPath} (exit ${run.code ?? '?'})${tail === '' ? '' : `: ${tail}`}`,
137
+ report: null,
138
+ }
139
+ }
140
+ try {
141
+ return { ok: true, reason: '', report: JSON.parse(text) }
142
+ } catch (error) {
143
+ const tail = String(run.stderr ?? '').trim().split(/\r?\n/u).slice(-2).join(' / ')
144
+ return {
145
+ ok: false,
146
+ reason: `the self-check report is not valid JSON (exit ${run.code ?? '?'}): ${error instanceof Error ? error.message : String(error)}${tail === '' ? '' : ` -- ${tail}`}`,
147
+ report: null,
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Settings storage for dsh-plugin-remote-use.
3
+ *
4
+ * WHY A SECOND CONFIG SOURCE: the worker list otherwise lives only in the profile patch
5
+ * (`cordis.patch.yml`), which means every pairing change is a hand edit of YAML plus a
6
+ * restart. The page needs to write somewhere, and the profile patch is the wrong target:
7
+ * it is user-maintained prose with comments and hand formatting that a programmatic write
8
+ * would destroy. A settings file beside the plugin's other state is the same shape the
9
+ * sibling notification plugin uses.
10
+ *
11
+ * PRECEDENCE -- deliberate and stated, because "I changed it and nothing happened" is the
12
+ * failure this kind of split always produces:
13
+ *
14
+ * defaults < profile patch < settings file
15
+ *
16
+ * The settings file wins, and only for the keys it actually contains. It is absent until
17
+ * the operator saves something, so an install that never opens the page behaves exactly as
18
+ * before. `mergeSettings` reports which source won per key so the page can say so.
19
+ *
20
+ * TOKENS: never handed to the browser. The page receives `tokenSet` and a masked hint and
21
+ * can only WRITE a token. A worker token is a bearer credential for remote execution;
22
+ * sending it to a web page -- where it lands in devtools, memory, and any screenshot of the
23
+ * screen -- is a cost with no benefit, since the page never needs to display it.
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+ import { homedir } from 'node:os'
28
+
29
+ /** Keys the settings page owns and may write. Anything else in the file is ignored. */
30
+ export const MANAGED_KEYS = [
31
+ 'workers',
32
+ 'approvalTimeoutMs',
33
+ 'requiredTier',
34
+ 'heartbeatMs',
35
+ // Added along with the page controls for them. A key the page can SAVE but that is not
36
+ // managed here would be dropped on the next read -- the quiet way a setting seems to work
37
+ // once and then reverts.
38
+ 'keepShots',
39
+ // The byte ceiling that sits beside `keepShots`. Added here in the same change that added the
40
+ // control, because the note below records what happens when it is not: the key round-trips
41
+ // through disk and then reverts on the next read.
42
+ 'shotCacheBytes',
43
+ // Added with the audit-retention controls. These were LEFT OUT here when they shipped, so both
44
+ // silently reverted: saved to disk, dropped by the merge, and shown as the default again on the
45
+ // next read. Found 2026-10-15 while adding the third of them, by a guard that now checks the whole
46
+ // exposed set at once instead of relying on one round-trip test per key.
47
+ 'auditMaxBytes',
48
+ 'auditKeepFiles',
49
+ // The sandbox mode this machine arms its own remote sessions with.
50
+ 'workerSessionMode',
51
+ // The model those sessions run with, as `provider/model`. Empty means "whatever the acp profile
52
+ // configures". It is a deployment value like the mode above: the ACP child's image capability is
53
+ // decided at startup from the configured route, so it cannot be changed per call.
54
+ 'workerModel',
55
+ 'delegationPolicy',
56
+ // The worker role: whether this machine accepts remote execution, on which interface, and
57
+ // the credential a paired master presents. It holds a secret, so anything that renders it
58
+ // for the page must go through the mask (see core/worker-arming.js).
59
+ 'workerRole',
60
+ ]
61
+
62
+ /** Fields of one worker the page may write. */
63
+ export const WORKER_KEYS = ['id', 'name', 'url', 'token', 'cwd', 'enabled', 'serialQueue']
64
+
65
+ /**
66
+ * Root of this installation's state.
67
+ * @param {Record<string, string | undefined>} [env]
68
+ */
69
+ export function resolveDshHome(env = process.env) {
70
+ const fromEnv = typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '' ? env.DSH_HOME.trim() : ''
71
+ return fromEnv !== '' ? fromEnv : join(homedir(), '.dsh')
72
+ }
73
+
74
+ /** @param {Record<string, string | undefined>} [env] */
75
+ export function settingsPath(env = process.env) {
76
+ return join(resolveDshHome(env), 'storages', 'dsh-plugin-remote-use', 'settings.json')
77
+ }
78
+
79
+ /** Keep the last few characters so an operator can tell two tokens apart without seeing either. */
80
+ export function maskToken(token) {
81
+ if (typeof token !== 'string' || token === '') return ''
82
+ if (token.length <= 4) return '\u2022'.repeat(token.length)
83
+ return `${'\u2022'.repeat(Math.min(12, token.length - 4))}${token.slice(-4)}`
84
+ }
85
+
86
+ /** A worker as the PAGE sees it: no token, only whether one is set and a masked hint. */
87
+ export function publicWorker(worker) {
88
+ const token = typeof worker?.token === 'string' ? worker.token : ''
89
+ return {
90
+ id: String(worker?.id ?? ''),
91
+ name: String(worker?.name ?? ''),
92
+ url: String(worker?.url ?? ''),
93
+ cwd: String(worker?.cwd ?? ''),
94
+ enabled: worker?.enabled !== false,
95
+ serialQueue: Number.isFinite(worker?.serialQueue) ? Number(worker.serialQueue) : 1,
96
+ tokenSet: token !== '',
97
+ tokenHint: maskToken(token),
98
+ }
99
+ }
100
+
101
+ /** One worker, normalized. Returns undefined when it lacks the fields a connection needs. */
102
+ export function normalizeWorker(raw) {
103
+ if (raw === null || typeof raw !== 'object') return undefined
104
+ const id = typeof raw.id === 'string' ? raw.id.trim() : ''
105
+ const url = typeof raw.url === 'string' ? raw.url.trim() : ''
106
+ if (id === '' || url === '') return undefined
107
+ const worker = {
108
+ id,
109
+ name: typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : id,
110
+ url,
111
+ cwd: typeof raw.cwd === 'string' ? raw.cwd.trim() : '',
112
+ enabled: raw.enabled !== false,
113
+ serialQueue: Number.isFinite(raw.serialQueue) && Number(raw.serialQueue) >= 1 ? Math.floor(Number(raw.serialQueue)) : 1,
114
+ }
115
+ // A token may legitimately be absent (an unauthenticated relay), so only carry it when
116
+ // it is a non-empty string. `undefined` means "leave whatever the profile had".
117
+ if (typeof raw.token === 'string' && raw.token !== '') worker.token = raw.token
118
+ return worker
119
+ }
120
+
121
+ /**
122
+ * Read the settings file.
123
+ * @returns {{ settings: object | null, file: string, error: string }}
124
+ * `settings: null` means the file does not exist, which is NOT an error: it is the state
125
+ * every install starts in, and the caller falls back to the profile patch.
126
+ */
127
+ export function readSettings(env = process.env) {
128
+ const file = settingsPath(env)
129
+ if (!existsSync(file)) return { settings: null, file, error: '' }
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
132
+ if (parsed === null || typeof parsed !== 'object') {
133
+ return { settings: null, file, error: 'settings file is not a JSON object' }
134
+ }
135
+ const settings = {}
136
+ for (const key of MANAGED_KEYS) {
137
+ if (parsed[key] !== undefined) settings[key] = parsed[key]
138
+ }
139
+ if (Array.isArray(settings.workers)) {
140
+ settings.workers = settings.workers.map(normalizeWorker).filter((w) => w !== undefined)
141
+ }
142
+ return { settings, file, error: '' }
143
+ } catch (error) {
144
+ // A corrupt file must not stop the plugin from loading. Report it and fall back.
145
+ return { settings: null, file, error: `settings file is unreadable: ${error instanceof Error ? error.message : String(error)}` }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Write the settings file atomically (temp file + rename) so a crash mid-write cannot leave
151
+ * a half-written file that then fails to parse on the next start.
152
+ * @returns {{ ok: boolean, file: string, error: string }}
153
+ */
154
+ export function writeSettings(settings, env = process.env) {
155
+ const file = settingsPath(env)
156
+ try {
157
+ mkdirSync(dirname(file), { recursive: true })
158
+ const payload = {}
159
+ for (const key of MANAGED_KEYS) {
160
+ if (settings?.[key] !== undefined) payload[key] = settings[key]
161
+ }
162
+ const temp = `${file}.tmp`
163
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
164
+ renameSync(temp, file)
165
+ return { ok: true, file, error: '' }
166
+ } catch (error) {
167
+ return { ok: false, file, error: error instanceof Error ? error.message : String(error) }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Merge the settings file over the profile configuration.
173
+ *
174
+ * @param {object} profileConfig the Cordis config (already normalized)
175
+ * @param {object | null} settings the settings file contents, or null when absent
176
+ * @returns {{ config: object, sources: Record<string, 'profile' | 'settings'> }}
177
+ */
178
+ export function mergeSettings(profileConfig, settings) {
179
+ const config = { ...profileConfig }
180
+ const sources = {}
181
+ for (const key of MANAGED_KEYS) sources[key] = 'profile'
182
+ if (settings === null || settings === undefined) return { config, sources }
183
+
184
+ for (const key of MANAGED_KEYS) {
185
+ if (settings[key] === undefined) continue
186
+ // `workers` REPLACES rather than appends. Merging two lists would make a deletion in the
187
+ // page impossible to express, and a half-applied list is worse than an explicit swap.
188
+ config[key] = settings[key]
189
+ sources[key] = 'settings'
190
+ }
191
+ return { config, sources }
192
+ }
193
+
194
+ /**
195
+ * Apply a page-supplied worker edit onto the current list.
196
+ *
197
+ * A blank/omitted token means "keep the existing one": the page never receives the token, so
198
+ * requiring it on every save would force the operator to re-enter a credential to rename a
199
+ * worker.
200
+ *
201
+ * @param {object[]} workers current list (may contain tokens)
202
+ * @param {object} edit page-supplied worker
203
+ * @returns {{ workers: object[], error: string }}
204
+ */
205
+ export function upsertWorker(workers, edit) {
206
+ const list = Array.isArray(workers) ? [...workers] : []
207
+ const candidate = normalizeWorker(edit)
208
+ if (candidate === undefined) return { workers: list, error: 'a worker needs at least an id and a url' }
209
+
210
+ const index = list.findIndex((w) => w?.id === candidate.id)
211
+ if (edit?.token === undefined || edit?.token === null || edit?.token === '') {
212
+ if (index >= 0 && typeof list[index].token === 'string') candidate.token = list[index].token
213
+ else if (index < 0) return { workers: list, error: `worker "${candidate.id}" is new, so it needs a token` }
214
+ }
215
+
216
+ if (index >= 0) list[index] = candidate
217
+ else list.push(candidate)
218
+ return { workers: list, error: '' }
219
+ }
220
+
221
+ /** Remove a worker by id. @returns {{ workers: object[], removed: boolean }} */
222
+ export function removeWorker(workers, id) {
223
+ const list = Array.isArray(workers) ? [...workers] : []
224
+ const next = list.filter((w) => w?.id !== id)
225
+ return { workers: next, removed: next.length !== list.length }
226
+ }