mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
Compare commits
2 Commits
release/v3
...
feat/docke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf85fb3f0 | ||
|
|
ebcbdc771d |
@@ -143,6 +143,8 @@ PORT=20128
|
||||
# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict
|
||||
# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it.
|
||||
# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing.
|
||||
# Docker: pass it as a build arg (`docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode`);
|
||||
# setting it on an already-built server or image does nothing.
|
||||
# DASHBOARD_ALLOW_EMBED=vscode
|
||||
|
||||
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -140,6 +140,18 @@ ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
|
||||
# #10273: the dashboard's `frame-ancestors` policy is compiled into the route
|
||||
# manifest by next.config.mjs (via scripts/build/dashboardEmbed.mjs), so it is
|
||||
# fixed when the image is built and cannot be flipped with `-e` on a running
|
||||
# container. Build with `--build-arg DASHBOARD_ALLOW_EMBED=vscode` to produce an
|
||||
# image whose HTML pages may be framed by the VS Code Simple Browser
|
||||
# (OmniCopilot's `dashboardOpen: "editor"`). Unset — the default — keeps every
|
||||
# route on `frame-ancestors 'none'` + X-Frame-Options: DENY. Builder-stage only:
|
||||
# the runner stage deliberately does not carry it, because a runtime value would
|
||||
# suggest an effect it cannot have.
|
||||
ARG DASHBOARD_ALLOW_EMBED=""
|
||||
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
|
||||
|
||||
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
|
||||
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
|
||||
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(docker):** `DASHBOARD_ALLOW_EMBED` is now a Docker build argument — `docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` produces an image whose dashboard renders inside the VS Code Simple Browser (OmniCopilot's `dashboardOpen: "editor"`). Previously the flag was only reachable from a source build: Docker silently drops a `--build-arg` with no matching `ARG`, so the operator got the default image and no error. Builder-stage only and empty by default — the runtime stages deliberately do not carry it, and the unframable default posture is unchanged ([#10701](https://github.com/diegosouzapw/OmniRoute/pull/10701))
|
||||
@@ -6,16 +6,16 @@ lastUpdated: 2026-08-18
|
||||
|
||||
# VS Code Copilot Chat — OmniCopilot extension
|
||||
|
||||
**OmniCopilot** puts every model your OmniRoute serves into the *native* GitHub Copilot Chat
|
||||
**OmniCopilot** puts every model your OmniRoute serves into the _native_ GitHub Copilot Chat
|
||||
model picker. No second sidebar, no separate chat UI — Copilot's agent mode, tool calling,
|
||||
MCP servers and custom instructions all keep working, just running on the model you pick.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Install (VS Code)** | [Marketplace → `diegosouzapw.omnicopilot`](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) |
|
||||
| **Install (forks)** | [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro |
|
||||
| **Source / issues** | [github.com/diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) (MIT) |
|
||||
| **Requires** | VS Code 1.104+ |
|
||||
| | |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Install (VS Code)** | [Marketplace → `diegosouzapw.omnicopilot`](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) |
|
||||
| **Install (forks)** | [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro |
|
||||
| **Source / issues** | [github.com/diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) (MIT) |
|
||||
| **Requires** | VS Code 1.104+ |
|
||||
|
||||
> **No Copilot subscription needed.** Since VS Code 1.122 a language-model provider works
|
||||
> without a GitHub sign-in and without any Copilot plan. Inline completions and
|
||||
@@ -60,7 +60,7 @@ The extension requests **`GET /v1/models?prefix=alias`** so one id arrives per m
|
||||
changing the server-wide setting for your other clients. On a reference instance this collapsed
|
||||
**2345 entries to 1396 — 949 duplicates, zero models lost.**
|
||||
|
||||
If you would rather fix it server-wide for *every* client, set the
|
||||
If you would rather fix it server-wide for _every_ client, set the
|
||||
`MODELS_CATALOG_PREFIX_MODE` feature flag to `alias` in the dashboard. See
|
||||
[API_REFERENCE → prefix](../reference/API_REFERENCE.md#model-id-prefixes-prefix) for the
|
||||
query parameter and the warning about `canonical`.
|
||||
@@ -81,7 +81,7 @@ and OmniRoute translates those for `/v1/chat/completions`, so they are perfectly
|
||||
|
||||
### Providers you never configured
|
||||
|
||||
The catalog lists models from providers with an **active connection** *plus* every **noAuth**
|
||||
The catalog lists models from providers with an **active connection** _plus_ every **noAuth**
|
||||
provider — the keyless ones that make up much of the free tier. That is intentional. To hide
|
||||
them, add them to `blockedProviders` in the dashboard settings; nothing changes in the
|
||||
extension.
|
||||
@@ -108,11 +108,11 @@ DASHBOARD_ALLOW_EMBED=vscode npm run build # or npm run build:release
|
||||
npm start
|
||||
```
|
||||
|
||||
| How you installed | Can you enable embedding? |
|
||||
| --- | --- |
|
||||
| From source | ✅ set the variable on the build command, as above |
|
||||
| `npm install -g omniroute` | ❌ the published package ships a prebuilt bundle — build from source instead |
|
||||
| Docker image | ❌ the official image has no build arg for it — build your own from the `Dockerfile` with the variable set |
|
||||
| How you installed | Can you enable embedding? |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| From source | ✅ set the variable on the build command, as above |
|
||||
| `npm install -g omniroute` | ❌ the published package ships a prebuilt bundle — build from source instead |
|
||||
| Docker image | ✅ `docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode -t omniroute:embed .` — the prebuilt image on Docker Hub is not embed-enabled |
|
||||
|
||||
Without an embed-enabled build the page refuses to frame, the extension detects that from the
|
||||
response headers and falls back to the external browser — nothing breaks, and it says so once.
|
||||
@@ -133,14 +133,14 @@ Kilo and Roo — the same configs described in
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| No OmniRoute models in the picker | Server unreachable. The status-bar dot goes grey; run `OmniRoute: Check Connection`. Discovery is silent by design and contributes no models rather than prompting. |
|
||||
| Every model appears twice | You are on an OmniCopilot older than 1.0.1 — update. The extension now requests `?prefix=alias`. |
|
||||
| An image/audio model used to be listed and is gone | Intentional since 1.0.1 — it could never answer a chat request. |
|
||||
| Panel missing from the Activity Bar | VS Code moves extra view containers into the **"…"** overflow at the bottom of the Activity Bar, and a container hidden via right-click stays hidden. Right-click the Activity Bar → tick **OmniRoute**, or open it with `OmniRoute: Manage Connection`. |
|
||||
| Dashboard opens in the browser despite `editor` mode | The server was not **built** with `DASHBOARD_ALLOW_EMBED=vscode` (see above) — setting it at startup on a prebuilt install does nothing. The fallback is deliberate. |
|
||||
| Models list is stale after changing providers | `OmniRoute: Refresh Models`, or the ↻ link in the panel. |
|
||||
| Symptom | Cause / fix |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No OmniRoute models in the picker | Server unreachable. The status-bar dot goes grey; run `OmniRoute: Check Connection`. Discovery is silent by design and contributes no models rather than prompting. |
|
||||
| Every model appears twice | You are on an OmniCopilot older than 1.0.1 — update. The extension now requests `?prefix=alias`. |
|
||||
| An image/audio model used to be listed and is gone | Intentional since 1.0.1 — it could never answer a chat request. |
|
||||
| Panel missing from the Activity Bar | VS Code moves extra view containers into the **"…"** overflow at the bottom of the Activity Bar, and a container hidden via right-click stays hidden. Right-click the Activity Bar → tick **OmniRoute**, or open it with `OmniRoute: Manage Connection`. |
|
||||
| Dashboard opens in the browser despite `editor` mode | The server was not **built** with `DASHBOARD_ALLOW_EMBED=vscode` (see above) — setting it at startup on a prebuilt install does nothing. The fallback is deliberate. |
|
||||
| Models list is stale after changing providers | `OmniRoute: Refresh Models`, or the ↻ link in the panel. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
||||
| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). |
|
||||
| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs`, `scripts/docker/ensure-docker-base-path.mjs` | URL subpath for serving OmniRoute behind a reverse proxy (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. In Docker the value is baked during `docker build` (`ARG OMNIROUTE_BASE_PATH`); pre-built root images can apply a different runtime value once at container start before Next.js boots. Set `NEXT_PUBLIC_BASE_URL` to the public origin including the same subpath. |
|
||||
| `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` | _(empty = root)_ | `src/shared/hooks/useDisplayBaseUrl.ts` | Browser-visible mirror of `OMNIROUTE_BASE_PATH`, inlined at build time so the dashboard endpoint display shows `https://host/omniroute/v1` instead of `https://host/v1`. Falls back to `OMNIROUTE_BASE_PATH` when unset. Rebuild after changing (Next `basePath` is build-time). |
|
||||
| `DASHBOARD_ALLOW_EMBED` | _(unset = never framable)_ | `next.config.mjs`, `scripts/build/dashboardEmbed.mjs` | Opt-in iframe embedding of the HTML pages. Unset, every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`. Set to `vscode` to serve the pages (dashboard, login, docs, landing) with `frame-ancestors 'self' vscode-webview:` and no `X-Frame-Options`, so the VS Code Simple Browser can render them (OmniCopilot's `dashboardOpen: "editor"` mode). The API surface (`/api`, `/v1`, `/v1beta`, `/a2a`, `/healthz`, root-level aliases) keeps the strict headers either way. Only `vscode` is recognised — `1`/`true` do not enable it. Build-time: rebuild after changing. |
|
||||
| `DASHBOARD_ALLOW_EMBED` | _(unset = never framable)_ | `next.config.mjs`, `scripts/build/dashboardEmbed.mjs` | Opt-in iframe embedding of the HTML pages. Unset, every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`. Set to `vscode` to serve the pages (dashboard, login, docs, landing) with `frame-ancestors 'self' vscode-webview:` and no `X-Frame-Options`, so the VS Code Simple Browser can render them (OmniCopilot's `dashboardOpen: "editor"` mode). The API surface (`/api`, `/v1`, `/v1beta`, `/a2a`, `/healthz`, root-level aliases) keeps the strict headers either way. Only `vscode` is recognised — `1`/`true` do not enable it. Build-time: rebuild after changing (`docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` for images; setting it on a prebuilt install has no effect). |
|
||||
| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. |
|
||||
| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. |
|
||||
| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. |
|
||||
|
||||
@@ -28,16 +28,11 @@
|
||||
* OMNIROUTE_SMOKE_API_KEY sent as Authorization: Bearer when the gateway requires auth
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
buildRemoteSteps,
|
||||
classifyInstallOutcome,
|
||||
evaluateSmoke,
|
||||
planCanaryDeploy,
|
||||
} from "./deployCanary.ts";
|
||||
import { buildRemoteSteps, evaluateSmoke, planCanaryDeploy } from "./deployCanary.ts";
|
||||
import { makeGitAncestryProbe, readBuildSha } from "../build/buildProvenance.ts";
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -66,22 +61,6 @@ function run(step) {
|
||||
return execFileSync(command, rest, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `run`, but never throws: returns the exit code plus both streams. Used for the
|
||||
* install, whose exit code does not decide the outcome (see classifyInstallOutcome) and
|
||||
* whose stderr must reach the log — it used to be swallowed by execFileSync throwing.
|
||||
*/
|
||||
function runCapturing(step) {
|
||||
console.log(`\n▶ ${step.name}: ${step.description}`);
|
||||
const [command, ...rest] = step.argv;
|
||||
const result = spawnSync(command, rest, { encoding: "utf8" });
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: (result.stdout || "").trim(),
|
||||
stderr: (result.stderr || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function probeHealth(baseUrl) {
|
||||
try {
|
||||
const response = await fetch(new URL("/api/monitoring/health", baseUrl), {
|
||||
@@ -131,9 +110,8 @@ if (args.models.length === 0) {
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const localBuildSha = readBuildSha(repoRoot);
|
||||
const plan = planCanaryDeploy({
|
||||
buildSha: localBuildSha,
|
||||
buildSha: readBuildSha(repoRoot),
|
||||
isAncestorOfRelease: makeGitAncestryProbe(
|
||||
process.env.OMNIROUTE_RELEASE_REF || "origin/main",
|
||||
repoRoot
|
||||
@@ -169,23 +147,7 @@ try {
|
||||
console.log(`\n▶ upload: ${args.tarball} → ${args.host}:${remoteTarball}`);
|
||||
execFileSync("scp", [args.tarball, `${args.host}:${remoteTarball}`], { stdio: "inherit" });
|
||||
|
||||
const installResult = runCapturing(install);
|
||||
const outcome = classifyInstallOutcome({
|
||||
exitCode: installResult.exitCode,
|
||||
stderr: installResult.stderr,
|
||||
installedSha: run(verify),
|
||||
expectedSha: localBuildSha,
|
||||
});
|
||||
if (!outcome.installed) {
|
||||
if (installResult.stderr) console.error(installResult.stderr);
|
||||
fail(`install did not land: ${outcome.reason}`);
|
||||
}
|
||||
if (outcome.kind === "installed-with-cleanup-failure") {
|
||||
console.warn(` ⚠️ ${outcome.reason}`);
|
||||
} else {
|
||||
console.log(` ${outcome.reason}`);
|
||||
}
|
||||
|
||||
run(install);
|
||||
run(restart);
|
||||
|
||||
const installedSha = run(verify);
|
||||
|
||||
@@ -152,80 +152,3 @@ export function buildRemoteSteps(input: RemoteStepsInput): RemoteStep[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export type InstallOutcomeInput = {
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
/** BUILD_SHA read back from the installed package AFTER the install ran. */
|
||||
installedSha: string | null | undefined;
|
||||
/** BUILD_SHA of the artifact being shipped. */
|
||||
expectedSha: string;
|
||||
};
|
||||
|
||||
export type InstallOutcome = {
|
||||
installed: boolean;
|
||||
kind: "installed" | "installed-with-cleanup-failure" | "failed";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether the global install actually landed.
|
||||
*
|
||||
* The exit code alone is not trustworthy in either direction:
|
||||
*
|
||||
* - `npm install -g` on the .17 gateway writes the whole package and *then* fails renaming
|
||||
* the old tree into its staging directory (`ENOTEMPTY`, exit 217). Treating that as a
|
||||
* failure aborts the deploy after the artifact is already on disk — which happened twice
|
||||
* on 2026-08-18, each time leaving the host with new files and an old running process.
|
||||
* - The 2026-08-14 outage went the other way: the install exited 0 while shipping a package
|
||||
* built from the wrong branch.
|
||||
*
|
||||
* So the SHA on disk decides, and it must match exactly. An absent or unreadable SHA fails
|
||||
* closed — an artifact that cannot be identified is never attested (same rule as the
|
||||
* provenance gate).
|
||||
*/
|
||||
export function classifyInstallOutcome(input: InstallOutcomeInput): InstallOutcome {
|
||||
const { exitCode, stderr, installedSha, expectedSha } = input;
|
||||
const onDisk = (installedSha ?? "").trim();
|
||||
|
||||
if (!onDisk) {
|
||||
return {
|
||||
installed: false,
|
||||
kind: "failed",
|
||||
reason: "no BUILD_SHA could be read from the installed package after the install",
|
||||
};
|
||||
}
|
||||
if (onDisk !== expectedSha) {
|
||||
return {
|
||||
installed: false,
|
||||
kind: "failed",
|
||||
reason: `installed BUILD_SHA is ${onDisk}, expected ${expectedSha}`,
|
||||
};
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
return { installed: true, kind: "installed", reason: `installed ${onDisk}` };
|
||||
}
|
||||
|
||||
const staging = orphanStagingDirFromStderr(stderr);
|
||||
const enotempty = /ENOTEMPTY/.test(stderr);
|
||||
return {
|
||||
installed: true,
|
||||
kind: "installed-with-cleanup-failure",
|
||||
reason:
|
||||
`npm exited ${exitCode} but ${onDisk} is on disk — the package installed and npm failed ` +
|
||||
`during its own cleanup${enotempty ? " (ENOTEMPTY on the staging rename)" : ""}` +
|
||||
(staging ? `; orphaned staging dir left behind: ${staging}` : ""),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staging directory npm failed to rename into, if it named one. It blocks the NEXT
|
||||
* install with the same error (npm reuses the name), so the operator has to clear it —
|
||||
* surfacing the exact path is the whole point. Deliberately not removed automatically:
|
||||
* this is a path under /usr/lib and a blind `rm -rf` there is not something a deploy
|
||||
* script should do on its own.
|
||||
*/
|
||||
export function orphanStagingDirFromStderr(stderr: string): string | null {
|
||||
const match = /npm error dest (\/\S*\/\.\S+)/.exec(stderr || "");
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BatchRecord } from "@/lib/db/batches";
|
||||
import type { FileRecord } from "@/lib/db/files";
|
||||
import { BatchRecord } from "@/lib/db/batches";
|
||||
import { FileRecord } from "@/lib/db/files";
|
||||
|
||||
export function mapBatchApiToRecord(b: any): BatchRecord {
|
||||
return {
|
||||
|
||||
@@ -6,8 +6,8 @@ import FilesListTab from "../FilesListTab";
|
||||
import FilesConceptCard from "../components/FilesConceptCard";
|
||||
import UploadFileModal from "../components/UploadFileModal";
|
||||
import { mapFileApiToRecord, mapBatchApiToRecord } from "../batch-utils";
|
||||
import type { FileRecord } from "@/lib/db/files";
|
||||
import type { BatchRecord } from "@/lib/db/batches";
|
||||
import { FileRecord } from "@/lib/db/files";
|
||||
import { BatchRecord } from "@/lib/db/batches";
|
||||
|
||||
export default function BatchFilesPage() {
|
||||
const t = useTranslations("common");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BatchListTab from "./BatchListTab";
|
||||
import type { FileRecord } from "@/lib/db/files";
|
||||
import type { BatchRecord } from "@/lib/db/batches";
|
||||
import { FileRecord } from "@/lib/db/files";
|
||||
import { BatchRecord } from "@/lib/db/batches";
|
||||
import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils";
|
||||
import BatchConceptCard from "./components/BatchConceptCard";
|
||||
import NewBatchWizard from "./components/NewBatchWizard";
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
classifyInstallOutcome,
|
||||
orphanStagingDirFromStderr,
|
||||
} from "../../scripts/ops/deployCanary.ts";
|
||||
|
||||
/**
|
||||
* `npm install -g <tarball>` on the .17 gateway finishes writing the package and then fails
|
||||
* while renaming the old tree into its staging directory:
|
||||
*
|
||||
* npm error code ENOTEMPTY
|
||||
* npm error syscall rename
|
||||
* npm error path /usr/lib/node_modules/omniroute
|
||||
* npm error dest /usr/lib/node_modules/.omniroute-h797OOZa
|
||||
*
|
||||
* Exit status is 217, but `dist/BUILD_SHA`, the package version and every dependency are the
|
||||
* new ones. The canary treated the non-zero exit as "install failed", aborted before the
|
||||
* restart, and discarded npm's stderr — so the deploy stopped half-done twice (2026-08-18)
|
||||
* with no clue in the log about why.
|
||||
*
|
||||
* The exit code is not the source of truth here; the SHA on disk is. These cases pin that,
|
||||
* including the inverse trap: a ZERO exit that installed the wrong artifact must still fail.
|
||||
*/
|
||||
|
||||
const ENOTEMPTY_STDERR = [
|
||||
"npm warn deprecated boolean@3.2.0: Package no longer supported.",
|
||||
"npm error code ENOTEMPTY",
|
||||
"npm error syscall rename",
|
||||
"npm error path /usr/lib/node_modules/omniroute",
|
||||
"npm error dest /usr/lib/node_modules/.omniroute-h797OOZa",
|
||||
"npm error ENOTEMPTY: directory not empty, rename '/usr/lib/node_modules/omniroute' -> " +
|
||||
"'/usr/lib/node_modules/.omniroute-h797OOZa'",
|
||||
].join("\n");
|
||||
|
||||
test("non-zero exit with the expected SHA on disk is a cleanup failure, not an install failure", () => {
|
||||
const outcome = classifyInstallOutcome({
|
||||
exitCode: 217,
|
||||
stderr: ENOTEMPTY_STDERR,
|
||||
installedSha: "22b89a273b",
|
||||
expectedSha: "22b89a273b",
|
||||
});
|
||||
assert.equal(outcome.installed, true, "the artifact is on disk — the deploy must continue");
|
||||
assert.equal(outcome.kind, "installed-with-cleanup-failure");
|
||||
assert.match(outcome.reason, /ENOTEMPTY|cleanup/i);
|
||||
});
|
||||
|
||||
test("a clean install is reported as such", () => {
|
||||
const outcome = classifyInstallOutcome({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
installedSha: "22b89a273b",
|
||||
expectedSha: "22b89a273b",
|
||||
});
|
||||
assert.equal(outcome.installed, true);
|
||||
assert.equal(outcome.kind, "installed");
|
||||
});
|
||||
|
||||
test("non-zero exit with a stale SHA is a real failure", () => {
|
||||
const outcome = classifyInstallOutcome({
|
||||
exitCode: 217,
|
||||
stderr: ENOTEMPTY_STDERR,
|
||||
installedSha: "e05ac345da",
|
||||
expectedSha: "22b89a273b",
|
||||
});
|
||||
assert.equal(outcome.installed, false);
|
||||
assert.equal(outcome.kind, "failed");
|
||||
});
|
||||
|
||||
test("a ZERO exit that left the wrong artifact still fails", () => {
|
||||
// The 2026-08-14 outage shipped a package built from the wrong branch. An install that
|
||||
// "succeeds" while the SHA does not match must never be waved through.
|
||||
const outcome = classifyInstallOutcome({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
installedSha: "178febc50f",
|
||||
expectedSha: "22b89a273b",
|
||||
});
|
||||
assert.equal(outcome.installed, false);
|
||||
assert.equal(outcome.kind, "failed");
|
||||
});
|
||||
|
||||
test("an unreadable SHA fails closed", () => {
|
||||
for (const installedSha of ["", null, undefined]) {
|
||||
const outcome = classifyInstallOutcome({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
installedSha: installedSha as string | null,
|
||||
expectedSha: "22b89a273b",
|
||||
});
|
||||
assert.equal(outcome.installed, false, `installedSha=${JSON.stringify(installedSha)}`);
|
||||
assert.equal(outcome.kind, "failed");
|
||||
}
|
||||
});
|
||||
|
||||
test("the orphaned staging directory is extracted so the operator can clear it", () => {
|
||||
assert.equal(
|
||||
orphanStagingDirFromStderr(ENOTEMPTY_STDERR),
|
||||
"/usr/lib/node_modules/.omniroute-h797OOZa"
|
||||
);
|
||||
});
|
||||
|
||||
test("no staging directory is invented when npm did not report one", () => {
|
||||
assert.equal(orphanStagingDirFromStderr(""), null);
|
||||
assert.equal(orphanStagingDirFromStderr("npm error code EACCES"), null);
|
||||
});
|
||||
@@ -5,52 +5,38 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* #10692: a `"use client"` page reached the SQLite driver through
|
||||
* `serviceKindIndex → mediaServiceKinds → imageRegistry → aihorde/imageModels →
|
||||
* aihordeImageCatalog → safeOutboundFetch → proxyFetch → featureFlags → db/core`, so the
|
||||
* production build tried to bundle `fs`/`net`/`tls` for the browser and failed with 28
|
||||
* `Module not found` errors (`Build App` red for 60 consecutive runs).
|
||||
* #10692: `src/app/(dashboard)/dashboard/providers/page.tsx` is a `"use client"` page.
|
||||
* Through `serviceKindIndex → mediaServiceKinds → imageRegistry → aihorde/imageModels →
|
||||
* aihordeImageCatalog → safeOutboundFetch → proxyFetch → featureFlags → db/core` it reached
|
||||
* the SQLite driver, so the production build tried to bundle `fs`/`net`/`tls` for the browser
|
||||
* and failed with 28 `Module not found` errors (`Build App` red for 60 consecutive runs).
|
||||
*
|
||||
* `serviceKindIndex.ts` had stated the invariant in a comment — *"Client-safe:
|
||||
* `mediaServiceKinds` only pulls in the pure-data media registries (no server-only deps)"* —
|
||||
* and a comment cannot fail a build, so #10542 broke it unnoticed.
|
||||
* `serviceKindIndex.ts` already documents the invariant this guard enforces:
|
||||
* "Client-safe: `mediaServiceKinds` only pulls in the pure-data media registries
|
||||
* (no server-only deps)."
|
||||
*
|
||||
* This walks the real static-import graph, the same edges the bundler follows, from EVERY
|
||||
* `"use client"` file in the repo rather than a hand-picked pair.
|
||||
*
|
||||
* Two deliberate exclusions, both load-bearing:
|
||||
*
|
||||
* - **`import type` is not an edge.** TypeScript erases it before the bundler sees it. A scan
|
||||
* that counts type imports reports 26 phantom leaks against 2 real ones here — a guard that
|
||||
* cries wolf gets switched off.
|
||||
* - **Dynamic `import()` is not followed.** It does not actually break a bundle edge (that was
|
||||
* tried for #10692 and failed), but it does move the module into a chunk the browser only
|
||||
* fetches on demand, which is a legitimate boundary for a lazily-used server path.
|
||||
* That was a comment, so nothing stopped #10542 from breaking it. This walks the real
|
||||
* static-import graph instead — the same edges the bundler follows. Dynamic `import()` is
|
||||
* deliberately NOT followed: deferring a server-only module behind one is exactly how the
|
||||
* leak is fixed, and the bundler splits it into a chunk the browser never loads.
|
||||
*/
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
/** Modules that pull in Node builtins (fs/net/tls) and must never be statically reachable. */
|
||||
const SERVER_ONLY = new Set([
|
||||
"src/lib/db/core.ts",
|
||||
"src/lib/db/adapters/driverFactory.ts",
|
||||
"src/lib/db/adapters/sqljsAdapter.ts",
|
||||
"src/lib/db/migrationRunner.ts",
|
||||
"open-sse/utils/proxyFetch.ts",
|
||||
"open-sse/utils/tlsClient.ts",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Non-`"use client"` entry points that still end up in a client bundle because client
|
||||
* components import them. Kept explicit so the original #10692 chain stays pinned even if the
|
||||
* page that exposed it is refactored.
|
||||
*/
|
||||
const EXTRA_ENTRIES = [
|
||||
/** Entry points that end up in a client bundle and must stay free of server-only code. */
|
||||
const CLIENT_SAFE_ENTRIES = [
|
||||
"src/lib/providers/serviceKindIndex.ts",
|
||||
"open-sse/config/mediaServiceKinds.ts",
|
||||
];
|
||||
|
||||
/** Modules that pull in Node builtins (fs/net/tls) and must never be statically reachable. */
|
||||
const SERVER_ONLY = [
|
||||
"src/lib/db/core.ts",
|
||||
"src/lib/db/adapters/driverFactory.ts",
|
||||
"src/lib/db/adapters/sqljsAdapter.ts",
|
||||
"open-sse/utils/proxyFetch.ts",
|
||||
];
|
||||
|
||||
const EXTENSIONS = [".ts", ".tsx", ".mts", ".js"];
|
||||
const SKIP_DIRS = new Set(["node_modules", ".git", ".build", "dist", ".next", ".claude"]);
|
||||
|
||||
/** Resolve an import specifier to a repo-relative file, or null when it leaves the repo. */
|
||||
function resolveSpecifier(fromFile: string, specifier: string): string | null {
|
||||
@@ -82,105 +68,53 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when the import clause contributes no runtime binding (pure `import type`). */
|
||||
function isTypeOnlyClause(clause: string): boolean {
|
||||
if (/^\s*type\s/.test(clause)) return true;
|
||||
const named = /\{([^}]*)\}/.exec(clause);
|
||||
if (!named) return false;
|
||||
// `import Default, { type A }` still emits an edge for the default binding.
|
||||
const outsideBraces = clause.replace(/\{[^}]*\}/, "").trim();
|
||||
if (/[A-Za-z_$*]/.test(outsideBraces)) return false;
|
||||
const bindings = named[1]
|
||||
.split(",")
|
||||
.map((binding) => binding.trim())
|
||||
.filter(Boolean);
|
||||
return bindings.length > 0 && bindings.every((binding) => /^type\s/.test(binding));
|
||||
}
|
||||
|
||||
/** Value-carrying static specifiers only. */
|
||||
/** Static import/export specifiers only — `import(...)` expressions are intentionally skipped. */
|
||||
function staticSpecifiers(source: string): string[] {
|
||||
const withoutDynamic = source.replace(/\bimport\s*\(/g, "__dynamic_import__(");
|
||||
const out: string[] = [];
|
||||
for (const pattern of [
|
||||
/(?:^|\n)\s*import\s+([^;'"]*)from\s*["']([^"']+)["']/g,
|
||||
/(?:^|\n)\s*export\s+([^;'"]*)from\s*["']([^"']+)["']/g,
|
||||
]) {
|
||||
for (const match of withoutDynamic.matchAll(pattern)) {
|
||||
if (isTypeOnlyClause(match[1])) continue;
|
||||
out.push(match[2]);
|
||||
}
|
||||
}
|
||||
// Side-effect imports (`import "./x"`) always emit an edge.
|
||||
for (const match of withoutDynamic.matchAll(/(?:^|\n)\s*import\s*["']([^"']+)["']/g)) {
|
||||
out.push(match[1]);
|
||||
const patterns = [
|
||||
/(?:^|\n)\s*import\s+[^;'"]*from\s*["']([^"']+)["']/g,
|
||||
/(?:^|\n)\s*import\s*["']([^"']+)["']/g,
|
||||
/(?:^|\n)\s*export\s+[^;'"]*from\s*["']([^"']+)["']/g,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const match of withoutDynamic.matchAll(pattern)) out.push(match[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const specifierCache = new Map<string, string[]>();
|
||||
function edgesOf(file: string): string[] {
|
||||
const cached = specifierCache.get(file);
|
||||
if (cached) return cached;
|
||||
const absolute = path.join(REPO_ROOT, file);
|
||||
let edges: string[] = [];
|
||||
if (fs.existsSync(absolute)) {
|
||||
edges = staticSpecifiers(fs.readFileSync(absolute, "utf8"))
|
||||
.map((specifier) => resolveSpecifier(file, specifier))
|
||||
.filter((resolved): resolved is string => resolved !== null);
|
||||
}
|
||||
specifierCache.set(file, edges);
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** BFS over static imports; returns the first path reaching a server-only module. */
|
||||
function findServerOnlyPath(entry: string): string[] | null {
|
||||
const seen = new Set<string>([entry]);
|
||||
const queue: Array<string[]> = [[entry]];
|
||||
while (queue.length > 0) {
|
||||
const trail = queue.shift()!;
|
||||
for (const resolved of edgesOf(trail[trail.length - 1])) {
|
||||
if (seen.has(resolved)) continue;
|
||||
if (SERVER_ONLY.has(resolved)) return [...trail, resolved];
|
||||
const current = trail[trail.length - 1];
|
||||
const absolute = path.join(REPO_ROOT, current);
|
||||
if (!fs.existsSync(absolute)) continue;
|
||||
|
||||
for (const specifier of staticSpecifiers(fs.readFileSync(absolute, "utf8"))) {
|
||||
const resolved = resolveSpecifier(current, specifier);
|
||||
if (!resolved || seen.has(resolved)) continue;
|
||||
const next = [...trail, resolved];
|
||||
if (SERVER_ONLY.includes(resolved)) return next;
|
||||
seen.add(resolved);
|
||||
queue.push([...trail, resolved]);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walk(dir: string, acc: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(full, acc);
|
||||
} else if (/\.tsx?$/.test(entry.name)) {
|
||||
acc.push(path.relative(REPO_ROOT, full));
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
for (const entry of CLIENT_SAFE_ENTRIES) {
|
||||
test(`${entry} does not statically reach server-only code`, () => {
|
||||
const trail = findServerOnlyPath(entry);
|
||||
assert.equal(
|
||||
trail,
|
||||
null,
|
||||
trail
|
||||
? `A client bundle would have to include a server-only module. Static import chain:\n ${trail.join("\n → ")}\n` +
|
||||
`Break the chain (a dynamic import at the boundary is enough) rather than widening this guard.`
|
||||
: ""
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function clientEntryPoints(): string[] {
|
||||
return walk(path.join(REPO_ROOT, "src")).filter((file) =>
|
||||
/^\s*["']use client["']/m.test(fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200))
|
||||
);
|
||||
}
|
||||
|
||||
test("no client entry point statically reaches server-only code", () => {
|
||||
const entries = [...clientEntryPoints(), ...EXTRA_ENTRIES];
|
||||
assert.ok(entries.length > 100, `expected the repo's client components, found ${entries.length}`);
|
||||
|
||||
const offenders = entries
|
||||
.map((entry) => ({ entry, trail: findServerOnlyPath(entry) }))
|
||||
.filter((row): row is { entry: string; trail: string[] } => row.trail !== null);
|
||||
|
||||
assert.deepEqual(
|
||||
offenders.map((o) => o.entry),
|
||||
[],
|
||||
"A client bundle would have to include server-only modules:\n" +
|
||||
offenders.map((o) => ` ${o.trail.join("\n → ")}`).join("\n\n") +
|
||||
"\nBreak the chain — or, when the binding is only a type, mark it `import type` so it " +
|
||||
"carries no runtime edge."
|
||||
);
|
||||
});
|
||||
|
||||
82
tests/unit/dockerfile-dashboard-embed-arg-10273.test.ts
Normal file
82
tests/unit/dockerfile-dashboard-embed-arg-10273.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* #10273 — `DASHBOARD_ALLOW_EMBED=vscode` relaxes the dashboard's CSP
|
||||
* `frame-ancestors` so the VS Code Simple Browser (the OmniCopilot extension's
|
||||
* `dashboardOpen: "editor"` mode) can render it. next.config.mjs reads the
|
||||
* variable while the bundle is built and Next.js compiles the result into the
|
||||
* route manifest, so the policy is frozen at build time.
|
||||
*
|
||||
* The Dockerfile therefore has to expose it as a build argument. Without an
|
||||
* `ARG`, `docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` is silently
|
||||
* dropped by Docker and the operator gets the default (unframable) image with
|
||||
* no error — the same class of failure #6700's `OMNIROUTE_USE_TURBOPACK` note
|
||||
* documents for a bare `ENV`.
|
||||
*
|
||||
* Guarded here rather than in a real `docker build`, which this sandbox cannot
|
||||
* run: the assertions pin the mechanism (declared as ARG+ENV, inside the
|
||||
* builder stage, before the build step) and that the runner stage does NOT
|
||||
* carry the variable — a runtime value would advertise an effect it cannot
|
||||
* have.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const lines = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8").split("\n");
|
||||
|
||||
/** Line indices bounding a named stage: its FROM up to the next FROM. */
|
||||
function stageRange(name: string): { start: number; end: number } {
|
||||
const start = lines.findIndex((l) =>
|
||||
new RegExp(`^FROM\\s+\\S+\\s+AS\\s+${name}\\b`, "i").test(l.trim())
|
||||
);
|
||||
assert.ok(start >= 0, `Dockerfile must declare a \`${name}\` stage`);
|
||||
const after = lines.slice(start + 1).findIndex((l) => /^FROM\s+/i.test(l.trim()));
|
||||
return { start, end: after === -1 ? lines.length : start + 1 + after };
|
||||
}
|
||||
|
||||
test("#10273 the builder stage exposes DASHBOARD_ALLOW_EMBED as a build arg", () => {
|
||||
const { start, end } = stageRange("builder");
|
||||
const stage = lines.slice(start, end);
|
||||
|
||||
const argIdx = stage.findIndex((l) => /^ARG\s+DASHBOARD_ALLOW_EMBED\b/.test(l.trim()));
|
||||
assert.ok(
|
||||
argIdx >= 0,
|
||||
"builder stage must declare `ARG DASHBOARD_ALLOW_EMBED` — without it, " +
|
||||
"`docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` is silently ignored"
|
||||
);
|
||||
|
||||
// ARG alone is not visible to the build process; it has to be promoted to ENV,
|
||||
// and the ENV must come from the ARG (a bare `ENV X=vscode` would shadow it).
|
||||
const envIdx = stage.findIndex((l) =>
|
||||
/^ENV\s+DASHBOARD_ALLOW_EMBED=\$\{?DASHBOARD_ALLOW_EMBED\}?\s*$/.test(l.trim())
|
||||
);
|
||||
assert.ok(envIdx > argIdx, "ARG must be promoted to ENV from the ARG value, after the ARG");
|
||||
|
||||
// It only has an effect if it is set before `next build` runs.
|
||||
// The build command sits inside a multi-line RUN block, so match the line itself.
|
||||
const buildIdx = stage.findIndex((l) => /\bnpm run build\b/.test(l));
|
||||
assert.ok(buildIdx > envIdx, "DASHBOARD_ALLOW_EMBED must be set before the build step");
|
||||
});
|
||||
|
||||
test("#10273 the default is empty, so images stay unframable unless asked", () => {
|
||||
const { start, end } = stageRange("builder");
|
||||
const arg = lines.slice(start, end).find((l) => /^ARG\s+DASHBOARD_ALLOW_EMBED\b/.test(l.trim()));
|
||||
assert.match(
|
||||
String(arg).trim(),
|
||||
/^ARG\s+DASHBOARD_ALLOW_EMBED=(""|'')$/,
|
||||
"the build arg must default to empty — embedding is opt-in (Hard Rule: default posture unchanged)"
|
||||
);
|
||||
});
|
||||
|
||||
test("#10273 no runtime stage carries DASHBOARD_ALLOW_EMBED", () => {
|
||||
const { end } = stageRange("builder");
|
||||
const afterBuilder = lines.slice(end).join("\n");
|
||||
assert.doesNotMatch(
|
||||
afterBuilder,
|
||||
/^\s*(ENV|ARG)\s+DASHBOARD_ALLOW_EMBED\b/m,
|
||||
"the runtime stages (runner-base / runner-web / runner-cli) must not set it: the " +
|
||||
"headers are already baked, so a runtime value would advertise an effect it cannot have"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user