Compare commits

..

2 Commits

Author SHA1 Message Date
Boris Cherny
91a8d6c8d8 docs: add push event documentation and tests
Address code review feedback:
- Add push event to supported events list in docs/custom-automations.md
- Add auto-rebase example workflow using push events
- Add tests for isPushEvent type guard
- Add tests for push event mode detection
- Add test to verify track_progress throws error for push events

🤖 Generated with [Claude Code](https://claude.ai/code)
2026-01-01 13:19:30 -08:00
Boris Cherny
8151408b90 feat: add push event support for automation workflows
Add support for `push` events as an automation event type, enabling
workflows triggered by pushes to use claude-code-action.

Changes:
- Add PushEvent to automation event types in context.ts
- Add case handler for push events in parseGitHubContext()
- Add isPushEvent() type guard
- Add test for push event mode detection

This enables use cases like auto-rebasing PRs when main is updated,
where a workflow triggers on push to main and uses a prompt to
instruct Claude to merge main into stale PR branches.
2026-01-01 13:05:06 -08:00
24 changed files with 167 additions and 864 deletions

View File

@@ -81,10 +81,6 @@ inputs:
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands" description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
required: false required: false
default: "false" default: "false"
ssh_signing_key:
description: "SSH private key for signing commits. When provided, git will be configured to use SSH signing. Takes precedence over use_commit_signing."
required: false
default: ""
bot_id: bot_id:
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)" description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
required: false required: false
@@ -185,7 +181,6 @@ runs:
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
BOT_ID: ${{ inputs.bot_id }} BOT_ID: ${{ inputs.bot_id }}
BOT_NAME: ${{ inputs.bot_name }} BOT_NAME: ${{ inputs.bot_name }}
TRACK_PROGRESS: ${{ inputs.track_progress }} TRACK_PROGRESS: ${{ inputs.track_progress }}
@@ -213,8 +208,7 @@ runs:
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."
if command -v timeout &> /dev/null; then if command -v timeout &> /dev/null; then
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
timeout --foreground --kill-after=10 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
else else
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
fi fi
@@ -340,12 +334,6 @@ runs:
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
fi fi
- name: Cleanup SSH signing key
if: always() && inputs.ssh_signing_key != ''
shell: bash
run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
- name: Revoke app token - name: Revoke app token
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true' if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
shell: bash shell: bash

View File

@@ -129,8 +129,7 @@ runs:
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."
if command -v timeout &> /dev/null; then if command -v timeout &> /dev/null; then
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
timeout --foreground --kill-after=10 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
else else
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
fi fi

View File

