diff --git a/src/lib/onboard/child-exit-tracker.test.ts b/src/lib/onboard/child-exit-tracker.test.ts index 08ea8e961db..ba9fce48271 100644 --- a/src/lib/onboard/child-exit-tracker.test.ts +++ b/src/lib/onboard/child-exit-tracker.test.ts @@ -72,7 +72,6 @@ describe("trackChildExit (#3111)", () => { queueMicrotask(() => cb(null, null)); } }, - // biome-ignore lint/suspicious/noExplicitAny: narrowed mock shape } as any; const state = trackChildExit(fake); await waitFor(() => state.exited); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index a5ad383ae1d..b34b1be45ef 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -4725,6 +4725,77 @@ function startFreshShieldsDownTimer(input: { } } +function completeInterruptedShieldsDown( + sandboxName: string, + opts: ShieldsDownOpts, + state: LoadedShieldsState, + retainedProviderTarget: AgentConfigTarget | null, +): boolean { + if (!state.shieldsDown) return false; + + // Provider release deliberately precedes route convergence and the final + // timer-bound transition commit. A process can therefore die after the + // durable provider claim is gone while the host transition remains in + // preparing. Treat that marker as recovery authority too: verify (or + // repair) mutable posture, converge the route, then commit it active. + const completionTarget = + retainedProviderTarget ?? + resolveReleasedProviderShieldsDownTarget( + sandboxName, + state, + opts.allowLegacyHermesProtocol === true, + ); + if (!completionTarget) return false; + + const completion = prepareRecoveredShieldsDownCompletion(sandboxName, completionTarget, state); + // The provisional DOWN record can outlive a process that lost its + // provider-unlock response. Recovery first restores the retained plan's + // restrictive rollback. Reconcile the recorded mutable posture and verify + // it before treating this retry as complete. + try { + applyRecoveredShieldsDownForwardPolicy(sandboxName, completion); + if (retainedProviderTarget) { + runHermesProviderProtectionTransition( + sandboxName, + retainedProviderTarget, + "locked", + "locked", + ); + } + if (completion.authority) { + assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); + } + unlockAgentConfigUnderMutationLock( + sandboxName, + completionTarget, + false, + "provider-state-mutation-v2", + ); + if (completion.authority) { + assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); + } + finishRecoveredHermesShieldsDown(sandboxName, completion); + } catch (error) { + return failRecoveredHermesShieldsDown( + sandboxName, + completionTarget, + state, + completion, + opts.allowLegacyHermesProtocol === true, + error, + opts.throwOnError, + ); + } + if (!completion.alreadyCommitted) { + if (completion.authority) { + assertRecoveredShieldsDownAuthority(sandboxName, completion, "active"); + } + appendAuditEntry(completion.audit); + } + console.log(` Recovered interrupted config unlock for ${sandboxName}.`); + return true; +} + function persistIncompleteShieldsDownPosture( sandboxName: string, transition: ShieldsDownTransition, @@ -4776,73 +4847,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = recoveredProviderTarget = retainedProviderTarget; } const initialMode = deriveShieldsMode(state, state._hasStateFile); - if (state.shieldsDown) { - // Provider release deliberately precedes route convergence and the final - // timer-bound transition commit. A process can therefore die after the - // durable provider claim is gone while the exact host transition remains - // in preparing. Treat that marker as recovery authority too: verify (or - // repair) mutable posture, converge the route, then commit it active. - const completionTarget = - retainedProviderTarget ?? - resolveReleasedProviderShieldsDownTarget( - sandboxName, - state, - opts.allowLegacyHermesProtocol === true, - ); - if (completionTarget) { - const completion = prepareRecoveredShieldsDownCompletion( - sandboxName, - completionTarget, - state, - ); - // The provisional DOWN record can outlive a process that lost its - // provider-unlock response. Recovery first restores the retained plan's - // restrictive rollback. Reconcile the recorded mutable posture and - // verify it before treating this retry as complete. - try { - applyRecoveredShieldsDownForwardPolicy(sandboxName, completion); - if (retainedProviderTarget) { - runHermesProviderProtectionTransition( - sandboxName, - retainedProviderTarget, - "locked", - "locked", - ); - } - if (completion.authority) { - assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); - } - unlockAgentConfigUnderMutationLock( - sandboxName, - completionTarget, - false, - "provider-state-mutation-v2", - ); - if (completion.authority) { - assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); - } - finishRecoveredHermesShieldsDown(sandboxName, completion); - } catch (error) { - return failRecoveredHermesShieldsDown( - sandboxName, - completionTarget, - state, - completion, - opts.allowLegacyHermesProtocol === true, - error, - opts.throwOnError, - ); - } - if (!completion.alreadyCommitted) { - if (completion.authority) { - assertRecoveredShieldsDownAuthority(sandboxName, completion, "active"); - } - appendAuditEntry(completion.audit); - } - console.log(` Recovered interrupted config unlock for ${sandboxName}.`); - return; - } - } + if (completeInterruptedShieldsDown(sandboxName, opts, state, retainedProviderTarget)) return; const timeoutSeconds = parseDuration(opts.timeout || `${DEFAULT_TIMEOUT_SECONDS}`); const reason = opts.reason || null; diff --git a/test/build-base-image-platform-action.test.ts b/test/build-base-image-platform-action.test.ts new file mode 100644 index 00000000000..8201536a370 --- /dev/null +++ b/test/build-base-image-platform-action.test.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +type Step = { + env?: Record; + if?: string; + name?: string; + run?: string; +}; + +const repoRoot = path.resolve(import.meta.dirname, ".."); + +function required(value: T | undefined, message: string): T { + return ( + value ?? + (() => { + throw new Error(message); + })() + ); +} + +describe("base-image platform action", () => { + it("executes dos2unix from each Deep Agents Code platform image before manifest publication (#8870)", () => { + const action = YAML.parse( + fs.readFileSync( + path.join(repoRoot, ".github", "actions", "build-base-image-platform", "action.yaml"), + "utf8", + ), + ) as { runs?: { steps?: Step[] } }; + const steps = action.runs?.steps ?? []; + const validate = required( + steps.find((candidate) => candidate.name === "Validate Deep Agents Code dos2unix executable"), + "base-image platform action is missing the Deep Agents Code dos2unix validation", + ); + const buildIndex = steps.findIndex( + (candidate) => candidate.name === "Build and push platform digest", + ); + const validateIndex = steps.indexOf(validate); + const exportIndex = steps.findIndex((candidate) => candidate.name === "Export platform digest"); + + expect(validate.if).toBe("${{ inputs.agent == 'langchain-deepagents-code' }}"); + expect(validate.env).toMatchObject({ + DIGEST: "${{ steps.build.outputs.digest }}", + IMAGE: "${{ inputs.registry }}/${{ inputs.image }}", + PLATFORM: "${{ inputs.platform }}", + }); + expect(validate.run).toContain('reference="${IMAGE}@${DIGEST}"'); + expect(validate.run).toContain('docker run --rm --platform "$PLATFORM"'); + expect(validate.run).toContain("--network none"); + expect(validate.run).toContain("--cap-drop ALL"); + expect(validate.run).toContain("--security-opt no-new-privileges"); + expect(validate.run).toContain("--read-only"); + expect(validate.run).toContain("--user 999:999"); + expect(validate.run).toContain("test -x /usr/bin/dos2unix"); + expect(validate.run).toContain("dos2unix --version"); + expect(validateIndex).toBeGreaterThan(buildIndex); + expect(validateIndex).toBeLessThan(exportIndex); + }); +});