diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 65f8265f9bd..eec9cad08d1 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -23,7 +23,7 @@ "src/lib/inference/config.ts": 30, "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 25, - "src/lib/onboard/gateway-binding.ts": 51, + "src/lib/onboard/gateway-binding.ts": 52, "src/lib/runner.ts": 88, "src/lib/security/redact.ts": 53, "src/lib/state/onboard-session.ts": 36, diff --git a/docs/reference/system-readiness.mdx b/docs/reference/system-readiness.mdx index a0c39eb891d..bb34c5ec6e6 100644 --- a/docs/reference/system-readiness.mdx +++ b/docs/reference/system-readiness.mdx @@ -306,7 +306,10 @@ Explicit CPU-only intent skips the WSL GPU proof. The gateway phase validates authority again immediately before managed reconciliation or external attachment. Authoritative rebuild preflight pins read-only probes to the recorded gateway without selecting, starting, or recovering it. -Immediately before source deletion, the rebuild journal requires the complete gateway authority to match that preflight handoff and carries the journaled authority into replacement onboarding. +Immediately before source deletion, the rebuild journal requires the complete gateway authority to match that preflight handoff. +It accepts only one managed lifecycle change: a recorded package-managed service that now resolves as the standalone gateway. +It carries the journaled authority into replacement onboarding. +Refer to [Declare the OpenShell Gateway Lifecycle Authority](../deployment/gateway-lifecycle-authority) for the complete exception contract. Gateway recovery remains after the canonical readiness gate. For external supervision, onboarding skips managed gateway selection, reuse refresh, cleanup, start, stop, replace, and standalone fallback paths. diff --git a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts index a8e813c524c..3b2a9fda47f 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts @@ -269,16 +269,17 @@ describe("rebuild replacement journal", () => { expect(authority?.kind === "selected" && authority.value).toBe(journal.gatewayAuthority); }); - it("refuses an authority change after preflight before writing the journal (#7411)", () => { + it("refuses the standalone to package-managed authority change after preflight (#7411)", () => { const onAuthorityRefusal = vi.fn(); + mocks.resolveGatewayRebuildAuthority.mockReturnValue({ + ...STANDALONE_GATEWAY_AUTHORITY, + source: "packaged-service", + }); expect(() => openRebuildRecreateJournal({ target: NON_DEFAULT_TARGET, - expectedGatewayAuthority: { - ...STANDALONE_GATEWAY_AUTHORITY, - source: "packaged-service", - }, + expectedGatewayAuthority: STANDALONE_GATEWAY_AUTHORITY, agentName: "langchain-deepagents-code", targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), log: vi.fn(), @@ -291,6 +292,29 @@ describe("rebuild replacement journal", () => { expect(session.checkpoint?.sandboxRecreate ?? null).toBeNull(); }); + it("adopts the package-managed to standalone migration and journals standalone (#9088)", () => { + const onAuthorityRefusal = vi.fn(); + + const journal = openRebuildRecreateJournal({ + target: NON_DEFAULT_TARGET, + expectedGatewayAuthority: { + ...STANDALONE_GATEWAY_AUTHORITY, + source: "packaged-service", + }, + agentName: "langchain-deepagents-code", + targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), + log: vi.fn(), + onAuthorityRefusal, + }); + + expect(onAuthorityRefusal).not.toHaveBeenCalled(); + expect(journal.gatewayAuthority.source).toBe("standalone"); + expect(session.checkpoint?.gatewayAuthority).toMatchObject({ + kind: "selected", + value: { source: "standalone" }, + }); + }); + it("starts at deleted when the source sandbox is already absent", () => { mocks.captureOpenshell.mockReturnValue(absentProbe()); diff --git a/src/lib/actions/sandbox/rebuild-recreate-journal.ts b/src/lib/actions/sandbox/rebuild-recreate-journal.ts index 27bc6617e32..5c04bbd5263 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.ts @@ -9,6 +9,7 @@ import { describeGatewayOwnerForError, sameGatewayOwner } from "../../onboard/ga import { GatewayAuthorityError, gatewayAuthorityFailureLines, + isManagedPackagedServiceMigration, resolveGatewayRebuildAuthority, } from "../../onboard/gateway-teardown-authority"; import { @@ -126,7 +127,10 @@ export function openRebuildRecreateJournal( gatewayPort: target.gatewayPort, }); const expectedAuthority = gatewayOwnerFromCheckpoint(input.expectedGatewayAuthority); - if (!sameGatewayOwner(expectedAuthority, authority)) { + if ( + !sameGatewayOwner(expectedAuthority, authority) && + !isManagedPackagedServiceMigration(expectedAuthority, authority) + ) { throw new GatewayAuthorityError( "Gateway lifecycle authority changed after authoritative rebuild preflight " + `(${describeGatewayOwnerForError(expectedAuthority)} -> ${describeGatewayOwnerForError(authority)}). ` + diff --git a/src/lib/onboard/gateway-binding.ts b/src/lib/onboard/gateway-binding.ts index de3a43ace7d..750c9175ac7 100644 --- a/src/lib/onboard/gateway-binding.ts +++ b/src/lib/onboard/gateway-binding.ts @@ -29,7 +29,7 @@ export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway"; /** Docker-driver gateway compatibility container name for the default port. */ export const BASE_GATEWAY_COMPAT_CONTAINER_NAME = "nemoclaw-openshell-gateway"; -function isDefaultGatewayPort(port: number): boolean { +export function isDefaultGatewayPort(port: number): boolean { return port === DEFAULT_GATEWAY_PORT; } diff --git a/src/lib/onboard/gateway-host-runtime.test.ts b/src/lib/onboard/gateway-host-runtime.test.ts index db452c7196e..94639413704 100644 --- a/src/lib/onboard/gateway-host-runtime.test.ts +++ b/src/lib/onboard/gateway-host-runtime.test.ts @@ -178,6 +178,34 @@ describe("gateway host runtime ownership", () => { expect(() => runtime.getGatewayOwner()).toThrow(/authority changed during this run/); }); + it("resolves standalone on a non-default gateway port even when the packaged service exists (#9088)", () => { + const runtime = createGatewayHostRuntime( + createDeps({ + gatewayName: () => "nemoclaw-9443", + gatewayPort: () => 9443, + hasOpenShellGatewayUserService: () => true, + }), + ); + + expect(runtime.getGatewayOwner()).toMatchObject({ + gatewayName: "nemoclaw-9443", + gatewayPort: 9443, + mode: "nemoclaw-managed", + source: "standalone", + }); + }); + + it("resolves the packaged service on the default gateway port (#9088)", () => { + const runtime = createGatewayHostRuntime( + createDeps({ hasOpenShellGatewayUserService: () => true }), + ); + + expect(runtime.getGatewayOwner()).toMatchObject({ + gatewayPort: 8080, + source: "packaged-service", + }); + }); + it("adopts only a trusted standalone-to-packaged-service install transition (#7411)", () => { let hasPackagedService = false; const runtime = createGatewayHostRuntime( diff --git a/src/lib/onboard/gateway-host-runtime.ts b/src/lib/onboard/gateway-host-runtime.ts index 749a971e8bf..49cdd794fa7 100644 --- a/src/lib/onboard/gateway-host-runtime.ts +++ b/src/lib/onboard/gateway-host-runtime.ts @@ -18,6 +18,7 @@ import path from "node:path"; import { isGatewayHealthy } from "../state/gateway"; import type { GatewayPortListenerRawScan } from "./docker-driver-gateway-port-listener"; import { hasOpenShellGatewayUserService } from "./docker-driver-gateway-service"; +import { isDefaultGatewayPort } from "./gateway-binding"; import { isDockerDriverGatewayHttpReady, isGatewayHttpReady, @@ -137,7 +138,9 @@ export function createGatewayHostRuntime(deps: GatewayHostRuntimeDeps): GatewayH gatewayName, gatewayPort, declaration: loaded.declaration, - hasPackagedService: (deps.hasOpenShellGatewayUserService ?? hasOpenShellGatewayUserService)(), + hasPackagedService: + isDefaultGatewayPort(gatewayPort) && + (deps.hasOpenShellGatewayUserService ?? hasOpenShellGatewayUserService)(), }); } diff --git a/src/lib/onboard/gateway-teardown-authority.ts b/src/lib/onboard/gateway-teardown-authority.ts index c9715aa9b2b..2f1e07e1eb2 100644 --- a/src/lib/onboard/gateway-teardown-authority.ts +++ b/src/lib/onboard/gateway-teardown-authority.ts @@ -51,7 +51,7 @@ export type GatewayTeardownAuthorityResolver = ( type GatewayAuthorityEffect = "credential mutation" | "rebuild" | "teardown"; -function isManagedPackagedServiceMigration( +export function isManagedPackagedServiceMigration( recorded: GatewayOwner, resolved: GatewayOwner, ): boolean {