Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/design/acp-skill-management-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ACP Skill management module

## Context

The ACP agent currently owns remote Skill source validation, GitHub download and archive extraction, local installation, deletion, and enablement inside the same file as session and workspace control. The Skill logic is cohesive but its implementation is spread between top-level helpers, private agent methods, and three extension-method branches.

## Goals and non-goals

This refactor gives Skill source acquisition and managed Skill mutation dedicated modules while preserving the existing extension-method interface, validation, filesystem safety, cache refresh ordering, responses, and errors.

It does not change Skill discovery or status projection, add new scopes or source hosts, alter session Skill refresh, or change any Web Shell, bridge, or SDK contract.

## Module seams

The source module owns HTTPS and GitHub host validation, redirect validation, download limits, GitHub directory traversal, archive fallback, and tar extraction. Its primary interface resolves one source URL into the manifest content and files to install.

The management module owns request validation, global and project Skill resolution, frontmatter enablement, atomic installation, guarded deletion, and cache refresh. The ACP agent delegates install, delete, and set-enabled requests without interpreting their payloads.

The filesystem and network implementations remain direct dependencies. Tests use temporary directories and stubbed fetch responses; no new adapter layer is introduced.

## Preserved invariants

- Installation and deletion remain global-only; enablement retains global and project scopes.
- Skill slugs reject traversal and path separators.
- Remote sources remain HTTPS-only and limited to the existing GitHub host set.
- Redirect, compressed-size, decompressed-size, directory-depth, file-count, and cumulative-size guards remain unchanged.
- Installation validates the parsed Skill name, stages every file in a sibling directory, and refreshes the cache only after the swap succeeds.
- Deletion only removes a dedicated directory containing the validated `SKILL.md` and never removes a filesystem root or the global Qwen directory.
- Enablement edits only the top-level `disable-model-invocation` field and preserves comments, nested frontmatter, and body content.
- Extension-method response shapes, error types, error text, and requested working-directory behavior remain unchanged.

## Verification

Focused tests cover source and archive safety, global and project mutations, frontmatter preservation, route delegation, and the existing ACP integration behavior. The CLI package tests run alongside the repository build and typecheck.
146 changes: 0 additions & 146 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,14 +879,11 @@ import {
toSseServer,
toHttpServer,
normalizeCoreSettingValue,
extractFilesFromTarGz,
fetchAllowedGitHub,
createWorkspaceMcpBudget,
deliverClientMcpMessage,
selectVisibleHistoryRecords,
createManagedExternalToolGuard,
} from './acpAgent.js';
import { gzipSync } from 'node:zlib';
import type { Config, GoalSnapshotV2 } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
Expand Down Expand Up @@ -18644,149 +18641,6 @@ describe('normalizeCoreSettingValue', () => {
});
});

describe('extractFilesFromTarGz', () => {
// Minimal tar (ustar) entry builder — only the fields the parser reads.
function tarEntry(name: string, content: string): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 'utf8'); // name @ 0 (100 bytes)
const size = Buffer.byteLength(content);
header.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'utf8'); // size @ 124 (octal)
header.write('0', 156, 'utf8'); // typeflag '0' = regular file
const data = Buffer.alloc(Math.ceil(size / 512) * 512);
data.write(content, 0, 'utf8');
return Buffer.concat([header, data]);
}

function makeTarGz(name: string, content: string): Uint8Array {
const tar = Buffer.concat([tarEntry(name, content), Buffer.alloc(1024)]); // + end blocks
return new Uint8Array(gzipSync(tar));
}

it('extracts files under the requested directory (stripping the archive root)', async () => {
const archive = makeTarGz('repo-main/skills/SKILL.md', 'hello skill');
const files = await extractFilesFromTarGz(archive, 'skills');
expect(files).toHaveLength(1);
expect(files[0]!.relativePath).toBe('SKILL.md');
expect(Buffer.from(files[0]!.content).toString('utf8')).toBe('hello skill');
});

it('rejects an archive whose compressed size exceeds the limit', async () => {
await expect(
extractFilesFromTarGz(new Uint8Array(64), 'skills', {
maxCompressedBytes: 16,
}),
).rejects.toThrowError(/exceeds the maximum allowed size/);
});

it('rejects an archive that fails to decompress', async () => {
await expect(
extractFilesFromTarGz(new Uint8Array([1, 2, 3, 4, 5]), 'skills'),
).rejects.toThrowError(/Failed to decompress skill archive/);
});

it('rejects an archive whose decompressed size exceeds the limit', async () => {
const archive = makeTarGz('repo-main/skills/SKILL.md', 'x'.repeat(2048));
await expect(
extractFilesFromTarGz(archive, 'skills', {
maxDecompressedBytes: 16,
}),
).rejects.toThrowError(/Decompressed skill archive exceeds/);
});
});

describe('fetchAllowedGitHub', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

function fakeResponse(status: number, location?: string) {
return {
status,
ok: status >= 200 && status < 300,
headers: {
get: (key: string) =>
key.toLowerCase() === 'location' && location ? location : null,
},
};
}

it('returns the response directly when there is no redirect', async () => {
const res = fakeResponse(200);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res));
await expect(
fetchAllowedGitHub('https://raw.githubusercontent.com/a/b/main/SKILL.md'),
).resolves.toBe(res);
});

it('follows a redirect to an allowed GitHub CDN host', async () => {
const final = fakeResponse(200);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
fakeResponse(302, 'https://objects.githubusercontent.com/x'),
)
.mockResolvedValueOnce(final);
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchAllowedGitHub('https://codeload.github.com/a/b/tar.gz/main'),
).resolves.toBe(final);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('rejects a redirect to a disallowed host', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(fakeResponse(302, 'https://evil.com/x')),
);
await expect(
fetchAllowedGitHub('https://raw.githubusercontent.com/a/b/main/SKILL.md'),
).rejects.toThrow(/disallowed host/);
});

it('rejects a non-https redirect target', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
fakeResponse(302, 'http://raw.githubusercontent.com/x'),
),
);
await expect(
fetchAllowedGitHub('https://raw.githubusercontent.com/a/b/main/SKILL.md'),
).rejects.toThrow(/disallowed host/);
});

it('rejects when the redirect limit is exceeded', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
fakeResponse(302, 'https://raw.githubusercontent.com/loop'),
),
);
await expect(
fetchAllowedGitHub('https://raw.githubusercontent.com/a', {}, 2),
).rejects.toThrow(/maximum number of redirects/);
});

it('resolves a relative Location against the current URL', async () => {
const final = fakeResponse(200);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(fakeResponse(302, '/a/b/SKILL.md'))
.mockResolvedValueOnce(final);
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchAllowedGitHub('https://raw.githubusercontent.com/start'),
).resolves.toBe(final);
expect(fetchMock.mock.calls[1]![0]).toBe(
'https://raw.githubusercontent.com/a/b/SKILL.md',
);
});
});

// ---------------------------------------------------------------------------
// Multi-session language propagation
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading