dsh-crate-web 0.1.1 → 0.1.2-preview.3
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/README.md +12 -289
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +468 -217
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +343 -194
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +44 -7
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/** Owned, zero-model Desktop Verify controller. Only stdio protocol and local CDP. */
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { createInterface } from 'node:readline'
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
6
|
+
import { open, mkdir, readdir, lstat, readFile, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { createServer } from 'node:net'
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
12
|
+
const inside = (root, path) => { const part = relative(resolve(root), resolve(path)); return part === '' || (!part.startsWith('..') && !isAbsolute(part)) }
|
|
13
|
+
const send = value => process.stdout.write(JSON.stringify(value) + '\n')
|
|
14
|
+
|
|
15
|
+
export async function copyRegularTree(source, target, limit = { files: 0, bytes: 0 }) {
|
|
16
|
+
const info = await lstat(source)
|
|
17
|
+
if (info.isSymbolicLink()) throw new Error('Verify refuses reparse links in the source Profile')
|
|
18
|
+
if (info.isDirectory()) {
|
|
19
|
+
await mkdir(target, { recursive: false })
|
|
20
|
+
for (const item of await readdir(source)) await copyRegularTree(join(source, item), join(target, item), limit)
|
|
21
|
+
} else if (info.isFile()) {
|
|
22
|
+
limit.files += 1; limit.bytes += info.size
|
|
23
|
+
if (limit.files > 15000 || limit.bytes > 1024 ** 3) throw new Error('Verify Profile copy exceeds its bounded file/byte limit')
|
|
24
|
+
await writeFile(target, await readFile(source), { flag: 'wx', mode: 0o600 })
|
|
25
|
+
} else throw new Error('Verify Profile contains an unsupported filesystem entry')
|
|
26
|
+
return limit
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isolatedEnvironment(source, job, home) {
|
|
30
|
+
const result = {}
|
|
31
|
+
for (const [name, value] of Object.entries(source)) {
|
|
32
|
+
if (/secret|token|password|credential|authorization|api.?key|cookie|^auth$|private/i.test(name) || /^(DSH_|NODE_|ELECTRON_|CRATE_TEST_|PYTHONPATH$|PYTHONHOME$|PYTHONUSERBASE$)/i.test(name)) continue
|
|
33
|
+
if (typeof value === 'string' && !/:\/\/[^/@\s]+:[^/@\s]+@/.test(value)) result[name] = value
|
|
34
|
+
}
|
|
35
|
+
Object.assign(result, { DSH_HOME: home, HOME: join(job, 'user'), USERPROFILE: join(job, 'user'),
|
|
36
|
+
APPDATA: join(job, 'roaming'), LOCALAPPDATA: join(job, 'local'), TEMP: join(job, 'tmp'), TMP: join(job, 'tmp'),
|
|
37
|
+
XDG_CACHE_HOME: join(job, 'cache'), XDG_CONFIG_HOME: join(job, 'config'), XDG_DATA_HOME: join(job, 'data'),
|
|
38
|
+
NARB_NATIVE_CACHE_DIR: join(job, 'native-cache'), npm_config_cache: join(job, 'npm-cache'),
|
|
39
|
+
npm_config_userconfig: join(job, 'npmrc'), npm_config_fetch_retries: '0', npm_config_registry: 'http://127.0.0.1:9',
|
|
40
|
+
PNPM_CONFIG_STORE_DIR: join(job, 'pnpm-store'), PNPM_CONFIG_CACHE_DIR: join(job, 'pnpm-cache'),
|
|
41
|
+
PNPM_CONFIG_STATE_DIR: join(job, 'pnpm-state'), PNPM_CONFIG_CONFIG_DIR: join(job, 'pnpm-config'),
|
|
42
|
+
PNPM_CONFIG_OFFLINE: 'true', PNPM_CONFIG_FETCH_RETRIES: '0', PYTHONDONTWRITEBYTECODE: '1' })
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function freePort() {
|
|
47
|
+
const server = createServer()
|
|
48
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
|
49
|
+
const port = server.address().port
|
|
50
|
+
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
51
|
+
return port
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateSurface(value, home, webPort) {
|
|
55
|
+
const officialDesktop = value.protocol === 'dsh-app:' && value.hostname === 'app'
|
|
56
|
+
const localWeb = value.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(value.hostname)
|
|
57
|
+
if ((!officialDesktop && !localWeb) || !value.accountMenu || value.httpStatus !== 200 || value.backendHttpStatus !== 200 || value.backendStatus !== 'ok') {
|
|
58
|
+
throw new Error('Verification workspace/authenticated Web ready failed; see surface.observation')
|
|
59
|
+
}
|
|
60
|
+
if (typeof value.runtimeHome !== 'string' || resolve(value.runtimeHome).toLowerCase() !== resolve(home).toLowerCase() || value.runtimeProfile !== 'desktop' || !Number.isInteger(value.runtimePid) || value.runtimePid <= 0) {
|
|
61
|
+
throw new Error('Verification backend identity does not match its isolated desktop')
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isInteger(webPort) || webPort < 1 || webPort > 65535) throw new Error('Owned backend Web port missing from startup log')
|
|
64
|
+
return { ...value, webPort, authenticationContext: 'official owned desktop protocol and authenticated profiles route; credentials not exported' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class Cdp {
|
|
68
|
+
constructor(url) { this.url = url; this.pending = new Map(); this.sequence = 0 }
|
|
69
|
+
async connect() {
|
|
70
|
+
const parsed = new URL(this.url)
|
|
71
|
+
if (parsed.protocol !== 'ws:' || parsed.hostname !== '127.0.0.1') throw new Error('CDP is restricted to the owned local endpoint')
|
|
72
|
+
this.socket = new WebSocket(this.url)
|
|
73
|
+
this.socket.addEventListener('message', event => {
|
|
74
|
+
const data = JSON.parse(event.data)
|
|
75
|
+
if (!this.pending.has(data.id)) return
|
|
76
|
+
const pending = this.pending.get(data.id); this.pending.delete(data.id); clearTimeout(pending.timer)
|
|
77
|
+
data.error ? pending.reject(new Error(data.error.message)) : pending.resolve(data.result)
|
|
78
|
+
})
|
|
79
|
+
this.socket.addEventListener('close', () => {
|
|
80
|
+
for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error('CDP connection closed')) }
|
|
81
|
+
this.pending.clear()
|
|
82
|
+
})
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(() => reject(new Error('local CDP connection deadline')), 10000)
|
|
85
|
+
this.socket.addEventListener('open', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
86
|
+
this.socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('local CDP connection failed')) }, { once: true })
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
call(method, params = {}, sessionId) {
|
|
90
|
+
const id = ++this.sequence
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP command deadline: ' + method)) }, 10000)
|
|
93
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
94
|
+
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }))
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
async evaluate(expression) {
|
|
98
|
+
const response = await this.call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, this.pageSession)
|
|
99
|
+
if (response.exceptionDetails) throw new Error('owned page evaluation failed')
|
|
100
|
+
return response.result.value
|
|
101
|
+
}
|
|
102
|
+
disconnect() { if (this.socket && this.socket.readyState < 2) this.socket.close() }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class DesktopController {
|
|
106
|
+
constructor(config) { this.config = config; this.generation = 0; this.child = null; this.originalHashes = {} }
|
|
107
|
+
async event(name, values = {}) {
|
|
108
|
+
await writeFile(join(this.config.job, 'controller-events.jsonl'), JSON.stringify({ atUtc: new Date().toISOString(), event: name, generation: this.generation, ...values }) + '\n', { flag: 'a', mode: 0o600 })
|
|
109
|
+
}
|
|
110
|
+
async prepare() {
|
|
111
|
+
const { job, sourceHome, sourceProfile, runtimeRoot } = this.config
|
|
112
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sourceProfile)) throw new Error('invalid Profile name')
|
|
113
|
+
if (!inside(join(sourceHome, '.dsh-pack', 'verify-jobs'), job)) throw new Error('Verify job escaped its isolated workspace')
|
|
114
|
+
this.home = join(job, 'home'); this.documents = join(job, 'documents')
|
|
115
|
+
for (const name of ['home', 'home/profiles', 'documents', 'electron', 'user', 'roaming', 'local', 'tmp', 'cache', 'config', 'data', 'native-cache', 'npm-cache', 'pnpm-store', 'pnpm-cache', 'pnpm-state', 'pnpm-config']) await mkdir(join(job, name), { recursive: true })
|
|
116
|
+
await writeFile(join(job, 'npmrc'), '', { flag: 'wx' })
|
|
117
|
+
this.profile = join(this.home, 'profiles', 'desktop')
|
|
118
|
+
const source = join(sourceHome, 'profiles', sourceProfile)
|
|
119
|
+
if ((await lstat(source)).isSymbolicLink()) throw new Error('source Profile root is a reparse link')
|
|
120
|
+
await mkdir(this.profile)
|
|
121
|
+
let copied = { files: 0, bytes: 0 }
|
|
122
|
+
for (const name of ['package.json', 'pnpm-lock.yaml', 'node_modules']) {
|
|
123
|
+
let data
|
|
124
|
+
try { data = await lstat(join(source, name)) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
|
|
125
|
+
copied = await copyRegularTree(join(source, name), join(this.profile, name), copied)
|
|
126
|
+
if (data.isFile()) this.originalHashes[name] = createHash('sha256').update(await readFile(join(source, name))).digest('hex')
|
|
127
|
+
}
|
|
128
|
+
const require = createRequire(import.meta.url)
|
|
129
|
+
const yaml = require(join(runtimeRoot, 'node_modules', 'js-yaml'))
|
|
130
|
+
const patchPath = join(this.profile, 'cordis.patch.yml')
|
|
131
|
+
// Parse configuration before writing: credentials in config are never copied verbatim.
|
|
132
|
+
const rows = yaml.load(await readFile(join(source, 'cordis.patch.yml'), 'utf8')) ?? []
|
|
133
|
+
if (!Array.isArray(rows)) throw new Error('Profile patch must be a patch-list array')
|
|
134
|
+
const workspace = rows.find(row => row?.id === 'workspace-controller')
|
|
135
|
+
const oldDocuments = workspace?.config?.documentsDirectory
|
|
136
|
+
const rewrite = value => {
|
|
137
|
+
if (typeof value === 'string') {
|
|
138
|
+
let result = value.split(sourceHome).join(this.home)
|
|
139
|
+
if (typeof oldDocuments === 'string' && oldDocuments) result = result.split(oldDocuments).join(this.documents)
|
|
140
|
+
if (isAbsolute(result) && ![job, dirname(this.config.desktopExecutable), process.env.SystemRoot ?? 'C:\\Windows'].some(root => inside(root, result))) throw new Error('Profile has an absolute path outside Verify isolation')
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(value)) return value.map(rewrite)
|
|
144
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
|
|
145
|
+
/^(api.?key|token|password|secret|authorization|cookie|credential|private.?key)$/i.test(key) ? 'CRATE_VERIFY_REDACTED' : rewrite(item)]))
|
|
146
|
+
return value
|
|
147
|
+
}
|
|
148
|
+
let rewritten = rewrite(rows)
|
|
149
|
+
const put = (id, change) => { let row = rewritten.find(item => item?.id === id); if (!row) { row = { id }; rewritten.push(row) } change(row) }
|
|
150
|
+
put('workspace-controller', row => { row.config = { ...row.config, documentsDirectory: this.documents } })
|
|
151
|
+
for (const id of ['session-title-llm', 'session-telemetry-otel', 'desktop-product-telemetry', 'product-analytics', 'llm-deepseek']) put(id, row => { row.disabled = true })
|
|
152
|
+
const providerRow = rewritten.find(row => row?.id === 'llm-pi-ai')
|
|
153
|
+
if (providerRow) {
|
|
154
|
+
const row = providerRow
|
|
155
|
+
for (const provider of Object.values(row.config?.providers ?? {})) if (provider && typeof provider === 'object') {
|
|
156
|
+
provider.baseURL = 'http://127.0.0.1:9/v1'; provider.apiKey = 'CRATE_VERIFY_NO_MODEL_REQUESTS'
|
|
157
|
+
provider.retryPolicy = { ...provider.retryPolicy, maxRetries: 0 }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.inspectorPort = await freePort()
|
|
161
|
+
put('experimental-inspector', row => { row.config = { ...row.config, port: this.inspectorPort } })
|
|
162
|
+
await writeFile(patchPath, yaml.dump(rewritten, { noRefs: true, lineWidth: 4096 }))
|
|
163
|
+
const rootRows = yaml.load(await readFile(join(source, 'cordis.yml'), 'utf8')) ?? []
|
|
164
|
+
if (!Array.isArray(rootRows) || rootRows.length) throw new Error('Nonempty Profile root requires an explicit runner; built-in Verify will not guess its isolation')
|
|
165
|
+
await writeFile(join(this.profile, 'cordis.yml'), '[]\n')
|
|
166
|
+
for (const name of ['cordis.patch.yml', 'cordis.yml']) this.originalHashes[name] = createHash('sha256').update(await readFile(join(source, name))).digest('hex')
|
|
167
|
+
const homePatch = join(sourceHome, 'cordis.patch.yml')
|
|
168
|
+
try {
|
|
169
|
+
if ((await lstat(homePatch)).isSymbolicLink()) throw new Error('Home patch reparse link refused')
|
|
170
|
+
const data = yaml.load(await readFile(homePatch, 'utf8'))
|
|
171
|
+
if (Array.isArray(data) ? data.length : data && Object.keys(data).length) throw new Error('Nonempty Home overlay requires an explicit runner; built-in Verify will not guess its isolation')
|
|
172
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
173
|
+
const preparation = { sourceProfile, runtimeProfile: 'desktop', copied, sourceHashes: this.originalHashes,
|
|
174
|
+
credentialsFilesCopied: false, modelRequests: 0, requiredPluginsRemoved: false, home: this.home, documents: this.documents }
|
|
175
|
+
await writeFile(join(job, 'preparation.json'), JSON.stringify(preparation, null, 2))
|
|
176
|
+
this.prepared = true
|
|
177
|
+
return preparation
|
|
178
|
+
}
|
|
179
|
+
async boot() {
|
|
180
|
+
if (this.generation >= this.config.maximumBoots) throw new Error('Verify desktop boot limit reached')
|
|
181
|
+
if (!this.prepared) await this.prepare()
|
|
182
|
+
this.generation += 1; this.cdpPort = await freePort()
|
|
183
|
+
const env = isolatedEnvironment(process.env, this.config.job, this.home)
|
|
184
|
+
if (this.config.observe) {
|
|
185
|
+
this.trace = join(this.config.job, 'trace-' + this.generation)
|
|
186
|
+
await mkdir(this.trace)
|
|
187
|
+
Object.assign(env, { CRATE_TEST_ROOT: this.config.job, CRATE_TEST_OBSERVATION_DIR: this.trace, CRATE_TEST_RUN_ID: randomUUID() })
|
|
188
|
+
}
|
|
189
|
+
const stdout = await open(join(this.config.job, `desktop-${this.generation}.stdout.log`), 'wx')
|
|
190
|
+
const stderr = await open(join(this.config.job, `desktop-${this.generation}.stderr.log`), 'wx')
|
|
191
|
+
try {
|
|
192
|
+
this.child = spawn(this.config.desktopExecutable, [`--user-data-dir=${join(this.config.job, 'electron')}`,
|
|
193
|
+
'--remote-debugging-address=127.0.0.1', `--remote-debugging-port=${this.cdpPort}`], {
|
|
194
|
+
cwd: this.config.job, env, windowsHide: true, stdio: ['ignore', stdout.fd, stderr.fd] })
|
|
195
|
+
} finally { await stdout.close(); await stderr.close() }
|
|
196
|
+
this.childError = null
|
|
197
|
+
this.child.once('error', error => { this.childError = error })
|
|
198
|
+
const state = { pid: this.child.pid, cdpPort: this.cdpPort, inspectorPort: this.inspectorPort, trace: this.trace,
|
|
199
|
+
home: this.home, job: this.config.job, generation: this.generation }
|
|
200
|
+
await writeFile(join(this.config.job, 'state.json'), JSON.stringify(state, null, 2))
|
|
201
|
+
send({ event: 'desktop.spawned', ...state })
|
|
202
|
+
await this.event('desktop.spawned', state)
|
|
203
|
+
const deadline = Date.now() + 45000
|
|
204
|
+
let version
|
|
205
|
+
while (Date.now() < deadline) {
|
|
206
|
+
if (this.childError) throw new Error('Verification desktop spawn failed: ' + this.childError.code)
|
|
207
|
+
if (this.child.exitCode !== null) throw new Error('Verification desktop exited before ready: ' + this.child.exitCode)
|
|
208
|
+
try {
|
|
209
|
+
const response = await fetch(`http://127.0.0.1:${this.cdpPort}/json/version`, { signal: AbortSignal.timeout(1500) })
|
|
210
|
+
if (response.ok) { version = await response.json(); break }
|
|
211
|
+
} catch { /* Passive startup wait; no launch retry. */ }
|
|
212
|
+
await sleep(200)
|
|
213
|
+
}
|
|
214
|
+
if (!version) throw new Error('Verification desktop CDP ready deadline')
|
|
215
|
+
this.browser = new Cdp(version.webSocketDebuggerUrl); await this.browser.connect()
|
|
216
|
+
let target
|
|
217
|
+
while (!target && Date.now() < deadline) {
|
|
218
|
+
const targets = await this.browser.call('Target.getTargets')
|
|
219
|
+
target = targets.targetInfos.find(item => item.type === 'page' && !item.url.startsWith('devtools:'))
|
|
220
|
+
if (!target) await sleep(150)
|
|
221
|
+
}
|
|
222
|
+
if (!target) throw new Error('Owned desktop page missing')
|
|
223
|
+
this.browser.pageSession = (await this.browser.call('Target.attachToTarget', { targetId: target.targetId, flatten: true })).sessionId
|
|
224
|
+
let openedKey = false
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
const ready = await this.browser.evaluate(`(() => {
|
|
227
|
+
const labels = element => (element.getAttribute('aria-label') || element.innerText || '').trim();
|
|
228
|
+
const buttons = [...document.querySelectorAll('button')];
|
|
229
|
+
if (buttons.some(button => /^(Account menu|账户菜单|账号菜单)$/i.test(labels(button)))) return 'ready';
|
|
230
|
+
const skip = document.querySelector('#skip-key'); if (skip) { skip.click(); return 'skip'; }
|
|
231
|
+
${openedKey ? '' : "const add = buttons.find(button => /^(Add API Key|添加 API Key)$/i.test(labels(button))); if (add) { add.click(); return 'opened-key'; }"}
|
|
232
|
+
return 'waiting';
|
|
233
|
+
})()`)
|
|
234
|
+
if (ready === 'ready') break
|
|
235
|
+
if (ready === 'opened-key') openedKey = true
|
|
236
|
+
await sleep(150)
|
|
237
|
+
}
|
|
238
|
+
const surface = await this.surface()
|
|
239
|
+
await this.event('desktop.ready', { ...state, surface })
|
|
240
|
+
return { ...state, surface }
|
|
241
|
+
}
|
|
242
|
+
async surface() {
|
|
243
|
+
const value = await this.browser.evaluate(`(async () => {
|
|
244
|
+
const accountMenu = [...document.querySelectorAll('button')].some(button => /^(Account menu|账户菜单|账号菜单)$/i.test((button.getAttribute('aria-label') || button.innerText || '').trim()));
|
|
245
|
+
const response = await fetch('/', { credentials: 'same-origin', signal: AbortSignal.timeout(7000) });
|
|
246
|
+
const backend = await fetch('/dsh-crate/api', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'profiles' }), signal: AbortSignal.timeout(7000) });
|
|
247
|
+
let result; try { result = await backend.json() } catch { result = {} }
|
|
248
|
+
return { accountMenu, httpStatus: response.status, origin: location.origin, protocol: location.protocol, hostname: location.hostname,
|
|
249
|
+
backendHttpStatus: backend.status, backendStatus: result.status, runtimePid: result.runtime?.pid,
|
|
250
|
+
runtimeHome: result.runtime?.dshHome, runtimeProfile: result.runtime?.currentProfile };
|
|
251
|
+
})()`)
|
|
252
|
+
const log = join(this.config.job, `desktop-${this.generation}.stdout.log`)
|
|
253
|
+
if ((await lstat(log)).size > 1024 * 1024) throw new Error('Owned desktop startup log exceeded observation limit')
|
|
254
|
+
const ports = [...(await readFile(log, 'utf8')).matchAll(/dsh web: http:\/\/127\.0\.0\.1:(\d+)\//g)]
|
|
255
|
+
const webPort = ports.length ? Number(ports.at(-1)[1]) : undefined
|
|
256
|
+
await this.event('surface.observation', { ...value, webPort })
|
|
257
|
+
return validateSurface(value, this.home, webPort)
|
|
258
|
+
}
|
|
259
|
+
async newSession() {
|
|
260
|
+
const selected = () => this.browser.evaluate(`([...document.querySelectorAll('[data-row-key^="session:"][aria-selected="true"]')].map(element => element.getAttribute('data-row-key').slice(8)))`)
|
|
261
|
+
const selectedBefore = await selected()
|
|
262
|
+
const clicked = await this.browser.evaluate(`(() => {
|
|
263
|
+
const button = [...document.querySelectorAll('button')].find(element => /^New session$/i.test((element.getAttribute('aria-label') || element.innerText || '').trim()) && !element.disabled && element.getClientRects().length);
|
|
264
|
+
if (!button) return false; button.click(); return true;
|
|
265
|
+
})()`)
|
|
266
|
+
if (!clicked) throw new Error('Official New session button unavailable')
|
|
267
|
+
await this.event('new-session.clicked')
|
|
268
|
+
let selectedAfter = []
|
|
269
|
+
const deadline = Date.now() + 10000
|
|
270
|
+
while (Date.now() < deadline) {
|
|
271
|
+
selectedAfter = await selected()
|
|
272
|
+
if (selectedAfter.length === 1) break
|
|
273
|
+
await sleep(100)
|
|
274
|
+
}
|
|
275
|
+
if (selectedAfter.length !== 1) throw new Error('New session did not select exactly one session')
|
|
276
|
+
return { officialButtonClicks: 1, selectedBefore, selectedAfter }
|
|
277
|
+
}
|
|
278
|
+
async close() {
|
|
279
|
+
if (!this.child) return { requested: false, exitCode: null }
|
|
280
|
+
let requested = false
|
|
281
|
+
if (this.child.exitCode === null && this.browser) {
|
|
282
|
+
requested = true
|
|
283
|
+
try { await this.browser.call('Browser.close') } catch { /* Browser may close its socket before acknowledging. */ }
|
|
284
|
+
}
|
|
285
|
+
const deadline = Date.now() + 20000
|
|
286
|
+
while (this.child.exitCode === null && Date.now() < deadline) await sleep(100)
|
|
287
|
+
this.browser?.disconnect()
|
|
288
|
+
const value = { requested, pid: this.child.pid, exitCode: this.child.exitCode, generation: this.generation }
|
|
289
|
+
await this.event('desktop.closed', value)
|
|
290
|
+
if (value.exitCode === null) throw new Error('Owned desktop did not exit after close request')
|
|
291
|
+
return value
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
296
|
+
const config = JSON.parse(await readFile(process.argv[2], 'utf8'))
|
|
297
|
+
if (config.modelRequests !== 0 || config.automaticRetries !== 0) throw new Error('Built-in desktop verifier only accepts zero-model, no-retry scope')
|
|
298
|
+
const controller = new DesktopController(config)
|
|
299
|
+
try {
|
|
300
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
301
|
+
let response
|
|
302
|
+
try {
|
|
303
|
+
const request = JSON.parse(line)
|
|
304
|
+
const value = request.operation === 'boot' ? await controller.boot()
|
|
305
|
+
: request.operation === 'surface' ? await controller.surface()
|
|
306
|
+
: request.operation === 'new_session' ? await controller.newSession()
|
|
307
|
+
: request.operation === 'close' ? await controller.close()
|
|
308
|
+
: (() => { throw new Error('Unsupported desktop controller operation') })()
|
|
309
|
+
response = { value }
|
|
310
|
+
} catch (error) { response = { error: { type: error.name, message: error.message } } }
|
|
311
|
+
send(response)
|
|
312
|
+
}
|
|
313
|
+
} finally { await controller.close().catch(() => undefined) }
|
|
314
|
+
}
|