Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
7 changes: 7 additions & 0 deletions src/lib/agent/defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe("agent definitions", () => {
"wechat",
"whatsapp",
]);
expect(openclaw.inferenceProviderOptions).toEqual([]);
expect(openclaw.legacyPaths?.startScript).toContain("scripts/nemoclaw-start.sh");
});

Expand Down Expand Up @@ -95,6 +96,12 @@ describe("agent definitions", () => {
);
});

it("treats an explicit agent flag as overriding NEMOCLAW_AGENT", () => {
process.env.NEMOCLAW_AGENT = "hermes";

expect(resolveAgentName({ agentFlag: "openclaw" })).toBe("openclaw");
});

it("rejects non-object manifest payloads", () => {
const agentName = `invalid-top-level-manifest-${String(Date.now())}`;
writeTempAgentManifest(agentName, ["- not", "- an", "- object"].join("\n"));
Expand Down
98 changes: 84 additions & 14 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2443,16 +2443,6 @@
});
}

const requestedAgent = opts.agent || process.env.NEMOCLAW_AGENT || null;
const recordedAgent = session?.agent || null;
if (requestedAgent && recordedAgent && requestedAgent !== recordedAgent) {
conflicts.push({
field: "agent",
requested: requestedAgent,
recorded: recordedAgent,
});
}

return conflicts;
}

Expand Down Expand Up @@ -4485,6 +4475,53 @@
return normalized;
}

function resetStepForAgentChange(session: Session, stepName: string): void {
const stepState = session.steps[stepName];
if (!stepState) return;
stepState.status = "pending";
stepState.startedAt = null;
stepState.completedAt = null;
stepState.error = null;
}

function clearAgentScopedResumeState(session: Session, selectedAgentName: string): Session {
const normalizedAgentName = normalizeSandboxAgentName(selectedAgentName);
session.agent = normalizedAgentName === "openclaw" ? null : normalizedAgentName;
session.provider = null;
session.model = null;
session.endpointUrl = null;
session.credentialEnv = null;
session.hermesAuthMethod = null;
session.hermesToolGateways = null;
session.preferredInferenceApi = null;
session.nimContainer = null;
session.routerPid = null;
session.routerCredentialHash = null;
session.policyPresets = null;

const resetSteps = [
"provider_selection",
"inference",
"sandbox",
"openclaw",
"agent_setup",
"policies",
];
for (const stepName of resetSteps) resetStepForAgentChange(session, stepName);
if (session.lastCompletedStep && resetSteps.includes(session.lastCompletedStep)) {
session.lastCompletedStep =
session.steps.gateway?.status === "complete"
? "gateway"
: session.steps.preflight?.status === "complete"
? "preflight"
: null;
}
if (session.lastStepStarted && resetSteps.includes(session.lastStepStarted)) {
session.lastStepStarted = null;
}
return session;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

function getDefaultSandboxNameForAgent(agent: AgentDefinition | null | undefined): string {
return getRequestedSandboxAgentName(agent) === "hermes" ? "hermes" : "my-assistant";
}
Expand Down Expand Up @@ -5138,7 +5175,15 @@
}

