diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a856ab70..8a769815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,8 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + # Full history so the lockfile policy check can read commit messages. + fetch-depth: 0 submodules: false - name: Setup Node environment @@ -114,6 +116,10 @@ jobs: - name: Check types, lint, and format run: pnpm check + - name: Enforce lockfile change policy + if: github.event_name == 'pull_request' + run: node scripts/check-lockfile-policy.mjs --base "origin/${{ github.base_ref }}" --head "${{ github.event.pull_request.head.sha }}" + build-artifacts: needs: [docs-scope, changed-scope, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..cda82e50 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,43 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "warn" + }, + "rules": { + "no-explicit-any": "warn", + "typescript/no-floating-promises": "warn", + "typescript/no-misused-promises": "warn", + "typescript/await-thenable": "warn", + "typescript/no-base-to-string": "warn", + "typescript/restrict-template-expressions": "warn", + "typescript/no-misused-spread": "warn", + "typescript/no-this-alias": "warn", + "no-unused-vars": "warn", + "no-unassigned-vars": "warn", + "no-dupe-keys": "warn", + "no-unused-expressions": "warn", + "no-misleading-character-class": "warn", + "no-control-regex": "warn", + "no-new-array": "off" + }, + "overrides": [ + { + "files": [ + "**/*.test.ts", + "**/*.e2e.test.ts", + "**/*.live.test.ts", + "**/*.test-helpers.ts", + "**/test-helpers/**", + "**/test-utils/**" + ], + "rules": { + "typescript/no-floating-promises": "off", + "typescript/no-misused-promises": "off", + "typescript/unbound-method": "off", + "no-explicit-any": "off", + "typescript/no-base-to-string": "off", + "typescript/restrict-template-expressions": "off" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 7a543e1d..6f603a8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,16 @@ - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Naming: use **Mayros** for product/app/docs headings; use `mayros` for CLI command, package/binary, paths, and config keys. +## Prompt Cache Discipline + +Per-conversation prompt caching is sacred. A long-lived session reuses a cached prefix on every turn; anything that mutates past context, swaps toolsets, or rebuilds the system prompt with volatile content invalidates that cache and multiplies token cost. The project enforces caching via the `cacheRetention` stream option (handled by the provider layer), and the cost of an invalidation is real and silent. + +- The system prompt is rebuilt on every run. Its content must be **stable across runs that have no reason to change it**. Do not add volatile data (timestamps that tick every second, random IDs, unordered maps) to the system prompt or to context files that feed it. The `Runtime:` line is the sanctioned place for run-scoped info and is designed to change only when the operator changes model, channel, or thinking level. +- Slash commands that mutate prompt-relevant state (`/model`, `/think`, `/reasoning`, `/elevated`, `/fast`, `/new`) are expected to invalidate the cache on the **next** turn by design. That is the only acceptable invalidation. A change that invalidates the cache mid-turn, or on every turn regardless of whether the operator touched anything, is a bug. +- When adding context to the prompt (new sections, hook output, injected memory), prefer **append-only** structure. Reordering existing sections or inserting in the middle invalidates everything after the insertion point. +- Hooks (`before_prompt_build`, `before_agent_start`) that override `systemPrompt` or `prependContext` must be deterministic for a given input. A hook that returns different text on consecutive calls with the same inputs breaks the cache and is a high-priority bug. +- The `diagnostics.cacheTrace` subsystem records a SHA-256 of the system prompt and messages at each pipeline stage. When investigating cache regressions, compare `systemDigest` across consecutive turns of a session where nothing should have changed — a drift there is the signature of a cache-breaking change. + ## Release Channels (Naming) - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. diff --git a/SECURITY.md b/SECURITY.md index 784bdf96..74786ce3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,6 +57,48 @@ Mayros security guidance assumes: - Anyone who can modify `~/.mayros` state/config (including `mayros.json`) is effectively a trusted operator. - A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. +## Trust Model + +The single load-bearing security boundary in Mayros is **operating-system level +isolation**. Everything else is a heuristic or a convenience, not a containment +boundary. + +This distinction matters because it is easy to over-trust in-process defenses. +The rule below governs how each layer should be treated: + +### What is and is not a boundary + +- **Real boundaries** — mechanisms the OS or kernel enforces against a + malicious process running inside them: + - Separate OS users, containers, VMs, namespaces, seccomp, Landlock. + - A sandboxed Docker container with `--cap-drop=ALL --read-only`. + - A separate host entirely (the strongest option). +- **In-process defenses** — useful, but **not containment**. A sufficiently + resourced attacker who has code execution inside the process can bypass them: + - The approval gate and interactive permission prompts. + - Output redaction and secret masking (`src/security/output-masking.ts`). + - The static skill scanner and bash command blocklist. + - Rate limiters, query/write caps, and the enrichment timeout. + - The WASM skill sandbox (it narrows the attack surface of untrusted skill + code, but the host process can still be reached via bugs in the runtime). + +Treat the in-process defenses as **defense in depth and attack-surface +reduction**, never as the thing that stops a determined adversary. The thing +that stops a determined adversary is running the untrusted workload under a +real OS boundary. + +### Practical implications + +- For fully untrusted skills, plugins, or tool output, run the Gateway (or the + session) inside a container or separate OS user. Do not rely on the approval + gate alone. +- The default loopback-only bind is a real boundary against remote network + attackers; keep it unless you have a replacement boundary (Tailscale, SSH + tunnel, firewall). +- When reviewing a security finding, classify it by which boundary it crosses. + A bug that lets a plugin read another plugin's memory is contained by the OS + user; a bug that lets a remotely-reached endpoint execute code is not. + ## Plugin Trust Boundary Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code. @@ -123,6 +165,56 @@ docker run --read-only --cap-drop=ALL \ mayros/mayros:latest ``` +## Dependency Supply Chain + +Mayros treats its dependency tree as part of its trust boundary. A malicious or +broken transitive package reaches users the moment it enters the lockfile, so the +project enforces the following controls in `package.json`: + +### Release age floor + +`pnpm.minimumReleaseAge: 2880` (48 hours) gates every resolution. A package +published less than 48 hours ago is not eligible for install, which buffers +against compromised or yanked releases that get quarantined shortly after +publish. + +### Forced security overrides + +The `pnpm.overrides` map forces specific versions of transitive dependencies +known to carry security regressions. Each override exists because a transitive +in the tree resolved to a vulnerable version and the override is the most +durable way to pin the patched floor without waiting for upstreams to bump. + +When adding or changing an override, record why in the commit message: the CVE +or GHSA it addresses (or the regression it prevents). Overrides without a +recorded rationale are rejected in review. + +### Patched dependencies + +Any dependency declared in `pnpm.patchedDependencies` must use an exact version +(no `^` or `~`). This is a hard rule: a range on a patched package lets a future +resolve drift past the patch, silently reverting the fix. + +### Lockfile discipline + +The npm/pnpm lockfile pins the full transitive tree by content hash. Treat the +lockfile as a reviewed artifact, not an auto-generated one: + +- A change to `pnpm-lock.yaml` without a corresponding dependency change in + `package.json` is suspicious and must be explained in the commit. +- Dependabot/Renovate PRs that bump a transitive without a recorded CVE/fix are + held until the change is understood. +- `pnpm install --frozen-lockfile` is the canonical install command in CI; + `pnpm install` (which can mutate the lockfile) is reserved for local + dependency changes that are committed alongside a `deps:` commit. + +### Native build scripts + +`pnpm.onlyBuiltDependencies` enumerates the packages allowed to run native +build scripts (node-gyp, prebuilt binaries). Packages not in this list cannot +execute install scripts, which limits the blast radius of a compromised native +dependency. + ## Security Scanning This project uses `detect-secrets` for automated secret detection in CI/CD. diff --git a/docs/platforms/tui-redesign.md b/docs/platforms/tui-redesign.md new file mode 100644 index 00000000..edc609c2 --- /dev/null +++ b/docs/platforms/tui-redesign.md @@ -0,0 +1,109 @@ +--- +title: TUI Redesign Roadmap +description: Audit of the current terminal UI and prioritized plan for reaching a polished, best-in-class experience. +--- + +# TUI Redesign Roadmap + +This document captures the current state of the Mayros terminal UI (`mayros code` / `mayros tui`), the gaps identified during an internal audit, and a prioritized roadmap for the next redesign pass. It is a planning artifact; implementation is tracked separately. + +## Current state at a glance + +The TUI is a gateway client: it renders in the terminal and talks to a running `mayros gateway` daemon over WebSocket. The rendering, input, and component layer comes from the shared terminal UI dependency; Mayros adds the slash-command surface, themes, vim mode, mouse scrolling, accessibility fallback, and the gateway chat client. + +| Area | Maturity | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Rendering and streaming | Mature. Event-driven render loop, full assistant-message re-render on each delta. | +| Slash commands | Mature in count (40+ built-in), uneven in depth. Core session commands are first-class; ecosystem commands are prompt-injection shims. | +| Themes | Mature. Ten palettes with 22 color tokens each, live hot-swap. | +| Vim mode | Basic. Custom implementation; operators partially stubbed, no visual or command-line mode. | +| Accessibility | Split. A linear text-mode TUI exists but is a separate, feature-poor implementation. | +| Platform coverage | Gaps. Clipboard image paste and `/copy` work on macOS and Linux only; Windows and Wayland are unsupported. | +| Architecture | Workable but strained. Two monolithic files (`tui.ts`, `tui-command-handlers.ts`), a mutable god-object state, and two parallel palette systems. | + +## Architectural constraints + +These shape what a redesign can and cannot do without larger upstream work. + +- **The terminal UI dependency is the rendering floor.** Layout capabilities (vertical-stack containers, select-list overlays) bound what Mayros can render. The tightest couplings are a `CustomEditor` that subclasses the shared editor and a `ChatLog` that subclasses the shared container. Refactoring either to composition requires an adapter layer or upstream changes. +- **The gateway is mandatory.** The TUI cannot run without a reachable gateway daemon. There is no in-process fallback, which affects offline use and dev ergonomics. +- **Theme hot-swap relies on singleton mutation.** Active theme objects are mutated in place so existing references stay live. Any snapshotting or caching of theme values would break live switching. +- **Mutable state object.** The TUI threads a single state object through every handler with manual render requests. There is no reactive store, although the signal libraries used elsewhere in the repo are available as a foundation. +- **Two parallel TUI implementations.** The graphical TUI and the accessibility TUI share almost no code. Every new feature currently needs to be implemented twice or skipped in one of them. +- **Two palette systems.** Terminal output (onboarding, prompts, CLI tables) and the TUI use separate palette modules with different accent colors. Visual consistency across the CLI experience requires unifying them. + +## Gaps by category + +### Rendering + +- No inline image rendering despite the shared terminal dependency shipping the capability. Image content blocks currently render as text placeholders. +- The diff renderer hardcodes colors instead of consuming the active theme, so diffs look wrong under light or high-contrast themes. +- Only vertical-stack layout; no split panes or resizable regions beyond the select-list overlay. +- Full assistant-text re-render on every streaming delta; long messages may degrade. + +### Input + +- Vim operator-plus-motion combinations are a simplified stub (non-linewise motions collapse to single-character operations). No visual mode, no command-line mode, no search, no repeat, no registers beyond a single yank buffer. +- The vim bridge does not appear to be wired from the TUI side; motions may silently no-op if the bridge is never connected. This needs verification before any vim work. +- Ctrl+C does not abort the active run; only Escape does. This is surprising and should align with user expectations. +- A cross-session input history store exists in code but is not connected to the main loop. + +### Commands + +- Ecosystem commands (`/team`, `/tasks`, `/workflow`, `/rules`, `/mailbox`, `/sync`, `/trace`, `/kg`, `/plan`) inject a prompt that asks the model to call a tool, rather than being first-class features. Results depend on the model. +- Several commands lack argument autocomplete. +- The theme preset list is duplicated in two files and can drift. +- The README command list is stale relative to the implementation. + +### Themes + +- The terminal palette and the TUI palette are separate modules with different accent colors. +- No user-defined custom themes. +- No colorblind-optimized palettes beyond the existing high-contrast option. +- No explicit `NO_COLOR` or `FORCE_COLOR` handling in the TUI. + +### Accessibility + +- The accessibility TUI is a stripped-down parallel implementation: no slash commands beyond exit, no session switching, no abort, no model selection. It excludes screen-reader users from most functionality. +- No screen-reader announcements in the graphical TUI. +- No monochrome or no-color mode in the graphical TUI. + +### Robustness and platform + +- Clipboard image paste and `/copy` support macOS and Linux (xclip) only. Windows and Wayland are unsupported. +- Several silent `catch` blocks swallow errors without logging. +- Disconnect handling shows hints but does not auto-reconnect with backoff. +- Window resize handling is delegated entirely to the shared terminal dependency and is not verified end-to-end in Mayros. + +## Roadmap + +Priorities are assigned as P0 (correctness or blocker), P1 (high-value polish), and P2 (nice to have). + +### P0 — Correctness and blockers + +1. **Verify the vim bridge is connected.** If the editor bridge is never set from the TUI side, every vim motion that relies on it silently no-ops. Confirm or fix before any further vim work. +2. **Accessibility TUI feature parity.** Either linearize the graphical renderer for screen readers or bring slash commands, session switching, and abort into the accessibility TUI. The current split excludes users from most functionality. + +### P1 — High-value polish + +3. **Inline image rendering.** Use the shared terminal image component to render image content blocks inline instead of text placeholders. +4. **Unify the palettes.** Merge the terminal palette and the TUI palette into one source of truth; make the diff renderer and every component consume the active theme. +5. **Complete vim mode.** Implement real operator-plus-motion semantics and add visual mode, or adopt a proven keymap pattern. +6. **Windows and Wayland clipboard support.** Add the platform-native clipboard commands for `/copy` and image paste. +7. **Make Ctrl+C abort the active run** with a double-press-to-exit fallback, keeping Escape as a secondary abort. +8. **Refresh the README** to reflect the actual theme count and command list. + +### P2 — Nice to have + +9. Terminal color-scheme auto-detection to pick dark or light theme automatically. +10. Wire the cross-session input history store into the main loop, or remove the dead code. +11. User-defined custom themes loaded from a config directory. +12. Argument autocomplete for every command; deduplicate the theme preset list. +13. Colorblind-optimized palettes and a true monochrome mode. +14. Incremental streaming render for long assistant messages. +15. Replace silent error swallowing with structured logging behind a debug flag. +16. Split the two monolithic TUI files to bring them under the file-size guideline. + +## Next steps + +The first implementation pass should focus on the P0 items, since both block a trustworthy feature surface: vim cannot be extended on top of an unwired bridge, and the accessibility TUI cannot ship as the screen-reader path while it lacks core commands. The P1 items are the bulk of the visible polish work and can be sequenced independently once the P0 verifications land. diff --git a/extensions/agent-mesh/index.ts b/extensions/agent-mesh/index.ts index 24ba5bf6..76eb5f12 100644 --- a/extensions/agent-mesh/index.ts +++ b/extensions/agent-mesh/index.ts @@ -2222,7 +2222,12 @@ const agentMeshPlugin = { for (const [name, handler] of Object.entries(methods)) { api.registerGatewayMethod(name, async ({ params, respond }) => { try { - const result = await handler((params ?? {}) as any); + // Handlers in the Mamoru gateway map are heterogeneous (each + // declares its own params shape). The runtime contract is that + // params arrive validated by the gateway dispatcher, so we forward + // the resolved object typed as the handler's first parameter. + const fn = handler as (input: Record) => Promise; + const result = await fn((params ?? {}) as Record); respond(true, result); } catch (err) { respond(false, { error: err instanceof Error ? err.message : String(err) }); @@ -2277,21 +2282,28 @@ const agentMeshPlugin = { const nodeFsP = await import("node:fs/promises"); const home = process.env.HOME ?? ""; - // Update mayros.json with model + ollama auth profile + // Update mayros.json with model + ollama auth profile. The config is + // read from disk and mutated, so we model the shape we touch rather + // than widening to Record. + type MayrosJsonFile = { + agents?: { defaults?: { model?: { primary?: string; [k: string]: unknown } } }; + auth?: { profiles?: Record }; + [k: string]: unknown; + }; const configPath = nodePath.join(home, ".mayros", "mayros.json"); - let cfg: Record = {}; + let cfg: MayrosJsonFile = {}; try { - cfg = JSON.parse(await nodeFsP.readFile(configPath, "utf8")); + cfg = JSON.parse(await nodeFsP.readFile(configPath, "utf8")) as MayrosJsonFile; } catch { /* new */ } - if (!cfg.agents) cfg.agents = {}; - if (!cfg.agents.defaults) cfg.agents.defaults = {}; - if (!cfg.agents.defaults.model) cfg.agents.defaults.model = {}; + cfg.agents ??= {}; + cfg.agents.defaults ??= {}; + cfg.agents.defaults.model ??= {}; cfg.agents.defaults.model.primary = p.model; if (p.provider === "local" && p.model.startsWith("ollama/")) { - if (!cfg.auth) cfg.auth = {}; - if (!cfg.auth.profiles) cfg.auth.profiles = {}; + cfg.auth ??= {}; + cfg.auth.profiles ??= {}; cfg.auth.profiles["ollama"] = { provider: "ollama", mode: "api_key" }; } await nodeFsP.writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n"); @@ -2301,13 +2313,18 @@ const agentMeshPlugin = { const agentDir = nodePath.join(home, ".mayros", "agents", "main", "agent"); await nodeFsP.mkdir(agentDir, { recursive: true }); const storePath = nodePath.join(agentDir, "auth-profiles.json"); - let store: Record = { version: 2, profiles: {} }; + type AuthProfileStore = { + version?: number; + profiles?: Record; + [k: string]: unknown; + }; + let store: AuthProfileStore = { version: 2, profiles: {} }; try { - store = JSON.parse(await nodeFsP.readFile(storePath, "utf8")); + store = JSON.parse(await nodeFsP.readFile(storePath, "utf8")) as AuthProfileStore; } catch { /* new */ } - if (!store.profiles) store.profiles = {}; + store.profiles ??= {}; store.profiles["ollama"] = { provider: "ollama", type: "api_key", key: "ollama-local" }; await nodeFsP.writeFile(storePath, JSON.stringify(store, null, 2)); } diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 95af28eb..83215694 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -86,7 +86,7 @@ type SenderNameResult = { async function resolveFeishuSenderName(params: { account: ResolvedFeishuAccount; senderOpenId: string; - log: (...args: any[]) => void; + log: (...args: unknown[]) => void; }): Promise { const { account, senderOpenId, log } = params; if (!account.configured) return {}; @@ -100,10 +100,19 @@ async function resolveFeishuSenderName(params: { const client = createFeishuClient(account); // contact/v3/users/:user_id?user_id_type=open_id - const res: any = await client.contact.user.get({ + // The Feishu SDK returns an untyped response envelope; narrow the fields + // we actually read instead of widening the whole value to `any`. + type FeishuUser = { + name?: string; + display_name?: string; + nickname?: string; + en_name?: string; + }; + type FeishuUserResponse = { data?: { user?: FeishuUser } }; + const res = (await client.contact.user.get({ path: { user_id: senderOpenId }, params: { user_id_type: "open_id" }, - }); + })) as FeishuUserResponse; const name: string | undefined = res?.data?.user?.name || diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 52f90dbb..f4f5d048 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -93,11 +93,16 @@ async function convertMarkdown(client: Lark.Client, markdown: string) { }; } -function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] { +/** Minimal structural shape of a Feishu/Lark document block used for sorting. */ +type FeishuBlock = { block_id?: string; [key: string]: unknown }; + +function sortBlocksByFirstLevel(blocks: FeishuBlock[], firstLevelIds: string[]): FeishuBlock[] { if (!firstLevelIds || firstLevelIds.length === 0) return blocks; - const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean); + const sorted = firstLevelIds + .map((id) => blocks.find((b) => b.block_id === id)) + .filter((b): b is FeishuBlock => Boolean(b)); const sortedIds = new Set(firstLevelIds); - const remaining = blocks.filter((b) => !sortedIds.has(b.block_id)); + const remaining = blocks.filter((b) => !sortedIds.has(b.block_id ?? "")); return [...sorted, ...remaining]; } diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index ed979461..37ce4591 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -67,23 +67,23 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { } const postIdRaw = - typeof (params as any)?.messageId === "string" - ? (params as any).messageId - : typeof (params as any)?.postId === "string" - ? (params as any).postId + typeof params.messageId === "string" + ? params.messageId + : typeof params.postId === "string" + ? params.postId : ""; const postId = postIdRaw.trim(); if (!postId) { throw new Error("Mattermost react requires messageId (post id)"); } - const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : ""; + const emojiRaw = typeof params.emoji === "string" ? params.emoji : ""; const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, ""); if (!emojiName) { throw new Error("Mattermost react requires emoji"); } - const remove = (params as any)?.remove === true; + const remove = params.remove === true; if (remove) { const result = await removeMattermostReaction({ cfg, diff --git a/extensions/mcp-server/index.ts b/extensions/mcp-server/index.ts index 65efa511..299f8016 100644 --- a/extensions/mcp-server/index.ts +++ b/extensions/mcp-server/index.ts @@ -15,6 +15,7 @@ // @ts-expect-error — dist/index.js has no declaration file; types resolved via source paths import type { MayrosPluginApi, MayrosPluginToolContext } from "@apilium/mayros"; +import type { Command } from "commander"; import { mcpServerConfigSchema, type McpServerConfig } from "./config.js"; import { McpServer, type McpServerOptions } from "./server.js"; import type { AdaptableTool } from "./tool-adapter.js"; @@ -142,7 +143,7 @@ const mcpServerPlugin = { // ── Register CLI ──────────────────────────────────────────────── - api.registerCli(({ program }: { program: any }) => { + api.registerCli(({ program }: { program: Command }) => { const serve = program .command("serve") .description("Start MCP server to expose Mayros tools, resources, and prompts"); @@ -303,45 +304,49 @@ const mcpServerPlugin = { // ── Register gateway method (MCP Dashboard) ──────────────────── - api.registerGatewayMethod("mcp.dashboard", async ({ respond }: { respond: any }) => { - // Cortex health check with 3s timeout — always runs - let cortexHealth: { status: "online" | "offline"; latencyMs: number }; - try { - const cortexPort = cfg.cortex?.port ?? 19090; - const cortexUrl = `http://127.0.0.1:${cortexPort}/api/v1/health`; - const start = Date.now(); - const cortexRes = await fetch(cortexUrl, { - signal: AbortSignal.timeout(3000), - }); - const latencyMs = Date.now() - start; - cortexHealth = { - status: cortexRes.ok ? "online" : "offline", - latencyMs, - }; - } catch { - cortexHealth = { status: "offline", latencyMs: 0 }; - } + type DashboardRespond = (ok: boolean, payload?: unknown) => void; + api.registerGatewayMethod( + "mcp.dashboard", + async ({ respond }: { respond: DashboardRespond }) => { + // Cortex health check with 3s timeout — always runs + let cortexHealth: { status: "online" | "offline"; latencyMs: number }; + try { + const cortexPort = cfg.cortex?.port ?? 19090; + const cortexUrl = `http://127.0.0.1:${cortexPort}/api/v1/health`; + const start = Date.now(); + const cortexRes = await fetch(cortexUrl, { + signal: AbortSignal.timeout(3000), + }); + const latencyMs = Date.now() - start; + cortexHealth = { + status: cortexRes.ok ? "online" : "offline", + latencyMs, + }; + } catch { + cortexHealth = { status: "offline", latencyMs: 0 }; + } - if (!server) { - respond(true, { - status: { - running: false, - transport: cfg.transport ?? "http", - toolCount: 0, - initialized: false, - uptimeMs: 0, - sseSessionCount: 0, - }, - metrics: null, - cortexHealth, - }); - return; - } + if (!server) { + respond(true, { + status: { + running: false, + transport: cfg.transport ?? "http", + toolCount: 0, + initialized: false, + uptimeMs: 0, + sseSessionCount: 0, + }, + metrics: null, + cortexHealth, + }); + return; + } - const status = server.status(); - const metrics = server.getMetrics(); - respond(true, { status, metrics, cortexHealth }); - }); + const status = server.status(); + const metrics = server.getMetrics(); + respond(true, { status, metrics, cortexHealth }); + }, + ); // ── Register service lifecycle ────────────────────────────────── diff --git a/scripts/check-lockfile-policy.mjs b/scripts/check-lockfile-policy.mjs new file mode 100644 index 00000000..59e95005 --- /dev/null +++ b/scripts/check-lockfile-policy.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Lockfile change policy check. + * + * Enforces the dependency supply-chain policy documented in SECURITY.md: + * a change to pnpm-lock.yaml should arrive alongside a dependency change in + * package.json, and the commit carrying it should be scoped as `deps:`. + * + * The check compares the lockfile against a base. On CI it reads the base + * from the GITHUB_BASE_REF / commit range; locally it can be invoked with + * --base to run the same gate before pushing. + * + * Exit codes: + * 0 — policy satisfied (or lockfile unchanged) + * 1 — policy violated (lockfile changed without a deps-scoped commit) + * 2 — unable to determine the range (soft-fail with a warning) + */ +import { execSync } from "node:child_process"; +import process from "node:process"; + +const LOCKFILE = "pnpm-lock.yaml"; +const DEPS_SCOPE = /^deps:/; +const DEPS_FILES = new Set(["package.json", "pnpm-workspace.yaml"]); + +function parseArgs(argv) { + const args = { base: null, head: null }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--base") args.base = argv[++i]; + else if (a === "--head") args.head = argv[++i]; + else if (a === "-h" || a === "--help") { + process.stdout.write( + "Usage: check-lockfile-policy.mjs [--base ] [--head ]\n" + + " Checks that pnpm-lock.yaml changes arrive in deps-scoped commits.\n", + ); + process.exit(0); + } + } + return args; +} + +function run(cmd) { + return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function tryRun(cmd) { + try { + return run(cmd); + } catch { + return null; + } +} + +function resolveRange(args) { + const head = args.head || process.env.GITHUB_SHA || "HEAD"; + let base = args.base; + if (!base) { + if (process.env.GITHUB_BASE_REF) { + base = `origin/${process.env.GITHUB_BASE_REF}`; + } else if (process.env.GITHUB_EVENT_BEFORE) { + base = process.env.GITHUB_EVENT_BEFORE; + } else { + // local default: compare against the default branch tip + base = + tryRun("git rev-parse --verify origin/main >/dev/null 2>&1 && echo origin/main") || null; + } + } + return { base, head }; +} + +function changedFiles(base, head) { + const out = tryRun(`git diff --name-only ${base}..${head}`); + if (!out) return null; + return new Set(out.split(/\r?\n/).filter(Boolean)); +} + +function commitSubjects(base, head) { + const out = tryRun(`git log --format=%s ${base}..${head}`); + if (!out) return []; + return out.split(/\r?\n/).filter(Boolean); +} + +function main() { + const args = parseArgs(process.argv); + const { base, head } = resolveRange(args); + + if (!base) { + console.warn("[lockfile-policy] could not determine a base ref; skipping."); + process.exit(2); + } + + // Make sure the base is locally resolvable (CI shallow clones). + const resolvedBase = tryRun(`git rev-parse --verify "${base}^{commit}"`); + if (!resolvedBase) { + const fetched = tryRun(`git fetch --no-tags --depth=1 origin ${base}`); + if (!fetched === false) { + console.warn(`[lockfile-policy] base ref ${base} not resolvable; skipping.`); + process.exit(2); + } + } + + const files = changedFiles(base, head); + if (!files) { + console.warn("[lockfile-policy] could not compute diff; skipping."); + process.exit(2); + } + + if (!files.has(LOCKFILE)) { + console.log(`[lockfile-policy] ${LOCKFILE} unchanged — policy satisfied.`); + process.exit(0); + } + + // Lockfile changed. Require either a deps-scoped commit or a change to a + // recognized dependency manifest in the same range. + const subjects = commitSubjects(base, head); + const hasDepsScope = subjects.some((s) => DEPS_SCOPE.test(s)); + const hasManifestChange = [...files].some((f) => DEPS_FILES.has(f)); + + if (hasDepsScope || hasManifestChange) { + console.log(`[lockfile-policy] ${LOCKFILE} changed with deps-scoped commit — OK.`); + process.exit(0); + } + + console.error( + `[lockfile-policy] ${LOCKFILE} changed but no commit in range is scoped "deps:" ` + + `and no manifest (${[...DEPS_FILES].join(", ")}) changed.\n` + + `Commit subjects in range:\n` + + subjects.map((s) => ` - ${s}`).join("\n") + + '\nEither re-scope the commit to start with "deps:" or explain the lockfile drift.', + ); + process.exit(1); +} + +main(); diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts new file mode 100644 index 00000000..c6edcaab --- /dev/null +++ b/src/agents/cache-trace.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { MayrosConfig } from "../config/config.js"; +import { createCacheTrace } from "./cache-trace.js"; + +/** + * cache-trace drift detection unit tests. + * + * These cover the system-prompt drift signal added to wrapStreamFn: when the + * same session sees a different system digest between consecutive stream calls, + * a `cache:system-drift` event is emitted so cache-breaking changes become + * visible instead of silently inflating token cost. + */ + +type TraceLine = { stage: string; systemDigest?: string; previousSystemDigest?: string }; + +function makeTrace(sessionKey: string, lines: TraceLine[]) { + return createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + includeSystem: true, + }, + }, + } as MayrosConfig, + env: {}, + sessionKey, + writer: { + filePath: "memory", + write: (line: string) => { + const parsed = JSON.parse(line) as TraceLine; + lines.push(parsed); + }, + }, + }); +} + +// Minimal StreamFn stub: returns an empty async iterable and records nothing. +function noopStreamFn() { + return (async function* () { + /* empty */ + })(); +} + +// The wrapper invokes streamFn(model, context, options). Build a minimal model +// stub and a typed context so tsgo accepts the call without constructing a full +// Model (which carries many fields irrelevant to drift detection). +type StreamCallContext = { system?: unknown; messages: unknown[] }; +function callWrapped( + wrapped: ReturnType>["wrapStreamFn"]> | undefined, + system: unknown, +) { + const model = { id: "m", provider: "anthropic" } as unknown as Parameters< + NonNullable + >[0]; + const context = { system, messages: [] } as StreamCallContext as Parameters< + NonNullable + >[1]; + wrapped?.(model, context, {}); +} + +describe("cache-trace drift detection", () => { + it("does not emit a drift event on the first stream call for a session", () => { + const lines: TraceLine[] = []; + const trace = makeTrace("sess-first", lines); + const wrapped = trace?.wrapStreamFn(noopStreamFn as never); + + callWrapped(wrapped, "system-A"); + + const drifts = lines.filter((l) => l.stage === "cache:system-drift"); + expect(drifts).toHaveLength(0); + }); + + it("emits a drift event when the system prompt changes between two calls of the same session", () => { + const lines: TraceLine[] = []; + const trace = makeTrace("sess-drift", lines); + const wrapped = trace?.wrapStreamFn(noopStreamFn as never); + + callWrapped(wrapped, "system-A"); + callWrapped(wrapped, "system-B"); + + const drifts = lines.filter((l) => l.stage === "cache:system-drift"); + expect(drifts).toHaveLength(1); + expect(drifts[0]?.previousSystemDigest).toBeDefined(); + expect(drifts[0]?.systemDigest).toBeDefined(); + expect(drifts[0]?.previousSystemDigest).not.toBe(drifts[0]?.systemDigest); + }); + + it("does not emit a drift event when the system prompt stays stable across calls", () => { + const lines: TraceLine[] = []; + const trace = makeTrace("sess-stable", lines); + const wrapped = trace?.wrapStreamFn(noopStreamFn as never); + + callWrapped(wrapped, "same-system"); + callWrapped(wrapped, "same-system"); + callWrapped(wrapped, "same-system"); + + const drifts = lines.filter((l) => l.stage === "cache:system-drift"); + expect(drifts).toHaveLength(0); + }); + + it("tracks sessions independently", () => { + const linesA: TraceLine[] = []; + const linesB: TraceLine[] = []; + const traceA = makeTrace("sess-a", linesA); + const traceB = makeTrace("sess-b", linesB); + + // Session A and B both start with the same system, then A changes. + callWrapped(traceA?.wrapStreamFn(noopStreamFn as never), "shared-system"); + callWrapped(traceB?.wrapStreamFn(noopStreamFn as never), "shared-system"); + callWrapped(traceA?.wrapStreamFn(noopStreamFn as never), "changed-system"); + + const driftsA = linesA.filter((l) => l.stage === "cache:system-drift"); + const driftsB = linesB.filter((l) => l.stage === "cache:system-drift"); + expect(driftsA).toHaveLength(1); + expect(driftsB).toHaveLength(0); + }); +}); diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index cc9a6786..9da1fc3d 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -15,6 +15,7 @@ export type CacheTraceStage = | "prompt:before" | "prompt:images" | "stream:context" + | "cache:system-drift" | "session:after"; export type CacheTraceEvent = { @@ -38,6 +39,8 @@ export type CacheTraceEvent = { messageFingerprints?: string[]; messagesDigest?: string; systemDigest?: string; + /** Previous system digest for the same session, when a drift is detected. */ + previousSystemDigest?: string; note?: string; error?: string; }; @@ -74,6 +77,14 @@ type CacheTraceWriter = QueuedFileWriter; const writers = new Map(); +/** + * Last system digest seen per session key, so we can detect when the system + * prompt drifts between consecutive turns of the same session. A drift with no + * operator-triggered change (model, thinking, channel) is the signature of a + * cache-breaking bug. Keyed by sessionKey (or sessionId as fallback). + */ +const lastSystemDigestBySession = new Map(); + function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig { const env = params.env ?? process.env; const config = params.cfg?.diagnostics?.cacheTrace; @@ -222,6 +233,9 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { if (payload.error) { event.error = payload.error; } + if (payload.previousSystemDigest !== undefined) { + event.previousSystemDigest = payload.previousSystemDigest; + } const line = safeJsonStringify(event); if (!line) { @@ -232,16 +246,37 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { const wrapStreamFn: CacheTrace["wrapStreamFn"] = (streamFn) => { const wrapped: StreamFn = (model, context, options) => { + const system = (context as { system?: unknown }).system; + const messages = (context as { messages?: AgentMessage[] }).messages ?? []; recordStage("stream:context", { model: { id: model?.id, provider: model?.provider, api: model?.api, }, - system: (context as { system?: unknown }).system, - messages: (context as { messages?: AgentMessage[] }).messages ?? [], + system, + messages, options: (options ?? {}) as Record, }); + + // Detect system-prompt drift across turns of the same session. A + // different digest with no operator-triggered change (model/thinking) + // is the signature of a cache-breaking bug, per the Prompt Cache + // Discipline policy in AGENTS.md. + if (system !== undefined && params.sessionKey) { + const currentDigest = digest(system); + const previous = lastSystemDigestBySession.get(params.sessionKey); + if (previous !== undefined && previous !== currentDigest) { + recordStage("cache:system-drift", { + system, + systemDigest: currentDigest, + previousSystemDigest: previous, + note: "system prompt changed between turns without a session restart; investigate if no operator change triggered this", + }); + } + lastSystemDigestBySession.set(params.sessionKey, currentDigest); + } + return streamFn(model, context, options); }; return wrapped;