diff --git a/runners/agent-runner/src/host-evidence.ts b/runners/agent-runner/src/host-evidence.ts index ef84be9..cedbdcf 100644 --- a/runners/agent-runner/src/host-evidence.ts +++ b/runners/agent-runner/src/host-evidence.ts @@ -12,6 +12,8 @@ export const HOST_EVIDENCE_ACTIONS = [ 'github_pr_status', 'github_pr_diff_stat', 'github_run_status', + 'github_run_jobs', + 'github_workflow_file', ] as const; export const DB_EVIDENCE_ACTIONS = [ @@ -29,6 +31,8 @@ export const GITHUB_EVIDENCE_ACTIONS = [ 'github_pr_status', 'github_pr_diff_stat', 'github_run_status', + 'github_run_jobs', + 'github_workflow_file', ] as const; export const ARTIFACT_EVIDENCE_KINDS = [ diff --git a/runners/agent-runner/src/ipc-host-evidence-tool.ts b/runners/agent-runner/src/ipc-host-evidence-tool.ts index 5e5ca5c..107f182 100644 --- a/runners/agent-runner/src/ipc-host-evidence-tool.ts +++ b/runners/agent-runner/src/ipc-host-evidence-tool.ts @@ -114,6 +114,16 @@ export function registerHostEvidenceTools( .describe('Only for GitHub evidence, in owner/repo form.'), pr_number: z.number().int().positive().optional(), run_id: z.number().int().positive().optional(), + workflow_path: z + .string() + .optional() + .describe( + 'Only for github_workflow_file, e.g. .github/workflows/ci.yml.', + ), + ref: z + .string() + .optional() + .describe('Only for github_workflow_file; branch, tag, or commit SHA.'), artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(), }, async (args) => @@ -129,6 +139,8 @@ export function registerHostEvidenceTools( repo: args.repo, pr_number: args.pr_number, run_id: args.run_id, + workflow_path: args.workflow_path, + ref: args.ref, artifact_kind: args.artifact_kind, }), ); @@ -176,6 +188,16 @@ export function registerHostEvidenceTools( repo: z.string().describe('GitHub repository in owner/repo form.'), pr_number: z.number().int().positive().optional(), run_id: z.number().int().positive().optional(), + workflow_path: z + .string() + .optional() + .describe( + 'Only for github_workflow_file, e.g. .github/workflows/ci.yml.', + ), + ref: z + .string() + .optional() + .describe('Only for github_workflow_file; branch, tag, or commit SHA.'), }, async (args) => requestHostEvidence(options, { @@ -183,6 +205,8 @@ export function registerHostEvidenceTools( repo: args.repo, pr_number: args.pr_number, run_id: args.run_id, + workflow_path: args.workflow_path, + ref: args.ref, }), ); } diff --git a/src/github-evidence.test.ts b/src/github-evidence.test.ts index f0ee83f..857b01a 100644 --- a/src/github-evidence.test.ts +++ b/src/github-evidence.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { buildGitHubEvidenceCommand, + decodeGitHubContentBase64, + normalizeGitHubRef, normalizeGitHubRepo, + normalizeGitHubWorkflowPath, normalizePositiveInteger, } from './github-evidence.js'; @@ -18,6 +21,35 @@ describe('GitHub evidence helpers', () => { ); }); + it('validates workflow evidence paths and refs', () => { + expect( + normalizeGitHubWorkflowPath('.github/workflows/display-analyzer.yml'), + ).toBe('.github/workflows/display-analyzer.yml'); + expect(normalizeGitHubWorkflowPath('.github/workflows/ci.yaml')).toBe( + '.github/workflows/ci.yaml', + ); + expect(() => normalizeGitHubWorkflowPath('README.md')).toThrow( + 'Unsupported GitHub workflow path', + ); + expect(() => + normalizeGitHubWorkflowPath('.github/workflows/../ci.yml'), + ).toThrow('Unsupported GitHub workflow path'); + + expect(normalizeGitHubRef('prod')).toBe('prod'); + expect(normalizeGitHubRef('feature/display-analyzer')).toBe( + 'feature/display-analyzer', + ); + expect(normalizeGitHubRef('00b971972e4b2e7ecf0c6d789f405fef7edeb258')).toBe( + '00b971972e4b2e7ecf0c6d789f405fef7edeb258', + ); + expect(() => normalizeGitHubRef('../prod')).toThrow( + 'Missing or invalid ref', + ); + expect(() => normalizeGitHubRef('feature branch')).toThrow( + 'Missing or invalid ref', + ); + }); + it('builds fixed gh commands', () => { expect( buildGitHubEvidenceCommand({ @@ -42,5 +74,42 @@ describe('GitHub evidence helpers', () => { runId: 123, }).args, ).toContain('123'); + + expect( + buildGitHubEvidenceCommand({ + action: 'github_run_jobs', + repo: 'owner/repo', + runId: 123, + }).args, + ).toEqual([ + 'run', + 'view', + '123', + '--repo', + 'owner/repo', + '--json', + 'databaseId,name,status,conclusion,url,headBranch,headSha,jobs', + ]); + + expect( + buildGitHubEvidenceCommand({ + action: 'github_workflow_file', + repo: 'owner/repo', + workflowPath: '.github/workflows/display-analyzer-check.yml', + ref: 'feature/display-analyzer', + }), + ).toMatchObject({ + args: [ + 'api', + 'repos/owner/repo/contents/.github/workflows/display-analyzer-check.yml?ref=feature%2Fdisplay-analyzer', + '--jq', + '.content', + ], + decodeBase64Stdout: true, + }); + }); + + it('decodes GitHub contents API workflow bodies', () => { + expect(decodeGitHubContentBase64('bmFtZTogQ0kK')).toBe('name: CI\n'); }); }); diff --git a/src/github-evidence.ts b/src/github-evidence.ts index 7808610..4918571 100644 --- a/src/github-evidence.ts +++ b/src/github-evidence.ts @@ -4,6 +4,8 @@ export const GITHUB_EVIDENCE_ACTIONS = [ 'github_pr_status', 'github_pr_diff_stat', 'github_run_status', + 'github_run_jobs', + 'github_workflow_file', ] as const; export type GitHubEvidenceAction = (typeof GITHUB_EVIDENCE_ACTIONS)[number]; @@ -13,15 +15,20 @@ export interface GitHubEvidenceRequest { repo?: string; prNumber?: number; runId?: number; + workflowPath?: string; + ref?: string; } interface GitHubCommandSpec { file: string; args: string[]; commandText: string; + decodeBase64Stdout?: boolean; } const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/; +const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/; const COMMAND_TIMEOUT_MS = 10_000; const COMMAND_MAX_BUFFER = 2 * 1024 * 1024; const MAX_OUTPUT_CHARS = 24_000; @@ -53,6 +60,32 @@ export function normalizePositiveInteger( return value; } +export function normalizeGitHubWorkflowPath(value?: string): string { + const workflowPath = value?.trim(); + if (!workflowPath || !WORKFLOW_PATH_PATTERN.test(workflowPath)) { + throw new Error(`Unsupported GitHub workflow path for evidence: ${value}`); + } + return workflowPath; +} + +export function normalizeGitHubRef(value?: string): string { + const ref = value?.trim(); + if ( + !ref || + ref.includes('..') || + ref.includes('//') || + ref.endsWith('/') || + !REF_PATTERN.test(ref) + ) { + throw new Error(`Missing or invalid ref for GitHub evidence`); + } + return ref; +} + +export function decodeGitHubContentBase64(value: string): string { + return Buffer.from(value.replace(/\s+/g, ''), 'base64').toString('utf8'); +} + export function buildGitHubEvidenceCommand( request: GitHubEvidenceRequest, ): GitHubCommandSpec { @@ -102,6 +135,35 @@ export function buildGitHubEvidenceCommand( commandText: `gh ${args.join(' ')}`, }; } + case 'github_run_jobs': { + const runId = normalizePositiveInteger(request.runId, 'run_id'); + const args = [ + 'run', + 'view', + String(runId), + '--repo', + repo, + '--json', + 'databaseId,name,status,conclusion,url,headBranch,headSha,jobs', + ]; + return { + file: 'gh', + args, + commandText: `gh ${args.join(' ')}`, + }; + } + case 'github_workflow_file': { + const workflowPath = normalizeGitHubWorkflowPath(request.workflowPath); + const ref = normalizeGitHubRef(request.ref); + const endpoint = `repos/${repo}/contents/${workflowPath}?ref=${encodeURIComponent(ref)}`; + const args = ['api', endpoint, '--jq', '.content']; + return { + file: 'gh', + args, + commandText: `gh ${args.join(' ')}`, + decodeBase64Stdout: true, + }; + } } } @@ -142,9 +204,12 @@ export function runGitHubEvidenceCommand( ); return; } + const normalizedStdout = command.decodeBase64Stdout + ? decodeGitHubContentBase64(stdout) + : stdout; resolve({ command: command.commandText, - stdout: truncateGitHubEvidenceText(stdout), + stdout: truncateGitHubEvidenceText(normalizedStdout), stderr: truncateGitHubEvidenceText(stderr), }); }, diff --git a/src/host-evidence-ipc.test.ts b/src/host-evidence-ipc.test.ts index 13ea843..8b9a078 100644 --- a/src/host-evidence-ipc.test.ts +++ b/src/host-evidence-ipc.test.ts @@ -95,6 +95,8 @@ describe('host evidence IPC', () => { repo: undefined, prNumber: undefined, runId: undefined, + workflowPath: undefined, + ref: undefined, artifactKind: undefined, sourceGroup: 'other-group', isMain: false, diff --git a/src/host-evidence.test.ts b/src/host-evidence.test.ts index e9b6452..6ac9b71 100644 --- a/src/host-evidence.test.ts +++ b/src/host-evidence.test.ts @@ -13,6 +13,8 @@ describe('host evidence helpers', () => { expect(isHostEvidenceAction('db_paired_task_status')).toBe(true); expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true); expect(isHostEvidenceAction('github_pr_status')).toBe(true); + expect(isHostEvidenceAction('github_run_jobs')).toBe(true); + expect(isHostEvidenceAction('github_workflow_file')).toBe(true); expect(isHostEvidenceAction('rm -rf /')).toBe(false); }); diff --git a/src/host-evidence.ts b/src/host-evidence.ts index adee13a..9b760d4 100644 --- a/src/host-evidence.ts +++ b/src/host-evidence.ts @@ -44,6 +44,8 @@ export interface HostEvidenceRequest { repo?: string; prNumber?: number; runId?: number; + workflowPath?: string; + ref?: string; artifactKind?: string; sourceGroup?: string; isMain?: boolean; @@ -282,6 +284,8 @@ export async function runHostEvidenceRequest( repo: request.repo, prNumber: request.prNumber, runId: request.runId, + workflowPath: request.workflowPath, + ref: request.ref, }); commandText = githubResult.command; return { diff --git a/src/ipc-task-processor/host-evidence.ts b/src/ipc-task-processor/host-evidence.ts index 7e96281..e92b713 100644 --- a/src/ipc-task-processor/host-evidence.ts +++ b/src/ipc-task-processor/host-evidence.ts @@ -54,6 +54,9 @@ export async function handleHostEvidenceRequest( repo: typeof data.repo === 'string' ? data.repo : undefined, prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined, runId: typeof data.run_id === 'number' ? data.run_id : undefined, + workflowPath: + typeof data.workflow_path === 'string' ? data.workflow_path : undefined, + ref: typeof data.ref === 'string' ? data.ref : undefined, artifactKind: typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined, sourceGroup, diff --git a/src/ipc-types.ts b/src/ipc-types.ts index 8dcf576..691e86b 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -153,6 +153,8 @@ export interface TaskIpcPayload { repo?: string; pr_number?: number; run_id?: number; + workflow_path?: string; + ref?: string; artifact_kind?: string; profile?: string; expected_snapshot_id?: string;