Skip to content

Commit 42b530c

Browse files
pwang347Copilot
andauthored
Sync complete skill directories to remote agent hosts (#333827)
* agentHost: sync linked skill files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: sync complete skill directories Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60c8636-9aaa-42ee-b94f-e7daba0c32aa * agentHost: hash synced files as binary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60c8636-9aaa-42ee-b94f-e7daba0c32aa * test: compare synced origins by URI value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60c8636-9aaa-42ee-b94f-e7daba0c32aa * Address skill sync review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54febce8-3c95-4100-bf37-0e38cca467d1 * Harden synced skill directory traversal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54febce8-3c95-4100-bf37-0e38cca467d1 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60c8636-9aaa-42ee-b94f-e7daba0c32aa Copilot-Session: 54febce8-3c95-4100-bf37-0e38cca467d1
1 parent cf72409 commit 42b530c

2 files changed

Lines changed: 335 additions & 56 deletions

File tree

src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts

Lines changed: 86 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { Limiter } from '../../../../../../base/common/async.js';
67
import { VSBuffer } from '../../../../../../base/common/buffer.js';
78
import { Disposable } from '../../../../../../base/common/lifecycle.js';
89
import { equals } from '../../../../../../base/common/objects.js';
910
import { ResourceMap } from '../../../../../../base/common/map.js';
10-
import { basename, dirname } from '../../../../../../base/common/resources.js';
11+
import { basename, dirname, extUri } from '../../../../../../base/common/resources.js';
1112
import { URI } from '../../../../../../base/common/uri.js';
1213
import { hash } from '../../../../../../base/common/hash.js';
13-
import { IFileService } from '../../../../../../platform/files/common/files.js';
14+
import { IFileService, IFileStatWithPartialMetadata } from '../../../../../../platform/files/common/files.js';
15+
import { ILogService } from '../../../../../../platform/log/common/log.js';
1416
import { IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js';
1517
import { PromptsType } from '../../../common/promptSyntax/promptTypes.js';
1618
import { AICustomizationSource } from '../../../common/aiCustomizationWorkspaceService.js';
@@ -19,11 +21,14 @@ import { withCustomizationEnablement } from '../../../../../../platform/agentHos
1921
import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js';
2022
import { CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
2123
import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js';
24+
import { IgnoreFile } from '../../../../../../workbench/services/search/common/ignoreFile.js';
2225

2326
// Re-export so existing consumers don't need to change their import source.
2427
export { SYNCED_CUSTOMIZATION_SCHEME };
2528

2629
const DISPLAY_NAME = 'VS Code Synced Data';
30+
const FILE_OPERATION_CONCURRENCY = 10;
31+
const SKILL_DIRECTORY_IGNORE = new IgnoreFile('.git\nnode_modules\n', '/', undefined, true);
2732

2833
const MANIFEST_CONTENT = JSON.stringify({
2934
name: DISPLAY_NAME,
@@ -48,6 +53,34 @@ function pluginDirForType(type: PromptsType): string | undefined {
4853
}
4954
}
5055

56+
type QueueFileOperation = <T>(operation: () => Promise<T>) => Promise<T>;
57+
58+
async function collectDirectoryFiles(fileService: IFileService, logService: ILogService, root: URI, directory: URI, queueFileOperation: QueueFileOperation): Promise<IFileStatWithPartialMetadata[]> {
59+
const stat = await queueFileOperation(() => fileService.resolve(directory));
60+
const children = (await Promise.all((stat.children ?? []).map(async child => {
61+
try {
62+
return await queueFileOperation(() => fileService.stat(child.resource));
63+
} catch (error) {
64+
logService.trace('[SyncedCustomizationBundler] Failed to stat skill resource', child.resource.toString(), error);
65+
return undefined;
66+
}
67+
}))).filter((child): child is IFileStatWithPartialMetadata => child !== undefined);
68+
const files = await Promise.all(children.map(async child => {
69+
const relativePath = extUri.relativePath(root, child.resource);
70+
if (relativePath === undefined) {
71+
throw new Error(`Unable to resolve skill resource path: ${child.resource.toString()}`);
72+
}
73+
if (child.isSymbolicLink || !SKILL_DIRECTORY_IGNORE.isPathIncludedInTraversal(`/${relativePath}`, child.isDirectory)) {
74+
return [];
75+
}
76+
if (child.isDirectory) {
77+
return collectDirectoryFiles(fileService, logService, root, child.resource, queueFileOperation);
78+
}
79+
return child.isFile ? [child] : [];
80+
}));
81+
return files.flat();
82+
}
83+
5184
export interface ISyncableFile {
5285
readonly uri: URI;
5386
readonly type: PromptsType;
@@ -113,14 +146,15 @@ interface IBundleResult {
113146
* rules/ ← instruction files
114147
* commands/ ← prompt files
115148
* agents/ ← agent files
116-
* skills/ ← skill files
149+
* skills/ ← skill directories
117150
* ```
118151
*
119-
* The bundler computes a content-based nonce so the agent host can
152+
* The bundler computes a metadata-based nonce so the agent host can
120153
* skip re-loading when nothing has changed.
121154
*/
122155
export class SyncedCustomizationBundler extends Disposable {
123156

157+
private readonly _fileOperationLimiter = this._register(new Limiter<unknown>(FILE_OPERATION_CONCURRENCY));
124158
private readonly _authority: string;
125159
private _lastNonce: string | undefined;
126160
private _lastRef: IBundleResult | undefined;
@@ -131,6 +165,7 @@ export class SyncedCustomizationBundler extends Disposable {
131165
authority: string,
132166
@IFileService private readonly _fileService: IFileService,
133167
@IAgentHostFileSystemService agentHostFileSystemService: IAgentHostFileSystemService,
168+
@ILogService private readonly _logService: ILogService,
134169
) {
135170
super();
136171
this._authority = authority;
@@ -146,12 +181,16 @@ export class SyncedCustomizationBundler extends Disposable {
146181
return URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: `/${this._authority}` });
147182
}
148183

184+
private _queueFileOperation<T>(operation: () => Promise<T>): Promise<T> {
185+
return this._fileOperationLimiter.queue(operation) as Promise<T>;
186+
}
187+
149188
/**
150189
* Bundles the given files and MCP servers into the in-memory plugin
151190
* filesystem.
152191
*
153192
* Overwrites any previous bundle content. Returns a {@link ClientPluginCustomization}
154-
* pointing at the virtual plugin directory with a content-based nonce.
193+
* pointing at the virtual plugin directory with a metadata-based nonce.
155194
*
156195
* @returns The bundle result, or `undefined` if there is nothing to sync.
157196
*/
@@ -161,13 +200,19 @@ export class SyncedCustomizationBundler extends Disposable {
161200
return undefined;
162201
}
163202

164-
// Read every source file up front so the content nonce can be computed
165-
// before touching the in-memory tree. This lets us skip the destructive
166-
// delete + rewrite entirely when nothing has changed since the last
167-
// bundle (a frequent case when a change event fires but content is
168-
// identical).
169-
const entries: { destUri: URI; content: VSBuffer; hashPart: string }[] = [];
203+
const entries: { sourceUri: URI; destUri: URI; hashPart: string }[] = [];
170204
const originByDest = new ResourceMap<ISyncedCustomizationOrigin>();
205+
const addEntry = (file: ISyncableFile, source: IFileStatWithPartialMetadata, destUri: URI, hashKey: string): void => {
206+
entries.push({ sourceUri: source.resource, destUri, hashPart: `${hashKey}:${source.mtime}:${source.size}` });
207+
if (file.source !== undefined) {
208+
originByDest.set(destUri, {
209+
uri: source.resource,
210+
source: file.source,
211+
extensionId: file.extensionId,
212+
pluginUri: file.pluginUri,
213+
});
214+
}
215+
};
171216
await Promise.all(syncable.map(async file => {
172217
const dir = pluginDirForType(file.type)!;
173218
const fileName = basename(file.uri);
@@ -176,38 +221,32 @@ export class SyncedCustomizationBundler extends Disposable {
176221
// The file locator returns the SKILL.md URI, so basename is
177222
// always "SKILL.md" — which would cause every skill to collide.
178223
// Preserve the directory structure: skills/{skillName}/SKILL.md.
179-
let destUri: URI;
180-
let hashKey: string;
181224
if (file.type === PromptsType.skill && fileName.toLowerCase() === 'skill.md') {
182-
const skillDirName = basename(dirname(file.uri));
183-
destUri = URI.joinPath(this._rootUri, dir, skillDirName, fileName);
184-
hashKey = `${dir}/${skillDirName}/${fileName}`;
225+
const skillRoot = dirname(file.uri);
226+
const skillDirName = basename(skillRoot);
227+
const entrypoint = await this._queueFileOperation(() => this._fileService.stat(file.uri));
228+
addEntry(file, entrypoint, URI.joinPath(this._rootUri, dir, skillDirName, fileName), `${dir}/${skillDirName}/${fileName}`);
229+
for (const source of await collectDirectoryFiles(this._fileService, this._logService, skillRoot, skillRoot, operation => this._queueFileOperation(operation))) {
230+
if (extUri.isEqual(source.resource, file.uri)) {
231+
continue;
232+
}
233+
const relativePath = extUri.relativePath(skillRoot, source.resource);
234+
if (relativePath === undefined) {
235+
throw new Error(`Unable to resolve skill resource path: ${source.resource.toString()}`);
236+
}
237+
addEntry(
238+
file,
239+
source,
240+
URI.joinPath(this._rootUri, dir, skillDirName, relativePath),
241+
`${dir}/${skillDirName}/${relativePath}`,
242+
);
243+
}
185244
} else {
186-
destUri = URI.joinPath(this._rootUri, dir, fileName);
187-
hashKey = `${dir}/${fileName}`;
188-
}
189-
190-
// Record the reverse mapping so the flattened file's original
191-
// provenance (extension/plugin/built-in) can be recovered later.
192-
// Only files that carry a source have recoverable provenance.
193-
if (file.source !== undefined) {
194-
originByDest.set(destUri, {
195-
uri: file.uri,
196-
source: file.source,
197-
extensionId: file.extensionId,
198-
pluginUri: file.pluginUri,
199-
});
245+
const source = await this._queueFileOperation(() => this._fileService.stat(file.uri));
246+
addEntry(file, source, URI.joinPath(this._rootUri, dir, fileName), `${dir}/${fileName}`);
200247
}
201-
202-
const content = await this._fileService.readFile(file.uri);
203-
entries.push({ destUri, content: content.value, hashPart: `${hashKey}:${content.value.toString()}` });
204248
}));
205249

206-
// Publish the freshly computed provenance map. This is done before the
207-
// nonce short-circuit below so the map always reflects the latest set of
208-
// bundled files, even when the content nonce is unchanged.
209-
this._originByDest = originByDest;
210-
211250
// Write MCP servers into `.mcp.json`. The agent host's Open Plugin
212251
// adapter reads this file relative to the plugin root. Servers are
213252
// sorted by name so the serialized content (and nonce) is stable.
@@ -241,8 +280,9 @@ export class SyncedCustomizationBundler extends Disposable {
241280
const nonce = String(hash(hashParts.join('\n')));
242281

243282
// Nothing changed since the last successful bundle — reuse it and skip
244-
// the delete + rewrite of the in-memory plugin tree.
283+
// reading file contents and rewriting the in-memory plugin tree.
245284
if (nonce === this._lastNonce && this._lastRef) {
285+
this._originByDest = originByDest;
246286
if (mcpServers.length > 0 && !equals(childEnablement, this._lastRef.ref.childEnablement)) {
247287
return {
248288
ref: {
@@ -254,6 +294,12 @@ export class SyncedCustomizationBundler extends Disposable {
254294
return this._lastRef;
255295
}
256296

297+
const fileContents = await Promise.all(entries.map(async entry => ({
298+
destUri: entry.destUri,
299+
content: (await this._queueFileOperation(() => this._fileService.readFile(entry.sourceUri))).value,
300+
})));
301+
this._originByDest = originByDest;
302+
257303
// Delete the previous tree for this authority, preserving other authorities
258304
try {
259305
await this._fileService.del(this._rootUri, { recursive: true });
@@ -266,7 +312,7 @@ export class SyncedCustomizationBundler extends Disposable {
266312
await this._fileService.writeFile(manifestUri, VSBuffer.fromString(MANIFEST_CONTENT));
267313

268314
// Write each source file into the correct plugin directory.
269-
for (const entry of entries) {
315+
for (const entry of fileContents) {
270316
await this._fileService.writeFile(entry.destUri, entry.content);
271317
}
272318

0 commit comments

Comments
 (0)