mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(agents): prevent invalid names from targeting the default agent (#124670)
* fix(agents): reject unrepresentable agent ids * refactor(system-agent): split model selection setup * chore: shrink assertion safety baseline * docs: record strict agent id validation proof * style: format strict agent id report * chore: drop stray unrelated report artifact * chore: restore REPORT.md to main state
This commit is contained in:
committed by
GitHub
parent
63401b730b
commit
aeff737da9
@@ -3858,7 +3858,6 @@ src/snapshot/manifest.ts 1
|
||||
src/state/agent-deletion-journal.ts 15
|
||||
src/state/backup-run-records.ts 1
|
||||
src/state/config-machine-state.ts 2
|
||||
src/state/control-ui-device-auth-migration.ts 1
|
||||
src/state/local-onboarding-state.ts 1
|
||||
src/state/openclaw-agent-board-schema.ts 1
|
||||
src/state/openclaw-agent-db-migration-required.ts 1
|
||||
@@ -4150,7 +4149,7 @@ ui/src/lib/cron/index.ts 16
|
||||
ui/src/lib/gateway-diagnostics.ts 3
|
||||
ui/src/lib/gateway-errors.ts 1
|
||||
ui/src/lib/keyboard-shortcuts.ts 1
|
||||
ui/src/lib/nodes/index.ts 4
|
||||
ui/src/lib/nodes/index.ts 3
|
||||
ui/src/lib/nodes/inventory.ts 1
|
||||
ui/src/lib/plugin-activation.ts 3
|
||||
ui/src/lib/session-pull-requests.ts 2
|
||||
|
||||
@@ -253,7 +253,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
|
||||
| `plugin-sdk/cron-store-runtime` | Private-local after July 2026; Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
| `plugin-sdk/plugin-state-runtime` | Private-local after July 2026; Plugin-scoped keyed-state and BLOB contracts plus connection pragma, verified WAL maintenance, and atomic STRICT-schema migration helpers. Plugin-state leases were removed; use SQLite transactions and keyed stores instead |
|
||||
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
|
||||
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId`. Use `normalizeAgentId` when omitted input should resolve to `main`; use the Result-returning `normalizeAgentIdStrict` for an explicitly supplied ID that must not fall back to the default agent. |
|
||||
| `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers |
|
||||
| `plugin-sdk/target-resolver-runtime` | Private-local after July 2026; Shared target resolver helpers |
|
||||
| `plugin-sdk/string-normalization-runtime` | Private-local after July 2026; Slug/string normalization helpers |
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id";
|
||||
import {
|
||||
isValidAgentId,
|
||||
normalizeAgentId,
|
||||
normalizeAgentIdStrict,
|
||||
} from "@openclaw/normalization-core/agent-id";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("normalization-core/agent-id", () => {
|
||||
@@ -21,4 +25,16 @@ describe("normalization-core/agent-id", () => {
|
||||
])("validates %j", (input, expected) => {
|
||||
expect(isValidAgentId(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", { ok: false, error: "unrepresentable" }],
|
||||
[" ", { ok: false, error: "unrepresentable" }],
|
||||
["агент✨", { ok: false, error: "unrepresentable" }],
|
||||
["---", { ok: false, error: "unrepresentable" }],
|
||||
["valid-id", { ok: true, value: "valid-id" }],
|
||||
["../../etc/evil", { ok: true, value: "etc-evil" }],
|
||||
["a".repeat(65), { ok: true, value: "a".repeat(64) }],
|
||||
])("strictly normalizes %j", (input, expected) => {
|
||||
expect(normalizeAgentIdStrict(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
|
||||
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
@@ -8,21 +9,25 @@ const TRAILING_DASH_RE = /-+$/;
|
||||
|
||||
/** Normalizes an OpenClaw agent id to its filesystem-safe canonical form. */
|
||||
export function normalizeAgentId(value: string | undefined | null): string {
|
||||
const result = normalizeAgentIdStrict(value);
|
||||
return result.ok ? result.value : DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
/** Normalizes an explicitly supplied agent id without falling back to the default agent. */
|
||||
export function normalizeAgentIdStrict(
|
||||
value: string | undefined | null,
|
||||
): Result<string, "unrepresentable"> {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AGENT_ID;
|
||||
}
|
||||
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (VALID_ID_RE.test(trimmed)) {
|
||||
return normalized;
|
||||
return ok(normalized);
|
||||
}
|
||||
return (
|
||||
normalized
|
||||
.replace(INVALID_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64) || DEFAULT_AGENT_ID
|
||||
);
|
||||
const agentId = normalized
|
||||
.replace(INVALID_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64);
|
||||
return agentId ? ok(agentId) : err("unrepresentable");
|
||||
}
|
||||
|
||||
/** Returns whether a value is already a canonical agent-id input. */
|
||||
|
||||
@@ -33,7 +33,7 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [
|
||||
{
|
||||
file: "packages/normalization-core/src/agent-id.ts",
|
||||
kind: "function",
|
||||
names: ["isValidAgentId", "normalizeAgentId"],
|
||||
names: ["isValidAgentId", "normalizeAgentId", "normalizeAgentIdStrict"],
|
||||
},
|
||||
{
|
||||
file: "packages/normalization-core/src/string-coerce.ts",
|
||||
|
||||
@@ -287,7 +287,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// -17: retire the messaging-targets subpath, embedded Pi aliases, and shipped
|
||||
// channel setup compatibility helpers.
|
||||
// +1: concrete plugin side-effect owner resolution for agent harness runtimes.
|
||||
4317,
|
||||
// +1: strict explicit agent-id normalization without default-agent fallback.
|
||||
4318,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -362,7 +363,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// -1: remove the orphan diagnostic traceparent propagation export.
|
||||
// -12: retire the callable messaging-targets, embedded Pi, and channel setup helpers.
|
||||
// +1: concrete plugin side-effect owner resolution for agent harness runtimes.
|
||||
2565,
|
||||
// +1: strict explicit agent-id normalization without default-agent fallback.
|
||||
2566,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -18,7 +18,7 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.j
|
||||
import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { FsSafeError, root } from "../infra/fs-safe.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
|
||||
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
|
||||
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@@ -99,11 +99,6 @@ function createError(
|
||||
return { status: "error", reason, message, ...(agentId ? { agentId } : {}) };
|
||||
}
|
||||
|
||||
/** True when raw user input contains a character that can survive agent-id normalization. */
|
||||
function hasValidRawAgentIdCharacters(value: string): boolean {
|
||||
return /[a-z0-9]/iu.test(value);
|
||||
}
|
||||
|
||||
export function validateAgentIdInput(
|
||||
rawId: string,
|
||||
options: { displayName?: string } = {},
|
||||
@@ -111,14 +106,15 @@ export function validateAgentIdInput(
|
||||
| { ok: true; agentId: string }
|
||||
| { ok: false; reason: "invalid-name" | "reserved-id"; message: string; agentId?: string } {
|
||||
const displayName = options.displayName ?? rawId;
|
||||
if (!hasValidRawAgentIdCharacters(rawId)) {
|
||||
const normalized = normalizeAgentIdStrict(rawId);
|
||||
if (!normalized.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "invalid-name",
|
||||
message: `agent name "${displayName}" has no valid id characters`,
|
||||
message: `Agent name "${displayName}" has no valid id characters. Use at least one letter a-z or digit.`,
|
||||
};
|
||||
}
|
||||
const agentId = normalizeAgentId(rawId);
|
||||
const agentId = normalized.value;
|
||||
if (isReservedSystemAgentId(agentId)) {
|
||||
return { ok: false, reason: "reserved-id", message: `"${agentId}" is reserved`, agentId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTargetAcpAgentId } from "./acp-spawn-target.js";
|
||||
|
||||
describe("resolveTargetAcpAgentId", () => {
|
||||
it.each(["", " ", "агент✨", "---"])(
|
||||
"rejects explicit unrepresentable ACP agent id %j",
|
||||
(agentId) => {
|
||||
expect(
|
||||
resolveTargetAcpAgentId({
|
||||
requestedAgentId: agentId,
|
||||
cfg: { acp: { defaultAgent: "codex" } },
|
||||
}),
|
||||
).toEqual({ ok: false, error: `agentId "${agentId}" was not found` });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps omitted ACP agent ids on the configured default path", () => {
|
||||
expect(
|
||||
resolveTargetAcpAgentId({
|
||||
cfg: { acp: { defaultAgent: "codex" } },
|
||||
}),
|
||||
).toEqual({ ok: true, agentId: "codex" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import { normalizeOptionalAgentId } from "../../../routing/session-key.js";
|
||||
import { normalizeAgentIdStrict, normalizeOptionalAgentId } from "../../../routing/session-key.js";
|
||||
import { listAgentEntries } from "../../agent-scope-config.js";
|
||||
import { listAgentIds } from "../../agent-scope.js";
|
||||
|
||||
@@ -7,7 +7,12 @@ export function resolveTargetAcpAgentId(params: {
|
||||
requestedAgentId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}): { ok: true; agentId: string; configAgentId?: string } | { ok: false; error: string } {
|
||||
const requested = normalizeOptionalAgentId(params.requestedAgentId);
|
||||
const normalizedRequest =
|
||||
params.requestedAgentId === undefined ? null : normalizeAgentIdStrict(params.requestedAgentId);
|
||||
if (normalizedRequest && !normalizedRequest.ok) {
|
||||
return { ok: false, error: `agentId "${params.requestedAgentId}" was not found` };
|
||||
}
|
||||
const requested = normalizedRequest?.value;
|
||||
if (requested) {
|
||||
const configuredAgent = listAgentEntries(params.cfg).find(
|
||||
(agent) => normalizeOptionalAgentId(agent.id) === requested,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
isSubagentSessionKey,
|
||||
normalizeAccountId,
|
||||
normalizeAgentId,
|
||||
normalizeAgentIdStrict,
|
||||
toAgentStoreSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
|
||||
@@ -500,30 +501,38 @@ export function createSessionsSendTool(opts?: {
|
||||
|
||||
const sessionKeyParam = readToolStringParam(params, "sessionKey");
|
||||
const labelParam = normalizeOptionalString(readToolStringParam(params, "label"));
|
||||
const labelAgentIdParam = normalizeOptionalString(readToolStringParam(params, "agentId"));
|
||||
const labelAgentIdInput = readToolStringParam(params, "agentId");
|
||||
const normalizedLabelAgentId =
|
||||
labelAgentIdInput === undefined ? null : normalizeAgentIdStrict(labelAgentIdInput);
|
||||
if (normalizedLabelAgentId && !normalizedLabelAgentId.ok) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "error",
|
||||
error: `Agent "${labelAgentIdInput}" not found. Run openclaw agents list to see configured agents.`,
|
||||
});
|
||||
}
|
||||
const explicitTargetAgentId = normalizedLabelAgentId?.value;
|
||||
|
||||
let sessionKey = sessionKeyParam;
|
||||
let resolvedTargetAgentId: string | undefined;
|
||||
let resolvedLabelKey: string | undefined;
|
||||
if (!sessionKey && !labelParam && labelAgentIdParam) {
|
||||
if (!sessionKey && !labelParam && explicitTargetAgentId) {
|
||||
const agentMainKey = resolveConfiguredAgentMainSessionKey({
|
||||
cfg,
|
||||
agentId: labelAgentIdParam,
|
||||
agentId: explicitTargetAgentId,
|
||||
mainKey,
|
||||
});
|
||||
if (!agentMainKey) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "error",
|
||||
error: `agent not found: ${labelAgentIdParam}`,
|
||||
error: `Agent "${labelAgentIdInput}" not found. Run openclaw agents list to see configured agents.`,
|
||||
});
|
||||
}
|
||||
sessionKey = agentMainKey;
|
||||
}
|
||||
if (!sessionKey && labelParam) {
|
||||
const requestedAgentId = labelAgentIdParam
|
||||
? normalizeAgentId(labelAgentIdParam)
|
||||
: undefined;
|
||||
const requestedAgentId = explicitTargetAgentId;
|
||||
|
||||
if (restrictToSpawned && requestedAgentId && requestedAgentId !== requesterAgentId) {
|
||||
return jsonResult({
|
||||
@@ -700,7 +709,7 @@ export function createSessionsSendTool(opts?: {
|
||||
const resolvedTargetOwner =
|
||||
visibleSession.agentId ??
|
||||
resolvedTargetAgentId ??
|
||||
(labelParam && labelAgentIdParam ? normalizeAgentId(labelAgentIdParam) : undefined);
|
||||
(labelParam ? explicitTargetAgentId : undefined);
|
||||
if (
|
||||
persistedTargetOwner.kind === "configured" &&
|
||||
resolvedTargetOwner &&
|
||||
|
||||
@@ -1036,6 +1036,22 @@ describe("sessions_send gating", () => {
|
||||
expect(requireGatewayRequest().method).toBe("sessions.resolve");
|
||||
});
|
||||
|
||||
it("rejects an unrepresentable agent id before resolving a main session", async () => {
|
||||
const tool = createMainSessionsSendTool();
|
||||
|
||||
const result = await tool.execute("call-invalid-agent", {
|
||||
agentId: "агент✨",
|
||||
message: "hello",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
|
||||
expect(requireDetails(result)).toMatchObject({
|
||||
status: "error",
|
||||
error: 'Agent "агент✨" not found. Run openclaw agents list to see configured agents.',
|
||||
});
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("conceals missing explicit keys denied by session visibility", async () => {
|
||||
callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing"));
|
||||
const tool = createSessionsSendTool({
|
||||
|
||||
@@ -91,7 +91,10 @@ vi.mock("../config/config.js", async () => ({
|
||||
replaceConfigFile: replaceConfigFileMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-create.js", () => ({
|
||||
vi.mock("../agents/agent-create.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../agents/agent-create.js")>(
|
||||
"../agents/agent-create.js",
|
||||
)),
|
||||
checkAgentCreationGate: checkAgentCreationGateMock,
|
||||
createAgent: createAgentMock,
|
||||
}));
|
||||
@@ -249,6 +252,33 @@ describe("agents add command", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unrepresentable positional name before targeting an existing agent", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: { agents: { entries: { main: {} } } },
|
||||
sourceConfig: { agents: { entries: { main: {} } } },
|
||||
});
|
||||
const prompter = {
|
||||
intro: vi.fn(),
|
||||
text: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
note: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
};
|
||||
wizardMocks.createClackPrompter.mockReturnValue(prompter);
|
||||
|
||||
await agentsAddCommand({ name: "агент✨" }, runtime);
|
||||
|
||||
expect(prompter.outro).toHaveBeenCalledWith(
|
||||
'Agent name "агент✨" has no valid id characters. Use at least one letter a-z or digit.',
|
||||
);
|
||||
expect(prompter.confirm).not.toHaveBeenCalled();
|
||||
expect(prompter.note).not.toHaveBeenCalled();
|
||||
expect(checkAgentCreationGateMock).not.toHaveBeenCalled();
|
||||
expect(createAgentMock).not.toHaveBeenCalled();
|
||||
expect(writeConfigFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(RESERVED_SYSTEM_AGENT_IDS_FOR_TEST)(
|
||||
"rejects reserved system-agent id %s from an interactive positional argument",
|
||||
async (name) => {
|
||||
|
||||
@@ -203,6 +203,24 @@ describe("agents bind/unbind commands", () => {
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["агент✨", " "])(
|
||||
"rejects an explicit unrepresentable agent %j instead of binding the default",
|
||||
async (agent) => {
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: {},
|
||||
});
|
||||
|
||||
await agentsBindCommand({ agent, bind: ["telegram"] }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
`Agent "${agent}" not found. Run openclaw agents list to see configured agents.`,
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(writeConfigFileMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses a wildcard account binding for multi-account channels", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { checkAgentCreationGate, createAgent } from "../agents/agent-create.js";
|
||||
import {
|
||||
checkAgentCreationGate,
|
||||
createAgent,
|
||||
validateAgentIdInput,
|
||||
} from "../agents/agent-create.js";
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
@@ -37,7 +41,6 @@ import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
@@ -123,7 +126,17 @@ export async function agentsAddCommand(
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const agentId = normalizeAgentId(nameInput);
|
||||
const validation = validateAgentIdInput(nameInput);
|
||||
if (!validation.ok) {
|
||||
runtime.error(
|
||||
validation.reason === "reserved-id"
|
||||
? `"${validation.agentId}" is reserved. Choose another name, or run ${formatCliCommand("openclaw agents list")} to inspect configured agents.`
|
||||
: validation.message,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const agentId = validation.agentId;
|
||||
if (agentId !== nameInput) {
|
||||
runtime.log(`Normalized agent id to "${agentId}".`);
|
||||
}
|
||||
@@ -205,20 +218,27 @@ export async function agentsAddCommand(
|
||||
if (!value?.trim()) {
|
||||
return "Required";
|
||||
}
|
||||
const normalized = normalizeAgentId(value);
|
||||
if (isReservedSystemAgentId(normalized)) {
|
||||
return `"${normalized}" is reserved. Choose another name.`;
|
||||
const validation = validateAgentIdInput(value);
|
||||
if (!validation.ok) {
|
||||
return validation.reason === "reserved-id"
|
||||
? `"${validation.agentId}" is reserved. Choose another name.`
|
||||
: validation.message;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
const agentName = normalizeOptionalString(name) ?? "";
|
||||
const agentId = normalizeAgentId(agentName);
|
||||
if (isReservedSystemAgentId(agentId)) {
|
||||
await prompter.outro(`"${agentId}" is reserved. Choose another name.`);
|
||||
const validation = validateAgentIdInput(agentName);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === "reserved-id") {
|
||||
await prompter.outro(`"${validation.agentId}" is reserved. Choose another name.`);
|
||||
return;
|
||||
}
|
||||
await prompter.outro(validation.message);
|
||||
return;
|
||||
}
|
||||
const agentId = validation.agentId;
|
||||
if (agentName !== agentId) {
|
||||
await prompter.note(`Normalized id to "${agentId}".`, "Agent id");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { AgentRouteBinding } from "../config/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
@@ -41,23 +41,6 @@ function loadAgentBindingsModule(): Promise<AgentBindingsModule> {
|
||||
return agentBindingsModuleLoader.load();
|
||||
}
|
||||
|
||||
function resolveAgentId(
|
||||
cfg: Awaited<ReturnType<typeof requireValidConfig>>,
|
||||
agentInput: string | undefined,
|
||||
params?: { fallbackToDefault?: boolean },
|
||||
): string | null {
|
||||
if (!cfg) {
|
||||
return null;
|
||||
}
|
||||
if (agentInput?.trim()) {
|
||||
return normalizeAgentId(agentInput);
|
||||
}
|
||||
if (params?.fallbackToDefault) {
|
||||
return resolveDefaultAgentId(cfg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAgent(cfg: Awaited<ReturnType<typeof requireValidConfig>>, agentId: string): boolean {
|
||||
if (!cfg) {
|
||||
return false;
|
||||
@@ -75,20 +58,20 @@ function formatBindingOwnerLine(binding: AgentRouteBinding): string {
|
||||
}
|
||||
|
||||
function resolveTargetAgentIdOrExit(params: {
|
||||
cfg: Awaited<ReturnType<typeof requireValidConfig>>;
|
||||
cfg: NonNullable<Awaited<ReturnType<typeof requireValidConfig>>>;
|
||||
runtime: RuntimeEnv;
|
||||
agentInput: string | undefined;
|
||||
}): string | null {
|
||||
const agentId = resolveAgentId(params.cfg, params.agentInput?.trim(), {
|
||||
fallbackToDefault: true,
|
||||
});
|
||||
if (!agentId) {
|
||||
const normalized =
|
||||
params.agentInput === undefined ? null : normalizeAgentIdStrict(params.agentInput);
|
||||
if (normalized && !normalized.ok) {
|
||||
params.runtime.error(
|
||||
`Unable to resolve agent id. Run ${formatCliCommand("openclaw agents list")} to choose one.`,
|
||||
`Agent "${params.agentInput}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`,
|
||||
);
|
||||
params.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const agentId = normalized?.value ?? resolveDefaultAgentId(params.cfg);
|
||||
if (!hasAgent(params.cfg, agentId)) {
|
||||
params.runtime.error(
|
||||
`Agent "${agentId}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`,
|
||||
@@ -184,14 +167,15 @@ export async function agentsBindingsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const filterAgentId = resolveAgentId(cfg, opts.agent?.trim());
|
||||
if (opts.agent && !filterAgentId) {
|
||||
const normalizedFilter = opts.agent === undefined ? null : normalizeAgentIdStrict(opts.agent);
|
||||
if (normalizedFilter && !normalizedFilter.ok) {
|
||||
runtime.error(
|
||||
`Agent id is required. Run ${formatCliCommand("openclaw agents list")} to choose one.`,
|
||||
`Agent "${opts.agent}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const filterAgentId = normalizedFilter?.value;
|
||||
if (filterAgentId && !hasAgent(cfg, filterAgentId)) {
|
||||
runtime.error(
|
||||
`Agent "${filterAgentId}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
isGatewayCredentialsRequiredError,
|
||||
isGatewayTransportError,
|
||||
} from "../gateway/call.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
|
||||
@@ -65,6 +65,16 @@ type AgentsDeleteGatewayResult = {
|
||||
failed?: Array<{ path: string; reason: string }>;
|
||||
};
|
||||
|
||||
function failAgentsDelete(opts: AgentsDeleteOptions, runtime: RuntimeEnv, message: string): void {
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { error: message });
|
||||
runtime.exit(1, { resetStream: process.stderr });
|
||||
} else {
|
||||
runtime.error(message);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function logClearedOwnerRefs(runtime: RuntimeEnv, clearedOwnerRefs: readonly string[]): void {
|
||||
if (clearedOwnerRefs.length > 0) {
|
||||
runtime.log(`Cleared owner references: ${clearedOwnerRefs.join(", ")}`);
|
||||
@@ -108,24 +118,35 @@ export async function agentsDeleteCommand(
|
||||
|
||||
const input = opts.id?.trim();
|
||||
if (!input) {
|
||||
runtime.error(
|
||||
failAgentsDelete(
|
||||
opts,
|
||||
runtime,
|
||||
`Agent id is required. Run ${formatCliCommand("openclaw agents list")} to choose one.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = normalizeAgentId(input);
|
||||
if (agentId !== input) {
|
||||
const normalized = normalizeAgentIdStrict(input);
|
||||
if (!normalized.ok) {
|
||||
failAgentsDelete(
|
||||
opts,
|
||||
runtime,
|
||||
`Agent "${input}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const agentId = normalized.value;
|
||||
if (!opts.json && agentId !== input) {
|
||||
runtime.log(`Normalized agent id to "${agentId}".`);
|
||||
}
|
||||
const configured = findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0;
|
||||
let existingJournal = configured ? undefined : readAgentDeletionJournal(agentId);
|
||||
if (!configured && (!existingJournal || existingJournal.cleanupCompleted)) {
|
||||
runtime.error(
|
||||
failAgentsDelete(
|
||||
opts,
|
||||
runtime,
|
||||
`Agent "${agentId}" not found. Run ${formatCliCommand("openclaw agents list")} to see configured agents.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const configuredAgentDir = configured ? resolveAgentDir(cfg, agentId) : undefined;
|
||||
@@ -141,14 +162,16 @@ export async function agentsDeleteCommand(
|
||||
sharedAuthDbPath: resolveSharedAuthStorePath(),
|
||||
})
|
||||
) {
|
||||
runtime.error(formatSharedAuthStoreOwnerDeleteError(agentId));
|
||||
runtime.exit(1);
|
||||
failAgentsDelete(opts, runtime, formatSharedAuthStoreOwnerDeleteError(agentId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (configured && agentId === tryResolveSoleAgentId(cfg)) {
|
||||
runtime.error(`Agent "${agentId}" is the only configured agent and cannot be deleted.`);
|
||||
runtime.exit(1);
|
||||
failAgentsDelete(
|
||||
opts,
|
||||
runtime,
|
||||
`Agent "${agentId}" is the only configured agent and cannot be deleted.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const explicitInheritedAuthAgentId = cfg.agents?.defaults?.authInheritance?.agentId?.trim();
|
||||
@@ -156,10 +179,11 @@ export async function agentsDeleteCommand(
|
||||
explicitInheritedAuthAgentId ||
|
||||
(sharedAuthOwnership.location === "legacy-main" ? resolveLegacyInheritedAuthAgentId(cfg) : "");
|
||||
if (inheritedAuthAgentId && agentId === normalizeAgentId(inheritedAuthAgentId)) {
|
||||
runtime.error(
|
||||
failAgentsDelete(
|
||||
opts,
|
||||
runtime,
|
||||
`Agent "${agentId}" owns inherited credentials through agents.defaults.authInheritance.agentId and cannot be deleted. Relocate those credentials, then re-point or remove that binding before retrying.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { AgentConfig, IdentityConfig } from "../config/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
@@ -71,7 +71,6 @@ export async function agentsSetIdentityCommand(
|
||||
).config as OpenClawConfig;
|
||||
const baseHash = configSnapshot.hash;
|
||||
|
||||
const agentRaw = normalizeOptionalString(opts.agent);
|
||||
const nameRaw = normalizeOptionalString(opts.name);
|
||||
const emojiRaw = normalizeOptionalString(opts.emoji);
|
||||
const themeRaw = normalizeOptionalString(opts.theme);
|
||||
@@ -81,7 +80,13 @@ export async function agentsSetIdentityCommand(
|
||||
const identityFileRaw = normalizeOptionalString(opts.identityFile);
|
||||
const workspaceRaw = normalizeOptionalString(opts.workspace);
|
||||
const wantsIdentityFile = Boolean(opts.fromIdentity || identityFileRaw || !hasExplicitIdentity);
|
||||
let agentId = agentRaw ? normalizeAgentId(agentRaw) : undefined;
|
||||
const normalizedAgent = opts.agent === undefined ? null : normalizeAgentIdStrict(opts.agent);
|
||||
if (normalizedAgent && !normalizedAgent.ok) {
|
||||
runtime.error(`Agent "${opts.agent}" not found. Create it with \`openclaw agents add\`.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
let agentId = normalizedAgent?.value;
|
||||
|
||||
let identityFilePath: string | undefined;
|
||||
let workspaceDir: string | undefined;
|
||||
|
||||
@@ -237,10 +237,14 @@ describe("agents delete command", () => {
|
||||
|
||||
expect(gatewayMocks.callGateway).not.toHaveBeenCalled();
|
||||
expect(configMocks.replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Agent "main" owns the legacy shared auth store and cannot be deleted. Run openclaw doctor --fix to migrate shared auth, then retry.',
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(readJsonLogs()).toEqual([
|
||||
{
|
||||
error:
|
||||
'Agent "main" owns the legacy shared auth store and cannot be deleted. Run openclaw doctor --fix to migrate shared auth, then retry.',
|
||||
},
|
||||
]);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1, { resetStream: process.stderr });
|
||||
expectSessionStore(cfg, sessions, "main");
|
||||
});
|
||||
});
|
||||
@@ -274,6 +278,36 @@ describe("agents delete command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unrepresentable id before targeting or deleting an agent", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-invalid-id-", async ({ stateDir }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
|
||||
{ id: "second", default: true, workspace: path.join(stateDir, "workspace-second") },
|
||||
],
|
||||
},
|
||||
};
|
||||
const sessions = {
|
||||
"agent:main:main": { sessionId: "sess-main", updatedAt: Date.now() },
|
||||
};
|
||||
writeConfigMachineState("auth.sharedStore", { location: "state-db" });
|
||||
await arrangeAgentsDeleteTest({ stateDir, cfg, deletedAgentId: "main", sessions });
|
||||
|
||||
await agentsDeleteCommand({ id: "агент✨", force: true }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Agent "агент✨" not found. Run openclaw agents list to see configured agents.',
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(gatewayMocks.callGateway).not.toHaveBeenCalled();
|
||||
expect(configMocks.replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(fsSafeMocks.movePathToTrash).not.toHaveBeenCalled();
|
||||
expect(workspaceStateMocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
expectSessionStore(cfg, sessions, "main");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses deleting the auth-inheritance owner until credentials are relocated", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-auth-owner-", async ({ stateDir }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
@@ -609,10 +643,11 @@ describe("agents delete command", () => {
|
||||
|
||||
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Agent "ops" is the only configured agent and cannot be deleted.',
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(readJsonLogs()).toEqual([
|
||||
{ error: 'Agent "ops" is the only configured agent and cannot be deleted.' },
|
||||
]);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1, { resetStream: process.stderr });
|
||||
expectSessionStore(cfg, {
|
||||
"agent:main:main": { sessionId: "sess-default-alias", updatedAt: now + 1 },
|
||||
"agent:ops:quietchat:direct:u1": { sessionId: "sess-ops-direct", updatedAt: now + 2 },
|
||||
|
||||
@@ -261,20 +261,23 @@ describe("agents set-identity command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("errors without changing config when --agent names an unknown agent", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: { agents: { entries: { main: {} } } },
|
||||
});
|
||||
it.each(["ghostzzz", "агент✨", " "])(
|
||||
"errors without changing config when --agent names %j",
|
||||
async (agent) => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: { agents: { entries: { main: {} } } },
|
||||
});
|
||||
|
||||
await agentsSetIdentityCommand({ agent: "ghostzzz", name: "Ghost" }, runtime);
|
||||
await agentsSetIdentityCommand({ agent, name: "Ghost" }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Agent "ghostzzz" not found. Create it with `openclaw agents add`.',
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(configMocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
`Agent "${agent}" not found. Create it with \`openclaw agents add\`.`,
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(configMocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["main", "openclaw", "crestodian"])(
|
||||
"does not create absent reserved agent %s",
|
||||
|
||||
@@ -3023,6 +3023,23 @@ describe("agents.delete", () => {
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unrepresentable id before targeting the main agent", async () => {
|
||||
mocks.sharedAuthStoreOwnership = { location: "state-db" };
|
||||
mocks.loadConfigReturn = {
|
||||
agents: { list: [{ id: "main" }, { id: "ops", default: true }] },
|
||||
};
|
||||
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "агент✨",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectRespondErrorContaining(respond, 'agent "агент✨" not found');
|
||||
expect(mocks.beginAgentDeletionCommit).not.toHaveBeenCalled();
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(mocks.movePathToTrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes main through the normal journal path after shared auth relocation", async () => {
|
||||
mocks.sharedAuthStoreOwnership = { location: "state-db" };
|
||||
mocks.loadConfigReturn = {
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("agents.workspace RPC handlers", () => {
|
||||
const error = expectError(
|
||||
await invokeWorkspaceHandler("agents.workspace.list", { agentId: "ghost" }),
|
||||
);
|
||||
expect(error.message).toContain("unknown agent id");
|
||||
expect(error.message).toContain('agent "ghost" not found');
|
||||
});
|
||||
|
||||
it("rejects traversal outside the workspace for list and get", async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeAgentIdStrict } from "../../routing/session-key.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
import {
|
||||
@@ -70,11 +70,16 @@ function resolveWorkspaceScopeOrRespond(
|
||||
cfg: OpenClawConfig,
|
||||
respond: RespondFn,
|
||||
): { agentId: string; workspaceDir: string; browserPath: string } | null {
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
if (!new Set(listAgentIds(cfg)).has(agentId)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
|
||||
const normalized = normalizeAgentIdStrict(params.agentId);
|
||||
if (!normalized.ok || !new Set(listAgentIds(cfg)).has(normalized.value)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${params.agentId}" not found`),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const agentId = normalized.value;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const rawPath = params.path ?? "";
|
||||
const portablePath = rawPath.replaceAll("\\", "/");
|
||||
|
||||
@@ -88,7 +88,7 @@ import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { resolveSqliteDatabaseFilePaths } from "../../infra/sqlite-files.js";
|
||||
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeAgentId, normalizeAgentIdStrict } from "../../routing/session-key.js";
|
||||
import {
|
||||
readAgentDeletionJournal,
|
||||
type AgentDeletionJournalCleanupPath,
|
||||
@@ -170,7 +170,7 @@ function resolveAgentWorkspaceFileOrRespondError(
|
||||
cfg,
|
||||
);
|
||||
if (!agentId) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
|
||||
respondAgentNotFound(respond, String(rawAgentId));
|
||||
return null;
|
||||
}
|
||||
const rawName = params.name;
|
||||
@@ -299,7 +299,11 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
|
||||
}
|
||||
|
||||
function resolveAgentIdOrError(agentIdRaw: string, cfg: OpenClawConfig) {
|
||||
const agentId = normalizeAgentId(agentIdRaw);
|
||||
const normalized = normalizeAgentIdStrict(agentIdRaw);
|
||||
if (!normalized.ok) {
|
||||
return null;
|
||||
}
|
||||
const agentId = normalized.value;
|
||||
const allowed = new Set(listAgentIds(cfg));
|
||||
if (!allowed.has(agentId)) {
|
||||
return null;
|
||||
@@ -935,7 +939,12 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const normalized = normalizeAgentIdStrict(params.agentId);
|
||||
if (!normalized.ok) {
|
||||
respondAgentNotFound(respond, params.agentId);
|
||||
return;
|
||||
}
|
||||
const agentId = normalized.value;
|
||||
if (!isConfiguredAgent(cfg, agentId)) {
|
||||
respondAgentNotFound(respond, agentId);
|
||||
return;
|
||||
@@ -1029,7 +1038,12 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const normalized = normalizeAgentIdStrict(params.agentId);
|
||||
if (!normalized.ok) {
|
||||
respondAgentNotFound(respond, params.agentId);
|
||||
return;
|
||||
}
|
||||
const agentId = normalized.value;
|
||||
if (agentOwnsSharedAuthStore(cfg, agentId)) {
|
||||
respond(
|
||||
false,
|
||||
@@ -1522,7 +1536,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const agentId = resolveAgentIdOrError(params.agentId, cfg);
|
||||
if (!agentId) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
|
||||
respondAgentNotFound(respond, params.agentId);
|
||||
return;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js";
|
||||
import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeAgentIdStrict } from "../../routing/session-key.js";
|
||||
|
||||
type ModelAuthAgentScopeResult =
|
||||
| { ok: true; agentId: string; agentDir: string }
|
||||
@@ -53,13 +53,11 @@ export function resolveModelAuthAgentScope(
|
||||
if (!rawAgentId) {
|
||||
return { ok: false, agentId: requestedAgentId };
|
||||
}
|
||||
const agentId = normalizeAgentId(rawAgentId);
|
||||
// normalizeAgentId falls back to "main" when sanitization erases the entire
|
||||
// input; explicit garbage must not inherit the default agent's credentials.
|
||||
const collapsedToFallback = !/[A-Za-z0-9_]/u.test(rawAgentId);
|
||||
if (collapsedToFallback || !listAgentIds(cfg).includes(agentId)) {
|
||||
const normalized = normalizeAgentIdStrict(rawAgentId);
|
||||
if (!normalized.ok || !listAgentIds(cfg).includes(normalized.value)) {
|
||||
return { ok: false, agentId: rawAgentId };
|
||||
}
|
||||
const agentId = normalized.value;
|
||||
return { ok: true, agentId, agentDir: resolveAgentDir(cfg, agentId) };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { isConfiguredSessionStoreAgentId } from "../../config/sessions.js";
|
||||
import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeAgentIdStrict } from "../../routing/session-key.js";
|
||||
import { resolveRequestedSessionAgentId } from "../session-request-agent.js";
|
||||
import {
|
||||
resolveSessionStoreAgentId,
|
||||
@@ -15,7 +15,15 @@ import {
|
||||
} from "../session-store-key.js";
|
||||
|
||||
export function resolveSessionSearchScope(cfg: OpenClawConfig, params: SessionsSearchParams) {
|
||||
const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const normalizedRequest =
|
||||
params.agentId === undefined ? null : normalizeAgentIdStrict(params.agentId);
|
||||
if (normalizedRequest && !normalizedRequest.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${params.agentId}"`),
|
||||
};
|
||||
}
|
||||
const requestedAgentId = normalizedRequest?.value;
|
||||
const resolvedSessionKeys:
|
||||
| Array<{ sessionKey: string; agentId: string | undefined }>
|
||||
| undefined = params.sessionKeys ? [] : undefined;
|
||||
|
||||
@@ -75,6 +75,23 @@ describe("requested session agent ownership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", " ", "агент✨", "---"])(
|
||||
"rejects explicit unrepresentable agent id %j instead of selecting main",
|
||||
(agentId) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { ownership: "explicit", entries: { main: {}, ops: {} } },
|
||||
};
|
||||
|
||||
expect(resolveRequestedSessionAgentId(cfg, "global", agentId)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: `Unknown agent id "${agentId}"`,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps retired agent-qualified history readable outside global scope", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { ownership: "explicit", entries: { ops: {}, research: {} } },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
@@ -10,6 +9,7 @@ import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/sess
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
normalizeAgentId,
|
||||
normalizeAgentIdStrict,
|
||||
normalizeMainKey,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
@@ -39,11 +39,16 @@ export function resolveRequestedSessionAgentId(
|
||||
options?: { allowUnconfiguredExplicitAgent?: boolean },
|
||||
): RequestedSessionAgentIdResolution {
|
||||
const parsed = parseAgentSessionKey(key.trim());
|
||||
const requestedAgentId = normalizeOptionalString(explicitAgentId);
|
||||
const configuredAgentIds = listAgentIds(cfg);
|
||||
const normalizedRequestedAgentId = requestedAgentId
|
||||
? normalizeAgentId(requestedAgentId)
|
||||
: undefined;
|
||||
const normalizedRequest =
|
||||
explicitAgentId === undefined ? null : normalizeAgentIdStrict(explicitAgentId);
|
||||
if (normalizedRequest && !normalizedRequest.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`),
|
||||
};
|
||||
}
|
||||
const normalizedRequestedAgentId = normalizedRequest?.value;
|
||||
if (
|
||||
normalizedRequestedAgentId &&
|
||||
!options?.allowUnconfiguredExplicitAgent &&
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
isSubagentSessionKey,
|
||||
normalizeAccountId,
|
||||
normalizeAgentId,
|
||||
normalizeAgentIdStrict,
|
||||
normalizeMainKey,
|
||||
normalizeOptionalAccountId,
|
||||
parseAgentSessionKey,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id";
|
||||
import {
|
||||
isValidAgentId,
|
||||
normalizeAgentId,
|
||||
normalizeAgentIdStrict,
|
||||
} from "@openclaw/normalization-core/agent-id";
|
||||
// Routing session key helpers build stable session keys from route targets.
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -29,7 +33,7 @@ export {
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "./account-id.js";
|
||||
export { isValidAgentId, normalizeAgentId };
|
||||
export { isValidAgentId, normalizeAgentId, normalizeAgentIdStrict };
|
||||
|
||||
/** Legacy on-disk identity used only by doctor/migration and their fixtures. */
|
||||
export const LEGACY_IMPLICIT_AGENT_ID = "main";
|
||||
|
||||
@@ -542,7 +542,7 @@ export async function executeSetDefaultModel(
|
||||
run: async (ctx) => {
|
||||
const { mutateConfigFile, readConfigFileSnapshot } = await loadConfigModule();
|
||||
const { applySystemAgentModelSelection, createSystemAgentModelSelectionUpdater } =
|
||||
await import("./setup-apply.js");
|
||||
await import("./setup-model-selection.js");
|
||||
const targetAgentId = operation.agentId;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
// Route projection and the live probes below all take the same optional
|
||||
|
||||
@@ -33,6 +33,13 @@ describe("parseSystemAgentOperation", () => {
|
||||
kind: "set-default-model",
|
||||
model: "openai/gpt-5.2",
|
||||
});
|
||||
expect(parseSystemAgentOperation("set default model openai/gpt-5.2 for agent агент✨")).toEqual(
|
||||
{
|
||||
kind: "set-default-model",
|
||||
model: "openai/gpt-5.2",
|
||||
agentId: "агент✨",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("parses interactive model provider setup", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseConfigSetPath } from "../cli/config-cli-path.js";
|
||||
import type { ConfigSetOptions } from "../cli/config-set-input.js";
|
||||
import type { DoctorOptions } from "../commands/doctor.types.js";
|
||||
import { DEFAULT_SECRET_PROVIDER_ALIAS } from "../config/types.secrets.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentIdStrict } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { isValidSecretRef } from "../secrets/ref-contract.js";
|
||||
import type { TuiResult } from "../tui/tui-types.js";
|
||||
@@ -139,6 +139,12 @@ const OPEN_GATEWAY_SETUP_RE = /^open\s+gateway\s+wizard$/i;
|
||||
const NO_MATCH_MESSAGE =
|
||||
"I can run doctor/status/health, check or restart Gateway, configure gateway settings, list agents/models, configure skills or web search, import memory, set default model, connect channels (`connect telegram`), show `channel info <channel>`, open the setup wizard, show audit, or switch to your agent TUI.";
|
||||
|
||||
function normalizeExplicitSystemAgentId(agentId: string): string {
|
||||
const normalized = normalizeAgentIdStrict(agentId);
|
||||
// Preserve an unrepresentable input so the execution owner rejects it instead of targeting main.
|
||||
return normalized.ok ? normalized.value : agentId;
|
||||
}
|
||||
|
||||
function parseConfigSetCommand(
|
||||
input: string,
|
||||
): { path: string; value: string; valid: true } | { valid: false } | undefined {
|
||||
@@ -443,7 +449,7 @@ export function parseSystemAgentOperation(input: string): SystemAgentOperation {
|
||||
const model = createMatch.groups.model;
|
||||
return {
|
||||
kind: "create-agent",
|
||||
agentId: normalizeAgentId(createMatch.groups.agent),
|
||||
agentId: normalizeExplicitSystemAgentId(createMatch.groups.agent),
|
||||
...(workspace ? { workspace } : {}),
|
||||
...(model ? { model } : {}),
|
||||
};
|
||||
@@ -463,7 +469,7 @@ export function parseSystemAgentOperation(input: string): SystemAgentOperation {
|
||||
return {
|
||||
kind: "set-default-model",
|
||||
model: setModelMatch.groups.model,
|
||||
...(agent ? { agentId: normalizeAgentId(agent) } : {}),
|
||||
...(agent ? { agentId: normalizeExplicitSystemAgentId(agent) } : {}),
|
||||
};
|
||||
}
|
||||
return { kind: "none", message: NO_MATCH_MESSAGE };
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { applySystemAgentModelSelection } from "./setup-apply.js";
|
||||
import { applySystemAgentModelSelection } from "./setup-model-selection.js";
|
||||
|
||||
describe("applySystemAgentModelSelection", () => {
|
||||
it("rejects an unrepresentable explicit agent instead of updating main", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
entries: { main: { default: true }, ops: {} },
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
applySystemAgentModelSelection({
|
||||
config,
|
||||
model: "openai/gpt-5.5",
|
||||
targetAgentId: "агент✨",
|
||||
}),
|
||||
).rejects.toThrow('Could not resolve configured agent "агент✨".');
|
||||
expect(config.agents.entries.main).toEqual({ default: true });
|
||||
});
|
||||
|
||||
it("clears stale harness pins in both model scopes for a native route", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "../config/config.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { formatExternalSupervisorActionRequired } from "../infra/gateway-supervision.js";
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
sameSetupConfiguredRoute,
|
||||
sameSetupInferenceRoute,
|
||||
} from "./setup-inference-route-guard.js";
|
||||
import { applySystemAgentModelSelection } from "./setup-model-selection.js";
|
||||
|
||||
/**
|
||||
* The whole first-run setup as one approved operation: the user says "yes" in
|
||||
@@ -139,139 +139,6 @@ function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig {
|
||||
};
|
||||
}
|
||||
|
||||
type SystemAgentModelSelectionParams = {
|
||||
config: OpenClawConfig;
|
||||
model: string;
|
||||
/** Write the model onto this configured agent instead of the default route. */
|
||||
targetAgentId?: string;
|
||||
agentRuntimeId?: string;
|
||||
/** Pin the selected model to the exact credential that passed inference. */
|
||||
authProfileId?: string;
|
||||
};
|
||||
|
||||
type SystemAgentModelSelectionModules = {
|
||||
agentScope: typeof import("../agents/agent-scope.js");
|
||||
modelConfig: typeof import("../commands/models/shared.js");
|
||||
runtimePolicy: typeof import("../agents/model-runtime-policy.js");
|
||||
};
|
||||
|
||||
function applySystemAgentModelSelectionWithModules(
|
||||
params: SystemAgentModelSelectionParams,
|
||||
modules: SystemAgentModelSelectionModules,
|
||||
): OpenClawConfig {
|
||||
const { agentScope, modelConfig, runtimePolicy } = modules;
|
||||
const nextConfig = structuredClone(params.config);
|
||||
const targetAgentId = params.targetAgentId ? normalizeAgentId(params.targetAgentId) : undefined;
|
||||
const agentId = targetAgentId ?? agentScope.resolveDefaultAgentId(nextConfig);
|
||||
const roster = agentScope.listAgentEntries(nextConfig);
|
||||
if (targetAgentId && !roster.some((entry) => normalizeAgentId(entry.id) === targetAgentId)) {
|
||||
throw new Error(`Could not resolve configured agent "${targetAgentId}".`);
|
||||
}
|
||||
// A targeted selection always lands on the agent entry; the default-route
|
||||
// selection only writes the agent when it already carries an explicit model.
|
||||
const writesAgent = Boolean(
|
||||
targetAgentId || agentScope.resolveAgentExplicitModelPrimary(nextConfig, agentId),
|
||||
);
|
||||
nextConfig.agents ??= {};
|
||||
nextConfig.agents.defaults ??= {};
|
||||
const agentDefaults = nextConfig.agents.defaults;
|
||||
const target = modelConfig.resolveModelTarget({ raw: params.model, cfg: nextConfig });
|
||||
const key = modelConfig.upsertCanonicalModelConfigEntry({}, target);
|
||||
|
||||
const configuredVisibleModels = agentDefaults.models;
|
||||
if (configuredVisibleModels && Object.keys(configuredVisibleModels).length > 0) {
|
||||
// An authored global visibility map is restrictive. Extend it for the
|
||||
// approved selection; never create one merely to carry runtime metadata.
|
||||
const defaultModels = { ...configuredVisibleModels };
|
||||
modelConfig.upsertCanonicalModelConfigEntry(defaultModels, target);
|
||||
agentDefaults.models = defaultModels;
|
||||
}
|
||||
|
||||
const agentEntries = toAgentEntriesRecord(roster);
|
||||
if (writesAgent || params.agentRuntimeId) {
|
||||
const { list: _legacyList, ...agentConfig } = nextConfig.agents;
|
||||
nextConfig.agents = { ...agentConfig, entries: agentEntries };
|
||||
}
|
||||
const agentEntryKey =
|
||||
roster.find((entry) => normalizeAgentId(entry.id) === agentId)?.id ?? agentId;
|
||||
let agent = agentEntries[agentEntryKey];
|
||||
if (writesAgent) {
|
||||
if (!agent) {
|
||||
throw new Error(`Could not resolve configured default agent "${agentId}".`);
|
||||
}
|
||||
const agentModels = { ...agent.models };
|
||||
agent.models = agentModels;
|
||||
modelConfig.upsertCanonicalModelConfigEntry(agentModels, target);
|
||||
}
|
||||
|
||||
if (params.agentRuntimeId) {
|
||||
if (!agent) {
|
||||
agent = { default: true };
|
||||
agentEntries[agentEntryKey] = agent;
|
||||
}
|
||||
const agentModels = { ...agent.models };
|
||||
const agentKey = modelConfig.upsertCanonicalModelConfigEntry(agentModels, target);
|
||||
agentModels[agentKey] = {
|
||||
...agentModels[agentKey],
|
||||
agentRuntime: { id: params.agentRuntimeId },
|
||||
};
|
||||
agent.models = agentModels;
|
||||
} else {
|
||||
const clearRuntimePin = (
|
||||
models: Record<string, AgentModelEntryConfig>,
|
||||
): Record<string, AgentModelEntryConfig> => {
|
||||
const nextModels = { ...models };
|
||||
const modelKey = modelConfig.upsertCanonicalModelConfigEntry(nextModels, target);
|
||||
const entry = { ...nextModels[modelKey] };
|
||||
delete entry.agentRuntime;
|
||||
nextModels[modelKey] = entry;
|
||||
return nextModels;
|
||||
};
|
||||
const defaultModels = agentDefaults.models;
|
||||
if (defaultModels && Object.keys(defaultModels).length > 0) {
|
||||
agentDefaults.models = clearRuntimePin(defaultModels);
|
||||
}
|
||||
if (agent?.models && Object.keys(agent.models).length > 0) {
|
||||
agent.models = clearRuntimePin(agent.models);
|
||||
}
|
||||
}
|
||||
const selectedModel = params.authProfileId ? `${key}@${params.authProfileId}` : key;
|
||||
agentScope.setAgentEffectiveModelPrimary(nextConfig, agentId, selectedModel, {
|
||||
forceAgent: Boolean(targetAgentId),
|
||||
});
|
||||
if (params.agentRuntimeId) {
|
||||
const effectiveRuntime = runtimePolicy.resolveModelRuntimePolicy({
|
||||
config: nextConfig,
|
||||
provider: target.provider,
|
||||
modelId: target.model,
|
||||
agentId,
|
||||
}).policy?.id;
|
||||
if (effectiveRuntime !== params.agentRuntimeId) {
|
||||
throw new Error(`Could not pin ${key} to the ${params.agentRuntimeId} runtime.`);
|
||||
}
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export async function createSystemAgentModelSelectionUpdater(
|
||||
params: Omit<SystemAgentModelSelectionParams, "config">,
|
||||
): Promise<(config: OpenClawConfig) => OpenClawConfig> {
|
||||
const [agentScope, modelConfig, runtimePolicy] = await Promise.all([
|
||||
import("../agents/agent-scope.js"),
|
||||
import("../commands/models/shared.js"),
|
||||
import("../agents/model-runtime-policy.js"),
|
||||
]);
|
||||
const modules = { agentScope, modelConfig, runtimePolicy };
|
||||
return (config) => applySystemAgentModelSelectionWithModules({ ...params, config }, modules);
|
||||
}
|
||||
|
||||
export async function applySystemAgentModelSelection(
|
||||
params: SystemAgentModelSelectionParams,
|
||||
): Promise<OpenClawConfig> {
|
||||
const update = await createSystemAgentModelSelectionUpdater(params);
|
||||
return update(params.config);
|
||||
}
|
||||
|
||||
export async function applySystemAgentSetup(
|
||||
params: SystemAgentSetupApplyParams,
|
||||
hooks?: SystemAgentSetupApplyHooks,
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
SystemAgentConfiguredRoute,
|
||||
SystemAgentConfiguredRouteDeps,
|
||||
} from "./inference-route.js";
|
||||
import { createSystemAgentModelSelectionUpdater } from "./setup-apply.js";
|
||||
import {
|
||||
SetupInferenceActivationIndeterminateError,
|
||||
SetupInferenceActivationUnavailableError,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
resolveSetupAgentRuntimeId,
|
||||
type SetupInferenceTestPlan,
|
||||
} from "./setup-inference-plan-helpers.js";
|
||||
import { createSystemAgentModelSelectionUpdater } from "./setup-model-selection.js";
|
||||
import type { SystemAgentOwnerPluginArtifactSnapshot } from "./verified-inference.js";
|
||||
|
||||
type ProjectedInferenceRoute = Awaited<ReturnType<typeof projectInferenceRoute>>;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resolveSystemAgentConfiguredRouteFromConfig,
|
||||
sameDefaultInferenceRoute,
|
||||
} from "./inference-route.js";
|
||||
import { applySystemAgentModelSelection, createQuickstartNotePrompter } from "./setup-apply.js";
|
||||
import { createQuickstartNotePrompter } from "./setup-apply.js";
|
||||
import {
|
||||
persistActivatedSetupInference,
|
||||
type SetupInferenceActivationPersistenceState,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
resolveSetupAgentRuntimeId,
|
||||
} from "./setup-inference-plan-helpers.js";
|
||||
import { buildTestPlan } from "./setup-inference-plan.js";
|
||||
import { applySystemAgentModelSelection } from "./setup-model-selection.js";
|
||||
import {
|
||||
captureSystemAgentOwnerPluginArtifacts,
|
||||
type SystemAgentOwnerPluginArtifactSnapshot,
|
||||
|
||||
@@ -44,7 +44,6 @@ import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { cleanupSystemAgentSession, createSystemAgentSession } from "./agent-turn.js";
|
||||
import { runSystemAgentTurnWithDeps } from "./agent-turn.test-support.js";
|
||||
import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js";
|
||||
import { applySystemAgentModelSelection } from "./setup-apply.js";
|
||||
import { setupInferenceLog } from "./setup-inference-core.js";
|
||||
import { runSetupInferenceTest } from "./setup-inference-persist.js";
|
||||
import { resolveSetupInferenceProbeStreamParams } from "./setup-inference-probe.js";
|
||||
@@ -61,6 +60,7 @@ import {
|
||||
verifySetupInference as verifySetupInferenceImpl,
|
||||
verifySetupInferenceConfig as verifySetupInferenceConfigImpl,
|
||||
} from "./setup-inference.js";
|
||||
import { applySystemAgentModelSelection } from "./setup-model-selection.js";
|
||||
import {
|
||||
installSystemAgentPluginMetadataTestSnapshot,
|
||||
type SystemAgentPluginMetadataTestSnapshot,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { toAgentEntriesRecord } from "../agents/agent-scope-config.js";
|
||||
import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
|
||||
|
||||
type SystemAgentModelSelectionParams = {
|
||||
config: OpenClawConfig;
|
||||
model: string;
|
||||
/** Write the model onto this configured agent instead of the default route. */
|
||||
targetAgentId?: string;
|
||||
agentRuntimeId?: string;
|
||||
/** Pin the selected model to the exact credential that passed inference. */
|
||||
authProfileId?: string;
|
||||
};
|
||||
|
||||
type SystemAgentModelSelectionModules = {
|
||||
agentScope: typeof import("../agents/agent-scope.js");
|
||||
modelConfig: typeof import("../commands/models/shared.js");
|
||||
runtimePolicy: typeof import("../agents/model-runtime-policy.js");
|
||||
};
|
||||
|
||||
function applySystemAgentModelSelectionWithModules(
|
||||
params: SystemAgentModelSelectionParams,
|
||||
modules: SystemAgentModelSelectionModules,
|
||||
): OpenClawConfig {
|
||||
const { agentScope, modelConfig, runtimePolicy } = modules;
|
||||
const nextConfig = structuredClone(params.config);
|
||||
const normalizedTarget =
|
||||
params.targetAgentId === undefined ? null : normalizeAgentIdStrict(params.targetAgentId);
|
||||
if (normalizedTarget && !normalizedTarget.ok) {
|
||||
throw new Error(`Could not resolve configured agent "${params.targetAgentId}".`);
|
||||
}
|
||||
const targetAgentId = normalizedTarget?.value;
|
||||
const agentId = targetAgentId ?? agentScope.resolveDefaultAgentId(nextConfig);
|
||||
const roster = agentScope.listAgentEntries(nextConfig);
|
||||
if (targetAgentId && !roster.some((entry) => normalizeAgentId(entry.id) === targetAgentId)) {
|
||||
throw new Error(`Could not resolve configured agent "${targetAgentId}".`);
|
||||
}
|
||||
// A targeted selection always lands on the agent entry; the default-route
|
||||
// selection only writes the agent when it already carries an explicit model.
|
||||
const writesAgent = Boolean(
|
||||
targetAgentId || agentScope.resolveAgentExplicitModelPrimary(nextConfig, agentId),
|
||||
);
|
||||
nextConfig.agents ??= {};
|
||||
nextConfig.agents.defaults ??= {};
|
||||
const agentDefaults = nextConfig.agents.defaults;
|
||||
const target = modelConfig.resolveModelTarget({ raw: params.model, cfg: nextConfig });
|
||||
const key = modelConfig.upsertCanonicalModelConfigEntry({}, target);
|
||||
|
||||
const configuredVisibleModels = agentDefaults.models;
|
||||
if (configuredVisibleModels && Object.keys(configuredVisibleModels).length > 0) {
|
||||
// An authored global visibility map is restrictive. Extend it for the
|
||||
// approved selection; never create one merely to carry runtime metadata.
|
||||
const defaultModels = { ...configuredVisibleModels };
|
||||
modelConfig.upsertCanonicalModelConfigEntry(defaultModels, target);
|
||||
agentDefaults.models = defaultModels;
|
||||
}
|
||||
|
||||
const agentEntries = toAgentEntriesRecord(roster);
|
||||
if (writesAgent || params.agentRuntimeId) {
|
||||
const { list: _legacyList, ...agentConfig } = nextConfig.agents;
|
||||
nextConfig.agents = { ...agentConfig, entries: agentEntries };
|
||||
}
|
||||
const agentEntryKey =
|
||||
roster.find((entry) => normalizeAgentId(entry.id) === agentId)?.id ?? agentId;
|
||||
let agent = agentEntries[agentEntryKey];
|
||||
if (writesAgent) {
|
||||
if (!agent) {
|
||||
throw new Error(`Could not resolve configured default agent "${agentId}".`);
|
||||
}
|
||||
const agentModels = { ...agent.models };
|
||||
agent.models = agentModels;
|
||||
modelConfig.upsertCanonicalModelConfigEntry(agentModels, target);
|
||||
}
|
||||
|
||||
if (params.agentRuntimeId) {
|
||||
if (!agent) {
|
||||
agent = { default: true };
|
||||
agentEntries[agentEntryKey] = agent;
|
||||
}
|
||||
const agentModels = { ...agent.models };
|
||||
const agentKey = modelConfig.upsertCanonicalModelConfigEntry(agentModels, target);
|
||||
agentModels[agentKey] = {
|
||||
...agentModels[agentKey],
|
||||
agentRuntime: { id: params.agentRuntimeId },
|
||||
};
|
||||
agent.models = agentModels;
|
||||
} else {
|
||||
const clearRuntimePin = (
|
||||
models: Record<string, AgentModelEntryConfig>,
|
||||
): Record<string, AgentModelEntryConfig> => {
|
||||
const nextModels = { ...models };
|
||||
const modelKey = modelConfig.upsertCanonicalModelConfigEntry(nextModels, target);
|
||||
const entry = { ...nextModels[modelKey] };
|
||||
delete entry.agentRuntime;
|
||||
nextModels[modelKey] = entry;
|
||||
return nextModels;
|
||||
};
|
||||
const defaultModels = agentDefaults.models;
|
||||
if (defaultModels && Object.keys(defaultModels).length > 0) {
|
||||
agentDefaults.models = clearRuntimePin(defaultModels);
|
||||
}
|
||||
if (agent?.models && Object.keys(agent.models).length > 0) {
|
||||
agent.models = clearRuntimePin(agent.models);
|
||||
}
|
||||
}
|
||||
const selectedModel = params.authProfileId ? `${key}@${params.authProfileId}` : key;
|
||||
agentScope.setAgentEffectiveModelPrimary(nextConfig, agentId, selectedModel, {
|
||||
forceAgent: Boolean(targetAgentId),
|
||||
});
|
||||
if (params.agentRuntimeId) {
|
||||
const effectiveRuntime = runtimePolicy.resolveModelRuntimePolicy({
|
||||
config: nextConfig,
|
||||
provider: target.provider,
|
||||
modelId: target.model,
|
||||
agentId,
|
||||
}).policy?.id;
|
||||
if (effectiveRuntime !== params.agentRuntimeId) {
|
||||
throw new Error(`Could not pin ${key} to the ${params.agentRuntimeId} runtime.`);
|
||||
}
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export async function createSystemAgentModelSelectionUpdater(
|
||||
params: Omit<SystemAgentModelSelectionParams, "config">,
|
||||
): Promise<(config: OpenClawConfig) => OpenClawConfig> {
|
||||
const [agentScope, modelConfig, runtimePolicy] = await Promise.all([
|
||||
import("../agents/agent-scope.js"),
|
||||
import("../commands/models/shared.js"),
|
||||
import("../agents/model-runtime-policy.js"),
|
||||
]);
|
||||
const modules = { agentScope, modelConfig, runtimePolicy };
|
||||
return (config) => applySystemAgentModelSelectionWithModules({ ...params, config }, modules);
|
||||
}
|
||||
|
||||
export async function applySystemAgentModelSelection(
|
||||
params: SystemAgentModelSelectionParams,
|
||||
): Promise<OpenClawConfig> {
|
||||
const update = await createSystemAgentModelSelectionUpdater(params);
|
||||
return update(params.config);
|
||||
}
|
||||
Reference in New Issue
Block a user