diff --git a/docs/index.yml b/docs/index.yml index 290d7c63c2d..13f4d674326 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -1168,6 +1168,9 @@ navigation: - page: "Extension Taxonomy and SDK Readiness" path: _build/agent-variants/reference/extension-taxonomy-sdk-readiness.hermes.generated.mdx slug: extension-taxonomy-sdk-readiness + - page: "Headless Lifecycle Package" + path: reference/headless-lifecycle-package.mdx + slug: headless-lifecycle-package - page: "CLI Commands Reference" path: _build/agent-variants/reference/commands.hermes.generated.mdx slug: commands diff --git a/docs/reference/headless-lifecycle-package.mdx b/docs/reference/headless-lifecycle-package.mdx new file mode 100644 index 00000000000..3dee83fb981 --- /dev/null +++ b/docs/reference/headless-lifecycle-package.mdx @@ -0,0 +1,78 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Use the Headless Lifecycle Package" +sidebar-title: "Headless Lifecycle Package" +description: "Plan and observe a Hermes sandbox through the supported NemoClaw TypeScript package boundary." +description-agent: "Documents the supported TypeScript lifecycle package for deterministic Hermes planning and read-only OpenShell observation. Use when integrating a headless service without CLI, transport, authentication, persistence, or mutation ownership." +keywords: "NemoClaw lifecycle package, Hermes lifecycle, OpenShell observation" +agent-variants: ["hermes"] +content: + type: "reference" +--- + +Use `nemoclaw/lifecycle` when a service needs deterministic Hermes planning and read-only observation without invoking the NemoClaw command-line interface (CLI). +The first API version supports Hermes `0.19.0` with OpenShell `0.0.106`. + +## Understand the Boundary + +Your service supplies an `OpenShellHermesAgentObserver` implementation that owns its OpenShell authentication and transport. +The observer is a trusted boundary that must independently authenticate the requested target and inspect the live OpenShell resource, image, Hermes version, configuration fingerprint, sandbox phase, and Hermes health endpoint. +It must not return request values without verifying them against live evidence. +NemoClaw validates the request, calls that capability once, derives readiness, verifies the observed identities, and returns a redacted result. + +The public package does not read an ambient OpenShell profile, start a local Gateway, open a terminal, run a subprocess, or persist lifecycle state. +It does not expose OpenShell software development kit (SDK), gRPC, protobuf, or CLI types. + +## Plan and Observe Hermes + +Provide SHA-256 identities for the target Gateway, OpenShell resource, sandbox image, and Hermes configuration. +Keep credentials and private endpoint values inside your injected capability. + +```typescript +import { + HERMES_LIFECYCLE_DEFINITION, + NEMOCLAW_LIFECYCLE_API_VERSION, + observeHermesLifecycle, + planHermesLifecycle, + type HermesLifecyclePlanRequest, + type LifecycleDigest, + type OpenShellHermesAgentObserver, +} from "nemoclaw/lifecycle"; + +declare const observer: OpenShellHermesAgentObserver; + +const digest = (value: string) => value as LifecycleDigest; +const request: HermesLifecyclePlanRequest = { + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + target: { + gatewayIdentity: digest(process.env.GATEWAY_IDENTITY!), + workspace: "hermes-workspace", + openshellVersion: HERMES_LIFECYCLE_DEFINITION.openshellVersion, + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: digest(process.env.RESOURCE_IDENTITY!), + imageDigest: digest(process.env.IMAGE_DIGEST!), + configurationFingerprint: digest(process.env.CONFIGURATION_FINGERPRINT!), + }, +}; + +const plan = planHermesLifecycle(request); +if (!plan.ok) throw new Error(plan.error.message); + +const observation = await observeHermesLifecycle({ plan: request, timeoutMs: 5_000 }, observer); +if (!observation.ok) throw new Error(observation.error.message); +``` + +The plan rejects unknown fields, unsupported versions, invalid names, and malformed digests. +The observation fails closed when the target, resource, image, agent, or configuration identity differs from the plan. +NemoClaw derives sandbox readiness from the closed OpenShell phase set and derives Hermes readiness from the health status code. +The combined readiness is `ready` only when the sandbox phase is `Ready` or `Running` and the Hermes health endpoint returns status `200`. +When the capability reports that the resource is missing, the result has `state: "missing"` and `readiness: "not_ready"`. + +## Respect the First API Limits + +This API does not define an image reference, entrypoint, provider, network policy, checkpoint, ownership record, or mutation authority. +It does not create, stop, start, replace, delete, or clean up a sandbox. +OpenShell `0.0.115` requires separate compatibility evidence before a later API definition can support it. diff --git a/package.json b/package.json index 31ff7e1f967..e93683cc9e5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,15 @@ "version": "0.1.0", "description": "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference", "license": "Apache-2.0", + "exports": { + "./lifecycle": { + "types": "./dist/lifecycle/index.d.ts", + "import": "./dist/lifecycle/index.js", + "require": "./dist/lifecycle/index.js", + "default": "./dist/lifecycle/index.js" + }, + "./*": "./*" + }, "bin": { "nemoclaw": "./bin/nemoclaw.js", "nemohermes": "./bin/nemohermes.js", diff --git a/src/lib/actions/lifecycle/observe-hermes.test.ts b/src/lib/actions/lifecycle/observe-hermes.test.ts new file mode 100644 index 00000000000..83a89dbd8f7 --- /dev/null +++ b/src/lib/actions/lifecycle/observe-hermes.test.ts @@ -0,0 +1,380 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { + OpenShellHermesAgentHealthEvidence, + OpenShellHermesAgentObservation, + OpenShellHermesAgentObserver, +} from "../../adapters/openshell/hermes-agent-observer"; +import { + NEMOCLAW_LIFECYCLE_API_VERSION, + type HermesLifecycleObserveRequest, + type HermesLifecyclePlanRequest, + type HermesLifecycleSandboxPhase, + type LifecycleVerificationField, +} from "../../domain/lifecycle/contract"; +import { HERMES_LIFECYCLE_DEFINITION } from "../../domain/lifecycle/hermes-definition"; +import { observeHermesLifecycle } from "./observe-hermes"; + +const GATEWAY_IDENTITY = `sha256:${"1".repeat(64)}` as const; +const RESOURCE_IDENTITY = `sha256:${"2".repeat(64)}` as const; +const IMAGE_DIGEST = `sha256:${"3".repeat(64)}` as const; +const CONFIGURATION_FINGERPRINT = `sha256:${"4".repeat(64)}` as const; + +function planRequest(): HermesLifecyclePlanRequest { + return { + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + target: { + gatewayIdentity: GATEWAY_IDENTITY, + workspace: "hermes-workspace", + openshellVersion: HERMES_LIFECYCLE_DEFINITION.openshellVersion, + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: RESOURCE_IDENTITY, + imageDigest: IMAGE_DIGEST, + configurationFingerprint: CONFIGURATION_FINGERPRINT, + }, + }; +} + +type PresentObservation = Extract; + +function observed( + phase: HermesLifecycleSandboxPhase = "Ready", + health: OpenShellHermesAgentHealthEvidence = { state: "reachable", statusCode: 200 }, +): PresentObservation { + return { + state: "present", + target: planRequest().target, + sandbox: { + name: "hermes-agent", + resourceIdentity: RESOURCE_IDENTITY, + imageDigest: IMAGE_DIGEST, + phase, + }, + agent: { + name: "hermes", + version: "0.19.0", + configurationFingerprint: CONFIGURATION_FINGERPRINT, + health, + }, + }; +} + +function request(): HermesLifecycleObserveRequest { + return { plan: planRequest(), timeoutMs: 5_000 }; +} + +function observerWith(value: OpenShellHermesAgentObservation): { + capability: OpenShellHermesAgentObserver; + observe: ReturnType; +} { + const observe = vi.fn().mockResolvedValue({ ok: true, value }); + return { capability: { observeHermesAgent: observe }, observe }; +} + +describe("Hermes lifecycle observation", () => { + it("returns ready after the injected capability verifies every field (#10613)", async () => { + const { capability, observe } = observerWith(observed()); + + const result = await observeHermesLifecycle(request(), capability); + + expect(observe).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenCalledWith({ + target: planRequest().target, + sandboxName: "hermes-agent", + resourceIdentity: RESOURCE_IDENTITY, + timeoutMs: 5_000, + }); + expect(result).toEqual({ + ok: true, + value: { + apiVersion: "v1alpha1", + state: "present", + agent: { name: "hermes", version: "0.19.0", readiness: "ready" }, + target: planRequest().target, + sandbox: { + ...planRequest().sandbox, + phase: "Ready", + readiness: "ready", + }, + readiness: "ready", + }, + }); + }); + + it.each([ + ["Creating", { state: "reachable", statusCode: 200 }, "not_ready"], + ["Ready", { state: "unreachable" }, "not_ready"], + ["Failed", { state: "reachable", statusCode: 200 }, "terminal"], + ["Ready", { state: "reachable", statusCode: 503 }, "not_ready"], + ] as const)( + "derives readiness from phase %s and health case %# without retrying (#10613)", + async (phase, health, expected) => { + const { capability, observe } = observerWith(observed(phase, health)); + + const result = await observeHermesLifecycle(request(), capability); + + expect(result.ok && result.value.readiness).toBe(expected); + expect(observe).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + "CrashLoopBackOff", + "Error", + "Evicted", + "Failed", + "ImagePullBackOff", + "Unknown", + ] as const)("maps terminal OpenShell phase %s to terminal (#10613)", async (phase) => { + const { capability } = observerWith(observed(phase)); + + const result = await observeHermesLifecycle(request(), capability); + + expect(result.ok && result.value.readiness).toBe("terminal"); + }); + + it("reports a missing OpenShell resource without treating it as a capability failure (#10613)", async () => { + const observe = vi.fn().mockResolvedValue({ ok: true, value: { state: "missing" } }); + + const result = await observeHermesLifecycle(request(), { observeHermesAgent: observe }); + + expect(result).toEqual({ + ok: true, + value: { + apiVersion: "v1alpha1", + state: "missing", + target: planRequest().target, + sandbox: { name: "hermes-agent", resourceIdentity: RESOURCE_IDENTITY }, + readiness: "not_ready", + }, + }); + }); + + it.each<[LifecycleVerificationField, (value: PresentObservation) => PresentObservation]>([ + [ + "target.gatewayIdentity", + (value) => ({ + ...value, + target: { ...value.target, gatewayIdentity: `sha256:${"5".repeat(64)}` }, + }), + ], + [ + "target.workspace", + (value) => ({ ...value, target: { ...value.target, workspace: "other-workspace" } }), + ], + [ + "target.openshellVersion", + (value) => ({ ...value, target: { ...value.target, openshellVersion: "0.0.115" } }), + ], + ["sandbox.name", (value) => ({ ...value, sandbox: { ...value.sandbox, name: "other-agent" } })], + [ + "sandbox.resourceIdentity", + (value) => ({ + ...value, + sandbox: { ...value.sandbox, resourceIdentity: `sha256:${"6".repeat(64)}` }, + }), + ], + [ + "sandbox.imageDigest", + (value) => ({ + ...value, + sandbox: { ...value.sandbox, imageDigest: `sha256:${"7".repeat(64)}` }, + }), + ], + ["agent.name", (value) => ({ ...value, agent: { ...value.agent, name: "openclaw" } })], + ["agent.version", (value) => ({ ...value, agent: { ...value.agent, version: "0.20.0" } })], + [ + "agent.configurationFingerprint", + (value) => ({ + ...value, + agent: { ...value.agent, configurationFingerprint: `sha256:${"8".repeat(64)}` }, + }), + ], + ])("fails closed when %s does not match (#10613)", async (field, change) => { + const { capability } = observerWith(change(observed())); + + const result = await observeHermesLifecycle(request(), capability); + + expect(result).toEqual({ + ok: false, + error: { + code: "verification-failed", + field, + message: `Hermes lifecycle verification failed: ${field}.`, + }, + }); + }); + + it("snapshots capability evidence once before verification (#10613)", async () => { + const value = observed(); + let reads = 0; + const target = Object.defineProperty({ ...value.target }, "gatewayIdentity", { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? GATEWAY_IDENTITY : "private-endpoint-value"; + }, + }); + const { capability } = observerWith({ ...value, target } as PresentObservation); + + const result = await observeHermesLifecycle(request(), capability); + + expect(result.ok).toBe(true); + expect(reads).toBe(1); + expect(JSON.stringify(result)).not.toContain("private-endpoint-value"); + }); + + it.each([ + [ + "private-success-workspace", + (value: PresentObservation) => ({ + ...value, + target: { ...value.target, workspace: "private-success-workspace" }, + }), + ], + [ + "private-success-phase", + (value: PresentObservation) => ({ + ...value, + sandbox: { ...value.sandbox, phase: "private-success-phase" }, + }), + ], + [ + "private-success-health", + (value: PresentObservation) => ({ + ...value, + agent: { ...value.agent, health: { state: "private-success-health" } }, + }), + ], + [ + "private-success-extra", + (value: PresentObservation) => ({ ...value, credential: "private-success-extra" }), + ], + ])( + "does not retain capability-supplied private data case %# (#10613)", + async (privateValue, change) => { + const { capability } = observerWith(change(observed()) as PresentObservation); + + const result = await observeHermesLifecycle(request(), capability); + + expect(JSON.stringify(result)).not.toContain(privateValue); + }, + ); + + it.each(["authentication", "command", "schema", "timeout", "transport"] as const)( + "redacts a capability %s failure and does not retry (#10613)", + async (kind) => { + const privateValue = "private-observer-value"; + const observe = vi.fn().mockResolvedValue({ + ok: false, + error: { kind, message: `${privateValue} https://private.invalid` }, + }); + + const result = await observeHermesLifecycle(request(), { observeHermesAgent: observe }); + + expect(result).toEqual({ + ok: false, + error: { + code: "capability-failure", + reason: kind, + message: `OpenShell agent observation failed: ${kind}.`, + }, + }); + expect(JSON.stringify(result)).not.toContain(privateValue); + expect(JSON.stringify(result)).not.toContain("private.invalid"); + expect(observe).toHaveBeenCalledOnce(); + }, + ); + + it("returns a redacted exception failure when the capability throws (#10613)", async () => { + const observe = vi.fn().mockRejectedValue(new Error("private thrown value")); + + const result = await observeHermesLifecycle(request(), { observeHermesAgent: observe }); + + expect(result).toEqual({ + ok: false, + error: { + code: "capability-failure", + reason: "exception", + message: "OpenShell agent observation failed: exception.", + }, + }); + expect(JSON.stringify(result)).not.toContain("private thrown value"); + }); + + it.each(["method", "response"] as const)( + "redacts a throwing capability %s accessor (#10613)", + async (accessor) => { + const privateValue = `private-${accessor}-accessor`; + const capability = + accessor === "method" + ? Object.defineProperty({}, "observeHermesAgent", { + get() { + throw new Error(privateValue); + }, + }) + : { + observeHermesAgent: vi.fn().mockResolvedValue( + Object.defineProperty({}, "ok", { + get() { + throw new Error(privateValue); + }, + }), + ), + }; + + const result = await observeHermesLifecycle( + request(), + capability as OpenShellHermesAgentObserver, + ); + + expect(result).toEqual({ + ok: false, + error: { + code: "capability-failure", + reason: "exception", + message: "OpenShell agent observation failed: exception.", + }, + }); + expect(JSON.stringify(result)).not.toContain(privateValue); + }, + ); + + it("rejects an invalid request before the capability runs (#10613)", async () => { + const { capability, observe } = observerWith(observed()); + const invalid = { ...request(), timeoutMs: 0 }; + + const result = await observeHermesLifecycle(invalid, capability); + + expect(result).toMatchObject({ ok: false, error: { field: "timeoutMs" } }); + expect(observe).not.toHaveBeenCalled(); + }); + + it("redacts a throwing request accessor before the capability runs (#10613)", async () => { + const { capability, observe } = observerWith(observed()); + const hostileRequest = Object.defineProperty({}, "plan", { + enumerable: true, + get() { + throw new Error("private-request-value"); + }, + }) as HermesLifecycleObserveRequest; + + const result = await observeHermesLifecycle(hostileRequest, capability); + + expect(result).toEqual({ + ok: false, + error: { + code: "invalid-request", + field: "request", + message: "Invalid lifecycle request field: request.", + }, + }); + expect(JSON.stringify(result)).not.toContain("private-request-value"); + expect(observe).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/lifecycle/observe-hermes.ts b/src/lib/actions/lifecycle/observe-hermes.ts new file mode 100644 index 00000000000..d407d8ce93f --- /dev/null +++ b/src/lib/actions/lifecycle/observe-hermes.ts @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + OpenShellHermesAgentHealthEvidence, + OpenShellHermesAgentObservation, + OpenShellHermesAgentObserver, +} from "../../adapters/openshell/hermes-agent-observer"; +import { + NEMOCLAW_LIFECYCLE_API_VERSION, + type HermesLifecycleObservation, + type HermesLifecycleObserveRequest, + type HermesLifecycleReadiness, + type HermesLifecycleSandboxPhase, + type LifecycleCapabilityFailureReason, + type LifecycleRequestField, + type LifecycleResult, + type LifecycleVerificationField, +} from "../../domain/lifecycle/contract"; +import { HERMES_LIFECYCLE_DEFINITION } from "../../domain/lifecycle/hermes-definition"; +import { planHermesLifecycle } from "../../domain/lifecycle/hermes-plan"; + +type UnknownRecord = Record; + +const OBSERVE_REQUEST_KEYS = new Set(["plan", "timeoutMs"]); +const CAPABILITY_FAILURE_REASONS = new Set([ + "authentication", + "command", + "schema", + "timeout", + "transport", +]); +const READY_PHASES = new Set(["Ready", "Running"]); +const TERMINAL_PHASES = new Set([ + "CrashLoopBackOff", + "Error", + "Evicted", + "Failed", + "ImagePullBackOff", + "Unknown", +]); +const KNOWN_PHASES = new Set([ + ...READY_PHASES, + ...TERMINAL_PHASES, + "Creating", + "Deleting", + "NotReady", + "Pending", + "Provisioning", + "Terminating", + null, +]); +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +function isRecord(value: unknown): value is UnknownRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function invalid(field: LifecycleRequestField): LifecycleResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ + code: "invalid-request", + field, + message: `Invalid lifecycle request field: ${field}.`, + }), + }); +} + +function capabilityFailure(reason: LifecycleCapabilityFailureReason): LifecycleResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ + code: "capability-failure", + reason, + message: `OpenShell agent observation failed: ${reason}.`, + }), + }); +} + +function verificationFailure(field: LifecycleVerificationField): LifecycleResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ + code: "verification-failed", + field, + message: `Hermes lifecycle verification failed: ${field}.`, + }), + }); +} + +function isDigest(value: unknown): value is `sha256:${string}` { + return typeof value === "string" && SHA256_PATTERN.test(value); +} + +function isCapabilityFailureReason(value: unknown): value is LifecycleCapabilityFailureReason { + return ( + typeof value === "string" && + CAPABILITY_FAILURE_REASONS.has(value as LifecycleCapabilityFailureReason) + ); +} + +function isSandboxPhase(value: unknown): value is HermesLifecycleSandboxPhase { + return KNOWN_PHASES.has(value as HermesLifecycleSandboxPhase); +} + +function normalizeObservation(value: unknown): OpenShellHermesAgentObservation | null { + if (!isRecord(value)) return null; + const state = value.state; + if (state === "missing") return Object.freeze({ state }); + if (state !== "present") return null; + + const observedTarget = value.target; + const observedSandbox = value.sandbox; + const observedAgent = value.agent; + if (!isRecord(observedTarget) || !isRecord(observedSandbox) || !isRecord(observedAgent)) { + return null; + } + const gatewayIdentity = observedTarget.gatewayIdentity; + const workspace = observedTarget.workspace; + const openshellVersion = observedTarget.openshellVersion; + const sandboxName = observedSandbox.name; + const resourceIdentity = observedSandbox.resourceIdentity; + const imageDigest = observedSandbox.imageDigest; + const phase = observedSandbox.phase; + const agentName = observedAgent.name; + const agentVersion = observedAgent.version; + const configurationFingerprint = observedAgent.configurationFingerprint; + const health = observedAgent.health; + if ( + !isDigest(gatewayIdentity) || + typeof workspace !== "string" || + typeof openshellVersion !== "string" || + typeof sandboxName !== "string" || + !isDigest(resourceIdentity) || + !isDigest(imageDigest) || + !isSandboxPhase(phase) || + typeof agentName !== "string" || + typeof agentVersion !== "string" || + !isDigest(configurationFingerprint) || + !isRecord(health) + ) { + return null; + } + const healthState = health.state; + let healthEvidence: OpenShellHermesAgentHealthEvidence | null = null; + if (healthState === "unreachable") { + healthEvidence = Object.freeze({ state: healthState }); + } else if (healthState === "reachable") { + const statusCode = health.statusCode; + if ( + typeof statusCode === "number" && + Number.isInteger(statusCode) && + statusCode >= 100 && + statusCode <= 599 + ) { + healthEvidence = Object.freeze({ state: healthState, statusCode }); + } + } + if (healthEvidence === null) return null; + + return Object.freeze({ + state, + target: Object.freeze({ gatewayIdentity, workspace, openshellVersion }), + sandbox: Object.freeze({ name: sandboxName, resourceIdentity, imageDigest, phase }), + agent: Object.freeze({ + name: agentName, + version: agentVersion, + configurationFingerprint, + health: healthEvidence, + }), + }); +} + +function sandboxReadinessForPhase(phase: HermesLifecycleSandboxPhase): HermesLifecycleReadiness { + if (READY_PHASES.has(phase)) return "ready"; + if (TERMINAL_PHASES.has(phase)) return "terminal"; + return "not_ready"; +} + +function verifyObservation( + expected: ReturnType & { ok: true }, + observed: OpenShellHermesAgentObservation, +): LifecycleResult { + const plan = expected.value; + if (observed.state === "missing") { + return Object.freeze({ + ok: true, + value: Object.freeze({ + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + state: "missing", + target: plan.target, + sandbox: Object.freeze({ + name: plan.sandbox.name, + resourceIdentity: plan.sandbox.resourceIdentity, + }), + readiness: "not_ready", + }), + }); + } + const comparisons: readonly [LifecycleVerificationField, string, string][] = [ + ["target.gatewayIdentity", observed.target.gatewayIdentity, plan.target.gatewayIdentity], + ["target.workspace", observed.target.workspace, plan.target.workspace], + ["target.openshellVersion", observed.target.openshellVersion, plan.target.openshellVersion], + ["sandbox.name", observed.sandbox.name, plan.sandbox.name], + ["sandbox.resourceIdentity", observed.sandbox.resourceIdentity, plan.sandbox.resourceIdentity], + ["sandbox.imageDigest", observed.sandbox.imageDigest, plan.sandbox.imageDigest], + ["agent.name", observed.agent.name, plan.agent.name], + ["agent.version", observed.agent.version, plan.agent.version], + [ + "agent.configurationFingerprint", + observed.agent.configurationFingerprint, + plan.sandbox.configurationFingerprint, + ], + ]; + for (const [field, actual, wanted] of comparisons) { + if (actual !== wanted) return verificationFailure(field); + } + + const sandboxReadiness = sandboxReadinessForPhase(observed.sandbox.phase); + const agentReadiness = + observed.agent.health.state === "reachable" && observed.agent.health.statusCode === 200 + ? "ready" + : "not_ready"; + const readiness = + sandboxReadiness === "terminal" + ? "terminal" + : sandboxReadiness === "ready" && agentReadiness === "ready" + ? "ready" + : "not_ready"; + const value = Object.freeze({ + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + state: "present" as const, + agent: Object.freeze({ + name: HERMES_LIFECYCLE_DEFINITION.agent, + version: HERMES_LIFECYCLE_DEFINITION.agentVersion, + readiness: agentReadiness, + }), + target: plan.target, + sandbox: Object.freeze({ + ...plan.sandbox, + phase: observed.sandbox.phase, + readiness: sandboxReadiness, + }), + readiness, + }); + return Object.freeze({ ok: true, value }); +} + +/** Observe one recorded Hermes resource without retries or lifecycle effects. */ +export async function observeHermesLifecycle( + request: HermesLifecycleObserveRequest, + capability: OpenShellHermesAgentObserver, +): Promise> { + let planned: Extract, { ok: true }>; + let timeoutMs: number | undefined; + try { + if (!isRecord(request) || !Object.keys(request).every((key) => OBSERVE_REQUEST_KEYS.has(key))) { + return invalid("request"); + } + const candidate = planHermesLifecycle(request.plan); + if (!candidate.ok) return candidate; + timeoutMs = request.timeoutMs; + if ( + timeoutMs !== undefined && + (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) + ) { + return invalid("timeoutMs"); + } + planned = candidate; + } catch { + return invalid("request"); + } + if ((typeof capability !== "object" || capability === null) && typeof capability !== "function") { + return invalid("capability"); + } + + try { + const observeHermesAgent = capability.observeHermesAgent; + if (typeof observeHermesAgent !== "function") return invalid("capability"); + const response: unknown = await observeHermesAgent.call( + capability, + Object.freeze({ + target: planned.value.target, + sandboxName: planned.value.sandbox.name, + resourceIdentity: planned.value.sandbox.resourceIdentity, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }), + ); + if (!isRecord(response)) { + return capabilityFailure("schema"); + } + const responseOk = response.ok; + if (typeof responseOk !== "boolean") return capabilityFailure("schema"); + if (!responseOk) { + const responseError = response.error; + if (!isRecord(responseError)) return capabilityFailure("schema"); + const failureReason = responseError.kind; + if (!isCapabilityFailureReason(failureReason)) { + return capabilityFailure("schema"); + } + return capabilityFailure(failureReason); + } + const responseValue = response.value; + const observation = normalizeObservation(responseValue); + if (observation === null) return capabilityFailure("schema"); + return verifyObservation(planned, observation); + } catch { + return capabilityFailure("exception"); + } +} diff --git a/src/lib/adapters/openshell/hermes-agent-observer.ts b/src/lib/adapters/openshell/hermes-agent-observer.ts new file mode 100644 index 00000000000..79bd8070a84 --- /dev/null +++ b/src/lib/adapters/openshell/hermes-agent-observer.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + HermesLifecycleSandboxPhase, + HermesLifecycleSandbox, + HermesLifecycleTarget, + LifecycleCapabilityFailureReason, + LifecycleDigest, +} from "../../domain/lifecycle/contract"; + +export type OpenShellHermesAgentHealthEvidence = + | Readonly<{ state: "reachable"; statusCode: number }> + | Readonly<{ state: "unreachable" }>; + +export type ObserveOpenShellHermesAgentRequest = Readonly<{ + target: HermesLifecycleTarget; + sandboxName: HermesLifecycleSandbox["name"]; + resourceIdentity: LifecycleDigest; + timeoutMs?: number; +}>; + +export type OpenShellHermesAgentObservation = + | Readonly<{ state: "missing" }> + | Readonly<{ + state: "present"; + target: Readonly<{ + gatewayIdentity: LifecycleDigest; + workspace: string; + openshellVersion: string; + }>; + sandbox: Readonly<{ + name: string; + resourceIdentity: LifecycleDigest; + imageDigest: LifecycleDigest; + phase: HermesLifecycleSandboxPhase; + }>; + agent: Readonly<{ + name: string; + version: string; + configurationFingerprint: LifecycleDigest; + health: OpenShellHermesAgentHealthEvidence; + }>; + }>; + +export type OpenShellHermesAgentObservationError = Readonly<{ + kind: Exclude; +}>; + +export type OpenShellHermesAgentObservationResult = + | Readonly<{ ok: true; value: OpenShellHermesAgentObservation }> + | Readonly<{ ok: false; error: OpenShellHermesAgentObservationError }>; + +/** + * Trusted transport boundary supplied by the consumer. + * + * The implementation must independently authenticate the requested target and + * observe the live OpenShell version, resource identity, image digest, Hermes + * version, configuration fingerprint, sandbox phase, and Hermes health + * endpoint. It must not satisfy the result by echoing request values. + */ +export interface OpenShellHermesAgentObserver { + observeHermesAgent( + request: ObserveOpenShellHermesAgentRequest, + ): Promise; +} diff --git a/src/lib/adapters/openshell/resolve-shared.ts b/src/lib/adapters/openshell/resolve-shared.ts index 611e3d24449..044274cd97b 100644 --- a/src/lib/adapters/openshell/resolve-shared.ts +++ b/src/lib/adapters/openshell/resolve-shared.ts @@ -3,6 +3,7 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { HERMES_LIFECYCLE_DEFINITION } from "../../domain/lifecycle/hermes-definition"; import { assertPodmanExecutableAuthority, capturePodmanExecutableAuthority, @@ -11,7 +12,7 @@ import { } from "../podman/executable-authority"; import { resolveOpenshell } from "./resolve"; -export const HERMES_PORTABLE_OPENSHELL_VERSION = "0.0.106" as const; +export const HERMES_PORTABLE_OPENSHELL_VERSION = HERMES_LIFECYCLE_DEFINITION.openshellVersion; const VERSION_TIMEOUT_MS = 5_000; const VERSION_MAX_BUFFER_BYTES = 16 * 1024; const SEMVER_PATTERN = /(?:^|[^0-9.])([0-9]+\.[0-9]+\.[0-9]+)(?![0-9.])/u; diff --git a/src/lib/domain/lifecycle/contract.ts b/src/lib/domain/lifecycle/contract.ts new file mode 100644 index 00000000000..4f44e1339b8 --- /dev/null +++ b/src/lib/domain/lifecycle/contract.ts @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { HERMES_LIFECYCLE_DEFINITION } from "./hermes-definition"; + +export const NEMOCLAW_LIFECYCLE_API_VERSION = "v1alpha1" as const; + +export type LifecycleDigest = `sha256:${string}`; + +export type HermesLifecycleTarget = Readonly<{ + gatewayIdentity: LifecycleDigest; + workspace: string; + openshellVersion: typeof HERMES_LIFECYCLE_DEFINITION.openshellVersion; +}>; + +export type HermesLifecycleSandbox = Readonly<{ + name: string; + resourceIdentity: LifecycleDigest; + imageDigest: LifecycleDigest; + configurationFingerprint: LifecycleDigest; +}>; + +export type HermesLifecyclePlanRequest = Readonly<{ + apiVersion: typeof NEMOCLAW_LIFECYCLE_API_VERSION; + target: HermesLifecycleTarget; + sandbox: HermesLifecycleSandbox; +}>; + +export type HermesLifecycleCheck = + | "target" + | "resource-identity" + | "image" + | "agent" + | "configuration" + | "sandbox-readiness" + | "agent-readiness"; + +export type HermesLifecyclePlan = Readonly<{ + apiVersion: typeof NEMOCLAW_LIFECYCLE_API_VERSION; + operation: "observe"; + agent: Readonly<{ + name: typeof HERMES_LIFECYCLE_DEFINITION.agent; + version: typeof HERMES_LIFECYCLE_DEFINITION.agentVersion; + }>; + target: HermesLifecycleTarget; + sandbox: HermesLifecycleSandbox; + checks: readonly HermesLifecycleCheck[]; +}>; + +export type HermesLifecycleObserveRequest = Readonly<{ + plan: HermesLifecyclePlanRequest; + timeoutMs?: number; +}>; + +export type HermesLifecycleReadiness = "ready" | "not_ready" | "terminal"; + +export type HermesLifecycleSandboxPhase = + | "CrashLoopBackOff" + | "Creating" + | "Deleting" + | "Error" + | "Evicted" + | "Failed" + | "ImagePullBackOff" + | "NotReady" + | "Pending" + | "Provisioning" + | "Ready" + | "Running" + | "Terminating" + | "Unknown" + | null; + +export type HermesLifecycleObservation = + | Readonly<{ + apiVersion: typeof NEMOCLAW_LIFECYCLE_API_VERSION; + state: "missing"; + target: HermesLifecycleTarget; + sandbox: Readonly<{ + name: HermesLifecycleSandbox["name"]; + resourceIdentity: HermesLifecycleSandbox["resourceIdentity"]; + }>; + readiness: "not_ready"; + }> + | Readonly<{ + apiVersion: typeof NEMOCLAW_LIFECYCLE_API_VERSION; + state: "present"; + agent: Readonly<{ + name: typeof HERMES_LIFECYCLE_DEFINITION.agent; + version: typeof HERMES_LIFECYCLE_DEFINITION.agentVersion; + readiness: Exclude; + }>; + target: HermesLifecycleTarget; + sandbox: HermesLifecycleSandbox & + Readonly<{ + phase: HermesLifecycleSandboxPhase; + readiness: HermesLifecycleReadiness; + }>; + readiness: HermesLifecycleReadiness; + }>; + +export type LifecycleRequestField = + | "request" + | "apiVersion" + | "target" + | "target.gatewayIdentity" + | "target.workspace" + | "target.openshellVersion" + | "sandbox" + | "sandbox.name" + | "sandbox.resourceIdentity" + | "sandbox.imageDigest" + | "sandbox.configurationFingerprint" + | "timeoutMs" + | "capability"; + +export type LifecycleVerificationField = + | "target.gatewayIdentity" + | "target.workspace" + | "target.openshellVersion" + | "sandbox.name" + | "sandbox.resourceIdentity" + | "sandbox.imageDigest" + | "agent.name" + | "agent.version" + | "agent.configurationFingerprint"; + +export type LifecycleCapabilityFailureReason = + | "authentication" + | "command" + | "exception" + | "schema" + | "timeout" + | "transport"; + +export type LifecycleError = + | Readonly<{ + code: "invalid-request"; + field: LifecycleRequestField; + message: string; + }> + | Readonly<{ + code: "capability-failure"; + reason: LifecycleCapabilityFailureReason; + message: string; + }> + | Readonly<{ + code: "verification-failed"; + field: LifecycleVerificationField; + message: string; + }>; + +export type LifecycleResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: LifecycleError }>; diff --git a/src/lib/domain/lifecycle/hermes-definition.test.ts b/src/lib/domain/lifecycle/hermes-definition.test.ts new file mode 100644 index 00000000000..f2738e9a366 --- /dev/null +++ b/src/lib/domain/lifecycle/hermes-definition.test.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { loadAgent } from "../../agent/defs"; +import { HERMES_LIFECYCLE_DEFINITION } from "./hermes-definition"; + +describe("Hermes lifecycle definition", () => { + it("matches the packaged Hermes manifest version (#10613)", () => { + const hermes = loadAgent("hermes"); + + expect(HERMES_LIFECYCLE_DEFINITION).toEqual({ + agent: hermes.name, + agentVersion: hermes.expectedVersion, + openshellVersion: "0.0.106", + }); + }); +}); diff --git a/src/lib/domain/lifecycle/hermes-definition.ts b/src/lib/domain/lifecycle/hermes-definition.ts new file mode 100644 index 00000000000..e5c522531e1 --- /dev/null +++ b/src/lib/domain/lifecycle/hermes-definition.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Hermes and OpenShell versions supported by the first lifecycle API. */ +export const HERMES_LIFECYCLE_DEFINITION = Object.freeze({ + agent: "hermes" as const, + agentVersion: "0.19.0" as const, + openshellVersion: "0.0.106" as const, +}); diff --git a/src/lib/domain/lifecycle/hermes-plan.test.ts b/src/lib/domain/lifecycle/hermes-plan.test.ts new file mode 100644 index 00000000000..390c5a3d28e --- /dev/null +++ b/src/lib/domain/lifecycle/hermes-plan.test.ts @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { NEMOCLAW_LIFECYCLE_API_VERSION, type HermesLifecyclePlanRequest } from "./contract"; +import { HERMES_LIFECYCLE_DEFINITION } from "./hermes-definition"; +import { planHermesLifecycle } from "./hermes-plan"; + +const GATEWAY_IDENTITY = `sha256:${"1".repeat(64)}` as const; +const RESOURCE_IDENTITY = `sha256:${"2".repeat(64)}` as const; +const IMAGE_DIGEST = `sha256:${"3".repeat(64)}` as const; +const CONFIGURATION_FINGERPRINT = `sha256:${"4".repeat(64)}` as const; + +function validRequest(): HermesLifecyclePlanRequest { + return { + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + target: { + gatewayIdentity: GATEWAY_IDENTITY, + workspace: "hermes-workspace", + openshellVersion: HERMES_LIFECYCLE_DEFINITION.openshellVersion, + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: RESOURCE_IDENTITY, + imageDigest: IMAGE_DIGEST, + configurationFingerprint: CONFIGURATION_FINGERPRINT, + }, + }; +} + +describe("Hermes lifecycle planning", () => { + it("returns the same frozen observation plan for the same request (#10613)", () => { + const request = validRequest(); + const original = structuredClone(request); + + const first = planHermesLifecycle(request); + const second = planHermesLifecycle(request); + + expect(first).toEqual(second); + expect(request).toEqual(original); + expect(first).toEqual({ + ok: true, + value: { + apiVersion: "v1alpha1", + operation: "observe", + agent: { name: "hermes", version: "0.19.0" }, + target: request.target, + sandbox: request.sandbox, + checks: [ + "target", + "resource-identity", + "image", + "agent", + "configuration", + "sandbox-readiness", + "agent-readiness", + ], + }, + }); + expect(first.ok && Object.isFrozen(first.value)).toBe(true); + expect(first.ok && Object.isFrozen(first.value.target)).toBe(true); + expect(first.ok && Object.isFrozen(first.value.sandbox)).toBe(true); + }); + + it.each([ + ["apiVersion", { ...validRequest(), apiVersion: "v2" }], + [ + "target.gatewayIdentity", + { ...validRequest(), target: { ...validRequest().target, gatewayIdentity: "gateway-one" } }, + ], + [ + "target.workspace", + { ...validRequest(), target: { ...validRequest().target, workspace: "Hermes Workspace" } }, + ], + [ + "target.openshellVersion", + { ...validRequest(), target: { ...validRequest().target, openshellVersion: "0.0.115" } }, + ], + ["sandbox.name", { ...validRequest(), sandbox: { ...validRequest().sandbox, name: "--help" } }], + [ + "sandbox.resourceIdentity", + { ...validRequest(), sandbox: { ...validRequest().sandbox, resourceIdentity: "resource" } }, + ], + [ + "sandbox.imageDigest", + { + ...validRequest(), + sandbox: { ...validRequest().sandbox, imageDigest: `sha256:${"A".repeat(64)}` }, + }, + ], + [ + "sandbox.configurationFingerprint", + { + ...validRequest(), + sandbox: { ...validRequest().sandbox, configurationFingerprint: "sha256:short" }, + }, + ], + ])("rejects an invalid %s without returning the value (#10613)", (field, request) => { + expect(planHermesLifecycle(request as HermesLifecyclePlanRequest)).toEqual({ + ok: false, + error: { + code: "invalid-request", + field, + message: `Invalid lifecycle request field: ${field}.`, + }, + }); + }); + + it("rejects agent, endpoint, and credential fields without retaining their values (#10613)", () => { + const privateValue = "private-lifecycle-value"; + const request = { + ...validRequest(), + agent: "openclaw", + endpoint: `https://${privateValue}.invalid`, + credential: privateValue, + } as unknown as HermesLifecyclePlanRequest; + + const result = planHermesLifecycle(request); + + expect(result).toEqual({ + ok: false, + error: { + code: "invalid-request", + field: "request", + message: "Invalid lifecycle request field: request.", + }, + }); + expect(JSON.stringify(result)).not.toContain(privateValue); + }); + + it("snapshots request values once before retaining the plan (#10613)", () => { + const request = validRequest(); + let reads = 0; + const target = Object.defineProperty({ ...request.target }, "gatewayIdentity", { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? GATEWAY_IDENTITY : "private-endpoint-value"; + }, + }); + + const result = planHermesLifecycle({ ...request, target } as HermesLifecyclePlanRequest); + + expect(result.ok && result.value.target.gatewayIdentity).toBe(GATEWAY_IDENTITY); + expect(reads).toBe(1); + expect(JSON.stringify(result)).not.toContain("private-endpoint-value"); + }); + + it("redacts a throwing request accessor (#10613)", () => { + const request = validRequest(); + const target = Object.defineProperty({ ...request.target }, "workspace", { + enumerable: true, + get() { + throw new Error("private-request-value"); + }, + }); + + const result = planHermesLifecycle({ ...request, target } as HermesLifecyclePlanRequest); + + expect(result).toEqual({ + ok: false, + error: { + code: "invalid-request", + field: "request", + message: "Invalid lifecycle request field: request.", + }, + }); + expect(JSON.stringify(result)).not.toContain("private-request-value"); + }); +}); diff --git a/src/lib/domain/lifecycle/hermes-plan.ts b/src/lib/domain/lifecycle/hermes-plan.ts new file mode 100644 index 00000000000..324cc18eccc --- /dev/null +++ b/src/lib/domain/lifecycle/hermes-plan.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isValidName } from "../../sandbox-name-contract"; +import { + NEMOCLAW_LIFECYCLE_API_VERSION, + type HermesLifecycleCheck, + type HermesLifecyclePlan, + type HermesLifecyclePlanRequest, + type LifecycleRequestField, + type LifecycleResult, +} from "./contract"; +import { HERMES_LIFECYCLE_DEFINITION } from "./hermes-definition"; + +type UnknownRecord = Record; + +const REQUEST_KEYS = new Set(["apiVersion", "target", "sandbox"]); +const TARGET_KEYS = new Set(["gatewayIdentity", "workspace", "openshellVersion"]); +const SANDBOX_KEYS = new Set([ + "name", + "resourceIdentity", + "imageDigest", + "configurationFingerprint", +]); +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const CHECKS = Object.freeze([ + "target", + "resource-identity", + "image", + "agent", + "configuration", + "sandbox-readiness", + "agent-readiness", +]); + +function isRecord(value: unknown): value is UnknownRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasOnlyKeys(value: UnknownRecord, allowed: ReadonlySet): boolean { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function isDigest(value: unknown): value is `sha256:${string}` { + return typeof value === "string" && SHA256_PATTERN.test(value); +} + +function invalid(field: LifecycleRequestField): LifecycleResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ + code: "invalid-request", + field, + message: `Invalid lifecycle request field: ${field}.`, + }), + }); +} + +/** Build a deterministic, credential-free observation plan for Hermes. */ +export function planHermesLifecycle( + request: HermesLifecyclePlanRequest, +): LifecycleResult { + try { + if (!isRecord(request) || !hasOnlyKeys(request, REQUEST_KEYS)) return invalid("request"); + const apiVersion = request.apiVersion; + const requestedTarget = request.target; + const requestedSandbox = request.sandbox; + if (apiVersion !== NEMOCLAW_LIFECYCLE_API_VERSION) return invalid("apiVersion"); + + if (!isRecord(requestedTarget) || !hasOnlyKeys(requestedTarget, TARGET_KEYS)) { + return invalid("target"); + } + const gatewayIdentity = requestedTarget.gatewayIdentity; + const workspace = requestedTarget.workspace; + const openshellVersion = requestedTarget.openshellVersion; + if (!isDigest(gatewayIdentity)) return invalid("target.gatewayIdentity"); + if (!isValidName(workspace)) return invalid("target.workspace"); + if (openshellVersion !== HERMES_LIFECYCLE_DEFINITION.openshellVersion) { + return invalid("target.openshellVersion"); + } + + if (!isRecord(requestedSandbox) || !hasOnlyKeys(requestedSandbox, SANDBOX_KEYS)) { + return invalid("sandbox"); + } + const sandboxName = requestedSandbox.name; + const resourceIdentity = requestedSandbox.resourceIdentity; + const imageDigest = requestedSandbox.imageDigest; + const configurationFingerprint = requestedSandbox.configurationFingerprint; + if (!isValidName(sandboxName)) return invalid("sandbox.name"); + if (!isDigest(resourceIdentity)) return invalid("sandbox.resourceIdentity"); + if (!isDigest(imageDigest)) return invalid("sandbox.imageDigest"); + if (!isDigest(configurationFingerprint)) { + return invalid("sandbox.configurationFingerprint"); + } + + const target = Object.freeze({ gatewayIdentity, workspace, openshellVersion }); + const sandbox = Object.freeze({ + name: sandboxName, + resourceIdentity, + imageDigest, + configurationFingerprint, + }); + const plan = Object.freeze({ + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + operation: "observe" as const, + agent: Object.freeze({ + name: HERMES_LIFECYCLE_DEFINITION.agent, + version: HERMES_LIFECYCLE_DEFINITION.agentVersion, + }), + target, + sandbox, + checks: CHECKS, + }); + return Object.freeze({ ok: true, value: plan }); + } catch { + return invalid("request"); + } +} diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts new file mode 100644 index 00000000000..5f9e4d0c72f --- /dev/null +++ b/src/lifecycle/index.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Supported headless lifecycle API for the packaged Hermes definition. + * + * The consumer supplies an OpenShell observation capability. This module does + * not construct a transport, acquire credentials, persist state, or mutate a + * sandbox. + */ +export { observeHermesLifecycle } from "../lib/actions/lifecycle/observe-hermes"; +export type { + ObserveOpenShellHermesAgentRequest, + OpenShellHermesAgentHealthEvidence, + OpenShellHermesAgentObservation, + OpenShellHermesAgentObservationError, + OpenShellHermesAgentObservationResult, + OpenShellHermesAgentObserver, +} from "../lib/adapters/openshell/hermes-agent-observer"; +export { + NEMOCLAW_LIFECYCLE_API_VERSION, + type HermesLifecycleCheck, + type HermesLifecycleObservation, + type HermesLifecycleObserveRequest, + type HermesLifecyclePlan, + type HermesLifecyclePlanRequest, + type HermesLifecycleReadiness, + type HermesLifecycleSandbox, + type HermesLifecycleSandboxPhase, + type HermesLifecycleTarget, + type LifecycleCapabilityFailureReason, + type LifecycleDigest, + type LifecycleError, + type LifecycleRequestField, + type LifecycleResult, + type LifecycleVerificationField, +} from "../lib/domain/lifecycle/contract"; +export { HERMES_LIFECYCLE_DEFINITION } from "../lib/domain/lifecycle/hermes-definition"; +export { planHermesLifecycle } from "../lib/domain/lifecycle/hermes-plan"; diff --git a/test/package-contract/helpers/package-fixture.ts b/test/package-contract/helpers/package-fixture.ts index 4ca83034d3e..7be6620d3c0 100644 --- a/test/package-contract/helpers/package-fixture.ts +++ b/test/package-contract/helpers/package-fixture.ts @@ -10,16 +10,29 @@ const REPOSITORY_ROOT = path.join(import.meta.dirname, "..", "..", ".."); export function createPackageFixture(options: { readonly prefix: string; readonly entries: readonly string[]; + readonly omitRuntimeDependencies?: boolean; }): string { const fixtureRoot = mkdtempSync(path.join(tmpdir(), options.prefix)); const packageJson = JSON.parse( readFileSync(path.join(REPOSITORY_ROOT, "package.json"), "utf8"), - ) as { scripts?: Record }; + ) as { + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + scripts?: Record; + }; // npm runs `prepare` when it packs a local directory, even with // `--ignore-scripts`. Remove package scripts so parallel package-contract // workers only read the copied package inputs. packageJson.scripts = {}; + if (options.omitRuntimeDependencies) { + // A subpath contract with an exact module-load allowlist does not need to + // refetch the repository's separately reviewed production dependency graph. + packageJson.dependencies = {}; + packageJson.optionalDependencies = {}; + packageJson.peerDependencies = {}; + } writeFileSync( path.join(fixtureRoot, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, diff --git a/test/package-contract/lifecycle-api.test.ts b/test/package-contract/lifecycle-api.test.ts new file mode 100644 index 00000000000..02b32b5f9ae --- /dev/null +++ b/test/package-contract/lifecycle-api.test.ts @@ -0,0 +1,366 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawnSync, type SpawnSyncReturns } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createPackageFixture } from "./helpers/package-fixture"; + +const REPOSITORY_ROOT = path.join(import.meta.dirname, "..", ".."); + +function commandOutput(result: SpawnSyncReturns): string { + return `${result.stdout ?? ""}${result.stderr ?? ""}`; +} + +function assertCommandSucceeded(result: SpawnSyncReturns, label: string): void { + expect(result.status, `${label} failed:\n${commandOutput(result)}`).toBe(0); +} + +function npmEnvironment(): NodeJS.ProcessEnv { + return { + ...process.env, + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_update_notifier: "false", + }; +} + +function consumerEnvironment(home: string): NodeJS.ProcessEnv { + return { + HOME: home, + OPENSHELL_GATEWAY: "private-endpoint-sentinel", + OPENSHELL_PROFILE: "private-profile-sentinel", + OPENSHELL_TOKEN: "private-credential-sentinel", + PATH: process.env.PATH, + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_DATA_HOME: path.join(home, ".local", "share"), + XDG_STATE_HOME: path.join(home, ".local", "state"), + }; +} + +describe("packaged lifecycle API", () => { + it( + "installs the packed Hermes lifecycle export with declarations and no transport dependency (#10613)", + { timeout: 120_000 }, + () => { + const fixtureRoot = createPackageFixture({ + prefix: "nemoclaw-lifecycle-package-", + entries: ["dist", "nemoclaw/dist"], + omitRuntimeDependencies: true, + }); + const archiveRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-archive-")); + const consumerRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-consumer-")); + const consumerHome = path.join(consumerRoot, "home"); + + try { + const pack = spawnSync( + "npm", + ["pack", "--ignore-scripts", "--silent", "--pack-destination", archiveRoot], + { cwd: fixtureRoot, encoding: "utf8", env: npmEnvironment() }, + ); + assertCommandSucceeded(pack, "package archive creation"); + const archives = fs.readdirSync(archiveRoot).filter((entry) => entry.endsWith(".tgz")); + expect(archives).toHaveLength(1); + + fs.mkdirSync(path.join(consumerHome, ".config", "openshell"), { recursive: true }); + fs.writeFileSync( + path.join(consumerHome, ".config", "openshell", "profile.yaml"), + "token: private-profile-file-sentinel\n", + ); + fs.writeFileSync( + path.join(consumerRoot, "package.json"), + JSON.stringify({ name: "lifecycle-consumer", private: true }), + ); + const install = spawnSync( + "npm", + [ + "install", + "--ignore-scripts", + "--no-package-lock", + "--no-save", + "--offline", + path.join(archiveRoot, archives[0]!), + ], + { cwd: consumerRoot, encoding: "utf8", env: npmEnvironment() }, + ); + assertCommandSucceeded(install, "packed artifact installation"); + + const installedPackage = path.join(consumerRoot, "node_modules", "nemoclaw"); + expect(fs.existsSync(path.join(installedPackage, "dist/lifecycle/index.js"))).toBe(true); + expect(fs.existsSync(path.join(installedPackage, "dist/lifecycle/index.d.ts"))).toBe(true); + expect(fs.existsSync(path.join(installedPackage, "dist/lifecycle/index.d.ts.map"))).toBe( + true, + ); + + const probe = String.raw` +const Module = require("node:module"); +const writeError = process.stderr.write.bind(process.stderr); +const writeOutput = process.stdout.write.bind(process.stdout); +const originalEnvironment = process.env; +const allowedEnvironmentReads = new Set(["NODE_V8_COVERAGE"]); +globalThis.fetch = () => { + throw new Error("network access is forbidden"); +}; +globalThis.prompt = () => { + throw new Error("prompt access is forbidden"); +}; +process.exit = () => { + throw new Error("process exit is forbidden"); +}; +process.getBuiltinModule = () => { + throw new Error("builtin module access is forbidden"); +}; +for (const stream of ["stdin", "stdout", "stderr"]) { + Object.defineProperty(process, stream, { + configurable: true, + get() { + throw new Error("terminal access is forbidden"); + }, + }); +} +process.env = new Proxy(originalEnvironment, { + get(target, property) { + if (!allowedEnvironmentReads.has(String(property))) { + throw new Error("environment access is forbidden"); + } + return Reflect.get(target, property, target); + }, + getOwnPropertyDescriptor() { + throw new Error("environment access is forbidden"); + }, + has() { + throw new Error("environment access is forbidden"); + }, + ownKeys() { + throw new Error("environment access is forbidden"); + }, +}); +const allowedModuleRequests = new Set([ + "nemoclaw/lifecycle", + "../lib/actions/lifecycle/observe-hermes", + "../lib/domain/lifecycle/contract", + "../lib/domain/lifecycle/hermes-definition", + "../lib/domain/lifecycle/hermes-plan", + "../../domain/lifecycle/contract", + "../../domain/lifecycle/hermes-definition", + "../../domain/lifecycle/hermes-plan", + "../../sandbox-name-contract", + "../../nemoclaw/dist/shared/sandbox-name.cjs", + "./contract", + "./hermes-definition", + "./name-validation", +]); +const originalLoad = Module._load; +Module._load = function(request, parent, isMain) { + if (!allowedModuleRequests.has(request)) { + throw new Error("module access is forbidden"); + } + return originalLoad.call(this, request, parent, isMain); +}; +const lifecycle = require("nemoclaw/lifecycle"); +const digest = (character) => "sha256:" + character.repeat(64); +const request = { + apiVersion: lifecycle.NEMOCLAW_LIFECYCLE_API_VERSION, + target: { + gatewayIdentity: digest("1"), + workspace: "hermes-workspace", + openshellVersion: lifecycle.HERMES_LIFECYCLE_DEFINITION.openshellVersion, + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: digest("2"), + imageDigest: digest("3"), + configurationFingerprint: digest("4"), + }, +}; +void (async () => { + const plan = lifecycle.planHermesLifecycle(request); + const observation = await lifecycle.observeHermesLifecycle( + { plan: request }, + { + async observeHermesAgent(observationRequest) { + if ( + observationRequest.sandboxName !== "hermes-agent" || + observationRequest.resourceIdentity !== digest("2") + ) { + throw new Error("unexpected observation request"); + } + return { + ok: true, + value: { + state: "present", + target: { + gatewayIdentity: digest("1"), + workspace: "hermes-workspace", + openshellVersion: "0.0.106", + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: digest("2"), + imageDigest: digest("3"), + phase: "Ready", + }, + agent: { + name: "hermes", + version: "0.19.0", + configurationFingerprint: digest("4"), + health: { state: "reachable", statusCode: 200 }, + }, + }, + }; + }, + }, + ); + writeOutput( + JSON.stringify({ exportKeys: Object.keys(lifecycle).sort(), observation, plan }), + ); +})().catch((error) => { + writeError(String(error)); + process.exitCode = 1; +}); +`; + const runtime = spawnSync(process.execPath, ["--eval", probe], { + cwd: consumerRoot, + encoding: "utf8", + env: consumerEnvironment(consumerHome), + }); + assertCommandSucceeded(runtime, "CommonJS consumer import"); + expect(runtime.stdout).not.toContain("private-"); + expect(runtime.stderr).not.toContain("private-"); + const runtimeEvidence = JSON.parse(runtime.stdout) as { + exportKeys: string[]; + observation: { ok: boolean; value?: { readiness?: string } }; + plan: { ok: boolean; value?: { agent?: { name?: string } } }; + }; + expect(runtimeEvidence.exportKeys).toEqual([ + "HERMES_LIFECYCLE_DEFINITION", + "NEMOCLAW_LIFECYCLE_API_VERSION", + "observeHermesLifecycle", + "planHermesLifecycle", + ]); + expect(runtimeEvidence.plan).toMatchObject({ + ok: true, + value: { agent: { name: "hermes" } }, + }); + expect(runtimeEvidence.observation).toMatchObject({ + ok: true, + value: { readiness: "ready" }, + }); + const moduleConsumer = String.raw` +const lifecycle = await import("nemoclaw/lifecycle"); +process.stdout.write(lifecycle.HERMES_LIFECYCLE_DEFINITION.agent); +`; + const moduleImport = spawnSync( + process.execPath, + ["--input-type=module", "--eval", moduleConsumer], + { + cwd: consumerRoot, + encoding: "utf8", + env: consumerEnvironment(consumerHome), + }, + ); + assertCommandSucceeded(moduleImport, "ES module consumer import"); + expect(moduleImport.stdout).toBe("hermes"); + + const typeConsumer = path.join(consumerRoot, "consumer.ts"); + fs.writeFileSync( + typeConsumer, + String.raw` +import { + HERMES_LIFECYCLE_DEFINITION, + NEMOCLAW_LIFECYCLE_API_VERSION, + observeHermesLifecycle, + planHermesLifecycle, + type HermesLifecyclePlanRequest, + type LifecycleDigest, + type OpenShellHermesAgentObserver, +} from "nemoclaw/lifecycle"; + +const digest = (character: string): LifecycleDigest => + ("sha256:" + character.repeat(64)) as LifecycleDigest; +const request: HermesLifecyclePlanRequest = { + apiVersion: NEMOCLAW_LIFECYCLE_API_VERSION, + target: { + gatewayIdentity: digest("1"), + workspace: "hermes-workspace", + openshellVersion: HERMES_LIFECYCLE_DEFINITION.openshellVersion, + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: digest("2"), + imageDigest: digest("3"), + configurationFingerprint: digest("4"), + }, +}; +const observer: OpenShellHermesAgentObserver = { + async observeHermesAgent() { + return { + ok: true, + value: { + state: "present", + target: { + gatewayIdentity: digest("1"), + workspace: "hermes-workspace", + openshellVersion: "0.0.106", + }, + sandbox: { + name: "hermes-agent", + resourceIdentity: digest("2"), + imageDigest: digest("3"), + phase: "Ready", + }, + agent: { + name: "hermes", + version: "0.19.0", + configurationFingerprint: digest("4"), + health: { state: "reachable", statusCode: 200 }, + }, + }, + }; + }, +}; +planHermesLifecycle(request); +void observeHermesLifecycle({ plan: request }, observer); +`, + ); + const typecheck = spawnSync( + path.join(REPOSITORY_ROOT, "node_modules", ".bin", "tsc"), + [ + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "ES2022", + "--module", + "Node16", + "--moduleResolution", + "Node16", + typeConsumer, + ], + { + cwd: consumerRoot, + encoding: "utf8", + env: consumerEnvironment(consumerHome), + }, + ); + assertCommandSucceeded(typecheck, "TypeScript consumer declaration check"); + + expect( + execFileSync(process.execPath, ["--eval", "require.resolve('nemoclaw/package.json')"], { + cwd: consumerRoot, + encoding: "utf8", + env: consumerEnvironment(consumerHome), + }), + ).toBe(""); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + fs.rmSync(archiveRoot, { recursive: true, force: true }); + fs.rmSync(consumerRoot, { recursive: true, force: true }); + } + }, + ); +});