dsh-sidebar-browser-agent 0.0.0-stage → 0.2.6
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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +226 -2
- package/cordis.patch.yml +8 -0
- package/icon.svg +8 -0
- package/lib/client.js +1651 -0
- package/lib/entry-0.2.6.js +1181 -0
- package/lib/index.js +1181 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
- package/test/client-harness.mjs +687 -0
- package/test/host-harness.mjs +548 -0
- package/test/run.mjs +22 -0
- package/vendor/NOTICE.md +15 -0
- package/vendor/cosmokit/LICENSE +21 -0
- package/vendor/cosmokit/index.js +418 -0
- package/vendor/schemastery/LICENSE +21 -0
- package/vendor/schemastery/index.mjs +635 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-half regression suite.
|
|
3
|
+
*
|
|
4
|
+
* Exercises, with no third-party dependencies:
|
|
5
|
+
* - the native Schemastery Config contract (defaults, validation, markers);
|
|
6
|
+
* - configuration switches (enabled, allowedHosts, allowEval, allowNativeInput);
|
|
7
|
+
* - tool registration, output schemas, and the real HTTP bridge handlers;
|
|
8
|
+
* - screenshot attachment conversion and its rendered image block;
|
|
9
|
+
* - version consistency between package.json, both halves, and diagnostics.
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { apply, Config, PLUGIN_VERSION } from '../lib/index.js'
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
|
|
19
|
+
const clientSource = fs.readFileSync(path.join(here, '..', 'lib', 'client.js'), 'utf8')
|
|
20
|
+
|
|
21
|
+
let failures = 0
|
|
22
|
+
function check(name, condition, detail) {
|
|
23
|
+
if (condition) console.log(`PASS ${name}`)
|
|
24
|
+
else {
|
|
25
|
+
failures += 1
|
|
26
|
+
console.log(`FAIL ${name}${detail === undefined ? '' : ` :: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- schema checker: mirrors the supported raw-JSON-Schema subset the registry enforces ---
|
|
31
|
+
|
|
32
|
+
function assertSupportedJsonSchema(schema) {
|
|
33
|
+
const TYPES = ['string', 'number', 'integer', 'boolean', 'null', 'array', 'object']
|
|
34
|
+
const ALLOWED = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'description'])
|
|
35
|
+
function walk(node, at) {
|
|
36
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) throw new Error(`${at} must be an object`)
|
|
37
|
+
if (node.required === true) throw new Error(`${at}.required is not supported as a property flag`)
|
|
38
|
+
if (node.required !== undefined && !Array.isArray(node.required)) throw new Error(`${at}.required must be an array`)
|
|
39
|
+
for (const key of Object.keys(node)) if (!ALLOWED.has(key)) throw new Error(`${at}.${key} is not supported`)
|
|
40
|
+
if (node.type !== undefined && !TYPES.includes(node.type)) throw new Error(`${at}.type ${node.type} is not supported`)
|
|
41
|
+
if (node.type === 'object') {
|
|
42
|
+
if (typeof node.additionalProperties !== 'boolean') throw new Error(`${at}.additionalProperties must be explicit`)
|
|
43
|
+
for (const [name, child] of Object.entries(node.properties ?? {})) walk(child, `${at}.properties.${name}`)
|
|
44
|
+
for (const name of node.required ?? []) if (node.properties?.[name] === undefined) throw new Error(`${at}.required names unknown ${name}`)
|
|
45
|
+
}
|
|
46
|
+
if (node.type === 'array' && node.items !== undefined) walk(node.items, `${at}.items`)
|
|
47
|
+
}
|
|
48
|
+
walk(schema, 'schema')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateJsonSchemaValue(schema, value, at) {
|
|
52
|
+
const violations = []
|
|
53
|
+
function walk(node, candidate, where) {
|
|
54
|
+
if (node.type === 'object') {
|
|
55
|
+
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
56
|
+
violations.push(`${where} must be an object`)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
for (const name of node.required ?? []) if (!(name in candidate)) violations.push(`${where}.${name} is required`)
|
|
60
|
+
for (const [name, child] of Object.entries(candidate)) {
|
|
61
|
+
const childSchema = node.properties?.[name]
|
|
62
|
+
if (childSchema === undefined) {
|
|
63
|
+
if (node.additionalProperties === false) violations.push(`${where}.${name} is not allowed`)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
walk(childSchema, child, `${where}.${name}`)
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (node.type === 'array') {
|
|
71
|
+
if (!Array.isArray(candidate)) {
|
|
72
|
+
violations.push(`${where} must be an array`)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (node.items !== undefined) candidate.forEach((item, index) => walk(node.items, item, `${where}[${index}]`))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (node.enum !== undefined && !node.enum.includes(candidate)) violations.push(`${where} is not one of the enum`)
|
|
79
|
+
if (node.type === 'string' && typeof candidate !== 'string') violations.push(`${where} must be a string`)
|
|
80
|
+
if (node.type === 'boolean' && typeof candidate !== 'boolean') violations.push(`${where} must be a boolean`)
|
|
81
|
+
if (node.type === 'integer' && !Number.isInteger(candidate)) violations.push(`${where} must be an integer`)
|
|
82
|
+
if (node.type === 'number' && typeof candidate !== 'number') violations.push(`${where} must be a number`)
|
|
83
|
+
}
|
|
84
|
+
walk(schema, value, at)
|
|
85
|
+
return violations
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- mocked plugin context ---
|
|
89
|
+
|
|
90
|
+
function makeHarness() {
|
|
91
|
+
const routes = new Map()
|
|
92
|
+
const tools = new Map()
|
|
93
|
+
const logs = { info: [], warn: [] }
|
|
94
|
+
const savedImages = []
|
|
95
|
+
const attachments = {
|
|
96
|
+
async saveImage({ data, mediaType, name }) {
|
|
97
|
+
savedImages.push({ bytes: data.length, mediaType, name })
|
|
98
|
+
return { attachmentId: `attachment-${savedImages.length}`, mediaType, bytes: data.length, width: 1024, height: 768, name }
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
const ctx = {
|
|
102
|
+
webServer: {
|
|
103
|
+
register(route) {
|
|
104
|
+
if (routes.has(route.path)) throw new Error(`duplicate route ${route.path}`)
|
|
105
|
+
routes.set(route.path, route)
|
|
106
|
+
return () => routes.delete(route.path)
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
connection: { requestRejection: () => undefined },
|
|
110
|
+
tools: {
|
|
111
|
+
register(definition, options) {
|
|
112
|
+
if (tools.has(definition.name)) throw new Error(`duplicate tool ${definition.name}`)
|
|
113
|
+
tools.set(definition.name, { definition, options })
|
|
114
|
+
return () => tools.delete(definition.name)
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
effect(callback) {
|
|
118
|
+
const disposer = callback()
|
|
119
|
+
return typeof disposer === 'function' ? disposer : () => {}
|
|
120
|
+
},
|
|
121
|
+
get(name) {
|
|
122
|
+
if (name === 'attachments') return attachments
|
|
123
|
+
if (name === 'webServer') return ctx.webServer
|
|
124
|
+
if (name === 'connection') return ctx.connection
|
|
125
|
+
if (name === 'tools') return ctx.tools
|
|
126
|
+
return undefined
|
|
127
|
+
},
|
|
128
|
+
logger: {
|
|
129
|
+
info: (...args) => logs.info.push(args.join(' ')),
|
|
130
|
+
warn: (...args) => logs.warn.push(args.join(' ')),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
return { routes, tools, logs, savedImages, attachments, ctx }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the config shape the Loader hands to `apply`: every field is a stable
|
|
138
|
+
* volatile reference whose `.get()` the plugin calls at use time.
|
|
139
|
+
*/
|
|
140
|
+
function liveConfigRefs(initial) {
|
|
141
|
+
const values = {
|
|
142
|
+
enabled: true,
|
|
143
|
+
allowEval: true,
|
|
144
|
+
allowNativeInput: true,
|
|
145
|
+
allowedHosts: [],
|
|
146
|
+
...initial,
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
values,
|
|
150
|
+
config: {
|
|
151
|
+
enabled: { get: () => values.enabled },
|
|
152
|
+
allowEval: { get: () => values.allowEval },
|
|
153
|
+
allowNativeInput: { get: () => values.allowNativeInput },
|
|
154
|
+
allowedHosts: { get: () => values.allowedHosts },
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Mount the plugin with live volatile config references. */
|
|
160
|
+
function start(initial) {
|
|
161
|
+
const harness = makeHarness()
|
|
162
|
+
const live = liveConfigRefs(initial)
|
|
163
|
+
apply(harness.ctx, live.config)
|
|
164
|
+
return { ...harness, live }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function makeExec(sessionId = 'session-1') {
|
|
168
|
+
return { agent: { session: { id: sessionId } }, signal: new AbortController().signal }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function runTool(harness, name, args, sessionId = 'session-1') {
|
|
172
|
+
const registration = harness.tools.get(name)
|
|
173
|
+
if (registration === undefined) throw new Error(`${name} is not registered`)
|
|
174
|
+
return registration.definition.execute(args, makeExec(sessionId))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- fake page over the real bridge handlers ---
|
|
178
|
+
|
|
179
|
+
function makeReq(method, url, body) {
|
|
180
|
+
const req = new EventEmitter()
|
|
181
|
+
req.method = method
|
|
182
|
+
req.url = url
|
|
183
|
+
if (body !== undefined) {
|
|
184
|
+
queueMicrotask(() => {
|
|
185
|
+
req.emit('data', Buffer.from(body))
|
|
186
|
+
req.emit('end')
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return req
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function makeRes() {
|
|
193
|
+
const res = new EventEmitter()
|
|
194
|
+
res.writableEnded = false
|
|
195
|
+
res.destroyed = false
|
|
196
|
+
let done
|
|
197
|
+
res.finished = new Promise((resolve) => {
|
|
198
|
+
done = resolve
|
|
199
|
+
})
|
|
200
|
+
res.writeHead = (status, headers) => {
|
|
201
|
+
res.status = status
|
|
202
|
+
res.headers = headers
|
|
203
|
+
}
|
|
204
|
+
res.end = (body) => {
|
|
205
|
+
res.writableEnded = true
|
|
206
|
+
res.body = body
|
|
207
|
+
done()
|
|
208
|
+
}
|
|
209
|
+
return res
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function routeHelpers(harness) {
|
|
213
|
+
async function callRoute(routePath, { method = 'GET', query = '', body } = {}) {
|
|
214
|
+
const route = harness.routes.get(routePath)
|
|
215
|
+
if (route === undefined) throw new Error(`route ${routePath} is not registered`)
|
|
216
|
+
const res = makeRes()
|
|
217
|
+
await route.handler(makeReq(method, `${routePath}${query}`, body), res)
|
|
218
|
+
await res.finished
|
|
219
|
+
return { status: res.status, json: res.body === undefined ? undefined : JSON.parse(res.body) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parkPoll(query = '?clientId=page-1&session=session-1') {
|
|
223
|
+
const route = harness.routes.get('/plugin/sidebar-browser-agent/poll')
|
|
224
|
+
const res = makeRes()
|
|
225
|
+
const pending = Promise.resolve(route.handler(makeReq('GET', `/plugin/sidebar-browser-agent/poll${query}`), res)).then(async () => {
|
|
226
|
+
await res.finished
|
|
227
|
+
return { status: res.status, json: JSON.parse(res.body) }
|
|
228
|
+
})
|
|
229
|
+
return pending
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { callRoute, parkPoll }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
236
|
+
|
|
237
|
+
/** One fake page turn: take the next parked poll and answer its commands. */
|
|
238
|
+
async function pageTurn(harness, handler) {
|
|
239
|
+
const { callRoute, parkPoll } = routeHelpers(harness)
|
|
240
|
+
const poll = parkPoll()
|
|
241
|
+
await sleep(20)
|
|
242
|
+
return poll.then(async (result) => {
|
|
243
|
+
for (const command of result.json.commands ?? []) {
|
|
244
|
+
await callRoute('/plugin/sidebar-browser-agent/result', {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
body: JSON.stringify({ id: command.id, ...handler(command) }),
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
return result
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fakePage(command) {
|
|
254
|
+
const base = { sessionId: command.sessionId, tabId: command.tabId ?? 'tab-1' }
|
|
255
|
+
switch (command.action) {
|
|
256
|
+
case 'open':
|
|
257
|
+
return { ok: true, value: { action: 'open', sessionId: command.sessionId, tabId: 'tab-2', url: command.url } }
|
|
258
|
+
case 'list':
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
value: {
|
|
262
|
+
action: 'list',
|
|
263
|
+
sessionId: command.sessionId,
|
|
264
|
+
tabs: [{ tabId: 'tab-2', kind: 'browser', url: 'https://example.com/', title: 'Example Domain', active: true }],
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
case 'read':
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
value: {
|
|
271
|
+
action: 'read',
|
|
272
|
+
...base,
|
|
273
|
+
mode: command.mode ?? 'text',
|
|
274
|
+
url: 'https://example.com/',
|
|
275
|
+
title: 'Example Domain',
|
|
276
|
+
...(command.mode === 'interactive'
|
|
277
|
+
? { elements: [{ index: 0, tag: 'a', label: 'More information', href: 'https://iana.org/', selector: 'a', x: 100, y: 200 }] }
|
|
278
|
+
: command.mode === 'html'
|
|
279
|
+
? { html: '<html><body>hi</body></html>', totalChars: 30, truncated: false }
|
|
280
|
+
: { text: 'Example Domain\nThis domain is for use in illustrative examples.', totalChars: 60, truncated: false }),
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
case 'click':
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
value: {
|
|
287
|
+
action: 'click',
|
|
288
|
+
...base,
|
|
289
|
+
url: 'https://example.com/',
|
|
290
|
+
title: 'Example Domain',
|
|
291
|
+
target: { tag: 'a', label: 'More information', x: 100, y: 200 },
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
case 'type':
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
value: {
|
|
298
|
+
action: 'type',
|
|
299
|
+
...base,
|
|
300
|
+
url: 'https://example.com/',
|
|
301
|
+
title: 'Example Domain',
|
|
302
|
+
target: { tag: 'input' },
|
|
303
|
+
typed: String(command.text ?? '').length,
|
|
304
|
+
submitted: command.submit === true,
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
case 'screenshot':
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
value: {
|
|
311
|
+
action: 'screenshot',
|
|
312
|
+
...base,
|
|
313
|
+
url: 'https://example.com/',
|
|
314
|
+
title: 'Example Domain',
|
|
315
|
+
width: 1024,
|
|
316
|
+
height: 768,
|
|
317
|
+
mediaType: 'image/png',
|
|
318
|
+
base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
case 'wait':
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
value: {
|
|
325
|
+
action: 'wait',
|
|
326
|
+
...base,
|
|
327
|
+
url: 'https://example.com/',
|
|
328
|
+
title: 'Example Domain',
|
|
329
|
+
matched: true,
|
|
330
|
+
waitedMs: 240,
|
|
331
|
+
...(command.selector === undefined ? {} : { selector: command.selector }),
|
|
332
|
+
...(command.contains === undefined ? {} : { contains: command.contains }),
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
case 'eval':
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
value: { action: 'eval', ...base, url: 'https://example.com/', title: 'Example Domain', result: '{"answer":42}', truncated: false },
|
|
339
|
+
}
|
|
340
|
+
default:
|
|
341
|
+
return { ok: false, error: `unexpected action ${command.action}` }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function expectThrow(run) {
|
|
346
|
+
try {
|
|
347
|
+
await run()
|
|
348
|
+
return undefined
|
|
349
|
+
} catch (error) {
|
|
350
|
+
return error.message
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function main() {
|
|
355
|
+
// --- Config contract: a native Schemastery schema whose fields are volatile ---
|
|
356
|
+
check('config is native schemastery', Config[Symbol.for('schemastery')] === true && typeof Config.type === 'string' && typeof Config.meta === 'object')
|
|
357
|
+
const declared = ['enabled', 'allowedHosts', 'allowEval', 'allowNativeInput']
|
|
358
|
+
check('config exposes the four switches', declared.every((key) => Config.dict[key] !== undefined), Object.keys(Config.dict ?? {}).join(','))
|
|
359
|
+
check('config fields are volatile', declared.every((key) => Config.dict[key].meta?.volatile === true), JSON.stringify(declared.map((key) => [key, Config.dict[key]?.meta?.volatile])))
|
|
360
|
+
check('config defaults', Config.dict.enabled.meta.default === true
|
|
361
|
+
&& Config.dict.allowEval.meta.default === true
|
|
362
|
+
&& Config.dict.allowNativeInput.meta.default === true
|
|
363
|
+
&& Array.isArray(Config.dict.allowedHosts.meta.default)
|
|
364
|
+
&& Config.dict.allowedHosts.meta.default.length === 0, JSON.stringify(declared.map((key) => Config.dict[key].meta.default)))
|
|
365
|
+
const invalid = Config['~standard'].validate({ enabled: 'nope' })
|
|
366
|
+
check('config rejects invalid values', Array.isArray(invalid.issues) && invalid.issues.length > 0, JSON.stringify(invalid))
|
|
367
|
+
const badHosts = Config['~standard'].validate({ allowedHosts: 'example.com' })
|
|
368
|
+
check('config rejects a non-array host list', Array.isArray(badHosts.issues) && badHosts.issues.length > 0, JSON.stringify(badHosts))
|
|
369
|
+
|
|
370
|
+
// --- version consistency and the versioned Host entry ---
|
|
371
|
+
check('package version matches host constant', packageJson.version === PLUGIN_VERSION, `${packageJson.version} vs ${PLUGIN_VERSION}`)
|
|
372
|
+
check('client version matches package', clientSource.includes(`PLUGIN_VERSION = '${packageJson.version}'`), packageJson.version)
|
|
373
|
+
const expectedEntry = `./lib/entry-${packageJson.version}.js`
|
|
374
|
+
check('package entry is versioned', packageJson.exports?.['.'] === expectedEntry, packageJson.exports?.['.'])
|
|
375
|
+
const entryFile = path.join(here, '..', 'lib', `entry-${packageJson.version}.js`)
|
|
376
|
+
const hostSource = fs.readFileSync(path.join(here, '..', 'lib', 'index.js'), 'utf8')
|
|
377
|
+
check('versioned entry exists', fs.existsSync(entryFile), entryFile)
|
|
378
|
+
check('versioned entry matches the host source', fs.existsSync(entryFile) && fs.readFileSync(entryFile, 'utf8') === hostSource)
|
|
379
|
+
check('versioned entry reports the current version', fs.existsSync(entryFile) && fs.readFileSync(entryFile, 'utf8').includes(`PLUGIN_VERSION = '${packageJson.version}'`))
|
|
380
|
+
|
|
381
|
+
// --- default mount ---
|
|
382
|
+
const harness = start()
|
|
383
|
+
check('routes registered', harness.routes.size === 3, [...harness.routes.keys()].join(','))
|
|
384
|
+
check('both tools registered', harness.tools.has('sidebar_browser') && harness.tools.has('sidebar_page'))
|
|
385
|
+
check('startup self-check logged ready', harness.logs.info.some((line) => line.includes('ready')) && harness.logs.warn.length === 0, JSON.stringify(harness.logs))
|
|
386
|
+
for (const name of ['sidebar_browser', 'sidebar_page']) {
|
|
387
|
+
let schemaError
|
|
388
|
+
try {
|
|
389
|
+
assertSupportedJsonSchema(harness.tools.get(name).definition.output.schema)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
schemaError = error.message
|
|
392
|
+
}
|
|
393
|
+
check(`${name} output schema supported`, schemaError === undefined, schemaError)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- status offline carries host diagnostics ---
|
|
397
|
+
const offline = await runTool(harness, 'sidebar_browser', { action: 'status' })
|
|
398
|
+
check('offline status is diagnostic', offline.pageConnected === false
|
|
399
|
+
&& offline.note.includes(`host-plugin=${PLUGIN_VERSION}`)
|
|
400
|
+
&& offline.note.includes('attachments=available')
|
|
401
|
+
&& offline.note.includes('"enabled":true'), offline.note)
|
|
402
|
+
|
|
403
|
+
// --- disabled mount: tools stay registered but their step condition hides them ---
|
|
404
|
+
const disabled = start({ enabled: false })
|
|
405
|
+
check('disabled config keeps both tools registered', disabled.tools.size === 2, [...disabled.tools.keys()].join(','))
|
|
406
|
+
check('disabled config hides tools from agents', disabled.tools.get('sidebar_browser').options.when() === false
|
|
407
|
+
&& disabled.tools.get('sidebar_page').options.when() === false, JSON.stringify(disabled.tools.get('sidebar_browser').options))
|
|
408
|
+
check('disabled config keeps bridge routes', disabled.routes.size === 3)
|
|
409
|
+
check('disabled config logs ready', disabled.logs.info.some((line) => line.includes('enabled=false')), JSON.stringify(disabled.logs))
|
|
410
|
+
|
|
411
|
+
// --- editing a volatile field applies live, without a restart ---
|
|
412
|
+
const liveHarness = start()
|
|
413
|
+
check('enabled tool visible by default', liveHarness.tools.get('sidebar_browser').options.when() === true)
|
|
414
|
+
liveHarness.live.values.enabled = false
|
|
415
|
+
check('enabled switch hides tools live', liveHarness.tools.get('sidebar_browser').options.when() === false)
|
|
416
|
+
liveHarness.live.values.enabled = true
|
|
417
|
+
check('enabled switch restores tools live', liveHarness.tools.get('sidebar_browser').options.when() === true)
|
|
418
|
+
liveHarness.live.values.allowEval = false
|
|
419
|
+
const liveEvalBlocked = await expectThrow(() => runTool(liveHarness, 'sidebar_page', { action: 'eval', code: '1+1' }))
|
|
420
|
+
check('live allowEval gate applies', typeof liveEvalBlocked === 'string' && liveEvalBlocked.includes('"eval" is disabled'), liveEvalBlocked)
|
|
421
|
+
|
|
422
|
+
// --- configuration gates fire before the bridge ---
|
|
423
|
+
const gated = start({ allowedHosts: ['example.com'], allowEval: false, allowNativeInput: false })
|
|
424
|
+
const blockedHost = await expectThrow(() => runTool(gated, 'sidebar_browser', { action: 'open', url: 'https://malicious.test/' }))
|
|
425
|
+
check('allowedHosts blocks other hosts', typeof blockedHost === 'string' && blockedHost.includes('does not allow host "malicious.test"'), blockedHost)
|
|
426
|
+
const blockedEval = await expectThrow(() => runTool(gated, 'sidebar_page', { action: 'eval', code: '1+1' }))
|
|
427
|
+
check('allowEval=false blocks eval', typeof blockedEval === 'string' && blockedEval.includes('"eval" is disabled'), blockedEval)
|
|
428
|
+
const blockedClick = await expectThrow(() => runTool(gated, 'sidebar_page', { action: 'click', selector: 'a' }))
|
|
429
|
+
check('allowNativeInput=false blocks click', typeof blockedClick === 'string' && blockedClick.includes('disabled by the plugin configuration'), blockedClick)
|
|
430
|
+
const blockedType = await expectThrow(() => runTool(gated, 'sidebar_page', { action: 'type', text: 'x' }))
|
|
431
|
+
check('allowNativeInput=false blocks type', typeof blockedType === 'string' && blockedType.includes('disabled'), blockedType)
|
|
432
|
+
|
|
433
|
+
// --- allowlist permits a configured host through the bridge ---
|
|
434
|
+
await routeHelpers(gated).callRoute('/plugin/sidebar-browser-agent/report', {
|
|
435
|
+
method: 'POST',
|
|
436
|
+
body: JSON.stringify({ status: 'started', mounted: 'session-1', carrier: 'electron-webview', href: 'http://127.0.0.1:1/' }),
|
|
437
|
+
})
|
|
438
|
+
const allowedTurn = pageTurn(gated, fakePage)
|
|
439
|
+
await sleep(10)
|
|
440
|
+
const allowedOpen = await runTool(gated, 'sidebar_browser', { action: 'open', url: 'https://example.com/page' })
|
|
441
|
+
await allowedTurn
|
|
442
|
+
check('allowedHosts permits configured host', allowedOpen.url === 'https://example.com/page', JSON.stringify(allowedOpen))
|
|
443
|
+
|
|
444
|
+
// --- bridge round trip + tool behaviours on the default mount ---
|
|
445
|
+
await routeHelpers(harness).callRoute('/plugin/sidebar-browser-agent/report', {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
body: JSON.stringify({ status: 'started', mounted: 'session-1', carrier: 'electron-webview', href: 'http://127.0.0.1:1/' }),
|
|
448
|
+
})
|
|
449
|
+
let turn = pageTurn(harness, fakePage)
|
|
450
|
+
await sleep(10)
|
|
451
|
+
const open = await runTool(harness, 'sidebar_browser', { action: 'open', url: 'https://example.com' })
|
|
452
|
+
await turn
|
|
453
|
+
check('open result', open.tabId === 'tab-2' && open.url === 'https://example.com/', JSON.stringify(open))
|
|
454
|
+
|
|
455
|
+
turn = pageTurn(harness, fakePage)
|
|
456
|
+
await sleep(10)
|
|
457
|
+
const list = await runTool(harness, 'sidebar_browser', { action: 'list' })
|
|
458
|
+
await turn
|
|
459
|
+
check('list carries native title/url', list.tabs[0].title === 'Example Domain', JSON.stringify(list))
|
|
460
|
+
|
|
461
|
+
turn = pageTurn(harness, fakePage)
|
|
462
|
+
await sleep(10)
|
|
463
|
+
const read = await runTool(harness, 'sidebar_page', { action: 'read' })
|
|
464
|
+
await turn
|
|
465
|
+
check('read text value', read.text.includes('Example Domain') && read.mode === 'text', JSON.stringify(read))
|
|
466
|
+
const readContent = harness.tools.get('sidebar_page').definition.output.render({}, read)
|
|
467
|
+
check('read render', readContent[0].type === 'text' && readContent[0].text.includes('illustrative examples'), readContent[0].text)
|
|
468
|
+
|
|
469
|
+
turn = pageTurn(harness, fakePage)
|
|
470
|
+
await sleep(10)
|
|
471
|
+
const interactive = await runTool(harness, 'sidebar_page', { action: 'read', mode: 'interactive' })
|
|
472
|
+
await turn
|
|
473
|
+
check('interactive read', interactive.elements.length === 1 && interactive.elements[0].selector === 'a', JSON.stringify(interactive))
|
|
474
|
+
|
|
475
|
+
turn = pageTurn(harness, fakePage)
|
|
476
|
+
await sleep(10)
|
|
477
|
+
const click = await runTool(harness, 'sidebar_page', { action: 'click', contains: 'More information' })
|
|
478
|
+
await turn
|
|
479
|
+
check('click value', click.target.tag === 'a' && click.target.x === 100, JSON.stringify(click))
|
|
480
|
+
|
|
481
|
+
turn = pageTurn(harness, fakePage)
|
|
482
|
+
await sleep(10)
|
|
483
|
+
const typed = await runTool(harness, 'sidebar_page', { action: 'type', selector: '#q', text: 'hello', submit: true })
|
|
484
|
+
await turn
|
|
485
|
+
check('type value', typed.typed === 5 && typed.submitted === true, JSON.stringify(typed))
|
|
486
|
+
|
|
487
|
+
turn = pageTurn(harness, fakePage)
|
|
488
|
+
await sleep(10)
|
|
489
|
+
const waited = await runTool(harness, 'sidebar_page', { action: 'wait', selector: '#q' })
|
|
490
|
+
await turn
|
|
491
|
+
check('wait value', waited.matched === true, JSON.stringify(waited))
|
|
492
|
+
|
|
493
|
+
turn = pageTurn(harness, fakePage)
|
|
494
|
+
await sleep(10)
|
|
495
|
+
const evaluated = await runTool(harness, 'sidebar_page', { action: 'eval', code: '({answer: 42})' })
|
|
496
|
+
await turn
|
|
497
|
+
const evalContent = harness.tools.get('sidebar_page').definition.output.render({}, evaluated)
|
|
498
|
+
check('eval render', evalContent[0].text.includes('{"answer":42}'), evalContent[0].text)
|
|
499
|
+
|
|
500
|
+
// --- screenshot: attachment conversion + image content block ---
|
|
501
|
+
turn = pageTurn(harness, fakePage)
|
|
502
|
+
await sleep(10)
|
|
503
|
+
const shot = await runTool(harness, 'sidebar_page', { action: 'screenshot' })
|
|
504
|
+
await turn
|
|
505
|
+
check('screenshot saved as attachment', harness.savedImages.length === 1 && harness.savedImages[0].mediaType === 'image/png', JSON.stringify(harness.savedImages))
|
|
506
|
+
check('screenshot value strips base64', shot.image.attachmentId === 'attachment-1' && shot.base64 === undefined, JSON.stringify(shot))
|
|
507
|
+
const shotContent = harness.tools.get('sidebar_page').definition.output.render({}, shot)
|
|
508
|
+
check('screenshot renders image block', shotContent.length === 2 && shotContent[1].type === 'image' && shotContent[1].attachment.attachmentId === 'attachment-1', JSON.stringify(shotContent))
|
|
509
|
+
|
|
510
|
+
// --- argument validation before the bridge ---
|
|
511
|
+
const missingText = await expectThrow(() => runTool(harness, 'sidebar_page', { action: 'type' }))
|
|
512
|
+
check('type requires text', missingText === 'type requires "text"', missingText)
|
|
513
|
+
const missingClickTarget = await expectThrow(() => runTool(harness, 'sidebar_page', { action: 'click' }))
|
|
514
|
+
check('click requires target', typeof missingClickTarget === 'string' && missingClickTarget.includes('needs "selector"'), missingClickTarget)
|
|
515
|
+
const badUrl = await expectThrow(() => runTool(harness, 'sidebar_browser', { action: 'open', url: 'file:///etc/passwd' }))
|
|
516
|
+
check('non-http url rejected', typeof badUrl === 'string' && badUrl.includes('only opens http://'), badUrl)
|
|
517
|
+
|
|
518
|
+
// --- page failure propagates ---
|
|
519
|
+
turn = pageTurn(harness, () => ({ ok: false, error: 'the page context rejected the script: boom' }))
|
|
520
|
+
await sleep(10)
|
|
521
|
+
const pageError = await expectThrow(() => runTool(harness, 'sidebar_page', { action: 'read' }))
|
|
522
|
+
await turn
|
|
523
|
+
check('page failure propagates', pageError === 'the page context rejected the script: boom', pageError)
|
|
524
|
+
|
|
525
|
+
// --- output values validate against their own schemas ---
|
|
526
|
+
for (const [name, value] of [
|
|
527
|
+
['sidebar_browser', open],
|
|
528
|
+
['sidebar_browser', list],
|
|
529
|
+
['sidebar_page', read],
|
|
530
|
+
['sidebar_page', interactive],
|
|
531
|
+
['sidebar_page', click],
|
|
532
|
+
['sidebar_page', typed],
|
|
533
|
+
['sidebar_page', waited],
|
|
534
|
+
['sidebar_page', evaluated],
|
|
535
|
+
['sidebar_page', shot],
|
|
536
|
+
]) {
|
|
537
|
+
const violations = validateJsonSchemaValue(harness.tools.get(name).definition.output.schema, value, 'value')
|
|
538
|
+
check(`${name} ${String(value.action)} output validates`, violations.length === 0, violations.join('; '))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`)
|
|
542
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
main().catch((error) => {
|
|
546
|
+
console.error('harness crashed:', error)
|
|
547
|
+
process.exitCode = 1
|
|
548
|
+
})
|
package/test/run.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free regression runner.
|
|
3
|
+
*
|
|
4
|
+
* Each suite runs in its own Node process so a crash in one cannot hide the
|
|
5
|
+
* other's report. Exit code is non-zero when any suite fails.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const suites = ['host-harness.mjs', 'client-harness.mjs']
|
|
13
|
+
|
|
14
|
+
let failed = 0
|
|
15
|
+
for (const suite of suites) {
|
|
16
|
+
console.log(`\n=== ${suite} ===`)
|
|
17
|
+
const result = spawnSync(process.execPath, [join(here, suite)], { stdio: 'inherit' })
|
|
18
|
+
if (result.status !== 0) failed += 1
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(failed === 0 ? '\nAll suites passed.' : `\n${failed} suite(s) failed.`)
|
|
22
|
+
process.exitCode = failed === 0 ? 0 : 1
|
package/vendor/NOTICE.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Vendored third-party code
|
|
2
|
+
|
|
3
|
+
The plugin ships two MIT-licensed packages from the DeepSeek Harness repository
|
|
4
|
+
so a profile-linked install works without module resolution from the profile:
|
|
5
|
+
|
|
6
|
+
| Directory | Package | Version | Source |
|
|
7
|
+
| --- | --- | --- | --- |
|
|
8
|
+
| `schemastery/` | `@deepseek-ai/schemastery` | 3.19.0 | https://github.com/deepseek-ai/deepseek-harness (vendor/schemastery) |
|
|
9
|
+
| `cosmokit/` | `@deepseek-ai/cosmokit` | 1.9.0 | https://github.com/deepseek-ai/deepseek-harness (vendor/cosmokit) |
|
|
10
|
+
|
|
11
|
+
Local changes: `schemastery/index.mjs` imports `../cosmokit/index.js` instead of
|
|
12
|
+
the bare `@deepseek-ai/cosmokit` specifier. No other edits. Both packages keep
|
|
13
|
+
their own `LICENSE` file (MIT, Copyright (c) 2021-present Shigma).
|
|
14
|
+
|
|
15
|
+
They are used only by the Host half to declare the native plugin Config schema.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-present Shigma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|