@shidesheng0218/dsh-plugin-lint 0.1.0 → 0.4.0
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/NOTICE +1 -1
- package/README.md +515 -81
- package/bin/dsh-plugin-analyze.mjs +378 -0
- package/bin/dsh-plugin-lint.mjs +315 -27
- package/bin/dsh-plugin-matrix.mjs +78 -0
- package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
- package/package.json +12 -4
- package/schema/dsh-manifest.schema.json +134 -0
- package/scripts/lib/probe.mjs +28 -385
- package/scripts/probe-boot.mjs +103 -37
- package/src/composition.js +1 -0
- package/src/core/baseline.js +98 -0
- package/src/core/changed.js +41 -0
- package/src/core/load.js +145 -0
- package/src/core/mask.js +207 -0
- package/src/core/pack.js +95 -0
- package/src/core/packlist.js +186 -0
- package/src/core/peer.js +39 -0
- package/src/core/profile-scan.js +121 -0
- package/src/core/report.js +174 -0
- package/src/core/rules.js +581 -0
- package/src/core/semver.js +192 -0
- package/src/core/tarball.js +90 -0
- package/src/data/event-types.json +111 -9
- package/src/experimental/conflicts.js +315 -0
- package/src/experimental/index.js +4 -0
- package/src/experimental/performance.js +515 -0
- package/src/experimental/security.js +462 -0
- package/src/index.js +15 -17
- package/src/load.js +1 -116
- package/src/mask.js +1 -156
- package/src/matrix/cache.js +19 -0
- package/src/matrix/cell.js +79 -0
- package/src/matrix/cells.js +58 -0
- package/src/matrix/conflicts.js +210 -0
- package/src/matrix/export.js +155 -0
- package/src/matrix/index.js +71 -0
- package/src/matrix/manifest.js +17 -0
- package/src/matrix/migrate.js +41 -0
- package/src/matrix/run.js +241 -0
- package/src/matrix/sanitize.js +42 -0
- package/src/matrix/schema.js +178 -0
- package/src/matrix/site.js +61 -0
- package/src/matrix/sync.js +72 -0
- package/src/matrix/verify.js +152 -0
- package/src/performance.js +1 -0
- package/src/probe/artifact-resolver.js +161 -0
- package/src/probe/classify.js +8 -0
- package/src/probe/dsh-adapter.js +212 -0
- package/src/probe/environment/docker.js +270 -0
- package/src/probe/environment/host-unsafe.js +39 -0
- package/src/probe/environment/materialize.js +22 -0
- package/src/probe/environment.js +50 -0
- package/src/probe/installed-artifact.js +17 -0
- package/src/probe/probe.js +278 -0
- package/src/report.js +1 -79
- package/src/rules.js +1 -379
- package/src/security.js +1 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/hash.js +31 -0
- package/src/shared/status.js +12 -0
- package/src/shared/validate.js +14 -0
- package/src/shared/version.js +4 -0
- package/src/upgrade/compatibility.js +14 -0
- package/src/upgrade/planner.js +119 -0
- package/src/upgrade/profile.js +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shidesheng0218/dsh-plugin-lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Conformance checks and a compatibility matrix for DeepSeek Harness plugins: what the code declares, what it publishes, and what the host does with it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -18,15 +18,21 @@
|
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": "./src/index.js",
|
|
21
|
+
"./experimental": "./src/experimental/index.js",
|
|
22
|
+
"./schema/dsh-manifest.schema.json": "./schema/dsh-manifest.schema.json",
|
|
21
23
|
"./package.json": "./package.json"
|
|
22
24
|
},
|
|
23
25
|
"bin": {
|
|
24
|
-
"dsh-plugin-lint": "
|
|
25
|
-
"dsh-plugin-probe": "
|
|
26
|
+
"dsh-plugin-lint": "bin/dsh-plugin-lint.mjs",
|
|
27
|
+
"dsh-plugin-probe": "scripts/probe-boot.mjs",
|
|
28
|
+
"dsh-plugin-analyze": "bin/dsh-plugin-analyze.mjs",
|
|
29
|
+
"dsh-plugin-upgrade-plan": "bin/dsh-plugin-upgrade-plan.mjs",
|
|
30
|
+
"dsh-plugin-matrix": "bin/dsh-plugin-matrix.mjs"
|
|
26
31
|
},
|
|
27
32
|
"files": [
|
|
28
33
|
"bin/",
|
|
29
34
|
"src/",
|
|
35
|
+
"schema/",
|
|
30
36
|
"scripts/probe-boot.mjs",
|
|
31
37
|
"scripts/lib/",
|
|
32
38
|
"README.md",
|
|
@@ -35,7 +41,9 @@
|
|
|
35
41
|
],
|
|
36
42
|
"scripts": {
|
|
37
43
|
"test": "node --test test/*.test.js",
|
|
38
|
-
"
|
|
44
|
+
"test:coverage": "node --experimental-test-coverage --test --test-coverage-lines=85 --test-coverage-branches=80 --test-coverage-functions=80 --test-coverage-exclude=\"**/node_modules/**\" --test-coverage-exclude=\"**/.cache/**\" test/*.test.js",
|
|
45
|
+
"package:smoke": "node scripts/package-smoke.mjs",
|
|
46
|
+
"e2e:official": "node scripts/e2e-official.mjs --expect 80",
|
|
39
47
|
"harvest:events": "node scripts/harvest-event-types.mjs",
|
|
40
48
|
"fetch:official": "node scripts/fetch-official-from-npm.mjs",
|
|
41
49
|
"matrix": "node scripts/matrix/run.mjs",
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/shidesheng0218/dsh-plugin-lint/main/schema/dsh-manifest.schema.json",
|
|
4
|
+
"title": "DeepSeek Harness plugin package.json",
|
|
5
|
+
"description": "The package.json fields a DSH plugin author declares. Unknown fields are allowed; editors validate the parts the host reads before CI runs dsh-plugin-lint.",
|
|
6
|
+
"$comment": "schemaVersion 1, shipped with @shidesheng0218/dsh-plugin-lint 0.4.0",
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": ["name", "version"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$"
|
|
13
|
+
},
|
|
14
|
+
"version": {
|
|
15
|
+
"type": "string"
|
|
16
|
+
},
|
|
17
|
+
"private": {
|
|
18
|
+
"type": "boolean"
|
|
19
|
+
},
|
|
20
|
+
"type": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": ["module", "commonjs"]
|
|
23
|
+
},
|
|
24
|
+
"main": {
|
|
25
|
+
"type": "string"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
"$ref": "#/definitions/exports"
|
|
29
|
+
},
|
|
30
|
+
"files": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": { "type": "string" }
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"oneOf": [
|
|
36
|
+
{ "type": "string" },
|
|
37
|
+
{ "type": "object", "additionalProperties": { "type": "string" } }
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"icon": {
|
|
41
|
+
"type": "string"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"additionalProperties": { "type": "string" }
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"$ref": "#/definitions/dsh"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"additionalProperties": true,
|
|
52
|
+
"definitions": {
|
|
53
|
+
"exports": {
|
|
54
|
+
"oneOf": [
|
|
55
|
+
{ "type": "string" },
|
|
56
|
+
{ "type": "array", "items": { "$ref": "#/definitions/exports" } },
|
|
57
|
+
{ "type": "object", "additionalProperties": { "$ref": "#/definitions/exports" } }
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"dsh": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"description": "Everything the DSH host reads from package.json.",
|
|
63
|
+
"properties": {
|
|
64
|
+
"bundle": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"patch": {
|
|
68
|
+
"oneOf": [
|
|
69
|
+
{ "type": "string" },
|
|
70
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"additionalProperties": true
|
|
75
|
+
},
|
|
76
|
+
"client": {
|
|
77
|
+
"description": "Declares a browser half; the host resolves exports[\"./client\"].",
|
|
78
|
+
"oneOf": [
|
|
79
|
+
{ "type": "boolean" },
|
|
80
|
+
{
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {
|
|
83
|
+
"platform": { "type": "string" },
|
|
84
|
+
"immediately": { "type": "boolean" },
|
|
85
|
+
"inject": { "type": "array", "items": { "type": "string" } },
|
|
86
|
+
"external": { "type": "array", "items": { "type": "string" } }
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": true
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
"profile": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"bundles": {
|
|
96
|
+
"type": "array",
|
|
97
|
+
"items": {
|
|
98
|
+
"oneOf": [
|
|
99
|
+
{ "type": "string" },
|
|
100
|
+
{
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"name": { "type": "string" },
|
|
104
|
+
"id": { "type": "string" },
|
|
105
|
+
"package": { "type": "string" },
|
|
106
|
+
"bundle": { "type": "string" },
|
|
107
|
+
"plugin": { "type": "string" }
|
|
108
|
+
},
|
|
109
|
+
"additionalProperties": true
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"additionalProperties": true
|
|
116
|
+
},
|
|
117
|
+
"compatibility": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"dshReleases": {
|
|
121
|
+
"description": "A declaration, not the host gate; the host checks peerDependencies on @deepseek-ai/dsh*.",
|
|
122
|
+
"type": "object",
|
|
123
|
+
"additionalProperties": {
|
|
124
|
+
"oneOf": [{ "type": "string" }, { "type": "boolean" }]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"additionalProperties": true
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"additionalProperties": true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
package/scripts/lib/probe.mjs
CHANGED
|
@@ -1,391 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
* Probe core: one throwaway environment, one plugin, one verdict.
|
|
3
|
-
*
|
|
4
|
-
* Shared by scripts/probe-boot.mjs (single package) and scripts/matrix/run.mjs
|
|
5
|
-
* (a grid of plugin versions x DSH versions), so both can never drift apart.
|
|
6
|
-
*
|
|
7
|
-
* Two DSH sources:
|
|
8
|
-
* desktop the installed DeepSeek Harness app (Electron reads the asar)
|
|
9
|
-
* npm @deepseek-ai/dsh@<version> installed into a cache directory and run
|
|
10
|
-
* by plain node, which is what lets the matrix run on Linux CI
|
|
11
|
-
*
|
|
12
|
-
* Verified facts this file depends on (see docs/10 and docs/11):
|
|
13
|
-
* - a failing entry is a WARNING and the process still exits 0, so stderr is
|
|
14
|
-
* the signal and the exit code never is;
|
|
15
|
-
* - with no credentials in a throwaway home, reaching MISSING_CREDENTIAL means
|
|
16
|
-
* every entry mounted;
|
|
17
|
-
* - the stub provider must be served while the child runs, so children are
|
|
18
|
-
* started ASYNCHRONOUSLY (a synchronous child deadlocks the stub).
|
|
19
|
-
*/
|
|
20
|
-
import { execFile, spawn } from 'node:child_process';
|
|
21
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
-
import { createServer } from 'node:http';
|
|
1
|
+
import { mkdtempSync } from 'node:fs';
|
|
23
2
|
import { tmpdir } from 'node:os';
|
|
24
|
-
import { join
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** A minimal Anthropic-Messages endpoint that records what the harness sends. */
|
|
47
|
-
export function startStub() {
|
|
48
|
-
const seen = [];
|
|
49
|
-
const server = createServer((req, res) => {
|
|
50
|
-
let raw = '';
|
|
51
|
-
req.on('data', (c) => { raw += c; });
|
|
52
|
-
req.on('end', () => {
|
|
53
|
-
let body = null;
|
|
54
|
-
try { body = JSON.parse(raw); } catch { /* keep null */ }
|
|
55
|
-
if (body) seen.push(body);
|
|
56
|
-
if (!body || !body.stream) {
|
|
57
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
58
|
-
res.end(JSON.stringify({
|
|
59
|
-
id: 'msg_probe', type: 'message', role: 'assistant', model: body?.model || 'probe',
|
|
60
|
-
content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', stop_sequence: null,
|
|
61
|
-
usage: { input_tokens: 1, output_tokens: 1 },
|
|
62
|
-
}));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const sse = (event, data) => res.write('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n');
|
|
66
|
-
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
67
|
-
sse('message_start', { type: 'message_start', message: { id: 'msg_probe', type: 'message', role: 'assistant', model: body.model || 'probe', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } } });
|
|
68
|
-
sse('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
69
|
-
sse('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } });
|
|
70
|
-
sse('content_block_stop', { type: 'content_block_stop', index: 0 });
|
|
71
|
-
sse('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
|
72
|
-
sse('message_stop', { type: 'message_stop' });
|
|
73
|
-
res.end();
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
return new Promise((resolve_) => {
|
|
77
|
-
server.listen(0, '127.0.0.1', () => resolve_({ server, seen, port: server.address().port }));
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { resolveArtifact } from '../../src/probe/artifact-resolver.js';
|
|
5
|
+
import { createProbeEnvironment } from '../../src/probe/environment.js';
|
|
6
|
+
import { probePackage as probePinnedPackage } from '../../src/probe/probe.js';
|
|
7
|
+
|
|
8
|
+
export { WEB_READY, resolveDsh, runDsh, runUntilReady, startStub } from '../../src/probe/dsh-adapter.js';
|
|
9
|
+
export { classify } from '../../src/probe/classify.js';
|
|
10
|
+
export {
|
|
11
|
+
capture,
|
|
12
|
+
redact,
|
|
13
|
+
parseInactive,
|
|
14
|
+
awaitedServices,
|
|
15
|
+
rejectionOf,
|
|
16
|
+
noteOf,
|
|
17
|
+
payloadOf,
|
|
18
|
+
} from '../../src/probe/probe.js';
|
|
19
|
+
|
|
20
|
+
export async function probePackage(options) {
|
|
21
|
+
const artifact = options.artifact || await resolveArtifact(options.target);
|
|
22
|
+
const environment = options.environment || await createProbeEnvironment({
|
|
23
|
+
backend: options.backend || 'auto',
|
|
24
|
+
workDir: options.workDir,
|
|
78
25
|
});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
env: { ...process.env, DSH_HOME: home, ...(dsh.kind === 'desktop' ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...env },
|
|
85
|
-
encoding: 'utf8',
|
|
86
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
87
|
-
timeout: 180000,
|
|
88
|
-
});
|
|
89
|
-
return stdout + stderr;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Run a long-lived profile until it reports readiness, then stop it.
|
|
94
|
-
*
|
|
95
|
-
* The web profile serves until killed, so readiness is a sentinel line rather
|
|
96
|
-
* than an exit: "dsh web: http://127.0.0.1:<port>/?token=…". We stop as soon as
|
|
97
|
-
* it appears (plus a short grace period) because a mounted plugin may start doing
|
|
98
|
-
* work of its own — one probed plugin started downloading a 116 MB Electron build.
|
|
99
|
-
*/
|
|
100
|
-
export function runUntilReady(dsh, home, args, env, readyRe, options = {}) {
|
|
101
|
-
const timeoutMs = options.timeoutMs ?? 90000;
|
|
102
|
-
const graceMs = options.graceMs ?? 2500;
|
|
103
|
-
return new Promise((resolve) => {
|
|
104
|
-
const child = spawn(dsh.node, [...dsh.extraArgs, dsh.cli, ...args], {
|
|
105
|
-
env: { ...process.env, DSH_HOME: home, ...(dsh.kind === 'desktop' ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...env },
|
|
106
|
-
});
|
|
107
|
-
let out = '';
|
|
108
|
-
let finished = false;
|
|
109
|
-
const finish = () => {
|
|
110
|
-
if (finished) return;
|
|
111
|
-
finished = true;
|
|
112
|
-
child.kill('SIGTERM');
|
|
113
|
-
setTimeout(() => child.kill('SIGKILL'), 1500).unref();
|
|
114
|
-
resolve(out);
|
|
115
|
-
};
|
|
116
|
-
const feed = (chunk) => {
|
|
117
|
-
out += chunk;
|
|
118
|
-
if (readyRe.test(out)) setTimeout(finish, graceMs).unref();
|
|
119
|
-
};
|
|
120
|
-
child.stdout.on('data', feed);
|
|
121
|
-
child.stderr.on('data', feed);
|
|
122
|
-
child.on('exit', () => { if (!finished) { finished = true; resolve(out); } });
|
|
123
|
-
setTimeout(finish, timeoutMs).unref();
|
|
26
|
+
return probePinnedPackage({
|
|
27
|
+
...options,
|
|
28
|
+
artifact,
|
|
29
|
+
environment,
|
|
30
|
+
composition: options.composition || options.mode || 'headless',
|
|
124
31
|
});
|
|
125
32
|
}
|
|
126
33
|
|
|
127
|
-
/** Readiness sentinel of the web profile. */
|
|
128
|
-
export const WEB_READY = /dsh web:\s*https?:\/\//;
|
|
129
|
-
|
|
130
|
-
export async function capture(fn) {
|
|
131
|
-
try { return { out: await fn(), failed: false }; } catch (error) {
|
|
132
|
-
return { out: String(error.stdout || '') + String(error.stderr || ''), failed: true };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** An ephemeral localhost token from the web readiness line never belongs in a cell. */
|
|
137
|
-
const redact = (s) => String(s).replace(/token=[^\s&"']+/gi, 'token=<redacted>');
|
|
138
|
-
|
|
139
|
-
/** Inactive entries are listed under a "did not activate" warning, one per line. */
|
|
140
|
-
export function parseInactive(text) {
|
|
141
|
-
const out = [];
|
|
142
|
-
const lines = text.split('\n');
|
|
143
|
-
const start = lines.findIndex((l) => /did not activate/.test(l));
|
|
144
|
-
if (start < 0) return out;
|
|
145
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
146
|
-
const m = /^(\S.*?)\s+\((\S+)\):\s*(.*)$/.exec(lines[i]);
|
|
147
|
-
if (m) { out.push({ name: m[1], id: m[2], reason: m[3].trim() }); continue; }
|
|
148
|
-
if (out.length > 0 && /^\s+at /.test(lines[i])) {
|
|
149
|
-
const last = out[out.length - 1];
|
|
150
|
-
last.detail = (last.detail ? last.detail + ' ' : '') + lines[i].trim();
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
if (out.length > 0) break;
|
|
154
|
-
}
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Map raw probe output to the verdict vocabulary. install-failed is never a
|
|
160
|
-
* plugin verdict.
|
|
161
|
-
*
|
|
162
|
-
* Two different success sentinels, because the two modes end differently:
|
|
163
|
-
* mount-only the run stops at MISSING_CREDENTIAL (no credentials in the throwaway home)
|
|
164
|
-
* with --tools the stub answers, so the run COMPLETES: a captured tool-bearing
|
|
165
|
-
* request is the proof that the composition mounted
|
|
166
|
-
*/
|
|
167
|
-
export function classify(bootText, entries, toolPayloadCaptured) {
|
|
168
|
-
if (entries.some((e) => /Error/.test(e.reason))) return 'inactive-error';
|
|
169
|
-
// "awaiting-service" is NOT a failure. Measured on 2026-10-13: every pending
|
|
170
|
-
// plugin in the smoke sample was waiting for a service the headless profile does
|
|
171
|
-
// not provide (webServer, connection, agentPresets), so this verdict says
|
|
172
|
-
// "cannot be tested in this composition", not "broken".
|
|
173
|
-
if (entries.length > 0) return 'awaiting-service';
|
|
174
|
-
if (toolPayloadCaptured === true) return 'active';
|
|
175
|
-
if (toolPayloadCaptured === false) return 'boot-failed';
|
|
176
|
-
return /MISSING_CREDENTIAL/.test(bootText) ? 'active' : 'boot-failed';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** The services a pending entry is waiting for, so a reader can judge the reason. */
|
|
180
|
-
export function awaitedServices(entries) {
|
|
181
|
-
const out = [];
|
|
182
|
-
for (const entry of entries) {
|
|
183
|
-
const m = /waiting for service:\s*([^)\s]+)/.exec(entry.reason || '');
|
|
184
|
-
if (m) out.push(m[1]);
|
|
185
|
-
}
|
|
186
|
-
return [...new Set(out)];
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* The host refuses some installs itself, with the DSH version named in the
|
|
191
|
-
* reason. That is a stronger verdict than anything this probe can produce, so it
|
|
192
|
-
* gets its own status rather than being filed as an environment failure.
|
|
193
|
-
*/
|
|
194
|
-
export function rejectionOf(installText) {
|
|
195
|
-
const m = /installation rejected:\s*([^\n]+)/.exec(installText);
|
|
196
|
-
return m ? m[1].trim().slice(0, 400) : undefined;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function noteOf(text) {
|
|
200
|
-
return redact(text).split('\n').map((l) => l.trim())
|
|
201
|
-
.filter((l) => l && !/MISSING_CREDENTIAL/.test(l) && !/^at /.test(l))
|
|
202
|
-
.slice(0, 3).join(' | ').slice(0, 300);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const payloadOf = (seen) => seen.filter((b) => Array.isArray(b.tools)).pop();
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Probe several packages together in ONE profile.
|
|
209
|
-
*
|
|
210
|
-
* This is what composition questions need: installing plugins one at a time says
|
|
211
|
-
* nothing about what happens when they meet. The comparison that matters is the
|
|
212
|
-
* merged tool list against the union of what each plugin registered alone — when
|
|
213
|
-
* the merged list is smaller, something was silently shadowed.
|
|
214
|
-
*/
|
|
215
|
-
export async function probeCombo({ targets, dsh, stub, tools = true, keep = false }) {
|
|
216
|
-
const resolved = targets.map((t) => (existsSync(t) ? resolve(t) : t));
|
|
217
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-combo-'));
|
|
218
|
-
const cell = { status: 'unknown', install: 'ok', entries: [], durationMs: 0, note: '', tools: undefined, installed: [] };
|
|
219
|
-
const started = Date.now();
|
|
220
|
-
const patchPath = join(home, 'probe-patch.yml');
|
|
221
|
-
const bootArgs = ['--profile', 'headless', ...(tools ? ['--patch', patchPath] : []), 'probe'];
|
|
222
|
-
const bootEnv = tools ? { PROBE_API_KEY: 'probe' } : {};
|
|
223
|
-
if (tools) {
|
|
224
|
-
writeFileSync(patchPath, [
|
|
225
|
-
'- id: llm-deepseek',
|
|
226
|
-
' name: "@deepseek-ai/dsh-llm-deepseek-api-key"',
|
|
227
|
-
' config:',
|
|
228
|
-
' baseURL: http://127.0.0.1:' + stub.port,
|
|
229
|
-
' apiKeyEnv: PROBE_API_KEY',
|
|
230
|
-
'',
|
|
231
|
-
].join('\n'));
|
|
232
|
-
}
|
|
233
|
-
try {
|
|
234
|
-
await capture(() => runDsh(dsh, home, bootArgs, bootEnv));
|
|
235
|
-
if (!readdirSync(home).includes('profiles')) {
|
|
236
|
-
cell.install = 'template-failed';
|
|
237
|
-
return cell;
|
|
238
|
-
}
|
|
239
|
-
const before = tools ? payloadOf(stub.seen.splice(0)) : undefined;
|
|
240
|
-
|
|
241
|
-
const profilePkgPath = join(home, 'profiles', 'headless', 'package.json');
|
|
242
|
-
for (const target of resolved) {
|
|
243
|
-
const beforePkg = existsSync(profilePkgPath) ? readFileSync(profilePkgPath, 'utf8') : '';
|
|
244
|
-
const installed = await capture(() => runDsh(dsh, home, ['plugin', '--profile', 'headless', 'add', target], bootEnv));
|
|
245
|
-
const afterPkg = existsSync(profilePkgPath) ? readFileSync(profilePkgPath, 'utf8') : '';
|
|
246
|
-
if (installed.failed && afterPkg === beforePkg) {
|
|
247
|
-
const rejection = rejectionOf(installed.out);
|
|
248
|
-
cell.installed.push({ target, ok: false, rejection, note: noteOf(installed.out) });
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
cell.installed.push({ target, ok: true });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const booted = await capture(() => runDsh(dsh, home, bootArgs, bootEnv));
|
|
255
|
-
cell.entries = parseInactive(booted.out);
|
|
256
|
-
cell.awaitedServices = awaitedServices(cell.entries);
|
|
257
|
-
const after = tools ? payloadOf(stub.seen.splice(0)) : undefined;
|
|
258
|
-
cell.status = classify(booted.out, cell.entries, tools ? Boolean(after) : undefined);
|
|
259
|
-
cell.note = noteOf(booted.out);
|
|
260
|
-
|
|
261
|
-
if (tools && before && after) {
|
|
262
|
-
const names = (p) => p.tools.map((t) => t.name);
|
|
263
|
-
const b = new Set(names(before));
|
|
264
|
-
const a = new Set(names(after));
|
|
265
|
-
cell.tools = { baseline: b.size, total: a.size, added: [...a].filter((x) => !b.has(x)), removed: [...b].filter((x) => !a.has(x)) };
|
|
266
|
-
cell.systemPrompt = { baselineChars: JSON.stringify(before.system ?? '').length, chars: JSON.stringify(after.system ?? '').length };
|
|
267
|
-
}
|
|
268
|
-
return cell;
|
|
269
|
-
} finally {
|
|
270
|
-
cell.durationMs = Date.now() - started;
|
|
271
|
-
if (!keep) rmSync(home, { recursive: true, force: true });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Probe one package.
|
|
278
|
-
* @returns a cell: the verdict, what it registered, and how to reproduce it.
|
|
279
|
-
*/
|
|
280
|
-
export async function probePackage({ target: rawTarget, dsh, stub, tools = false, keep = false, mode = 'headless' }) {
|
|
281
|
-
const target = existsSync(rawTarget) ? resolve(rawTarget) : rawTarget;
|
|
282
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-probe-'));
|
|
283
|
-
const cell = {
|
|
284
|
-
status: 'unknown',
|
|
285
|
-
install: 'ok',
|
|
286
|
-
entries: [],
|
|
287
|
-
durationMs: 0,
|
|
288
|
-
note: '',
|
|
289
|
-
composition: mode,
|
|
290
|
-
tools: undefined,
|
|
291
|
-
systemPrompt: undefined,
|
|
292
|
-
packagesAdded: undefined,
|
|
293
|
-
};
|
|
294
|
-
const started = Date.now();
|
|
295
|
-
const patchPath = join(home, 'probe-patch.yml');
|
|
296
|
-
const profile = mode === 'web' ? 'web' : 'headless';
|
|
297
|
-
const bootEnv = tools ? { PROBE_API_KEY: 'probe' } : {};
|
|
298
|
-
const bootArgs = mode === 'web'
|
|
299
|
-
? ['--profile', 'web', '--port', '0', '--no-open']
|
|
300
|
-
: ['--profile', 'headless', ...(tools ? ['--patch', patchPath] : []), 'probe'];
|
|
301
|
-
|
|
302
|
-
if (tools && mode !== 'web') {
|
|
303
|
-
writeFileSync(patchPath, [
|
|
304
|
-
'- id: llm-deepseek',
|
|
305
|
-
' name: "@deepseek-ai/dsh-llm-deepseek-api-key"',
|
|
306
|
-
' config:',
|
|
307
|
-
' baseURL: http://127.0.0.1:' + stub.port,
|
|
308
|
-
' apiKeyEnv: PROBE_API_KEY',
|
|
309
|
-
'',
|
|
310
|
-
].join('\n'));
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
try {
|
|
314
|
-
// 1. lay the profile down and capture the baseline the plugin is measured against
|
|
315
|
-
const baseline = mode === 'web'
|
|
316
|
-
? await runUntilReady(dsh, home, bootArgs, bootEnv, WEB_READY, { timeoutMs: 60000, graceMs: 1200 })
|
|
317
|
-
: await capture(() => runDsh(dsh, home, bootArgs, bootEnv)).then((r) => r.out);
|
|
318
|
-
void baseline;
|
|
319
|
-
if (!readdirSync(home).includes('profiles')) {
|
|
320
|
-
cell.install = 'template-failed';
|
|
321
|
-
return cell;
|
|
322
|
-
}
|
|
323
|
-
const before = tools && mode !== 'web' ? payloadOf(stub.seen.splice(0)) : undefined;
|
|
324
|
-
|
|
325
|
-
// 2. install. The exit code is NOT the artifact: measured on 2026-10-13, a 200-cell
|
|
326
|
-
// run recorded 26 install failures whose profile package.json had in fact been
|
|
327
|
-
// updated and whose plugin was on disk. A non-zero exit is only believed after
|
|
328
|
-
// the profile has been inspected.
|
|
329
|
-
const profilePkgPath = join(home, 'profiles', profile, 'package.json');
|
|
330
|
-
const beforePkg = existsSync(profilePkgPath) ? readFileSync(profilePkgPath, 'utf8') : '';
|
|
331
|
-
const installed = await capture(() => runDsh(dsh, home, ['plugin', '--profile', profile, 'add', target], bootEnv));
|
|
332
|
-
if (installed.failed) {
|
|
333
|
-
const rejection = rejectionOf(installed.out);
|
|
334
|
-
const afterPkg = existsSync(profilePkgPath) ? readFileSync(profilePkgPath, 'utf8') : '';
|
|
335
|
-
const landed = afterPkg !== beforePkg && afterPkg.length > 0;
|
|
336
|
-
if (rejection && !landed) {
|
|
337
|
-
cell.status = 'rejected-by-host';
|
|
338
|
-
cell.rejection = rejection;
|
|
339
|
-
cell.note = noteOf(installed.out);
|
|
340
|
-
return cell;
|
|
341
|
-
}
|
|
342
|
-
if (!landed) {
|
|
343
|
-
cell.install = 'failed';
|
|
344
|
-
cell.status = 'install-failed';
|
|
345
|
-
cell.note = noteOf(installed.out);
|
|
346
|
-
cell.installOutput = redact(installed.out).slice(-700);
|
|
347
|
-
return cell;
|
|
348
|
-
}
|
|
349
|
-
// The plugin landed, so the non-zero exit was noise (peer-dependency warnings).
|
|
350
|
-
cell.install = 'ok-with-warnings';
|
|
351
|
-
cell.installOutput = noteOf(installed.out);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// 3. boot again and read what changed
|
|
355
|
-
const booted = mode === 'web'
|
|
356
|
-
? { out: await runUntilReady(dsh, home, bootArgs, bootEnv, WEB_READY, { timeoutMs: 90000, graceMs: 2500 }), failed: false }
|
|
357
|
-
: await capture(() => runDsh(dsh, home, bootArgs, bootEnv));
|
|
358
|
-
if (mode === 'web') cell.ready = WEB_READY.test(booted.out);
|
|
359
|
-
cell.entries = parseInactive(booted.out);
|
|
360
|
-
cell.awaitedServices = awaitedServices(cell.entries);
|
|
361
|
-
const after = tools && mode !== 'web' ? payloadOf(stub.seen.splice(0)) : undefined;
|
|
362
|
-
cell.status = mode === 'web'
|
|
363
|
-
? (cell.entries.some((e) => /Error/.test(e.reason)) ? 'inactive-error'
|
|
364
|
-
: cell.entries.length > 0 ? 'awaiting-service'
|
|
365
|
-
: cell.ready ? 'active' : 'boot-failed')
|
|
366
|
-
: classify(booted.out, cell.entries, tools ? Boolean(after) : undefined);
|
|
367
|
-
cell.note = noteOf(booted.out);
|
|
368
|
-
|
|
369
|
-
// Web mode has no task to run, so there is no model request to read tools from.
|
|
370
|
-
if (tools && mode !== 'web') {
|
|
371
|
-
if (before && after) {
|
|
372
|
-
const names = (p) => p.tools.map((t) => t.name);
|
|
373
|
-
const b = new Set(names(before));
|
|
374
|
-
const a = new Set(names(after));
|
|
375
|
-
cell.tools = { baseline: b.size, total: a.size, added: [...a].filter((x) => !b.has(x)), removed: [...b].filter((x) => !a.has(x)) };
|
|
376
|
-
cell.systemPrompt = { baselineChars: JSON.stringify(before.system ?? '').length, chars: JSON.stringify(after.system ?? '').length };
|
|
377
|
-
const pkgs = (p) => new Set((p.dsh_plugin_packages?.packages ?? []).map((x) => x.name + '@' + x.version));
|
|
378
|
-
const pb = pkgs(before);
|
|
379
|
-
cell.packagesAdded = [...pkgs(after)].filter((x) => !pb.has(x));
|
|
380
|
-
} else {
|
|
381
|
-
cell.tools = { error: 'the harness sent no tool-bearing request' };
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
return cell;
|
|
385
|
-
} finally {
|
|
386
|
-
cell.durationMs = Date.now() - started;
|
|
387
|
-
if (!keep) rmSync(home, { recursive: true, force: true });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
34
|
export const tmpHome = () => mkdtempSync(join(tmpdir(), 'dsh-probe-'));
|