mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 14:24:13 +08:00
feat: add ssh_signing_key input for SSH commit signing (#784)
* feat: add ssh_signing_key input for SSH commit signing Add a new ssh_signing_key input that allows passing an SSH signing key for commit signing, as an alternative to the existing use_commit_signing (which uses GitHub API-based commits). When ssh_signing_key is provided: - Git is configured to use SSH signing (gpg.format=ssh, commit.gpgsign=true) - The key is written to ~/.ssh/claude_signing_key with 0600 permissions - Git CLI commands are used (not MCP file ops) - The key is cleaned up in a post step for security Behavior matrix: | ssh_signing_key | use_commit_signing | Result | |-----------------|-------------------|--------| | not set | false | Regular git, no signing | | not set | true | GitHub API (MCP), verified commits | | set | false | Git CLI with SSH signing | | set | true | Git CLI with SSH signing (ssh_signing_key takes precedence) * docs: add SSH signing key documentation - Update security.md with detailed setup instructions for both signing options - Explain that ssh_signing_key enables full git CLI operations (rebasing, etc.) - Add ssh_signing_key to inputs table in usage.md - Update bot_id/bot_name descriptions to note they're needed for verified commits * fix: address security review feedback for SSH signing - Write SSH key atomically with mode 0o600 (fixes TOCTOU race condition) - Create .ssh directory with mode 0o700 (SSH best practices) - Add input validation for SSH key format - Remove unused chmod import - Add tests for validation logic
This commit is contained in:
11
action.yml
11
action.yml
@@ -81,6 +81,10 @@ inputs:
|
||||
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||
required: 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:
|
||||
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
||||
required: false
|
||||
@@ -181,6 +185,7 @@ runs:
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
|
||||
BOT_ID: ${{ inputs.bot_id }}
|
||||
BOT_NAME: ${{ inputs.bot_name }}
|
||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||
@@ -334,6 +339,12 @@ runs:
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
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
|
||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -38,7 +38,64 @@ The following permissions are requested but not yet actively used. These will en
|
||||
|
||||
## Commit Signing
|
||||
|
||||
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.
|
||||
By default, commits made by Claude are unsigned. You can enable commit signing using one of two methods:
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -71,9 +71,10 @@ jobs:
|
||||
| `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 | "" |
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||
| `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` |
|
||||
| `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). 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). 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_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 | "" |
|
||||
|
||||
21
src/entrypoints/cleanup-ssh-signing.ts
Normal file
21
src/entrypoints/cleanup-ssh-signing.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/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();
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export function collectActionInputsPresence(): void {
|
||||
max_turns: "",
|
||||
use_sticky_comment: "false",
|
||||
use_commit_signing: "false",
|
||||
ssh_signing_key: "",
|
||||
};
|
||||
|
||||
const allInputsJson = process.env.ALL_INPUTS;
|
||||
|
||||
@@ -90,6 +90,7 @@ type BaseContext = {
|
||||
branchPrefix: string;
|
||||
useStickyComment: boolean;
|
||||
useCommitSigning: boolean;
|
||||
sshSigningKey: string;
|
||||
botId: string;
|
||||
botName: string;
|
||||
allowedBots: string;
|
||||
@@ -146,6 +147,7 @@ export function parseGitHubContext(): GitHubContext {
|
||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
||||
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
||||
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { mkdir, writeFile, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type { GitHubContext } from "../context";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
|
||||
|
||||
type GitUser = {
|
||||
login: string;
|
||||
id: number;
|
||||
@@ -54,3 +59,50 @@ export async function configureGitAuth(
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import type { PreparedContext } from "../../create-prompt/types";
|
||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||
import { parseAllowedTools } from "./parse-tools";
|
||||
import { configureGitAuth } from "../../github/operations/git-config";
|
||||
import {
|
||||
configureGitAuth,
|
||||
setupSshSigning,
|
||||
} from "../../github/operations/git-config";
|
||||
import type { GitHubContext } from "../../github/context";
|
||||
import { isEntityContext } from "../../github/context";
|
||||
|
||||
@@ -79,7 +82,27 @@ export const agentMode: Mode = {
|
||||
|
||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||
// Configure git authentication for agent mode (same as tag mode)
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
// SSH signing takes precedence if provided
|
||||
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
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
||||
import { setupBranch } from "../../github/operations/branch";
|
||||
import { configureGitAuth } from "../../github/operations/git-config";
|
||||
import {
|
||||
configureGitAuth,
|
||||
setupSshSigning,
|
||||
} from "../../github/operations/git-config";
|
||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||
import {
|
||||
fetchGitHubData,
|
||||
@@ -88,8 +91,28 @@ export const tagMode: Mode = {
|
||||
// Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Configure git authentication if not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
// Configure git authentication
|
||||
// SSH signing takes precedence if provided
|
||||
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
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
@@ -135,8 +158,9 @@ export const tagMode: Mode = {
|
||||
...userAllowedMCPTools,
|
||||
];
|
||||
|
||||
// Add git commands when not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
// Add git commands when using git CLI (no API commit signing, or SSH signing)
|
||||
// SSH signing still uses git CLI, just with signing enabled
|
||||
if (!useApiCommitSigning) {
|
||||
tagModeTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
@@ -147,7 +171,7 @@ export const tagMode: Mode = {
|
||||
"Bash(git rm:*)",
|
||||
);
|
||||
} else {
|
||||
// When using commit signing, use MCP file ops tools
|
||||
// When using API commit signing, use MCP file ops tools
|
||||
tagModeTools.push(
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => {
|
||||
branchPrefix: "",
|
||||
useStickyComment: false,
|
||||
useCommitSigning: false,
|
||||
sshSigningKey: "",
|
||||
botId: String(CLAUDE_APP_BOT_ID),
|
||||
botName: CLAUDE_BOT_LOGIN,
|
||||
allowedBots: "",
|
||||
|
||||
@@ -20,6 +20,7 @@ const defaultInputs = {
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
useCommitSigning: false,
|
||||
sshSigningKey: "",
|
||||
botId: String(CLAUDE_APP_BOT_ID),
|
||||
botName: CLAUDE_BOT_LOGIN,
|
||||
allowedBots: "",
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("detectMode with enhanced routing", () => {
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
useCommitSigning: false,
|
||||
sshSigningKey: "",
|
||||
botId: "123456",
|
||||
botName: "claude-bot",
|
||||
allowedBots: "",
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("checkWritePermissions", () => {
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
useCommitSigning: false,
|
||||
sshSigningKey: "",
|
||||
botId: String(CLAUDE_APP_BOT_ID),
|
||||
botName: CLAUDE_BOT_LOGIN,
|
||||
allowedBots: "",
|
||||
|
||||
250
test/ssh-signing.test.ts
Normal file
250
test/ssh-signing.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user