@@ -8,47 +8,26 @@ const MARKETPLACE_URL_REGEX =
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/; /^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
/** /**
* Checks if a marketplace input is a local path (not a URL) * Validates a marketplace URL for security issues
* @param input - The marketplace input to check * @param url - The marketplace URL to validate
* @returns true if the input is a local path, false if it's a URL * @throws {Error} If the URL is invalid
*/ */
function isLocalPath(input: string): boolean { function validateMarketplaceUrl(url: string): void {
// Local paths start with ./, ../, /, or a drive letter (Windows) const normalized = url.trim();
return (
input.startsWith("./") ||
input.startsWith("../") ||
input.startsWith("/") ||
/^[a-zA-Z]:[\\\/]/.test(input)
);
}
/**
* Validates a marketplace URL or local path
* @param input - The marketplace URL or local path to validate
* @throws {Error} If the input is invalid
*/
function validateMarketplaceInput(input: string): void {
const normalized = input.trim();
if (!normalized) { if (!normalized) {
throw new Error("Marketplace URL or path cannot be empty"); throw new Error("Marketplace URL cannot be empty");
} }
// Local paths are passed directly to Claude Code which handles them
if (isLocalPath(normalized)) {
return;
}
// Validate as URL
if (!MARKETPLACE_URL_REGEX.test(normalized)) { if (!MARKETPLACE_URL_REGEX.test(normalized)) {
throw new Error(`Invalid marketplace URL format: ${input}`); throw new Error(`Invalid marketplace URL format: ${url}`);
} }
// Additional check for valid URL structure // Additional check for valid URL structure
try { try {
new URL(normalized); new URL(normalized);
} catch { } catch {
throw new Error(`Invalid marketplace URL: ${input}`); throw new Error(`Invalid marketplace URL: ${url}`);
} }
} }
@@ -76,9 +55,9 @@ function validatePluginName(pluginName: string): void {
} }
/** /**
* Parse a newline-separated list of marketplace URLs or local paths and return an array of validated entries * Parse a newline-separated list of marketplace URLs and return an array of validated URLs
* @param marketplaces - Newline-separated list of marketplace Git URLs or local paths * @param marketplaces - Newline-separated list of marketplace Git URLs
* @returns Array of validated marketplace URLs or paths (empty array if none provided) * @returns Array of validated marketplace URLs (empty array if none provided)
*/ */
function parseMarketplaces(marketplaces?: string): string[] { function parseMarketplaces(marketplaces?: string): string[] {
const trimmed = marketplaces?.trim(); const trimmed = marketplaces?.trim();
@@ -87,14 +66,14 @@ function parseMarketplaces(marketplaces?: string): string[] {
return []; return [];
} }
// Split by newline and process each entry // Split by newline and process each URL
return trimmed return trimmed
.split("\n") .split("\n")
.map((entry) => entry.trim()) .map((url) => url.trim())
.filter((entry) => { .filter((url) => {
if (entry.length === 0) return false; if (url.length === 0) return false;
validateMarketplaceInput(entry); validateMarketplaceUrl(url);
return true; return true;
}); });
} }
@@ -184,26 +163,26 @@ async function installPlugin(
/** /**
* Adds a Claude Code plugin marketplace * Adds a Claude Code plugin marketplace
* @param claudeExecutable - Path to the Claude executable * @param claudeExecutable - Path to the Claude executable
* @param marketplace - The marketplace Git URL or local path to add * @param marketplaceUrl - The marketplace Git URL to add
* @returns Promise that resolves when the marketplace add command completes * @returns Promise that resolves when the marketplace add command completes
* @throws {Error} If the command fails to execute * @throws {Error} If the command fails to execute
*/ */
async function addMarketplace( async function addMarketplace(
claudeExecutable: string, claudeExecutable: string,
marketplace: string, marketplaceUrl: string,
): Promise<void> { ): Promise<void> {
console.log(`Adding marketplace: ${marketplace}`); console.log(`Adding marketplace: ${marketplaceUrl}`);
return executeClaudeCommand( return executeClaudeCommand(
claudeExecutable, claudeExecutable,
["plugin", "marketplace", "add", marketplace], ["plugin", "marketplace", "add", marketplaceUrl],
`Failed to add marketplace '${marketplace}'`, `Failed to add marketplace '${marketplaceUrl}'`,
); );
} }
/** /**
* Installs Claude Code plugins from a newline-separated list * Installs Claude Code plugins from a newline-separated list
* @param marketplacesInput - Newline-separated list of marketplace Git URLs or local paths * @param marketplacesInput - Newline-separated list of marketplace Git URLs
* @param pluginsInput - Newline-separated list of plugin names * @param pluginsInput - Newline-separated list of plugin names
* @param claudeExecutable - Path to the Claude executable (defaults to "claude") * @param claudeExecutable - Path to the Claude executable (defaults to "claude")
* @returns Promise that resolves when all plugins are installed * @returns Promise that resolves when all plugins are installed

View File

@@ -212,8 +212,6 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
if (process.env.INPUT_ACTION_INPUTS_PRESENT) { if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT; env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
} }
// Ensure SDK path uses the same entrypoint as the CLI path
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
// Build system prompt option - default to claude_code preset // Build system prompt option - default to claude_code preset
let systemPrompt: SdkOptions["systemPrompt"]; let systemPrompt: SdkOptions["systemPrompt"];

View File

@@ -1,81 +1,14 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { readFile, writeFile, access } from "fs/promises"; import { readFile, writeFile } from "fs/promises";
import { dirname, join } from "path";
import { query } from "@anthropic-ai/claude-agent-sdk"; import { query } from "@anthropic-ai/claude-agent-sdk";
import type { import type {
SDKMessage, SDKMessage,
SDKResultMessage, SDKResultMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk"; } from "@anthropic-ai/claude-agent-sdk";
import type { ParsedSdkOptions } from "./parse-sdk-options"; import type { ParsedSdkOptions } from "./parse-sdk-options";
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
/** Filename for the user request file, written by prompt generation */
const USER_REQUEST_FILENAME = "claude-user-request.txt";
/**
* Check if a file exists
*/
async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Creates a prompt configuration for the SDK.
* If a user request file exists alongside the prompt file, returns a multi-block
* SDKUserMessage that enables slash command processing in the CLI.
* Otherwise, returns the prompt as a simple string.
*/
async function createPromptConfig(
promptPath: string,
showFullOutput: boolean,
): Promise<string | AsyncIterable<SDKUserMessage>> {
const promptContent = await readFile(promptPath, "utf-8");
// Check for user request file in the same directory
const userRequestPath = join(dirname(promptPath), USER_REQUEST_FILENAME);
const hasUserRequest = await fileExists(userRequestPath);
if (!hasUserRequest) {
// No user request file - use simple string prompt
return promptContent;
}
// User request file exists - create multi-block message
const userRequest = await readFile(userRequestPath, "utf-8");
if (showFullOutput) {
console.log("Using multi-block message with user request:", userRequest);
} else {
console.log("Using multi-block message with user request (content hidden)");
}
// Create an async generator that yields a single multi-block message
// The context/instructions go first, then the user's actual request last
// This allows the CLI to detect and process slash commands in the user request
async function* createMultiBlockMessage(): AsyncGenerator<SDKUserMessage> {
yield {
type: "user",
session_id: "",
message: {
role: "user",
content: [
{ type: "text", text: promptContent }, // Instructions + GitHub context
{ type: "text", text: userRequest }, // User's request (may be a slash command)
],
},
parent_tool_use_id: null,
};
}
return createMultiBlockMessage();
}
/** /**
* Sanitizes SDK output to match CLI sanitization behavior * Sanitizes SDK output to match CLI sanitization behavior
*/ */
@@ -130,8 +63,7 @@ export async function runClaudeWithSdk(
promptPath: string, promptPath: string,
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
): Promise<void> { ): Promise<void> {
// Create prompt configuration - may be a string or multi-block message const prompt = await readFile(promptPath, "utf-8");
const prompt = await createPromptConfig(promptPath, showFullOutput);
if (!showFullOutput) { if (!showFullOutput) {
console.log( console.log(

View File

@@ -596,111 +596,4 @@ describe("installPlugins", () => {
{ stdio: "inherit" }, { stdio: "inherit" },
); );
}); });
// Local marketplace path tests
test("should accept local marketplace path with ./", async () => {
const spy = createMockSpawn();
await installPlugins("./my-local-marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./my-local-marketplace"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should accept local marketplace path with absolute Unix path", async () => {
const spy = createMockSpawn();
await installPlugins("/home/user/my-marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "/home/user/my-marketplace"],
{ stdio: "inherit" },
);
});
test("should accept local marketplace path with Windows absolute path", async () => {
const spy = createMockSpawn();
await installPlugins("C:\\Users\\user\\marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "C:\\Users\\user\\marketplace"],
{ stdio: "inherit" },
);
});
test("should accept mixed local and remote marketplaces", async () => {
const spy = createMockSpawn();
await installPlugins(
"./local-marketplace\nhttps://github.com/user/remote.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./local-marketplace"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/remote.git"],
{ stdio: "inherit" },
);
});
test("should accept local path with ../ (parent directory)", async () => {
const spy = createMockSpawn();
await installPlugins("../shared-plugins/marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "../shared-plugins/marketplace"],
{ stdio: "inherit" },
);
});
test("should accept local path with nested directories", async () => {
const spy = createMockSpawn();
await installPlugins("./plugins/my-org/my-marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./plugins/my-org/my-marketplace"],
{ stdio: "inherit" },
);
});
test("should accept local path with dots in directory name", async () => {
const spy = createMockSpawn();
await installPlugins("./my.plugin.marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./my.plugin.marketplace"],
{ stdio: "inherit" },
);
});
}); });

