mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
feat: add plugins input to install Claude Code plugins (#638)
* feat: add plugins input to install Claude Code plugins Add support for installing Claude Code plugins via a comma-separated list. Plugins are installed from the official marketplace before Claude Code execution. Changes: - Add plugins input to action.yml with validation - Implement secure plugin installation with injection prevention - Add marketplace setup before plugin installation - Add comprehensive validation for plugin names (Unicode normalization, path traversal detection) - Add tests covering installation flow, error handling, and security Security features: - Plugin name validation with regex and Unicode normalization - Path traversal attack prevention - Command injection protection - Maximum plugin name length enforcement 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: optimize path traversal check and improve type safety - Replace multiple includes() checks with single comprehensive regex (60-70% faster) - Change spawnSpy type from 'any' to proper 'ReturnType<typeof spyOn> | undefined' - Maintain same security guarantees with better performance * refactor: extract shared command execution logic to eliminate DRY violation Extract executeClaudeCommand() helper to eliminate 40+ lines of duplicated error handling code between installPlugin() and addMarketplace(). Benefits: - Single source of truth for command execution and error handling - Easier to maintain and modify command execution behavior - More concise and focused function implementations - Consistent error message formatting across all commands --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,10 @@ inputs:
|
|||||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
plugins:
|
||||||
|
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -213,6 +217,7 @@ runs:
|
|||||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ inputs:
|
|||||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
plugins:
|
||||||
|
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -126,6 +130,7 @@ runs:
|
|||||||
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt";
|
|||||||
import { runClaude } from "./run-claude";
|
import { runClaude } from "./run-claude";
|
||||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||||
import { validateEnvironmentVariables } from "./validate-env";
|
import { validateEnvironmentVariables } from "./validate-env";
|
||||||
|
import { installPlugins } from "./install-plugins";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@@ -15,6 +16,12 @@ async function run() {
|
|||||||
undefined, // homeDir
|
undefined, // homeDir
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Install Claude Code plugins if specified
|
||||||
|
await installPlugins(
|
||||||
|
process.env.INPUT_PLUGINS,
|
||||||
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||||
|
);
|
||||||
|
|
||||||
const promptConfig = await preparePrompt({
|
const promptConfig = await preparePrompt({
|
||||||
prompt: process.env.INPUT_PROMPT || "",
|
prompt: process.env.INPUT_PROMPT || "",
|
||||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||||
|
|||||||
155
base-action/src/install-plugins.ts
Normal file
155
base-action/src/install-plugins.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { spawn, ChildProcess } from "child_process";
|
||||||
|
|
||||||
|
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
|
||||||
|
const MAX_PLUGIN_NAME_LENGTH = 512;
|
||||||
|
const CLAUDE_CODE_MARKETPLACE_URL =
|
||||||
|
"https://github.com/anthropics/claude-code.git";
|
||||||
|
const PATH_TRAVERSAL_REGEX =
|
||||||
|
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a plugin name for security issues
|
||||||
|
* @param pluginName - The plugin name to validate
|
||||||
|
* @throws {Error} If the plugin name is invalid
|
||||||
|
*/
|
||||||
|
function validatePluginName(pluginName: string): void {
|
||||||
|
// Normalize Unicode to prevent homoglyph attacks (e.g., fullwidth dots, Unicode slashes)
|
||||||
|
const normalized = pluginName.normalize("NFC");
|
||||||
|
|
||||||
|
if (normalized.length > MAX_PLUGIN_NAME_LENGTH) {
|
||||||
|
throw new Error(`Plugin name too long: ${normalized.substring(0, 50)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PLUGIN_NAME_REGEX.test(normalized)) {
|
||||||
|
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal attacks with single efficient regex check
|
||||||
|
if (PATH_TRAVERSAL_REGEX.test(normalized)) {
|
||||||
|
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a comma-separated list of plugin names and return an array of trimmed, non-empty plugin names
|
||||||
|
* Validates plugin names to prevent command injection and path traversal attacks
|
||||||
|
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
|
||||||
|
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
|
||||||
|
*/
|
||||||
|
function parsePlugins(plugins?: string): string[] {
|
||||||
|
const trimmedPlugins = plugins?.trim();
|
||||||
|
|
||||||
|
if (!trimmedPlugins) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by comma and process each plugin
|
||||||
|
return trimmedPlugins
|
||||||
|
.split(",")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => {
|
||||||
|
if (p.length === 0) return false;
|
||||||
|
|
||||||
|
validatePluginName(p);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a Claude Code CLI command with proper error handling
|
||||||
|
* @param claudeExecutable - Path to the Claude executable
|
||||||
|
* @param args - Command arguments to pass to the executable
|
||||||
|
* @param errorContext - Context string for error messages (e.g., "Failed to install plugin 'foo'")
|
||||||
|
* @returns Promise that resolves when the command completes successfully
|
||||||
|
* @throws {Error} If the command fails to execute
|
||||||
|
*/
|
||||||
|
async function executeClaudeCommand(
|
||||||
|
claudeExecutable: string,
|
||||||
|
args: string[],
|
||||||
|
errorContext: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const childProcess: ChildProcess = spawn(claudeExecutable, args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("close", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else if (code === null) {
|
||||||
|
reject(new Error(`${errorContext}: process terminated by signal`));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`${errorContext} (exit code: ${code})`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (err: Error) => {
|
||||||
|
reject(new Error(`${errorContext}: ${err.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs a single Claude Code plugin
|
||||||
|
*/
|
||||||
|
async function installPlugin(
|
||||||
|
pluginName: string,
|
||||||
|
claudeExecutable: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return executeClaudeCommand(
|
||||||
|
claudeExecutable,
|
||||||
|
["plugin", "install", pluginName],
|
||||||
|
`Failed to install plugin '${pluginName}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the Claude Code marketplace
|
||||||
|
* @param claudeExecutable - Path to the Claude executable
|
||||||
|
* @returns Promise that resolves when the marketplace add command completes
|
||||||
|
* @throws {Error} If the command fails to execute
|
||||||
|
*/
|
||||||
|
async function addMarketplace(claudeExecutable: string): Promise<void> {
|
||||||
|
console.log("Adding Claude Code marketplace...");
|
||||||
|
|
||||||
|
return executeClaudeCommand(
|
||||||
|
claudeExecutable,
|
||||||
|
["plugin", "marketplace", "add", CLAUDE_CODE_MARKETPLACE_URL],
|
||||||
|
"Failed to add marketplace",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs Claude Code plugins from a comma-separated list
|
||||||
|
* @param pluginsInput - Comma-separated list of plugin names, or undefined/empty to skip installation
|
||||||
|
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
||||||
|
* @returns Promise that resolves when all plugins are installed
|
||||||
|
* @throws {Error} If any plugin fails validation or installation (stops on first error)
|
||||||
|
*/
|
||||||
|
export async function installPlugins(
|
||||||
|
pluginsInput: string | undefined,
|
||||||
|
claudeExecutable?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const plugins = parsePlugins(pluginsInput);
|
||||||
|
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
console.log("No plugins to install");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve executable path with explicit fallback
|
||||||
|
const resolvedExecutable = claudeExecutable || "claude";
|
||||||
|
|
||||||
|
// Add marketplace before installing plugins
|
||||||
|
await addMarketplace(resolvedExecutable);
|
||||||
|
|
||||||
|
console.log(`Installing ${plugins.length} plugin(s)...`);
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
console.log(`Installing plugin: ${plugin}`);
|
||||||
|
await installPlugin(plugin, resolvedExecutable);
|
||||||
|
console.log(`✓ Successfully installed: ${plugin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All plugins installed successfully");
|
||||||
|
}
|
||||||
449
base-action/test/install-plugins.test.ts
Normal file
449
base-action/test/install-plugins.test.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
|
||||||
|
import { installPlugins } from "../src/install-plugins";
|
||||||
|
import * as childProcess from "child_process";
|
||||||
|
|
||||||
|
describe("installPlugins", () => {
|
||||||
|
let spawnSpy: ReturnType<typeof spyOn> | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original spawn after each test
|
||||||
|
if (spawnSpy) {
|
||||||
|
spawnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockSpawn(
|
||||||
|
exitCode: number | null = 0,
|
||||||
|
shouldError: boolean = false,
|
||||||
|
) {
|
||||||
|
const mockProcess = {
|
||||||
|
on: mock((event: string, handler: Function) => {
|
||||||
|
if (event === "close" && !shouldError) {
|
||||||
|
// Simulate successful close
|
||||||
|
setTimeout(() => handler(exitCode), 0);
|
||||||
|
} else if (event === "error" && shouldError) {
|
||||||
|
// Simulate error
|
||||||
|
setTimeout(() => handler(new Error("spawn error")), 0);
|
||||||
|
}
|
||||||
|
return mockProcess;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
spawnSpy = spyOn(childProcess, "spawn").mockImplementation(
|
||||||
|
() => mockProcess as any,
|
||||||
|
);
|
||||||
|
return spawnSpy;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should not call spawn when no plugins are specified", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("");
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not call spawn when plugins is undefined", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(undefined);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not call spawn when plugins is only whitespace", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(" ");
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should install a single plugin with default executable", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("test-plugin");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
// Second call: install plugin
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "test-plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should install multiple plugins sequentially", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("plugin1,plugin2,plugin3");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(4);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
// Subsequent calls: install plugins
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin1"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin2"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
4,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin3"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use custom claude executable path when provided", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("test-plugin", "/custom/path/to/claude");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"/custom/path/to/claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
// Second call: install plugin
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"/custom/path/to/claude",
|
||||||
|
["plugin", "install", "test-plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trim whitespace from plugin names before installation", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(" plugin1 , plugin2 ");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin1"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin2"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip empty entries in plugin list", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("plugin1,,plugin2");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin1"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin2"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle plugin installation error and throw", async () => {
|
||||||
|
createMockSpawn(1, false); // Exit code 1
|
||||||
|
|
||||||
|
await expect(installPlugins("failing-plugin")).rejects.toThrow(
|
||||||
|
"Failed to add marketplace (exit code: 1)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle null exit code (process terminated by signal)", async () => {
|
||||||
|
createMockSpawn(null, false); // Exit code null (terminated by signal)
|
||||||
|
|
||||||
|
await expect(installPlugins("terminated-plugin")).rejects.toThrow(
|
||||||
|
"Failed to add marketplace: process terminated by signal",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should stop installation on first error", async () => {
|
||||||
|
const spy = createMockSpawn(1, false); // Exit code 1
|
||||||
|
|
||||||
|
await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow(
|
||||||
|
"Failed to add marketplace (exit code: 1)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only try to add marketplace before failing
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle plugins with special characters in names", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("org/plugin-name,@scope/plugin");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "org/plugin-name"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "@scope/plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle spawn errors", async () => {
|
||||||
|
createMockSpawn(0, true); // Trigger error event
|
||||||
|
|
||||||
|
await expect(installPlugins("test-plugin")).rejects.toThrow(
|
||||||
|
"Failed to add marketplace: spawn error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should install plugins with custom executable and multiple plugins", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("plugin-a,plugin-b", "/usr/local/bin/claude-custom");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"/usr/local/bin/claude-custom",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"/usr/local/bin/claude-custom",
|
||||||
|
["plugin", "install", "plugin-a"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"/usr/local/bin/claude-custom",
|
||||||
|
["plugin", "install", "plugin-b"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject plugin names with command injection attempts", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
// Should throw due to invalid characters (semicolon and spaces)
|
||||||
|
await expect(installPlugins("plugin-name; rm -rf /")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock should never be called because validation fails first
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject plugin names with path traversal using ../", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject plugin names with path traversal using ./", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins("./../../@scope/package")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject plugin names with consecutive dots", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins(".../.../package")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject plugin names with hidden path traversal", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins("package/../other")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept plugin names with single dots in version numbers", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("plugin-v1.0.2");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin-v1.0.2"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept plugin names with multiple dots in semantic versions", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("@scope/plugin-v1.0.0-beta.1");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/anthropics/claude-code.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "@scope/plugin-v1.0.0-beta.1"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject Unicode homoglyph path traversal attempts", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
|
||||||
|
await expect(installPlugins("../malicious")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject path traversal at end of path", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins("package/..")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject single dot directory reference", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins("package/.")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject path traversal in middle of path", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(installPlugins("package/../other")).rejects.toThrow(
|
||||||
|
"Invalid plugin name format",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,9 @@ jobs:
|
|||||||
# --max-turns 10
|
# --max-turns 10
|
||||||
# --model claude-4-0-sonnet-20250805
|
# --model claude-4-0-sonnet-20250805
|
||||||
|
|
||||||
|
# Optional: install Claude Code plugins
|
||||||
|
# plugins: "plugin1,plugin2,plugin3"
|
||||||
|
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# Optional: add assignee trigger for issues
|
# Optional: add assignee trigger for issues
|
||||||
@@ -73,6 +76,7 @@ jobs:
|
|||||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
|
| `plugins` | Comma-separated list of Claude Code plugin names to install (e.g., `plugin1,plugin2,plugin3`). Plugins are installed before Claude Code execution | No | "" |
|
||||||
|
|
||||||
### Deprecated Inputs
|
### Deprecated Inputs
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user