@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,270 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ToolError } from '../../shared/errors.js';
|
|
6
|
+
import { safeEnv } from '../dsh-adapter.js';
|
|
7
|
+
|
|
8
|
+
const IMAGE = 'node:22-bookworm-slim';
|
|
9
|
+
// node:22-bookworm-slim ships npm/npx but not pnpm, and `dsh plugin add`
|
|
10
|
+
// spawns pnpm from PATH. The shim is written into the writable profile mount
|
|
11
|
+
// and prepended to PATH; npx downloads the pinned pnpm on first use.
|
|
12
|
+
// pnpm 9 refuses `pnpm add` in a workspace root without -w; DSH invokes it
|
|
13
|
+
// without that flag, and the profile is a pnpm workspace. pnpm 11 accepts it.
|
|
14
|
+
const PNPM_SPEC = 'pnpm@11';
|
|
15
|
+
const PNPM_SHIM = '#!/bin/sh\nexec npx --yes ' + PNPM_SPEC + ' "$@"\n';
|
|
16
|
+
const CONTAINER_PATH = '/profile/.probe-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
17
|
+
const STUB_SCRIPT = `
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import { appendFileSync } from 'node:fs';
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
let raw = '';
|
|
22
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
23
|
+
req.on('end', () => {
|
|
24
|
+
let body = null;
|
|
25
|
+
try { body = JSON.parse(raw); } catch { body = null; }
|
|
26
|
+
if (body) appendFileSync('/profile/stub-seen.jsonl', JSON.stringify(body) + '\\n');
|
|
27
|
+
if (!body || !body.stream) {
|
|
28
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
29
|
+
res.end(JSON.stringify({ id: 'msg_probe', type: 'message', role: 'assistant', model: body?.model || 'probe', content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const sse = (event, data) => res.write('event: ' + event + '\\ndata: ' + JSON.stringify(data) + '\\n\\n');
|
|
33
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
34
|
+
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 } } });
|
|
35
|
+
sse('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
36
|
+
sse('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } });
|
|
37
|
+
sse('content_block_stop', { type: 'content_block_stop', index: 0 });
|
|
38
|
+
sse('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
|
39
|
+
sse('message_stop', { type: 'message_stop' });
|
|
40
|
+
res.end();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
server.listen(3000, '0.0.0.0', () => console.log('PROBE_STUB_READY'));
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
export async function preflightDocker({ runner, timeoutMs = 10000 }) {
|
|
47
|
+
await runner('docker', ['version', '--format', '{{.Server.Version}}'], {
|
|
48
|
+
env: safeEnv(),
|
|
49
|
+
timeout: timeoutMs,
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
maxBuffer: 1024 * 1024,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createDockerEnvironment({ workDir, sourceDir, profileDir, dshDir, toolDir, timeoutMs, network, runner, materialize }) {
|
|
56
|
+
const containers = new Set();
|
|
57
|
+
const networks = new Set();
|
|
58
|
+
|
|
59
|
+
// `dsh plugin add` spawns pnpm, which node:22-bookworm-slim does not ship.
|
|
60
|
+
// The shim lives in the writable profile mount and is first on PATH.
|
|
61
|
+
const probeBin = join(profileDir, '.probe-bin');
|
|
62
|
+
mkdirSync(probeBin, { recursive: true });
|
|
63
|
+
writeFileSync(join(probeBin, 'pnpm'), PNPM_SHIM, { mode: 0o755 });
|
|
64
|
+
|
|
65
|
+
function dockerArgs(name, dsh, args, env, requestedNetwork, { writableDsh = false } = {}) {
|
|
66
|
+
const networkName = requestedNetwork === 'registry' || requestedNetwork === 'bridge' ? 'bridge' : (requestedNetwork || 'none');
|
|
67
|
+
const containerEnv = safeEnv({
|
|
68
|
+
HOME: '/profile',
|
|
69
|
+
DSH_HOME: '/profile',
|
|
70
|
+
NO_COLOR: '1',
|
|
71
|
+
PATH: CONTAINER_PATH,
|
|
72
|
+
// The native-addon loader copies .node files into os.tmpdir() and dlopens
|
|
73
|
+
// them there; Docker mounts /tmp noexec, so the copy cannot be mapped.
|
|
74
|
+
// Loading straight from the read-only package tree works.
|
|
75
|
+
NARB_DISABLE_NATIVE_CACHE: '1',
|
|
76
|
+
// Share the npx/pnpm download cache across cells when the environment
|
|
77
|
+
// provides a shared tool directory.
|
|
78
|
+
...(toolDir ? { npm_config_cache: '/tools/npm' } : {}),
|
|
79
|
+
...env,
|
|
80
|
+
});
|
|
81
|
+
return [
|
|
82
|
+
'run', '--rm', '--name', name,
|
|
83
|
+
'--read-only',
|
|
84
|
+
'--cap-drop=ALL',
|
|
85
|
+
'--security-opt=no-new-privileges',
|
|
86
|
+
'--user=1000:1000',
|
|
87
|
+
'--pids-limit=128',
|
|
88
|
+
'--memory=512m',
|
|
89
|
+
'--cpus=1',
|
|
90
|
+
`--network=${networkName}`,
|
|
91
|
+
`--volume=${sourceDir}:/source:ro`,
|
|
92
|
+
`--volume=${profileDir}:/profile:rw`,
|
|
93
|
+
`--volume=${dshDir}:/dsh:${writableDsh ? 'rw' : 'ro'}`,
|
|
94
|
+
...(toolDir ? [`--volume=${toolDir}:/tools:rw`] : []),
|
|
95
|
+
'--tmpfs=/tmp:rw,nosuid,nodev,size=128m',
|
|
96
|
+
'--workdir=/profile',
|
|
97
|
+
...Object.entries(containerEnv).map(([key, value]) => `--env=${key}=${value}`),
|
|
98
|
+
IMAGE,
|
|
99
|
+
dsh.containerNode || 'node',
|
|
100
|
+
...(dsh.extraArgs || []),
|
|
101
|
+
dsh.containerCli || dsh.cli,
|
|
102
|
+
...args,
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run a container until its output matches `readyRe`, then stop it.
|
|
108
|
+
*
|
|
109
|
+
* `execFile` cannot see the readiness line until the (long-lived) web server
|
|
110
|
+
* exits, so the old code capped every web boot at 15 s: fast boots wasted the
|
|
111
|
+
* whole cap and slower ones were killed and reported boot-failed. Polling
|
|
112
|
+
* `docker logs` lets a ready container stop immediately and a slow one use
|
|
113
|
+
* the full environment timeout. It also keeps the injected `runner` as the
|
|
114
|
+
* only process boundary, so the path is testable without a daemon.
|
|
115
|
+
*/
|
|
116
|
+
async function runUntilReady(name, argsForDocker, readyRe, { timeoutMs: limit, graceMs = 2500 } = {}) {
|
|
117
|
+
const started = Date.now();
|
|
118
|
+
let settled;
|
|
119
|
+
const runPromise = runner('docker', argsForDocker, {
|
|
120
|
+
env: safeEnv(),
|
|
121
|
+
timeout: limit,
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
124
|
+
}).then((result) => { settled = { result }; })
|
|
125
|
+
.catch((error) => { settled = { error }; });
|
|
126
|
+
let out = '';
|
|
127
|
+
let ready = false;
|
|
128
|
+
while (!settled && Date.now() - started < limit) {
|
|
129
|
+
await delay(150);
|
|
130
|
+
if (settled) break;
|
|
131
|
+
try {
|
|
132
|
+
const logs = await runner('docker', ['logs', name], { env: safeEnv(), timeout: 5000, encoding: 'utf8' });
|
|
133
|
+
out = String(logs.stdout || '') + String(logs.stderr || '');
|
|
134
|
+
if (readyRe.test(out)) { ready = true; break; }
|
|
135
|
+
} catch { /* the container may not have started yet */ }
|
|
136
|
+
}
|
|
137
|
+
if (ready) await delay(graceMs);
|
|
138
|
+
// Stop a running container after readiness or timeout; a natural exit makes
|
|
139
|
+
// this a no-op that fails harmlessly.
|
|
140
|
+
try {
|
|
141
|
+
await runner('docker', ['kill', name], { env: safeEnv(), timeout: 5000, encoding: 'utf8' });
|
|
142
|
+
} catch { /* already exited */ }
|
|
143
|
+
await runPromise;
|
|
144
|
+
const final = settled?.result || settled?.error;
|
|
145
|
+
if (final) out = String(final.stdout || '') + String(final.stderr || '') || out;
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function execute(stage, dsh, args, env, requestedNetwork, { writableDsh = false, readyRe, timeoutMs: commandTimeout, graceMs } = {}) {
|
|
150
|
+
if (dsh.kind === 'desktop') {
|
|
151
|
+
throw new ToolError('ISOLATION_UNSUPPORTED', 'Desktop DSH is not supported by the Docker backend.');
|
|
152
|
+
}
|
|
153
|
+
const name = `dsh-probe-${randomUUID()}`;
|
|
154
|
+
containers.add(name);
|
|
155
|
+
const argsForDocker = dockerArgs(name, dsh, args, env, requestedNetwork, { writableDsh });
|
|
156
|
+
try {
|
|
157
|
+
if (readyRe) {
|
|
158
|
+
const out = await runUntilReady(name, argsForDocker, readyRe, {
|
|
159
|
+
timeoutMs: commandTimeout ?? timeoutMs,
|
|
160
|
+
graceMs,
|
|
161
|
+
});
|
|
162
|
+
return { stage, out, ready: readyRe.test(out) };
|
|
163
|
+
}
|
|
164
|
+
const result = await runner('docker', argsForDocker, {
|
|
165
|
+
env: safeEnv(),
|
|
166
|
+
timeout: commandTimeout ?? timeoutMs,
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
169
|
+
});
|
|
170
|
+
return { ...result, stage, out: String(result.stdout || '') + String(result.stderr || '') };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const out = String(error.stdout || '') + String(error.stderr || '');
|
|
173
|
+
if (readyRe?.test(out)) return { stage, out, ready: true };
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
backend: 'docker',
|
|
180
|
+
isolation: 'docker',
|
|
181
|
+
workDir,
|
|
182
|
+
sourceDir,
|
|
183
|
+
profileDir,
|
|
184
|
+
dshDir,
|
|
185
|
+
materialize,
|
|
186
|
+
async install({ dsh, target, profile = 'headless', env = {} }) {
|
|
187
|
+
const installTarget = typeof target === 'string' ? target : target?.installTarget;
|
|
188
|
+
if (!installTarget) throw new ToolError('ARTIFACT_MISSING', 'No pinned artifact was provided for installation.');
|
|
189
|
+
return execute('install', dsh, ['plugin', '--profile', profile, 'add', installTarget], env, 'registry');
|
|
190
|
+
},
|
|
191
|
+
async boot({ dsh, args, env = {}, network: bootNetwork, writableDsh = false, readyRe, graceMs, timeoutMs: bootTimeout }) {
|
|
192
|
+
return execute('boot', dsh, args, env, bootNetwork || network, { writableDsh, readyRe, graceMs, timeoutMs: bootTimeout });
|
|
193
|
+
},
|
|
194
|
+
async startStub() {
|
|
195
|
+
const networkName = `dsh-probe-net-${randomUUID()}`;
|
|
196
|
+
const stubName = `dsh-probe-stub-${randomUUID()}`;
|
|
197
|
+
writeFileSync(join(sourceDir, 'probe-stub.mjs'), STUB_SCRIPT);
|
|
198
|
+
networks.add(networkName);
|
|
199
|
+
await runner('docker', ['network', 'create', '--internal', networkName], { env: safeEnv(), timeout: 10000, encoding: 'utf8' });
|
|
200
|
+
containers.add(stubName);
|
|
201
|
+
await runner('docker', [
|
|
202
|
+
'run', '-d', '--rm', '--name', stubName,
|
|
203
|
+
'--network', networkName,
|
|
204
|
+
'--network-alias', 'probe-stub',
|
|
205
|
+
'--read-only',
|
|
206
|
+
'--cap-drop=ALL',
|
|
207
|
+
'--security-opt=no-new-privileges',
|
|
208
|
+
'--user=1000:1000',
|
|
209
|
+
'--pids-limit=64',
|
|
210
|
+
'--memory=256m',
|
|
211
|
+
'--cpus=0.5',
|
|
212
|
+
`--volume=${sourceDir}:/source:ro`,
|
|
213
|
+
`--volume=${profileDir}:/profile:rw`,
|
|
214
|
+
'--tmpfs=/tmp:rw,nosuid,nodev,size=64m',
|
|
215
|
+
'--env=HOME=/profile',
|
|
216
|
+
IMAGE,
|
|
217
|
+
'node', '/source/probe-stub.mjs',
|
|
218
|
+
], { env: safeEnv(), timeout: 30000, encoding: 'utf8' });
|
|
219
|
+
let ready = false;
|
|
220
|
+
for (let attempt = 0; attempt < 40 && !ready; attempt += 1) {
|
|
221
|
+
const result = await runner('docker', ['logs', stubName], { env: safeEnv(), timeout: 5000, encoding: 'utf8' });
|
|
222
|
+
ready = String(result.stdout || '').includes('PROBE_STUB_READY');
|
|
223
|
+
if (!ready) await delay(25);
|
|
224
|
+
}
|
|
225
|
+
if (!ready) throw new ToolError('ISOLATION_UNAVAILABLE', 'The isolated stub did not become ready.');
|
|
226
|
+
const seen = [];
|
|
227
|
+
let offset = 0;
|
|
228
|
+
return {
|
|
229
|
+
baseURL: 'http://probe-stub:3000',
|
|
230
|
+
network: networkName,
|
|
231
|
+
port: 3000,
|
|
232
|
+
seen,
|
|
233
|
+
async sync() {
|
|
234
|
+
const path = join(profileDir, 'stub-seen.jsonl');
|
|
235
|
+
if (!existsSync(path)) return;
|
|
236
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
237
|
+
for (const line of lines.slice(offset)) {
|
|
238
|
+
try { seen.push(JSON.parse(line)); } catch { /* incomplete final record */ }
|
|
239
|
+
}
|
|
240
|
+
offset = lines.length;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
async destroy() {
|
|
245
|
+
const removals = [...containers].map((name) => runner('docker', ['rm', '-f', name], {
|
|
246
|
+
env: safeEnv(),
|
|
247
|
+
timeout: 10000,
|
|
248
|
+
encoding: 'utf8',
|
|
249
|
+
maxBuffer: 1024 * 1024,
|
|
250
|
+
}));
|
|
251
|
+
await Promise.allSettled(removals);
|
|
252
|
+
containers.clear();
|
|
253
|
+
const networkRemovals = [...networks].map((name) => runner('docker', ['network', 'rm', name], {
|
|
254
|
+
env: safeEnv(),
|
|
255
|
+
timeout: 10000,
|
|
256
|
+
encoding: 'utf8',
|
|
257
|
+
}));
|
|
258
|
+
await Promise.allSettled(networkRemovals);
|
|
259
|
+
networks.clear();
|
|
260
|
+
try {
|
|
261
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
262
|
+
} catch {
|
|
263
|
+
// The container writes npm cache directories as uid 1000; the host user
|
|
264
|
+
// may not be able to traverse them. The tree lives under the per-run
|
|
265
|
+
// temp root and is reclaimed by the OS, so a cleanup failure must not
|
|
266
|
+
// turn a completed observation into an error.
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { ToolError } from '../../shared/errors.js';
|
|
3
|
+
import { runDsh, runUntilReady } from '../dsh-adapter.js';
|
|
4
|
+
|
|
5
|
+
export function createHostUnsafeEnvironment({ workDir, profileDir, dshDir, timeoutMs, runner, materialize }) {
|
|
6
|
+
// DSH_HOME must be the profile root. The Docker backend mounts the same
|
|
7
|
+
// directory at /profile, so the probe reads profiles/<name> from one place in
|
|
8
|
+
// both backends. Before this, the host backend set DSH_HOME to workDir while
|
|
9
|
+
// probe.js read profileDir, and every non-Docker probe ended in TEMPLATE_FAILED.
|
|
10
|
+
return {
|
|
11
|
+
backend: 'host-unsafe',
|
|
12
|
+
isolation: 'host-unsafe',
|
|
13
|
+
warning: 'UNSAFE: plugin code runs on the host without container isolation.',
|
|
14
|
+
workDir,
|
|
15
|
+
profileDir,
|
|
16
|
+
dshDir,
|
|
17
|
+
materialize,
|
|
18
|
+
async install({ dsh, target, profile = 'headless', env = {} }) {
|
|
19
|
+
const installTarget = typeof target === 'string' ? target : target?.hostPath || target?.installTarget || target?.path;
|
|
20
|
+
if (!installTarget) throw new ToolError('ARTIFACT_MISSING', 'No artifact was provided for installation.');
|
|
21
|
+
const out = await runDsh(dsh, profileDir, ['plugin', '--profile', profile, 'add', installTarget], env, { runner, timeoutMs });
|
|
22
|
+
return { out };
|
|
23
|
+
},
|
|
24
|
+
async boot({ dsh, args, env = {}, readyRe, graceMs = 2500 }) {
|
|
25
|
+
const out = readyRe
|
|
26
|
+
? await runUntilReady(dsh, profileDir, args, env, readyRe, { timeoutMs, graceMs })
|
|
27
|
+
: await runDsh(dsh, profileDir, args, env, { runner, timeoutMs });
|
|
28
|
+
return { out };
|
|
29
|
+
},
|
|
30
|
+
async destroy() {
|
|
31
|
+
try {
|
|
32
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
33
|
+
} catch {
|
|
34
|
+
// Same rationale as the Docker backend: the per-run tree is disposable
|
|
35
|
+
// and a cleanup failure must not rewrite the verdict.
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { copyFileSync, cpSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { ToolError } from '../../shared/errors.js';
|
|
4
|
+
|
|
5
|
+
export async function materializeArtifact(artifact, sourceDir) {
|
|
6
|
+
const sourcePath = artifact?.tarballPath || artifact?.path;
|
|
7
|
+
if (!sourcePath) throw new ToolError('ARTIFACT_MISSING', 'The artifact has no local file or directory to materialize.');
|
|
8
|
+
let stat;
|
|
9
|
+
try {
|
|
10
|
+
stat = statSync(sourcePath);
|
|
11
|
+
} catch (cause) {
|
|
12
|
+
throw new ToolError('ARTIFACT_MISSING', 'The artifact source is missing or unreadable.', { cause });
|
|
13
|
+
}
|
|
14
|
+
const name = stat.isDirectory()
|
|
15
|
+
? String(artifact.name || basename(sourcePath)).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
16
|
+
: basename(sourcePath);
|
|
17
|
+
const hostPath = join(sourceDir, name);
|
|
18
|
+
if (stat.isDirectory()) cpSync(sourcePath, hostPath, { recursive: true, force: true });
|
|
19
|
+
else copyFileSync(sourcePath, hostPath);
|
|
20
|
+
const containerPath = `/source/${name}`;
|
|
21
|
+
return { ...artifact, hostPath, containerPath, installTarget: containerPath };
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { ToolError } from '../shared/errors.js';
|
|
5
|
+
import { execFileRunner } from './dsh-adapter.js';
|
|
6
|
+
import { createDockerEnvironment, preflightDocker } from './environment/docker.js';
|
|
7
|
+
import { createHostUnsafeEnvironment } from './environment/host-unsafe.js';
|
|
8
|
+
import { materializeArtifact } from './environment/materialize.js';
|
|
9
|
+
|
|
10
|
+
export async function createProbeEnvironment({
|
|
11
|
+
backend = 'auto',
|
|
12
|
+
workDir,
|
|
13
|
+
dshDir: sharedDshDir,
|
|
14
|
+
timeoutMs = 180000,
|
|
15
|
+
network = 'none',
|
|
16
|
+
runner = execFileRunner,
|
|
17
|
+
} = {}) {
|
|
18
|
+
const parent = workDir ? resolve(workDir) : tmpdir();
|
|
19
|
+
mkdirSync(parent, { recursive: true });
|
|
20
|
+
const root = mkdtempSync(join(parent, 'dsh-probe-'));
|
|
21
|
+
const sourceDir = join(root, 'source');
|
|
22
|
+
const profileDir = join(root, 'profile');
|
|
23
|
+
// A shared dshDir (one per DSH version under the cache root) lets consecutive
|
|
24
|
+
// probe cells reuse the harness install instead of downloading it per cell.
|
|
25
|
+
const dshDir = sharedDshDir ? resolve(sharedDshDir) : join(root, 'dsh');
|
|
26
|
+
// The npm cache behind the pnpm shim is shared too: without it every cell
|
|
27
|
+
// re-downloads pnpm before it can install the plugin.
|
|
28
|
+
const toolDir = join(parent, '.probe-tools');
|
|
29
|
+
for (const dir of [sourceDir, profileDir, dshDir, toolDir]) mkdirSync(dir, { recursive: true });
|
|
30
|
+
chmodSync(root, 0o755);
|
|
31
|
+
chmodSync(sourceDir, 0o755);
|
|
32
|
+
chmodSync(profileDir, 0o777);
|
|
33
|
+
chmodSync(dshDir, 0o777);
|
|
34
|
+
chmodSync(toolDir, 0o777);
|
|
35
|
+
const materialize = (artifact) => materializeArtifact(artifact, sourceDir);
|
|
36
|
+
try {
|
|
37
|
+
if (backend === 'host-unsafe') {
|
|
38
|
+
return createHostUnsafeEnvironment({ workDir: root, profileDir, dshDir, timeoutMs, runner, materialize });
|
|
39
|
+
}
|
|
40
|
+
if (backend !== 'auto' && backend !== 'docker') {
|
|
41
|
+
throw new ToolError('ISOLATION_UNSUPPORTED', `Unsupported probe backend: ${backend}`);
|
|
42
|
+
}
|
|
43
|
+
await preflightDocker({ runner, timeoutMs: Math.min(timeoutMs, 10000) });
|
|
44
|
+
return createDockerEnvironment({ workDir: root, sourceDir, profileDir, dshDir, toolDir, timeoutMs, network, runner, materialize });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
rmSync(root, { recursive: true, force: true });
|
|
47
|
+
if (error instanceof ToolError) throw error;
|
|
48
|
+
throw new ToolError('ISOLATION_UNAVAILABLE', 'Docker is unavailable; no plugin code was executed.', { cause: error });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ToolError } from '../shared/errors.js';
|
|
4
|
+
|
|
5
|
+
export function readInstalledArtifact(profileDir, name) {
|
|
6
|
+
if (typeof name !== 'string' || name.includes('..') || name.startsWith('/') || name.startsWith('\\')) {
|
|
7
|
+
throw new ToolError('ARTIFACT_MISSING', 'The installed artifact name is invalid.');
|
|
8
|
+
}
|
|
9
|
+
const path = join(profileDir, 'node_modules', ...name.split('/'));
|
|
10
|
+
try {
|
|
11
|
+
const manifest = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8'));
|
|
12
|
+
if (typeof manifest.version !== 'string' || !manifest.version) throw new Error('missing version');
|
|
13
|
+
return { version: manifest.version, path };
|
|
14
|
+
} catch (cause) {
|
|
15
|
+
throw new ToolError('ARTIFACT_MISSING', `The installed manifest for ${name} is missing or unreadable.`, { cause });
|
|
16
|
+
}
|
|
17
|
+
}
|