Files
OmniRoute/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts
Moseyuh333 b9d18dd8c4 Continue fix bugs and upgrade skill_collector (#6294)
* fix(skills): gate skill-collector CLI detection behind management auth + loopback

PR #6294 fork-main bundled genuinely new skill-collector CLI-detection routes
(GET /api/skills/collect/detect, POST /api/skills/collect/install) on top of
content already shipped via #6186. This reconstructs the PR against the
current release tip, keeping only the new detect/install routes and their
SKILL.md, and drops the 3 already-merged commits so two post-merge quality
fixes on /api/github-skills (Zod validation + sanitizeErrorMessage) are not
reverted.

- GET /api/skills/collect/detect spawned a child process per CLI_TOOL_IDS
  entry via getCliRuntimeStatus(), unauthenticated and reachable over any
  tunnel. All 3 routes (github-skills GET/POST, skills/collect/detect,
  skills/collect/install) now require requireManagementAuth(), matching
  every sibling /api/skills/* route.
- Classified /api/skills/collect/ in LOCAL_ONLY_API_PREFIXES and
  SPAWN_CAPABLE_PREFIXES (routeGuard.ts / spawnCapablePrefixes.ts) and added
  src/app/api/skills/collect to SPAWN_CAPABLE_ROUTE_ROOTS in
  check-route-guard-membership.ts so the automated gate actually scans it
  (Hard Rules #15 + #17).
- omniroute_github_skills_install MCP tool now reports the honest
  action: "planned" instead of "installed", matching the REST route.
- Dropped docker-compose.drive-d.yml, start.sh, and the unrelated
  @types/node/settings.ts changes (personal dev-machine / out-of-scope).
- Added route-level tests for all 3 routes + the 3 MCP tools (auth-required
  and no-stack-trace-leak assertions) and a route-guard regression test.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(quality): register new routeGuard covering test in stryker.conf.json

check:mutation-test-coverage --strict (Fast Quality Gates) flagged
tests/unit/authz/route-guard-skills-collect.test.ts as a covering unit test
for src/server/authz/routeGuard.ts that was missing from tap.testFiles, so
its mutant kills would silently not count toward the mutation-test baseline.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore: resync CHANGELOG after merging release/v3.8.47

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Moseyuh333 <Moseyuh333@users.noreply.github.com>
2026-07-09 19:43:43 -03:00

91 lines
3.8 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const VALIDATION_DIR = join(ROOT, "src/shared/validation");
function walkTsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTsFiles(full));
else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
/**
* Returns the VALUE-import specifiers in `src` (i.e. imports that survive to the
* runtime bundle). `import type …` is excluded — it is erased by the compiler/SWC and
* never creates a webpack bundle edge, so it cannot leak a Node-only dep into a
* client bundle.
*/
function valueImportSpecifiers(src: string): string[] {
const specs: string[] = [];
// import [type] [<clause> from] "<spec>" — `[^"';]*?` spans multi-line clauses.
const re = /\bimport\s+(type\s+)?(?:[^"';]*?\bfrom\s*)?["']([^"']+)["']/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src))) {
if (m[1]) continue; // `import type …` — erased, not a runtime edge
specs.push(m[2]);
}
return specs;
}
// Regression guard for the dast-smoke "Build CLI bundle" failure:
// Module not found: Can't resolve 'dns' / 'net' (./node_modules/ioredis/...)
// Root cause: `settingsSchemas.ts` VALUE-imported SPAWN_CAPABLE_PREFIXES from
// `@/server/authz/routeGuard`, whose server runtime (runtimeSettings → localDb →
// apiKeys → rateLimiter → ioredis) then got dragged into the client/CLI webpack bundle
// via the dashboard onboarding wizard → validation barrel chain. Validation schemas are
// client-reachable and MUST depend only on zod + `@/shared` leaves — never on the
// server (`@/server/…`) or server-side lib (`@/lib/…`, which reaches the DB/ioredis).
const FORBIDDEN_VALUE_ROOTS = ["@/server/", "@/lib/"];
test("validation schemas must not VALUE-import from server-side roots (client/CLI build safety)", () => {
const offenders: string[] = [];
for (const file of walkTsFiles(VALIDATION_DIR)) {
const rel = file.slice(ROOT.length + 1);
for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) {
if (FORBIDDEN_VALUE_ROOTS.some((root) => spec.startsWith(root))) {
offenders.push(`${rel}${spec}`);
}
}
}
assert.deepEqual(
offenders,
[],
"Client-reachable validation schemas VALUE-import server-side modules, which drags the " +
"server runtime (→ ioredis) into the browser/CLI bundle and breaks the Next build with " +
`"Can't resolve 'dns'/'net'". Move the needed value to a server-free @/shared/constants ` +
`leaf (see src/shared/constants/spawnCapablePrefixes.ts). Offenders:\n ${offenders.join("\n ")}`
);
});
test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with the expected entries", () => {
assert.ok(Array.isArray(SPAWN_CAPABLE_PREFIXES));
// The full deny-list survived the extraction out of routeGuard.ts (Hard Rules #15/#17).
for (const prefix of [
"/api/cli-tools/runtime/",
"/api/services/",
"/api/tools/agent-bridge/",
"/api/tools/traffic-inspector/",
"/api/plugins/",
"/api/local/",
"/api/skills/collect/",
"/api/headroom/start",
"/api/headroom/stop",
]) {
assert.ok(
SPAWN_CAPABLE_PREFIXES.includes(prefix),
`SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction`
);
}
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 9);
});