Skip to content

Commit 289d5a5

Browse files
fix(inference): authenticate managed llama.cpp bridge (#9670)
## Summary - replace the managed llama.cpp raw TCP bridge with an HTTP bridge that enforces one Bearer credential - pass the managed API key through inherited file descriptor 3 after validating the key file's ownership, mode, type, link count, and identity - preserve unauthenticated lifecycle probing only for exact `GET /health` - replace legacy pre-authentication bridge processes during lifecycle recovery - add focused coverage for missing, invalid, duplicate, and valid credentials, health-route boundaries, upstream failure, descriptor inheritance, legacy replacement, and secure credential-file validation ## Root cause The managed llama.cpp container required its configured API key, but the host bridge was a transparent TCP proxy. Requests to the host loopback listener therefore reached the container without bridge-layer authentication and could bypass the intended managed-route boundary. ## Impact Requests to the managed bridge now fail closed unless they carry exactly one valid Bearer credential. The credential is never placed in process arguments or environment variables. The exact health probe remains available to lifecycle management, and resume/recovery replaces older bridge processes that lack the authenticated mode marker. ## Validation - 73 focused Vitest tests passed - `npm run typecheck:cli` passed - `npm run checks:repository` passed - targeted Oxlint passed - commit hooks, repository checks, and secret scanning passed - verified live on NVIDIA DGX Spark with managed llama.cpp and Muse Glimmer; detailed credential-redacted proof is included in a PR comment Fixes #9591 Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Security Enhancements** * Added API-key authentication for private bridge connections. * Restricted health checks to unauthenticated access while protecting other requests. * Added secure validation and handling for API-key files. * Standardized authorization forwarding and improved error responses. * **Bug Fixes** * Private bridge startup now uses the configured API-key path. * Improved handling of invalid credentials and upstream connection failures. * Strengthened pull request validation to prevent mismatched workflow revisions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Senthil Ravichandran <senthilr@nvidia.com> Co-authored-by: Senthil Ravichandran <senthilr@nvidia.com>
1 parent f708a36 commit 289d5a5

7 files changed

Lines changed: 573 additions & 61 deletions

.github/workflows/pr-self-hosted.yaml

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,7 @@ concurrency:
2828
cancel-in-progress: true
2929

3030
jobs:
31-
get-pr-info:
32-
runs-on: ubuntu-latest
33-
timeout-minutes: 5
34-
outputs:
35-
pr-info: ${{ steps.get-pr-info.outputs.pr-info }}
36-
steps:
37-
- id: get-pr-info
38-
uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main
39-
4031
select-llama-cpp-generic-gpu:
41-
needs: get-pr-info
4232
runs-on: ubuntu-latest
4333
timeout-minutes: 5
4434
outputs:
@@ -48,12 +38,16 @@ jobs:
4838
name: Select llama.cpp generic GPU E2E from PR files
4939
env:
5040
GH_TOKEN: ${{ github.token }}
51-
PR_INFO: ${{ needs.get-pr-info.outputs.pr-info }}
5241
shell: bash
5342
run: |
5443
set -euo pipefail
55-
pr_number="$(jq -er '.number | select(type == "number" and . > 0)' <<<"$PR_INFO")"
56-
head_sha="$(jq -er '.head.sha | select(test("^[a-f0-9]{40}$"))' <<<"$PR_INFO")"
44+
[[ "$GITHUB_REF_NAME" =~ ^pull-request/([0-9]+)$ ]] || {
45+
echo "::error::Copied branch name does not identify a PR" >&2
46+
exit 1
47+
}
48+
pr_number="${BASH_REMATCH[1]}"
49+
pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number")"
50+
head_sha="$(jq -er '.head.sha | select(test("^[a-f0-9]{40}$"))' <<<"$pr_json")"
5751
[[ "$head_sha" == "$GITHUB_SHA" ]] || {
5852
echo "::error::Copied PR branch SHA does not match the current PR head" >&2
5953
exit 1
@@ -89,9 +83,7 @@ jobs:
8983
9084
llama-cpp-generic-gpu:
9185
name: llama.cpp on generic NVIDIA GPU
92-
needs:
93-
- get-pr-info
94-
- select-llama-cpp-generic-gpu
86+
needs: select-llama-cpp-generic-gpu
9587
if: ${{ needs.select-llama-cpp-generic-gpu.outputs.selected == 'true' }}
9688
runs-on: linux-amd64-gpu-rtxpro6000-latest-1
9789
timeout-minutes: 120
@@ -101,9 +93,9 @@ jobs:
10193
E2E_TARGET_ID: llama-cpp-generic-gpu
10294
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
10395
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
104-
NEMOCLAW_E2E_EXPECTED_SHA: ${{ fromJSON(needs.get-pr-info.outputs.pr-info).head.sha }}
96+
NEMOCLAW_E2E_EXPECTED_SHA: ${{ github.sha }}
10597
NEMOCLAW_E2E_SHARD: default
106-
NEMOCLAW_LLAMA_CPP_QUALIFICATION_HEAD_SHA: ${{ fromJSON(needs.get-pr-info.outputs.pr-info).head.sha }}
98+
NEMOCLAW_LLAMA_CPP_QUALIFICATION_HEAD_SHA: ${{ github.sha }}
10799
NEMOCLAW_LLAMACPP_RECIPE: llama-cpp.nemotron-3-nano-30b-a3b.spark-single.v1
108100
NEMOCLAW_NON_INTERACTIVE: "1"
109101
NEMOCLAW_PROVIDER: install-llama-cpp
@@ -115,7 +107,7 @@ jobs:
115107
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
116108
with:
117109
persist-credentials: false
118-
ref: ${{ fromJSON(needs.get-pr-info.outputs.pr-info).head.sha }}
110+
ref: ${{ github.sha }}
119111

120112
- name: Prepare E2E workspace
121113
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75

src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ describe("dormant Docker llama.cpp managed lifecycle", () => {
405405
expect(privateBridge.start).toHaveBeenCalledWith(
406406
expect.objectContaining({
407407
transactionId: TRANSACTION_ID,
408+
apiKeyPath: runtimeBindings.apiKeyHostPath,
408409
targetHost: "172.30.0.2",
409410
bindAddresses: ["127.0.0.1", "172.29.0.1"],
410411
}),

src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ function privateBridgeAuthority(
962962
}
963963
return Object.freeze({
964964
transactionId: journal.transactionId,
965+
apiKeyPath: options.bindings.apiKeyHostPath,
965966
targetHost: container.containerIp,
966967
targetPort: options.contract.serve.port,
967968
listenPort: options.bindings.hostPort,

src/lib/onboard/runtime-provider/docker-llama-cpp-private-bridge-process.ts

Lines changed: 163 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { timingSafeEqual } from "node:crypto";
5+
import fs from "node:fs";
6+
import http from "node:http";
47
import net from "node:net";
58

69
const SHA256 = /^[a-f0-9]{64}$/u;
10+
const API_KEY_FILE_DESCRIPTOR = 3;
11+
const AUTH_MODE = "api-key-fd3";
12+
const UNAUTHORIZED_BODY = `${JSON.stringify({
13+
error: {
14+
code: "unauthorized",
15+
message: "Authentication is required.",
16+
type: "authentication_error",
17+
},
18+
})}\n`;
719

820
export interface LlamaCppPrivateBridgeArguments {
921
readonly transactionId: string;
@@ -53,6 +65,7 @@ export function parseLlamaCppPrivateBridgeArguments(
5365
const bindAddresses = values.get("--bind-address") ?? [];
5466
const supported = new Set([
5567
"--transaction",
68+
"--auth-mode",
5669
"--target-host",
5770
"--target-port",
5871
"--listen-port",
@@ -63,6 +76,7 @@ export function parseLlamaCppPrivateBridgeArguments(
6376
}
6477
if (
6578
!SHA256.test(transactionId) ||
79+
one("--auth-mode") !== AUTH_MODE ||
6680
!isPrivateIpv4(targetHost) ||
6781
bindAddresses.length !== 2 ||
6882
bindAddresses[0] !== "127.0.0.1" ||
@@ -83,31 +97,151 @@ export function parseLlamaCppPrivateBridgeArguments(
8397
});
8498
}
8599

100+
function readPrivateBridgeApiKey(): string {
101+
let value: string;
102+
try {
103+
value = fs.readFileSync(API_KEY_FILE_DESCRIPTOR, "utf8").trim();
104+
} catch {
105+
throw new Error("private bridge credential is unavailable");
106+
} finally {
107+
try {
108+
fs.closeSync(API_KEY_FILE_DESCRIPTOR);
109+
} catch {
110+
// The inherited descriptor can already be closed after a failed read.
111+
}
112+
}
113+
if (!SHA256.test(value)) {
114+
throw new Error("private bridge credential is invalid");
115+
}
116+
return value;
117+
}
118+
119+
function authorizationValues(request: http.IncomingMessage): readonly string[] {
120+
const values: string[] = [];
121+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
122+
if (request.rawHeaders[index]?.toLowerCase() === "authorization") {
123+
values.push(request.rawHeaders[index + 1] ?? "");
124+
}
125+
}
126+
return values;
127+
}
128+
129+
function hasValidBearerCredential(request: http.IncomingMessage, apiKey: string): boolean {
130+
const values = authorizationValues(request);
131+
if (values.length !== 1) return false;
132+
const value = values[0]!;
133+
if (value.length !== 7 + apiKey.length || value.slice(0, 7).toLowerCase() !== "bearer ") {
134+
return false;
135+
}
136+
const supplied = Buffer.from(value.slice(7), "utf8");
137+
const expected = Buffer.from(apiKey, "utf8");
138+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
139+
}
140+
141+
function isUnauthenticatedHealthProbe(request: http.IncomingMessage): boolean {
142+
return request.method === "GET" && request.url === "/health";
143+
}
144+
145+
function writeUnauthorized(response: http.ServerResponse): void {
146+
response.writeHead(401, {
147+
"Cache-Control": "no-store",
148+
"Content-Length": Buffer.byteLength(UNAUTHORIZED_BODY),
149+
"Content-Type": "application/json",
150+
"WWW-Authenticate": "Bearer",
151+
"X-Content-Type-Options": "nosniff",
152+
});
153+
response.end(UNAUTHORIZED_BODY);
154+
}
155+
156+
function writeUpstreamUnavailable(response: http.ServerResponse): void {
157+
if (response.destroyed || response.writableEnded) return;
158+
if (response.headersSent) {
159+
response.destroy();
160+
return;
161+
}
162+
const body = `${JSON.stringify({
163+
error: {
164+
code: "upstream_unavailable",
165+
message: "The managed inference server is unavailable.",
166+
type: "server_error",
167+
},
168+
})}\n`;
169+
response.writeHead(502, {
170+
"Cache-Control": "no-store",
171+
"Content-Length": Buffer.byteLength(body),
172+
"Content-Type": "application/json",
173+
"X-Content-Type-Options": "nosniff",
174+
});
175+
response.end(body);
176+
}
177+
178+
export function createLlamaCppPrivateBridgeRequestHandler(
179+
authority: Pick<LlamaCppPrivateBridgeArguments, "targetHost" | "targetPort">,
180+
apiKey: string,
181+
): http.RequestListener {
182+
if (
183+
!SHA256.test(apiKey) ||
184+
(!isPrivateIpv4(authority.targetHost) && authority.targetHost !== "127.0.0.1")
185+
) {
186+
throw new Error("private bridge HTTP authority is invalid");
187+
}
188+
const targetPort = exactPort(String(authority.targetPort), "target port");
189+
const canonicalAuthorization = `Bearer ${apiKey}`;
190+
191+
return (request, response) => {
192+
const healthProbe = isUnauthenticatedHealthProbe(request);
193+
if (!healthProbe && !hasValidBearerCredential(request, apiKey)) {
194+
request.resume();
195+
writeUnauthorized(response);
196+
return;
197+
}
198+
199+
const headers: http.OutgoingHttpHeaders = { ...request.headers };
200+
headers.host = `${authority.targetHost}:${String(targetPort)}`;
201+
if (!healthProbe) headers.authorization = canonicalAuthorization;
202+
delete headers.forwarded;
203+
delete headers["x-forwarded-for"];
204+
delete headers["x-forwarded-host"];
205+
delete headers["x-forwarded-proto"];
206+
207+
const upstream = http.request(
208+
{
209+
headers,
210+
host: authority.targetHost,
211+
method: request.method,
212+
path: request.url,
213+
port: targetPort,
214+
},
215+
(upstreamResponse) => {
216+
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
217+
upstreamResponse.once("error", () => response.destroy());
218+
upstreamResponse.pipe(response);
219+
},
220+
);
221+
upstream.once("error", () => writeUpstreamUnavailable(response));
222+
request.once("close", () => {
223+
if (!request.complete) upstream.destroy();
224+
});
225+
request.once("error", () => upstream.destroy());
226+
response.once("close", () => {
227+
if (!response.writableEnded) upstream.destroy();
228+
});
229+
request.pipe(upstream);
230+
};
231+
}
232+
86233
export async function runLlamaCppPrivateBridge(
87234
authority: LlamaCppPrivateBridgeArguments,
235+
apiKey: string,
88236
): Promise<void> {
89-
const servers = authority.bindAddresses.map((host) =>
90-
net.createServer({ allowHalfOpen: false, pauseOnConnect: true }, (client) => {
91-
const upstream = net.createConnection({
92-
host: authority.targetHost,
93-
port: authority.targetPort,
94-
});
95-
const close = () => {
96-
client.destroy();
97-
upstream.destroy();
98-
};
99-
client.on("error", close);
100-
upstream.on("error", close);
101-
upstream.once("connect", () => {
102-
client.pipe(upstream);
103-
upstream.pipe(client);
104-
client.resume();
105-
});
106-
}),
107-
);
237+
const handler = createLlamaCppPrivateBridgeRequestHandler(authority, apiKey);
238+
const servers = authority.bindAddresses.map(() => http.createServer(handler));
108239

109240
const close = () => {
110-
for (const server of servers) server.close();
241+
for (const server of servers) {
242+
server.close();
243+
server.closeAllConnections();
244+
}
111245
};
112246
process.once("SIGINT", close);
113247
process.once("SIGTERM", close);
@@ -130,10 +264,15 @@ export async function runLlamaCppPrivateBridge(
130264
}
131265

132266
if (require.main === module) {
133-
runLlamaCppPrivateBridge(parseLlamaCppPrivateBridgeArguments(process.argv.slice(2))).catch(
134-
(error: unknown) => {
267+
Promise.resolve()
268+
.then(() =>
269+
runLlamaCppPrivateBridge(
270+
parseLlamaCppPrivateBridgeArguments(process.argv.slice(2)),
271+
readPrivateBridgeApiKey(),
272+
),
273+
)
274+
.catch((error: unknown) => {
135275
console.error(error instanceof Error ? error.message : String(error));
136276
process.exitCode = 1;
137-
},
138-
);
277+
});
139278
}

0 commit comments

Comments
 (0)