const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName);
const decision = decidePolicyCarryForward(previousEntry?.policies, process.env, isNonInteractive());
const previousPoliciesForCarryForward = recreateForAgentDrift ? null : previousEntry?.policies;
if (recreateForAgentDrift && previousEntry?.policies && previousEntry.policies.length > 0) {
note(" Agent type changed; refreshing policy presets instead of carrying them forward.");
}
const decision = decidePolicyCarryForward(
previousPoliciesForCarryForward,
process.env,
isNonInteractive(),
);
onboardSession.updateSession((c: Session) => {
c.policyPresets = decision.newPresets;
return c;
Expand Down Expand Up @@ -9368,8 +9413,22 @@
resume,
canPrompt: !cannotPrompt,
});
const selectedAgentName = normalizeSandboxAgentName(agent?.name);
const recordedAgentName = normalizeSandboxAgentName(session?.agent);
let resumeAgentChanged = false;
let forceProviderSelectionForAgentChange = false;
if (resume && session && recordedAgentName !== selectedAgentName) {
resumeAgentChanged = true;
forceProviderSelectionForAgentChange = true;
note(
` Agent changed from ${formatSandboxAgentName(recordedAgentName)} to ${formatSandboxAgentName(selectedAgentName)}; refreshing provider selection.`,
);
session = onboardSession.updateSession((current: Session) =>
clearAgentScopedResumeState(current, selectedAgentName),
);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
setOnboardBrandingAgent(agent?.name || "openclaw");
onboardSession.updateSession((s: Session) => {
session = onboardSession.updateSession((s: Session) => {
s.agent = agent?.name ?? null;
return s;
});
Expand Down Expand Up @@ -9614,7 +9673,7 @@
let preferredInferenceApi = session?.preferredInferenceApi || null;
let nimContainer = session?.nimContainer || null;
let webSearchConfig = session?.webSearchConfig || null;
let forceProviderSelection = false;
let forceProviderSelection = forceProviderSelectionForAgentChange;
while (true) {
const resumeProviderSelection =
!forceProviderSelection &&
Expand Down Expand Up @@ -9837,6 +9896,7 @@
);
const resumeSandbox =
resume &&
!resumeAgentChanged &&
!webSearchConfigChanged &&
!telegramConfigChanged &&
!sandboxGpuConfigChanged &&
Expand All @@ -9853,7 +9913,11 @@
skippedStepMessage("sandbox", sandboxName);
} else {
if (resume && session?.steps?.sandbox?.status === "complete") {
if (webSearchConfigChanged) {
if (resumeAgentChanged) {
note(
" [resume] Agent selection changed; revalidating sandbox compatibility.",
);
} else if (webSearchConfigChanged) {
note(" [resume] Web Search configuration changed; recreating sandbox.");
if (sandboxName) {
registry.removeSandbox(sandboxName);
Expand Down Expand Up @@ -10049,6 +10113,11 @@
policyPresetSupportOptions,
customPolicyPresetNames,
);
if (!webSearchConfig && !customPolicyPresetNames.has("brave")) {
recordedPolicyPresetsForSupport = recordedPolicyPresetsForSupport.filter(
(name) => name !== "brave",
);
}
if (recordedPolicyPresets) {
recordedPolicyPresetsForSupport = mergeRequiredHermesToolGatewayPolicyPresets(
recordedPolicyPresetsForSupport,
Expand Down Expand Up @@ -10254,6 +10323,7 @@
hasStaleGateway,
getRequestedSandboxNameHint,
getResumeSandboxConflict,
clearAgentScopedResumeState,
getSandboxReuseState,
getSandboxStateFromOutputs,
getPortConflictServiceHints,
Expand Down
6 changes: 5 additions & 1 deletion src/lib/onboard/policy-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function computeSetupPresetSuggestions(
const suggestions = deps.tiers
.resolveTierPresets(tierName)
.map((preset) => preset.name)
.filter((name) => name !== "brave" || !!webSearchConfig)
.filter((name) => deps.policies.setupPolicyPresetSupported(name, supportOptions))
.filter((name) => !known || known.has(name));
const add = (name: string) => {
Expand Down Expand Up @@ -140,6 +141,8 @@ export async function setupPoliciesWithSelection(
supportOptions,
customPresetNames,
);
const isStaleBuiltinBrave = (name: string) =>
name === "brave" && !webSearchConfig && !customPresetNames.has(name);
const filterSupportedPresetNames = (presetNames: string[]) =>
presetNames.filter(
(name) =>
Expand Down Expand Up @@ -233,6 +236,7 @@ export async function setupPoliciesWithSelection(
const preserved: string[] = [];
for (const name of applied) {
if (chosenSet.has(name)) continue;
if (isStaleBuiltinBrave(name)) continue;
chosen.push(name);
chosenSet.add(name);
preserved.push(name);
Expand All @@ -254,7 +258,7 @@ export async function setupPoliciesWithSelection(

const knownNames = new Set(allPresets.map((preset) => preset.name));
const extraSelected = [
...applied.filter((name) => knownNames.has(name)),
...applied.filter((name) => knownNames.has(name) && !isStaleBuiltinBrave(name)),
...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)),
];
const resolvedPresets = await deps.selectTierPresetsAndAccess(tierName, allPresets, extraSelected);
Expand Down
14 changes: 12 additions & 2 deletions test/onboard-policy-suggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const {
enabledChannels?: string[] | null;
knownPresetNames: string[];
provider?: string | null;
webSearchConfig?: { provider?: string | null } | null;
webSearchConfig?: { fetchEnabled?: boolean; provider?: string | null } | null;
webSearchSupported?: boolean | null;
},
) => string[];
Expand Down Expand Up @@ -108,6 +108,16 @@ describe("onboard policy preset suggestions", () => {
enabledChannels: [],
knownPresetNames: known,
});
expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]);
});

it("adds Brave to balanced tier defaults only when web search is configured", () => {
const suggestions = computeSetupPresetSuggestions("balanced", {
enabledChannels: [],
knownPresetNames: known,
webSearchConfig: { fetchEnabled: true },
webSearchSupported: true,
});
expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]);
});

Expand Down Expand Up @@ -147,7 +157,7 @@ describe("onboard policy preset suggestions", () => {
});
expect(suggestions).toContain("telegram");
expect(suggestions).toContain("npm");
expect(suggestions).toContain("brave");
expect(suggestions).not.toContain("brave");

const multi = computeSetupPresetSuggestions("balanced", {
enabledChannels: ["discord", "slack"],
Expand Down
16 changes: 8 additions & 8 deletions test/onboard-preset-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,17 @@ console.log = () => {};
`expected chosen to preserve local-inference, got ${JSON.stringify(payload.chosen)}`,
);

// Nothing should be removed — every applied preset is either a tier
// default or a user-added extra that the additive policy preserves.
// User-added extras stay additive, but built-in Brave is no longer
// preserved after Brave search was declined.
assert.deepEqual(
payload.removedCalls,
[],
`expected no removals, got ${JSON.stringify(payload.removedCalls)}`,
["brave"],
`expected only stale built-in Brave to be removed, got ${JSON.stringify(payload.removedCalls)}`,
);

// Final state should still contain every previously-applied preset.
// Final state should still contain every non-Brave previously-applied preset.
const finalSorted = payload.finalApplied.slice().sort();
assert.deepEqual(finalSorted, ["brave", "brew", "huggingface", "local-inference", "npm", "pypi"]);
assert.deepEqual(finalSorted, ["brew", "huggingface", "local-inference", "npm", "pypi"]);
});

// Custom presets loaded via `policy-add --from-file` / `--from-dir` are
Expand Down Expand Up @@ -241,8 +241,8 @@ console.log = () => {};
);
assert.deepEqual(
payload.removedCalls,
[],
`expected no removals, got ${JSON.stringify(payload.removedCalls)}`,
["brave"],
`expected only stale built-in Brave to be removed, got ${JSON.stringify(payload.removedCalls)}`,
);
});

Expand Down
71 changes: 63 additions & 8 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type OnboardTestInternals = {
requestedSandboxName: string;
recordedSandboxName: string;
} | null>;
clearAgentScopedResumeState: <T extends Record<string, unknown>>(
session: T,
selectedAgentName: string,
) => T;
pullAndResolveBaseImageDigest: () => { digest: string | null; ref: string } | null;
SANDBOX_BASE_IMAGE: string;
};
Expand Down Expand Up @@ -80,6 +84,7 @@ const {
getRequestedSandboxNameHint,
getResumeConfigConflicts,
getResumeSandboxConflict,
clearAgentScopedResumeState,
SANDBOX_BASE_IMAGE,
} = onboardTestInternals;