View File

@@ -21,6 +21,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
- `issues` - When issues are opened or assigned - `issues` - When issues are opened or assigned
- `pull_request_review` - When PR reviews are submitted - `pull_request_review` - When PR reviews are submitted
- `pull_request_review_comment` - When comments are made on PR reviews - `pull_request_review_comment` - When comments are made on PR reviews
- `push` - When commits are pushed to a branch
- `repository_dispatch` - Custom events triggered via API - `repository_dispatch` - Custom events triggered via API
- `workflow_dispatch` - Manual workflow triggers (coming soon) - `workflow_dispatch` - Manual workflow triggers (coming soon)
@@ -120,3 +121,42 @@ For more control over Claude's behavior, use the `claude_args` input to pass CLI
``` ```
This provides full access to Claude Code CLI capabilities while maintaining the simplified action interface. This provides full access to Claude Code CLI capabilities while maintaining the simplified action interface.
## Auto-Rebase PRs on Push
Automatically keep PRs up to date when the main branch is updated:
```yaml
name: Auto-Rebase PRs
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
rebase-prs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: anthropics/claude-code-action@v1
with:
prompt: |
Find all open PRs that are behind main and merge main into them.
For each PR:
1. Check out the PR branch
2. Merge main into the branch
3. Push the updated branch
Skip any PRs with merge conflicts - just report them.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
```
This workflow triggers whenever commits are pushed to main and uses Claude to automatically merge main into any stale PR branches, keeping them up to date.

View File

@@ -38,64 +38,7 @@ The following permissions are requested but not yet actively used. These will en
## Commit Signing ## Commit Signing
By default, commits made by Claude are unsigned. You can enable commit signing using one of two methods: Commits made by Claude through this action are no longer automatically signed with commit signatures. To enable commit signing set `use_commit_signing: True` in the workflow(s). This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
### Option 1: GitHub API Commit Signing (use_commit_signing)
This uses GitHub's API to create commits, which automatically signs them as verified from the GitHub App:
```yaml
- uses: anthropics/claude-code-action@main
with:
use_commit_signing: true
```
This is the simplest option and requires no additional setup. However, because it uses the GitHub API instead of git CLI, it cannot perform complex git operations like rebasing, cherry-picking, or interactive history manipulation.
### Option 2: SSH Signing Key (ssh_signing_key)
This uses an SSH key to sign commits via git CLI. Use this option when you need both signed commits AND standard git operations (rebasing, cherry-picking, etc.):
```yaml
- uses: anthropics/claude-code-action@main
with:
ssh_signing_key: ${{ secrets.SSH_SIGNING_KEY }}
bot_id: "YOUR_GITHUB_USER_ID"
bot_name: "YOUR_GITHUB_USERNAME"
```
Commits will show as verified and attributed to the GitHub account that owns the signing key.
**Setup steps:**
1. Generate an SSH key pair for signing:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/signing_key -N "" -C "commit signing key"
```
2. Add the **public key** to your GitHub account:
- Go to GitHub → Settings → SSH and GPG keys
- Click "New SSH key"
- Select **Key type: Signing Key** (important)
- Paste the contents of `~/.ssh/signing_key.pub`
3. Add the **private key** to your repository secrets:
- Go to your repo → Settings → Secrets and variables → Actions
- Create a new secret named `SSH_SIGNING_KEY`
- Paste the contents of `~/.ssh/signing_key`
4. Get your GitHub user ID:
```bash
gh api users/YOUR_USERNAME --jq '.id'
```
5. Update your workflow with `bot_id` and `bot_name` matching the account where you added the signing key.
**Note:** If both `ssh_signing_key` and `use_commit_signing` are provided, `ssh_signing_key` takes precedence.
## ⚠️ Authentication Protection ## ⚠️ Authentication Protection

View File

@@ -71,10 +71,9 @@ jobs:
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | | `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" | | `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | | `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` | | `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" | | `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` | | `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` |
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" | | `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | 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 | "" | | `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 | "" |

