Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e4e2e60
fix(cli): recover missing legacy gateway
ericksoa Aug 26, 2026
0b2862f
fix(cli): revert typed sandbox observer
ericksoa Aug 26, 2026
56edb11
fix(onboard): restore legacy pre-restart proof
ericksoa Aug 26, 2026
022312c
Merge branch 'main' into fix/legacy-upgrade-unknown-gateway
cv Aug 26, 2026
e867df4
Revert "fix(onboard): finalize exact Docker sandbox handoff (#10044)"
ericksoa Aug 26, 2026
5954929
Reapply "fix(onboard): finalize exact Docker sandbox handoff (#10044)"
ericksoa Aug 26, 2026
cdfff8c
fix(onboard): report sandbox recreate failure
ericksoa Aug 26, 2026
1be28ad
fix(onboard): prove legacy row before authority backfill
ericksoa Aug 26, 2026
88645ef
Merge branch 'main' into fix/legacy-upgrade-unknown-gateway
ericksoa Aug 26, 2026
cfdd4c3
fix(onboard): report post-create policy refusal
ericksoa Aug 26, 2026
db72396
fix(onboard): carry verified policy into provider activation
ericksoa Aug 26, 2026
7a83820
fix(policy): parse base readback before receipt comparison
ericksoa Aug 26, 2026
30b7115
fix(rebuild): ignore receipt-bound source projections
ericksoa Aug 26, 2026
62b6ba2
fix(e2e): match registered managed image workflow
ericksoa Aug 26, 2026
8fb1d5b
fix(onboard): wait for recreate reconnect before revalidation
ericksoa Aug 26, 2026
51e143a
Merge branch 'main' into fix/legacy-upgrade-unknown-gateway
prekshivyas Aug 26, 2026
5ff730b
fix(onboard): scope final handoff to gateway namespace
ericksoa Aug 26, 2026
da51a4b
test(onboard): model gateway namespace handoff
ericksoa Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,9 @@ describe("rebuild recreate shields state", () => {
"bail: Recreate failed (stale-sandbox recovery).",
);

expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Sandbox recreate error: inner onboard failed"),
);
expect(clearShieldsState).not.toHaveBeenCalled();
});
});
16 changes: 10 additions & 6 deletions src/lib/actions/sandbox/rebuild-recreate-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ import type { Session } from "../../state/onboard-session";
import * as onboardSession from "../../state/onboard-session";
import * as registry from "../../state/registry";
import { cloneSandboxHostMounts } from "../../state/registry/host-mount";
import {
excludePolicyPresetsByName,
type RebuildBackupManifest,
} from "./rebuild-backup-phase";
import { excludePolicyPresetsByName, type RebuildBackupManifest } from "./rebuild-backup-phase";
import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight";
import type { RebuildDurableConfig } from "./rebuild-durable-config";
import { isolateAmbientRecreateEnv } from "./rebuild-env-isolation";
Expand Down Expand Up @@ -288,7 +285,9 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput):
await rebuildOnboardDependencies.onboard({
...recreateOptions,
rebuildGatewayAuthority,
...(Array.isArray(recreatePolicyPresets) ? { rebuildPolicyPresets: recreatePolicyPresets } : {}),
...(Array.isArray(recreatePolicyPresets)
? { rebuildPolicyPresets: recreatePolicyPresets }
: {}),
...(rebuildsHermesSandbox && backupManifest?.preservedEnv
? { rebuildPreservedEnv: backupManifest.preservedEnv }
: {}),
Expand All @@ -299,7 +298,12 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput):
onboardFailed = true;
const message = error instanceof Error ? error.message : String(error);
const name = error instanceof Error ? error.name : "";
if (name !== "RebuildOnboardExit") log(`onboard() threw: ${message}`);
if (name !== "RebuildOnboardExit") {
log(`onboard() threw: ${message}`);
console.error(
` ${_RD}Sandbox recreate error:${R} ${onboardSession.redactSensitiveText(message) ?? "Inner onboarding failed."}`,
);
}
} finally {
process.exit = savedExit;
restoreRebuildBaseImageOverride();
Expand Down
1 change: 1 addition & 0 deletions src/lib/adapters/openshell/sandbox-observer-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ describe("CLI OpenShell sandbox observer", () => {
it.each([
["transport", "unreachable", captured(1, "", "client error (Connect): Connection refused")],
["transport", "unreachable", captured(1, "", "Status: Disconnected")],
["transport", "unreachable", captured(1, "", "Unknown gateway 'nemoclaw'.")],
["transport", "identity_mismatch", captured(1, "", "handshake verification failed")],
["schema", undefined, captured(1, "", "protobuf decode error: invalid wire type")],
["command", "failed", captured(7, "", "unexpected opaque failure")],
Expand Down
2 changes: 1 addition & 1 deletion src/lib/adapters/openshell/sandbox-observer-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ function commandError(result: CapturedSandboxCommandResult): OpenShellSandboxErr
};
}
if (
/\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b|status:\s*disconnected/iu.test(
/\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured|unknown gateway)\b|status:\s*disconnected/iu.test(
output,
)
) {
Expand Down
88 changes: 79 additions & 9 deletions src/lib/onboard/docker-gpu-patch-finalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,14 @@ describe("finalizeDockerGpuPatchBackup", () => {
inspect: { event: "confirm running replacement", result: { status: 0, stdout: "true\n" } },
} as const;
const dockerRun = vi.fn((args: readonly string[]) => {
const response = dockerResults[String(args[0]) as keyof typeof dockerResults];
const namespaceInspect =
args[0] === "inspect" && String(args[4]).includes("sandbox-namespace");
const response = namespaceInspect
? {
event: "read replacement namespace",
result: { status: 0, stdout: "current-gateway\n" },
}
: dockerResults[String(args[0]) as keyof typeof dockerResults];
events.push(response.event);
return response.result;
});
Expand Down Expand Up @@ -187,6 +194,7 @@ describe("finalizeDockerGpuPatchBackup", () => {
"start replacement",
"observe ready",
"exec ready",
"read replacement namespace",
"confirm sole replacement",
"confirm running replacement",
]);
Expand Down Expand Up @@ -260,15 +268,14 @@ describe("finalizeDockerGpuPatchBackup", () => {
expect(sleep).not.toHaveBeenCalled();
});

it("accepts a retiring Error row only when the exact replacement has the OpenShell label (#9962)", () => {
it("scopes the final sole-container proof to the replacement gateway namespace", () => {
const result = exactDeferredCreateResult();
const dockerRunResults = {
inspect: { status: 0, stdout: "true\n" },
ps: { status: 0, stdout: `${result.newContainerId}\n` },
} as const;
const dockerRun = vi.fn(
(args: readonly string[]) =>
dockerRunResults[String(args[0]) as keyof typeof dockerRunResults],
const dockerRun = vi.fn((args: readonly string[]) =>
args[0] === "inspect"
? String(args[4]).includes("sandbox-namespace")
? { status: 0, stdout: "current-gateway\n" }
: { status: 0, stdout: "true\n" }
: { status: 0, stdout: `${result.newContainerId}\n` },
);

const outcome = finalizeDockerGpuPatchBackup(
Expand Down Expand Up @@ -299,18 +306,81 @@ describe("finalizeDockerGpuPatchBackup", () => {
finalHandoffAcknowledged: true,
});
expect(dockerRun.mock.calls[0]?.[0]).toEqual([
"ps",
"-a",
"--no-trunc",
"--filter",
`id=${result.newContainerId}`,
"--filter",
"label=openshell.ai/managed-by=openshell",
"--format",
"{{.ID}}",
]);
expect(dockerRun.mock.calls[1]?.[0]).toEqual([
"inspect",
"--type",
"container",
"--format",
'{{ index .Config.Labels "openshell.ai/sandbox-namespace" }}',
result.newContainerId,
]);
expect(dockerRun.mock.calls[2]?.[0]).toEqual([
"ps",
"-a",
"--no-trunc",
"--filter",
"label=openshell.ai/managed-by=openshell",
"--filter",
"label=openshell.ai/sandbox-name=restored-name",
"--filter",
"label=openshell.ai/sandbox-namespace=current-gateway",
"--format",
"{{.ID}}",
]);
});

it("rejects multiple same-name containers within the replacement gateway namespace", () => {
const result = exactDeferredCreateResult();
const dockerRun = vi.fn((args: readonly string[]) =>
args[0] === "inspect"
? String(args[4]).includes("sandbox-namespace")
? { status: 0, stdout: "current-gateway\n" }
: { status: 0, stdout: "true\n" }
: args.includes("label=openshell.ai/sandbox-namespace=current-gateway")
? { status: 0, stdout: `${result.newContainerId}\n${"c".repeat(64)}\n` }
: { status: 0, stdout: `${result.newContainerId}\n` },
);

const outcome = finalizeDockerGpuPatchBackup(
{
result,
supervisorReady: true,
sandboxName: "alpha",
finalHandoffTimeoutSecs: 60,
},
{
dockerStop: vi.fn(() => ({ status: 0 })),
dockerRm: vi.fn(() => ({ status: 0 })),
dockerStart: vi.fn(() => ({ status: 0 })),
dockerRun,
runCaptureOpenshell: vi.fn(() => "alpha 2026-08-23 01:40:35 Ready\n"),
runOpenshell: vi.fn((args: readonly string[]) =>
args[1] === "list"
? { status: 0, stdout: "beta 2026-08-23 01:40:35 Ready\n" }
: { status: 0 },
),
sleep: vi.fn(),
},
);

expect(outcome).toMatchObject({
backupRemoved: true,
lifecycleReleaseObserved: true,
replacementRestarted: true,
finalHandoffAcknowledged: false,
});
});

it.each([
["a failed query", { status: 1, stderr: "daemon unavailable" }],
["no labeled replacement", { status: 0, stdout: "" }],
Expand Down
97 changes: 81 additions & 16 deletions src/lib/onboard/docker-gpu-patch-finalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ import {
waitForOpenShellFinalHandoff,
waitForOpenShellSandboxLifecycleRelease,
} from "./docker-gpu-supervisor-reconnect";
import { queryOpenShellDockerSandboxContainers } from "./openshell-docker-sandbox-containers";
import {
OPENSHELL_MANAGED_BY_LABEL,
OPENSHELL_MANAGED_BY_VALUE,
OPENSHELL_SANDBOX_NAMESPACE_LABEL,
queryOpenShellDockerSandboxContainers,
} from "./openshell-docker-sandbox-containers";

export {
restoreDockerGpuPatchBackupAfterRecreateFailure as rollbackDockerGpuPatchOnRecreateFailure,
Expand Down Expand Up @@ -72,6 +77,45 @@ export type DockerGpuPatchFinalizeOutcome = {
replacementPresence?: "absent" | "present" | "unknown";
};

function isExactOpenShellReplacement(
replacementContainerId: string,
dockerRun: NonNullable<DockerGpuPatchDeps["dockerRun"]>,
timeoutMs: number,
): boolean {
const expectedContainerId = fullDockerContainerId(replacementContainerId);
if (!expectedContainerId || timeoutMs <= 0) return false;
try {
const query = dockerRun(
[
"ps",
"-a",
"--no-trunc",
"--filter",
`id=${expectedContainerId}`,
"--filter",
`label=${OPENSHELL_MANAGED_BY_LABEL}=${OPENSHELL_MANAGED_BY_VALUE}`,
"--format",
"{{.ID}}",
],
{
ignoreError: true,
suppressOutput: true,
timeout: Math.max(1, Math.min(DOCKER_GPU_PATCH_TIMEOUT_MS, Math.floor(timeoutMs))),
},
);
if (!hasZeroDockerExitStatus(query)) return false;
const containerIds = String(query.stdout ?? "")
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
return (
containerIds.length === 1 && fullDockerContainerId(containerIds[0]) === expectedContainerId
);
} catch {
return false;
}
}

function isExactRunningReplacement(
sandboxName: string,
replacementContainerId: string,
Expand All @@ -82,15 +126,44 @@ function isExactRunningReplacement(
if (!expectedContainerId || timeoutMs <= 0) return false;
try {
const deadline = Date.now() + timeoutMs;
const containers = queryOpenShellDockerSandboxContainers(sandboxName, { dockerRun }, timeoutMs);
const namespace = dockerRun(
[
"inspect",
"--type",
"container",
"--format",
`{{ index .Config.Labels "${OPENSHELL_SANDBOX_NAMESPACE_LABEL}" }}`,
expectedContainerId,
],
{
ignoreError: true,
suppressOutput: true,
timeout: Math.min(DOCKER_GPU_PATCH_TIMEOUT_MS, timeoutMs),
},
);
const sandboxNamespace = String(namespace.stdout ?? "").trim();
if (
!hasZeroDockerExitStatus(namespace) ||
!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(sandboxNamespace)
) {
return false;
}
let remainingMs = deadline - Date.now();
if (remainingMs <= 0) return false;
const containers = queryOpenShellDockerSandboxContainers(
sandboxName,
{ dockerRun },
remainingMs,
sandboxNamespace,
);
if (
!containers.ok ||
containers.ids.length !== 1 ||
fullDockerContainerId(containers.ids[0]) !== expectedContainerId
) {
return false;
}
const remainingMs = deadline - Date.now();
remainingMs = deadline - Date.now();
if (remainingMs <= 0) return false;
const inspect = dockerRun(
[
Expand Down Expand Up @@ -174,20 +247,12 @@ export function finalizeDockerGpuPatchBackup(
{
runOpenshell: deps.runOpenshell,
sleep: deps.sleep,
soleLabeledReplacementCorroboratesRetiringPhase: (remainingMs) => {
const expectedContainerId = fullDockerContainerId(options.result.newContainerId);
if (!expectedContainerId || remainingMs <= 0) return false;
const containers = queryOpenShellDockerSandboxContainers(
options.sandboxName,
{ dockerRun: resolved.dockerRun },
soleLabeledReplacementCorroboratesRetiringPhase: (remainingMs) =>
isExactOpenShellReplacement(
options.result.newContainerId,
resolved.dockerRun,
remainingMs,
);
return (
containers.ok &&
containers.ids.length === 1 &&
fullDockerContainerId(containers.ids[0]) === expectedContainerId
);
},
),
},
);
if (!lifecycleReleaseObserved) {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard/managed-workload/onboard-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ export interface PrepareOnboardSandboxWorkloadLaunchInput {
readonly policyAuthority: MaterializeSandboxCreatePlanInput["policyAuthority"];
readonly deferSandboxEffectsUntilPolicyVerification?: boolean;
readonly rebindMessagingTokenDefs: () => Promise<readonly MessagingTokenDef[]>;
readonly runProviderPreDeleteCleanup: () => void;
readonly runProviderPreDeleteCleanup: MaterializeSandboxCreatePlanInput["runProviderPreDeleteCleanup"];
readonly upsertMessagingProviders: MaterializeSandboxCreatePlanInput["upsertMessagingProviders"];
readonly getHermesToolGatewayProviderName: (sandboxName: string) => string;
readonly discloseInitialSandboxPolicy: (policy: InitialSandboxPolicy) => void;
Expand Down Expand Up @@ -379,7 +379,7 @@ export interface PreparedOnboardSandboxWorkloadLaunch {
readonly messagingProviders: string[];
readonly gpuRoutePlan: SandboxCreateIntent["gpuRoutePlan"];
readonly compatibilityPolicyPath: string | null;
readonly activateDeferredProviderEffects: (() => readonly string[]) | null;
readonly activateDeferredProviderEffects: SandboxCreatePlan["activateDeferredProviderEffects"];
readonly initialGpuRoute: SelectedDockerGpuRoute;
readonly sandboxReadyTimeoutSecs: number;
readonly buildId: string;
Expand Down
Loading
Loading