dsh-agent-plugins-market 0.5.2 → 0.6.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/README.md +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1388 -204
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
4
|
import { discoverLspEntries, listMdFiles } from '../catalog/surfaces.js';
|
|
5
|
+
import { credentialRefsInServer } from '../runtime/mcp-config.js';
|
|
6
|
+
import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
|
|
7
|
+
import { applyOverride } from '../runtime/mcp-overrides.js';
|
|
5
8
|
import { effectiveSurfaces } from '../model/types.js';
|
|
6
9
|
/** Build the detail response for one normalized suite. */
|
|
7
10
|
export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrides = {}) {
|
|
@@ -21,18 +24,27 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
|
|
|
21
24
|
installed: installed !== undefined,
|
|
22
25
|
enabled: installed?.enabled === true,
|
|
23
26
|
surfaceToggles: effectiveSurfaces(installed?.surfaces),
|
|
24
|
-
mcpOverrides,
|
|
27
|
+
mcpOverrides: redactMcpOverrides(mcpOverrides),
|
|
25
28
|
skills: suite.skills.map(skill => ({
|
|
26
29
|
name: skill.name,
|
|
27
30
|
description: skill.description,
|
|
28
31
|
...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
|
|
29
32
|
path: skill.file
|
|
30
33
|
})),
|
|
31
|
-
mcpServers: suite.mcp === undefined
|
|
34
|
+
mcpServers: suite.mcp === undefined
|
|
35
|
+
? []
|
|
36
|
+
: Object.entries(suite.mcp.servers).map(([key, server]) => {
|
|
37
|
+
const effective = applyOverride(server, mcpOverrides[key]);
|
|
38
|
+
return {
|
|
39
|
+
key,
|
|
40
|
+
...redactMcpConfig(server),
|
|
41
|
+
credentialRefs: credentialRefsInServer(effective)
|
|
42
|
+
};
|
|
43
|
+
}),
|
|
32
44
|
hooks: remoteUrl === undefined ? await hooksPreviews(suite.root) : { count: 0, entries: [] },
|
|
33
45
|
commands: remoteUrl === undefined ? await markdownPreviews(`${suite.root}/commands`) : [],
|
|
34
46
|
agents: remoteUrl === undefined ? await markdownPreviews(`${suite.root}/agents`) : [],
|
|
35
|
-
lsp: remoteUrl === undefined ? await
|
|
47
|
+
lsp: remoteUrl === undefined ? await lspDetail(suite) : { servers: [], raw: [] },
|
|
36
48
|
errors: suite.errors,
|
|
37
49
|
mcpErrors: diagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
|
|
38
50
|
};
|
|
@@ -125,15 +137,30 @@ async function hooksPreviews(root) {
|
|
|
125
137
|
}
|
|
126
138
|
return { count: 0, entries: [] };
|
|
127
139
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
/**
|
|
141
|
+
* The suite's LSP surface: inline-declared `lspServers` (already parsed into
|
|
142
|
+
* `suite.lsp` at discovery) rendered as structured server previews, plus the
|
|
143
|
+
* directory-style `.claude-plugin/lsp/*.json` / reverse-domain files as raw
|
|
144
|
+
* previews. Remote suites (not cloned) carry an empty detail.
|
|
145
|
+
*/
|
|
146
|
+
async function lspDetail(suite) {
|
|
147
|
+
const servers = Object.values(suite.lsp?.servers ?? {}).map(spec => ({
|
|
148
|
+
key: spec.key,
|
|
149
|
+
command: spec.command,
|
|
150
|
+
args: spec.args,
|
|
151
|
+
extensions: spec.extensionToLanguage,
|
|
152
|
+
...(spec.env === undefined ? {} : { env: spec.env })
|
|
153
|
+
}));
|
|
154
|
+
const raw = [];
|
|
155
|
+
if (suite.remote?.url === undefined) {
|
|
156
|
+
for (const entry of await discoverLspEntries(suite.root)) {
|
|
157
|
+
try {
|
|
158
|
+
raw.push({ name: entry.name, content: await readPreview(entry.path) });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Unreadable LSP files are omitted from the detail response.
|
|
162
|
+
}
|
|
136
163
|
}
|
|
137
164
|
}
|
|
138
|
-
return
|
|
165
|
+
return { servers, raw };
|
|
139
166
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive source acquisition: HTTPS download, SHA-256 verification, and safe
|
|
3
|
+
* extraction of zip / tar.gz / tgz / tar payloads into a source checkout.
|
|
4
|
+
*
|
|
5
|
+
* The pipeline mirrors the acquisition guarantees of Claude Code's `archive`
|
|
6
|
+
* plugin source: HTTPS-only by default, a 256 MiB cap, and an optional
|
|
7
|
+
* SHA-256 pin that doubles as an integrity check. Extraction is guarded
|
|
8
|
+
* against path traversal (zip-slip): zip entries are name-checked and never
|
|
9
|
+
* materialized as symlinks, and tar output is walked afterward so no symlink
|
|
10
|
+
* may point outside the extraction root.
|
|
11
|
+
*
|
|
12
|
+
* The same primitives back the git codeload-tarball fallback in
|
|
13
|
+
* `application/catalog.ts`, which is why extraction and layout normalization
|
|
14
|
+
* live here instead of inline in the manager.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { mkdir, open, readdir, readFile, readlink, rename, rm, stat } from 'node:fs/promises';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { promisify } from 'node:util';
|
|
21
|
+
import { Unzip, UnzipInflate } from 'fflate';
|
|
22
|
+
const run = promisify(execFile);
|
|
23
|
+
/** Hard cap on archive download size (mirrors Claude Code's archive limit). */
|
|
24
|
+
export const ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
|
25
|
+
/** Hard cap on the total decompressed bytes one archive may write. */
|
|
26
|
+
export const ARCHIVE_MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024;
|
|
27
|
+
/** Hard cap on the number of files one archive may write. */
|
|
28
|
+
export const ARCHIVE_MAX_ENTRIES = 20_000;
|
|
29
|
+
/** Hard cap on the decompressed size of a single archive entry. */
|
|
30
|
+
export const ARCHIVE_MAX_ENTRY_BYTES = 128 * 1024 * 1024;
|
|
31
|
+
export const DEFAULT_ARCHIVE_TIMEOUT_MS = 180_000;
|
|
32
|
+
/** Classify an archive URL by extension; undefined when unsupported. */
|
|
33
|
+
export function archiveFormatOf(url) {
|
|
34
|
+
const clean = url.trim().toLowerCase();
|
|
35
|
+
if (clean.endsWith('.zip'))
|
|
36
|
+
return 'zip';
|
|
37
|
+
if (clean.endsWith('.tar.gz') || clean.endsWith('.tgz'))
|
|
38
|
+
return 'targz';
|
|
39
|
+
if (clean.endsWith('.tar'))
|
|
40
|
+
return 'tar';
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Download the payload to a temp file, enforcing the size cap and digest. */
|
|
44
|
+
export async function downloadArchive(url, tempFile, options = {}) {
|
|
45
|
+
const trimmed = url.trim();
|
|
46
|
+
if (options.allowHttp !== true && !trimmed.startsWith('https://')) {
|
|
47
|
+
throw new Error(`archive source requires an https:// URL: ${trimmed}`);
|
|
48
|
+
}
|
|
49
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_ARCHIVE_TIMEOUT_MS;
|
|
50
|
+
const response = await fetch(trimmed, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' });
|
|
51
|
+
if (!response.ok)
|
|
52
|
+
throw new Error(`archive download failed: HTTP ${response.status} for ${trimmed}`);
|
|
53
|
+
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
54
|
+
if (Number.isFinite(declared) && declared > ARCHIVE_MAX_BYTES) {
|
|
55
|
+
throw new Error(`archive exceeds the ${ARCHIVE_MAX_BYTES} byte limit: ${declared}`);
|
|
56
|
+
}
|
|
57
|
+
const body = response.body;
|
|
58
|
+
if (body === null)
|
|
59
|
+
throw new Error(`archive download failed: empty response body for ${trimmed}`);
|
|
60
|
+
const hash = createHash('sha256');
|
|
61
|
+
const handle = await open(tempFile, 'w');
|
|
62
|
+
try {
|
|
63
|
+
let size = 0;
|
|
64
|
+
const reader = body.getReader();
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done)
|
|
68
|
+
break;
|
|
69
|
+
size += value.byteLength;
|
|
70
|
+
if (size > ARCHIVE_MAX_BYTES) {
|
|
71
|
+
void reader.cancel();
|
|
72
|
+
throw new Error(`archive exceeds the ${ARCHIVE_MAX_BYTES} byte limit`);
|
|
73
|
+
}
|
|
74
|
+
hash.update(value);
|
|
75
|
+
await handle.write(value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await handle.close();
|
|
80
|
+
}
|
|
81
|
+
const sha256 = hash.digest('hex');
|
|
82
|
+
if (options.sha256 !== undefined && options.sha256 !== '' && options.sha256.toLowerCase() !== sha256) {
|
|
83
|
+
throw new Error(`archive sha256 mismatch: expected ${options.sha256}, got ${sha256}`);
|
|
84
|
+
}
|
|
85
|
+
return sha256;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Acquire one archive source into `dest`: download, verify, extract into a
|
|
89
|
+
* sibling temp directory, normalize a single top-level wrapper directory,
|
|
90
|
+
* then swap into `dest`. Returns the payload digest.
|
|
91
|
+
*/
|
|
92
|
+
export async function archiveInstall(url, dest, options = {}) {
|
|
93
|
+
const format = options.format ?? archiveFormatOf(url);
|
|
94
|
+
if (format === undefined)
|
|
95
|
+
throw new Error(`unsupported archive format (expected .zip, .tar.gz, .tgz, or .tar): ${url}`);
|
|
96
|
+
const parent = resolve(dest, '..');
|
|
97
|
+
await mkdir(parent, { recursive: true });
|
|
98
|
+
const stamp = `${process.pid}.${Date.now()}`;
|
|
99
|
+
const tempFile = join(parent, `.${stamp}.download`);
|
|
100
|
+
const extractDir = join(parent, `.${stamp}.extract`);
|
|
101
|
+
try {
|
|
102
|
+
const sha256 = await downloadArchive(url, tempFile, options);
|
|
103
|
+
await mkdir(extractDir, { recursive: true });
|
|
104
|
+
if (format === 'zip') {
|
|
105
|
+
await extractZip(tempFile, extractDir);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
await extractTar(tempFile, extractDir, format);
|
|
109
|
+
}
|
|
110
|
+
await assertNoEscapingSymlinks(extractDir);
|
|
111
|
+
const root = await unwrapSingleRoot(extractDir);
|
|
112
|
+
await rm(dest, { recursive: true, force: true });
|
|
113
|
+
try {
|
|
114
|
+
await rename(root, dest);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
await copyTree(root, dest);
|
|
118
|
+
}
|
|
119
|
+
return { sha256 };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await rm(tempFile, { force: true });
|
|
123
|
+
await rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Extract a zip payload with per-entry path-traversal guards and resource
|
|
128
|
+
* limits enforced *during* decompression: the stream feeds a stateful unzip
|
|
129
|
+
* (never a whole-archive `unzipSync` into memory), and total bytes, entry
|
|
130
|
+
* count, and per-entry size are counted as data arrives — a zip bomb aborts
|
|
131
|
+
* the stream before it can exhaust memory or disk. Guard violations set a
|
|
132
|
+
* failure instead of throwing inside the stream callbacks (a throw from
|
|
133
|
+
* within fflate's synchronous push recursion overflows the stack); the
|
|
134
|
+
* chunked feed halts at the next slice.
|
|
135
|
+
*/
|
|
136
|
+
async function extractZip(archiveFile, dest) {
|
|
137
|
+
const compressed = await readFile(archiveFile);
|
|
138
|
+
let totalBytes = 0;
|
|
139
|
+
let entryCount = 0;
|
|
140
|
+
let failure;
|
|
141
|
+
const fail = (error) => {
|
|
142
|
+
failure ??= error;
|
|
143
|
+
};
|
|
144
|
+
const unzip = new Unzip();
|
|
145
|
+
unzip.register(UnzipInflate);
|
|
146
|
+
const writes = [];
|
|
147
|
+
unzip.onfile = file => {
|
|
148
|
+
if (failure !== undefined)
|
|
149
|
+
return;
|
|
150
|
+
entryCount += 1;
|
|
151
|
+
if (entryCount > ARCHIVE_MAX_ENTRIES) {
|
|
152
|
+
fail(new Error(`archive exceeds the ${ARCHIVE_MAX_ENTRIES} entry limit`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const normalized = file.name.replace(/\\/g, '/');
|
|
156
|
+
if (normalized === '' || normalized.endsWith('/'))
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
assertSafeEntryName(normalized);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const target = join(dest, normalized);
|
|
166
|
+
if (target !== dest && !target.startsWith(`${dest}/`)) {
|
|
167
|
+
fail(new Error(`zip entry escapes the extraction root: ${file.name}`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let entryBytes = 0;
|
|
171
|
+
const chunks = [];
|
|
172
|
+
file.ondata = (error, data, final) => {
|
|
173
|
+
if (error !== null) {
|
|
174
|
+
fail(error);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
entryBytes += data.byteLength;
|
|
178
|
+
totalBytes += data.byteLength;
|
|
179
|
+
if (entryBytes > ARCHIVE_MAX_ENTRY_BYTES) {
|
|
180
|
+
fail(new Error(`archive entry "${file.name}" exceeds the ${ARCHIVE_MAX_ENTRY_BYTES} byte per-entry limit`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (totalBytes > ARCHIVE_MAX_EXTRACTED_BYTES) {
|
|
184
|
+
fail(new Error(`archive exceeds the ${ARCHIVE_MAX_EXTRACTED_BYTES} byte extracted-size limit`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
chunks.push(Buffer.from(data));
|
|
188
|
+
if (final)
|
|
189
|
+
writes.push(writeZipEntry(target, chunks));
|
|
190
|
+
};
|
|
191
|
+
file.start();
|
|
192
|
+
};
|
|
193
|
+
// Feed in small slices so the failure flag halts processing promptly.
|
|
194
|
+
const view = new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength);
|
|
195
|
+
const slice = 64 * 1024;
|
|
196
|
+
for (let offset = 0; offset < view.length && failure === undefined; offset += slice) {
|
|
197
|
+
unzip.push(view.subarray(offset, Math.min(offset + slice, view.length)), offset + slice >= view.length);
|
|
198
|
+
}
|
|
199
|
+
if (failure !== undefined)
|
|
200
|
+
throw failure;
|
|
201
|
+
await Promise.all(writes);
|
|
202
|
+
}
|
|
203
|
+
/** Write one buffered zip entry to disk after its data completed inflating. */
|
|
204
|
+
async function writeZipEntry(target, chunks) {
|
|
205
|
+
await mkdir(resolve(target, '..'), { recursive: true });
|
|
206
|
+
const handle = await open(target, 'w');
|
|
207
|
+
try {
|
|
208
|
+
for (const chunk of chunks)
|
|
209
|
+
await handle.write(chunk);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await handle.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function assertSafeEntryName(name) {
|
|
216
|
+
if (name.startsWith('/') || name === '..' || name.startsWith('../') || name.includes('/../')) {
|
|
217
|
+
throw new Error(`archive entry escapes the extraction root: ${name}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Extract a tar / tar.gz payload with the system tar (bsdtar and GNU tar
|
|
222
|
+
* sanitize member names), then bound what landed on disk: the walk enforces
|
|
223
|
+
* the total-bytes, entry-count, and per-file limits so a tar bomb cannot
|
|
224
|
+
* exhaust the disk. The system tar streams to disk, so the cap runs on the
|
|
225
|
+
* output tree rather than on a decompressed in-memory buffer.
|
|
226
|
+
*/
|
|
227
|
+
async function extractTar(archiveFile, dest, format) {
|
|
228
|
+
const args = format === 'targz' ? ['-xzf', archiveFile] : ['-xf', archiveFile];
|
|
229
|
+
args.push('--no-same-owner');
|
|
230
|
+
await run('tar', args, { cwd: dest, timeout: 120_000 });
|
|
231
|
+
await assertBoundedTree(dest);
|
|
232
|
+
}
|
|
233
|
+
/** Post-extraction disk bounds shared by tar formats. */
|
|
234
|
+
async function assertBoundedTree(root) {
|
|
235
|
+
const stack = [root];
|
|
236
|
+
let entriesSeen = 0;
|
|
237
|
+
let totalBytes = 0;
|
|
238
|
+
while (stack.length > 0) {
|
|
239
|
+
const dir = stack.pop();
|
|
240
|
+
let dirents;
|
|
241
|
+
try {
|
|
242
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
for (const entry of dirents) {
|
|
248
|
+
const path = join(dir, entry.name);
|
|
249
|
+
entriesSeen += 1;
|
|
250
|
+
if (entriesSeen > ARCHIVE_MAX_ENTRIES) {
|
|
251
|
+
throw new Error(`archive exceeds the ${ARCHIVE_MAX_ENTRIES} entry limit`);
|
|
252
|
+
}
|
|
253
|
+
if (entry.isSymbolicLink())
|
|
254
|
+
continue;
|
|
255
|
+
if (entry.isDirectory()) {
|
|
256
|
+
stack.push(path);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let size = 0;
|
|
260
|
+
try {
|
|
261
|
+
size = (await stat(path)).size;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
totalBytes += size;
|
|
267
|
+
if (size > ARCHIVE_MAX_ENTRY_BYTES) {
|
|
268
|
+
throw new Error(`archive entry exceeds the ${ARCHIVE_MAX_ENTRY_BYTES} byte per-entry limit: ${entry.name}`);
|
|
269
|
+
}
|
|
270
|
+
if (totalBytes > ARCHIVE_MAX_EXTRACTED_BYTES) {
|
|
271
|
+
throw new Error(`archive exceeds the ${ARCHIVE_MAX_EXTRACTED_BYTES} byte extracted-size limit`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Walk the extracted tree and reject any symlink whose target resolves outside `root`. */
|
|
277
|
+
async function assertNoEscapingSymlinks(root) {
|
|
278
|
+
const stack = [root];
|
|
279
|
+
while (stack.length > 0) {
|
|
280
|
+
const dir = stack.pop();
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
const path = join(dir, entry.name);
|
|
290
|
+
if (entry.isSymbolicLink()) {
|
|
291
|
+
// readlink, not readFile: readFile would follow the link and return the
|
|
292
|
+
// target's *content*, so a link pointing outside the root resolves to a
|
|
293
|
+
// bogus in-root path and the escape survives extraction.
|
|
294
|
+
const target = await readlink(path, 'utf8').catch(() => '');
|
|
295
|
+
const resolved = resolve(dir, target);
|
|
296
|
+
if (resolved !== root && !resolved.startsWith(`${root}/`)) {
|
|
297
|
+
throw new Error(`archive contains a symlink escaping the extraction root: ${entry.name} -> ${target}`);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (entry.isDirectory())
|
|
302
|
+
stack.push(path);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Test seam: run the symlink-containment walk against a prepared tree. */
|
|
307
|
+
export const assertNoEscapingSymlinksForTest = assertNoEscapingSymlinks;
|
|
308
|
+
/** If the extraction produced exactly one top-level directory and nothing else, use it as the root. */
|
|
309
|
+
async function unwrapSingleRoot(extractDir) {
|
|
310
|
+
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
311
|
+
if (entries.length === 1 && entries[0].isDirectory())
|
|
312
|
+
return join(extractDir, entries[0].name);
|
|
313
|
+
return extractDir;
|
|
314
|
+
}
|
|
315
|
+
async function copyTree(from, to) {
|
|
316
|
+
await mkdir(to, { recursive: true });
|
|
317
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
318
|
+
const source = join(from, entry.name);
|
|
319
|
+
const target = join(to, entry.name);
|
|
320
|
+
if (entry.isDirectory())
|
|
321
|
+
await copyTree(source, target);
|
|
322
|
+
else if (entry.isFile()) {
|
|
323
|
+
const handle = await open(target, 'w');
|
|
324
|
+
try {
|
|
325
|
+
await handle.write(await readFile(source));
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
await handle.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Probe whether a path exists and is a directory. */
|
|
334
|
+
export async function isDirectory(path) {
|
|
335
|
+
try {
|
|
336
|
+
return (await stat(path)).isDirectory();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
package/lib/catalog/git.js
CHANGED
|
@@ -1,44 +1,117 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Git operations for repository sources: clone,
|
|
2
|
+
* Git operations for repository sources: clone, sync, head, remote-url, remove.
|
|
3
3
|
*
|
|
4
4
|
* Every operation runs `git` through `execFile` without a shell, so a source
|
|
5
5
|
* URL or branch can never interpolate into a command string. Failures carry
|
|
6
6
|
* the stderr tail as the message; the manager surfaces them as source errors.
|
|
7
|
+
*
|
|
8
|
+
* Network tuning rides one {@link GitOptions} object: a proxy, `insteadOf`
|
|
9
|
+
* URL rewrites (mirror acceleration), a per-operation timeout, and one
|
|
10
|
+
* automatic retry for network-flaky clones. Low-speed guards
|
|
11
|
+
* (`GIT_HTTP_LOW_SPEED_*`) fail a stalled transfer early instead of burning
|
|
12
|
+
* the whole timeout.
|
|
7
13
|
*/
|
|
8
14
|
import { execFile } from 'node:child_process';
|
|
9
15
|
import { rm } from 'node:fs/promises';
|
|
10
16
|
import { promisify } from 'node:util';
|
|
11
17
|
const run = promisify(execFile);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
export const DEFAULT_GIT_TIMEOUT_MS = 120_000;
|
|
19
|
+
function configArgs(options) {
|
|
20
|
+
const args = [];
|
|
21
|
+
if (options.proxy !== undefined && options.proxy !== '') {
|
|
22
|
+
args.push('-c', `http.proxy=${options.proxy}`, '-c', `https.proxy=${options.proxy}`);
|
|
23
|
+
}
|
|
24
|
+
for (const [from, to] of Object.entries(options.insteadOf ?? {})) {
|
|
25
|
+
if (from === '' || to === '')
|
|
26
|
+
continue;
|
|
27
|
+
args.push('-c', `url.${to}.insteadOf=${from}`);
|
|
28
|
+
}
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
function gitEnv() {
|
|
32
|
+
// Fail early on a stalled transfer; the timeout then catches the remainder.
|
|
33
|
+
return { ...process.env, GIT_HTTP_LOW_SPEED_LIMIT: '1000', GIT_HTTP_LOW_SPEED_TIME: '30' };
|
|
34
|
+
}
|
|
35
|
+
async function runGit({ args, options, operation, maxBuffer }) {
|
|
36
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
|
16
37
|
try {
|
|
17
|
-
await run('git', args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 });
|
|
38
|
+
const { stdout } = await run('git', args, { timeout: timeoutMs, maxBuffer: maxBuffer ?? 10 * 1024 * 1024, env: gitEnv() });
|
|
39
|
+
return stdout;
|
|
18
40
|
}
|
|
19
41
|
catch (error) {
|
|
20
|
-
throw gitError(
|
|
42
|
+
throw gitError(operation, error);
|
|
21
43
|
}
|
|
22
44
|
}
|
|
23
|
-
/**
|
|
24
|
-
export async function
|
|
45
|
+
/** Clone a source (depth 1, optional branch) into a not-yet-existing directory. */
|
|
46
|
+
export async function gitClone(url, branch, dest, options = {}) {
|
|
47
|
+
const attempt = () => runGit({
|
|
48
|
+
args: [...configArgs(options), 'clone', '--depth', '1', '--no-tags', ...(branch === undefined ? [] : ['--branch', branch]), '--', url, dest],
|
|
49
|
+
options,
|
|
50
|
+
operation: 'clone'
|
|
51
|
+
});
|
|
52
|
+
const retry = options.cloneRetry !== false;
|
|
25
53
|
try {
|
|
26
|
-
await
|
|
54
|
+
await attempt();
|
|
27
55
|
}
|
|
28
56
|
catch (error) {
|
|
29
|
-
|
|
57
|
+
if (!retry)
|
|
58
|
+
throw error;
|
|
59
|
+
// One retry: transient network failures are the dominant clone failure
|
|
60
|
+
// mode; `git clone` cleans its partial target before failing.
|
|
61
|
+
await attempt();
|
|
30
62
|
}
|
|
31
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Shallow-friendly update: fetch depth 1 into FETCH_HEAD and hard-reset.
|
|
66
|
+
* `git pull --ff-only` on shallow clones can refuse with history-divergence
|
|
67
|
+
* errors; fetch+reset is idempotent and always lands on the remote tip. The
|
|
68
|
+
* local checkout is plugin-managed, so discarding working-tree drift is the
|
|
69
|
+
* intended semantics.
|
|
70
|
+
*/
|
|
71
|
+
export async function gitSync(dir, branch, options = {}) {
|
|
72
|
+
const config = configArgs(options);
|
|
73
|
+
await runGit({ args: [...config, '-C', dir, 'fetch', '--depth', '1', 'origin', branch ?? 'HEAD'], options, operation: 'fetch' });
|
|
74
|
+
await runGit({ args: [...config, '-C', dir, 'reset', '--hard', 'FETCH_HEAD'], options, operation: 'reset' });
|
|
75
|
+
}
|
|
32
76
|
/** Read the checked-out HEAD commit. */
|
|
33
77
|
export async function gitHead(dir, timeoutMs = 30_000) {
|
|
34
78
|
try {
|
|
35
|
-
const { stdout } = await run('git', ['-C', dir, 'rev-parse', 'HEAD'], { timeout: timeoutMs });
|
|
79
|
+
const { stdout } = await run('git', ['-C', dir, 'rev-parse', 'HEAD'], { timeout: timeoutMs, env: gitEnv() });
|
|
80
|
+
return stdout.trim();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw gitError('rev-parse', error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Read the checked-out branch name (`HEAD` when detached). */
|
|
87
|
+
export async function gitCurrentBranch(dir, timeoutMs = 30_000) {
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await run('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout: timeoutMs, env: gitEnv() });
|
|
36
90
|
return stdout.trim();
|
|
37
91
|
}
|
|
38
92
|
catch (error) {
|
|
39
93
|
throw gitError('rev-parse', error);
|
|
40
94
|
}
|
|
41
95
|
}
|
|
96
|
+
/** Read the checkout's `origin` remote URL; throws when the directory is not a git repository. */
|
|
97
|
+
export async function gitRemoteUrl(dir, timeoutMs = 30_000) {
|
|
98
|
+
try {
|
|
99
|
+
const { stdout } = await run('git', ['-C', dir, 'remote', 'get-url', 'origin'], { timeout: timeoutMs, env: gitEnv() });
|
|
100
|
+
return stdout.trim();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw gitError('remote', error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Point the checkout's `origin` at a new URL (download-region rerouting). */
|
|
107
|
+
export async function gitSetRemoteUrl(dir, url, timeoutMs = 30_000) {
|
|
108
|
+
try {
|
|
109
|
+
await run('git', ['-C', dir, 'remote', 'set-url', 'origin', url], { timeout: timeoutMs, env: gitEnv() });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw gitError('remote', error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
42
115
|
/** Remove a source checkout tree entirely. */
|
|
43
116
|
export async function gitRemove(dir) {
|
|
44
117
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time migration from the pre-0.5.4 split layout, where suite data and
|
|
3
|
+
* MCP overrides lived under a sibling `~/.dsh/agent-plugins-data` root: if
|
|
4
|
+
* that root exists, its `data/` and `overrides/` subtrees move to their
|
|
5
|
+
* consolidated homes (`<dataRoot>/data` and `<dataRoot>/overrides`, matching
|
|
6
|
+
* the live resolution) and the emptied sibling is removed. Idempotent — a
|
|
7
|
+
* missing, empty, or already-migrated legacy root is a no-op — and
|
|
8
|
+
* best-effort: a filesystem failure leaves both trees in place for a manual
|
|
9
|
+
* move rather than deleting user data.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { cp, mkdir, readdir, rm } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** Migrate `data/` and `overrides/` from the legacy sibling root, if present. */
|
|
17
|
+
export async function migrateLegacyDataRoot(legacyRoot, dataRoot) {
|
|
18
|
+
if (!existsSync(legacyRoot))
|
|
19
|
+
return;
|
|
20
|
+
let entries = [];
|
|
21
|
+
try {
|
|
22
|
+
entries = await readdir(legacyRoot);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Unreadable legacy root: leave everything for a manual move.
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const subtree of ['data', 'overrides']) {
|
|
29
|
+
const from = join(legacyRoot, subtree);
|
|
30
|
+
if (!entries.includes(subtree) || !existsSync(from))
|
|
31
|
+
continue;
|
|
32
|
+
const to = join(dataRoot, subtree);
|
|
33
|
+
if (existsSync(to)) {
|
|
34
|
+
// Merge into an existing target: copy contents, then drop the source.
|
|
35
|
+
await mkdir(to, { recursive: true });
|
|
36
|
+
for (const entry of await readdir(from)) {
|
|
37
|
+
await cp(join(from, entry), join(to, entry), { recursive: true, force: false, errorOnExist: false });
|
|
38
|
+
}
|
|
39
|
+
await rm(from, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
await mkdir(dataRoot, { recursive: true });
|
|
43
|
+
await cp(from, to, { recursive: true });
|
|
44
|
+
await rm(from, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Remove the legacy root only when nothing else remains inside it.
|
|
48
|
+
const remaining = await readdir(legacyRoot).catch(() => ['keep']);
|
|
49
|
+
if (remaining.length === 0)
|
|
50
|
+
await rm(legacyRoot, { recursive: true, force: true });
|
|
51
|
+
}
|