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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Skills define what Warden analyzes and when.
| `ignorePaths` | Files to exclude (glob patterns) |
| `failOn` | Minimum severity to fail: `critical`, `high`, `medium`, `low`, `info`, `off` |
| `reportOn` | Minimum severity to report |
| `remote` | GitHub repository for remote skills: `owner/repo` or `owner/repo@sha` |
| `remote` | GitHub repository for remote skills: `owner/repo` or `owner/repo@sha`. Hosted private remotes use `github-token` auth on `github.com`. |
| `model` | Model override |
| `maxTurns` | Max agentic turns per hunk |

Expand Down Expand Up @@ -473,7 +473,7 @@ jobs:

| Input | Default | Description |
|-------|---------|-------------|
| `github-token` | `GITHUB_TOKEN` | GitHub token for posting comments |
| `github-token` | `GITHUB_TOKEN` | GitHub token for posting comments and private remote-skill fetch auth in hosted runs |
| `anthropic-api-key` | - | Anthropic API key (falls back to `WARDEN_ANTHROPIC_API_KEY`) |
| `config-path` | `warden.toml` | Path to config file |
| `fail-on` | - | Minimum severity to fail the check |
Expand Down
16 changes: 14 additions & 2 deletions packages/docs/src/pages/config.astro
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ actions = ["opened", "synchronize"]`}
<dt>reportOn</dt>
<dd>Minimum severity to report</dd>
<dt>remote</dt>
<dd>GitHub repository for remote skills: <code>owner/repo</code> or <code>owner/repo@sha</code></dd>
<dd>
GitHub repository for remote skills: <code>owner/repo</code> or <code>owner/repo@sha</code>.
In hosted GitHub Actions runs, private remotes use <code>github-token</code> auth on <code>github.com</code>.
</dd>
<dt>model</dt>
<dd>Model override (optional)</dd>
<dt>maxTurns</dt>
Expand Down Expand Up @@ -426,7 +429,7 @@ jobs:

<dl>
<dt>github-token</dt>
<dd>GitHub token for posting comments. Default: <code>GITHUB_TOKEN</code></dd>
<dd>GitHub token for posting comments and private remote-skill fetch auth in hosted runs. Default: <code>GITHUB_TOKEN</code></dd>
<dt>anthropic-api-key</dt>
<dd>Anthropic API key (falls back to <code>WARDEN_ANTHROPIC_API_KEY</code>)</dd>
<dt>config-path</dt>
Expand All @@ -444,4 +447,13 @@ jobs:
<dt>parallel</dt>
<dd>Maximum concurrent trigger executions. Default: <code>5</code></dd>
</dl>

<h3>Private Remote Troubleshooting</h3>

<ul>
<li>Use a token with repository read access (for example <code>contents: read</code> equivalent).</li>
<li>For GitHub App tokens, ensure the app is installed on the remote skill repository.</li>
<li>Hosted auth support in this version is scoped to <code>github.com</code> remotes.</li>
<li>SSH URLs are no longer required in hosted checks when token access is configured.</li>
</ul>
</Base>
43 changes: 43 additions & 0 deletions specs/private-remote-auth-rollout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Private Remote Auth Rollout Notes

## What Changed
- Hosted GitHub Action runs now pass `github-token` into remote skill resolution.
- Remote fetches can authenticate private `github.com` remotes using per-command git auth env injection.
- For authenticated GitHub fetches, runtime transport uses HTTPS even if config used SSH remote syntax.

## Known Limitations
- Authenticated private-remote fetch support is scoped to `github.com`.
- GitHub token must have repository read access and, for GitHub Apps, installation access to the remote skill repo.
- CLI token fallback (`WARDEN_GITHUB_TOKEN`) is not part of this MVP.

## Operator Runbook
1. Confirm token source:
- Action: `github-token` input (defaults to `GITHUB_TOKEN`)
- Ensure token is non-empty and has read access to the remote repo.

2. Confirm repository access model:
- For GitHub App tokens: app must be installed on both code repo and remote skill repo.
- For PATs: token owner must have read access to remote skill repo.

3. Confirm remote host:
- MVP auth path supports `github.com` remotes.
- Non-`github.com` remotes follow existing unauthenticated behavior.

4. Failure interpretation:
- `Failed to authenticate when cloning owner/repo` indicates token access/scope/installation issue.
- `Failed to clone ... via HTTPS` in unauthenticated flow indicates missing credentials.

## Verification Summary
Executed:
- `corepack pnpm lint` ✅
- `corepack pnpm build` ✅
- Targeted auth and action tests ✅
- `corepack pnpm test src/skills/remote-auth.test.ts src/skills/remote.test.ts src/action/triggers/executor.test.ts src/action/workflow/schedule.test.ts`
- Secret-safety sweep ✅
- `rg -n "ghp_|github_pat_|Authorization:\s*Bearer|x-access-token" src packages/docs agent-docs -S`

Full suite status:
- `corepack pnpm test` currently fails due pre-existing unrelated tests:
- `src/action/inputs.test.ts` (`setupAuthEnv` OAuth env expectation)
- `src/cli/output/tty.test.ts` (`FORCE_COLOR` expectation)
- `src/action/fix-evaluation/judge.test.ts` (live judge fallback assertions)
17 changes: 16 additions & 1 deletion src/action/triggers/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('../../output/renderer.js', () => ({
import { runSkillTask } from '../../cli/output/tasks.js';
import { createSkillCheck, updateSkillCheck, failSkillCheck } from '../../output/github-checks.js';
import { renderSkillReport } from '../../output/renderer.js';
import { resolveSkillAsync } from '../../skills/loader.js';

describe('executeTrigger', () => {
// Suppress console output during tests
Expand Down Expand Up @@ -75,6 +76,7 @@ describe('executeTrigger', () => {
context: mockContext,
config: mockConfig,
anthropicApiKey: 'test-key',
githubToken: 'gh-token',
claudePath: '/test/claude',
globalMaxFindings: 10,
};
Expand Down Expand Up @@ -102,7 +104,12 @@ describe('executeTrigger', () => {
vi.mocked(updateSkillCheck).mockResolvedValue(undefined);
vi.mocked(renderSkillReport).mockReturnValue(mockRenderResult);

const result = await executeTrigger(mockTrigger, mockDeps);
const triggerWithRemote: ResolvedTrigger = {
...mockTrigger,
remote: 'owner/repo',
};

const result = await executeTrigger(triggerWithRemote, mockDeps);

expect(result.triggerName).toBe('test-trigger');
expect(result.report).toBe(mockReport);
Expand All @@ -122,6 +129,14 @@ describe('executeTrigger', () => {
minConfidence: 'medium',
failCheck: undefined,
});

const taskOptions = vi.mocked(runSkillTask).mock.calls[0]?.[0];
expect(taskOptions).toBeDefined();
await taskOptions?.resolveSkill();
expect(resolveSkillAsync).toHaveBeenCalledWith('test-skill', '/test/path', {
remote: 'owner/repo',
githubToken: 'gh-token',
});
});

it('executes a trigger successfully with no findings', async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/action/triggers/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TriggerExecutorDeps {
context: EventContext;
config: WardenConfig;
anthropicApiKey: string;
githubToken?: string;
claudePath: string;
/** Global fail-on from action inputs (trigger-specific takes precedence) */
globalFailOn?: SeverityThreshold;
Expand Down Expand Up @@ -131,6 +132,7 @@ export async function executeTrigger(
failOn,
resolveSkill: () => resolveSkillAsync(trigger.skill, context.repoPath, {
remote: trigger.remote,
githubToken: deps.githubToken,
}),
context: filterContextByPaths(context, trigger.filters),
runnerOptions: {
Expand Down
5 changes: 3 additions & 2 deletions src/action/workflow/pr-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ async function executeAllTriggers(
context,
config,
anthropicApiKey: inputs.anthropicApiKey,
githubToken: inputs.githubToken,
claudePath,
globalFailOn: inputs.failOn,
globalReportOn: inputs.reportOn,
Expand Down Expand Up @@ -403,7 +404,7 @@ async function evaluateFixesAndResolveStale(
logAction(`Resolved ${resolvedCount} comments via fix evaluation`);
}
// Track only actually resolved comments for allResolved check
resolvedIds.forEach((id) => commentsResolvedByFixEval.add(id));
for (const id of resolvedIds) commentsResolvedByFixEval.add(id);
}

// Post replies for failed fixes and track them so stale pass doesn't override
Expand Down Expand Up @@ -467,7 +468,7 @@ async function evaluateFixesAndResolveStale(
emitStaleResolutionMetric(count, skill);
}
}
resolvedIds.forEach((id) => commentsResolvedByStale.add(id));
for (const id of resolvedIds) commentsResolvedByStale.add(id);
}
} catch (error) {
Sentry.captureException(error, { tags: { operation: 'resolve_stale_comments' } });
Expand Down
4 changes: 4 additions & 0 deletions src/action/workflow/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ describe('runScheduleWorkflow', () => {

await runScheduleWorkflow(mockOctokit, createDefaultInputs(), SCHEDULE_FIXTURES);

expect(mockResolveSkillAsync).toHaveBeenCalledWith('test-skill', SCHEDULE_FIXTURES, {
remote: undefined,
githubToken: 'test-github-token',
});
expect(mockRunSkill).toHaveBeenCalledTimes(1);
expect(mockCreateOrUpdateIssue).toHaveBeenCalledWith(
mockOctokit,
Expand Down
1 change: 1 addition & 0 deletions src/action/workflow/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export async function runScheduleWorkflow(
// Run skill
const skill = await resolveSkillAsync(resolved.skill, repoPath, {
remote: resolved.remote,
githubToken: inputs.githubToken,
});
const claudePath = await findClaudeCodeExecutable();
const report = await runSkill(skill, context, {
Expand Down
15 changes: 15 additions & 0 deletions src/skills/auth-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Optional authentication options for remote skill/agent fetches.
*/
export interface RemoteAuthOptions {
/**
* GitHub token for authenticating private remote skill/agent fetches.
*
* Accepts: PAT (classic/fine-grained), GitHub App token, or GITHUB_TOKEN.
* Must have repository read access to the remote skill repo.
* For GitHub Apps, the app must be installed on the remote repo.
*
* Whitespace-only values are treated as unset (useful for CI env vars).
*/
githubToken?: string;
}
2 changes: 2 additions & 0 deletions src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type {
ResolveSkillOptions,
} from './loader.js';

export type { RemoteAuthOptions } from './auth-options.js';

export {
parseRemoteRef,
formatRemoteRef,
Expand Down
49 changes: 48 additions & 1 deletion src/skills/loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { writeFileSync, unlinkSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
Expand All @@ -18,6 +18,13 @@ import {
AGENT_MARKER_FILE,
} from './loader.js';

vi.mock('./remote.js', () => ({
resolveRemoteSkill: vi.fn(),
resolveRemoteAgent: vi.fn(),
}));

import { resolveRemoteSkill, resolveRemoteAgent } from './remote.js';

describe('loadSkillFromFile', () => {
it('rejects unsupported file types', async () => {
await expect(loadSkillFromFile('/path/to/skill.json')).rejects.toThrow(SkillLoaderError);
Expand All @@ -41,6 +48,46 @@ describe('resolveSkillAsync', () => {
await expect(resolveSkillAsync('nonexistent-skill')).rejects.toThrow(SkillLoaderError);
await expect(resolveSkillAsync('nonexistent-skill')).rejects.toThrow('Skill not found');
});

it('forwards githubToken and offline to remote skill resolution', async () => {
vi.mocked(resolveRemoteSkill).mockResolvedValue({
name: 'remote-skill',
description: 'from remote',
prompt: 'prompt',
});

await resolveSkillAsync('remote-skill', '/tmp/repo', {
remote: 'owner/repo',
offline: true,
githubToken: 'test-token',
});

expect(resolveRemoteSkill).toHaveBeenCalledWith('owner/repo', 'remote-skill', {
offline: true,
githubToken: 'test-token',
});
});
});

describe('resolveAgentAsync', () => {
it('forwards githubToken and offline to remote agent resolution', async () => {
vi.mocked(resolveRemoteAgent).mockResolvedValue({
name: 'remote-agent',
description: 'from remote',
prompt: 'prompt',
});

await resolveAgentAsync('remote-agent', '/tmp/repo', {
remote: 'owner/repo',
offline: true,
githubToken: 'test-token',
});

expect(resolveRemoteAgent).toHaveBeenCalledWith('owner/repo', 'remote-agent', {
offline: true,
githubToken: 'test-token',
});
});
});

describe('skills caching', () => {
Expand Down
11 changes: 6 additions & 5 deletions src/skills/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import type { SkillDefinition, ToolName } from '../config/schema.js';
import { ToolNameSchema } from '../config/schema.js';
import type { RemoteAuthOptions } from './auth-options.js';

export class SkillLoaderError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
Expand Down Expand Up @@ -123,15 +124,15 @@ function parseMarkdownFrontmatter(content: string): { frontmatter: Record<string
if (line.startsWith(' ') && inMetadata) {
// Nested metadata value
const metaMatch = trimmed.match(/^(\w+):\s*(.*)$/);
if (metaMatch && metaMatch[1]) {
if (metaMatch?.[1]) {
metadata[metaMatch[1]] = metaMatch[2]?.replace(/^["']|["']$/g, '') ?? '';
}
continue;
}

inMetadata = false;
const keyMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (keyMatch && keyMatch[1]) {
if (keyMatch?.[1]) {
currentKey = keyMatch[1];
const value = (keyMatch[2] ?? '').trim();

Expand Down Expand Up @@ -380,7 +381,7 @@ export async function discoverAllSkills(
return discoverFromDirectories(repoRoot, SKILL_DIRECTORIES, options);
}

export interface ResolveSkillOptions {
export interface ResolveSkillOptions extends RemoteAuthOptions {
/** Remote repository reference (e.g., "owner/repo" or "owner/repo@sha") */
remote?: string;
/** Skip network operations - only use cache for remote skills */
Expand Down Expand Up @@ -411,14 +412,14 @@ async function resolveEntry(
options: ResolveSkillOptions | undefined,
config: ResolveConfig,
): Promise<SkillDefinition> {
const { remote, offline } = options ?? {};
const { remote, offline, githubToken } = options ?? {};

// 1. Remote repository resolution takes priority when specified
if (remote) {
// Dynamic import to avoid circular dependencies
const { resolveRemoteSkill, resolveRemoteAgent } = await import('./remote.js');
const resolver = config.kind === 'skill' ? resolveRemoteSkill : resolveRemoteAgent;
return resolver(remote, nameOrPath, { offline });
return resolver(remote, nameOrPath, { offline, githubToken });
}

// 2. Direct path resolution
Expand Down
Loading