From 1c00851247a0969c30b3ad2d78c3a81f3d2a3ee4 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 18 Jun 2026 09:02:57 -0700 Subject: [PATCH 1/2] fix(api-proxy): copy token-tracker-shared and otel modules into image PR #4780 (released in v0.27.3) extracted token-tracker-shared.js but did not add it to the Dockerfile COPY list. Because the image copies files individually by name (no bundler), require('./token-tracker-shared') threw MODULE_NOT_FOUND in the container. The graceful-degradation guard in proxy-request.js then silently stubbed trackTokenUsage to a no-op, so the container booted and served traffic normally but never wrote token-usage.jsonl -- causing AI-credit accounting to report 0 for every run since v0.27.3. models.json still appeared because model-discovery.js does not depend on the missing module. otel-exporters.js and otel-serialization.js (required by otel.js) were likewise missing and silently disabled OTEL tracing via its own graceful-degradation guard. Add all three files to the COPY list and add a guard test that computes the require closure from server.js and asserts every local module is present in the Dockerfile, preventing this recurring class of bug. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/api-proxy/Dockerfile | 5 +- .../dockerfile-copy-coverage.test.js | 119 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 containers/api-proxy/dockerfile-copy-coverage.test.js diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 925a7205..5d77393e 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -17,7 +17,7 @@ RUN npm ci --omit=dev # Copy application files COPY server.js logging.js metrics.js rate-limiter.js \ token-tracker.js token-persistence.js token-parsers.js \ - token-tracker-http.js token-tracker-ws.js \ + token-tracker-http.js token-tracker-ws.js token-tracker-shared.js \ model-resolver.js model-utils.js model-body-rewriter.js proxy-utils.js adapter-factory.js anthropic-transforms.js \ model-config.js key-validation.js server-factory.js startup.js \ proxy-request.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \ @@ -27,7 +27,8 @@ COPY server.js logging.js metrics.js rate-limiter.js \ ai-credits-pricing.js models-dev-catalog.js models.dev.catalog.json \ oidc-refresh-utils.js body-transform.js body-utils.js rate-limit.js websocket-proxy.js \ deprecated-header-tracker.js billing-headers.js upstream-response.js \ - anthropic-cache.js otel.js token-budget-log.js blocked-request-diagnostics.js \ + anthropic-cache.js otel.js otel-exporters.js otel-serialization.js \ + token-budget-log.js blocked-request-diagnostics.js \ provider-env-constants.js ./ COPY guards/ ./guards/ COPY providers/ ./providers/ diff --git a/containers/api-proxy/dockerfile-copy-coverage.test.js b/containers/api-proxy/dockerfile-copy-coverage.test.js new file mode 100644 index 00000000..d6388e5d --- /dev/null +++ b/containers/api-proxy/dockerfile-copy-coverage.test.js @@ -0,0 +1,119 @@ +/** + * Guard test: every local module reachable from the runtime entrypoint + * (server.js) MUST be present in the Dockerfile COPY list. + * + * Background: the api-proxy image copies source files individually by name + * (no bundler). When a refactor adds a new module but forgets to update the + * Dockerfile, `require()` throws MODULE_NOT_FOUND inside the container. The + * proxy's graceful-degradation guards then silently stub the affected + * subsystem (e.g. token tracking, OTEL), so the container still boots but + * produces no token-usage.jsonl — causing AI-credit accounting to report 0. + * + * This regression has happened at least twice (OIDC modules, then + * token-tracker-shared.js). This test fails fast in CI instead. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = __dirname; +const ENTRYPOINT = path.join(ROOT, 'server.js'); + +/** Resolve a relative require spec to an existing file path, or null. */ +function resolveLocal(fromFile, spec) { + const base = path.resolve(path.dirname(fromFile), spec); + const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]; + for (const c of candidates) { + if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; + } + return null; +} + +/** Compute the transitive closure of local (./ and ../) requires from an entry file. */ +function computeRequireClosure(entry) { + const seen = new Set(); + const stack = [entry]; + const requireRe = /require\(\s*(["'])(\.{1,2}\/[^"']+)\1\s*\)/g; + + while (stack.length > 0) { + const file = stack.pop(); + if (seen.has(file)) continue; + seen.add(file); + + let src; + try { + src = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + + let m; + while ((m = requireRe.exec(src)) !== null) { + const resolved = resolveLocal(file, m[2]); + if (resolved && !resolved.includes(`${path.sep}node_modules${path.sep}`)) { + stack.push(resolved); + } + } + } + return seen; +} + +/** Parse the Dockerfile into a set of copied files and copied directory prefixes. */ +function parseDockerfileCopies(dockerfilePath) { + const lines = fs.readFileSync(dockerfilePath, 'utf8').split('\n'); + const files = new Set(); + const dirs = new Set(); + + let inCopy = false; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line.startsWith('#')) continue; + + let body = line; + if (line.startsWith('COPY ')) { + inCopy = true; + body = line.slice('COPY '.length); + } else if (!inCopy) { + continue; + } + + const continues = body.endsWith('\\'); + body = body.replace(/\\$/, '').trim(); + + for (const tok of body.split(/\s+/)) { + if (!tok || tok === '.' || tok === './') continue; + const clean = tok.replace(/^\.\//, ''); + if (clean.endsWith('/')) { + dirs.add(clean); + } else if (/\.(js|json)$/.test(clean)) { + files.add(clean); + } + } + + if (!continues) inCopy = false; + } + return { files, dirs }; +} + +describe('Dockerfile COPY coverage', () => { + test('every module reachable from server.js is copied into the image', () => { + const closure = computeRequireClosure(ENTRYPOINT); + const { files, dirs } = parseDockerfileCopies(path.join(ROOT, 'Dockerfile')); + + const isCopied = (relPath) => { + if (files.has(relPath)) return true; + for (const dir of dirs) { + if (relPath.startsWith(dir)) return true; + } + return false; + }; + + const missing = [...closure] + .map((abs) => path.relative(ROOT, abs)) + .filter((rel) => !rel.startsWith('node_modules')) + .filter((rel) => !isCopied(rel)) + .sort(); + + expect(missing).toEqual([]); + }); +}); From 9ad6ead6f68ae310b8b2f7c83326bde004894490 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:12:04 +0000 Subject: [PATCH 2/2] fix(test): normalize path.relative() to POSIX separators in dockerfile-copy-coverage test --- containers/api-proxy/dockerfile-copy-coverage.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/dockerfile-copy-coverage.test.js b/containers/api-proxy/dockerfile-copy-coverage.test.js index d6388e5d..bf1c1270 100644 --- a/containers/api-proxy/dockerfile-copy-coverage.test.js +++ b/containers/api-proxy/dockerfile-copy-coverage.test.js @@ -109,7 +109,7 @@ describe('Dockerfile COPY coverage', () => { }; const missing = [...closure] - .map((abs) => path.relative(ROOT, abs)) + .map((abs) => path.relative(ROOT, abs).split(path.sep).join('/')) .filter((rel) => !rel.startsWith('node_modules')) .filter((rel) => !isCopied(rel)) .sort();