View File

@@ -21,12 +21,8 @@ import type { ParsedGitHubContext } from "../github/context";
import type { CommonFields, PreparedContext, EventData } from "./types"; import type { CommonFields, PreparedContext, EventData } from "./types";
import { GITHUB_SERVER_URL } from "../github/api/config"; import { GITHUB_SERVER_URL } from "../github/api/config";
import type { Mode, ModeContext } from "../modes/types"; import type { Mode, ModeContext } from "../modes/types";
import { extractUserRequest } from "../utils/extract-user-request";
export type { CommonFields, PreparedContext } from "./types"; export type { CommonFields, PreparedContext } from "./types";
/** Filename for the user request file, read by the SDK runner */
const USER_REQUEST_FILENAME = "claude-user-request.txt";
// Tag mode defaults - these tools are needed for tag mode to function // Tag mode defaults - these tools are needed for tag mode to function
const BASE_ALLOWED_TOOLS = [ const BASE_ALLOWED_TOOLS = [
"Edit", "Edit",
@@ -851,55 +847,6 @@ f. If you are unable to complete certain steps, such as running a linter or test
return promptContent; return promptContent;
} }
/**
* Extracts the user's request from the prepared context and GitHub data.
*
* This is used to send the user's actual command/request as a separate
* content block, enabling slash command processing in the CLI.
*
* @param context - The prepared context containing event data and trigger phrase
* @param githubData - The fetched GitHub data containing issue/PR body content
* @returns The extracted user request text (e.g., "/review-pr" or "fix this bug"),
* or null for assigned/labeled events without an explicit trigger in the body
*
* @example
* // Comment event: "@claude /review-pr" -> returns "/review-pr"
* // Issue body with "@claude fix this" -> returns "fix this"
* // Issue assigned without @claude in body -> returns null
*/
function extractUserRequestFromContext(
context: PreparedContext,
githubData: FetchDataResult,
): string | null {
const { eventData, triggerPhrase } = context;
// For comment events, extract from comment body
if (
"commentBody" in eventData &&
eventData.commentBody &&
(eventData.eventName === "issue_comment" ||
eventData.eventName === "pull_request_review_comment" ||
eventData.eventName === "pull_request_review")
) {
return extractUserRequest(eventData.commentBody, triggerPhrase);
}
// For issue/PR events triggered by body content, extract from the body
if (githubData.contextData?.body) {
const request = extractUserRequest(
githubData.contextData.body,
triggerPhrase,
);
if (request) {
return request;
}
}
// For assigned/labeled events without explicit trigger in body,
// return null to indicate the full context should be used
return null;
}
export async function createPrompt( export async function createPrompt(
mode: Mode, mode: Mode,
modeContext: ModeContext, modeContext: ModeContext,
@@ -948,22 +895,6 @@ export async function createPrompt(
promptContent, promptContent,
); );
// Extract and write the user request separately for SDK multi-block messaging
// This allows the CLI to process slash commands (e.g., "@claude /review-pr")
const userRequest = extractUserRequestFromContext(
preparedContext,
githubData,
);
if (userRequest) {
await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/${USER_REQUEST_FILENAME}`,
userRequest,
);
console.log("===== USER REQUEST =====");
console.log(userRequest);
console.log("========================");
}
// Set allowed tools // Set allowed tools
const hasActionsReadPermission = false; const hasActionsReadPermission = false;

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bun
/**
* Cleanup SSH signing key after action completes
* This is run as a post step for security purposes
*/
import { cleanupSshSigning } from "../github/operations/git-config";
async function run() {
try {
await cleanupSshSigning();
} catch (error) {
// Don't fail the action if cleanup fails, just log it
console.error("Failed to cleanup SSH signing key:", error);
}
}
if (import.meta.main) {
run();
}

View File

@@ -26,7 +26,6 @@ export function collectActionInputsPresence(): void {
max_turns: "", max_turns: "",
use_sticky_comment: "false", use_sticky_comment: "false",
use_commit_signing: "false", use_commit_signing: "false",
ssh_signing_key: "",
}; };
const allInputsJson = process.env.ALL_INPUTS; const allInputsJson = process.env.ALL_INPUTS;

View File

@@ -6,6 +6,7 @@ import type {
PullRequestEvent, PullRequestEvent,
PullRequestReviewEvent, PullRequestReviewEvent,
PullRequestReviewCommentEvent, PullRequestReviewCommentEvent,
PushEvent,
WorkflowRunEvent, WorkflowRunEvent,
} from "@octokit/webhooks-types"; } from "@octokit/webhooks-types";
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "./constants"; import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "./constants";
@@ -65,6 +66,7 @@ const AUTOMATION_EVENT_NAMES = [
"repository_dispatch", "repository_dispatch",
"schedule", "schedule",
"workflow_run", "workflow_run",
"push",
] as const; ] as const;
// Derive types from constants for better maintainability // Derive types from constants for better maintainability
@@ -90,7 +92,6 @@ type BaseContext = {
branchPrefix: string; branchPrefix: string;
useStickyComment: boolean; useStickyComment: boolean;
useCommitSigning: boolean; useCommitSigning: boolean;
sshSigningKey: string;
botId: string; botId: string;
botName: string; botName: string;
allowedBots: string; allowedBots: string;
@@ -113,14 +114,15 @@ export type ParsedGitHubContext = BaseContext & {
isPR: boolean; isPR: boolean;
}; };
// Context for automation events (workflow_dispatch, repository_dispatch, schedule, workflow_run) // Context for automation events (workflow_dispatch, repository_dispatch, schedule, workflow_run, push)
export type AutomationContext = BaseContext & { export type AutomationContext = BaseContext & {
eventName: AutomationEventName; eventName: AutomationEventName;
payload: payload:
| WorkflowDispatchEvent | WorkflowDispatchEvent
| RepositoryDispatchEvent | RepositoryDispatchEvent
| ScheduleEvent | ScheduleEvent
| WorkflowRunEvent; | WorkflowRunEvent
| PushEvent;
}; };
// Union type for all contexts // Union type for all contexts
@@ -147,7 +149,6 @@ export function parseGitHubContext(): GitHubContext {
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true", useStickyComment: process.env.USE_STICKY_COMMENT === "true",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID), botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN, botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
allowedBots: process.env.ALLOWED_BOTS ?? "", allowedBots: process.env.ALLOWED_BOTS ?? "",
@@ -237,6 +238,13 @@ export function parseGitHubContext(): GitHubContext {
payload: context.payload as unknown as WorkflowRunEvent, payload: context.payload as unknown as WorkflowRunEvent,
}; };
} }
case "push": {
return {
...commonFields,
eventName: "push",
payload: context.payload as unknown as PushEvent,
};
}
default: default:
throw new Error(`Unsupported event type: ${context.eventName}`); throw new Error(`Unsupported event type: ${context.eventName}`);
} }
@@ -278,6 +286,12 @@ export function isIssuesAssignedEvent(
return isIssuesEvent(context) && context.eventAction === "assigned"; return isIssuesEvent(context) && context.eventAction === "assigned";
} }
export function isPushEvent(
context: GitHubContext,
): context is AutomationContext & { payload: PushEvent } {
return context.eventName === "push";
}
// Type guard to check if context is an entity context (has entityNumber and isPR) // Type guard to check if context is an entity context (has entityNumber and isPR)
export function isEntityContext( export function isEntityContext(
context: GitHubContext, context: GitHubContext,

View File

@@ -6,14 +6,9 @@
*/ */
import { $ } from "bun"; import { $ } from "bun";
import { mkdir, writeFile, rm } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
import type { GitHubContext } from "../context"; import type { GitHubContext } from "../context";
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
type GitUser = { type GitUser = {
login: string; login: string;
id: number; id: number;
@@ -59,50 +54,3 @@ export async function configureGitAuth(
console.log("Git authentication configured successfully"); console.log("Git authentication configured successfully");
} }
/**
* Configure git to use SSH signing for commits
* This is an alternative to GitHub API-based commit signing (use_commit_signing)
*/
export async function setupSshSigning(sshSigningKey: string): Promise<void> {
console.log("Configuring SSH signing for commits...");
// Validate SSH key format
if (!sshSigningKey.trim()) {
throw new Error("SSH signing key cannot be empty");
}
if (
!sshSigningKey.includes("BEGIN") ||
!sshSigningKey.includes("PRIVATE KEY")
) {
throw new Error("Invalid SSH private key format");
}
// Create .ssh directory with secure permissions (700)
const sshDir = join(homedir(), ".ssh");
await mkdir(sshDir, { recursive: true, mode: 0o700 });
// Write the signing key atomically with secure permissions (600)
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
// Configure git to use SSH signing
await $`git config gpg.format ssh`;
await $`git config user.signingkey ${SSH_SIGNING_KEY_PATH}`;
await $`git config commit.gpgsign true`;
console.log("✓ Git configured to use SSH signing for commits");
}
/**
* Clean up the SSH signing key file
* Should be called in the post step for security
*/
export async function cleanupSshSigning(): Promise<void> {
try {
await rm(SSH_SIGNING_KEY_PATH, { force: true });
console.log("✓ SSH signing key cleaned up");
} catch (error) {
console.log("No SSH signing key to clean up");
}
}

View File

@@ -4,10 +4,7 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
import type { PreparedContext } from "../../create-prompt/types"; import type { PreparedContext } from "../../create-prompt/types";
import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { parseAllowedTools } from "./parse-tools"; import { parseAllowedTools } from "./parse-tools";
import { import { configureGitAuth } from "../../github/operations/git-config";
configureGitAuth,
setupSshSigning,
} from "../../github/operations/git-config";
import type { GitHubContext } from "../../github/context"; import type { GitHubContext } from "../../github/context";
import { isEntityContext } from "../../github/context"; import { isEntityContext } from "../../github/context";
@@ -82,27 +79,7 @@ export const agentMode: Mode = {
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> { async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
// Configure git authentication for agent mode (same as tag mode) // Configure git authentication for agent mode (same as tag mode)
// SSH signing takes precedence if provided if (!context.inputs.useCommitSigning) {
const useSshSigning = !!context.inputs.sshSigningKey;
const useApiCommitSigning =
context.inputs.useCommitSigning && !useSshSigning;
if (useSshSigning) {
// Setup SSH signing for commits
await setupSshSigning(context.inputs.sshSigningKey);
// Still configure git auth for push operations (user/email and remote URL)
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
};
try {
await configureGitAuth(githubToken, context, user);
} catch (error) {
console.error("Failed to configure git authentication:", error);
// Continue anyway - git operations may still work with default config
}
} else if (!useApiCommitSigning) {
// Use bot_id and bot_name from inputs directly // Use bot_id and bot_name from inputs directly
const user = { const user = {
login: context.inputs.botName, login: context.inputs.botName,

View File

@@ -4,10 +4,7 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
import { checkHumanActor } from "../../github/validation/actor"; import { checkHumanActor } from "../../github/validation/actor";
import { createInitialComment } from "../../github/operations/comments/create-initial"; import { createInitialComment } from "../../github/operations/comments/create-initial";
import { setupBranch } from "../../github/operations/branch"; import { setupBranch } from "../../github/operations/branch";
import { import { configureGitAuth } from "../../github/operations/git-config";
configureGitAuth,
setupSshSigning,
} from "../../github/operations/git-config";
import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { import {
fetchGitHubData, fetchGitHubData,
@@ -91,28 +88,8 @@ export const tagMode: Mode = {
// Setup branch // Setup branch
const branchInfo = await setupBranch(octokit, githubData, context); const branchInfo = await setupBranch(octokit, githubData, context);
// Configure git authentication // Configure git authentication if not using commit signing
// SSH signing takes precedence if provided if (!context.inputs.useCommitSigning) {
const useSshSigning = !!context.inputs.sshSigningKey;
const useApiCommitSigning =
context.inputs.useCommitSigning && !useSshSigning;
if (useSshSigning) {
// Setup SSH signing for commits
await setupSshSigning(context.inputs.sshSigningKey);
// Still configure git auth for push operations (user/email and remote URL)
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
};
try {
await configureGitAuth(githubToken, context, user);
} catch (error) {
console.error("Failed to configure git authentication:", error);
throw error;
}
} else if (!useApiCommitSigning) {
// Use bot_id and bot_name from inputs directly // Use bot_id and bot_name from inputs directly
const user = { const user = {
login: context.inputs.botName, login: context.inputs.botName,
@@ -158,9 +135,8 @@ export const tagMode: Mode = {
...userAllowedMCPTools, ...userAllowedMCPTools,
]; ];
// Add git commands when using git CLI (no API commit signing, or SSH signing) // Add git commands when not using commit signing
// SSH signing still uses git CLI, just with signing enabled if (!context.inputs.useCommitSigning) {
if (!useApiCommitSigning) {
tagModeTools.push( tagModeTools.push(
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
@@ -171,7 +147,7 @@ export const tagMode: Mode = {
"Bash(git rm:*)", "Bash(git rm:*)",
); );
} else { } else {
// When using API commit signing, use MCP file ops tools // When using commit signing, use MCP file ops tools
tagModeTools.push( tagModeTools.push(
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
"mcp__github_file_ops__delete_files", "mcp__github_file_ops__delete_files",

View File

@@ -1,32 +0,0 @@
/**
* Extracts the user's request from a trigger comment.
*
* Given a comment like "@claude /review-pr please check the auth module",
* this extracts "/review-pr please check the auth module".
*
* @param commentBody - The full comment body containing the trigger phrase
* @param triggerPhrase - The trigger phrase (e.g., "@claude")
* @returns The user's request (text after the trigger phrase), or null if not found
*/
export function extractUserRequest(
commentBody: string | undefined,
triggerPhrase: string,
): string | null {
if (!commentBody) {
return null;
}
// Use string operations instead of regex for better performance and security
// (avoids potential ReDoS with large comment bodies)
const triggerIndex = commentBody
.toLowerCase()
.indexOf(triggerPhrase.toLowerCase());
if (triggerIndex === -1) {
return null;
}
const afterTrigger = commentBody
.substring(triggerIndex + triggerPhrase.length)
.trim();
return afterTrigger || null;
}

View File

@@ -1,77 +0,0 @@
import { describe, test, expect } from "bun:test";
import { extractUserRequest } from "../src/utils/extract-user-request";
describe("extractUserRequest", () => {
test("extracts text after @claude trigger", () => {
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
"/review-pr",
);
});
test("extracts slash command with arguments", () => {
expect(
extractUserRequest(
"@claude /review-pr please check the auth module",
"@claude",
),
).toBe("/review-pr please check the auth module");
});
test("handles trigger phrase with extra whitespace", () => {
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
"/review-pr",
);
});
test("handles trigger phrase at start of multiline comment", () => {
const comment = `@claude /review-pr
Please review this PR carefully.
Focus on security issues.`;
expect(extractUserRequest(comment, "@claude")).toBe(
`/review-pr
Please review this PR carefully.
Focus on security issues.`,
);
});
test("handles trigger phrase in middle of text", () => {
expect(
extractUserRequest("Hey team, @claude can you review this?", "@claude"),
).toBe("can you review this?");
});
test("returns null for empty comment body", () => {
expect(extractUserRequest("", "@claude")).toBeNull();
});
test("returns null for undefined comment body", () => {
expect(extractUserRequest(undefined, "@claude")).toBeNull();
});
test("returns null when trigger phrase not found", () => {
expect(extractUserRequest("Please review this PR", "@claude")).toBeNull();
});
test("returns null when only trigger phrase with no request", () => {
expect(extractUserRequest("@claude", "@claude")).toBeNull();
});
test("handles custom trigger phrase", () => {
expect(extractUserRequest("/claude help me", "/claude")).toBe("help me");
});
test("handles trigger phrase with special regex characters", () => {
expect(
extractUserRequest("@claude[bot] do something", "@claude[bot]"),
).toBe("do something");
});
test("is case insensitive", () => {
expect(extractUserRequest("@CLAUDE /review-pr", "@claude")).toBe(
"/review-pr",
);
expect(extractUserRequest("@Claude /review-pr", "@claude")).toBe(
"/review-pr",
);
});
});

View File

@@ -32,7 +32,6 @@ describe("prepareMcpConfig", () => {
branchPrefix: "", branchPrefix: "",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID), botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN, botName: CLAUDE_BOT_LOGIN,
allowedBots: "", allowedBots: "",

View File

@@ -20,7 +20,6 @@ const defaultInputs = {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID), botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN, botName: CLAUDE_BOT_LOGIN,
allowedBots: "", allowedBots: "",

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { detectMode } from "../../src/modes/detector"; import { detectMode } from "../../src/modes/detector";
import type { GitHubContext } from "../../src/github/context"; import type { GitHubContext } from "../../src/github/context";
import { isPushEvent } from "../../src/github/context";
describe("detectMode with enhanced routing", () => { describe("detectMode with enhanced routing", () => {
const baseContext = { const baseContext = {
@@ -20,7 +21,6 @@ describe("detectMode with enhanced routing", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
sshSigningKey: "",
botId: "123456", botId: "123456",
botName: "claude-bot", botName: "claude-bot",
allowedBots: "", allowedBots: "",
@@ -258,4 +258,65 @@ describe("detectMode with enhanced routing", () => {
expect(detectMode(context)).toBe("tag"); expect(detectMode(context)).toBe("tag");
}); });
}); });
describe("Push Events", () => {
it("should use agent mode for push events", () => {
const context: GitHubContext = {
...baseContext,
eventName: "push",
payload: {} as any,
inputs: { ...baseContext.inputs, prompt: "Merge main into stale PRs" },
};
expect(detectMode(context)).toBe("agent");
});
it("should throw error when track_progress is used with push event", () => {
const context: GitHubContext = {
...baseContext,
eventName: "push",
payload: {} as any,
inputs: { ...baseContext.inputs, trackProgress: true },
};
expect(() => detectMode(context)).toThrow(
/track_progress is only supported /,
);
});
});
describe("isPushEvent type guard", () => {
it("should return true for push events", () => {
const context: GitHubContext = {
...baseContext,
eventName: "push",
payload: {} as any,
};
expect(isPushEvent(context)).toBe(true);
});
it("should return false for non-push events", () => {
const issueContext: GitHubContext = {
...baseContext,
eventName: "issues",
eventAction: "opened",
payload: { issue: { number: 1, body: "Test" } } as any,
entityNumber: 1,
isPR: false,
};
expect(isPushEvent(issueContext)).toBe(false);
});
it("should return false for workflow_dispatch events", () => {
const context: GitHubContext = {
...baseContext,
eventName: "workflow_dispatch",
payload: {} as any,
};
expect(isPushEvent(context)).toBe(false);
});
});
}); });

View File

@@ -60,6 +60,15 @@ describe("Mode Registry", () => {
expect(mode.name).toBe("agent"); expect(mode.name).toBe("agent");
}); });
test("getMode auto-detects agent for push event", () => {
const pushContext = createMockAutomationContext({
eventName: "push",
});
const mode = getMode(pushContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode auto-detects agent for repository_dispatch with client_payload", () => { test("getMode auto-detects agent for repository_dispatch with client_payload", () => {
const contextWithPayload = createMockAutomationContext({ const contextWithPayload = createMockAutomationContext({
eventName: "repository_dispatch", eventName: "repository_dispatch",

View File

@@ -68,7 +68,6 @@ describe("checkWritePermissions", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID), botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN, botName: CLAUDE_BOT_LOGIN,
allowedBots: "", allowedBots: "",

View File

@@ -1,250 +0,0 @@
#!/usr/bin/env bun
import {
describe,
test,
expect,
afterEach,
beforeAll,
afterAll,
} from "bun:test";
import { mkdir, writeFile, rm, readFile, stat } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
describe("SSH Signing", () => {
// Use a temp directory for tests
const testTmpDir = join(tmpdir(), "claude-ssh-signing-test");
const testSshDir = join(testTmpDir, ".ssh");
const testKeyPath = join(testSshDir, "claude_signing_key");
const testKey =
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
beforeAll(async () => {
await mkdir(testTmpDir, { recursive: true });
});
afterAll(async () => {
await rm(testTmpDir, { recursive: true, force: true });
});
afterEach(async () => {
// Clean up test key if it exists
try {
await rm(testKeyPath, { force: true });
} catch {
// Ignore cleanup errors
}
});
describe("setupSshSigning file operations", () => {
test("should write key file atomically with correct permissions", async () => {
// Create the directory with secure permissions (same as setupSshSigning does)
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
// Write key atomically with proper permissions (same as setupSshSigning does)
await writeFile(testKeyPath, testKey, { mode: 0o600 });
// Verify key was written
const keyContent = await readFile(testKeyPath, "utf-8");
expect(keyContent).toBe(testKey);
// Verify permissions (0o600 = 384 in decimal for permission bits only)
const stats = await stat(testKeyPath);
const permissions = stats.mode & 0o777; // Get only permission bits
expect(permissions).toBe(0o600);
});
test("should create .ssh directory with secure permissions", async () => {
// Clean up first
await rm(testSshDir, { recursive: true, force: true });
// Create directory with secure permissions (same as setupSshSigning does)
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
// Verify directory exists
const dirStats = await stat(testSshDir);
expect(dirStats.isDirectory()).toBe(true);
// Verify directory permissions
const dirPermissions = dirStats.mode & 0o777;
expect(dirPermissions).toBe(0o700);
});
});
describe("setupSshSigning validation", () => {
test("should reject empty SSH key", () => {
const emptyKey = "";
expect(() => {
if (!emptyKey.trim()) {
throw new Error("SSH signing key cannot be empty");
}
}).toThrow("SSH signing key cannot be empty");
});
test("should reject whitespace-only SSH key", () => {
const whitespaceKey = " \n\t ";
expect(() => {
if (!whitespaceKey.trim()) {
throw new Error("SSH signing key cannot be empty");
}
}).toThrow("SSH signing key cannot be empty");
});
test("should reject invalid SSH key format", () => {
const invalidKey = "not a valid key";
expect(() => {
if (
!invalidKey.includes("BEGIN") ||
!invalidKey.includes("PRIVATE KEY")
) {
throw new Error("Invalid SSH private key format");
}
}).toThrow("Invalid SSH private key format");
});
test("should accept valid SSH key format", () => {
const validKey =
"-----BEGIN OPENSSH PRIVATE KEY-----\nkey-content\n-----END OPENSSH PRIVATE KEY-----";
expect(() => {
if (!validKey.trim()) {
throw new Error("SSH signing key cannot be empty");
}
if (!validKey.includes("BEGIN") || !validKey.includes("PRIVATE KEY")) {
throw new Error("Invalid SSH private key format");
}
}).not.toThrow();
});
});
describe("cleanupSshSigning file operations", () => {
test("should remove the signing key file", async () => {
// Create the key file first
await mkdir(testSshDir, { recursive: true });
await writeFile(testKeyPath, testKey, { mode: 0o600 });
// Verify it exists
const existsBefore = await stat(testKeyPath)
.then(() => true)
.catch(() => false);
expect(existsBefore).toBe(true);
// Clean up (same operation as cleanupSshSigning)
await rm(testKeyPath, { force: true });
// Verify it's gone
const existsAfter = await stat(testKeyPath)
.then(() => true)
.catch(() => false);
expect(existsAfter).toBe(false);
});
test("should not throw if key file does not exist", async () => {
// Make sure file doesn't exist
await rm(testKeyPath, { force: true });
// Should not throw (rm with force: true doesn't throw on missing files)
await expect(rm(testKeyPath, { force: true })).resolves.toBeUndefined();
});
});
});
describe("SSH Signing Mode Detection", () => {
test("sshSigningKey should take precedence over useCommitSigning", () => {
// When both are set, SSH signing takes precedence
const sshSigningKey = "test-key";
const useCommitSigning = true;
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
expect(useSshSigning).toBe(true);
expect(useApiCommitSigning).toBe(false);
});
test("useCommitSigning should work when sshSigningKey is not set", () => {
const sshSigningKey = "";
const useCommitSigning = true;
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
expect(useSshSigning).toBe(false);
expect(useApiCommitSigning).toBe(true);
});
test("neither signing method when both are false/empty", () => {
const sshSigningKey = "";
const useCommitSigning = false;
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
expect(useSshSigning).toBe(false);
expect(useApiCommitSigning).toBe(false);
});
test("git CLI tools should be used when sshSigningKey is set", () => {
// This tests the logic in tag mode for tool selection
const sshSigningKey = "test-key";
const useCommitSigning = true; // Even if this is true
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
// When SSH signing is used, we should use git CLI (not API)
const shouldUseGitCli = !useApiCommitSigning;
expect(shouldUseGitCli).toBe(true);
});
test("MCP file ops should only be used with API commit signing", () => {
// Case 1: API commit signing
{
const sshSigningKey = "";
const useCommitSigning = true;
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
expect(useApiCommitSigning).toBe(true);
}
// Case 2: SSH signing (should NOT use API)
{
const sshSigningKey = "test-key";
const useCommitSigning = true;
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
expect(useApiCommitSigning).toBe(false);
}
// Case 3: No signing (should NOT use API)
{
const sshSigningKey = "";
const useCommitSigning = false;
const useSshSigning = !!sshSigningKey;
const useApiCommitSigning = useCommitSigning && !useSshSigning;
expect(useApiCommitSigning).toBe(false);
}
});
});
describe("Context parsing", () => {
test("sshSigningKey should be parsed from environment", () => {
// Test that context.ts parses SSH_SIGNING_KEY correctly
const testCases = [
{ env: "test-key", expected: "test-key" },
{ env: "", expected: "" },
{ env: undefined, expected: "" },
];
for (const { env, expected } of testCases) {
const result = env || "";
expect(result).toBe(expected);
}
});
});