diff --git a/.env.example b/.env.example index 479c0fe3b8..f10df5cff4 100644 --- a/.env.example +++ b/.env.example @@ -649,6 +649,10 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Legacy alias for OMNIROUTE_API_KEY. # ROUTER_API_KEY= +# Enable the offline/local Issue Agent recorded-triage endpoint. +# Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled. +# OMNIROUTE_ISSUE_AGENT_ENABLED=false + # CLI remote-mode context/profile for `omniroute` commands (overrides the active # context in the local contexts store). Equivalent to the `--context ` flag. # Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 9b39ccee2e..6527b4b303 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -411,6 +411,7 @@ detection above). | `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. | | `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | | `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | +| `OMNIROUTE_ISSUE_AGENT_ENABLED` | `false` | `src/app/api/issue-agent/runs/route.ts` | Enables the offline/local Issue Agent recorded-triage endpoint. Leave disabled unless explicitly running local recorded-triage workflows. | | `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context `. | | `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | | `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | diff --git a/docs/sessions/20260714-issue-agent-executable-triage/00_SESSION_OVERVIEW.md b/docs/sessions/20260714-issue-agent-executable-triage/00_SESSION_OVERVIEW.md new file mode 100644 index 0000000000..378c4454e9 --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/00_SESSION_OVERVIEW.md @@ -0,0 +1,36 @@ +# Issue-Agent Executable Triage: Session Overview + +Machine status: `in_progress` +Updated at: `2026-07-14` +Issue: `https://github.com/diegosouzapw/OmniRoute/issues/5980` +PR: `https://github.com/diegosouzapw/OmniRoute/pull/7002` + +## Goal + +Deliver GitHub issue #5980 as a production issue-agent workflow. The workflow +must execute recorded GitHub triage through OmniRoute routing, persist a complete +audit trail, return an actionable result, and cover all terminal outcomes. + +## Current State + +| artifact_id | requirement | status | current evidence | next proof | +| ----------- | ---------------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| AC1 | configured provider/model/policy use normal chat routing | `implemented_pending_acceptance` | `623e0d541`, `fa2c1d7c6`; real-route test invokes the issue-agent route and mocks only provider HTTP | prove routing-policy semantics and terminal failure handling | +| AC2 | persist lifecycle, request, output, usage/cost/runtime, terminal error | `not_started` | audit JSONL currently records only pre-execution run context | lifecycle persistence tests | +| AC3 | return actionable triage result | `not_started` | route forwards raw completion body | result contract and integration test | +| AC4 | success, provider failure, timeout, budget stop | `not_started` | only success-route coverage exists | terminal-outcome test matrix | +| release | CI/review evidence | `in_progress` | route-validation and focused tests have prior passing evidence | rerun final gates on PR head | + +## Decisions + +| decision_id | decision | rationale | status | +| ----------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------- | +| DEC-001 | Use the in-process `POST` export from `/api/v1/chat/completions` | preserves existing admission, initialization, guardrails, and provider routing | `implemented` | +| DEC-002 | Keep issue-agent execution opt-in with `OMNIROUTE_ISSUE_AGENT_ENABLED=true` | prevents unrequested autonomous execution | `implemented` | +| DEC-003 | Treat AC1 as incomplete until policy and error semantics are verified end-to-end | request construction alone does not prove the chat route consumes the policy or returns correct terminal state | `active` | + +## Traceability + +The canonical WBS is `03_DAG_WBS.md`; the canonical QA matrix is +`06_TESTING_STRATEGY.md`. Every status change must identify its commit SHA, +exact command, observed result, and PR head. diff --git a/docs/sessions/20260714-issue-agent-executable-triage/01_RESEARCH.md b/docs/sessions/20260714-issue-agent-executable-triage/01_RESEARCH.md new file mode 100644 index 0000000000..d0af543d87 --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/01_RESEARCH.md @@ -0,0 +1,28 @@ +# Issue-Agent Executable Triage: Research + +Machine status: `complete_for_current_phase` + +## In-Repository Findings + +| research_id | source | finding | consequence | +| ----------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| RES-001 | `src/app/api/issue-agent/runs/route.ts` | the endpoint validates body, rejects unsupported mode/disabled execution, builds recorded context, writes audit JSONL, then delegates non-dry runs | execution behavior is centralized at the issue-agent route | +| RES-002 | `src/app/api/v1/chat/completions/route.ts` | standard chat entrypoint exports `POST` and owns the normal chat request path | AC1 must exercise this export rather than a fake internal seam | +| RES-003 | `src/lib/issueAgent/execution.ts` | provider and model are resolved into the chat request; policy is only encoded as `X-OmniRoute-Mode` | an implementation review must establish that this header is a consumed routing-policy contract | +| RES-004 | `src/lib/issueAgent/audit.ts` | audit persistence occurs before execution and writes run context/steps only | AC2 is unsatisfied: no transition, completion, usage/cost/runtime, or terminal-error record exists | +| RES-005 | `tests/unit/issue-agent-route-execution.test.ts` | live route test initializes isolated DB, calls the actual issue-agent `POST`, and mocks only `globalThis.fetch` at provider boundary | strong AC1 path evidence, but it verifies success only and does not prove policy consumption | + +## Validation Evidence + +| evidence_id | command | observed | scope | evidence_sha | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------- | ------------ | +| EVD-001 | `bun test tests/unit/issue-agent-execution.test.ts tests/unit/issue-agent-route-execution.test.ts tests/unit/issue-agent-runs-route.test.ts` | prior focused run reported green | AC1 focused path | `fa2c1d7c6` | +| EVD-002 | `npm run check:route-validation:t06` | prior run reported pass | request route validation | `e6a63eb33` | +| EVD-003 | `npm run typecheck:core` | unresolved `omniglyph` declarations outside issue-agent paths | release gate blocked by pre-existing unrelated errors | pre-existing | + +## Research Conclusions + +The normal chat route is correctly selected as the AC1 integration seam. The +remaining design work must use a persisted run-lifecycle model rather than +extending the pre-execution JSONL row. No external API research was needed: +the implementation uses existing in-repository routes and provider adapters. diff --git a/docs/sessions/20260714-issue-agent-executable-triage/02_SPECIFICATIONS.md b/docs/sessions/20260714-issue-agent-executable-triage/02_SPECIFICATIONS.md new file mode 100644 index 0000000000..636b543a54 --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/02_SPECIFICATIONS.md @@ -0,0 +1,43 @@ +# Issue-Agent Executable Triage: Specifications + +Machine status: `in_progress` + +## Acceptance Contract + +| ac_id | requirement | acceptance evidence | status | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| AC1 | Non-dry recorded triage executes through normal chat routing with selected provider, model, and policy | actual issue-agent route reaches chat `POST`; provider-boundary mock observes selected target; policy is proven consumed by routing | `implemented_pending_acceptance` | +| AC2 | Persist `accepted`, `running`, and terminal state plus sanitized request/prompt, model output, usage, cost, runtime, and terminal error | durable queryable record contains each field for success and failures | `pending` | +| AC3 | API returns a useful, structured triage result derived from model output | response has stable triage schema and is not a raw opaque provider payload | `pending` | +| AC4 | Tests cover success, provider/model failure, timeout, and budget stop | each outcome asserts HTTP response and persisted terminal record | `pending` | + +## API Contract (Target) + +| field | rule | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `mode` | must be `recorded-triage` | +| execution selection | accepts configured `provider`, `model`, `routingPolicy`, and bounded `timeoutMs` | +| `runId` | stable execution identifier returned for every accepted run | +| result | includes structured triage decision/summary/actions and execution metadata | +| errors | return sanitized terminal error with explicit terminal status; never leak provider credentials or unredacted issue content | + +## Persistence Contract (Target) + +| field group | required values | +| -------------- | --------------------------------------------------------------------------------------------------------- | +| identity | run ID, issue URL/repository/number, mode, timestamps | +| lifecycle | `accepted`, `running`, `succeeded`, `failed`, `timed_out`, or `budget_stopped` with transition timestamps | +| input | redacted recorded context and rendered prompt fingerprint/content according to retention policy | +| routing | requested provider/model/policy and resolved execution target | +| output | sanitized model output and structured triage result | +| accounting | input/output/total tokens, cost, and runtime when available | +| terminal error | normalized code/message for failure, timeout, and budget stop | + +## Assumptions, Risks, Uncertainties + +| aru_id | type | statement | mitigation | status | +| ------- | ----------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------ | +| ARU-001 | risk | `X-OmniRoute-Mode` may not be a consumed routing-policy input in the chat route | trace the policy contract and test an observable policy effect | `open` | +| ARU-002 | risk | current catch maps all thrown execution errors to HTTP 400 and does not persist them | introduce typed terminal outcomes and persistence before response mapping | `open` | +| ARU-003 | risk | current audit row is emitted before execution and cannot represent final execution state | replace/extend with append-only lifecycle records or durable run storage | `open` | +| ARU-004 | uncertainty | provider response metadata may differ by adapter | normalize accounting fields and preserve unknowns explicitly | `open` | diff --git a/docs/sessions/20260714-issue-agent-executable-triage/03_DAG_WBS.md b/docs/sessions/20260714-issue-agent-executable-triage/03_DAG_WBS.md new file mode 100644 index 0000000000..284a885c5c --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/03_DAG_WBS.md @@ -0,0 +1,25 @@ +# Issue-Agent Executable Triage: DAG and WBS + +Machine status: `in_progress` + +| id | phase | acceptance criterion | status | source paths | test paths | evidence_sha | depends_on | +| ------- | ----------- | ---------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------- | ------------------------- | +| WBS-001 | contract | AC1-AC4 | complete | `src/app/api/issue-agent/runs/route.ts` | `tests/unit/issue-agent-runs-route.test.ts` | `a4378a26d` | - | +| WBS-002 | execution | AC1: execute through normal chat-completions routing/policy seam | pending | `src/app/api/issue-agent/runs/route.ts`; `src/app/api/v1/chat/completions/route.ts` | `tests/unit/issue-agent-runs-route.test.ts` | `e6a` (reconciled baseline) | WBS-001 | +| WBS-003 | persistence | AC2: persist lifecycle, input, output, usage, and terminal error | pending | `src/lib/issueAgent/*`; `src/app/api/issue-agent/runs/route.ts` | `tests/unit/issue-agent-audit.test.ts`; `tests/unit/issue-agent-runner.test.ts` | `e6a` (reconciled baseline) | WBS-002 | +| WBS-004 | result | AC3: return an actionable triage result from execution | pending | `src/lib/issueAgent/*`; `src/app/api/issue-agent/runs/route.ts` | `tests/unit/issue-agent-runner.test.ts`; `tests/unit/issue-agent-runs-route.test.ts` | `e6a` (reconciled baseline) | WBS-002, WBS-003 | +| WBS-005 | acceptance | AC4: cover success, provider failure, timeout, and budget stop | pending | `src/lib/issueAgent/*` | `tests/unit/issue-agent-*.test.ts` | `e6a` (reconciled baseline) | WBS-002, WBS-003, WBS-004 | +| WBS-006 | release | PR validation and maintainer review | pending | `.github/workflows/*` | CI checks | `a4378a26d` | WBS-005 | + +## Dependency Graph + +`WBS-001 -> WBS-002 -> WBS-003 -> WBS-004 -> WBS-005 -> WBS-006` + +`a4378a26d` is a prerequisite validation repair: it validates the issue-agent request body through the shared route validator and passes `npm run check:route-validation:t06` (535 routes). It does not satisfy AC1-AC4. + +## Machine Evidence Contract + +Every WBS item must maintain: `id`, `acceptance_criterion`, `status`, `source_paths`, `test_paths`, `command`, `expected`, `observed`, `evidence_sha`, `updated_at`, and `pr_url`. + +PR: `https://github.com/diegosouzapw/OmniRoute/pull/7002` +Issue: `https://github.com/diegosouzapw/OmniRoute/issues/5980` diff --git a/docs/sessions/20260714-issue-agent-executable-triage/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260714-issue-agent-executable-triage/04_IMPLEMENTATION_STRATEGY.md new file mode 100644 index 0000000000..1c59174973 --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/04_IMPLEMENTATION_STRATEGY.md @@ -0,0 +1,37 @@ +# Issue-Agent Executable Triage: Implementation Strategy + +Machine status: `in_progress` + +## Phase Plan + +| phase | work package | dependency | exit evidence | status | +| ----- | ----------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | ------------- | +| P1 | verify/finish routing-policy contract and failure semantics | existing AC1 seam | actual chat route test proves policy consumption and non-2xx mapping | `in_progress` | +| P2 | introduce durable execution lifecycle persistence | P1 | records transitions, request/prompt, output, accounting, terminal error | `pending` | +| P3 | normalize actionable triage result | P2 | stable API result schema derived from completion | `pending` | +| P4 | implement terminal outcome controls | P2 | provider failure, timeout, budget stop transition tests | `pending` | +| P5 | release validation and PR review | P1-P4 | focused tests, route gate, relevant typecheck/CI evidence | `pending` | + +## Architecture + +1. Keep `src/app/api/issue-agent/runs/route.ts` as the API adapter: validation, + feature gate, and response formatting only. +2. Keep the standard chat `POST` as the routing boundary; do not add a parallel + provider invocation path. +3. Extract lifecycle persistence and result normalization into focused + `src/lib/issueAgent/` modules. Do not overload the existing pre-execution audit + writer with unrelated transport behavior. +4. Use typed execution outcomes so provider failure, abort/timeout, and budget + termination are distinguishable before HTTP mapping and persistence. +5. Add tests from the actual route down to a mocked external provider boundary; + use unit tests for pure normalization and lifecycle state transitions. + +## Quality Controls + +| control | command or review | threshold | +| ------------------ | ----------------------------------------------------- | ---------------------------------------------------------- | +| route contract | `npm run check:route-validation:t06` | pass | +| AC1 route behavior | focused `bun test` issue-agent route/execution suites | policy and provider/model assertions pass | +| AC2-AC4 | lifecycle/result/terminal-outcome suites | all required states persist and API matches | +| static safety | `npm run typecheck:core` | distinguish new failures from existing `omniglyph` blocker | +| patch integrity | `git diff --check origin/main...HEAD` | pass | diff --git a/docs/sessions/20260714-issue-agent-executable-triage/05_KNOWN_ISSUES.md b/docs/sessions/20260714-issue-agent-executable-triage/05_KNOWN_ISSUES.md new file mode 100644 index 0000000000..6c3f0fbe0e --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/05_KNOWN_ISSUES.md @@ -0,0 +1,21 @@ +# Issue-Agent Executable Triage: Known Issues + +Machine status: `open` + +| issue_id | severity | status | evidence | impact | resolution owner | +| -------- | -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| KI-001 | P1 | `open` | `execution.ts` places `routingPolicy` in `X-OmniRoute-Mode`; the researched chat route has no observed consumer in the AC1 path | AC1 does not yet prove configured routing policy affects routing | AC1 implementation/review | +| KI-002 | P1 | `open` | issue-agent route catches execution errors and returns `{ error }` with HTTP 400 after writing only pre-execution audit | provider failure, timeout, and budget stop lack correct terminal semantics and persistence | AC2/AC4 implementation | +| KI-003 | P1 | `open` | `audit.ts` serializes only run context/steps before execution | AC2 fields for lifecycle, prompt, output, token/cost/runtime, and error are missing | AC2 implementation | +| KI-004 | P1 | `open` | API returns raw `completion.body` | AC3 has no stable actionable triage result contract | AC3 implementation | +| KI-005 | P2 | `open` | `npm run typecheck:core` has unresolved `omniglyph` declarations in `open-sse/services/compression/*` | full typecheck cannot be used as issue-agent completion evidence until separately resolved or excluded with provenance | release validation | + +## Resolved/Verified + +| issue_id | status | evidence | +| -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | +| KI-R001 | `verified` | `fa2c1d7c6` adds an isolated test that invokes the actual issue-agent route and mocks only provider HTTP for the success path | +| KI-R002 | `verified` | `e6a63eb33` applies shared request-body validation to the issue-agent route; prior route-validation gate passed | + +No workaround in this document changes the acceptance contract. Open P1 items +block declaring AC1-AC4 complete. diff --git a/docs/sessions/20260714-issue-agent-executable-triage/06_TESTING_STRATEGY.md b/docs/sessions/20260714-issue-agent-executable-triage/06_TESTING_STRATEGY.md new file mode 100644 index 0000000000..006908ef71 --- /dev/null +++ b/docs/sessions/20260714-issue-agent-executable-triage/06_TESTING_STRATEGY.md @@ -0,0 +1,25 @@ +# Issue-Agent Executable Triage: Testing Strategy + +Machine status: `in_progress` + +## QA Matrix + +| qa_id | AC | scenario | command | expected | observed | status | evidence_sha | +| ------ | ------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------- | --------------------------------------- | ------------- | ------------ | +| QA-001 | prerequisite | request schema validation | `npm run check:route-validation:t06` | all routes pass | 535 routes scanned; pass | pass | `a4378a26d` | +| QA-002 | AC1 | selected provider/model/policy reaches normal chat-completions seam | `bun test tests/unit/issue-agent-runs-route.test.ts` | captured request uses configured routing inputs | not implemented | pending | `e6a` | +| QA-003 | AC2 | run lifecycle persists input, output, usage, terminal error | `bun test tests/unit/issue-agent-audit.test.ts tests/unit/issue-agent-runner.test.ts` | durable records for every terminal state | not implemented | pending | `e6a` | +| QA-004 | AC3 | successful execution returns actionable triage output | `bun test tests/unit/issue-agent-runner.test.ts tests/unit/issue-agent-runs-route.test.ts` | output derives from routed execution, not placeholder | not implemented | pending | `e6a` | +| QA-005 | AC4 | provider/model failure | `bun test tests/unit/issue-agent-runner.test.ts` | failed lifecycle and sanitized error persisted | missing coverage | pending | `e6a` | +| QA-006 | AC4 | timeout | `bun test tests/unit/issue-agent-runner.test.ts` | timed-out lifecycle and terminal error persisted | missing coverage | pending | `e6a` | +| QA-007 | AC4 | budget stop | `bun test tests/unit/issue-agent-runner.test.ts` | budget stop is explicit and persisted | missing coverage | pending | `e6a` | +| QA-008 | release | core type safety | `npm run typecheck:core` | pass | pending rerun after dependency recovery | pending | `e6a` | +| QA-009 | release | whitespace integrity | `git diff --check origin/main...HEAD` | no errors | passed before remote rewrite | pass/reverify | `a4378a26d` | + +## Test Rules + +Tests must mock only the external provider boundary. AC1 must exercise the in-process `POST` export from `src/app/api/v1/chat/completions/route.ts` so admission, policy, translator initialization, and routing remain in the execution path. Each terminal outcome asserts both API behavior and persisted audit state. + +## Evidence Requirements + +Before a WBS item is marked complete, record the exact command output, commit SHA, test identifiers, and whether the test environment had a lockfile-compatible dependency set. The current recovered environment has incomplete dependencies due to `npm ci` disk exhaustion; no pending test may be reported as passing until rerun. diff --git a/src/app/api/issue-agent/runs/route.ts b/src/app/api/issue-agent/runs/route.ts new file mode 100644 index 0000000000..dd5b8c33b5 --- /dev/null +++ b/src/app/api/issue-agent/runs/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { appendIssueAgentAuditRecord } from "@/lib/issueAgent/audit"; +import { + executeRecordedTriageChatCompletion, + RecordedTriageTimeoutError, +} from "@/lib/issueAgent/execution"; +import { normalizeGitHubIssueExport } from "@/lib/issueAgent/githubExport"; +import { createRecordedTriageRun } from "@/lib/issueAgent/recordedTriage"; +import { POST as postChatCompletion } from "@/app/api/v1/chat/completions/route"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const issueAgentRunRequestSchema = z.object({ + mode: z.string().optional(), + issueUrl: z.string().optional(), + dryRun: z.boolean().optional(), + model: z.string().min(1).max(256).optional(), + provider: z.string().min(1).max(128).optional(), + routingPolicy: z.string().min(1).max(128).optional(), + timeoutMs: z.number().int().min(1).max(120_000).optional(), + recordedContext: z.unknown().optional(), + githubExport: z.unknown().optional(), +}); + +const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); + +function isIssueAgentEnabled(): boolean { + return ENABLED_VALUES.has((process.env.OMNIROUTE_ISSUE_AGENT_ENABLED ?? "").toLowerCase()); +} + +/** + * Node's own fs/system errors (ENOENT, EACCES, EEXIST, ...) always set `.code` + * to an uppercase errno identifier and embed the absolute path in `.message` + * (e.g. `appendIssueAgentAuditRecord`'s mkdir/appendFile on DATA_DIR). Hand-thrown + * validation errors in this module (createRecordedTriageRun, + * normalizeGitHubIssueExport) are plain `new Error("...")` with a curated, + * path-free message and never set `.code` — so this check safely distinguishes + * "safe to show the client" validation failures from opaque internal/system + * errors that could otherwise leak the server's filesystem layout. + */ +function isNodeSystemError(error: unknown): error is NodeJS.ErrnoException { + return ( + error instanceof Error && + typeof (error as NodeJS.ErrnoException).code === "string" && + /^[A-Z]/.test((error as NodeJS.ErrnoException).code as string) + ); +} + +export async function GET() { + return NextResponse.json({ + ok: true, + enabled: isIssueAgentEnabled(), + supportedModes: ["recorded-triage"], + execution: "disabled-by-default", + }); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(issueAgentRunRequestSchema, body); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const parsed = validation.data; + if (parsed.mode !== "recorded-triage") { + return NextResponse.json( + { error: "Unsupported issue-agent mode", supportedModes: ["recorded-triage"] }, + { status: 400 } + ); + } + + if (!isIssueAgentEnabled()) { + return NextResponse.json( + { + error: "Issue Agent execution is disabled", + enabled: false, + requiredEnv: "OMNIROUTE_ISSUE_AGENT_ENABLED=true", + }, + { status: 403 } + ); + } + + try { + const normalized = parsed.githubExport ? normalizeGitHubIssueExport(parsed.githubExport) : null; + const run = createRecordedTriageRun({ + ...parsed, + issueUrl: parsed.issueUrl ?? normalized?.issueUrl, + recordedContext: parsed.recordedContext ?? normalized?.recordedContext, + }); + const audit = await appendIssueAgentAuditRecord(run); + if (run.dryRun) { + return NextResponse.json({ ...run, auditPath: audit.path }); + } + + const completion = await executeRecordedTriageChatCompletion( + { + run, + model: parsed.model, + provider: parsed.provider, + routingPolicy: parsed.routingPolicy, + timeoutMs: parsed.timeoutMs, + }, + postChatCompletion + ); + return NextResponse.json( + { + ...run, + runner: "omniroute-chat-completions", + auditPath: audit.path, + completion: completion.body, + }, + { status: completion.status } + ); + } catch (error) { + if (error instanceof RecordedTriageTimeoutError) { + return NextResponse.json( + { error: sanitizeErrorMessage(error.message), code: "ISSUE_AGENT_TIMEOUT" }, + { status: 504 } + ); + } + // Hard Rule #12: never put a raw err.message in a response. Validation + // failures thrown by this module (bad issue URL, malformed GitHub export) + // are safe, curated messages meant for the client — sanitizeErrorMessage() + // is a no-op for them. An opaque Node system/fs error (e.g. audit.ts + // failing to mkdir/appendFile under DATA_DIR) is replaced with a generic + // message instead, since its raw text would otherwise embed the server's + // absolute filesystem path. + const message = isNodeSystemError(error) + ? "Issue Agent request failed due to an internal error" + : sanitizeErrorMessage(error instanceof Error ? error.message : "Invalid issue-agent request"); + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/lib/issueAgent/audit.ts b/src/lib/issueAgent/audit.ts new file mode 100644 index 0000000000..9abbff516e --- /dev/null +++ b/src/lib/issueAgent/audit.ts @@ -0,0 +1,43 @@ +import { mkdir, appendFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +import type { RecordedTriageRun } from "./recordedTriage"; + +export interface IssueAgentAuditOptions { + dataDir?: string; + now?: Date; +} + +export interface IssueAgentAuditResult { + path: string; +} + +function defaultDataDir(): string { + return process.env.DATA_DIR || join(homedir(), ".omniroute"); +} + +export async function appendIssueAgentAuditRecord( + run: RecordedTriageRun, + options: IssueAgentAuditOptions = {} +): Promise { + const dataDir = options.dataDir || defaultDataDir(); + const auditDir = join(dataDir, "issue-agent"); + const auditPath = join(auditDir, "audit.jsonl"); + const row = { + ts: (options.now ?? new Date()).toISOString(), + runId: run.runId, + mode: run.mode, + repository: run.repository, + issueNumber: run.issueNumber, + issueUrl: run.issueUrl, + dryRun: run.dryRun, + runner: run.runner, + context: run.context, + steps: run.steps, + }; + + await mkdir(auditDir, { recursive: true }); + await appendFile(auditPath, `${JSON.stringify(row)}\n`, "utf8"); + return { path: auditPath }; +} diff --git a/src/lib/issueAgent/execution.ts b/src/lib/issueAgent/execution.ts new file mode 100644 index 0000000000..85243de010 --- /dev/null +++ b/src/lib/issueAgent/execution.ts @@ -0,0 +1,114 @@ +import type { RecordedTriageRun } from "@/lib/issueAgent/recordedTriage"; + +export interface RecordedTriageExecutionInput { + run: RecordedTriageRun; + model?: string; + provider?: string; + routingPolicy?: string; + timeoutMs?: number; +} + +export type ChatCompletionsPost = (request: Request) => Promise; + +export interface RecordedTriageChatCompletion { + status: number; + body: unknown; +} + +export class RecordedTriageTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Issue Agent triage timed out after ${timeoutMs}ms`); + this.name = "RecordedTriageTimeoutError"; + } +} + +const DEFAULT_MODEL = "auto/quality"; +const DEFAULT_TIMEOUT_MS = 60_000; +const MAX_TIMEOUT_MS = 120_000; + +function configuredString(value: string | undefined, envName: string): string | undefined { + const configured = value ?? process.env[envName]; + const normalized = configured?.trim(); + return normalized || undefined; +} + +function resolveModel(input: RecordedTriageExecutionInput): string { + const model = configuredString(input.model, "OMNIROUTE_ISSUE_AGENT_MODEL") ?? DEFAULT_MODEL; + const provider = configuredString(input.provider, "OMNIROUTE_ISSUE_AGENT_PROVIDER"); + + if (!provider || model.includes("/")) return model; + return `${provider}/${model}`; +} + +function resolveTimeoutMs(input: RecordedTriageExecutionInput): number { + const configured = input.timeoutMs ?? Number(process.env.OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS); + if (!Number.isFinite(configured) || configured <= 0) return DEFAULT_TIMEOUT_MS; + return Math.min(Math.floor(configured), MAX_TIMEOUT_MS); +} + +function buildMessages(run: RecordedTriageRun) { + return [ + { + role: "system", + content: + "You are the OmniRoute Issue Agent. Analyze only the recorded GitHub context and produce a concise, actionable triage response. Do not claim to have accessed external state.", + }, + { + role: "user", + content: [ + `Issue: ${run.repository}#${run.issueNumber}`, + `URL: ${run.issueUrl}`, + `Intent: ${run.context.intent}`, + `Title: ${run.context.issueTitle ?? "(untitled)"}`, + "Recorded context:", + run.context.redactedDigestSource || "(none)", + ].join("\n"), + }, + ]; +} + +/** + * Route recorded-triage work through the same in-process chat endpoint used by + * clients, retaining its initialization, admission, guardrails, and policy path. + */ +export async function executeRecordedTriageChatCompletion( + input: RecordedTriageExecutionInput, + post: ChatCompletionsPost +): Promise { + const controller = new AbortController(); + const timeoutMs = resolveTimeoutMs(input); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const routingPolicy = configuredString( + input.routingPolicy, + "OMNIROUTE_ISSUE_AGENT_ROUTING_POLICY" + ); + + try { + const response = await post( + new Request("http://localhost/api/v1/chat/completions", { + method: "POST", + signal: controller.signal, + headers: { + "Content-Type": "application/json", + ...(routingPolicy ? { "X-OmniRoute-Mode": routingPolicy } : {}), + }, + body: JSON.stringify({ + model: resolveModel(input), + messages: buildMessages(input.run), + max_tokens: 1200, + temperature: 0, + stream: false, + }), + }) + ); + + return { status: response.status, body: await response.json() }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new RecordedTriageTimeoutError(timeoutMs); + } + throw error; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/issueAgent/githubExport.ts b/src/lib/issueAgent/githubExport.ts new file mode 100644 index 0000000000..e478ad7f4a --- /dev/null +++ b/src/lib/issueAgent/githubExport.ts @@ -0,0 +1,57 @@ +import type { RecordedTriageComment, RecordedTriageContextInput } from "./recordedTriage"; + +export interface NormalizedGitHubIssueExport { + issueUrl: string; + recordedContext: RecordedTriageContextInput; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function isBotUser(user: Record, fallbackAuthor: string | undefined): boolean { + const type = readString(user.type); + if (type?.toLowerCase() === "bot") return true; + return /\[bot\]$/i.test(fallbackAuthor ?? ""); +} + +function normalizeComment(value: unknown): RecordedTriageComment { + const row = asRecord(value); + const user = asRecord(row.user); + const author = readString(row.author) ?? readString(row.user_login) ?? readString(user.login); + const body = readString(row.body) ?? ""; + const explicitBot = typeof row.isBot === "boolean" ? row.isBot : undefined; + return { + author, + body, + isBot: explicitBot ?? isBotUser(user, author), + }; +} + +export function normalizeGitHubIssueExport(value: unknown): NormalizedGitHubIssueExport { + const root = asRecord(value); + const issue = asRecord(root.issue ?? root.pull_request ?? root.pr ?? root); + const issueUrl = readString(issue.html_url) ?? readString(issue.url) ?? readString(root.html_url) ?? readString(root.url); + if (!issueUrl) { + throw new Error("Recorded GitHub export must include html_url or url"); + } + + const rawComments = Array.isArray(root.comments) + ? root.comments + : Array.isArray(issue.comments) + ? issue.comments + : []; + + return { + issueUrl, + recordedContext: { + title: readString(issue.title), + body: readString(issue.body), + comments: rawComments.slice(0, 100).map(normalizeComment), + }, + }; +} diff --git a/src/lib/issueAgent/recordedTriage.ts b/src/lib/issueAgent/recordedTriage.ts new file mode 100644 index 0000000000..661ab58c48 --- /dev/null +++ b/src/lib/issueAgent/recordedTriage.ts @@ -0,0 +1,124 @@ +import { createHash } from "node:crypto"; +import { redactSecrets } from "@/shared/utils/logRedaction"; + +export interface RecordedTriageComment { + author?: string; + body?: string; + isBot?: boolean; +} + +export interface RecordedTriageContextInput { + title?: string; + body?: string; + comments?: RecordedTriageComment[]; +} + +export interface RecordedTriageInput { + issueUrl?: string; + dryRun?: boolean; + recordedContext?: RecordedTriageContextInput; +} + +export interface RecordedTriageContextSummary { + issueTitle: string | null; + commentCount: number; + humanCommentCount: number; + botCommentCount: number; + intent: "bugfix" | "review" | "question" | "unknown"; + redactedDigestSource: string; +} + +export interface RecordedTriageRun { + accepted: true; + mode: "recorded-triage"; + runner: "deterministic-recorded-triage" | "omniroute-chat-completions"; + runId: string; + issueUrl: string; + repository: string; + issueNumber: number; + dryRun: boolean; + context: RecordedTriageContextSummary; + steps: string[]; +} + +const GITHUB_ISSUE_URL = + /^https:\/\/(?:[^@/\s]+@)?github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/i; + +const RECORDED_TRIAGE_STEPS = [ + "load-recorded-github-context", + "classify-mention-intent", + "draft-safe-response-plan", + "emit-audit-record", +]; + +function redactUrlCredentials(issueUrl: string): string { + return issueUrl.replace(/^https:\/\/[^@/\s]+@github\.com\//i, "https://[REDACTED]@github.com/"); +} + +function buildRunId(repository: string, issueNumber: number): string { + const digest = createHash("sha256") + .update(`${repository}#${issueNumber}`) + .digest("hex") + .slice(0, 16); + return `issue-agent-recorded-triage-${digest}`; +} + +function normalizeText(value: unknown, maxLength = 400): string { + if (typeof value !== "string") return ""; + return value.trim().replace(/\s+/g, " ").slice(0, maxLength); +} + +function classifyIntent(text: string): RecordedTriageContextSummary["intent"] { + const lower = text.toLowerCase(); + if (/\b(bug|fix|failing|regression|broken|patch)\b/.test(lower)) return "bugfix"; + if (/\b(review|pr|pull request|approve|merge)\b/.test(lower)) return "review"; + if (/\b(question|why|how|what|help)\b/.test(lower)) return "question"; + return "unknown"; +} + +function summarizeContext( + input: RecordedTriageContextInput | undefined +): RecordedTriageContextSummary { + const comments = Array.isArray(input?.comments) ? input.comments.slice(0, 50) : []; + const title = normalizeText(input?.title, 160); + const body = normalizeText(input?.body); + const commentTexts = comments.map((comment) => normalizeText(comment.body, 200)).filter(Boolean); + const digestSource = redactSecrets([title, body, ...commentTexts].filter(Boolean).join("\n")); + const botCommentCount = comments.filter((comment) => comment.isBot === true).length; + const humanCommentCount = comments.length - botCommentCount; + + return { + issueTitle: title || null, + commentCount: comments.length, + humanCommentCount, + botCommentCount, + intent: classifyIntent(digestSource), + redactedDigestSource: digestSource.slice(0, 1200), + }; +} + +export function createRecordedTriageRun(input: RecordedTriageInput): RecordedTriageRun { + const issueUrl = typeof input.issueUrl === "string" ? input.issueUrl.trim() : ""; + const match = GITHUB_ISSUE_URL.exec(issueUrl); + if (!match) { + throw new Error("Expected a GitHub issue or pull request URL"); + } + + const owner = match[1]!; + const repo = match[2]!; + const issueNumber = Number(match[3]); + const repository = `${owner}/${repo}`; + + return { + accepted: true, + mode: "recorded-triage", + runner: "deterministic-recorded-triage", + runId: buildRunId(repository, issueNumber), + issueUrl: redactUrlCredentials(issueUrl), + repository, + issueNumber, + dryRun: input.dryRun !== false, + context: summarizeContext(input.recordedContext), + steps: [...RECORDED_TRIAGE_STEPS], + }; +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index ff2ec2c7e4..dbb4dadf00 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -37,6 +37,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass "/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17) "/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17) + "/api/issue-agent/", // Issue Agent: recorded/local triage executor surface; keep loopback/LAN until sandbox + audit hardening is complete "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/plugins", // bare path: GET list + POST install also trigger plugin loading "/api/middleware/", // SECURITY_AUDIT M8: middleware hooks compile+run arbitrary JS via new vm.Script (src/lib/middleware/registry.ts) on the request hot path — same code-exec class as /api/plugins/, so loopback-gate it for parity (Hard Rules #15 + #17) diff --git a/tests/unit/issue-agent-audit.test.ts b/tests/unit/issue-agent-audit.test.ts new file mode 100644 index 0000000000..f7c1c917db --- /dev/null +++ b/tests/unit/issue-agent-audit.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { appendIssueAgentAuditRecord } from "../../src/lib/issueAgent/audit.ts"; +import { createRecordedTriageRun } from "../../src/lib/issueAgent/recordedTriage.ts"; + +test("appendIssueAgentAuditRecord writes redacted JSONL under explicit data dir", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "issue-agent-audit-")); + const run = createRecordedTriageRun({ + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/42", + recordedContext: { + title: "Need fix", + body: "Authorization: Bearer sk-secret1234567890abcd", + }, + }); + + const result = await appendIssueAgentAuditRecord(run, { dataDir }); + const payload = readFileSync(result.path, "utf8"); + const row = JSON.parse(payload.trim()) as Record; + + assert.equal(result.path, join(dataDir, "issue-agent", "audit.jsonl")); + assert.equal(row.runId, run.runId); + assert.equal(row.repository, "KooshaPari/OmniRoute"); + assert.equal(row.issueNumber, 42); + assert.equal(row.dryRun, true); + assert.doesNotMatch(payload, /sk-secret/); + assert.match(payload, /\[REDACTED\]/); +}); diff --git a/tests/unit/issue-agent-execution.test.ts b/tests/unit/issue-agent-execution.test.ts new file mode 100644 index 0000000000..41352cde02 --- /dev/null +++ b/tests/unit/issue-agent-execution.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + executeRecordedTriageChatCompletion, + RecordedTriageTimeoutError, +} from "../../src/lib/issueAgent/execution.ts"; +import { createRecordedTriageRun } from "../../src/lib/issueAgent/recordedTriage.ts"; + +test("recorded triage invokes the normal chat-completions seam with configured routing", async () => { + const run = createRecordedTriageRun({ + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/5980", + recordedContext: { + title: "Execute issue-agent runs through OmniRoute routing", + body: "Use the configured provider and model.", + }, + }); + let received: Request | undefined; + + const response = await executeRecordedTriageChatCompletion( + { + run, + model: "gpt-4.1-mini", + provider: "openai", + routingPolicy: "quality", + timeoutMs: 5_000, + }, + async (request) => { + received = request; + return new Response(JSON.stringify({ id: "chatcmpl-issue-agent" }), { + headers: { "Content-Type": "application/json" }, + }); + } + ); + + assert.equal(received?.url, "http://localhost/api/v1/chat/completions"); + assert.equal(received?.method, "POST"); + assert.equal(received?.headers.get("X-OmniRoute-Mode"), "quality"); + assert.ok(received?.signal, "the chat request must carry the timeout AbortSignal"); + + const body = (await received!.json()) as Record; + assert.equal(body.model, "openai/gpt-4.1-mini"); + assert.equal(body.stream, false); + assert.equal(body.temperature, 0); + assert.equal(body.max_tokens, 1200); + assert.equal(response.status, 200); + assert.deepEqual(response.body, { id: "chatcmpl-issue-agent" }); + assert.match(JSON.stringify(body.messages), /#5980/); +}); + +test("recorded triage reports an aborted chat invocation as a timeout", async () => { + const run = createRecordedTriageRun({ + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/5980", + }); + + await assert.rejects( + () => + executeRecordedTriageChatCompletion({ run, timeoutMs: 1 }, async (request) => { + await new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => { + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + }); + return new Response(); + }), + (error: unknown) => + error instanceof RecordedTriageTimeoutError && + error.message === "Issue Agent triage timed out after 1ms" + ); +}); diff --git a/tests/unit/issue-agent-github-export.test.ts b/tests/unit/issue-agent-github-export.test.ts new file mode 100644 index 0000000000..f7c798dd3a --- /dev/null +++ b/tests/unit/issue-agent-github-export.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeGitHubIssueExport } from "../../src/lib/issueAgent/githubExport.ts"; + +test("normalizeGitHubIssueExport reads GitHub REST issue plus comments shape", () => { + const normalized = normalizeGitHubIssueExport({ + issue: { + title: "Fix route guard bypass", + body: "failing when Host is spoofed", + html_url: "https://github.com/KooshaPari/OmniRoute/issues/6059", + }, + comments: [ + { user: { login: "renovate[bot]", type: "Bot" }, body: "automated" }, + { user: { login: "maintainer", type: "User" }, body: "please patch" }, + ], + }); + + assert.equal(normalized.issueUrl, "https://github.com/KooshaPari/OmniRoute/issues/6059"); + assert.deepEqual(normalized.recordedContext, { + title: "Fix route guard bypass", + body: "failing when Host is spoofed", + comments: [ + { author: "renovate[bot]", body: "automated", isBot: true }, + { author: "maintainer", body: "please patch", isBot: false }, + ], + }); +}); + +test("normalizeGitHubIssueExport reads flat issue export shape", () => { + const normalized = normalizeGitHubIssueExport({ + title: "Question about native router", + body: "how should cooldowns work?", + url: "https://github.com/KooshaPari/OmniRoute/pull/6085", + comments: [{ author: "human", body: "review please", isBot: false }], + }); + + assert.equal(normalized.issueUrl, "https://github.com/KooshaPari/OmniRoute/pull/6085"); + assert.equal(normalized.recordedContext.title, "Question about native router"); + assert.equal(normalized.recordedContext.comments?.[0]?.author, "human"); +}); + +test("normalizeGitHubIssueExport rejects exports without a GitHub URL", () => { + assert.throws( + () => normalizeGitHubIssueExport({ title: "missing url" }), + /Recorded GitHub export must include html_url or url/ + ); +}); diff --git a/tests/unit/issue-agent-route-execution.test.ts b/tests/unit/issue-agent-route-execution.test.ts new file mode 100644 index 0000000000..9f0c7e0b86 --- /dev/null +++ b/tests/unit/issue-agent-route-execution.test.ts @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = + process.env.DATA_DIR ?? + fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-issue-agent-execution-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.APP_LOG_TO_FILE = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const issueAgentRoute = await import("../../src/app/api/issue-agent/runs/route.ts"); +const originalFetch = globalThis.fetch; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedOpenAiConnection() { + return providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "issue-agent-route-test", + apiKey: "sk-issue-agent-route-test", + isActive: true, + testStatus: "active", + }); +} + +test.beforeEach(async () => { + globalThis.fetch = originalFetch; + process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = "true"; + await resetStorage(); + await core.ensureDbInitialized(); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; +}); + +test("issue-agent live triage traverses the normal chat-completions POST route", async () => { + await seedOpenAiConnection(); + const fetchCalls: Array<{ url: string; init: RequestInit }> = []; + globalThis.fetch = async (url, init = {}) => { + fetchCalls.push({ url: String(url), init }); + return Response.json({ + id: "chatcmpl-issue-agent-route", + choices: [{ message: { role: "assistant", content: "Triage response" } }], + }); + }; + + const response = await issueAgentRoute.POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + dryRun: false, + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/5980", + recordedContext: { + title: "Execute issue-agent triage through the router", + body: "Use the configured provider and routing policy.", + }, + provider: "openai", + model: "gpt-4.1", + routingPolicy: "quality", + }), + }) + ); + const body = (await response.json()) as Record; + + assert.equal(response.status, 200); + assert.equal(body.runner, "omniroute-chat-completions"); + assert.equal(fetchCalls.length, 1, "only the external provider boundary is mocked"); + assert.match(fetchCalls[0]!.url, /\/chat\/completions$/); + + const providerRequest = JSON.parse(String(fetchCalls[0]!.init.body)) as Record; + assert.equal(providerRequest.model, "gpt-4.1"); + assert.equal(providerRequest.stream, false); + assert.match(JSON.stringify(providerRequest.messages), /#5980/); + assert.equal((body.completion as Record).id, "chatcmpl-issue-agent-route"); +}); + +test("issue-agent preserves the normal chat route provider failure response", async () => { + await seedOpenAiConnection(); + globalThis.fetch = async () => + Response.json( + { error: { message: "provider rate limited", type: "rate_limit_error" } }, + { status: 429 } + ); + + const response = await issueAgentRoute.POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + // #7315: dryRun must be explicit false — createRecordedTriageRun() + // (src/lib/issueAgent/recordedTriage.ts) computes `dryRun: input.dryRun + // !== false`, so an OMITTED dryRun defaults to true (dry-run mode) and + // the route returns the deterministic dry-run summary WITHOUT ever + // calling executeRecordedTriageChatCompletion() — meaning the mocked + // 429 fetch below is never invoked and this test always observed the + // 200 dry-run response instead. Not a semantic-cache collision: the + // sibling test above passes dryRun:false explicitly, this one didn't. + dryRun: false, + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/5980", + recordedContext: { body: "Preserve upstream errors for triage." }, + provider: "openai", + model: "gpt-4.1", + }), + }) + ); + const body = (await response.json()) as Record; + + // The normal chat-completions route enriches the raw upstream error (adds a + // "[provider/model] [status]:" prefix and a connection-cooldown hint) rather + // than passing the mocked JSON through byte-for-byte — that enrichment is the + // normal route's own, deliberate behavior (see RESILIENCE_GUIDE.md connection + // cooldown). This test's job is to prove the issue-agent execution wrapper + // preserves and surfaces THAT real response (status + original message text) + // rather than swallowing it or substituting its own error — so assert on the + // stable, observable parts instead of the exact enrichment wording/timing. + assert.equal(response.status, 429); + const completion = body.completion as { error?: { message?: string } }; + assert.match( + completion.error?.message ?? "", + /provider rate limited/, + "the original upstream error message must survive through the issue-agent execution wrapper" + ); +}); diff --git a/tests/unit/issue-agent-runner.test.ts b/tests/unit/issue-agent-runner.test.ts new file mode 100644 index 0000000000..45ad589936 --- /dev/null +++ b/tests/unit/issue-agent-runner.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createRecordedTriageRun } from "../../src/lib/issueAgent/recordedTriage.ts"; + +test("createRecordedTriageRun returns deterministic dry-run metadata", () => { + const run = createRecordedTriageRun({ + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/6059", + dryRun: true, + }); + + assert.equal(run.mode, "recorded-triage"); + assert.equal(run.accepted, true); + assert.equal(run.dryRun, true); + assert.equal(run.repository, "KooshaPari/OmniRoute"); + assert.equal(run.issueNumber, 6059); + assert.equal(run.runner, "deterministic-recorded-triage"); + assert.match(run.runId, /^issue-agent-recorded-triage-[a-f0-9]{16}$/); + assert.deepEqual(run.steps, [ + "load-recorded-github-context", + "classify-mention-intent", + "draft-safe-response-plan", + "emit-audit-record", + ]); +}); + +test("createRecordedTriageRun redacts URL credentials before returning metadata", () => { + const run = createRecordedTriageRun({ + issueUrl: "https://user:token12345678901234567890@github.com/KooshaPari/OmniRoute/issues/1", + }); + + assert.equal(run.issueUrl, "https://[REDACTED]@github.com/KooshaPari/OmniRoute/issues/1"); + assert.doesNotMatch(run.issueUrl, /token123/); +}); + +test("createRecordedTriageRun rejects non-GitHub issue URLs", () => { + assert.throws( + () => createRecordedTriageRun({ issueUrl: "https://example.com/not/github" }), + /Expected a GitHub issue or pull request URL/ + ); +}); + +test("createRecordedTriageRun summarizes recorded context without leaking secrets", () => { + const run = createRecordedTriageRun({ + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/42", + recordedContext: { + title: "Need fix for failing route guard", + body: "Please inspect Authorization: Bearer sk-secret1234567890abcd", + comments: [ + { author: "octobot", body: "automated noise", isBot: true }, + { author: "human", body: "mentions @KooshaPari and asks for patch", isBot: false }, + ], + }, + }); + + assert.equal(run.context.issueTitle, "Need fix for failing route guard"); + assert.equal(run.context.commentCount, 2); + assert.equal(run.context.humanCommentCount, 1); + assert.equal(run.context.botCommentCount, 1); + assert.equal(run.context.intent, "bugfix"); + assert.doesNotMatch(run.context.redactedDigestSource, /sk-secret/); + assert.match(run.context.redactedDigestSource, /\[REDACTED\]/); +}); diff --git a/tests/unit/issue-agent-runs-route.test.ts b/tests/unit/issue-agent-runs-route.test.ts new file mode 100644 index 0000000000..da22b52b16 --- /dev/null +++ b/tests/unit/issue-agent-runs-route.test.ts @@ -0,0 +1,285 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Bun evaluates test files in one process. Set DATA_DIR before importing the +// route so its import-time dependencies never probe the developer's real DB. +const TEST_DATA_DIR = + process.env.DATA_DIR ?? mkdtempSync(join(tmpdir(), "issue-agent-runs-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.APP_LOG_TO_FILE = "false"; + +const { GET, POST } = await import("../../src/app/api/issue-agent/runs/route.ts"); + +async function json(response: Response): Promise> { + return (await response.json()) as Record; +} + +test("issue-agent status reports default-off recorded triage support", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + try { + const response = await GET(); + const body = await json(response); + + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.enabled, false); + assert.deepEqual(body.supportedModes, ["recorded-triage"]); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + } +}); + +test("issue-agent run rejects invalid JSON", async () => { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: "{", + }) + ); + const body = await json(response); + + assert.equal(response.status, 400); + assert.equal(body.error, "Invalid JSON body"); +}); + +test("issue-agent run rejects invalid request field types", async () => { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ mode: 42 }), + }) + ); + + assert.equal(response.status, 400); +}); + +test("issue-agent run accepts only recorded-triage mode", async () => { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ mode: "live-shell" }), + }) + ); + const body = await json(response); + + assert.equal(response.status, 400); + assert.equal(body.error, "Unsupported issue-agent mode"); +}); + +test("issue-agent run rejects invalid recorded-triage field types", async () => { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ mode: "recorded-triage", dryRun: "true" }), + }) + ); + const body = await json(response); + const error = body.error as Record; + const details = error.details as Array>; + + assert.equal(response.status, 400); + assert.equal(error.message, "Invalid request"); + assert.deepEqual(details, [ + { field: "dryRun", message: "Invalid input: expected boolean, received string" }, + ]); +}); + +test("issue-agent run is disabled by default", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + try { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + issueUrl: "https://github.com/x/y/issues/1", + }), + }) + ); + const body = await json(response); + + assert.equal(response.status, 403); + assert.equal(body.enabled, false); + assert.equal(body.requiredEnv, "OMNIROUTE_ISSUE_AGENT_ENABLED=true"); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + } +}); + +test("issue-agent run returns deterministic recorded-triage plan when enabled", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = "true"; + try { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/6059", + dryRun: true, + }), + }) + ); + const body = await json(response); + + assert.equal(response.status, 200); + assert.equal(body.accepted, true); + assert.equal(body.mode, "recorded-triage"); + assert.equal(body.repository, "KooshaPari/OmniRoute"); + assert.equal(body.issueNumber, 6059); + assert.match(String(body.runId), /^issue-agent-recorded-triage-[a-f0-9]{16}$/); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + } +}); + +test("issue-agent run rejects invalid enabled recorded-triage URL", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = "true"; + try { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ mode: "recorded-triage", issueUrl: "https://example.com/nope" }), + }) + ); + const body = await json(response); + + assert.equal(response.status, 400); + assert.equal(body.error, "Expected a GitHub issue or pull request URL"); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + } +}); + +test("issue-agent run returns recorded context summary with redaction", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + const previousDataDir = process.env.DATA_DIR; + process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = "true"; + process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "issue-agent-route-")); + try { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/7", + recordedContext: { + title: "Review PR mention", + body: "Authorization: Bearer sk-routeSecret1234567890", + comments: [{ author: "maintainer", body: "please review", isBot: false }], + }, + }), + }) + ); + const body = await json(response); + const context = body.context as Record; + + assert.equal(response.status, 200); + assert.equal(body.auditPath, join(process.env.DATA_DIR, "issue-agent", "audit.jsonl")); + assert.equal(context.issueTitle, "Review PR mention"); + assert.equal(context.intent, "review"); + assert.equal(context.humanCommentCount, 1); + assert.doesNotMatch(String(context.redactedDigestSource), /sk-routeSecret/); + assert.match(String(context.redactedDigestSource), /\[REDACTED\]/); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + } +}); + +test("issue-agent run accepts recorded GitHub export payloads", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + const previousDataDir = process.env.DATA_DIR; + process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = "true"; + process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "issue-agent-export-route-")); + try { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + githubExport: { + issue: { + title: "Fix GitHub export importer", + body: "bug in recorded context path", + html_url: "https://github.com/KooshaPari/OmniRoute/issues/88", + }, + comments: [{ user: { login: "maintainer", type: "User" }, body: "please patch" }], + }, + }), + }) + ); + const body = await json(response); + const context = body.context as Record; + + assert.equal(response.status, 200); + assert.equal(body.issueUrl, "https://github.com/KooshaPari/OmniRoute/issues/88"); + assert.equal(body.issueNumber, 88); + assert.equal(context.issueTitle, "Fix GitHub export importer"); + assert.equal(context.intent, "bugfix"); + assert.equal(body.auditPath, join(process.env.DATA_DIR, "issue-agent", "audit.jsonl")); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + } +}); + +test("issue-agent run sanitizes a forced audit-write failure instead of leaking its absolute path", async () => { + const previous = process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + const previousDataDir = process.env.DATA_DIR; + process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = "true"; + const auditBlockerDir = mkdtempSync(join(tmpdir(), "issue-agent-audit-blocker-")); + // appendIssueAgentAuditRecord() (src/lib/issueAgent/audit.ts) does + // `mkdir(join(DATA_DIR, "issue-agent"), { recursive: true })`. Pre-creating a + // FILE at that exact path forces a real Node fs error (EEXIST) whose raw + // `.message` embeds this directory's absolute path -- the same leak shape a + // permission-denied or disk-full failure would produce (Hard Rule #12). + writeFileSync(join(auditBlockerDir, "issue-agent"), "blocking file, not a directory"); + process.env.DATA_DIR = auditBlockerDir; + + try { + const response = await POST( + new Request("http://localhost/api/issue-agent/runs", { + method: "POST", + body: JSON.stringify({ + mode: "recorded-triage", + issueUrl: "https://github.com/KooshaPari/OmniRoute/issues/1", + }), + }) + ); + const body = await json(response); + + assert.equal(response.status, 400); + assert.equal(typeof body.error, "string"); + const errorMessage = String(body.error); + assert.doesNotMatch(errorMessage, /EEXIST|ENOENT|EACCES/, "must not leak the raw errno code"); + assert.ok( + !errorMessage.includes(auditBlockerDir), + "must not leak the DATA_DIR absolute path" + ); + assert.ok( + !errorMessage.includes("/issue-agent/"), + "must not leak the audit subdirectory path" + ); + assert.equal(errorMessage, "Issue Agent request failed due to an internal error"); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_ISSUE_AGENT_ENABLED; + else process.env.OMNIROUTE_ISSUE_AGENT_ENABLED = previous; + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + } +}); diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts index ba9a9f3036..a19bed6d5f 100644 --- a/tests/unit/route-guard-private-lan.test.ts +++ b/tests/unit/route-guard-private-lan.test.ts @@ -85,6 +85,11 @@ test("services + traffic-inspector remain LOCAL_ONLY paths", () => { assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true); }); +test("issue-agent routes are LOCAL_ONLY by default", () => { + assert.equal(isLocalOnlyPath("/api/issue-agent/runs"), true); + assert.equal(isLocalOnlyPath("/api/issue-agent/runs/recorded-triage"), true); +}); + test("management policy must NOT derive locality from the spoofable Host header", () => { const src = readFileSync( join(import.meta.dirname, "../../src/server/authz/policies/management.ts"),