From 5e319874cde247675d09ae4618c1337278ca50d0 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 6 May 2026 04:08:27 +1000 Subject: [PATCH 1/2] feat(api): implement Gary header contract (read-only parsing) Parse X-Gary-Action-Id, X-Gary-Stage, X-Gary-Playbook, and X-Gary-Sensitivity on every /v1/chat/* request. Headers are optional; unknown enum values log a warning and are treated as absent (never 4xx). Parsed context is stored in a 20-entry in-memory ring buffer exposed via the health endpoint's new garyContextHistory field. Requests with at least one Gary header are also persisted to the audit_log table under action "gary.context". - src/lib/gary/context.ts: parser, enum sets, ring buffer, audit logging - src/sse/handlers/chat.ts: parse Gary context at handleChat() entry - open-sse/utils/cors.ts: allow four Gary headers in CORS preflight - src/lib/monitoring/observability.ts: add garyContextHistory to health payload - src/app/api/monitoring/health/route.ts: wire getRecentGaryContexts() - tests/unit/gary-context.test.ts: 14 tests covering all parse cases Co-Authored-By: Claude Sonnet 4.6 --- open-sse/utils/cors.ts | 2 +- src/app/api/monitoring/health/route.ts | 2 + src/lib/gary/context.ts | 147 +++++++++++++++++++++++ src/lib/monitoring/observability.ts | 3 + src/sse/handlers/chat.ts | 4 + tests/unit/gary-context.test.ts | 160 +++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/lib/gary/context.ts create mode 100644 tests/unit/gary-context.test.ts 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/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); +}); From d35bdcc0e03da35c780a86316bf5327ce3f7afdd Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 6 May 2026 04:26:42 +1000 Subject: [PATCH 2/2] fix(ci): correct three brand-sweep misses that broke unit/smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. sse-shim-contract.test.ts: revert assertions from @graze/open-sse back to @omniroute/open-sse — the TypeScript path alias rename was explicitly deferred; the test was incorrectly updated ahead of the rename. 2. executor-codex.test.ts: update expected x-codex-installation-id UUID from 7f06a8ee (derived from omniroute-codex-installation salt) to 5594a324 (derived from the renamed graze-codex-installation salt, which was changed in the brand sweep because no existing deployments use the old value). 3. electron/package.json: rename name/productName/appId/author/homepage from OmniRoute to Graze so electron-builder produces the graze-desktop executable that smoke-electron-packaged.mjs expects, and update publish target from diegosouzapw/OmniRoute to OpenGaryBot/graze. Co-Authored-By: Claude Sonnet 4.6 --- electron/package.json | 20 ++++++++++---------- tests/unit/executor-codex.test.ts | 2 +- tests/unit/sse-shim-contract.test.ts | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) 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/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/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)/); });