@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,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot housekeeping.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: every capture leaves a file behind, and a worker screenshot is a
|
|
5
|
+
* full-resolution PNG -- measured at ~9.5 MB each on the reference worker. A session that
|
|
6
|
+
* uses screenshots to drive GUI verification produces them steadily, and nothing ever removed
|
|
7
|
+
* them: the worker's shot directory had reached 56.9 MB across six captures before this was
|
|
8
|
+
* written, and the host's download directory grows the same way.
|
|
9
|
+
*
|
|
10
|
+
* TWO RULES SHAPE THE DESIGN:
|
|
11
|
+
*
|
|
12
|
+
* 1. NEVER THROW. Pruning runs as part of taking a screenshot. A full disk or a locked file
|
|
13
|
+
* must not turn a successful capture into a failed one, so every failure is reported in
|
|
14
|
+
* the result and the caller carries on. The failure mode this avoids -- "I cannot show you
|
|
15
|
+
* the screen because I could not delete an old file" -- is strictly worse than the disk
|
|
16
|
+
* usage it prevents.
|
|
17
|
+
*
|
|
18
|
+
* 2. ONLY TOUCH WHAT THIS PLUGIN WROTE. The host's screenshot directory sits in the system
|
|
19
|
+
* temp directory, which is shared with everything else on the machine. Deleting "some old
|
|
20
|
+
* files" there would be destructive, so a file is only a candidate when its name matches
|
|
21
|
+
* the pattern this plugin generates for that worker.
|
|
22
|
+
*
|
|
23
|
+
* WHY THERE IS ALSO A BYTE BUDGET: a COUNT budget does not bound disk usage, it only bounds the
|
|
24
|
+
* number of files. That is a real distinction and it bit this plugin: the count was 20, so the
|
|
25
|
+
* budget was believed to be "20 captures", while the directory had reached 218 MB -- and on a
|
|
26
|
+
* machine whose captures are larger, or whose operator raised the count, the count says nothing
|
|
27
|
+
* about whether the disk is filling. Byte usage is what the operator actually cares about, so it
|
|
28
|
+
* is both reported (see `scanShotUsage`) and enforced (see `pruneShots`'s `maxBytes`).
|
|
29
|
+
*/
|
|
30
|
+
import { readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
|
|
33
|
+
/** Default number of screenshots kept per worker. */
|
|
34
|
+
export const DEFAULT_KEEP_SHOTS = 20
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default byte ceiling for one worker's screenshots on the HOST, when the setting is absent.
|
|
38
|
+
*
|
|
39
|
+
* 0 means "no byte budget", which is the shipped default on purpose: the count budget already
|
|
40
|
+
* bounds ordinary use, and an install that upgrades into a byte budget would start deleting
|
|
41
|
+
* captures the operator never agreed to lose. An operator who wants a hard ceiling sets one.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_SHOT_CACHE_BYTES = 0
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One megabyte, for parsing and rendering the operator-facing budget strings.
|
|
47
|
+
*
|
|
48
|
+
* Binary, not decimal: operators compare this number against what Explorer shows, and Explorer
|
|
49
|
+
* reports MiB while calling it MB. Using 1e6 here would make the page disagree with the folder
|
|
50
|
+
* it is describing by ~5%, which reads as a bug in the accounting.
|
|
51
|
+
*/
|
|
52
|
+
export const BYTES_PER_MB = 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render a byte count the way the settings page shows it.
|
|
56
|
+
* @param {number} bytes
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function formatBytes(bytes) {
|
|
60
|
+
const n = Number(bytes)
|
|
61
|
+
if (!Number.isFinite(n) || n <= 0) return '0 B'
|
|
62
|
+
if (n < 1024) return `${Math.round(n)} B`
|
|
63
|
+
if (n < BYTES_PER_MB) return `${(n / 1024).toFixed(1)} KB`
|
|
64
|
+
if (n < BYTES_PER_MB * 1024) return `${(n / BYTES_PER_MB).toFixed(1)} MB`
|
|
65
|
+
return `${(n / (BYTES_PER_MB * 1024)).toFixed(2)} GB`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse an operator-entered budget. Accepts a bare number of BYTES, or a number with a unit
|
|
70
|
+
* suffix (`512MB`, `1.5 GB`, `0`).
|
|
71
|
+
*
|
|
72
|
+
* WHY A PARSER AND NOT JUST A NUMBER FIELD: the unit an operator thinks in is MB, and a field
|
|
73
|
+
* that silently wants bytes invites an off-by-a-million typo that then either deletes nothing or
|
|
74
|
+
* deletes everything. Returns undefined for anything unparseable, so the caller can refuse the
|
|
75
|
+
* save rather than apply a guess.
|
|
76
|
+
*
|
|
77
|
+
* @param {unknown} raw
|
|
78
|
+
* @returns {number | undefined} bytes, or undefined when unusable
|
|
79
|
+
*/
|
|
80
|
+
export function parseBytes(raw) {
|
|
81
|
+
if (typeof raw === 'number') return Number.isFinite(raw) ? Math.floor(raw) : undefined
|
|
82
|
+
if (typeof raw !== 'string') return undefined
|
|
83
|
+
const text = raw.trim()
|
|
84
|
+
if (text === '') return undefined
|
|
85
|
+
// A signed value is meaningful (negative disables), so the sign is captured rather than rejected.
|
|
86
|
+
const m = /^(-?\d+(?:\.\d+)?)\s*(b|kb|mb|gb|kib|mib|gib)?$/iu.exec(text)
|
|
87
|
+
if (m === null) return undefined
|
|
88
|
+
const value = Number(m[1])
|
|
89
|
+
if (!Number.isFinite(value)) return undefined
|
|
90
|
+
const unit = (m[2] ?? '').toLowerCase()
|
|
91
|
+
const scale = unit === '' || unit === 'b'
|
|
92
|
+
? 1
|
|
93
|
+
: unit === 'kb' || unit === 'kib'
|
|
94
|
+
? 1024
|
|
95
|
+
: unit === 'mb' || unit === 'mib'
|
|
96
|
+
? BYTES_PER_MB
|
|
97
|
+
: BYTES_PER_MB * 1024
|
|
98
|
+
return Math.floor(value * scale)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Files this plugin writes look like `<peerId>-<ISO stamp with : and . replaced>.png`.
|
|
103
|
+
* The trailing hyphen matters: without it a worker named `w1` would also match `w12`'s files.
|
|
104
|
+
* @param {string} peerId
|
|
105
|
+
* @returns {RegExp}
|
|
106
|
+
*/
|
|
107
|
+
export function shotPattern(peerId) {
|
|
108
|
+
const escaped = String(peerId).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
109
|
+
return new RegExp(`^${escaped}-\\d{4}-\\d{2}-\\d{2}T[\\d-]+Z\\.png$`, 'u')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List this worker's screenshots in `dir`, newest first.
|
|
114
|
+
*
|
|
115
|
+
* Shared by pruning, reporting and clearing so the three can never disagree about which files
|
|
116
|
+
* count as "ours" -- the definition of that set is the whole safety property here, and two
|
|
117
|
+
* copies of it would drift.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} dir
|
|
120
|
+
* @param {string} peerId
|
|
121
|
+
* @returns {{ files: Array<{path: string, name: string, mtimeMs: number, size: number}>, error: string }}
|
|
122
|
+
*/
|
|
123
|
+
function listShots(dir, peerId) {
|
|
124
|
+
let names
|
|
125
|
+
try {
|
|
126
|
+
names = readdirSync(dir)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// A directory that does not exist yet is the normal first-run case, not a failure.
|
|
129
|
+
if (error?.code === 'ENOENT') return { files: [], error: '' }
|
|
130
|
+
return { files: [], error: `cannot list ${dir}: ${error.message}` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pattern = shotPattern(peerId)
|
|
134
|
+
const files = []
|
|
135
|
+
for (const name of names) {
|
|
136
|
+
if (!pattern.test(name)) continue
|
|
137
|
+
const path = join(dir, name)
|
|
138
|
+
try {
|
|
139
|
+
const stat = statSync(path)
|
|
140
|
+
if (stat.isFile()) files.push({ path, name, mtimeMs: stat.mtimeMs, size: stat.size })
|
|
141
|
+
} catch { /* vanished between listing and stat; nothing to do */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Newest first. The name breaks ties: the stamp is ISO, so it sorts correctly, and a
|
|
145
|
+
// filesystem with coarse timestamps can hand out identical mtimes.
|
|
146
|
+
files.sort((a, b) => (b.mtimeMs - a.mtimeMs) || b.name.localeCompare(a.name))
|
|
147
|
+
return { files, error: '' }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What one worker's screenshots occupy on disk.
|
|
152
|
+
*
|
|
153
|
+
* WHY THIS EXISTS: the count budget was the only thing that was bounded, and it was invisible.
|
|
154
|
+
* An operator whose temp directory had reached 218 MB had no way to see that from the page, and
|
|
155
|
+
* "a limit nobody can see is indistinguishable from no limit" applies doubly to usage.
|
|
156
|
+
*
|
|
157
|
+
* Never throws: this is called on a page load and on a liveness probe, neither of which may fail
|
|
158
|
+
* because a directory was locked.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} dir
|
|
161
|
+
* @param {string} peerId
|
|
162
|
+
* @returns {{ files: number, bytes: number, newestAt: number, oldestAt: number, error: string }}
|
|
163
|
+
*/
|
|
164
|
+
export function scanShotUsage(dir, peerId) {
|
|
165
|
+
const empty = { files: 0, bytes: 0, newestAt: 0, oldestAt: 0, error: '' }
|
|
166
|
+
if (typeof dir !== 'string' || dir === '' || peerId === undefined || peerId === null || String(peerId) === '') {
|
|
167
|
+
return empty
|
|
168
|
+
}
|
|
169
|
+
const { files, error } = listShots(dir, String(peerId))
|
|
170
|
+
if (error !== '') return { ...empty, error }
|
|
171
|
+
let bytes = 0
|
|
172
|
+
// `listShots` sorts newest first, so the extremes are the ends of the list rather than a scan.
|
|
173
|
+
for (const file of files) bytes += file.size
|
|
174
|
+
return {
|
|
175
|
+
files: files.length,
|
|
176
|
+
bytes,
|
|
177
|
+
newestAt: files.length === 0 ? 0 : files[0].mtimeMs,
|
|
178
|
+
oldestAt: files.length === 0 ? 0 : files[files.length - 1].mtimeMs,
|
|
179
|
+
error: '',
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Delete all but the newest `keep` screenshots belonging to one worker, then, if `opts.maxBytes`
|
|
185
|
+
* is a positive budget, delete oldest-first until the survivors fit inside it.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} dir
|
|
188
|
+
* @param {string} peerId
|
|
189
|
+
* @param {number} keep how many to retain; a non-finite or negative value disables pruning
|
|
190
|
+
* @param {{ maxBytes?: number, keepNewest?: boolean }} [opts]
|
|
191
|
+
* `maxBytes`: byte ceiling for the survivors; non-finite, negative or 0 means no byte budget.
|
|
192
|
+
* `keepNewest` (default true): never delete the newest capture in the byte pass even if it
|
|
193
|
+
* alone exceeds the budget -- see the note in the body.
|
|
194
|
+
* @returns {{ ok: boolean, removed: number, kept: number, freedBytes: number,
|
|
195
|
+
* overBudgetBytes: number, bytes: number, error: string }}
|
|
196
|
+
*/
|
|
197
|
+
export function pruneShots(dir, peerId, keep, opts = {}) {
|
|
198
|
+
const nothing = { ok: true, removed: 0, kept: 0, freedBytes: 0, overBudgetBytes: 0, bytes: 0, error: '' }
|
|
199
|
+
if (typeof dir !== 'string' || dir === '') {
|
|
200
|
+
return { ...nothing, ok: false, error: 'pruneShots: no directory given' }
|
|
201
|
+
}
|
|
202
|
+
const hasCountBudget = Number.isFinite(keep) && keep >= 0
|
|
203
|
+
const maxBytes = Number(opts?.maxBytes)
|
|
204
|
+
// Same rule as the count budget, for the same reason: a missing or zero budget means "do not
|
|
205
|
+
// prune", never "delete everything". The dangerous reading of an absent setting is 0.
|
|
206
|
+
const hasByteBudget = Number.isFinite(maxBytes) && maxBytes > 0
|
|
207
|
+
// "No budget at all" is a no-op, but it is NOT an empty cache. Reporting `kept: 0` here would
|
|
208
|
+
// tell the page that a directory holding forty captures contains none -- two different facts
|
|
209
|
+
// (nothing was pruned / nothing is there) collapsed into one number, and the wrong one. The
|
|
210
|
+
// directory is listed so the count is real; a listing failure still reports zeroes.
|
|
211
|
+
if (!hasCountBudget && !hasByteBudget) {
|
|
212
|
+
const idle = listShots(dir, peerId)
|
|
213
|
+
if (idle.error !== '') return nothing
|
|
214
|
+
let idleBytes = 0
|
|
215
|
+
for (const file of idle.files) idleBytes += file.size
|
|
216
|
+
return { ...nothing, kept: idle.files.length, bytes: idleBytes }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { files, error } = listShots(dir, peerId)
|
|
220
|
+
if (error !== '') return { ...nothing, ok: false, error: `pruneShots: ${error}` }
|
|
221
|
+
|
|
222
|
+
// The count pass first: it is a whole-file decision the operator stated explicitly, and it is
|
|
223
|
+
// cheaper to reason about than a running total.
|
|
224
|
+
const survivors = hasCountBudget ? files.slice(0, Math.max(0, Math.floor(keep))) : [...files]
|
|
225
|
+
const doomed = hasCountBudget ? files.slice(Math.max(0, Math.floor(keep))) : []
|
|
226
|
+
|
|
227
|
+
// The byte pass. `keepNewest` exists because the newest capture is the one the host is usually
|
|
228
|
+
// about to read: deleting it to satisfy a byte ceiling would break the download that produced
|
|
229
|
+
// it, and the master would be left with a screenshot tool that reports success and has no file.
|
|
230
|
+
// So the byte pass may leave the directory over budget by exactly one capture, and it SAYS SO
|
|
231
|
+
// through `overBudgetBytes` rather than silently overshooting.
|
|
232
|
+
//
|
|
233
|
+
// ⚠️ THE `hasByteBudget` TEST IS LOAD-BEARING AND WAS ONCE MISSING. The loop originally read
|
|
234
|
+
// `running > maxBytes` alone. With no byte budget, `maxBytes` is 0, and EVERY non-empty
|
|
235
|
+
// directory is "over" 0 -- so asking for "no ceiling" deleted everything except the newest
|
|
236
|
+
// capture, which is the exact opposite of what a 0 budget means. It survived the unit tests
|
|
237
|
+
// because those passed `keep: Infinity`, which returns at the no-budget guard above and never
|
|
238
|
+
// reaches this loop; the end-to-end probe caught it. A budget of 0 must mean "do not run this
|
|
239
|
+
// pass at all", so the flag is checked here as well as at the guard.
|
|
240
|
+
const keepNewest = opts?.keepNewest !== false
|
|
241
|
+
let running = 0
|
|
242
|
+
for (const file of survivors) running += file.size
|
|
243
|
+
for (let i = survivors.length - 1; hasByteBudget && i >= 0 && running > maxBytes; i -= 1) {
|
|
244
|
+
if (keepNewest && i === 0) break
|
|
245
|
+
doomed.push(survivors[i])
|
|
246
|
+
running -= survivors[i].size
|
|
247
|
+
survivors.splice(i, 1)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let removed = 0
|
|
251
|
+
let freedBytes = 0
|
|
252
|
+
let firstError = ''
|
|
253
|
+
for (const file of doomed) {
|
|
254
|
+
try {
|
|
255
|
+
unlinkSync(file.path)
|
|
256
|
+
removed += 1
|
|
257
|
+
freedBytes += file.size
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (firstError === '') firstError = `cannot delete ${file.name}: ${error.message}`
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Bytes are re-derived from the survivors rather than from the running total: a file whose
|
|
264
|
+
// unlink failed is still on disk, and reporting it as freed would be a lie the page repeats.
|
|
265
|
+
let bytes = 0
|
|
266
|
+
for (const file of survivors) bytes += file.size
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
ok: firstError === '',
|
|
270
|
+
removed,
|
|
271
|
+
kept: survivors.length,
|
|
272
|
+
freedBytes,
|
|
273
|
+
overBudgetBytes: hasByteBudget ? Math.max(0, bytes - maxBytes) : 0,
|
|
274
|
+
bytes,
|
|
275
|
+
error: firstError,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Delete every screenshot belonging to one worker, or every screenshot for every worker when
|
|
281
|
+
* `peerId` is empty.
|
|
282
|
+
*
|
|
283
|
+
* WHY THIS IS SEPARATE FROM `pruneShots`: "keep the newest N" and "clear this cache" are
|
|
284
|
+
* different operator intentions, and expressing the second as `keep: 0` would silently change
|
|
285
|
+
* meaning the moment someone redefines 0 as "unset". The page has an explicit Clear button, so
|
|
286
|
+
* the code gets an explicit function for it.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} dir
|
|
289
|
+
* @param {string} [peerId] worker to clear; empty/undefined clears every worker's captures
|
|
290
|
+
* @returns {{ ok: boolean, removed: number, freedBytes: number, bytes: number, error: string }}
|
|
291
|
+
*/
|
|
292
|
+
export function clearShots(dir, peerId = '') {
|
|
293
|
+
const nothing = { ok: true, removed: 0, freedBytes: 0, bytes: 0, error: '' }
|
|
294
|
+
if (typeof dir !== 'string' || dir === '') {
|
|
295
|
+
return { ...nothing, ok: false, error: 'clearShots: no directory given' }
|
|
296
|
+
}
|
|
297
|
+
let names
|
|
298
|
+
try {
|
|
299
|
+
names = readdirSync(dir)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error?.code === 'ENOENT') return nothing
|
|
302
|
+
return { ...nothing, ok: false, error: `clearShots: cannot list ${dir}: ${error.message}` }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// An empty peer id means "every worker", which is how the page offers one button per directory
|
|
306
|
+
// rather than one deletion path per worker. The pattern still gates what may be removed: with
|
|
307
|
+
// no peer id the accepted shape is any `<peerId>-<stamp>.png`, and a foreign file still fails it.
|
|
308
|
+
const pattern = String(peerId ?? '') === ''
|
|
309
|
+
? /^[^-]+(?:-[^-]+)*-\d{4}-\d{2}-\d{2}T[\d-]+Z\.png$/u
|
|
310
|
+
: shotPattern(String(peerId))
|
|
311
|
+
|
|
312
|
+
let removed = 0
|
|
313
|
+
let freedBytes = 0
|
|
314
|
+
let bytes = 0
|
|
315
|
+
let firstError = ''
|
|
316
|
+
for (const name of names) {
|
|
317
|
+
const path = join(dir, name)
|
|
318
|
+
let stat
|
|
319
|
+
try {
|
|
320
|
+
stat = statSync(path)
|
|
321
|
+
} catch { continue }
|
|
322
|
+
if (!stat.isFile()) continue
|
|
323
|
+
if (!pattern.test(name)) continue
|
|
324
|
+
bytes += stat.size
|
|
325
|
+
try {
|
|
326
|
+
unlinkSync(path)
|
|
327
|
+
removed += 1
|
|
328
|
+
freedBytes += stat.size
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (firstError === '') firstError = `cannot delete ${name}: ${error.message}`
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { ok: firstError === '', removed, freedBytes, bytes: bytes - freedBytes, error: firstError }
|
|
334
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule about a task's working directory, shared by both ends of a pairing.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT IS SHARED: the directory is a WORKER-side absolute path. The master stores it and sends
|
|
5
|
+
* it as the ACP session's `cwd`; the worker advertises what it should be inside its pairing
|
|
6
|
+
* string. Both ends must agree on what "usable" means -- two copies of the rule would drift, and
|
|
7
|
+
* the drift would look like a worker advertising a path the master then refuses.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT MATTERS MORE THAN IT LOOKS (DESIGN §6.4): this is where the task runs AND the scope the
|
|
10
|
+
* sandbox allows writes to. A relative or empty value does not fail where it is set -- DSH's ACP
|
|
11
|
+
* server rejects a non-absolute `cwd` with an opaque -32602, and an empty one binds the task to
|
|
12
|
+
* the relay process's launch directory, so the task writes somewhere nobody chose.
|
|
13
|
+
*
|
|
14
|
+
* Two messages, one rule: `assertUsableCwd` is worded for the MASTER configuring a worker (it
|
|
15
|
+
* names the field to fix), while `isAbsoluteTaskPath` is the bare predicate the WORKER's own
|
|
16
|
+
* settings page uses to word its own refusal.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Is this a path a task may be given: absolute, non-empty?
|
|
21
|
+
*
|
|
22
|
+
* Windows drive-absolute (`C:\x`, `C:/x`) and POSIX-absolute (`/x`) only. A UNC path is
|
|
23
|
+
* deliberately not accepted: nothing has been validated against a share, and a task whose scope
|
|
24
|
+
* is a network path is a different security question than this plugin answers.
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isAbsoluteTaskPath(value) {
|
|
30
|
+
if (typeof value !== 'string') return false
|
|
31
|
+
const path = value.trim()
|
|
32
|
+
if (path === '') return false
|
|
33
|
+
return /^[a-zA-Z]:[\\/]/u.test(path) || path.startsWith('/')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Report why a worker cwd is unusable, or '' when it is fine.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} cwd
|
|
40
|
+
* @returns {string} a human-readable problem, or '' when usable
|
|
41
|
+
*/
|
|
42
|
+
export function assertUsableCwd(cwd) {
|
|
43
|
+
if (typeof cwd !== 'string' || cwd.trim() === '') {
|
|
44
|
+
return 'worker cwd is empty -- set `cwd` to an absolute path that exists on the worker'
|
|
45
|
+
}
|
|
46
|
+
if (!isAbsoluteTaskPath(cwd)) {
|
|
47
|
+
return `worker cwd must be an absolute path, got "${cwd}" (a relative path would bind tasks to the relay's launch directory)`
|
|
48
|
+
}
|
|
49
|
+
return ''
|
|
50
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务内审批记忆(INTERFACES.md 【A】;DESIGN §8.3)。
|
|
3
|
+
*
|
|
4
|
+
* 纯内存、无依赖、无 I/O。
|
|
5
|
+
*
|
|
6
|
+
* **范围严格限定在单任务内**:跨任务记忆会让「上周批准过写这个目录」静默变成永久授权
|
|
7
|
+
* (DESIGN §8.3「为什么默认单任务内」)。任务结束必须 `dispose()`;`dispose()` 之后
|
|
8
|
+
* `lookup()` 恒为 `'unknown'`,且 `noteApprove()` / `noteReject()` 变成 no-op——
|
|
9
|
+
* 已销毁的记忆绝不能复活授权。
|
|
10
|
+
*
|
|
11
|
+
* 三种状态互不覆盖,查询时**拒绝优先**(fail closed):
|
|
12
|
+
* - `noteReject(key)` → `lookup` = `'rejected'`(即使同一 key 也有批准)
|
|
13
|
+
* - `noteApprove(key, { scope: 'task' })` → `'allowed'`,任务内持续有效
|
|
14
|
+
* - `noteApprove(key, { scope: 'once' })` → `'allowed'`,由 `consumeOnce()` 用掉一次
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 单任务审批记忆。
|
|
19
|
+
*/
|
|
20
|
+
export class TaskMemory {
|
|
21
|
+
/** @type {string} */
|
|
22
|
+
#taskId
|
|
23
|
+
|
|
24
|
+
/** @type {Set<string>} */
|
|
25
|
+
#approvals
|
|
26
|
+
|
|
27
|
+
/** @type {Set<string>} */
|
|
28
|
+
#rejections
|
|
29
|
+
|
|
30
|
+
/** @type {Set<string>} */
|
|
31
|
+
#once
|
|
32
|
+
|
|
33
|
+
/** @type {boolean} */
|
|
34
|
+
#disposed
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} taskId 任务标识(非字符串按 `''` 处理,不抛错)
|
|
38
|
+
*/
|
|
39
|
+
constructor(taskId) {
|
|
40
|
+
this.#taskId = typeof taskId === 'string' ? taskId : ''
|
|
41
|
+
this.#approvals = new Set()
|
|
42
|
+
this.#rejections = new Set()
|
|
43
|
+
this.#once = new Set()
|
|
44
|
+
this.#disposed = false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 是否已 `dispose()`(只读,便于审计/测试)。
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
get disposed() {
|
|
52
|
+
return this.#disposed
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 记下本次批准。
|
|
57
|
+
*
|
|
58
|
+
* `scope: 'task'`(默认)= 本任务内该类永久有效;`scope: 'once'` = 只放行一次,
|
|
59
|
+
* 由 `consumeOnce()` 消耗。**未识别的 scope 按 `'once'` 处理**(fail closed:宁可
|
|
60
|
+
* 只放行一次,也不让一个拼错的 scope 静默放大成任务级授权)。
|
|
61
|
+
*
|
|
62
|
+
* 已是任务级批准的 key 再记 `'once'` 不生效(任务级已覆盖,无需降级)。
|
|
63
|
+
*
|
|
64
|
+
* @param {string} key 非空字符串(来自 `review-engine.memoryKey`),否则抛 `TypeError`
|
|
65
|
+
* @param {{ scope?: 'task' | 'once' }} [opts]
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
noteApprove(key, { scope = 'task' } = {}) {
|
|
69
|
+
const k = requireKey(key, 'noteApprove')
|
|
70
|
+
if (this.#disposed) return
|
|
71
|
+
if (scope === 'task') {
|
|
72
|
+
this.#approvals.add(k)
|
|
73
|
+
this.#once.delete(k)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (!this.#approvals.has(k)) this.#once.add(k)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 记下本次拒绝。同一 key 在本任务内不再询问(规则引擎会直接拒绝)。
|
|
81
|
+
* 拒绝会作废该 key 待用的一次性批准;已记录的任务级批准保留在快照里,
|
|
82
|
+
* 但查询时被拒绝覆盖(`lookup` 恒返回 `'rejected'`)。
|
|
83
|
+
*
|
|
84
|
+
* @param {string} key 非空字符串,否则抛 `TypeError`
|
|
85
|
+
* @returns {void}
|
|
86
|
+
*/
|
|
87
|
+
noteReject(key) {
|
|
88
|
+
const k = requireKey(key, 'noteReject')
|
|
89
|
+
if (this.#disposed) return
|
|
90
|
+
this.#rejections.add(k)
|
|
91
|
+
this.#once.delete(k)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 查询 key 的记忆状态。查询路径**永不抛错**(决策路径不能因为坏输入挂住审批)。
|
|
96
|
+
*
|
|
97
|
+
* @param {unknown} key
|
|
98
|
+
* @returns {'allowed' | 'rejected' | 'unknown'} 已 `dispose()` 或 key 非法时恒为 `'unknown'`
|
|
99
|
+
*/
|
|
100
|
+
lookup(key) {
|
|
101
|
+
if (this.#disposed) return 'unknown'
|
|
102
|
+
if (typeof key !== 'string' || key === '') return 'unknown'
|
|
103
|
+
if (this.#rejections.has(key)) return 'rejected'
|
|
104
|
+
if (this.#approvals.has(key)) return 'allowed'
|
|
105
|
+
if (this.#once.has(key)) return 'allowed'
|
|
106
|
+
return 'unknown'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 消耗一次 `'once'` 批准;返回 `true` 表示这次放行已用掉。
|
|
111
|
+
*
|
|
112
|
+
* `'task'` 作用域的批准不会被消耗(返回 `false`,`lookup` 仍为 `'allowed'`)。
|
|
113
|
+
* 若该 key 同时被拒绝,批准作废且返回 `false`(拒绝优先)。
|
|
114
|
+
*
|
|
115
|
+
* @param {unknown} key
|
|
116
|
+
* @returns {boolean} key 非法、已 `dispose()`、无 `'once'` 批准、或被拒绝时为 `false`
|
|
117
|
+
*/
|
|
118
|
+
consumeOnce(key) {
|
|
119
|
+
if (this.#disposed) return false
|
|
120
|
+
if (typeof key !== 'string' || key === '') return false
|
|
121
|
+
if (!this.#once.has(key)) return false
|
|
122
|
+
this.#once.delete(key)
|
|
123
|
+
return !this.#rejections.has(key)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 快照(每次返回新对象与新数组,调用方改动不影响内部状态)。
|
|
128
|
+
* @returns {{ taskId: string, approvals: string[], rejections: string[], once: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
snapshot() {
|
|
131
|
+
return {
|
|
132
|
+
taskId: this.#taskId,
|
|
133
|
+
approvals: [...this.#approvals],
|
|
134
|
+
rejections: [...this.#rejections],
|
|
135
|
+
once: [...this.#once],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 清空并标记失效。之后 `lookup()` 恒 `'unknown'`,`noteApprove` / `noteReject` 为 no-op,
|
|
141
|
+
* `consumeOnce()` 恒 `false`。幂等。
|
|
142
|
+
* @returns {void}
|
|
143
|
+
*/
|
|
144
|
+
dispose() {
|
|
145
|
+
this.#approvals.clear()
|
|
146
|
+
this.#rejections.clear()
|
|
147
|
+
this.#once.clear()
|
|
148
|
+
this.#disposed = true
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 校验记忆键(只用于**写入**路径;查询路径不抛错)。
|
|
154
|
+
* @param {unknown} key
|
|
155
|
+
* @param {string} method
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function requireKey(key, method) {
|
|
159
|
+
if (typeof key !== 'string' || key === '') {
|
|
160
|
+
throw new TypeError(`TaskMemory.${method}(): key must be a non-empty string`)
|
|
161
|
+
}
|
|
162
|
+
return key
|
|
163
|
+
}
|