@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { ToolError } from '../shared/errors.js';
|
|
4
|
+
import { sha256 } from '../shared/hash.js';
|
|
5
|
+
import { validateManifest } from './schema.js';
|
|
6
|
+
import { verifyPublicArtifact } from './export.js';
|
|
7
|
+
|
|
8
|
+
const MAX_FILES = 10000;
|
|
9
|
+
const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function safeRelative(path) {
|
|
12
|
+
return typeof path === 'string' && !path.startsWith('/') && !path.includes('\\') && path !== '..' && !path.startsWith('../') && !path.includes('/../') && !path.includes('\0');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function syncMatrix({ source, outDir, fetchImpl = fetch }) {
|
|
16
|
+
if (!source) throw new ToolError('MATRIX_SOURCE_REQUIRED', 'An explicit matrix source is required.');
|
|
17
|
+
if (!outDir) throw new ToolError('MATRIX_OUTPUT_REQUIRED', 'An explicit sync output directory is required.');
|
|
18
|
+
const remote = /^https:\/\//.test(source);
|
|
19
|
+
const base = remote ? new URL(source.endsWith('/') ? source : source + '/') : realpathSync(source);
|
|
20
|
+
async function readSource(path) {
|
|
21
|
+
if (!safeRelative(path)) throw new ToolError('MANIFEST_INVALID', 'The source contains an unsafe artifact path.');
|
|
22
|
+
if (remote) {
|
|
23
|
+
const url = new URL(path, base);
|
|
24
|
+
if (url.protocol !== 'https:') throw new ToolError('MATRIX_SOURCE_UNSUPPORTED', 'Only HTTPS matrix sources are supported.');
|
|
25
|
+
const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(10000) });
|
|
26
|
+
if (!response.ok) throw new ToolError('MATRIX_SOURCE_UNAVAILABLE', `The matrix source returned HTTP ${response.status}.`);
|
|
27
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
28
|
+
if (bytes.length > MAX_ARTIFACT_BYTES) throw new ToolError('ARTIFACT_TOO_LARGE', 'A matrix artifact exceeds the size limit.');
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
|
31
|
+
const pathOnDisk = realpathSync(resolve(base, path));
|
|
32
|
+
if (pathOnDisk !== base && !pathOnDisk.startsWith(base + sep)) throw new ToolError('MANIFEST_INVALID', 'A source file escapes the source directory.');
|
|
33
|
+
const bytes = readFileSync(pathOnDisk);
|
|
34
|
+
if (bytes.length > MAX_ARTIFACT_BYTES) throw new ToolError('ARTIFACT_TOO_LARGE', 'A matrix artifact exceeds the size limit.');
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const manifest = JSON.parse((await readSource('manifest.json')).toString('utf8'));
|
|
39
|
+
const validation = validateManifest(manifest);
|
|
40
|
+
if (!validation.ok) throw new ToolError('MANIFEST_INVALID', validation.errors.join('; '));
|
|
41
|
+
if (manifest.artifacts.length > MAX_FILES) throw new ToolError('ARTIFACT_TOO_LARGE', 'The matrix declares too many files.');
|
|
42
|
+
const parent = dirname(outDir);
|
|
43
|
+
mkdirSync(parent, { recursive: true });
|
|
44
|
+
const staging = mkdtempSync(join(parent, '.sync-'));
|
|
45
|
+
try {
|
|
46
|
+
for (const artifact of manifest.artifacts) {
|
|
47
|
+
const bytes = await readSource(artifact.path);
|
|
48
|
+
if (bytes.length !== artifact.bytes || sha256(bytes) !== artifact.sha256) {
|
|
49
|
+
throw new ToolError('ARTIFACT_INTEGRITY_MISMATCH', `Artifact integrity mismatch: ${artifact.path}`);
|
|
50
|
+
}
|
|
51
|
+
const destination = join(staging, artifact.path);
|
|
52
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
53
|
+
writeFileSync(destination, bytes);
|
|
54
|
+
}
|
|
55
|
+
writeFileSync(join(staging, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
56
|
+
const verification = await verifyPublicArtifact({ dir: staging });
|
|
57
|
+
if (!verification.ok) throw new ToolError('ARTIFACT_INTEGRITY_MISMATCH', verification.errors.join('; '));
|
|
58
|
+
const backup = `${outDir}.backup-${Date.now()}`;
|
|
59
|
+
if (existsSync(outDir)) renameSync(outDir, backup);
|
|
60
|
+
try {
|
|
61
|
+
renameSync(staging, outDir);
|
|
62
|
+
rmSync(backup, { recursive: true, force: true });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (existsSync(backup)) renameSync(backup, outDir);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return { manifest, filesWritten: manifest.artifacts.length + 1 };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
rmSync(staging, { recursive: true, force: true });
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { ToolError } from '../shared/errors.js';
|
|
4
|
+
import { PROBE_VERSION } from '../shared/version.js';
|
|
5
|
+
import { readCells, writeCell } from './cells.js';
|
|
6
|
+
import { validateCell } from './schema.js';
|
|
7
|
+
import { resolveArtifact } from '../probe/artifact-resolver.js';
|
|
8
|
+
import { createProbeEnvironment } from '../probe/environment.js';
|
|
9
|
+
import { probePackage } from '../probe/probe.js';
|
|
10
|
+
|
|
11
|
+
export function sampleCells(cells, { sample = 0.2, seed = 1, all = false } = {}) {
|
|
12
|
+
if (all) return [...cells];
|
|
13
|
+
if (sample <= 0) return [];
|
|
14
|
+
let state = seed;
|
|
15
|
+
const next = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;
|
|
16
|
+
return cells.filter(() => next() < Math.min(1, sample));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function publicError(error) {
|
|
20
|
+
return { code: error?.code || 'VERIFICATION_FAILED', message: error?.code ? error.message : 'The verification probe could not complete.' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function verifyCells({ cells, probe, verifier = { workflow: 'local', runId: 'local', url: '', probeVersion: PROBE_VERSION }, concurrency = 2 }) {
|
|
24
|
+
const report = {
|
|
25
|
+
schemaVersion: 2,
|
|
26
|
+
at: new Date().toISOString(),
|
|
27
|
+
checked: 0,
|
|
28
|
+
confirmed: 0,
|
|
29
|
+
disputed: 0,
|
|
30
|
+
errors: 0,
|
|
31
|
+
cells: [],
|
|
32
|
+
verifier,
|
|
33
|
+
};
|
|
34
|
+
const queue = [...cells];
|
|
35
|
+
async function worker() {
|
|
36
|
+
while (queue.length > 0) {
|
|
37
|
+
const cell = queue.shift();
|
|
38
|
+
const entry = {
|
|
39
|
+
cacheKey: cell.cacheKey,
|
|
40
|
+
plugin: cell.plugin.name,
|
|
41
|
+
dsh: cell.dsh.version,
|
|
42
|
+
composition: cell.dsh.composition,
|
|
43
|
+
reported: { status: cell.result.status },
|
|
44
|
+
verification: verifier,
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const observation = await probe(cell);
|
|
48
|
+
if (observation.outcome === 'error') {
|
|
49
|
+
entry.verdict = 'error';
|
|
50
|
+
entry.error = observation.error;
|
|
51
|
+
report.errors += 1;
|
|
52
|
+
} else {
|
|
53
|
+
entry.observed = { status: observation.result.status };
|
|
54
|
+
entry.verdict = observation.result.status === cell.result.status ? 'confirmed' : 'disputed';
|
|
55
|
+
if (entry.verdict === 'confirmed') report.confirmed += 1;
|
|
56
|
+
else report.disputed += 1;
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
entry.verdict = 'error';
|
|
60
|
+
entry.error = publicError(error);
|
|
61
|
+
report.errors += 1;
|
|
62
|
+
}
|
|
63
|
+
report.cells.push(entry);
|
|
64
|
+
report.checked += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), Math.max(1, queue.length)) }, worker));
|
|
68
|
+
return report;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function verifyMatrix({ cellsDir, sample = 0.2, seed = 1, all = false, concurrency = 2, probe, verifier }) {
|
|
72
|
+
const reads = readCells(cellsDir);
|
|
73
|
+
const damaged = reads.filter((entry) => !entry.ok);
|
|
74
|
+
if (damaged.length > 0) throw new ToolError('VERIFICATION_INPUT_INVALID', `Cannot read ${damaged.length} cell file(s).`);
|
|
75
|
+
const candidates = reads.filter((entry) => entry.kind === 'v2' && entry.cell.run.trust !== 'verified').map((entry) => entry.cell);
|
|
76
|
+
const selected = sampleCells(candidates, { sample, seed, all });
|
|
77
|
+
const report = await verifyCells({ cells: selected, probe, verifier, concurrency });
|
|
78
|
+
report.seed = seed;
|
|
79
|
+
report.sample = sample;
|
|
80
|
+
return report;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createVerificationProbe({ cacheRoot, backend = 'auto' }) {
|
|
84
|
+
return async (cell) => {
|
|
85
|
+
const artifact = await resolveArtifact({
|
|
86
|
+
name: cell.plugin.name,
|
|
87
|
+
version: cell.plugin.resolvedVersion,
|
|
88
|
+
integrity: cell.plugin.integrity,
|
|
89
|
+
...(cell.plugin.local ? { path: cell.plugin.path, local: true } : {}),
|
|
90
|
+
});
|
|
91
|
+
const environment = await createProbeEnvironment({
|
|
92
|
+
backend,
|
|
93
|
+
workDir: cacheRoot,
|
|
94
|
+
dshDir: cell.dsh.source === 'desktop' ? undefined : join(cacheRoot, 'dsh', cell.dsh.version),
|
|
95
|
+
});
|
|
96
|
+
const dsh = {
|
|
97
|
+
kind: cell.dsh.source === 'desktop' ? 'desktop' : 'npm',
|
|
98
|
+
version: cell.dsh.version,
|
|
99
|
+
source: cell.dsh.source,
|
|
100
|
+
composition: cell.dsh.composition,
|
|
101
|
+
};
|
|
102
|
+
return probePackage({ artifact, dsh, environment, tools: cell.dsh.composition !== 'web', composition: cell.dsh.composition, audit: true });
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function applyVerification(cell, reportEntry, { trustedVerifier = false } = {}) {
|
|
107
|
+
if (cell.schemaVersion !== 2) throw new ToolError('CELL_SCHEMA_UNSUPPORTED', 'Only v2 cells can accept verification.');
|
|
108
|
+
if (cell.cacheKey !== reportEntry.cacheKey) throw new ToolError('VERIFICATION_KEY_MISMATCH', 'The verification belongs to a different artifact or composition.');
|
|
109
|
+
if (cell.run.outcome !== 'completed' || reportEntry.reported?.status !== cell.result?.status) {
|
|
110
|
+
throw new ToolError('VERIFICATION_REPORT_MISMATCH', 'The verification does not match the reported observation.');
|
|
111
|
+
}
|
|
112
|
+
if (reportEntry.verdict === 'error') throw new ToolError('VERIFICATION_INCOMPLETE', 'Infrastructure errors are not plugin observations.');
|
|
113
|
+
const updated = structuredClone(cell);
|
|
114
|
+
const verification = reportEntry.verification;
|
|
115
|
+
if (reportEntry.verdict === 'disputed' || reportEntry.observed?.status !== cell.result.status) {
|
|
116
|
+
updated.run.trust = 'disputed';
|
|
117
|
+
updated.run.dispute = { reported: cell.result.status, observed: reportEntry.observed.status };
|
|
118
|
+
updated.run.disputedAt = new Date().toISOString();
|
|
119
|
+
if (verification) updated.run.verification = verification;
|
|
120
|
+
} else if (reportEntry.verdict === 'confirmed') {
|
|
121
|
+
if (trustedVerifier) {
|
|
122
|
+
if (!verification || typeof verification.workflow !== 'string' || !verification.workflow
|
|
123
|
+
|| typeof verification.runId !== 'string' || !verification.runId
|
|
124
|
+
|| typeof verification.url !== 'string' || !verification.url
|
|
125
|
+
|| verification.probeVersion !== cell.probe.version) {
|
|
126
|
+
throw new ToolError('VERIFIER_IDENTITY_INVALID', 'Trusted verification needs complete workflow provenance and a matching probe version.');
|
|
127
|
+
}
|
|
128
|
+
updated.run.trust = 'verified';
|
|
129
|
+
updated.run.verification = verification;
|
|
130
|
+
updated.run.verifiedAt = new Date().toISOString();
|
|
131
|
+
delete updated.run.dispute;
|
|
132
|
+
} else {
|
|
133
|
+
updated.run.trust = cell.run.trust;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
throw new ToolError('VERIFICATION_INCOMPLETE', 'The verification has no completed observed status.');
|
|
137
|
+
}
|
|
138
|
+
const validation = validateCell(updated);
|
|
139
|
+
if (!validation.ok) throw new ToolError('CELL_INVALID', validation.errors.join('; '));
|
|
140
|
+
return updated;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function runVerification({ cellsDir, cacheRoot, sample = 0.2, seed = 1, all = false, concurrency = 2, backend = 'auto', workflow = 'local', runId = 'local', runUrl = '', out }) {
|
|
144
|
+
const probe = createVerificationProbe({ cacheRoot, backend });
|
|
145
|
+
const verifier = { workflow, runId, url: runUrl, probeVersion: PROBE_VERSION };
|
|
146
|
+
const report = await verifyMatrix({ cellsDir, sample, seed, all, concurrency, probe, verifier });
|
|
147
|
+
if (out) {
|
|
148
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
149
|
+
writeFileSync(out, JSON.stringify(report, null, 2) + '\n');
|
|
150
|
+
}
|
|
151
|
+
return { report, out };
|
|
152
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './experimental/performance.js';
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { ToolError } from '../shared/errors.js';
|
|
6
|
+
|
|
7
|
+
const REGISTRY = 'https://registry.npmjs.org/';
|
|
8
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
9
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
|
10
|
+
|
|
11
|
+
function parseSpec(value) {
|
|
12
|
+
const text = String(value);
|
|
13
|
+
const split = text.startsWith('@') ? text.lastIndexOf('@') > text.indexOf('/') : text.lastIndexOf('@') > 0;
|
|
14
|
+
if (!split) return { name: text, version: 'latest' };
|
|
15
|
+
const index = text.lastIndexOf('@');
|
|
16
|
+
return { name: text.slice(0, index), version: text.slice(index + 1) || 'latest' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validateName(name) {
|
|
20
|
+
if (typeof name !== 'string' || !PACKAGE_NAME.test(name)) {
|
|
21
|
+
throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'The artifact package name is invalid.');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateRequestedVersion(version) {
|
|
26
|
+
if (EXACT_VERSION.test(version)) return version;
|
|
27
|
+
if (/^[A-Za-z][A-Za-z0-9._-]*$/.test(version)) return version;
|
|
28
|
+
throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', `Artifact version ranges are not reproducible inputs: ${version}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function localArtifact(target) {
|
|
32
|
+
const root = resolve(target.path || target);
|
|
33
|
+
const manifestPath = join(root, 'package.json');
|
|
34
|
+
let manifest;
|
|
35
|
+
try {
|
|
36
|
+
if (!statSync(root).isDirectory()) throw new Error('not a directory');
|
|
37
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'Local artifacts must be package directories with a readable package.json.', { cause });
|
|
40
|
+
}
|
|
41
|
+
const name = manifest.name || target.name;
|
|
42
|
+
validateName(name);
|
|
43
|
+
if (!EXACT_VERSION.test(manifest.version || '')) {
|
|
44
|
+
throw new ToolError('ARTIFACT_METADATA_MISMATCH', 'The local package manifest has no exact version.');
|
|
45
|
+
}
|
|
46
|
+
const hash = createHash('sha256');
|
|
47
|
+
function walk(current) {
|
|
48
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
49
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
50
|
+
const absolute = join(current, entry.name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
walk(absolute);
|
|
53
|
+
} else if (entry.isFile()) {
|
|
54
|
+
const relative = absolute.slice(root.length + 1).replace(/\\/g, '/');
|
|
55
|
+
hash.update(relative);
|
|
56
|
+
hash.update('\0');
|
|
57
|
+
hash.update(readFileSync(absolute));
|
|
58
|
+
hash.update('\0');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
walk(root);
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
requestedVersion: 'local',
|
|
66
|
+
resolvedVersion: manifest.version,
|
|
67
|
+
integrity: undefined,
|
|
68
|
+
tarballUrl: undefined,
|
|
69
|
+
installTarget: root,
|
|
70
|
+
local: true,
|
|
71
|
+
path: root,
|
|
72
|
+
sourceDigest: hash.digest('hex'),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function resolveArtifact(target, { fetchImpl = fetch } = {}) {
|
|
77
|
+
if ((typeof target === 'string' && existsSync(target)) || (target && typeof target === 'object' && (target.local || target.path))) {
|
|
78
|
+
return localArtifact(target);
|
|
79
|
+
}
|
|
80
|
+
const input = typeof target === 'string' ? parseSpec(target) : { name: target?.name, version: target?.version ?? 'latest', integrity: target?.integrity };
|
|
81
|
+
validateName(input.name);
|
|
82
|
+
const requestedVersion = validateRequestedVersion(input.version || 'latest');
|
|
83
|
+
const url = REGISTRY + encodeURIComponent(input.name);
|
|
84
|
+
let response;
|
|
85
|
+
try {
|
|
86
|
+
response = await fetchImpl(url, { headers: { accept: 'application/vnd.npm.install-v1+json' } });
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', 'The npm registry could not be read.', { cause });
|
|
89
|
+
}
|
|
90
|
+
if (!response?.ok) throw new ToolError('ARTIFACT_REGISTRY_ERROR', `The npm registry returned HTTP ${response?.status ?? 'unknown'}.`);
|
|
91
|
+
let metadata;
|
|
92
|
+
try {
|
|
93
|
+
metadata = await response.json();
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', 'The npm registry returned invalid JSON.', { cause });
|
|
96
|
+
}
|
|
97
|
+
const resolvedVersion = EXACT_VERSION.test(requestedVersion)
|
|
98
|
+
? requestedVersion
|
|
99
|
+
: metadata?.['dist-tags']?.[requestedVersion];
|
|
100
|
+
if (!EXACT_VERSION.test(resolvedVersion || '')) {
|
|
101
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', `The registry did not provide an exact version for ${input.name}@${requestedVersion}.`);
|
|
102
|
+
}
|
|
103
|
+
const versionMetadata = metadata?.versions?.[resolvedVersion];
|
|
104
|
+
if (!versionMetadata || (versionMetadata.version !== undefined && versionMetadata.version !== resolvedVersion)) {
|
|
105
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', 'The registry metadata does not contain the resolved version.');
|
|
106
|
+
}
|
|
107
|
+
const integrity = versionMetadata?.dist?.integrity;
|
|
108
|
+
const tarballUrl = versionMetadata?.dist?.tarball;
|
|
109
|
+
if (typeof integrity !== 'string' || typeof tarballUrl !== 'string') {
|
|
110
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', 'The registry artifact is missing tarball or integrity metadata.');
|
|
111
|
+
}
|
|
112
|
+
let parsedTarball;
|
|
113
|
+
try {
|
|
114
|
+
parsedTarball = new URL(tarballUrl);
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', 'The registry returned an invalid tarball URL.', { cause });
|
|
117
|
+
}
|
|
118
|
+
if (parsedTarball.protocol !== 'https:' || parsedTarball.hostname !== 'registry.npmjs.org') {
|
|
119
|
+
throw new ToolError('ARTIFACT_REGISTRY_ERROR', 'The registry returned a tarball outside the explicit registry origin.');
|
|
120
|
+
}
|
|
121
|
+
if (input.integrity !== undefined && input.integrity !== integrity) {
|
|
122
|
+
throw new ToolError('ARTIFACT_INTEGRITY_MISMATCH', 'The target integrity differs from the registry metadata.');
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name: input.name,
|
|
126
|
+
requestedVersion,
|
|
127
|
+
resolvedVersion,
|
|
128
|
+
integrity,
|
|
129
|
+
tarballUrl,
|
|
130
|
+
installTarget: `${input.name}@${resolvedVersion}`,
|
|
131
|
+
local: false,
|
|
132
|
+
sourceDigest: integrity,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function verifyIntegrity(bytes, integrity) {
|
|
137
|
+
if (typeof integrity !== 'string') return false;
|
|
138
|
+
const match = /^([A-Za-z0-9]+)-([A-Za-z0-9+/=]+)$/.exec(integrity);
|
|
139
|
+
if (!match || !['sha256', 'sha384', 'sha512'].includes(match[1])) return false;
|
|
140
|
+
try {
|
|
141
|
+
const actual = createHash(match[1]).update(bytes).digest();
|
|
142
|
+
const expected = Buffer.from(match[2], 'base64');
|
|
143
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function downloadArtifact(artifact, { fetchImpl = fetch, destination } = {}) {
|
|
150
|
+
if (artifact.local) return { ...artifact, tarballPath: artifact.path };
|
|
151
|
+
const response = await fetchImpl(artifact.tarballUrl);
|
|
152
|
+
if (!response?.ok) throw new ToolError('ARTIFACT_DOWNLOAD_FAILED', `The artifact download returned HTTP ${response?.status ?? 'unknown'}.`);
|
|
153
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
154
|
+
if (!verifyIntegrity(bytes, artifact.integrity)) {
|
|
155
|
+
throw new ToolError('ARTIFACT_INTEGRITY_MISMATCH', 'The downloaded artifact does not match its pinned integrity.');
|
|
156
|
+
}
|
|
157
|
+
const target = destination || join(mkdtempSync(join(tmpdir(), 'dsh-artifact-')), basename(artifact.tarballUrl));
|
|
158
|
+
mkdirSync(join(target, '..'), { recursive: true });
|
|
159
|
+
writeFileSync(target, bytes);
|
|
160
|
+
return { ...artifact, tarballPath: target };
|
|
161
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function classify(bootText, entries, completionSignal) {
|
|
2
|
+
const failing = entries.filter((entry) => !/waiting for service/.test(entry.reason || ''));
|
|
3
|
+
if (failing.length > 0) return 'inactive-error';
|
|
4
|
+
if (entries.length > 0) return 'awaiting-service';
|
|
5
|
+
if (completionSignal === true) return 'active';
|
|
6
|
+
if (completionSignal === false) return 'boot-failed';
|
|
7
|
+
return /MISSING_CREDENTIAL/.test(bootText) ? 'active' : 'boot-failed';
|
|
8
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { ToolError } from '../shared/errors.js';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const DESKTOP_ELECTRON = process.env.DSH_ELECTRON || '/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness';
|
|
10
|
+
const DESKTOP_CLI = process.env.DSH_CLI || '/Applications/DeepSeek Harness.app/Contents/Resources/app.asar/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js';
|
|
11
|
+
const DESKTOP_APP = '/Applications/DeepSeek Harness.app';
|
|
12
|
+
const AMBIENT_KEYS = ['PATH', 'HOME', 'NO_COLOR', 'LANG', 'LC_ALL', 'LC_CTYPE'];
|
|
13
|
+
const EXPLICIT_KEYS = new Set([...AMBIENT_KEYS, 'DSH_HOME', 'ELECTRON_RUN_AS_NODE', 'PROBE_API_KEY', 'NARB_DISABLE_NATIVE_CACHE', 'npm_config_cache']);
|
|
14
|
+
|
|
15
|
+
export function safeEnv(overrides = {}) {
|
|
16
|
+
const env = {};
|
|
17
|
+
for (const key of AMBIENT_KEYS) {
|
|
18
|
+
if (typeof process.env[key] === 'string') env[key] = process.env[key];
|
|
19
|
+
}
|
|
20
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
21
|
+
if (EXPLICIT_KEYS.has(key) && value !== undefined) env[key] = String(value);
|
|
22
|
+
}
|
|
23
|
+
return env;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function execFileRunner(command, args, options = {}) {
|
|
27
|
+
return execFileAsync(command, args, {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* In-flight container harness installs, keyed by the shared dshDir. Matrix cells
|
|
36
|
+
* with the same DSH version reuse one install instead of racing four downloads
|
|
37
|
+
* into the same tree.
|
|
38
|
+
*/
|
|
39
|
+
const dockerDshInstalls = new Map();
|
|
40
|
+
|
|
41
|
+
export async function resolveDsh(version, cacheRoot, { runner = execFileRunner, environment } = {}) {
|
|
42
|
+
if (!version || version === 'desktop') {
|
|
43
|
+
// The CLI lives inside app.asar, which existsSync cannot traverse; check the
|
|
44
|
+
// app bundle (or an explicit override) instead.
|
|
45
|
+
const available = Boolean(process.env.DSH_CLI) || existsSync(DESKTOP_APP) || existsSync(DESKTOP_CLI);
|
|
46
|
+
if (!available) {
|
|
47
|
+
throw new ToolError('DSH_NOT_FOUND', 'The desktop DSH was not found; pass --dsh <npm-version> to use the Docker backend.');
|
|
48
|
+
}
|
|
49
|
+
return { id: 'desktop', kind: 'desktop', version: 'desktop', node: DESKTOP_ELECTRON, cli: DESKTOP_CLI, extraArgs: ['--expose-internals'] };
|
|
50
|
+
}
|
|
51
|
+
if (environment?.isolation === 'docker') {
|
|
52
|
+
const dir = environment.dshDir;
|
|
53
|
+
const cli = join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
54
|
+
if (!existsSync(cli)) {
|
|
55
|
+
let pending = dockerDshInstalls.get(dir);
|
|
56
|
+
if (!pending) {
|
|
57
|
+
pending = (async () => {
|
|
58
|
+
await environment.boot({
|
|
59
|
+
dsh: { kind: 'npm', cli: '/usr/local/lib/node_modules/npm/bin/npm-cli.js', extraArgs: [] },
|
|
60
|
+
args: [
|
|
61
|
+
'install', '--prefix', '/dsh', '--no-audit', '--no-fund', '--silent',
|
|
62
|
+
// The shared tool volume keeps the harness tarballs across cells
|
|
63
|
+
// and across DSH versions instead of downloading them again.
|
|
64
|
+
'--cache', '/tools/npm', '--registry=https://registry.npmjs.org/',
|
|
65
|
+
'@deepseek-ai/dsh@' + version,
|
|
66
|
+
],
|
|
67
|
+
env: { HOME: '/profile' },
|
|
68
|
+
network: 'registry',
|
|
69
|
+
writableDsh: true,
|
|
70
|
+
// Installing the harness is a one-time cold-cache download. A cold run can
|
|
71
|
+
// take 15 minutes on a slow network; the normal boot timeout is far
|
|
72
|
+
// too short for it.
|
|
73
|
+
timeoutMs: 1200000,
|
|
74
|
+
});
|
|
75
|
+
})().finally(() => dockerDshInstalls.delete(dir));
|
|
76
|
+
dockerDshInstalls.set(dir, pending);
|
|
77
|
+
}
|
|
78
|
+
await pending;
|
|
79
|
+
}
|
|
80
|
+
if (!existsSync(cli)) throw new ToolError('ARTIFACT_MISSING', 'DSH installation did not produce the expected CLI.');
|
|
81
|
+
return {
|
|
82
|
+
id: 'npm:' + version,
|
|
83
|
+
kind: 'npm',
|
|
84
|
+
version,
|
|
85
|
+
node: process.execPath,
|
|
86
|
+
cli,
|
|
87
|
+
containerNode: 'node',
|
|
88
|
+
containerCli: '/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js',
|
|
89
|
+
extraArgs: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const dir = join(cacheRoot, 'dsh', version);
|
|
93
|
+
const cli = join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
94
|
+
if (!existsSync(cli)) {
|
|
95
|
+
const home = join(dir, 'home');
|
|
96
|
+
mkdirSync(home, { recursive: true });
|
|
97
|
+
await runner('npm', [
|
|
98
|
+
'install', '--prefix', dir, '--no-audit', '--no-fund', '--silent',
|
|
99
|
+
'--cache', join(dir, 'npm-cache'), '--registry=https://registry.npmjs.org/',
|
|
100
|
+
'@deepseek-ai/dsh@' + version,
|
|
101
|
+
], { env: safeEnv({ HOME: home }), timeout: 600000, maxBuffer: 16 * 1024 * 1024 });
|
|
102
|
+
}
|
|
103
|
+
if (!existsSync(cli)) throw new ToolError('ARTIFACT_MISSING', 'DSH installation did not produce the expected CLI.');
|
|
104
|
+
return { id: 'npm:' + version, kind: 'npm', version, node: process.execPath, cli, extraArgs: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function startStub() {
|
|
108
|
+
const seen = [];
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
let raw = '';
|
|
111
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
112
|
+
req.on('end', () => {
|
|
113
|
+
let body = null;
|
|
114
|
+
try { body = JSON.parse(raw); } catch { /* keep null */ }
|
|
115
|
+
if (body) seen.push(body);
|
|
116
|
+
if (!body || !body.stream) {
|
|
117
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
118
|
+
res.end(JSON.stringify({
|
|
119
|
+
id: 'msg_probe', type: 'message', role: 'assistant', model: body?.model || 'probe',
|
|
120
|
+
content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', stop_sequence: null,
|
|
121
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
122
|
+
}));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const sse = (event, data) => res.write('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n');
|
|
126
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
127
|
+
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 } } });
|
|
128
|
+
sse('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
129
|
+
sse('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } });
|
|
130
|
+
sse('content_block_stop', { type: 'content_block_stop', index: 0 });
|
|
131
|
+
sse('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
|
132
|
+
sse('message_stop', { type: 'message_stop' });
|
|
133
|
+
res.end();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
server.listen(0, '127.0.0.1', () => resolve({ server, seen, port: server.address().port }));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function runDsh(dsh, home, args, env = {}, { runner = execFileRunner, timeoutMs = 180000 } = {}) {
|
|
142
|
+
const { stdout, stderr } = await runner(dsh.node, [...dsh.extraArgs, dsh.cli, ...args], {
|
|
143
|
+
env: safeEnv({ DSH_HOME: home, ...(dsh.kind === 'desktop' ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...env }),
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
146
|
+
timeout: timeoutMs,
|
|
147
|
+
});
|
|
148
|
+
return String(stdout || '') + String(stderr || '');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function runUntilReady(dsh, home, args, env, readyRe, options = {}) {
|
|
152
|
+
const timeoutMs = options.timeoutMs ?? 90000;
|
|
153
|
+
const graceMs = options.graceMs ?? 2500;
|
|
154
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const child = spawnImpl(dsh.node, [...dsh.extraArgs, dsh.cli, ...args], {
|
|
157
|
+
env: safeEnv({ DSH_HOME: home, ...(dsh.kind === 'desktop' ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...env }),
|
|
158
|
+
detached: process.platform !== 'win32',
|
|
159
|
+
});
|
|
160
|
+
let out = '';
|
|
161
|
+
let finished = false;
|
|
162
|
+
let readyScheduled = false;
|
|
163
|
+
// Keep the grace and timeout timers referenced until the promise settles.
|
|
164
|
+
// An unref'd timer does not keep the event loop alive, so on a fast or
|
|
165
|
+
// containerized scheduler the loop can be considered idle — and the test
|
|
166
|
+
// runner cancels the pending promise — before the grace timer fires. The
|
|
167
|
+
// timers are cleared in finish(), so referencing them here changes nothing
|
|
168
|
+
// about how long the process lives once the child is done.
|
|
169
|
+
const timers = new Set();
|
|
170
|
+
const later = (fn, ms) => {
|
|
171
|
+
const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms);
|
|
172
|
+
timers.add(timer);
|
|
173
|
+
return timer;
|
|
174
|
+
};
|
|
175
|
+
const signal = (name) => {
|
|
176
|
+
if (child.pid && process.platform !== 'win32') {
|
|
177
|
+
try { process.kill(-child.pid, name); return; } catch { /* fall back to the direct child */ }
|
|
178
|
+
}
|
|
179
|
+
child.kill(name);
|
|
180
|
+
};
|
|
181
|
+
const finish = () => {
|
|
182
|
+
if (finished) return;
|
|
183
|
+
finished = true;
|
|
184
|
+
for (const timer of timers) clearTimeout(timer);
|
|
185
|
+
timers.clear();
|
|
186
|
+
signal('SIGTERM');
|
|
187
|
+
// The SIGKILL backstop is best-effort cleanup after resolve: it stays
|
|
188
|
+
// unref'd so it never extends the process lifetime past settlement.
|
|
189
|
+
setTimeout(() => signal('SIGKILL'), 1500).unref();
|
|
190
|
+
resolve(out);
|
|
191
|
+
};
|
|
192
|
+
const feed = (chunk) => {
|
|
193
|
+
out += chunk;
|
|
194
|
+
if (!readyScheduled && readyRe.test(out)) {
|
|
195
|
+
readyScheduled = true;
|
|
196
|
+
later(finish, graceMs);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
child.stdout.on('data', feed);
|
|
200
|
+
child.stderr.on('data', feed);
|
|
201
|
+
child.on('exit', () => {
|
|
202
|
+
if (finished) return;
|
|
203
|
+
finished = true;
|
|
204
|
+
for (const timer of timers) clearTimeout(timer);
|
|
205
|
+
timers.clear();
|
|
206
|
+
resolve(out);
|
|
207
|
+
});
|
|
208
|
+
later(finish, timeoutMs);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const WEB_READY = /dsh web:\s*https?:\/\//;
|