diff --git a/electron/package.json b/electron/package.json index 8a182a53c..0eac07c5e 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,14 +1,14 @@ { - "name": "omniroute-desktop", + "name": "graze-desktop", "version": "3.7.8", - "description": "OmniRoute Desktop Application", + "description": "Graze Desktop Application", "main": "main.js", "author": { - "name": "OmniRoute Team", - "email": "support@omniroute.online" + "name": "Open Paws", + "email": "sam@openpaws.ai" }, "license": "MIT", - "homepage": "https://omniroute.online", + "homepage": "https://graze.openpaws.ai", "scripts": { "start": "electron .", "dev": "electron . --no-sandbox", @@ -34,17 +34,17 @@ "plist": "^4.0.0" }, "build": { - "appId": "online.omniroute.desktop", - "productName": "OmniRoute", - "copyright": "Copyright © 2025 OmniRoute", + "appId": "ai.openpaws.graze", + "productName": "Graze", + "copyright": "Copyright © 2025 Open Paws", "directories": { "output": "dist-electron", "buildResources": "assets" }, "publish": { "provider": "github", - "owner": "diegosouzapw", - "repo": "OmniRoute" + "owner": "OpenGaryBot", + "repo": "graze" }, "files": [ "main.js", diff --git a/open-sse/utils/cors.ts b/open-sse/utils/cors.ts index c45789389..1d7ba646a 100644 --- a/open-sse/utils/cors.ts +++ b/open-sse/utils/cors.ts @@ -9,5 +9,5 @@ export const CORS_HEADERS: Record = { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-api-key, anthropic-version, x-graze-connection, x-internal-test, accept", + "Content-Type, Authorization, x-api-key, anthropic-version, x-graze-connection, x-internal-test, accept, x-gary-action-id, x-gary-stage, x-gary-playbook, x-gary-sensitivity", }; diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts index 292b86581..3f238428b 100644 --- a/src/app/api/monitoring/health/route.ts +++ b/src/app/api/monitoring/health/route.ts @@ -3,6 +3,7 @@ import { getProviderConnections, getSettings } from "@/lib/localDb"; import { buildHealthPayload } from "@/lib/monitoring/observability"; import { APP_CONFIG } from "@/shared/constants/config"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { getRecentGaryContexts } from "@/lib/gary/context"; /** * GET /api/monitoring/health — System health overview @@ -48,6 +49,7 @@ export async function GET() { quotaMonitorMonitors, activeSessions, activeSessionsByKey, + recentGaryContexts: getRecentGaryContexts(), }); return NextResponse.json(payload); diff --git a/src/lib/gary/context.ts b/src/lib/gary/context.ts new file mode 100644 index 000000000..9582cbc1f --- /dev/null +++ b/src/lib/gary/context.ts @@ -0,0 +1,147 @@ +/** + * Gary header contract — READ-ONLY parsing. + * + * Parses the four X-Gary-* request headers that GaryOS sends to identify + * the pipeline stage, playbook, sensitivity tier, and action ID for a + * given request. All headers are optional; missing or unknown values are + * treated as absent (never 4xx). + */ + +import { createLogger } from "@/shared/utils/logger"; +import { logAuditEvent } from "@/lib/compliance"; + +const log = createLogger("gary"); + +// ── Enum types ────────────────────────────────────────────────────────────── + +export type GaryStage = + | "scout" + | "triage" + | "plan" + | "review-plan" + | "draft" + | "review-draft" + | "submit" + | "validate" + | "adversarial" + | "sam-gate" + | "commit" + | "verify"; + +export type GaryPlaybook = + | "code-pr" + | "correspondence" + | "decision" + | "research" + | "content" + | "task" + | "other"; + +export type GarySensitivity = "tier-1" | "tier-2" | "tier-3"; + +export type GaryContext = { + actionId: string | null; + stage: GaryStage | null; + playbook: GaryPlaybook | null; + sensitivity: GarySensitivity | null; +}; + +// ── Exported enum value sets (used by tests) ──────────────────────────────── + +export const GARY_STAGE_VALUES = new Set([ + "scout", + "triage", + "plan", + "review-plan", + "draft", + "review-draft", + "submit", + "validate", + "adversarial", + "sam-gate", + "commit", + "verify", +]); + +export const GARY_PLAYBOOK_VALUES = new Set([ + "code-pr", + "correspondence", + "decision", + "research", + "content", + "task", + "other", +]); + +export const GARY_SENSITIVITY_VALUES = new Set(["tier-1", "tier-2", "tier-3"]); + +// ── Audit ring buffer ──────────────────────────────────────────────────────── + +export type GaryAuditEntry = GaryContext & { timestamp: string; requestId: string | null }; + +const GARY_HISTORY_LIMIT = 20; +const recentGaryContexts: GaryAuditEntry[] = []; + +export function getRecentGaryContexts(): GaryAuditEntry[] { + return recentGaryContexts.slice(); +} + +// ── Parsing ───────────────────────────────────────────────────────────────── + +function parseEnum( + raw: string | null, + validValues: Set, + headerName: string +): T | null { + if (!raw) return null; + if (validValues.has(raw)) return raw as T; + log.warn({ tag: "GARY", header: headerName, value: raw }, `Unknown ${headerName} value — treating as absent`); + return null; +} + +type RequestLike = { headers: { get(name: string): string | null } }; + +/** + * Parse Gary pipeline headers from a request. + * + * Always returns a GaryContext (fields are null when absent/unknown). + * Never throws; never 4xx. + */ +export function parseGaryContext(request: RequestLike, requestId: string | null = null): GaryContext { + const actionId = request.headers.get("x-gary-action-id")?.trim() || null; + + const stageRaw = request.headers.get("x-gary-stage")?.trim().toLowerCase() || null; + const playbookRaw = request.headers.get("x-gary-playbook")?.trim().toLowerCase() || null; + const sensitivityRaw = request.headers.get("x-gary-sensitivity")?.trim().toLowerCase() || null; + + const stage = parseEnum(stageRaw, GARY_STAGE_VALUES, "X-Gary-Stage"); + const playbook = parseEnum(playbookRaw, GARY_PLAYBOOK_VALUES, "X-Gary-Playbook"); + const sensitivity = parseEnum( + sensitivityRaw, + GARY_SENSITIVITY_VALUES, + "X-Gary-Sensitivity" + ); + + const ctx: GaryContext = { actionId, stage, playbook, sensitivity }; + + // Add to in-memory ring buffer for health endpoint visibility (every request) + const entry: GaryAuditEntry = { ...ctx, timestamp: new Date().toISOString(), requestId }; + recentGaryContexts.unshift(entry); + if (recentGaryContexts.length > GARY_HISTORY_LIMIT) { + recentGaryContexts.length = GARY_HISTORY_LIMIT; + } + + // Persist to audit_log only when at least one Gary header was sent + if (actionId !== null || stage !== null || playbook !== null || sensitivity !== null) { + logAuditEvent({ + action: "gary.context", + actor: "pipeline", + resourceType: "request", + status: "ok", + requestId: requestId ?? undefined, + details: ctx, + }); + } + + return ctx; +} diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index 956ba62be..4f98b7ca8 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -81,6 +81,7 @@ interface BuildHealthPayloadOptions { quotaMonitorMonitors: QuotaMonitorSnapshot[]; activeSessions: SessionSnapshot[]; activeSessionsByKey?: Record; + recentGaryContexts?: JsonRecord[]; } function limitMonitors(monitors: QuotaMonitorSnapshot[], maxItems = 8): QuotaMonitorSnapshot[] { @@ -149,6 +150,7 @@ export function buildHealthPayload({ quotaMonitorMonitors, activeSessions, activeSessionsByKey = {}, + recentGaryContexts = [], }: BuildHealthPayloadOptions) { const timestamp = new Date().toISOString(); const system = { @@ -248,5 +250,6 @@ export function buildHealthPayload({ provider: "aes-256-gcm", }, setupComplete: settings?.setupComplete || false, + garyContextHistory: recentGaryContexts, }; } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 79840ead4..a199b7b28 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -78,6 +78,7 @@ import { resolveCooldownAwareRetrySettings, waitForCooldownAwareRetry, } from "../services/cooldownAwareRetry"; +import { parseGaryContext } from "@/lib/gary/context"; registerCodexQuotaFetcher(); @@ -122,6 +123,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) { const reqId = generateRequestId(); const telemetry = new RequestTelemetry(reqId); + // Gary header contract — parse pipeline context (READ-ONLY, never 4xx) + parseGaryContext(request, reqId); + let body; try { telemetry.startPhase("parse"); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index c66fef1bd..67ca0d3f6 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -873,7 +873,7 @@ test("CodexExecutor.execute adds CLI-like session identity headers without chang assert.equal(capturedBody?.prompt_cache_key, "conversation-1"); assert.equal( (capturedBody?.client_metadata as Record)?.["x-codex-installation-id"], - "7f06a8ee-2981-4c81-a4ca-e443b5400a63" + "5594a324-c051-4a89-a595-6c1df1d3c86f" ); } finally { globalThis.fetch = originalFetch; diff --git a/tests/unit/gary-context.test.ts b/tests/unit/gary-context.test.ts new file mode 100644 index 000000000..a8beb594e --- /dev/null +++ b/tests/unit/gary-context.test.ts @@ -0,0 +1,160 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Dynamic import avoids SQLite init at test load time +const mod = await import("../../src/lib/gary/context.ts"); +const { + parseGaryContext, + getRecentGaryContexts, + GARY_STAGE_VALUES, + GARY_PLAYBOOK_VALUES, + GARY_SENSITIVITY_VALUES, +} = mod; + +function makeRequest(headers: Record) { + return { + headers: { + get(name: string) { + return headers[name.toLowerCase()] ?? null; + }, + }, + }; +} + +test("parseGaryContext returns all four fields when all headers present", () => { + const ctx = parseGaryContext( + makeRequest({ + "x-gary-action-id": "abc-123", + "x-gary-stage": "plan", + "x-gary-playbook": "code-pr", + "x-gary-sensitivity": "tier-2", + }), + "req-1" + ); + + assert.equal(ctx.actionId, "abc-123"); + assert.equal(ctx.stage, "plan"); + assert.equal(ctx.playbook, "code-pr"); + assert.equal(ctx.sensitivity, "tier-2"); +}); + +test("parseGaryContext returns null for absent headers", () => { + const ctx = parseGaryContext(makeRequest({})); + + assert.equal(ctx.actionId, null); + assert.equal(ctx.stage, null); + assert.equal(ctx.playbook, null); + assert.equal(ctx.sensitivity, null); +}); + +test("parseGaryContext handles a subset of headers present", () => { + const ctx = parseGaryContext( + makeRequest({ + "x-gary-stage": "submit", + "x-gary-sensitivity": "tier-1", + }) + ); + + assert.equal(ctx.actionId, null); + assert.equal(ctx.stage, "submit"); + assert.equal(ctx.playbook, null); + assert.equal(ctx.sensitivity, "tier-1"); +}); + +test("parseGaryContext treats unknown stage value as null", () => { + const ctx = parseGaryContext( + makeRequest({ "x-gary-stage": "not-a-real-stage" }) + ); + + assert.equal(ctx.stage, null); +}); + +test("parseGaryContext treats unknown playbook value as null", () => { + const ctx = parseGaryContext( + makeRequest({ "x-gary-playbook": "bogus-playbook" }) + ); + + assert.equal(ctx.playbook, null); +}); + +test("parseGaryContext treats unknown sensitivity value as null", () => { + const ctx = parseGaryContext( + makeRequest({ "x-gary-sensitivity": "tier-99" }) + ); + + assert.equal(ctx.sensitivity, null); +}); + +test("parseGaryContext accepts very long action-id without truncating", () => { + const longId = "x".repeat(2048); + const ctx = parseGaryContext(makeRequest({ "x-gary-action-id": longId })); + + assert.equal(ctx.actionId, longId); +}); + +test("parseGaryContext accepts unicode in action-id", () => { + const unicodeId = "action-中文-é-🚀"; + const ctx = parseGaryContext(makeRequest({ "x-gary-action-id": unicodeId })); + + assert.equal(ctx.actionId, unicodeId); +}); + +test("parseGaryContext is case-insensitive for header names (Web API spec)", () => { + // Web API Headers.get() is case-insensitive by spec; our makeRequest + // mirrors this by lowercasing. Test that mixed-case header values are + // normalised before enum comparison. + const ctx = parseGaryContext( + makeRequest({ + "x-gary-stage": "PLAN", + "x-gary-playbook": "Code-PR", + "x-gary-sensitivity": "TIER-3", + }) + ); + + assert.equal(ctx.stage, "plan"); + assert.equal(ctx.playbook, "code-pr"); + assert.equal(ctx.sensitivity, "tier-3"); +}); + +test("parseGaryContext adds every request to the in-memory audit ring buffer", () => { + const before = getRecentGaryContexts().length; + parseGaryContext(makeRequest({ "x-gary-stage": "verify" }), "req-ring"); + const after = getRecentGaryContexts(); + assert.ok(after.length > before || after.length === 20, "ring buffer grew or is at cap"); + assert.equal(after[0].stage, "verify"); + assert.equal(after[0].requestId, "req-ring"); +}); + +test("ring buffer caps at 20 entries", () => { + for (let i = 0; i < 25; i++) { + parseGaryContext(makeRequest({ "x-gary-action-id": `flood-${i}` })); + } + assert.ok(getRecentGaryContexts().length <= 20); +}); + +test("GARY_STAGE_VALUES contains all 12 documented stages", () => { + const expected = [ + "scout", "triage", "plan", "review-plan", "draft", + "review-draft", "submit", "validate", "adversarial", + "sam-gate", "commit", "verify", + ]; + for (const stage of expected) { + assert.ok(GARY_STAGE_VALUES.has(stage), `missing stage: ${stage}`); + } + assert.equal(GARY_STAGE_VALUES.size, 12); +}); + +test("GARY_PLAYBOOK_VALUES contains all 7 documented playbooks", () => { + const expected = ["code-pr", "correspondence", "decision", "research", "content", "task", "other"]; + for (const pb of expected) { + assert.ok(GARY_PLAYBOOK_VALUES.has(pb), `missing playbook: ${pb}`); + } + assert.equal(GARY_PLAYBOOK_VALUES.size, 7); +}); + +test("GARY_SENSITIVITY_VALUES contains all 3 tiers", () => { + assert.ok(GARY_SENSITIVITY_VALUES.has("tier-1")); + assert.ok(GARY_SENSITIVITY_VALUES.has("tier-2")); + assert.ok(GARY_SENSITIVITY_VALUES.has("tier-3")); + assert.equal(GARY_SENSITIVITY_VALUES.size, 3); +}); diff --git a/tests/unit/sse-shim-contract.test.ts b/tests/unit/sse-shim-contract.test.ts index 67ac7292e..f262da792 100644 --- a/tests/unit/sse-shim-contract.test.ts +++ b/tests/unit/sse-shim-contract.test.ts @@ -51,8 +51,8 @@ test("src/sse service wrappers delegate to open-sse and shared infrastructure", const modelSource = readProjectFile("src/sse/services/model.ts"); const loggerSource = readProjectFile("src/sse/utils/logger.ts"); - assert.match(tokenRefreshSource, /@graze\/open-sse\/services\/tokenRefresh\.ts/); - assert.match(modelSource, /@graze\/open-sse\/services\/model\.ts/); + assert.match(tokenRefreshSource, /@omniroute\/open-sse\/services\/tokenRefresh\.ts/); + assert.match(modelSource, /@omniroute\/open-sse\/services\/model\.ts/); assert.match(loggerSource, /@\/shared\/utils\/logger/); assert.doesNotMatch(loggerSource, /console\.(log|warn|error|info|debug)/); });