From ff1fc7193118518b68ff8488b2def0ed6e770347 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 16 Jul 2026 16:19:27 +0800 Subject: [PATCH 1/7] feat(sandbox): add serving process health leg to status and doctor (#7003) Fresh-exec inference probes run with OpenShell's injected env (CA bundle, proxy, NODE_OPTIONS) and cannot attest what the long-running gateway process can reach. When that process was started without the injected env (e.g. manual recovery after #6635), every probe stays green while all real model calls fail. Adds the manifest contract and status/doctor surface to make this gap visible: - AgentSelfReport type + self_report field in agent manifests: agents that expose a self-report endpoint declare it here; NemoClaw reads and renders it. - ServingProcessHealth discriminated union in SandboxStatusSnapshot and SandboxStatusReport: { checked: false } when no self_report endpoint is declared, { checked: true, ok, detail } when one is probed. - status prints "Serving process (openclaw gateway): not checked" after the inference probe lines when the gateway is running but declares no self_report, replacing silent green with an honest "not checked" signal. - doctor's Inference group gains a "Serving process: not checked" info check for the same reason. No agent currently declares self_report, so all agents show "not checked" for now. The plumbing is extensible: adding self_report to a manifest wires the endpoint into status/doctor automatically. Refs #7003 Signed-off-by: Dongni Yang --- .../actions/sandbox/doctor-inference.test.ts | 20 +++++++++++ src/lib/actions/sandbox/doctor-inference.ts | 9 +++++ src/lib/actions/sandbox/status-snapshot.ts | 26 ++++++++++++++ src/lib/actions/sandbox/status-text.ts | 20 +++++++++++ src/lib/actions/sandbox/status.ts | 3 ++ src/lib/agent/definition-types.ts | 7 ++++ src/lib/agent/defs.test.ts | 34 +++++++++++++++++++ src/lib/agent/defs.ts | 9 +++++ .../hermes-recovery-boundary-fixtures.ts | 1 + src/lib/agent/manifest-readers.ts | 18 ++++++++++ src/lib/agent/onboard.test.ts | 1 + src/lib/agent/runtime.test.ts | 1 + test/helpers/base-image-test-harness.ts | 1 + 13 files changed, 150 insertions(+) diff --git a/src/lib/actions/sandbox/doctor-inference.test.ts b/src/lib/actions/sandbox/doctor-inference.test.ts index f41e1dc64b0..0a558df9cce 100644 --- a/src/lib/actions/sandbox/doctor-inference.test.ts +++ b/src/lib/actions/sandbox/doctor-inference.test.ts @@ -164,6 +164,26 @@ describe("doctor inference checks", () => { ); }); + it("includes a serving process check as not checked when no self_report is declared (#7003)", async () => { + const checks = await collectInferenceChecks( + "alpha", + { provider: "nvidia-prod", model: "model" }, + true, + { + probeProviderHealthImpl: () => upstream(), + probeSandboxInferenceGatewayHealthImpl: async () => gateway(true), + }, + ); + + expect(checks).toContainEqual( + expect.objectContaining({ + label: "Serving process", + status: "info", + detail: expect.stringContaining("not checked"), + }), + ); + }); + it("does not mutate direct provider health while adding route evidence", async () => { const providerHealth = upstream(); diff --git a/src/lib/actions/sandbox/doctor-inference.ts b/src/lib/actions/sandbox/doctor-inference.ts index 7b71a50d9b5..5e4912bb214 100644 --- a/src/lib/actions/sandbox/doctor-inference.ts +++ b/src/lib/actions/sandbox/doctor-inference.ts @@ -156,5 +156,14 @@ export async function collectInferenceChecks( )) { pushInferenceHealthCheck(checks, diagnostic, { authoritative: false }); } + // Serving-process leg: the above probes run in a fresh exec with OpenShell's + // injected env, so they cannot attest what the long-running gateway process + // can reach. Report explicitly when no self-report source is declared (#7003). + checks.push({ + group: "Inference", + label: "Serving process", + status: "info", + detail: "not checked — no self_report endpoint declared for this agent", + }); return checks; } diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 62c9c5a830b..4e66ba0ed11 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -52,6 +52,15 @@ type ProbeProviderHealth = ( ) => ProviderHealthStatus | null; type ProbeSandboxInferenceGatewayHealth = typeof probeSandboxInferenceGatewayHealth; +/** + * Health as reported by the serving process itself. `checked: false` means the + * agent declared no self_report endpoint so NemoClaw cannot attest what the + * serving process's environment can reach. + */ +export type ServingProcessHealth = + | { checked: false } + | { checked: true; ok: boolean; detail: string }; + export function getSandboxStatusInferenceHealth( gatewayPresent: boolean, currentProvider: unknown, @@ -160,6 +169,12 @@ export interface SandboxStatusReport { policies: string[]; failureLayer: SandboxStatusFailureLayer | null; terminalRuntimeHealth: TerminalRuntimeOomProbeResult | null; + /** + * Health sourced from the serving process itself (via its declared self_report + * endpoint). Null when the sandbox is not reachable or the agent runtime is not + * gateway-based. `checked: false` when the agent declares no self_report. + */ + servingProcessHealth: ServingProcessHealth | null; /** * Whether the resolved docker-driver sandbox container is paused * (`docker pause`). `false` for non-docker-driver sandboxes or when no @@ -186,6 +201,7 @@ export interface SandboxStatusSnapshot { routeDrift: SandboxStatusRouteDrift | null; inferenceHealth: ProviderHealthStatus | null; terminalRuntimeHealth: TerminalRuntimeOomProbeResult | null; + servingProcessHealth: ServingProcessHealth | null; } export interface SandboxStatusAgentInfo { @@ -305,6 +321,7 @@ export async function collectSandboxStatusSnapshot( routeDrift: null, inferenceHealth: null, terminalRuntimeHealth: null, + servingProcessHealth: null, }; } const live = @@ -386,6 +403,13 @@ export async function collectSandboxStatusSnapshot( lookup.state === "present" && statusAgent.agentRuntime === "terminal" ? (opts.deps?.probeTerminalRuntimeHealth ?? probeTerminalRuntimeCgroupOom)(sandboxName) : null; + // The serving process health leg is only meaningful when the gateway is up. + // When the agent declares no self_report endpoint, report checked: false so + // status shows "not checked" rather than staying silently green (#7003). + const servingProcessHealth: ServingProcessHealth | null = + lookup.state === "present" && statusAgent.agentRuntime === "gateway" + ? { checked: false } + : null; return { sb, lookup, @@ -397,6 +421,7 @@ export async function collectSandboxStatusSnapshot( routeDrift, inferenceHealth, terminalRuntimeHealth, + servingProcessHealth, }; } @@ -463,6 +488,7 @@ async function buildSandboxStatusReport( phase, gatewayState: lookup.state, inferenceHealth, + servingProcessHealth: snapshot.servingProcessHealth, rpcIssue: rpcIssue ? { kind: rpcIssue.kind } : null, hostGpuDetected: !!(sb && sb.hostGpuDetected), sandboxGpuEnabled, diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index a9be7b461a9..453dcc478d7 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -25,6 +25,7 @@ import { type SandboxStatusAgentInfo, type SandboxStatusRouteDrift, type SandboxStatusSnapshot, + type ServingProcessHealth, } from "./status-snapshot"; export interface SandboxStatusTextContext @@ -37,6 +38,7 @@ export interface SandboxStatusTextContext | "routeDrift" | "inferenceHealth" | "terminalRuntimeHealth" + | "servingProcessHealth" > { sandboxName: string; statusAgent: SandboxStatusAgentInfo; @@ -95,6 +97,23 @@ function printInferenceProbeLine(probe: ProviderHealthStatus): void { console.log(` ${probe.detail}`); } +function printServingProcessHealth( + statusAgent: SandboxStatusAgentInfo, + health: ServingProcessHealth | null, +): void { + if (!health) return; + const label = `Serving process (${statusAgent.agentDisplayName.toLowerCase()} gateway)`; + if (!health.checked) { + console.log(` ${label}: ${D}not checked${R}`); + return; + } + if (health.ok) { + console.log(` ${label}: ${G}${health.detail}${R}`); + return; + } + console.log(` ${label}: ${RD}${health.detail}${R}`); +} + function printInferenceStatus(context: SandboxStatusTextContext): void { if (context.inferenceHealth) { printInferenceProbeLine(context.inferenceHealth); @@ -105,6 +124,7 @@ function printInferenceStatus(context: SandboxStatusTextContext): void { if (context.lookup.state !== "present") { console.log(" Inference: not verified (gateway/sandbox state not verified)"); } + printServingProcessHealth(context.statusAgent, context.servingProcessHealth); } function inferenceHealthExitCode(inferenceHealth: ProviderHealthStatus | null): number | null { diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 0bb69da0791..9f15c04c4c5 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -43,6 +43,7 @@ export { resolveSandboxStatusDcodeAutoApprovalMode, type SandboxStatusReport, type SandboxStatusSnapshot, + type ServingProcessHealth, } from "./status-snapshot"; function maybeEnsureHermesToolGatewayBroker(sb: registry.SandboxEntry | null): void { @@ -81,6 +82,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { routeDrift, inferenceHealth, terminalRuntimeHealth, + servingProcessHealth, } = snapshot; // Resolve the docker-driver container once: reused for the paused-container // recovery hint (#4495) and the Docker health line below (#3975). @@ -109,6 +111,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { routeDrift, inferenceHealth, terminalRuntimeHealth, + servingProcessHealth, statusAgent, }; const textOutcome = printSandboxDetails(textContext); diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index 715b6120cf8..b9627aae1d0 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -16,6 +16,11 @@ export interface AgentHealthProbe { timeout_seconds: number; } +export interface AgentSelfReport { + url: string; + timeout_seconds: number; +} + export interface AgentConfigPaths { dir: string; configFile: string; @@ -116,6 +121,7 @@ export interface AgentDefinition { phone_home_hosts?: string[]; forward_ports?: number[]; health_probe?: AgentHealthProbe; + self_report?: AgentSelfReport; config?: ManifestRecord; inference?: AgentInference; mcp?: AgentMcpCapability; @@ -128,6 +134,7 @@ export interface AgentDefinition { manifestPath: string; readonly displayName: string; readonly healthProbe: AgentHealthProbe | null; + readonly selfReport: AgentSelfReport | null; readonly forwardPort: number; readonly dashboard: AgentDashboard; readonly webAuth: AgentWebAuth; diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 9b16d38ecf3..b242d67a9f0 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -400,4 +400,38 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*control characters/); }); + + it("exposes selfReport as null when self_report is absent from the manifest (#7003)", () => { + const agentName = `no-self-report-${String(Date.now())}`; + writeTempAgentManifest(agentName, `name: ${agentName}\n`); + const agent = loadAgent(agentName); + expect(agent.selfReport).toBeNull(); + }); + + it("parses self_report url and timeout from manifests (#7003)", () => { + const agentName = `has-self-report-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "self_report:", + ' url: "http://localhost:18789/health/monitor"', + " timeout_seconds: 10", + ].join("\n"), + ); + const agent = loadAgent(agentName); + expect(agent.selfReport).toEqual({ + url: "http://localhost:18789/health/monitor", + timeout_seconds: 10, + }); + }); + + it("rejects self_report entries with a missing url (#7003)", () => { + const agentName = `self-report-no-url-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "self_report:", " timeout_seconds: 10"].join("\n"), + ); + expect(() => loadAgent(agentName)).toThrow(/self_report\.url/); + }); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index c7d93db37cf..bcb39ee90a7 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -23,6 +23,7 @@ import type { AgentHealthProbe, AgentLegacyPaths, AgentMcpCapability, + AgentSelfReport, AgentStateFile, AgentVersionScheme, } from "./definition-types"; @@ -35,6 +36,7 @@ import { readMcpCapability, readObject, readPortArray, + readSelfReport, readStateFiles, readString, readStringArray, @@ -57,6 +59,7 @@ export type { AgentMcpAdapter, AgentMcpCapability, AgentMcpSupport, + AgentSelfReport, AgentStateFile, AgentStateFileStrategy, AgentVersionScheme, @@ -136,6 +139,7 @@ export function loadAgent(name: string): AgentDefinition { const dashboard = readDashboard(raw); const webAuth = readWebAuth(raw); const healthProbe = readHealthProbe(raw); + const selfReport = readSelfReport(raw); const config = readObject(raw, "config"); const inference = readInference(raw); const mcp = readMcpCapability(raw); @@ -169,6 +173,7 @@ export function loadAgent(name: string): AgentDefinition { phone_home_hosts: phoneHomeHosts, forward_ports: forwardPorts, health_probe: healthProbe, + self_report: selfReport, config, inference, mcp, @@ -197,6 +202,10 @@ export function loadAgent(name: string): AgentDefinition { ); }, + get selfReport(): AgentSelfReport | null { + return selfReport ?? null; + }, + get forwardPort(): number { if (runtime.kind === "terminal" && !forwardPorts?.[0]) { return 0; diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index 469e96446cd..b34bc588c87 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -44,6 +44,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini policyPermissivePath: null, pluginDir: null, legacyPaths: null, + selfReport: null, agentDir: "/tmp/agent", manifestPath: "/tmp/agent/manifest.yaml", ...overrides, diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index bb181d12db6..5d8f5872d0e 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -10,6 +10,7 @@ import type { AgentHealthProbe, AgentInference, AgentMcpCapability, + AgentSelfReport, AgentStateFile, AgentVersionScheme, ManifestRecord, @@ -226,6 +227,23 @@ export function readHealthProbe(record: ManifestRecord): AgentHealthProbe | unde return undefined; } +export function readSelfReport(record: ManifestRecord): AgentSelfReport | undefined { + const selfReport = readObject(record, "self_report"); + if (!selfReport) return undefined; + + const url = readString(selfReport, "url"); + if (!url) { + throw new Error("Agent manifest field 'self_report.url' is required"); + } + + const timeoutSeconds = selfReport.timeout_seconds; + if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds)) { + return { url, timeout_seconds: timeoutSeconds }; + } + + return { url, timeout_seconds: 10 }; +} + export function readDashboard(record: ManifestRecord): AgentDashboard { const dashboard = readObject(record, "dashboard") ?? {}; const rawKind = dashboard.kind; diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 78eaa117e63..770137f04aa 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -45,6 +45,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { policyPermissivePath: null, pluginDir: null, legacyPaths: null, + selfReport: null, agentDir: "/tmp/agent", manifestPath: "/tmp/agent/manifest.yaml", ...overrides, diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 4b4da8dcaf3..ed615fa2e96 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -39,6 +39,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { policyPermissivePath: null, pluginDir: null, legacyPaths: null, + selfReport: null, agentDir: "/tmp/agent", manifestPath: "/tmp/agent/manifest.yaml", ...overrides, diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index 999f878f078..b126b3e4f2f 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -59,6 +59,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini policyPermissivePath: null, pluginDir: null, legacyPaths: null, + selfReport: null, agentDir: "/repo/root/agents/hermes", manifestPath: "/repo/root/agents/hermes/manifest.yaml", ...overrides, From d4dc3b6560457110603166337aced05236043bb5 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 16 Jul 2026 16:38:41 +0800 Subject: [PATCH 2/7] fix(sandbox): address CodeRabbit feedback on serving process health (#7003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the PR review: 1. `collectInferenceChecks` was unconditionally appending the "not checked" serving-process leg. Agents that declare `self_report` should suppress it; `agentHasSelfReport` dep gates the check. `doctor.ts` resolves the flag by loading the agent manifest and passes it through. 2. The self_report parse test used timeout_seconds 10, which is also the fallback value, masking whether the parsed field was actually used. Changed to 7 for the explicit case and added a separate test for the fallback path (absent timeout_seconds → 10). Refs #7003 Signed-off-by: Dongni Yang --- .../actions/sandbox/doctor-inference.test.ts | 15 +++++++++++++++ src/lib/actions/sandbox/doctor-inference.ts | 16 ++++++++++------ src/lib/actions/sandbox/doctor.ts | 13 ++++++++++++- src/lib/agent/defs.test.ts | 19 +++++++++++++++++-- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/lib/actions/sandbox/doctor-inference.test.ts b/src/lib/actions/sandbox/doctor-inference.test.ts index 0a558df9cce..d6088c7d842 100644 --- a/src/lib/actions/sandbox/doctor-inference.test.ts +++ b/src/lib/actions/sandbox/doctor-inference.test.ts @@ -184,6 +184,21 @@ describe("doctor inference checks", () => { ); }); + it("omits the serving process check when the agent has declared self_report (#7003)", async () => { + const checks = await collectInferenceChecks( + "alpha", + { provider: "nvidia-prod", model: "model" }, + true, + { + probeProviderHealthImpl: () => upstream(), + probeSandboxInferenceGatewayHealthImpl: async () => gateway(true), + agentHasSelfReport: true, + }, + ); + + expect(checks).not.toContainEqual(expect.objectContaining({ label: "Serving process" })); + }); + it("does not mutate direct provider health while adding route evidence", async () => { const providerHealth = upstream(); diff --git a/src/lib/actions/sandbox/doctor-inference.ts b/src/lib/actions/sandbox/doctor-inference.ts index 5e4912bb214..9b1285d4b08 100644 --- a/src/lib/actions/sandbox/doctor-inference.ts +++ b/src/lib/actions/sandbox/doctor-inference.ts @@ -15,6 +15,8 @@ export type DoctorInferenceRoute = { type DoctorInferenceDeps = { probeProviderHealthImpl?: typeof probeProviderHealth; probeSandboxInferenceGatewayHealthImpl?: typeof probeSandboxInferenceGatewayHealth; + /** True when the agent has declared a self_report endpoint; suppresses the "not checked" leg. */ + agentHasSelfReport?: boolean; }; function pushInferenceHealthCheck( @@ -159,11 +161,13 @@ export async function collectInferenceChecks( // Serving-process leg: the above probes run in a fresh exec with OpenShell's // injected env, so they cannot attest what the long-running gateway process // can reach. Report explicitly when no self-report source is declared (#7003). - checks.push({ - group: "Inference", - label: "Serving process", - status: "info", - detail: "not checked — no self_report endpoint declared for this agent", - }); + if (!deps.agentHasSelfReport) { + checks.push({ + group: "Inference", + label: "Serving process", + status: "info", + detail: "not checked — no self_report endpoint declared for this agent", + }); + } return checks; } diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index b6abbbde3a5..19ead99bd46 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -7,6 +7,7 @@ import { stripAnsi } from "../../adapters/openshell/client"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; +import { loadAgent } from "../../agent/defs"; import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { GATEWAY_PORT } from "../../core/ports"; @@ -387,6 +388,14 @@ function collectToolScopeChecks( }); } +function resolveAgentHasSelfReport(agentName: string | null | undefined): boolean { + try { + return loadAgent(agentName || "openclaw").selfReport !== null; + } catch { + return false; + } +} + async function collectDoctorChecks( sandboxName: string, sb: SandboxEntry | null | undefined, @@ -401,7 +410,9 @@ async function collectDoctorChecks( ...host.checks, ...gateway.checks, ...sandbox.checks, - ...(await collectInferenceChecks(sandboxName, route, sandbox.reachable)), + ...(await collectInferenceChecks(sandboxName, route, sandbox.reachable, { + agentHasSelfReport: resolveAgentHasSelfReport(sb?.agent), + })), ...collectRegisteredSandboxChecks(sandboxName, sb, intent.wantsFix, sandbox.reachable), ...collectToolScopeChecks(sandboxName, sb, sandbox.reachable, intent.wantsFix), ollamaDoctorCheck(route.provider), diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index b242d67a9f0..5ded8399cc2 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -408,7 +408,7 @@ describe("agent definitions", () => { expect(agent.selfReport).toBeNull(); }); - it("parses self_report url and timeout from manifests (#7003)", () => { + it("parses self_report url and explicit timeout from manifests (#7003)", () => { const agentName = `has-self-report-${String(Date.now())}`; writeTempAgentManifest( agentName, @@ -416,10 +416,25 @@ describe("agent definitions", () => { `name: ${agentName}`, "self_report:", ' url: "http://localhost:18789/health/monitor"', - " timeout_seconds: 10", + " timeout_seconds: 7", ].join("\n"), ); const agent = loadAgent(agentName); + expect(agent.selfReport).toEqual({ + url: "http://localhost:18789/health/monitor", + timeout_seconds: 7, + }); + }); + + it("falls back to timeout_seconds 10 when self_report omits it (#7003)", () => { + const agentName = `self-report-no-timeout-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "self_report:", ' url: "http://localhost:18789/health/monitor"'].join( + "\n", + ), + ); + const agent = loadAgent(agentName); expect(agent.selfReport).toEqual({ url: "http://localhost:18789/health/monitor", timeout_seconds: 10, From 483b28833a728d432cbc108190d85fbc1f30c5c8 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Fri, 17 Jul 2026 15:59:52 +0800 Subject: [PATCH 3/7] chore(sandbox): clarify agentHasSelfReport doc comment Refs #7003 Signed-off-by: Dongni Yang --- src/lib/actions/sandbox/doctor-inference.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/doctor-inference.ts b/src/lib/actions/sandbox/doctor-inference.ts index 9b1285d4b08..4c918b0b4c7 100644 --- a/src/lib/actions/sandbox/doctor-inference.ts +++ b/src/lib/actions/sandbox/doctor-inference.ts @@ -15,7 +15,7 @@ export type DoctorInferenceRoute = { type DoctorInferenceDeps = { probeProviderHealthImpl?: typeof probeProviderHealth; probeSandboxInferenceGatewayHealthImpl?: typeof probeSandboxInferenceGatewayHealth; - /** True when the agent has declared a self_report endpoint; suppresses the "not checked" leg. */ + /** True when the agent's manifest declares a self_report endpoint; suppresses the "not checked" leg. */ agentHasSelfReport?: boolean; }; From c42c257d4d319ba962fc72fd1a4ce088b04013a6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 03:16:48 -0700 Subject: [PATCH 4/7] fix(sandbox): keep serving health explicitly unchecked Co-authored-by: Dongni Yang Signed-off-by: Apurv Kumaria --- docs/reference/commands.mdx | 9 +- src/lib/actions/sandbox/doctor-flow.test.ts | 53 ++++++- .../actions/sandbox/doctor-inference.test.ts | 8 +- src/lib/actions/sandbox/doctor-inference.ts | 12 +- src/lib/actions/sandbox/doctor.ts | 13 +- src/lib/actions/sandbox/status-flow.test.ts | 2 + .../actions/sandbox/status-inference.test.ts | 22 ++- src/lib/actions/sandbox/status-snapshot.ts | 22 ++- src/lib/actions/sandbox/status-text.ts | 10 +- src/lib/agent/defs.test.ts | 131 +++++++++++++++++- src/lib/agent/manifest-readers.ts | 101 +++++++++++++- test/support/status-flow-test-harness.ts | 13 +- 12 files changed, 346 insertions(+), 50 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 303d509e66e..ad4a4c7d54b 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1180,7 +1180,7 @@ Use this form when you care about a specific sandbox's live OpenShell state, age Do not pass a sandbox name to `$$nemoclaw status`; that command is the global all-sandbox/service overview. Pass `--json` to emit a structured per-sandbox report instead of the text renderer. -The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, `terminalRuntimeHealth`, and `dockerPaused`. +The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, `terminalRuntimeHealth`, `servingProcessHealth`, and `dockerPaused`. The schema-version `1` `model` and `provider` fields keep their established live-route meaning when the gateway route is readable. Use `recordedRoute` for the sandbox's durable provider and model and `liveRoute` for the gateway-global route. When the live shared route differs, text output prints both routes and JSON output sets `routeDrift.live`, `routeDrift.recorded`, and `routeDrift.canConnect`. @@ -1192,6 +1192,10 @@ Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-ga In that case, text output keeps OpenShell's authoritative phase but prints a `docker unpause ` recovery hint instead of sending you directly to rebuild. For terminal runtime sandboxes, the command also checks cgroup OOM kill counters. If the counter records an OOM kill, text output prints `Runtime health: degraded (... OOM kill recorded)` and points you to `$$nemoclaw rebuild`; JSON output reports `terminalRuntimeHealth.kind: "degraded"` with the OOM kill count and source counter path. +For a present gateway runtime, text output prints `Serving process ( gateway): not checked`, and JSON output reports `servingProcessHealth: { "checked": false }`. +The existing inference probes run in a fresh sandbox command, so they do not attest that the long-running gateway process has equivalent inference access. +NemoClaw does not probe the serving process yet. +For terminal runtimes, `servingProcessHealth` is `null` and the text output omits this line because there is no long-running gateway process. The command exits non-zero when the sandbox is missing locally, the gateway state is not `present`, the gateway reports a schema/protobuf mismatch (mirrored as `rpcIssue`), `failureLayer` is non-null, the authoritative in-sandbox inference route fails or cannot be probed, or a terminal runtime sandbox reports a recorded OOM kill. The alias form `$$nemoclaw status --json` requires the sandbox to be registered locally; the canonical form `$$nemoclaw sandbox status --json` is the one to use from automation that may run against an unknown sandbox name, since it still emits a JSON document with `found: false` instead of a text error. @@ -1351,6 +1355,9 @@ For inference health, `doctor` treats the probe to `https://inference.local/v1/m HTTP responses from `200` through `499`, including `401` and `403`, pass this check. HTTP `500` through `599`, interim `100` through `199`, transport failures with status `000`, invalid status values, and an unavailable authoritative probe fail the check. Direct provider and upstream probes are diagnostics only, so their failure does not fail `doctor` when the authoritative in-sandbox route is healthy. +For gateway runtimes, `doctor` also reports an informational `Serving process: not checked` result because its fresh sandbox probes do not attest the long-running gateway process. +This result does not fail the readiness check. +Terminal runtimes omit it because they have no long-running gateway process. Warnings do not make the command fail. Failed checks, including a failed or unavailable authoritative inference route, exit non-zero so scripts can use `doctor` as a readiness gate. diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index d1a105a5f2c..5a015576339 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -267,6 +267,40 @@ describe("runSandboxDoctor flow", () => { }, ); + it.each([ + { label: "without", selfReport: null }, + { + label: "with", + selfReport: { url: "http://127.0.0.1:18789/health", timeout_seconds: 10 }, + }, + ])("keeps serving-process health unchecked for gateway manifests $label self_report (#7003)", async ({ + selfReport, + }) => { + const harness = createDoctorHarness(); + harness.loadAgentSpy.mockReturnValue({ + name: "openclaw", + runtime: { kind: "gateway" }, + selfReport, + configPaths: { + dir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json", + }, + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(harness.loadAgentSpy).toHaveBeenCalledWith("openclaw"); + expect(report?.checks).toContainEqual( + expect.objectContaining({ + group: "Inference", + label: "Serving process", + status: "info", + detail: "not checked — serving-process self_report probing is not implemented", + }), + ); + }); + it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); @@ -412,21 +446,34 @@ describe("runSandboxDoctor flow", () => { ]); }); - it("skips OpenClaw tool-scope checks for other agents", async () => { + it("skips gateway-specific and OpenClaw checks for terminal agents", async () => { const harness = createDoctorHarness(); harness.getSandboxSpy.mockReturnValue({ name: "alpha", - agent: "hermes", + agent: "langchain-deepagents-code", model: "registry-model", provider: "ollama-local", openshellDriver: "docker", gatewayName: "nemoclaw-19080", gatewayPort: 19080, }); + harness.loadAgentSpy.mockReturnValue({ + name: "langchain-deepagents-code", + runtime: { kind: "terminal", interactive_command: "deepagents" }, + selfReport: null, + configPaths: { + dir: "/sandbox/.deepagents", + configFile: "config.json", + format: "json", + }, + }); - await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); expect(harness.buildToolScopeChecksSpy).not.toHaveBeenCalled(); + expect(report?.checks).not.toContainEqual( + expect.objectContaining({ group: "Inference", label: "Serving process" }), + ); }); it("appends the local gateway result without mutating provider health", async () => { diff --git a/src/lib/actions/sandbox/doctor-inference.test.ts b/src/lib/actions/sandbox/doctor-inference.test.ts index d6088c7d842..f5bf309b0a3 100644 --- a/src/lib/actions/sandbox/doctor-inference.test.ts +++ b/src/lib/actions/sandbox/doctor-inference.test.ts @@ -164,7 +164,7 @@ describe("doctor inference checks", () => { ); }); - it("includes a serving process check as not checked when no self_report is declared (#7003)", async () => { + it("keeps serving-process health explicitly unchecked until a probe contract exists (#7003)", async () => { const checks = await collectInferenceChecks( "alpha", { provider: "nvidia-prod", model: "model" }, @@ -179,12 +179,12 @@ describe("doctor inference checks", () => { expect.objectContaining({ label: "Serving process", status: "info", - detail: expect.stringContaining("not checked"), + detail: "not checked — serving-process self_report probing is not implemented", }), ); }); - it("omits the serving process check when the agent has declared self_report (#7003)", async () => { + it("omits serving-process health for terminal agents without a gateway process (#7003)", async () => { const checks = await collectInferenceChecks( "alpha", { provider: "nvidia-prod", model: "model" }, @@ -192,7 +192,7 @@ describe("doctor inference checks", () => { { probeProviderHealthImpl: () => upstream(), probeSandboxInferenceGatewayHealthImpl: async () => gateway(true), - agentHasSelfReport: true, + includeServingProcessCheck: false, }, ); diff --git a/src/lib/actions/sandbox/doctor-inference.ts b/src/lib/actions/sandbox/doctor-inference.ts index 4c918b0b4c7..a414f33c06c 100644 --- a/src/lib/actions/sandbox/doctor-inference.ts +++ b/src/lib/actions/sandbox/doctor-inference.ts @@ -15,8 +15,8 @@ export type DoctorInferenceRoute = { type DoctorInferenceDeps = { probeProviderHealthImpl?: typeof probeProviderHealth; probeSandboxInferenceGatewayHealthImpl?: typeof probeSandboxInferenceGatewayHealth; - /** True when the agent's manifest declares a self_report endpoint; suppresses the "not checked" leg. */ - agentHasSelfReport?: boolean; + /** False for terminal agents that do not have a long-running gateway serving process. */ + includeServingProcessCheck?: boolean; }; function pushInferenceHealthCheck( @@ -160,13 +160,15 @@ export async function collectInferenceChecks( } // Serving-process leg: the above probes run in a fresh exec with OpenShell's // injected env, so they cannot attest what the long-running gateway process - // can reach. Report explicitly when no self-report source is declared (#7003). - if (!deps.agentHasSelfReport) { + // can reach. A manifest declaration is plumbing only until NemoClaw defines + // and implements a self-report response contract, so it must not suppress + // this honest result (#7003). + if (deps.includeServingProcessCheck !== false) { checks.push({ group: "Inference", label: "Serving process", status: "info", - detail: "not checked — no self_report endpoint declared for this agent", + detail: "not checked — serving-process self_report probing is not implemented", }); } return checks; diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 435672a63a5..79cdc472dfb 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -7,7 +7,7 @@ import { stripAnsi } from "../../adapters/openshell/client"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; -import { loadAgent } from "../../agent/defs"; +import { getAgentRuntimeKind, loadAgent } from "../../agent/defs"; import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { GATEWAY_PORT } from "../../core/ports"; @@ -387,11 +387,14 @@ function collectToolScopeChecks( }); } -function resolveAgentHasSelfReport(agentName: string | null | undefined): boolean { +function shouldReportServingProcessHealth(agentName: string | null | undefined): boolean { + const resolvedName = agentName || "openclaw"; try { - return loadAgent(agentName || "openclaw").selfReport !== null; + return getAgentRuntimeKind(loadAgent(resolvedName)) === "gateway"; } catch { - return false; + // Status preserves OpenClaw's gateway default if its manifest cannot be + // loaded, while unknown non-default agents are classified as unknown. + return resolvedName === "openclaw"; } } @@ -410,7 +413,7 @@ async function collectDoctorChecks( ...gateway.checks, ...sandbox.checks, ...(await collectInferenceChecks(sandboxName, route, sandbox.reachable, { - agentHasSelfReport: resolveAgentHasSelfReport(sb?.agent), + includeServingProcessCheck: shouldReportServingProcessHealth(sb?.agent), })), ...collectRegisteredSandboxChecks(sandboxName, sb, intent.wantsFix, sandbox.reachable), ...collectToolScopeChecks(sandboxName, sb, sandbox.reachable, intent.wantsFix), diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index 25c1b11321b..b3fbb50cc3e 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -109,6 +109,8 @@ describe("showSandboxStatus flow", () => { expect(output).toContain("Model: nvidia/nemotron"); expect(output).toContain("Inference: healthy"); expect(output).toContain("Inference (ollama backend):"); + expect(output).toContain("Serving process (openclaw gateway):"); + expect(output).toContain("not checked"); expect(output).toContain("Host GPU: yes"); expect(output).toContain("last CUDA proof failed: cuInit"); expect(output).toContain("CUDA initialization failed"); diff --git a/src/lib/actions/sandbox/status-inference.test.ts b/src/lib/actions/sandbox/status-inference.test.ts index e535b3dea3c..fd038918548 100644 --- a/src/lib/actions/sandbox/status-inference.test.ts +++ b/src/lib/actions/sandbox/status-inference.test.ts @@ -6,6 +6,7 @@ import { collectSandboxStatusSnapshot, getSandboxStatusInferenceHealth } from ". describe("sandbox status inference.local route health (#6192)", () => { function snapshotDeps(options: { + agent?: string; provider?: string; liveProvider?: string; liveModel?: string; @@ -23,7 +24,7 @@ describe("sandbox status inference.local route health (#6192)", () => { const reportInferenceProbeError = vi.fn(); const sandbox = { name: "alpha", - agent: "openclaw", + agent: options.agent ?? "openclaw", model: "nvidia/nemotron", provider, }; @@ -51,6 +52,7 @@ describe("sandbox status inference.local route health (#6192)", () => { ? async () => Promise.reject(new Error("openshell unavailable TOKEN=super-secret")) : async () => options.routeHealth, ), + probeTerminalRuntimeHealth: vi.fn(() => ({ kind: "ok" as const, oomKillCount: 0 as const })), reportInferenceProbeError, }; } @@ -83,6 +85,24 @@ describe("sandbox status inference.local route health (#6192)", () => { expect(snapshot.inferenceHealth?.subprobes).toEqual([ expect.objectContaining({ ok: true, probeLabel: "upstream" }), ]); + expect(snapshot.servingProcessHealth).toEqual({ checked: false }); + }); + + it("does not invent serving-process health for terminal agents (#7003)", async () => { + const deps = snapshotDeps({ + agent: "langchain-deepagents-code", + routeHealth: { + ok: true, + endpoint: "https://inference.local/v1/models", + httpStatus: 200, + detail: "route reachable", + }, + }); + + const snapshot = await collectSandboxStatusSnapshot("alpha", { deps }); + + expect(snapshot.servingProcessHealth).toBeNull(); + expect(deps.probeTerminalRuntimeHealth).toHaveBeenCalledWith("alpha"); }); it.each([ diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 4e66ba0ed11..31cab6fd59a 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -53,13 +53,11 @@ type ProbeProviderHealth = ( type ProbeSandboxInferenceGatewayHealth = typeof probeSandboxInferenceGatewayHealth; /** - * Health as reported by the serving process itself. `checked: false` means the - * agent declared no self_report endpoint so NemoClaw cannot attest what the - * serving process's environment can reach. + * Honest serving-process state while the self-report response and probe + * contracts remain undefined. Do not add a checked result until both contracts + * and their failure mapping are implemented together. */ -export type ServingProcessHealth = - | { checked: false } - | { checked: true; ok: boolean; detail: string }; +export type ServingProcessHealth = { checked: false }; export function getSandboxStatusInferenceHealth( gatewayPresent: boolean, @@ -170,9 +168,9 @@ export interface SandboxStatusReport { failureLayer: SandboxStatusFailureLayer | null; terminalRuntimeHealth: TerminalRuntimeOomProbeResult | null; /** - * Health sourced from the serving process itself (via its declared self_report - * endpoint). Null when the sandbox is not reachable or the agent runtime is not - * gateway-based. `checked: false` when the agent declares no self_report. + * Whether serving-process health was checked. Null when the sandbox is not + * reachable or the agent runtime is not gateway-based. This remains + * `checked: false` until a self-report probe contract is implemented. */ servingProcessHealth: ServingProcessHealth | null; /** @@ -403,9 +401,9 @@ export async function collectSandboxStatusSnapshot( lookup.state === "present" && statusAgent.agentRuntime === "terminal" ? (opts.deps?.probeTerminalRuntimeHealth ?? probeTerminalRuntimeCgroupOom)(sandboxName) : null; - // The serving process health leg is only meaningful when the gateway is up. - // When the agent declares no self_report endpoint, report checked: false so - // status shows "not checked" rather than staying silently green (#7003). + // The serving-process leg is only meaningful when the gateway is up. A + // manifest declaration alone is not evidence: no self-report response/probe + // contract exists yet, so status must stay explicitly unchecked (#7003). const servingProcessHealth: ServingProcessHealth | null = lookup.state === "present" && statusAgent.agentRuntime === "gateway" ? { checked: false } diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index 453dcc478d7..9e6411ff7b5 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -103,15 +103,7 @@ function printServingProcessHealth( ): void { if (!health) return; const label = `Serving process (${statusAgent.agentDisplayName.toLowerCase()} gateway)`; - if (!health.checked) { - console.log(` ${label}: ${D}not checked${R}`); - return; - } - if (health.ok) { - console.log(` ${label}: ${G}${health.detail}${R}`); - return; - } - console.log(` ${label}: ${RD}${health.detail}${R}`); + console.log(` ${label}: ${D}not checked${R}`); } function printInferenceStatus(context: SandboxStatusTextContext): void { diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 5ded8399cc2..183130f1a9b 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -408,12 +408,16 @@ describe("agent definitions", () => { expect(agent.selfReport).toBeNull(); }); - it("parses self_report url and explicit timeout from manifests (#7003)", () => { + it("parses self_report when health_probe declares its port (#7003)", () => { const agentName = `has-self-report-${String(Date.now())}`; writeTempAgentManifest( agentName, [ `name: ${agentName}`, + "health_probe:", + ' url: "http://localhost:18789/health"', + " port: 18789", + " timeout_seconds: 10", "self_report:", ' url: "http://localhost:18789/health/monitor"', " timeout_seconds: 7", @@ -430,9 +434,13 @@ describe("agent definitions", () => { const agentName = `self-report-no-timeout-${String(Date.now())}`; writeTempAgentManifest( agentName, - [`name: ${agentName}`, "self_report:", ' url: "http://localhost:18789/health/monitor"'].join( - "\n", - ), + [ + `name: ${agentName}`, + "forward_ports:", + " - 18789", + "self_report:", + ' url: "http://localhost:18789/health/monitor"', + ].join("\n"), ); const agent = loadAgent(agentName); expect(agent.selfReport).toEqual({ @@ -449,4 +457,119 @@ describe("agent definitions", () => { ); expect(() => loadAgent(agentName)).toThrow(/self_report\.url/); }); + + it.each([ + "http://127.0.0.1:18789/health/monitor", + "http://[::1]:18789/health/monitor", + ])("accepts an explicit loopback self_report endpoint %s (#7003)", (url) => { + const agentName = `self-report-loopback-${String(Date.now())}-${url.length}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "forward_ports:", " - 18789", "self_report:", ` url: "${url}"`].join( + "\n", + ), + ); + + expect(loadAgent(agentName).selfReport).toEqual({ url, timeout_seconds: 10 }); + }); + + it.each([ + ["malformed", "not-a-url"], + ["leading whitespace", " http://127.0.0.1:18789/health"], + ["internal whitespace", "http://127.0.0.1:18789/health monitor"], + ["unicode whitespace", "http://127.0.0.1:18789/health\u00a0monitor"], + ["https", "https://127.0.0.1:18789/health"], + ["public host", "http://example.com:18789/health"], + ["private host", "http://10.0.0.8:18789/health"], + ["credentials", "http://user:pass@127.0.0.1:18789/health"], + ["implicit port", "http://127.0.0.1/health"], + ["root path", "http://127.0.0.1:18789/"], + ["double slash path", "http://127.0.0.1:18789/health//monitor"], + ["dot segment", "http://127.0.0.1:18789/health/../monitor"], + ["encoded traversal", "http://127.0.0.1:18789/%2e%2e/monitor"], + ["encoded control", "http://127.0.0.1:18789/health/%0d%0aheader"], + ["backslash path", "http://127.0.0.1:18789/health\\monitor"], + ["query", "http://127.0.0.1:18789/health?token=x"], + ["fragment", "http://127.0.0.1:18789/health#detail"], + ])("rejects a %s self_report URL before it can become a probe target (#7003)", (label, url) => { + const agentName = `self-report-bad-url-${label.replaceAll(" ", "-")}-${String(Date.now())}`; + const yamlUrl = url.replaceAll("\\", "\\\\"); + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "forward_ports:", + " - 18789", + "self_report:", + ` url: "${yamlUrl}"`, + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/self_report\.url/); + }); + + it.each([ + "0", + "-1", + "0.5", + "11", + "31", + "1000000000", + '"7"', + ".nan", + ".inf", + ])("rejects unsafe self_report timeout_seconds %s (#7003)", (timeout) => { + const agentName = `self-report-bad-timeout-${timeout.replaceAll(/[^a-z0-9]/gi, "x")}-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "forward_ports:", + " - 18789", + "self_report:", + ' url: "http://127.0.0.1:18789/health"', + ` timeout_seconds: ${timeout}`, + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/self_report\.timeout_seconds.*between 1 and 10/); + }); + + it("rejects a self_report port not declared by health_probe or forward_ports (#7003)", () => { + const agentName = `self-report-undeclared-port-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "forward_ports:", + " - 18789", + "self_report:", + ' url: "http://127.0.0.1:19000/health"', + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow( + /self_report\.url.*health_probe\.port or forward_ports/, + ); + }); + + it("rejects non-object and unknown self_report fields (#7003)", () => { + const scalarName = `self-report-scalar-${String(Date.now())}`; + writeTempAgentManifest(scalarName, `name: ${scalarName}\nself_report: disabled\n`); + expect(() => loadAgent(scalarName)).toThrow(/self_report.*object/); + + const unknownName = `self-report-unknown-${String(Date.now())}`; + writeTempAgentManifest( + unknownName, + [ + `name: ${unknownName}`, + "forward_ports:", + " - 18789", + "self_report:", + ' url: "http://127.0.0.1:18789/health"', + " method: GET", + ].join("\n"), + ); + expect(() => loadAgent(unknownName)).toThrow(/self_report\.method.*not allowed/); + }); }); diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index 5d8f5872d0e..4c3b88086aa 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import { isPlainObject } from "../core/json-types"; +import { isLoopbackHostname } from "../private-networks"; import { isSafeModelId } from "../validation"; import type { AgentDashboard, @@ -66,6 +67,10 @@ export function readStringArray(record: ManifestRecord, key: string): string[] | const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; const STATE_FILE_FIELDS = new Set(["path", "strategy", "restore"]); +const SELF_REPORT_FIELDS = new Set(["url", "timeout_seconds"]); +const SELF_REPORT_DEFAULT_TIMEOUT_SECONDS = 10; +const SELF_REPORT_MAX_TIMEOUT_SECONDS = 10; +const SELF_REPORT_MAX_URL_LENGTH = 2048; function assertStateFilePath(value: string, field: string): void { if (value.length === 0) { @@ -227,21 +232,107 @@ export function readHealthProbe(record: ManifestRecord): AgentHealthProbe | unde return undefined; } +function selfReportAllowedPorts(record: ManifestRecord): Set { + const ports = new Set(readPortArray(record, "forward_ports") ?? []); + const healthProbePort = readObject(record, "health_probe")?.port; + if (isValidPort(healthProbePort)) ports.add(healthProbePort); + return ports; +} + +function validateSelfReportUrl(value: string, allowedPorts: ReadonlySet): string { + if ( + value !== value.trim() || + value.length === 0 || + value.length > SELF_REPORT_MAX_URL_LENGTH || + CONTROL_CHAR_RE.test(value) || + /\s/u.test(value) || + value.includes("%") || + value.includes("\\") + ) { + throw new Error( + "Agent manifest field 'self_report.url' must be a bounded canonical URL without whitespace, control characters, percent escapes, or backslashes", + ); + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("Agent manifest field 'self_report.url' must be an absolute URL"); + } + + if (parsed.protocol !== "http:") { + throw new Error("Agent manifest field 'self_report.url' must use http"); + } + if (!isLoopbackHostname(parsed.hostname)) { + throw new Error("Agent manifest field 'self_report.url' must target a loopback host"); + } + if (parsed.username || parsed.password) { + throw new Error("Agent manifest field 'self_report.url' must not include credentials"); + } + const port = Number(parsed.port); + if (!parsed.port || !isValidPort(port, 1024)) { + throw new Error( + "Agent manifest field 'self_report.url' must include an explicit TCP port between 1024 and 65535", + ); + } + if (!allowedPorts.has(port)) { + throw new Error( + "Agent manifest field 'self_report.url' port must be declared by health_probe.port or forward_ports", + ); + } + if (parsed.search || parsed.hash) { + throw new Error("Agent manifest field 'self_report.url' must not include a query or fragment"); + } + const authorityStart = value.indexOf("://") + 3; + const pathStart = value.indexOf("/", authorityStart); + const rawPath = pathStart === -1 ? "/" : value.slice(pathStart); + const pathSegments = rawPath.slice(1).split("/"); + if ( + rawPath === "/" || + rawPath !== parsed.pathname || + pathSegments.some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + throw new Error( + "Agent manifest field 'self_report.url' must include a canonical non-root endpoint path without empty or dot segments", + ); + } + return value; +} + export function readSelfReport(record: ManifestRecord): AgentSelfReport | undefined { + if (record.self_report === undefined) return undefined; const selfReport = readObject(record, "self_report"); - if (!selfReport) return undefined; + if (!selfReport) { + throw new Error("Agent manifest field 'self_report' must be an object"); + } + for (const key of Object.keys(selfReport)) { + if (!SELF_REPORT_FIELDS.has(key)) { + throw new Error(`Agent manifest field 'self_report.${key}' is not allowed`); + } + } const url = readString(selfReport, "url"); if (!url) { throw new Error("Agent manifest field 'self_report.url' is required"); } - const timeoutSeconds = selfReport.timeout_seconds; - if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds)) { - return { url, timeout_seconds: timeoutSeconds }; + const timeoutSeconds = selfReport.timeout_seconds ?? SELF_REPORT_DEFAULT_TIMEOUT_SECONDS; + if ( + typeof timeoutSeconds !== "number" || + !Number.isInteger(timeoutSeconds) || + timeoutSeconds < 1 || + timeoutSeconds > SELF_REPORT_MAX_TIMEOUT_SECONDS + ) { + throw new Error( + `Agent manifest field 'self_report.timeout_seconds' must be an integer between 1 and ${String(SELF_REPORT_MAX_TIMEOUT_SECONDS)}`, + ); } - return { url, timeout_seconds: 10 }; + return { + url: validateSelfReportUrl(url, selfReportAllowedPorts(record)), + timeout_seconds: timeoutSeconds, + }; } export function readDashboard(record: ManifestRecord): AgentDashboard { diff --git a/test/support/status-flow-test-harness.ts b/test/support/status-flow-test-harness.ts index 180bdc3acef..871864d4bce 100644 --- a/test/support/status-flow-test-harness.ts +++ b/test/support/status-flow-test-harness.ts @@ -7,7 +7,10 @@ import { type MockInstance, vi } from "vitest"; import type { SandboxGatewayState } from "../../src/lib/actions/sandbox/gateway-state"; import type { SandboxStatusPreflightResult } from "../../src/lib/actions/sandbox/status-preflight"; -import type { SandboxStatusRouteDrift } from "../../src/lib/actions/sandbox/status-snapshot"; +import type { + SandboxStatusRouteDrift, + ServingProcessHealth, +} from "../../src/lib/actions/sandbox/status-snapshot"; import type { ProviderHealthStatus } from "../../src/lib/inference/health"; type ShowSandboxStatus = typeof import("../../src/lib/actions/sandbox/status")["showSandboxStatus"]; @@ -56,6 +59,7 @@ export type StatusFlowHarnessOptions = { currentProvider?: string; routeDrift?: SandboxStatusRouteDrift | null; inferenceHealth?: ProviderHealthStatus | null; + servingProcessHealth?: ServingProcessHealth | null; lookup?: SandboxGatewayState; lookupState?: "present" | "missing"; preflight?: SandboxStatusPreflightResult; @@ -167,6 +171,13 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): ], } : options.inferenceHealth, + terminalRuntimeHealth: null, + servingProcessHealth: + options.servingProcessHealth === undefined + ? sandboxEntry.agent === "langchain-deepagents-code" + ? null + : { checked: false } + : options.servingProcessHealth, }); const getSandboxDockerRuntimeSpy = vi .spyOn(dockerHealth, "getSandboxDockerRuntime") From 9bd58dc07913a43c7d6e905e458745d51569eb47 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 03:40:49 -0700 Subject: [PATCH 5/7] test(sandbox): cover serving health status report Co-authored-by: Dongni Yang Signed-off-by: Apurv Kumaria --- src/lib/actions/sandbox/status-inference.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/status-inference.test.ts b/src/lib/actions/sandbox/status-inference.test.ts index fd038918548..06c91d07b78 100644 --- a/src/lib/actions/sandbox/status-inference.test.ts +++ b/src/lib/actions/sandbox/status-inference.test.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { collectSandboxStatusSnapshot, getSandboxStatusInferenceHealth } from "./status"; +import { + collectSandboxStatusSnapshot, + getSandboxStatusInferenceHealth, + getSandboxStatusReport, +} from "./status"; describe("sandbox status inference.local route health (#6192)", () => { function snapshotDeps(options: { @@ -86,6 +90,9 @@ describe("sandbox status inference.local route health (#6192)", () => { expect.objectContaining({ ok: true, probeLabel: "upstream" }), ]); expect(snapshot.servingProcessHealth).toEqual({ checked: false }); + + const report = await getSandboxStatusReport("alpha", deps); + expect(report.servingProcessHealth).toEqual({ checked: false }); }); it("does not invent serving-process health for terminal agents (#7003)", async () => { @@ -103,6 +110,9 @@ describe("sandbox status inference.local route health (#6192)", () => { expect(snapshot.servingProcessHealth).toBeNull(); expect(deps.probeTerminalRuntimeHealth).toHaveBeenCalledWith("alpha"); + + const report = await getSandboxStatusReport("alpha", deps); + expect(report.servingProcessHealth).toBeNull(); }); it.each([ From 55a40b64bf73e33095953a28b6f879e3457ff5db Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:33:51 -0700 Subject: [PATCH 6/7] refactor(agent): defer self-report manifest contract --- src/lib/actions/sandbox/doctor-flow.test.ts | 32 ++-- .../actions/sandbox/doctor-inference.test.ts | 2 +- src/lib/actions/sandbox/doctor-inference.ts | 7 +- src/lib/agent/definition-types.ts | 7 - src/lib/agent/defs.test.ts | 172 ------------------ src/lib/agent/defs.ts | 9 - .../hermes-recovery-boundary-fixtures.ts | 1 - src/lib/agent/manifest-readers.ts | 109 ----------- src/lib/agent/onboard.test.ts | 1 - src/lib/agent/runtime.test.ts | 1 - test/helpers/base-image-test-harness.ts | 1 - 11 files changed, 21 insertions(+), 321 deletions(-) diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 5a015576339..6a40fb06130 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -268,35 +268,38 @@ describe("runSandboxDoctor flow", () => { ); it.each([ - { label: "without", selfReport: null }, - { - label: "with", - selfReport: { url: "http://127.0.0.1:18789/health", timeout_seconds: 10 }, - }, - ])("keeps serving-process health unchecked for gateway manifests $label self_report (#7003)", async ({ - selfReport, - }) => { + "openclaw", + "hermes", + ] as const)("keeps serving-process health explicitly unchecked for the %s gateway (#7003)", async (agent) => { const harness = createDoctorHarness(); harness.loadAgentSpy.mockReturnValue({ - name: "openclaw", + name: agent, runtime: { kind: "gateway" }, - selfReport, configPaths: { - dir: "/sandbox/.openclaw", - configFile: "openclaw.json", + dir: "/sandbox/.agent", + configFile: "config.json", format: "json", }, }); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent, + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + }); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - expect(harness.loadAgentSpy).toHaveBeenCalledWith("openclaw"); + expect(harness.loadAgentSpy).toHaveBeenCalledWith(agent); expect(report?.checks).toContainEqual( expect.objectContaining({ group: "Inference", label: "Serving process", status: "info", - detail: "not checked — serving-process self_report probing is not implemented", + detail: "not checked — serving-process probing is not implemented", }), ); }); @@ -460,7 +463,6 @@ describe("runSandboxDoctor flow", () => { harness.loadAgentSpy.mockReturnValue({ name: "langchain-deepagents-code", runtime: { kind: "terminal", interactive_command: "deepagents" }, - selfReport: null, configPaths: { dir: "/sandbox/.deepagents", configFile: "config.json", diff --git a/src/lib/actions/sandbox/doctor-inference.test.ts b/src/lib/actions/sandbox/doctor-inference.test.ts index f5bf309b0a3..bf641d7bc9e 100644 --- a/src/lib/actions/sandbox/doctor-inference.test.ts +++ b/src/lib/actions/sandbox/doctor-inference.test.ts @@ -179,7 +179,7 @@ describe("doctor inference checks", () => { expect.objectContaining({ label: "Serving process", status: "info", - detail: "not checked — serving-process self_report probing is not implemented", + detail: "not checked — serving-process probing is not implemented", }), ); }); diff --git a/src/lib/actions/sandbox/doctor-inference.ts b/src/lib/actions/sandbox/doctor-inference.ts index a414f33c06c..d7946c2163a 100644 --- a/src/lib/actions/sandbox/doctor-inference.ts +++ b/src/lib/actions/sandbox/doctor-inference.ts @@ -160,15 +160,14 @@ export async function collectInferenceChecks( } // Serving-process leg: the above probes run in a fresh exec with OpenShell's // injected env, so they cannot attest what the long-running gateway process - // can reach. A manifest declaration is plumbing only until NemoClaw defines - // and implements a self-report response contract, so it must not suppress - // this honest result (#7003). + // can reach. Until NemoClaw defines and implements a process-owned probe + // contract, keep this honest result explicit (#7003). if (deps.includeServingProcessCheck !== false) { checks.push({ group: "Inference", label: "Serving process", status: "info", - detail: "not checked — serving-process self_report probing is not implemented", + detail: "not checked — serving-process probing is not implemented", }); } return checks; diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index b9627aae1d0..715b6120cf8 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -16,11 +16,6 @@ export interface AgentHealthProbe { timeout_seconds: number; } -export interface AgentSelfReport { - url: string; - timeout_seconds: number; -} - export interface AgentConfigPaths { dir: string; configFile: string; @@ -121,7 +116,6 @@ export interface AgentDefinition { phone_home_hosts?: string[]; forward_ports?: number[]; health_probe?: AgentHealthProbe; - self_report?: AgentSelfReport; config?: ManifestRecord; inference?: AgentInference; mcp?: AgentMcpCapability; @@ -134,7 +128,6 @@ export interface AgentDefinition { manifestPath: string; readonly displayName: string; readonly healthProbe: AgentHealthProbe | null; - readonly selfReport: AgentSelfReport | null; readonly forwardPort: number; readonly dashboard: AgentDashboard; readonly webAuth: AgentWebAuth; diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 183130f1a9b..9b16d38ecf3 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -400,176 +400,4 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*control characters/); }); - - it("exposes selfReport as null when self_report is absent from the manifest (#7003)", () => { - const agentName = `no-self-report-${String(Date.now())}`; - writeTempAgentManifest(agentName, `name: ${agentName}\n`); - const agent = loadAgent(agentName); - expect(agent.selfReport).toBeNull(); - }); - - it("parses self_report when health_probe declares its port (#7003)", () => { - const agentName = `has-self-report-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "health_probe:", - ' url: "http://localhost:18789/health"', - " port: 18789", - " timeout_seconds: 10", - "self_report:", - ' url: "http://localhost:18789/health/monitor"', - " timeout_seconds: 7", - ].join("\n"), - ); - const agent = loadAgent(agentName); - expect(agent.selfReport).toEqual({ - url: "http://localhost:18789/health/monitor", - timeout_seconds: 7, - }); - }); - - it("falls back to timeout_seconds 10 when self_report omits it (#7003)", () => { - const agentName = `self-report-no-timeout-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "forward_ports:", - " - 18789", - "self_report:", - ' url: "http://localhost:18789/health/monitor"', - ].join("\n"), - ); - const agent = loadAgent(agentName); - expect(agent.selfReport).toEqual({ - url: "http://localhost:18789/health/monitor", - timeout_seconds: 10, - }); - }); - - it("rejects self_report entries with a missing url (#7003)", () => { - const agentName = `self-report-no-url-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [`name: ${agentName}`, "self_report:", " timeout_seconds: 10"].join("\n"), - ); - expect(() => loadAgent(agentName)).toThrow(/self_report\.url/); - }); - - it.each([ - "http://127.0.0.1:18789/health/monitor", - "http://[::1]:18789/health/monitor", - ])("accepts an explicit loopback self_report endpoint %s (#7003)", (url) => { - const agentName = `self-report-loopback-${String(Date.now())}-${url.length}`; - writeTempAgentManifest( - agentName, - [`name: ${agentName}`, "forward_ports:", " - 18789", "self_report:", ` url: "${url}"`].join( - "\n", - ), - ); - - expect(loadAgent(agentName).selfReport).toEqual({ url, timeout_seconds: 10 }); - }); - - it.each([ - ["malformed", "not-a-url"], - ["leading whitespace", " http://127.0.0.1:18789/health"], - ["internal whitespace", "http://127.0.0.1:18789/health monitor"], - ["unicode whitespace", "http://127.0.0.1:18789/health\u00a0monitor"], - ["https", "https://127.0.0.1:18789/health"], - ["public host", "http://example.com:18789/health"], - ["private host", "http://10.0.0.8:18789/health"], - ["credentials", "http://user:pass@127.0.0.1:18789/health"], - ["implicit port", "http://127.0.0.1/health"], - ["root path", "http://127.0.0.1:18789/"], - ["double slash path", "http://127.0.0.1:18789/health//monitor"], - ["dot segment", "http://127.0.0.1:18789/health/../monitor"], - ["encoded traversal", "http://127.0.0.1:18789/%2e%2e/monitor"], - ["encoded control", "http://127.0.0.1:18789/health/%0d%0aheader"], - ["backslash path", "http://127.0.0.1:18789/health\\monitor"], - ["query", "http://127.0.0.1:18789/health?token=x"], - ["fragment", "http://127.0.0.1:18789/health#detail"], - ])("rejects a %s self_report URL before it can become a probe target (#7003)", (label, url) => { - const agentName = `self-report-bad-url-${label.replaceAll(" ", "-")}-${String(Date.now())}`; - const yamlUrl = url.replaceAll("\\", "\\\\"); - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "forward_ports:", - " - 18789", - "self_report:", - ` url: "${yamlUrl}"`, - ].join("\n"), - ); - - expect(() => loadAgent(agentName)).toThrow(/self_report\.url/); - }); - - it.each([ - "0", - "-1", - "0.5", - "11", - "31", - "1000000000", - '"7"', - ".nan", - ".inf", - ])("rejects unsafe self_report timeout_seconds %s (#7003)", (timeout) => { - const agentName = `self-report-bad-timeout-${timeout.replaceAll(/[^a-z0-9]/gi, "x")}-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "forward_ports:", - " - 18789", - "self_report:", - ' url: "http://127.0.0.1:18789/health"', - ` timeout_seconds: ${timeout}`, - ].join("\n"), - ); - - expect(() => loadAgent(agentName)).toThrow(/self_report\.timeout_seconds.*between 1 and 10/); - }); - - it("rejects a self_report port not declared by health_probe or forward_ports (#7003)", () => { - const agentName = `self-report-undeclared-port-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "forward_ports:", - " - 18789", - "self_report:", - ' url: "http://127.0.0.1:19000/health"', - ].join("\n"), - ); - - expect(() => loadAgent(agentName)).toThrow( - /self_report\.url.*health_probe\.port or forward_ports/, - ); - }); - - it("rejects non-object and unknown self_report fields (#7003)", () => { - const scalarName = `self-report-scalar-${String(Date.now())}`; - writeTempAgentManifest(scalarName, `name: ${scalarName}\nself_report: disabled\n`); - expect(() => loadAgent(scalarName)).toThrow(/self_report.*object/); - - const unknownName = `self-report-unknown-${String(Date.now())}`; - writeTempAgentManifest( - unknownName, - [ - `name: ${unknownName}`, - "forward_ports:", - " - 18789", - "self_report:", - ' url: "http://127.0.0.1:18789/health"', - " method: GET", - ].join("\n"), - ); - expect(() => loadAgent(unknownName)).toThrow(/self_report\.method.*not allowed/); - }); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index bcb39ee90a7..c7d93db37cf 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -23,7 +23,6 @@ import type { AgentHealthProbe, AgentLegacyPaths, AgentMcpCapability, - AgentSelfReport, AgentStateFile, AgentVersionScheme, } from "./definition-types"; @@ -36,7 +35,6 @@ import { readMcpCapability, readObject, readPortArray, - readSelfReport, readStateFiles, readString, readStringArray, @@ -59,7 +57,6 @@ export type { AgentMcpAdapter, AgentMcpCapability, AgentMcpSupport, - AgentSelfReport, AgentStateFile, AgentStateFileStrategy, AgentVersionScheme, @@ -139,7 +136,6 @@ export function loadAgent(name: string): AgentDefinition { const dashboard = readDashboard(raw); const webAuth = readWebAuth(raw); const healthProbe = readHealthProbe(raw); - const selfReport = readSelfReport(raw); const config = readObject(raw, "config"); const inference = readInference(raw); const mcp = readMcpCapability(raw); @@ -173,7 +169,6 @@ export function loadAgent(name: string): AgentDefinition { phone_home_hosts: phoneHomeHosts, forward_ports: forwardPorts, health_probe: healthProbe, - self_report: selfReport, config, inference, mcp, @@ -202,10 +197,6 @@ export function loadAgent(name: string): AgentDefinition { ); }, - get selfReport(): AgentSelfReport | null { - return selfReport ?? null; - }, - get forwardPort(): number { if (runtime.kind === "terminal" && !forwardPorts?.[0]) { return 0; diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index b34bc588c87..469e96446cd 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -44,7 +44,6 @@ export function makeAgent(overrides: Partial = {}): AgentDefini policyPermissivePath: null, pluginDir: null, legacyPaths: null, - selfReport: null, agentDir: "/tmp/agent", manifestPath: "/tmp/agent/manifest.yaml", ...overrides, diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index 4c3b88086aa..bb181d12db6 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -3,7 +3,6 @@ import fs from "node:fs"; import { isPlainObject } from "../core/json-types"; -import { isLoopbackHostname } from "../private-networks"; import { isSafeModelId } from "../validation"; import type { AgentDashboard, @@ -11,7 +10,6 @@ import type { AgentHealthProbe, AgentInference, AgentMcpCapability, - AgentSelfReport, AgentStateFile, AgentVersionScheme, ManifestRecord, @@ -67,10 +65,6 @@ export function readStringArray(record: ManifestRecord, key: string): string[] | const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; const STATE_FILE_FIELDS = new Set(["path", "strategy", "restore"]); -const SELF_REPORT_FIELDS = new Set(["url", "timeout_seconds"]); -const SELF_REPORT_DEFAULT_TIMEOUT_SECONDS = 10; -const SELF_REPORT_MAX_TIMEOUT_SECONDS = 10; -const SELF_REPORT_MAX_URL_LENGTH = 2048; function assertStateFilePath(value: string, field: string): void { if (value.length === 0) { @@ -232,109 +226,6 @@ export function readHealthProbe(record: ManifestRecord): AgentHealthProbe | unde return undefined; } -function selfReportAllowedPorts(record: ManifestRecord): Set { - const ports = new Set(readPortArray(record, "forward_ports") ?? []); - const healthProbePort = readObject(record, "health_probe")?.port; - if (isValidPort(healthProbePort)) ports.add(healthProbePort); - return ports; -} - -function validateSelfReportUrl(value: string, allowedPorts: ReadonlySet): string { - if ( - value !== value.trim() || - value.length === 0 || - value.length > SELF_REPORT_MAX_URL_LENGTH || - CONTROL_CHAR_RE.test(value) || - /\s/u.test(value) || - value.includes("%") || - value.includes("\\") - ) { - throw new Error( - "Agent manifest field 'self_report.url' must be a bounded canonical URL without whitespace, control characters, percent escapes, or backslashes", - ); - } - - let parsed: URL; - try { - parsed = new URL(value); - } catch { - throw new Error("Agent manifest field 'self_report.url' must be an absolute URL"); - } - - if (parsed.protocol !== "http:") { - throw new Error("Agent manifest field 'self_report.url' must use http"); - } - if (!isLoopbackHostname(parsed.hostname)) { - throw new Error("Agent manifest field 'self_report.url' must target a loopback host"); - } - if (parsed.username || parsed.password) { - throw new Error("Agent manifest field 'self_report.url' must not include credentials"); - } - const port = Number(parsed.port); - if (!parsed.port || !isValidPort(port, 1024)) { - throw new Error( - "Agent manifest field 'self_report.url' must include an explicit TCP port between 1024 and 65535", - ); - } - if (!allowedPorts.has(port)) { - throw new Error( - "Agent manifest field 'self_report.url' port must be declared by health_probe.port or forward_ports", - ); - } - if (parsed.search || parsed.hash) { - throw new Error("Agent manifest field 'self_report.url' must not include a query or fragment"); - } - const authorityStart = value.indexOf("://") + 3; - const pathStart = value.indexOf("/", authorityStart); - const rawPath = pathStart === -1 ? "/" : value.slice(pathStart); - const pathSegments = rawPath.slice(1).split("/"); - if ( - rawPath === "/" || - rawPath !== parsed.pathname || - pathSegments.some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - throw new Error( - "Agent manifest field 'self_report.url' must include a canonical non-root endpoint path without empty or dot segments", - ); - } - return value; -} - -export function readSelfReport(record: ManifestRecord): AgentSelfReport | undefined { - if (record.self_report === undefined) return undefined; - const selfReport = readObject(record, "self_report"); - if (!selfReport) { - throw new Error("Agent manifest field 'self_report' must be an object"); - } - for (const key of Object.keys(selfReport)) { - if (!SELF_REPORT_FIELDS.has(key)) { - throw new Error(`Agent manifest field 'self_report.${key}' is not allowed`); - } - } - - const url = readString(selfReport, "url"); - if (!url) { - throw new Error("Agent manifest field 'self_report.url' is required"); - } - - const timeoutSeconds = selfReport.timeout_seconds ?? SELF_REPORT_DEFAULT_TIMEOUT_SECONDS; - if ( - typeof timeoutSeconds !== "number" || - !Number.isInteger(timeoutSeconds) || - timeoutSeconds < 1 || - timeoutSeconds > SELF_REPORT_MAX_TIMEOUT_SECONDS - ) { - throw new Error( - `Agent manifest field 'self_report.timeout_seconds' must be an integer between 1 and ${String(SELF_REPORT_MAX_TIMEOUT_SECONDS)}`, - ); - } - - return { - url: validateSelfReportUrl(url, selfReportAllowedPorts(record)), - timeout_seconds: timeoutSeconds, - }; -} - export function readDashboard(record: ManifestRecord): AgentDashboard { const dashboard = readObject(record, "dashboard") ?? {}; const rawKind = dashboard.kind; diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 770137f04aa..78eaa117e63 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -45,7 +45,6 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { policyPermissivePath: null, pluginDir: null, legacyPaths: null, - selfReport: null, agentDir: "/tmp/agent", manifestPath: "/tmp/agent/manifest.yaml", ...overrides, diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index ed615fa2e96..4b4da8dcaf3 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -39,7 +39,6 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { policyPermissivePath: null, pluginDir: null, legacyPaths: null, - selfReport: null, agentDir: "/tmp/agent", manifestPath: "/tmp/agent/manifest.yaml", ...overrides, diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index b126b3e4f2f..999f878f078 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -59,7 +59,6 @@ export function makeAgent(overrides: Partial = {}): AgentDefini policyPermissivePath: null, pluginDir: null, legacyPaths: null, - selfReport: null, agentDir: "/repo/root/agents/hermes", manifestPath: "/repo/root/agents/hermes/manifest.yaml", ...overrides, From eddacaf423791845ca9e2d34a4b932978ab14503 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 10:05:49 -0700 Subject: [PATCH 7/7] test(status): cover unavailable serving process --- src/lib/actions/sandbox/status-flow.test.ts | 13 ++++++++++ .../actions/sandbox/status-inference.test.ts | 24 +++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index c111a1aa99d..19278cc8fb7 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -130,6 +130,19 @@ describe("showSandboxStatus flow", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("omits serving-process status when the gateway is unavailable (#7003)", async () => { + const harness = createStatusFlowHarness({ + lookupState: "missing", + servingProcessHealth: null, + }); + + await expect(harness.showSandboxStatus("alpha")).rejects.toThrow("process.exit(1)"); + + const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).not.toContain("Serving process"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it.each([ { label: "unreachable" as const, detail: "inference.local is unreachable" }, { label: "unhealthy" as const, detail: "inference.local returned HTTP 503" }, diff --git a/src/lib/actions/sandbox/status-inference.test.ts b/src/lib/actions/sandbox/status-inference.test.ts index 06c91d07b78..3000c418d9f 100644 --- a/src/lib/actions/sandbox/status-inference.test.ts +++ b/src/lib/actions/sandbox/status-inference.test.ts @@ -11,6 +11,7 @@ import { describe("sandbox status inference.local route health (#6192)", () => { function snapshotDeps(options: { agent?: string; + lookupState?: "present" | "missing"; provider?: string; liveProvider?: string; liveModel?: string; @@ -35,10 +36,10 @@ describe("sandbox status inference.local route health (#6192)", () => { return { getSandbox: () => sandbox, listSandboxes: () => ({ sandboxes: [sandbox], defaultSandbox: "alpha" }), - reconcile: async () => ({ - state: "present" as const, - output: "Name: alpha\nPhase: Ready\n", - }), + reconcile: async () => + options.lookupState === "missing" + ? { state: "missing" as const, output: "sandbox alpha not found" } + : { state: "present" as const, output: "Name: alpha\nPhase: Ready\n" }, captureOpenshellForStatusImpl: async () => ({ status: 0, @@ -115,6 +116,21 @@ describe("sandbox status inference.local route health (#6192)", () => { expect(report.servingProcessHealth).toBeNull(); }); + it("does not invent serving-process health when the gateway is unavailable (#7003)", async () => { + const deps = snapshotDeps({ + lookupState: "missing", + routeHealth: null, + }); + + const snapshot = await collectSandboxStatusSnapshot("alpha", { deps }); + + expect(snapshot.servingProcessHealth).toBeNull(); + expect(deps.probeSandboxInferenceGatewayHealthImpl).not.toHaveBeenCalled(); + + const report = await getSandboxStatusReport("alpha", deps); + expect(report.servingProcessHealth).toBeNull(); + }); + it.each([ "nvidia-router", "hermes-provider",