Expand Down Expand Up @@ -383,7 +388,7 @@ startGateway(null).catch(() => {});
}
});

it("detects resume conflicts when a different agent is requested", () => {
it("does not treat a requested agent change as a hard resume conflict", () => {
expect(
getResumeConfigConflicts(
{
Expand All @@ -392,13 +397,7 @@ startGateway(null).catch(() => {});
},
{ agent: "hermes" },
),
).toEqual([
{
field: "agent",
requested: "hermes",
recorded: "openclaw",
},
]);
).toEqual([]);
});

it("allows resume when requested agent matches recorded agent", () => {
Expand All @@ -413,6 +412,62 @@ startGateway(null).catch(() => {});
).toEqual([]);
});

it("clears agent-scoped provider state when a resume switches from Hermes to OpenClaw", () => {
const completeStep = {
status: "complete",
startedAt: "2026-05-19T00:00:00.000Z",
completedAt: "2026-05-19T00:01:00.000Z",
error: null,
};
const session = {
agent: "hermes",
provider: "hermes-provider",
model: "moonshotai/kimi-k2.6",
endpointUrl: "https://inference-api.nousresearch.com/v1",
credentialEnv: "NOUS_API_KEY",
hermesAuthMethod: "oauth",
hermesToolGateways: ["nous-web"],
preferredInferenceApi: "openai-completions",
nimContainer: "nim-hermes",
routerPid: 123,
routerCredentialHash: "hash",
policyPresets: ["nous-web", "brave"],
lastCompletedStep: "policies",
lastStepStarted: "policies",
steps: {
preflight: { ...completeStep },
gateway: { ...completeStep },
provider_selection: { ...completeStep },
inference: { ...completeStep },
sandbox: { ...completeStep },
openclaw: { ...completeStep },
agent_setup: { ...completeStep },
policies: { ...completeStep },
},
};

const cleared = clearAgentScopedResumeState(session, "openclaw") as typeof session;

expect(cleared.agent).toBeNull();
expect(cleared.provider).toBeNull();
expect(cleared.model).toBeNull();
expect(cleared.endpointUrl).toBeNull();
expect(cleared.credentialEnv).toBeNull();
expect(cleared.hermesAuthMethod).toBeNull();
expect(cleared.hermesToolGateways).toBeNull();
expect(cleared.preferredInferenceApi).toBeNull();
expect(cleared.nimContainer).toBeNull();
expect(cleared.routerPid).toBeNull();
expect(cleared.routerCredentialHash).toBeNull();
expect(cleared.policyPresets).toBeNull();
expect(cleared.steps.gateway.status).toBe("complete");
expect(cleared.steps.provider_selection.status).toBe("pending");
expect(cleared.steps.sandbox.status).toBe("pending");
expect(cleared.steps.policies.status).toBe("pending");
expect(cleared.lastCompletedStep).toBe("gateway");
expect(cleared.lastStepStarted).toBeNull();
});

it("returns a future-shell PATH hint for user-local openshell installs", () => {
expect(getFutureShellPathHint("/home/test/.local/bin", "/usr/local/bin:/usr/bin")).toBe(
'export PATH="/home/test/.local/bin:$PATH"',
Expand Down
Loading
Loading