From c57aba2df4f735beed0b775cd80c16fe8b3b7598 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 26 Jun 2026 14:16:44 -0400 Subject: [PATCH 1/5] fix(rebuild): pin stale recreate endpoint --- .../actions/sandbox/rebuild-resume-config.test.ts | 14 +++++++++----- src/lib/actions/sandbox/rebuild-resume-config.ts | 15 +++++++++++++-- src/lib/actions/sandbox/rebuild.ts | 12 +++++++++--- test/e2e/test-double-onboard.sh | 5 +++++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 2286766da6..8478b84578 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -122,20 +122,24 @@ describe("getRebuildEndpointFromRegistry", () => { }); describe("prepareRebuildResumeConfig", () => { - it("pins registry config and does not pin endpoint for a matching session", () => { - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "alpha" }); + it("pins registry config and keeps a matching custom-endpoint session endpoint", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "alpha", + endpointUrl: "http://127.0.0.1:19999/v1", + }); const config = prepareRebuildResumeConfig( "alpha", - entry({ provider: "nvidia-prod", model: "m" }), + entry({ provider: "compatible-endpoint", model: "m" }), null, noopLog, throwingBail, ); expect(config).toMatchObject({ - provider: "nvidia-prod", + provider: "compatible-endpoint", model: "m", - credentialEnv: "NVIDIA_INFERENCE_API_KEY", + credentialEnv: "COMPATIBLE_API_KEY", pinEndpoint: false, + endpointUrl: "http://127.0.0.1:19999/v1", }); }); diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index 186bd670a3..9696254c56 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -163,7 +163,12 @@ export interface RebuildResumeConfig { readonly nimContainer: string | null; readonly credentialEnv: string | null; readonly preferredInferenceApi: string | null; - /** Overwrite the session endpoint with `endpointUrl`; false keeps a matching session's own custom URL. */ + /** + * Whether this endpoint was derived without trusting the matching onboard + * session. Kept for preflight/tests; rebuild writes `endpointUrl` + * unconditionally after validation so stale retry sessions cannot leak old + * provider URLs into recreate (#4497/#5869). + */ readonly pinEndpoint: boolean; readonly endpointUrl: string | null; readonly ambient: AmbientRecreateEnvAssessment; @@ -253,6 +258,12 @@ export function prepareRebuildResumeConfig( return null; } + const endpointUrl = rebuildEndpoint.known + ? rebuildEndpoint.endpointUrl + : sessionMatchesSandbox + ? (session?.endpointUrl ?? null) + : null; + return { agent: rebuildAgent, provider: registrySelection.provider, @@ -264,7 +275,7 @@ export function prepareRebuildResumeConfig( ), preferredInferenceApi: registrySelection.preferredInferenceApi, pinEndpoint: !sessionMatchesSandbox && rebuildEndpoint.known, - endpointUrl: rebuildEndpoint.known ? rebuildEndpoint.endpointUrl : null, + endpointUrl, ambient, }; } diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 148e79ed24..4c1ffdcbd3 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -784,9 +784,15 @@ export async function rebuildSandbox( s.nimContainer = resumeConfig.nimContainer; s.credentialEnv = resumeConfig.credentialEnv; s.preferredInferenceApi = resumeConfig.preferredInferenceApi; - if (resumeConfig.pinEndpoint) { - s.endpointUrl = resumeConfig.endpointUrl; - } + // `onboard --resume` uses the session as the recreate contract. Always + // overwrite the endpoint from the preflighted registry-derived config, + // even when the pre-existing session currently matches this sandbox name: + // stale recovery can be retrying after an earlier failed recreate left a + // partial session behind. Leaving the old endpoint in that case can silently + // steer the recreate to the wrong provider URL. `prepareRebuildResumeConfig` + // already validates whether this endpoint is recoverable before any + // destructive work, so this is the safest source boundary (#4497/#5869). + s.endpointUrl = resumeConfig.endpointUrl; return s; }); process.env.NEMOCLAW_SANDBOX_NAME = sandboxName; diff --git a/test/e2e/test-double-onboard.sh b/test/e2e/test-double-onboard.sh index 4a67e3b5fb..c30aee80f7 100755 --- a/test/e2e/test-double-onboard.sh +++ b/test/e2e/test-double-onboard.sh @@ -748,6 +748,11 @@ run_with_timeout "$PHASE_TIMEOUT" \ "${NEMOCLAW_CMD[@]}" "$SANDBOX_A" rebuild --yes >"$REBUILD_LOG" 2>&1 || rebuild_exit=$? rebuild_output="$(cat "$REBUILD_LOG")" rm -f "$REBUILD_LOG" +# Keep the shared diagnostic hook pointed at the command under test. Without +# this, stale-rebuild failures dump the previous onboard output and hide the +# actual recovery failure signature (#4497/#5869). +RUN_ONBOARD_OUTPUT="$rebuild_output" +RUN_ONBOARD_EXIT="$rebuild_exit" # A timeout (124 from `timeout`/`gtimeout`) must fail, not silently pass. if [ "$rebuild_exit" -eq 124 ]; then From 743682c5cad231232372b9e5a71f4e21ee0218be Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 26 Jun 2026 14:28:42 -0400 Subject: [PATCH 2/5] fix(rebuild): validate matching session endpoint --- src/lib/actions/sandbox/rebuild-flow.test.ts | 92 +++++++++++++++++-- .../sandbox/rebuild-resume-config.test.ts | 57 +++++++++++- .../actions/sandbox/rebuild-resume-config.ts | 25 +++-- src/lib/actions/sandbox/rebuild.ts | 12 +-- 4 files changed, 165 insertions(+), 21 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-flow.test.ts b/src/lib/actions/sandbox/rebuild-flow.test.ts index 6b7e3525d8..14c27ffad1 100644 --- a/src/lib/actions/sandbox/rebuild-flow.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow.test.ts @@ -552,11 +552,11 @@ describe("rebuildSandbox flow", () => { } }); - it("recreates a matching-session custom-endpoint sandbox from session, ignoring hostile ambient endpoint/provider/model (#5735 PRA-4)", async () => { + it("recreates a matching-session custom-endpoint sandbox from a validated session endpoint, ignoring hostile ambient endpoint/provider/model (#5735 PRA-4)", async () => { // Matching session (sandboxName === target) with a custom endpoint recorded // in that session. Hostile ambient NEMOCLAW_ENDPOINT_URL/PROVIDER/MODEL must - // be absent during recreate (so onboard --resume uses the session) and the - // session's own recorded endpoint must be preserved (not overwritten). + // be absent during recreate so onboard --resume uses the validated session + // endpoint selected by prepareRebuildResumeConfig. const restoreEnv = snapshotEnv([ "NEMOCLAW_ENDPOINT_URL", "NEMOCLAW_PROVIDER", @@ -581,8 +581,9 @@ describe("rebuildSandbox flow", () => { }; }, }); - // The custom endpoint lives only in this sandbox's own (matching) session. - harness.session.endpointUrl = "https://my-custom-endpoint.example/v1"; + // The custom endpoint lives only in this sandbox's own matching session; + // it is canonicalized at the pre-delete rebuild boundary before rewrite. + harness.session.endpointUrl = "https://my-custom-endpoint.example/v1?x=1#frag"; await expect( harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), @@ -594,7 +595,6 @@ describe("rebuildSandbox flow", () => { provider: undefined, model: undefined, }); - // The matching session's own recorded endpoint is preserved (not pinned/overwritten). expect(harness.session.endpointUrl).toBe("https://my-custom-endpoint.example/v1"); // Provider/model come from the registry entry, not the ambient values. expect(harness.session.provider).toBe("compatible-endpoint"); @@ -608,6 +608,86 @@ describe("rebuildSandbox flow", () => { } }); + it("overwrites a stale matching-session endpoint with durable registry metadata before onboard --resume (#4497/#5869)", async () => { + const restoreEnv = snapshotEnv(["COMPATIBLE_API_KEY"]); + process.env.COMPATIBLE_API_KEY = "compat-key"; // pass credential preflight + try { + const harness = createRebuildFlowHarness({ + applyPreset: () => true, + sandboxEntry: { + provider: "compatible-endpoint", + model: "registry-model", + endpointUrl: "https://registry.example.test/v1?x=1#frag", + }, + }); + harness.session.endpointUrl = "https://stale-retry.example.test/v1"; + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.onboardSpy).toHaveBeenCalled(); + expect(harness.session.endpointUrl).toBe("https://registry.example.test/v1"); + expect(harness.runOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.objectContaining({ ignoreError: true }), + ); + } finally { + restoreEnv(); + } + }); + + it("aborts before backup/delete when a matching custom-endpoint session has no recoverable endpoint (#4497/#5869)", async () => { + const restoreEnv = snapshotEnv(["COMPATIBLE_API_KEY"]); + process.env.COMPATIBLE_API_KEY = "compat-key"; // pass credential preflight + try { + const harness = createRebuildFlowHarness({ + sandboxEntry: { provider: "compatible-endpoint", model: "custom-model" }, + }); + delete harness.session.endpointUrl; + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Cannot validate recreate endpoint"); + + const errors = harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(errors).toContain("cannot validate the inference endpoint"); + expect(errors).toContain("Sandbox is untouched"); + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + } finally { + restoreEnv(); + } + }); + + it("aborts before backup/delete when a matching custom-endpoint session has an invalid endpoint (#4497/#5869)", async () => { + const restoreEnv = snapshotEnv(["COMPATIBLE_API_KEY"]); + process.env.COMPATIBLE_API_KEY = "compat-key"; // pass credential preflight + try { + const harness = createRebuildFlowHarness({ + sandboxEntry: { provider: "compatible-endpoint", model: "custom-model" }, + }); + harness.session.endpointUrl = "file:///tmp/not-http"; + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Cannot validate recreate endpoint"); + + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + } finally { + restoreEnv(); + } + }); + it("aborts before backup/delete when a custom-endpoint target has no matching session (#5735)", async () => { // Installer flow: the loaded onboard session belongs to a different // (just-created) sandbox, and the target uses a custom OpenAI-compatible diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 8478b84578..f10c490286 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -122,10 +122,10 @@ describe("getRebuildEndpointFromRegistry", () => { }); describe("prepareRebuildResumeConfig", () => { - it("pins registry config and keeps a matching custom-endpoint session endpoint", () => { + it("validates and canonicalizes a matching custom-endpoint session endpoint", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "alpha", - endpointUrl: "http://127.0.0.1:19999/v1", + endpointUrl: " http://127.0.0.1:19999/v1/?x=1#frag ", }); const config = prepareRebuildResumeConfig( "alpha", @@ -143,6 +143,59 @@ describe("prepareRebuildResumeConfig", () => { }); }); + it("prefers durable registry endpoint metadata over a stale matching session endpoint", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "alpha", + endpointUrl: "https://stale.example.test/v1", + }); + const config = prepareRebuildResumeConfig( + "alpha", + entry({ + provider: "compatible-endpoint", + model: "m", + endpointUrl: "https://registry.example.test/v1?x=1#frag", + }), + null, + noopLog, + throwingBail, + ); + expect(config).toMatchObject({ + provider: "compatible-endpoint", + model: "m", + pinEndpoint: true, + endpointUrl: "https://registry.example.test/v1", + }); + }); + + it("fails closed for a matching custom-endpoint session with no recoverable endpoint", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "alpha" }); + expect(() => + prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ), + ).toThrow("Cannot validate recreate endpoint"); + }); + + it("fails closed for a matching custom-endpoint session with an invalid endpoint", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "alpha", + endpointUrl: "https://user:pass@example.test/v1", + }); + expect(() => + prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ), + ).toThrow("Cannot validate recreate endpoint"); + }); + it("pins the canonical endpoint when the session belongs to another sandbox", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); const config = prepareRebuildResumeConfig( diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index 9696254c56..cb8d881d87 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -258,11 +258,24 @@ export function prepareRebuildResumeConfig( return null; } - const endpointUrl = rebuildEndpoint.known - ? rebuildEndpoint.endpointUrl - : sessionMatchesSandbox - ? (session?.endpointUrl ?? null) - : null; + let endpointUrl = rebuildEndpoint.known ? rebuildEndpoint.endpointUrl : null; + if (!rebuildEndpoint.known && sessionMatchesSandbox) { + endpointUrl = canonicalCustomEndpointUrl(session?.endpointUrl); + if (!endpointUrl) { + console.error(""); + console.error( + ` ${_RD}Rebuild preflight failed:${R} cannot validate the inference endpoint for provider '${registrySelection.provider}'.`, + ); + console.error( + ` The custom endpoint for '${sandboxName}' is missing or invalid in its onboard session.`, + ); + console.error(" Sandbox is untouched — no data was lost."); + bail( + `Cannot validate recreate endpoint for provider '${registrySelection.provider}' from matching session`, + ); + return null; + } + } return { agent: rebuildAgent, @@ -274,7 +287,7 @@ export function prepareRebuildResumeConfig( registrySelection.credentialEnv, ), preferredInferenceApi: registrySelection.preferredInferenceApi, - pinEndpoint: !sessionMatchesSandbox && rebuildEndpoint.known, + pinEndpoint: rebuildEndpoint.known, endpointUrl, ambient, }; diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 4c1ffdcbd3..5768e11a20 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -772,13 +772,11 @@ export async function rebuildSandbox( // null fallback) so a missing registry value doesn't silently leave a // stale session entry from an earlier sandbox in place. // #5735: apply the recreate config resolved + validated BEFORE delete by - // prepareRebuildResumeConfig (provider/model/credential/endpoint derived - // from the about-to-be-removed registry entry, never from ambient env), so - // onboard --resume recreates the recorded sandbox in non-interactive mode. - // Assign explicitly so a missing value doesn't leave a stale entry from an - // earlier sandbox in place. `pinEndpoint` is false for a matching session - // (keep its own custom endpoint) and true for a non-matching session with a - // canonical/registry-derivable endpoint. + // prepareRebuildResumeConfig, so onboard --resume recreates the recorded + // sandbox in non-interactive mode. Provider/model/credential/endpoint come + // from the about-to-be-removed registry entry or a validated matching + // custom-endpoint session, never ambient env. Assign explicitly so missing + // values cannot leave stale entries from an earlier sandbox in place. s.provider = resumeConfig.provider; s.model = resumeConfig.model; s.nimContainer = resumeConfig.nimContainer; From 303291a187de20395a71ca8d740d6284aef5b970 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 26 Jun 2026 15:33:07 -0400 Subject: [PATCH 3/5] fix(rebuild): allow explicit stale custom endpoint --- src/lib/actions/sandbox/rebuild-flow.test.ts | 35 +++++++++ .../sandbox/rebuild-resume-config.test.ts | 73 ++++++++++++++++++- .../actions/sandbox/rebuild-resume-config.ts | 46 ++++++++++-- 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-flow.test.ts b/src/lib/actions/sandbox/rebuild-flow.test.ts index 14c27ffad1..1c45a8ebe1 100644 --- a/src/lib/actions/sandbox/rebuild-flow.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow.test.ts @@ -688,6 +688,41 @@ describe("rebuildSandbox flow", () => { } }); + it("rebuilds a non-matching-session custom endpoint from explicit target-scoped env (#4497/#5869)", async () => { + const restoreEnv = snapshotEnv([ + "COMPATIBLE_API_KEY", + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_ENDPOINT_URL", + "NEMOCLAW_MODEL", + ]); + process.env.COMPATIBLE_API_KEY = "compat-key"; + process.env.NEMOCLAW_SANDBOX_NAME = "alpha"; + process.env.NEMOCLAW_PROVIDER = "custom"; + process.env.NEMOCLAW_ENDPOINT_URL = "https://explicit.example.test/v1?x=1#frag"; + process.env.NEMOCLAW_MODEL = "custom-model"; + try { + const harness = createRebuildFlowHarness({ + applyPreset: () => true, + sandboxEntry: { provider: "compatible-endpoint", model: "custom-model" }, + sessionSandboxName: "some-other-sandbox", + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.onboardSpy).toHaveBeenCalled(); + expect(harness.session.endpointUrl).toBe("https://explicit.example.test/v1"); + expect(harness.runOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.objectContaining({ ignoreError: true }), + ); + } finally { + restoreEnv(); + } + }); + it("aborts before backup/delete when a custom-endpoint target has no matching session (#5735)", async () => { // Installer flow: the loaded onboard session belongs to a different // (just-created) sandbox, and the target uses a custom OpenAI-compatible diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index f10c490286..70dfbe0753 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -23,6 +23,21 @@ function entry(overrides: Record = {}) { return { name: "alpha", provider: null, model: null, nimContainer: null, ...overrides }; } +function snapshotEnv(names: readonly string[]): () => void { + const saved = names.map((name) => [name, process.env[name]] as const); + return () => { + for (const [name] of saved) { + delete process.env[name]; + } + Object.assign( + process.env, + Object.fromEntries( + saved.filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + ); + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -209,7 +224,7 @@ describe("prepareRebuildResumeConfig", () => { expect(typeof config?.endpointUrl).toBe("string"); }); - it("fails closed for a custom endpoint with a non-matching session and no registry endpoint", () => { + it("fails closed for a custom endpoint with a non-matching session and no registry or explicit endpoint", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); expect(() => prepareRebuildResumeConfig( @@ -222,6 +237,62 @@ describe("prepareRebuildResumeConfig", () => { ).toThrow("Cannot determine recreate endpoint"); }); + it("uses an explicit target-scoped endpoint for a custom endpoint with a non-matching session", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + const restore = snapshotEnv([ + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_ENDPOINT_URL", + "NEMOCLAW_MODEL", + ]); + try { + process.env.NEMOCLAW_SANDBOX_NAME = "alpha"; + process.env.NEMOCLAW_PROVIDER = "custom"; + process.env.NEMOCLAW_ENDPOINT_URL = " http://127.0.0.1:19999/v1/?x=1#frag "; + process.env.NEMOCLAW_MODEL = "m"; + const config = prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ); + expect(config).toMatchObject({ + provider: "compatible-endpoint", + model: "m", + pinEndpoint: true, + endpointUrl: "http://127.0.0.1:19999/v1", + }); + } finally { + restore(); + } + }); + + it("does not use an explicit endpoint when its sandbox name targets another sandbox", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + const restore = snapshotEnv([ + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_ENDPOINT_URL", + ]); + try { + process.env.NEMOCLAW_SANDBOX_NAME = "beta"; + process.env.NEMOCLAW_PROVIDER = "custom"; + process.env.NEMOCLAW_ENDPOINT_URL = "http://127.0.0.1:19999/v1"; + expect(() => + prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ), + ).toThrow("Cannot determine recreate endpoint"); + } finally { + restore(); + } + }); + it("recreates custom endpoints from durable registry metadata when the session is unrelated", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); const config = prepareRebuildResumeConfig( diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index cb8d881d87..898cb6568c 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -56,6 +56,12 @@ function validCredentialEnvName(value: string | null | undefined): string | null return /^[A-Z_][A-Z0-9_]*$/.test(normalized) ? normalized : null; } +function providerNameFromEnvHint(value: string | null | undefined): string | null { + const hint = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!hint) return null; + return REMOTE_PROVIDER_CONFIG[hint]?.providerName ?? hint; +} + function providerRecordedCredentialEnv( provider: string | null | undefined, recordedCredentialEnv?: string | null, @@ -150,6 +156,20 @@ export function getRebuildEndpointFromRegistry( return { known: true, endpointUrl: remoteConfig?.endpointUrl || null }; } +function getExplicitTargetEndpointFromEnv( + sandboxName: string, + provider: string | null, + model: string | null, + env: NodeJS.ProcessEnv = process.env, +): string | null { + if (!provider || !SESSION_ONLY_ENDPOINT_PROVIDER_NAMES.has(provider)) return null; + if ((env.NEMOCLAW_SANDBOX_NAME || "").trim() !== sandboxName) return null; + if (providerNameFromEnvHint(env.NEMOCLAW_PROVIDER) !== provider) return null; + const envModel = typeof env.NEMOCLAW_MODEL === "string" ? env.NEMOCLAW_MODEL.trim() : ""; + if (model && envModel && envModel !== model) return null; + return canonicalCustomEndpointUrl(env.NEMOCLAW_ENDPOINT_URL); +} + /** * The exact agent/provider/model/credential/endpoint a rebuild will re-apply to * the onboard session so `onboard --resume` recreates the *recorded* sandbox @@ -223,14 +243,25 @@ export function prepareRebuildResumeConfig( registrySelection.provider, registrySelection.endpointUrl, ); + const explicitTargetEndpoint = rebuildEndpoint.known + ? null + : getExplicitTargetEndpointFromEnv( + sandboxName, + registrySelection.provider, + registrySelection.model, + ); // When the loaded session belongs to a *different* sandbox (e.g. an // installer's just-completed onboard before `upgrade-sandboxes --auto`), the // target's inference endpoint can only be re-derived for providers with a // canonical endpoint (NVIDIA Endpoints, Anthropic, etc.), local inference, - // routed inference, or durable custom endpoint metadata recorded on the target - // registry entry. For legacy custom OpenAI-compatible entries without that - // metadata, recreating would either fail or silently reconfigure against the + // routed inference, durable custom endpoint metadata recorded on the target + // registry entry, or an explicit command-scoped custom endpoint whose + // NEMOCLAW_SANDBOX_NAME/provider/model match this rebuild target. The latter + // supports legacy registry rows that predate durable endpoint persistence + // without borrowing from an unrelated onboard session; the value is validated + // here and then written into the resume session before ambient env isolation. + // Otherwise, recreating would either fail or silently reconfigure against the // unrelated session's endpoint. Fail closed before any destructive work so the // sandbox stays live. if ( @@ -238,7 +269,8 @@ export function prepareRebuildResumeConfig( registrySelection.provider && !isLocalInferenceProvider(registrySelection.provider) && registrySelection.provider !== hermesProviderAuth.HERMES_PROVIDER_NAME && - !rebuildEndpoint.known + !rebuildEndpoint.known && + !explicitTargetEndpoint ) { console.error(""); console.error( @@ -258,8 +290,8 @@ export function prepareRebuildResumeConfig( return null; } - let endpointUrl = rebuildEndpoint.known ? rebuildEndpoint.endpointUrl : null; - if (!rebuildEndpoint.known && sessionMatchesSandbox) { + let endpointUrl = rebuildEndpoint.known ? rebuildEndpoint.endpointUrl : explicitTargetEndpoint; + if (!endpointUrl && !rebuildEndpoint.known && sessionMatchesSandbox) { endpointUrl = canonicalCustomEndpointUrl(session?.endpointUrl); if (!endpointUrl) { console.error(""); @@ -287,7 +319,7 @@ export function prepareRebuildResumeConfig( registrySelection.credentialEnv, ), preferredInferenceApi: registrySelection.preferredInferenceApi, - pinEndpoint: rebuildEndpoint.known, + pinEndpoint: rebuildEndpoint.known || explicitTargetEndpoint !== null, endpointUrl, ambient, }; From c3d3b221aafa6f5889f75c5717acb28e3080cd0b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 26 Jun 2026 15:52:57 -0400 Subject: [PATCH 4/5] fix(rebuild): tighten explicit endpoint fallback --- src/lib/actions/sandbox/rebuild-flow.test.ts | 115 ------------------ .../sandbox/rebuild-resume-config.test.ts | 99 +++++++++++++++ .../actions/sandbox/rebuild-resume-config.ts | 32 +++-- 3 files changed, 121 insertions(+), 125 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-flow.test.ts b/src/lib/actions/sandbox/rebuild-flow.test.ts index 1c45a8ebe1..ca233c07b0 100644 --- a/src/lib/actions/sandbox/rebuild-flow.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow.test.ts @@ -608,121 +608,6 @@ describe("rebuildSandbox flow", () => { } }); - it("overwrites a stale matching-session endpoint with durable registry metadata before onboard --resume (#4497/#5869)", async () => { - const restoreEnv = snapshotEnv(["COMPATIBLE_API_KEY"]); - process.env.COMPATIBLE_API_KEY = "compat-key"; // pass credential preflight - try { - const harness = createRebuildFlowHarness({ - applyPreset: () => true, - sandboxEntry: { - provider: "compatible-endpoint", - model: "registry-model", - endpointUrl: "https://registry.example.test/v1?x=1#frag", - }, - }); - harness.session.endpointUrl = "https://stale-retry.example.test/v1"; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.onboardSpy).toHaveBeenCalled(); - expect(harness.session.endpointUrl).toBe("https://registry.example.test/v1"); - expect(harness.runOpenshellSpy).toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.objectContaining({ ignoreError: true }), - ); - } finally { - restoreEnv(); - } - }); - - it("aborts before backup/delete when a matching custom-endpoint session has no recoverable endpoint (#4497/#5869)", async () => { - const restoreEnv = snapshotEnv(["COMPATIBLE_API_KEY"]); - process.env.COMPATIBLE_API_KEY = "compat-key"; // pass credential preflight - try { - const harness = createRebuildFlowHarness({ - sandboxEntry: { provider: "compatible-endpoint", model: "custom-model" }, - }); - delete harness.session.endpointUrl; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Cannot validate recreate endpoint"); - - const errors = harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n"); - expect(errors).toContain("cannot validate the inference endpoint"); - expect(errors).toContain("Sandbox is untouched"); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - } finally { - restoreEnv(); - } - }); - - it("aborts before backup/delete when a matching custom-endpoint session has an invalid endpoint (#4497/#5869)", async () => { - const restoreEnv = snapshotEnv(["COMPATIBLE_API_KEY"]); - process.env.COMPATIBLE_API_KEY = "compat-key"; // pass credential preflight - try { - const harness = createRebuildFlowHarness({ - sandboxEntry: { provider: "compatible-endpoint", model: "custom-model" }, - }); - harness.session.endpointUrl = "file:///tmp/not-http"; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Cannot validate recreate endpoint"); - - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - } finally { - restoreEnv(); - } - }); - - it("rebuilds a non-matching-session custom endpoint from explicit target-scoped env (#4497/#5869)", async () => { - const restoreEnv = snapshotEnv([ - "COMPATIBLE_API_KEY", - "NEMOCLAW_SANDBOX_NAME", - "NEMOCLAW_PROVIDER", - "NEMOCLAW_ENDPOINT_URL", - "NEMOCLAW_MODEL", - ]); - process.env.COMPATIBLE_API_KEY = "compat-key"; - process.env.NEMOCLAW_SANDBOX_NAME = "alpha"; - process.env.NEMOCLAW_PROVIDER = "custom"; - process.env.NEMOCLAW_ENDPOINT_URL = "https://explicit.example.test/v1?x=1#frag"; - process.env.NEMOCLAW_MODEL = "custom-model"; - try { - const harness = createRebuildFlowHarness({ - applyPreset: () => true, - sandboxEntry: { provider: "compatible-endpoint", model: "custom-model" }, - sessionSandboxName: "some-other-sandbox", - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.onboardSpy).toHaveBeenCalled(); - expect(harness.session.endpointUrl).toBe("https://explicit.example.test/v1"); - expect(harness.runOpenshellSpy).toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.objectContaining({ ignoreError: true }), - ); - } finally { - restoreEnv(); - } - }); - it("aborts before backup/delete when a custom-endpoint target has no matching session (#5735)", async () => { // Installer flow: the loaded onboard session belongs to a different // (just-created) sandbox, and the target uses a custom OpenAI-compatible diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 70dfbe0753..486e44ab95 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -182,6 +182,36 @@ describe("prepareRebuildResumeConfig", () => { }); }); + it("ignores target-scoped explicit env when the custom-endpoint session matches the sandbox", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "alpha", + endpointUrl: "https://session.example.test/v1?x=1#frag", + }); + const restore = snapshotEnv([ + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_ENDPOINT_URL", + "NEMOCLAW_MODEL", + ]); + try { + process.env.NEMOCLAW_SANDBOX_NAME = "alpha"; + process.env.NEMOCLAW_PROVIDER = "custom"; + process.env.NEMOCLAW_ENDPOINT_URL = "https://env.example.test/v1"; + process.env.NEMOCLAW_MODEL = "m"; + const config = prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ); + expect(config?.pinEndpoint).toBe(false); + expect(config?.endpointUrl).toBe("https://session.example.test/v1"); + } finally { + restore(); + } + }); + it("fails closed for a matching custom-endpoint session with no recoverable endpoint", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "alpha" }); expect(() => @@ -268,6 +298,75 @@ describe("prepareRebuildResumeConfig", () => { } }); + it("accepts camelCase explicit provider aliases for non-matching session recovery", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + const restore = snapshotEnv([ + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_ENDPOINT_URL", + "NEMOCLAW_MODEL", + ]); + try { + process.env.NEMOCLAW_SANDBOX_NAME = "alpha"; + process.env.NEMOCLAW_PROVIDER = "anthropicCompatible"; + process.env.NEMOCLAW_ENDPOINT_URL = "https://anthropic.example.test/v1?x=1#frag"; + process.env.NEMOCLAW_MODEL = "claude-like"; + const config = prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-anthropic-endpoint", model: "claude-like" }), + null, + noopLog, + throwingBail, + ); + expect(config).toMatchObject({ + provider: "compatible-anthropic-endpoint", + model: "claude-like", + pinEndpoint: true, + endpointUrl: "https://anthropic.example.test/v1", + }); + } finally { + restore(); + } + }); + + it("rejects explicit target endpoints that do not exactly match the target boundary", () => { + const cases = [ + { name: "wrong sandbox", sandboxName: "beta" }, + { name: "wrong provider", provider: "openai" }, + { name: "unknown provider", provider: "compatible-endpoint-alias" }, + { name: "wrong model", model: "other-model" }, + { name: "unsupported url", endpointUrl: "file:///tmp/x" }, + { name: "userinfo url", endpointUrl: "https://u:p@example.test/v1" }, + ]; + for (const testCase of cases) { + vi.restoreAllMocks(); + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + const restore = snapshotEnv([ + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_ENDPOINT_URL", + "NEMOCLAW_MODEL", + ]); + try { + process.env.NEMOCLAW_SANDBOX_NAME = testCase.sandboxName ?? "alpha"; + process.env.NEMOCLAW_PROVIDER = testCase.provider ?? "custom"; + process.env.NEMOCLAW_ENDPOINT_URL = testCase.endpointUrl ?? "https://env.example.test/v1"; + process.env.NEMOCLAW_MODEL = testCase.model ?? "m"; + expect(() => + prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ), + ).toThrow("Cannot determine recreate endpoint"); + } finally { + restore(); + } + } + }); + it("does not use an explicit endpoint when its sandbox name targets another sandbox", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); const restore = snapshotEnv([ diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index 898cb6568c..4f98ebe64a 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -57,9 +57,14 @@ function validCredentialEnvName(value: string | null | undefined): string | null } function providerNameFromEnvHint(value: string | null | undefined): string | null { - const hint = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (!hint) return null; - return REMOTE_PROVIDER_CONFIG[hint]?.providerName ?? hint; + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) return null; + const hint = raw.toLowerCase(); + const config = Object.entries(REMOTE_PROVIDER_CONFIG).find( + ([key, config]) => + key.toLowerCase() === hint || config.providerName.toLowerCase() === hint, + )?.[1]; + return config?.providerName ?? null; } function providerRecordedCredentialEnv( @@ -243,13 +248,14 @@ export function prepareRebuildResumeConfig( registrySelection.provider, registrySelection.endpointUrl, ); - const explicitTargetEndpoint = rebuildEndpoint.known - ? null - : getExplicitTargetEndpointFromEnv( - sandboxName, - registrySelection.provider, - registrySelection.model, - ); + const explicitTargetEndpoint = + !sessionMatchesSandbox && !rebuildEndpoint.known + ? getExplicitTargetEndpointFromEnv( + sandboxName, + registrySelection.provider, + registrySelection.model, + ) + : null; // When the loaded session belongs to a *different* sandbox (e.g. an // installer's just-completed onboard before `upgrade-sandboxes --auto`), the @@ -290,6 +296,12 @@ export function prepareRebuildResumeConfig( return null; } + // Endpoint precedence at the destructive rebuild boundary: + // 1. Durable/canonical registry metadata, when known. + // 2. Explicit target-scoped env only for legacy rows whose loaded session is + // not this sandbox, after exact sandbox/provider/model checks and URL + // canonicalization. + // 3. The target sandbox's own matching session endpoint, validated below. let endpointUrl = rebuildEndpoint.known ? rebuildEndpoint.endpointUrl : explicitTargetEndpoint; if (!endpointUrl && !rebuildEndpoint.known && sessionMatchesSandbox) { endpointUrl = canonicalCustomEndpointUrl(session?.endpointUrl); From cce56b12d33cf79d85401e469c097d809710b967 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 26 Jun 2026 16:03:14 -0400 Subject: [PATCH 5/5] fix(rebuild): require explicit model match --- src/lib/actions/sandbox/rebuild-resume-config.test.ts | 1 + src/lib/actions/sandbox/rebuild-resume-config.ts | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 486e44ab95..bb101ef643 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -334,6 +334,7 @@ describe("prepareRebuildResumeConfig", () => { { name: "wrong sandbox", sandboxName: "beta" }, { name: "wrong provider", provider: "openai" }, { name: "unknown provider", provider: "compatible-endpoint-alias" }, + { name: "missing model", model: "" }, { name: "wrong model", model: "other-model" }, { name: "unsupported url", endpointUrl: "file:///tmp/x" }, { name: "userinfo url", endpointUrl: "https://u:p@example.test/v1" }, diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index 4f98ebe64a..86957e9804 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -61,8 +61,7 @@ function providerNameFromEnvHint(value: string | null | undefined): string | nul if (!raw) return null; const hint = raw.toLowerCase(); const config = Object.entries(REMOTE_PROVIDER_CONFIG).find( - ([key, config]) => - key.toLowerCase() === hint || config.providerName.toLowerCase() === hint, + ([key, config]) => key.toLowerCase() === hint || config.providerName.toLowerCase() === hint, )?.[1]; return config?.providerName ?? null; } @@ -171,7 +170,7 @@ function getExplicitTargetEndpointFromEnv( if ((env.NEMOCLAW_SANDBOX_NAME || "").trim() !== sandboxName) return null; if (providerNameFromEnvHint(env.NEMOCLAW_PROVIDER) !== provider) return null; const envModel = typeof env.NEMOCLAW_MODEL === "string" ? env.NEMOCLAW_MODEL.trim() : ""; - if (model && envModel && envModel !== model) return null; + if (model && envModel !== model) return null; return canonicalCustomEndpointUrl(env.NEMOCLAW_ENDPOINT_URL); }