@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.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- package/slave/workspace-integrity.mjs +157 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort desktop notification: "an approval is waiting for you".
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: the approval queue holds a worker still until a person answers, and measured
|
|
5
|
+
* on 2026-10-14 the operator had never seen a single prompt -- six requests expired unanswered
|
|
6
|
+
* while the only sign of them was a row on a settings page nobody had open. The page now polls,
|
|
7
|
+
* and the model is told in its runtime context, but both of those need the operator to be LOOKING
|
|
8
|
+
* at the harness. A notification is what reaches someone who is not.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT REUSES @hope_phenom/dsh-plugin-notify RATHER THAN ROLLING ITS OWN: that plugin already
|
|
11
|
+
* carries the platform plumbing (Shell_NotifyIcon balloon, WinRT toast fallback, python/custom
|
|
12
|
+
* channels) and its own settings. Importing its CODE would couple us to its internals and its
|
|
13
|
+
* version; invoking its bundled script is a file-level contract that survives it being updated,
|
|
14
|
+
* and disappears cleanly when the plugin is not installed.
|
|
15
|
+
*
|
|
16
|
+
* WHY IT CANNOT BREAK ANYTHING: every failure path returns false. A missing plugin, a missing
|
|
17
|
+
* interpreter, a policy that blocks PowerShell -- none of them may turn into a failed approval,
|
|
18
|
+
* because this runs on the approval path. Failures are logged once per kind, not per request.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn } from 'node:child_process'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { createRequire } from 'node:module'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/** The sibling plugin whose bundled notifier we borrow. */
|
|
26
|
+
export const NOTIFY_PACKAGE = '@hope_phenom/dsh-plugin-notify'
|
|
27
|
+
|
|
28
|
+
/** Interpreters tried in order. `pwsh` first, because the bundled script prefers it. */
|
|
29
|
+
const DEFAULT_INTERPRETERS = ['pwsh', 'powershell']
|
|
30
|
+
|
|
31
|
+
/** How long a notification child may live before we stop caring about it. */
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 8_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path of the sibling plugin's bundled notifier, or '' when it is not installed.
|
|
36
|
+
*
|
|
37
|
+
* Resolution walks up from THIS module, so it finds the copy installed beside us in the same
|
|
38
|
+
* profile's `node_modules`. A bare `import` of the package would instead require a declared
|
|
39
|
+
* dependency, which would make an optional integration mandatory.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} [opts]
|
|
42
|
+
* @param {(id: string) => string} [opts.resolve] module resolver, injectable for tests
|
|
43
|
+
* @param {(path: string) => boolean} [opts.exists] existence check, injectable for tests
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function resolveNotifyScript(opts = {}) {
|
|
47
|
+
const exists = opts.exists ?? existsSync
|
|
48
|
+
let resolve = opts.resolve
|
|
49
|
+
if (resolve === undefined) {
|
|
50
|
+
try {
|
|
51
|
+
const require = createRequire(import.meta.url)
|
|
52
|
+
resolve = (id) => require.resolve(id)
|
|
53
|
+
} catch {
|
|
54
|
+
return ''
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const manifest = resolve(`${NOTIFY_PACKAGE}/package.json`)
|
|
59
|
+
const script = join(dirname(manifest), 'scripts', 'notify.ps1')
|
|
60
|
+
return exists(script) ? script : ''
|
|
61
|
+
} catch {
|
|
62
|
+
// Not installed, or its exports map hides the manifest: a silent no-op by design.
|
|
63
|
+
return ''
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fire one notification and forget about it.
|
|
69
|
+
*
|
|
70
|
+
* Never throws and never awaits the child: the caller is holding an approval request open, and a
|
|
71
|
+
* notification must not add latency to it. Success means "handed to an interpreter", not
|
|
72
|
+
* "displayed" -- the bundled script reports display failures on its own stderr, which we discard.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string} opts.title
|
|
76
|
+
* @param {string} opts.message
|
|
77
|
+
* @param {string} [opts.script] notifier path; resolved on demand when omitted
|
|
78
|
+
* @param {string[]} [opts.interpreters]
|
|
79
|
+
* @param {(level: string, message: string) => void} [opts.log]
|
|
80
|
+
* @param {typeof spawn} [opts.spawnFn] injectable so tests never launch a process
|
|
81
|
+
* @returns {boolean} whether an interpreter was started
|
|
82
|
+
*/
|
|
83
|
+
export function notifyDesktop(opts) {
|
|
84
|
+
// The plugin's logger is an OBJECT with `.info`/`.warn` (see `index.js`), so this module accepts
|
|
85
|
+
// the same shape rather than inventing a second convention. A missing logger is a no-op.
|
|
86
|
+
const { info, warn } = methods(opts?.log)
|
|
87
|
+
const spawnFn = opts?.spawnFn ?? spawn
|
|
88
|
+
const script = typeof opts?.script === 'string' && opts.script !== '' ? opts.script : resolveNotifyScript(opts)
|
|
89
|
+
if (script === '') return false
|
|
90
|
+
|
|
91
|
+
const interpreters = Array.isArray(opts?.interpreters) && opts.interpreters.length > 0
|
|
92
|
+
? opts.interpreters
|
|
93
|
+
: DEFAULT_INTERPRETERS
|
|
94
|
+
const timeoutMs = Number.isFinite(opts?.timeoutMs) ? Number(opts.timeoutMs) : DEFAULT_TIMEOUT_MS
|
|
95
|
+
const argv = [
|
|
96
|
+
'-NoProfile',
|
|
97
|
+
'-ExecutionPolicy', 'Bypass',
|
|
98
|
+
'-File', script,
|
|
99
|
+
'-Title', String(opts?.title ?? 'DSH'),
|
|
100
|
+
'-Message', String(opts?.message ?? ''),
|
|
101
|
+
// `Kind`/`Event` are the sibling plugin's own vocabulary; `approval-waiting` is not one of
|
|
102
|
+
// its turn-end events, so its message templates fall through to the literal text we pass.
|
|
103
|
+
'-Kind', 'approval',
|
|
104
|
+
'-Event', 'approval-waiting',
|
|
105
|
+
'-DurationMs', '10000',
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A missing interpreter reports ENOENT through the `error` EVENT, not through `spawn`, so the
|
|
110
|
+
* fallback cannot be a `for` loop with a flag: by the time the event fires the loop is long
|
|
111
|
+
* gone. Each attempt hands the next interpreter to the event handler instead.
|
|
112
|
+
*/
|
|
113
|
+
const attempt = (index) => {
|
|
114
|
+
if (index >= interpreters.length) return false
|
|
115
|
+
const interpreter = interpreters[index]
|
|
116
|
+
let child
|
|
117
|
+
try {
|
|
118
|
+
child = spawnFn(interpreter, argv, { stdio: 'ignore', windowsHide: true })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
warn(`desktop notification via ${interpreter} could not start: ${describe(error)}`)
|
|
121
|
+
return attempt(index + 1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let settled = false
|
|
125
|
+
const timer = timeoutMs > 0
|
|
126
|
+
? setTimeout(() => { settled = true; kill(child) }, timeoutMs)
|
|
127
|
+
: undefined
|
|
128
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
129
|
+
const finish = () => { settled = true; if (timer !== undefined) clearTimeout(timer) }
|
|
130
|
+
|
|
131
|
+
if (typeof child?.once === 'function') {
|
|
132
|
+
child.once('error', (error) => {
|
|
133
|
+
if (settled) return
|
|
134
|
+
finish()
|
|
135
|
+
warn(`desktop notification via ${interpreter} failed: ${describe(error)}`)
|
|
136
|
+
attempt(index + 1)
|
|
137
|
+
})
|
|
138
|
+
child.once('exit', finish)
|
|
139
|
+
}
|
|
140
|
+
if (typeof child?.unref === 'function') child.unref()
|
|
141
|
+
|
|
142
|
+
info(`approval notification handed to ${interpreter} (${script})`)
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return attempt(0)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {unknown} child */
|
|
150
|
+
function kill(child) {
|
|
151
|
+
try {
|
|
152
|
+
child?.kill()
|
|
153
|
+
} catch { /* already gone */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {unknown} error */
|
|
157
|
+
function describe(error) {
|
|
158
|
+
return error instanceof Error ? error.message : String(error)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pull `info`/`warn` off a logger object, tolerating a missing or partial one.
|
|
163
|
+
* @param {unknown} log
|
|
164
|
+
*/
|
|
165
|
+
function methods(log) {
|
|
166
|
+
const source = log !== null && typeof log === 'object' ? log : {}
|
|
167
|
+
return {
|
|
168
|
+
info: typeof source.info === 'function' ? source.info.bind(source) : () => {},
|
|
169
|
+
warn: typeof source.warn === 'function' ? source.warn.bind(source) : () => {},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export { DEFAULT_INTERPRETERS, DEFAULT_TIMEOUT_MS, methods as loggerMethods }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the "fix this worker's environment" prompt the operator can hand to the worker's own DSH
|
|
3
|
+
* session.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT IS GENERATED HERE AND NOT SHIPPED AS A STATIC BLOCK: the useful parts are FACTS of this
|
|
6
|
+
* machine -- the selfcheck script's path, DSH_HOME, the armed task directory, and the last report.
|
|
7
|
+
* A prompt with a placeholder path is the documentation that already failed (same rule as the
|
|
8
|
+
* sandbox-mediator prescription).
|
|
9
|
+
*
|
|
10
|
+
* The long-form instructions live in `lib/tools/selfcheck-fix-prompt.md` (shipped), so the wording
|
|
11
|
+
* can be edited without touching code; this module only prepends what is machine-specific.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
/** @returns {string} absolute path of the shipped prompt body */
|
|
18
|
+
export function promptTemplatePath() {
|
|
19
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'selfcheck-fix-prompt.md')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One line per non-passing check, with its own `fix` text -- that is what makes the prompt
|
|
24
|
+
* actionable instead of a generic checklist.
|
|
25
|
+
*
|
|
26
|
+
* @param {object | null} report
|
|
27
|
+
* @param {string} [reason] why there is no report
|
|
28
|
+
* @returns {string[]}
|
|
29
|
+
*/
|
|
30
|
+
export function formatReportSummary(report, reason = '') {
|
|
31
|
+
if (report === null || report === undefined) {
|
|
32
|
+
return [`- 自检没有产生报告:${reason === '' ? '未知原因' : reason}`]
|
|
33
|
+
}
|
|
34
|
+
const checks = Array.isArray(report.checks) ? report.checks : []
|
|
35
|
+
const lines = [
|
|
36
|
+
`- verdict: ${String(report.verdict ?? 'unknown')} | blocking=${Number(report.blockingFailures ?? 0)} warn=${Number(report.warnings ?? 0)} unchecked=${Number(report.unchecked ?? 0)}`,
|
|
37
|
+
]
|
|
38
|
+
for (const check of checks) {
|
|
39
|
+
if (String(check.status ?? '') === 'pass') continue
|
|
40
|
+
const actual = String(check.actual ?? '').slice(0, 200)
|
|
41
|
+
const fix = String(check.fix ?? '').slice(0, 200)
|
|
42
|
+
lines.push(`- ${String(check.id ?? '?')} ${String(check.status ?? '?')}: ${actual}${fix === '' ? '' : ` (fix: ${fix})`}`)
|
|
43
|
+
}
|
|
44
|
+
return lines
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {object | null} [opts.report]
|
|
50
|
+
* @param {string} [opts.reason]
|
|
51
|
+
* @param {{ scriptPath?: string, home?: string, taskCwd?: string, pluginDir?: string }} [opts.facts]
|
|
52
|
+
* @returns {{ text: string, scriptPath: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function buildEnvironmentPrompt(opts = {}) {
|
|
55
|
+
const facts = opts.facts ?? {}
|
|
56
|
+
let body = ''
|
|
57
|
+
try {
|
|
58
|
+
body = readFileSync(promptTemplatePath(), 'utf8')
|
|
59
|
+
} catch { /* the facts + report are still useful without the long form */ }
|
|
60
|
+
const factsBlock = [
|
|
61
|
+
'# 本机事实(插件自动填写,直接照做即可)',
|
|
62
|
+
'',
|
|
63
|
+
`- 插件目录:${String(facts.pluginDir ?? '')}`,
|
|
64
|
+
`- 自检脚本:${String(facts.scriptPath ?? '')}`,
|
|
65
|
+
`- DSH home:${String(facts.home ?? '')}`,
|
|
66
|
+
`- 任务目录:${String(facts.taskCwd ?? '') === '' ? '(未设置)' : String(facts.taskCwd)}`,
|
|
67
|
+
'',
|
|
68
|
+
'## 最近一次自检',
|
|
69
|
+
...formatReportSummary(opts.report ?? null, String(opts.reason ?? '')),
|
|
70
|
+
'',
|
|
71
|
+
'---',
|
|
72
|
+
'',
|
|
73
|
+
body === '' ? '(提示词正文缺失:按上面报告的 fix 字段逐项处理,然后复跑 selfcheck.ps1。)' : body,
|
|
74
|
+
]
|
|
75
|
+
return { text: factsBlock.join('\n'), scriptPath: String(facts.scriptPath ?? '') }
|
|
76
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker-role listener, as a supervised child process.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT SUPERVISES `slave/relay.mjs` RATHER THAN REIMPLEMENTING IT: the relay already speaks
|
|
5
|
+
* the worker's whole surface -- the `dsh-remote/*` RPCs, the ACP pump, `/screenshot`, `/healthz`,
|
|
6
|
+
* screenshot retention, pairing-string generation -- and it is validated end to end against the
|
|
7
|
+
* reference worker. A second implementation inside the plugin would be a second thing to keep
|
|
8
|
+
* correct, and the two would drift exactly where it matters (authentication, the permission
|
|
9
|
+
* bridge, cancellation).
|
|
10
|
+
*
|
|
11
|
+
* WHY IT KEEPS NO POLICY: which address, which port, which token, and whether arming is allowed
|
|
12
|
+
* at all belong to `worker-arming.js`. This class starts what it is told and reports what
|
|
13
|
+
* happened, so the security decisions have one home.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
import { effectiveSessionMode } from '../../slave/permission-pairing.mjs'
|
|
22
|
+
|
|
23
|
+
/** How much of the relay's output is kept for diagnosis. Enough to see a crash, not a log file. */
|
|
24
|
+
const OUTPUT_LIMIT = 8 * 1024
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
|
|
28
|
+
/** `slave/` ships next to `lib/`, so the relay is addressed relative to this module. */
|
|
29
|
+
export function relayPath() {
|
|
30
|
+
return join(HERE, '..', '..', 'slave', 'relay.mjs')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class RelaySupervisor {
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} deps
|
|
36
|
+
* @param {(file: string, args: string[], opts: object) => object} [deps.spawn] injected for tests
|
|
37
|
+
* @param {() => object} [deps.launchSpec] the ACP agent spec to hand the relay
|
|
38
|
+
* @param {string} [deps.relay] override the relay path
|
|
39
|
+
* @param {{ info?: Function, warn?: Function, error?: Function }} [deps.log]
|
|
40
|
+
* @param {() => number} [deps.now]
|
|
41
|
+
*/
|
|
42
|
+
constructor(deps = {}) {
|
|
43
|
+
this.spawn = deps.spawn ?? spawn
|
|
44
|
+
this.launchSpec = deps.launchSpec ?? (() => ({ ok: false, error: 'no launch spec resolver injected' }))
|
|
45
|
+
this.relay = deps.relay ?? relayPath()
|
|
46
|
+
// The ACP spec file goes in the system temp directory: it carries an argv env, not a secret,
|
|
47
|
+
// and it must not be written next to the operator's plugin install.
|
|
48
|
+
this.tempDir = deps.tempDir ?? join(tmpdir(), 'dsh-plugin-remote-use')
|
|
49
|
+
// How many captures the worker keeps. Read at start time rather than captured once, so a
|
|
50
|
+
// change on the settings page applies to the next arming instead of the next DSH restart.
|
|
51
|
+
this.keepShots = deps.keepShots ?? (() => 20)
|
|
52
|
+
/**
|
|
53
|
+
* The ARMED task directory, i.e. where a relative transfer path must resolve on this machine.
|
|
54
|
+
*
|
|
55
|
+
* It has to be passed to the relay rather than inferred from `process.cwd()`. Measured 2026-10-16:
|
|
56
|
+
* a supervised relay inherits THIS process's working directory -- the DSH profile directory -- so
|
|
57
|
+
* `remotePath: "note.txt"` reported success while writing into `...\.dsh\profiles\desktop\`, not
|
|
58
|
+
* into the directory the operator armed.
|
|
59
|
+
*/
|
|
60
|
+
this.taskDir = deps.taskDir ?? (() => '')
|
|
61
|
+
// The worker's byte ceiling for its own captures. 0 means no ceiling, which is the shipped
|
|
62
|
+
// default: the count above already bounds ordinary use, and a byte ceiling that appeared on
|
|
63
|
+
// upgrade would start deleting captures the operator never agreed to lose.
|
|
64
|
+
//
|
|
65
|
+
// This is the worker's DEFAULT, not the whole story: the page can tighten it live over the
|
|
66
|
+
// `dsh-remote/shotcache` RPC without re-arming. Passing it here anyway matters because the
|
|
67
|
+
// capture path prunes BEFORE each capture, so without a CLI default a worker whose master is
|
|
68
|
+
// offline would grow unbounded between the page's visits.
|
|
69
|
+
this.shotCacheBytes = deps.shotCacheBytes ?? (() => 0)
|
|
70
|
+
// A per-installation directory under the OS temp root, NOT the package. A capture is a
|
|
71
|
+
// transient full-resolution PNG that is pruned on a budget, so temp is where it belongs;
|
|
72
|
+
// what matters is that it is outside the plugin.
|
|
73
|
+
this.shotDir = deps.shotDir ?? join(tmpdir(), 'dsh-plugin-remote-use', 'shots')
|
|
74
|
+
this.specFile = ''
|
|
75
|
+
/**
|
|
76
|
+
* The session mode the RUNNING relay was started with, captured from the spec at start time.
|
|
77
|
+
*
|
|
78
|
+
* The settings page shows `workerSessionMode` (what will be used on the next arming), while this
|
|
79
|
+
* is what the live listener will actually spawn. Keeping both is the difference between "I set it
|
|
80
|
+
* to full access" and "it is running at full access"; without it the page can only assert the
|
|
81
|
+
* first (PITFALLS F38).
|
|
82
|
+
*/
|
|
83
|
+
this.sessionMode = ''
|
|
84
|
+
this.sessionModeFrom = 'nothing'
|
|
85
|
+
const log = deps.log ?? {}
|
|
86
|
+
this.log = { info: log.info ?? (() => {}), warn: log.warn ?? (() => {}), error: log.error ?? (() => {}) }
|
|
87
|
+
this.now = deps.now ?? (() => Date.now())
|
|
88
|
+
|
|
89
|
+
this.child = undefined
|
|
90
|
+
this.pid = 0
|
|
91
|
+
this.startedAt = 0
|
|
92
|
+
this.bind = ''
|
|
93
|
+
this.port = 0
|
|
94
|
+
this.output = ''
|
|
95
|
+
this.lastError = ''
|
|
96
|
+
this.exited = undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True while a listener is believed to be running. */
|
|
100
|
+
get running() {
|
|
101
|
+
return this.child !== undefined && this.exited === undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Start the listener. Throws on anything that would leave the caller believing a port is open
|
|
106
|
+
* when it is not -- `WorkerArming` turns a throw into a refusal to arm.
|
|
107
|
+
*
|
|
108
|
+
* `taskCwd` is passed EXPLICITLY rather than read through a closure. Reading it through
|
|
109
|
+
* `deps.taskDir()` during a restore hits the temporal dead zone: that restore happens while
|
|
110
|
+
* `WorkerArming` is still being constructed, so the `arming` binding the closure refers to is
|
|
111
|
+
* not initialized yet. It also read the OLD value during a normal arm, because `WorkerArming`
|
|
112
|
+
* called `start()` before assigning the newly requested directory. Both are fixed by taking the
|
|
113
|
+
* value as an argument from the state machine that already owns it.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} bindAddress
|
|
116
|
+
* @param {number} port
|
|
117
|
+
* @param {string} token
|
|
118
|
+
* @param {string} [taskCwd] the armed task directory; omitted means "use deps.taskDir()"
|
|
119
|
+
*/
|
|
120
|
+
start(bindAddress, port, token, taskCwd) {
|
|
121
|
+
if (this.running) throw new Error(`the listener is already running on ${this.bind}:${this.port}`)
|
|
122
|
+
if (!existsSync(this.relay)) throw new Error(`the relay is missing at ${this.relay}`)
|
|
123
|
+
|
|
124
|
+
// No default-parameter call to `this.taskDir()`: that would still evaluate the closure before
|
|
125
|
+
// the caller's explicit value is considered. Only ask the fallback when no value was provided.
|
|
126
|
+
const taskDir = taskCwd === undefined ? String(this.taskDir() ?? '') : String(taskCwd ?? '')
|
|
127
|
+
|
|
128
|
+
const spec = this.launchSpec()
|
|
129
|
+
if (spec?.ok !== true) throw new Error(`cannot launch this machine's ACP agent: ${spec?.error ?? 'unknown'}`)
|
|
130
|
+
|
|
131
|
+
// The relay is handed the ACP agent as a SPEC FILE, not as a command line. That is the
|
|
132
|
+
// relay's own designed path (`--cmd-file`) and it exists for the same reason this module
|
|
133
|
+
// builds an argv array: the install directory contains a space, and every attempt to pass
|
|
134
|
+
// it as a string has been split somewhere along the way -- once already, as
|
|
135
|
+
// `Cannot find module 'C:\Users\<user>\AppData\Local\Programs\DeepSeek'`.
|
|
136
|
+
const specFile = join(this.tempDir, `acp-spec-${process.pid}-${port}.json`)
|
|
137
|
+
try {
|
|
138
|
+
mkdirSync(this.tempDir, { recursive: true })
|
|
139
|
+
writeFileSync(specFile, JSON.stringify({
|
|
140
|
+
command: spec.spec.command,
|
|
141
|
+
args: spec.spec.args,
|
|
142
|
+
env: spec.spec.env,
|
|
143
|
+
}), 'utf8')
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(`could not write the ACP launch spec: ${error instanceof Error ? error.message : String(error)}`)
|
|
146
|
+
}
|
|
147
|
+
this.specFile = specFile
|
|
148
|
+
|
|
149
|
+
const args = [
|
|
150
|
+
this.relay,
|
|
151
|
+
'--host', bindAddress,
|
|
152
|
+
'--port', String(port),
|
|
153
|
+
'--token', token,
|
|
154
|
+
'--advertise-host', bindAddress,
|
|
155
|
+
'--cmd-file', specFile,
|
|
156
|
+
// The plugin starts the relay from inside its own directory, so the relay must not write
|
|
157
|
+
// its pairing file next to itself: an installed plugin would modify its installation, and
|
|
158
|
+
// on a link: install that is the operator's source checkout. The master gets the pairing
|
|
159
|
+
// string from the RPC instead. Caught by running the supervised listener and looking at
|
|
160
|
+
// the repository.
|
|
161
|
+
'--pairing-file', '',
|
|
162
|
+
// A capture is a full-resolution PNG (~9.5 MB measured). Without this the worker kept
|
|
163
|
+
// every one of them, and the page's setting would only govern the HOST side -- the
|
|
164
|
+
// larger of the two accumulations would be the one the operator could not control.
|
|
165
|
+
'--keep-shots', String(this.keepShots()),
|
|
166
|
+
// Where a RELATIVE transfer path resolves. Passed explicitly for the same reason as every other
|
|
167
|
+
// path here: the relay's own `process.cwd()` is not the operator's task directory when the
|
|
168
|
+
// PLUGIN starts the relay, and a wrong answer is silent -- the transfer succeeds, into the wrong
|
|
169
|
+
// directory, and reports the path the caller asked for.
|
|
170
|
+
...(taskDir === '' ? [] : ['--task-dir', taskDir]),
|
|
171
|
+
// The byte ceiling, beside the count. Both are passed because they bound different things:
|
|
172
|
+
// the count bounds how many captures exist, the byte budget bounds what they occupy, and a
|
|
173
|
+
// worker left running a long GUI loop needs the second one to stay inside a disk budget.
|
|
174
|
+
'--keep-shot-bytes', String(this.shotCacheBytes()),
|
|
175
|
+
// Where the captures go. WITHOUT THIS the relay uses `join(HERE, 'shots')` -- HERE being the
|
|
176
|
+
// relay script's own directory, i.e. the plugin's INSTALLATION. On a `link:` install that is
|
|
177
|
+
// the operator's source checkout, and on a real install it is a directory the plugin has no
|
|
178
|
+
// business growing. Same defect as the `--pairing-file` one above, found the same way -- by
|
|
179
|
+
// running the supervised listener and looking at the tree -- because the capture path had
|
|
180
|
+
// never actually been exercised on this machine until it was.
|
|
181
|
+
'--shot-dir', this.shotDir,
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
const child = this.spawn(process.execPath, args, {
|
|
185
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
186
|
+
windowsHide: true,
|
|
187
|
+
env: { ...process.env, ...spec.spec.env },
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
this.child = child
|
|
191
|
+
this.pid = child.pid ?? 0
|
|
192
|
+
this.startedAt = this.now()
|
|
193
|
+
this.bind = bindAddress
|
|
194
|
+
this.port = port
|
|
195
|
+
this.output = ''
|
|
196
|
+
this.lastError = ''
|
|
197
|
+
this.exited = undefined
|
|
198
|
+
// Captured from the SAME spec the relay is reading. `env: process.env` mirrors what the relay
|
|
199
|
+
// spawn does, so an inherited variable is reported as inherited rather than as the spec's.
|
|
200
|
+
const mode = effectiveSessionMode({ specEnv: spec.spec.env, env: process.env })
|
|
201
|
+
this.sessionMode = mode.mode
|
|
202
|
+
this.sessionModeFrom = mode.from
|
|
203
|
+
|
|
204
|
+
const collect = (chunk) => {
|
|
205
|
+
this.output += String(chunk)
|
|
206
|
+
if (this.output.length > OUTPUT_LIMIT) this.output = this.output.slice(-OUTPUT_LIMIT)
|
|
207
|
+
}
|
|
208
|
+
child.stdout?.on('data', collect)
|
|
209
|
+
child.stderr?.on('data', collect)
|
|
210
|
+
child.on('exit', (code, signal) => {
|
|
211
|
+
this.exited = { code, signal, at: this.now() }
|
|
212
|
+
// Report it: a listener that died is the difference between "armed" and "reachable", and
|
|
213
|
+
// the page must not keep claiming the first while the second is false.
|
|
214
|
+
if (code !== 0 && code !== null) this.log.warn(`the worker listener exited with code ${code}`)
|
|
215
|
+
this.child = undefined
|
|
216
|
+
// Nothing is running, so there is no effective mode to report. Leaving the last one here
|
|
217
|
+
// would let the page compare a dead listener's spec against a live setting.
|
|
218
|
+
this.sessionMode = ''
|
|
219
|
+
this.sessionModeFrom = 'nothing'
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
this.log.info(`worker listener started on ${bindAddress}:${port} (pid ${this.pid})`)
|
|
223
|
+
return { pid: this.pid }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Stop the listener. Idempotent, and never throws. */
|
|
227
|
+
stop() {
|
|
228
|
+
const child = this.child
|
|
229
|
+
this.child = undefined
|
|
230
|
+
this.sessionMode = ''
|
|
231
|
+
this.sessionModeFrom = 'nothing'
|
|
232
|
+
if (child === undefined) {
|
|
233
|
+
this.#discardSpec()
|
|
234
|
+
this.pid = 0
|
|
235
|
+
return { stopped: false }
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
child.kill()
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.lastError = `could not stop the listener: ${error instanceof Error ? error.message : String(error)}`
|
|
241
|
+
this.log.warn(this.lastError)
|
|
242
|
+
}
|
|
243
|
+
this.#discardSpec()
|
|
244
|
+
this.pid = 0
|
|
245
|
+
return { stopped: true }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** What the page shows. */
|
|
249
|
+
status() {
|
|
250
|
+
return {
|
|
251
|
+
running: this.running,
|
|
252
|
+
pid: this.pid,
|
|
253
|
+
bindAddress: this.bind,
|
|
254
|
+
port: this.port,
|
|
255
|
+
startedAt: this.startedAt,
|
|
256
|
+
uptimeMs: this.running && this.startedAt > 0 ? Math.max(0, this.now() - this.startedAt) : 0,
|
|
257
|
+
exited: this.exited ?? null,
|
|
258
|
+
lastError: this.lastError,
|
|
259
|
+
// What the LIVE listener will actually spawn. Empty while stopped, so the page cannot compare
|
|
260
|
+
// a dead spec against the configured value and call it a running mismatch.
|
|
261
|
+
sessionMode: this.sessionMode,
|
|
262
|
+
sessionModeFrom: this.sessionModeFrom,
|
|
263
|
+
specFile: this.specFile,
|
|
264
|
+
// The tail only: enough to see why it died, not a log relay.
|
|
265
|
+
output: this.output.slice(-2000),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The spec file is transient: leaving it behind would accumulate one per start. */
|
|
270
|
+
#discardSpec() {
|
|
271
|
+
if (this.specFile === '') return
|
|
272
|
+
const file = this.specFile
|
|
273
|
+
this.specFile = ''
|
|
274
|
+
try {
|
|
275
|
+
rmSync(file, { force: true })
|
|
276
|
+
} catch { /* best effort: a leftover spec file is untidy, not dangerous */ }
|
|
277
|
+
}
|
|
278
|
+
}
|