refactor(core): deslop core runtime states (#165288)

* refactor(core): deslop core runtime states

Consume typed and validated owner facts directly, strengthen internal state
unions, and remove redundant normalization and forwarding paths. Preserve
external-input validation and live authority at their existing boundaries.

Retire the pre-July JSON dedupe migration APIs and run chat metadata tests
through the normal publication lifecycle instead of an alternate refresh path.

Fix CLI settlement swallowing falsy thrown values after cleanup. Preserve
error formatting and primary-failure precedence.

* test(core): stabilize cleanup fixtures and tighten ratchets

Join accepted reset-handler work before fixture teardown and control the unrelated RPC response clock. Preserve authority and durable-state assertions. Remove redundant fixture setup and unused overrides, and shrink assertion budgets to the measured production counts.

* fix(core): keep native listener descriptors type-safe

Retain native listener-array copying, preserve command-error precedence, and resolve scoped lint findings without changing assertions or boundary validation.

* fix(core): preserve runtime and fixture contracts during cleanup

* test(gateway): preserve prepared model read ownership

* fix(core): reconcile imports and SDK budgets after rebase
This commit is contained in:
Peter Steinberger 2026-10-05 04:42:41 -07:00 • committed by GitHub
parent 0be92a681d
commit bd64d93ff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 1100 additions and 2266 deletions

View file

@ -1274,7 +1274,6 @@ src/agents/cli-runner/bundle-mcp-gemini.ts 2
src/agents/cli-runner/bundle-mcp-runtime.ts 1
src/agents/cli-runner/bundle-mcp.ts 5
src/agents/cli-runner/cli-backend-auth-policy.ts 1
src/agents/cli-runner/cli-run-settlement.ts 1
src/agents/cli-runner/cli-run-transcript.ts 1
src/agents/cli-runner/delivery-evidence.ts 2
src/agents/cli-runner/execute-node-claude.ts 5
@ -2263,7 +2262,6 @@ src/infra/oauth-loopback-callback.ts 1
src/infra/openclaw-cli-shim.ts 1
src/infra/openclaw-root.ts 1
src/infra/outbound/agent-delivery.ts 2
src/infra/outbound/conversation-delivery.ts 1
src/infra/outbound/deliver-channel.ts 3
src/infra/outbound/deliver-payload.ts 1
src/infra/outbound/deliver-prepare.ts 1
@ -2458,7 +2456,7 @@ src/plugin-sdk/provider-auth-runtime.ts 1
src/plugin-sdk/provider-catalog-shared.ts 1
src/plugin-sdk/provider-entry.ts 3
src/plugin-sdk/provider-onboard.ts 3
src/plugin-sdk/provider-stream-shared.ts 10
src/plugin-sdk/provider-stream-shared.ts 9
src/plugin-sdk/provider-stream.ts 1
src/plugin-sdk/provider-tools.ts 4
src/plugin-sdk/qa-runtime.ts 5
@ -2482,7 +2480,7 @@ src/plugins/bundle-mcp.ts 2
src/plugins/bundled-channel-config-metadata.ts 4
src/plugins/bundled-plugin-scan.ts 1
src/plugins/candidate-install-owner.ts 1
src/plugins/capability-provider-runtime.ts 9
src/plugins/capability-provider-runtime.ts 8
src/plugins/captured-registration.ts 1
src/plugins/channel-presence-policy.ts 2
src/plugins/channel-validation.ts 1

View file

@ -197,7 +197,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +1: owner-approved async coding-tool construction with retained sync compatibility.
// +4: executor controller, binding, context, and resolver.
// +1: required session cleanup failure preserves native ownership before host reset.
3655,
3651,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@ -210,7 +210,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +1: owner-approved async agent-end preparation with retained sync compatibility.
// +1: owner-approved async coding-tool construction with retained sync compatibility.
// +1: resolve the controller from the current invocation registry.
2116,
2114,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

View file

@ -6,8 +6,6 @@ import { extractKeywords, isQueryStopWordToken } from "../../memory-host-sdk/que
import type { CompactionSummarizationInstructions } from "../compaction.js";
import { wrapUntrustedPromptDataBlock } from "../sanitize-for-prompt.js";
// Compaction summary quality helpers. They define the structured summary contract
// and audit whether summaries preserve pending asks plus exact identifiers.
const MAX_EXTRACTED_IDENTIFIERS = 12;
const MAX_UNTRUSTED_INSTRUCTION_CHARS = 4000;
const MAX_ASK_OVERLAP_TOKENS = 12;
@ -229,30 +227,24 @@ export function createSummaryQualityRetentionPlan(
return content ? `${heading}\n${content}` : heading;
});
const joinSectionContent = (index: number, optional: string) => {
const tail = protectedTails[index] ?? "";
const tail = protectedTails[index];
if (!tail) {
return optional;
}
if (index === PENDING_ASK_SECTION_INDEX) {
const leading = normalizedSummaryLines(optional)[0] ?? "";
if (leading === tail) {
return optional;
}
if (latestUnresolvedUserRequest) {
return [tail, isEmptyPendingAsk(leading) ? "" : optional].filter(Boolean).join("\n");
}
}
if (index === EXACT_IDENTIFIERS_SECTION_INDEX) {
const missing = auditedIdentifiers.filter(
(identifier) => !summaryIncludesIdentifier(optional, identifier),
);
return [optional, ...missing].filter(Boolean).join("\n");
}
const retainedOptional =
index === PENDING_ASK_SECTION_INDEX && protectedAskContext && isEmptyPendingAsk(optional)
? ""
: optional;
return [retainedOptional, tail].filter(Boolean).join("\n");
// Only pending asks and exact identifiers have protected tails.
const leading = normalizedSummaryLines(optional)[0] ?? "";
if (leading === tail) {
return optional;
}
return latestUnresolvedUserRequest
? [tail, isEmptyPendingAsk(leading) ? "" : optional].filter(Boolean).join("\n")
: [isEmptyPendingAsk(optional) ? "" : optional, tail].filter(Boolean).join("\n");
};
// Reserve every heading/content/tail separator up front so trimmed optional
// text can never push the rendered artifact past `maxChars`.
@ -431,10 +423,7 @@ function resolveAskOverlapRequirement(latestAsk: string | null): {
if (!latestAsk) {
return null;
}
const askTokens = uniqueStrings(tokenizeAskOverlapText(latestAsk)).slice(
0,
MAX_ASK_OVERLAP_TOKENS,
);
const askTokens = tokenizeAskOverlapText(latestAsk).slice(0, MAX_ASK_OVERLAP_TOKENS);
if (askTokens.length === 0) {
return null;
}

View file

@ -2041,17 +2041,13 @@ describe("compaction-safeguard double-compaction guard", () => {
tokensBefore: 1000,
},
});
const { result: result1 } = await runCompactionScenario(sessionManager, mockEvent, {
apiKey: "sk-test",
});
const { result: result1 } = await runCompactionScenario(sessionManager, mockEvent);
const compaction1 = expectCompactionResult(result1);
expect(compaction1.summary).toContain("## Decisions");
expect(compaction1.summary).toContain("No prior history.");
mockEvent.preparation.previousSummary = "## Decisions\nUsed approach A.";
const { result: result2 } = await runCompactionScenario(sessionManager, mockEvent, {
apiKey: "sk-test",
});
const { result: result2 } = await runCompactionScenario(sessionManager, mockEvent);
const compaction2 = expectCompactionResult(result2);
expect(compaction2.summary).toContain("## Decisions");
expect(compaction2.summary).toContain("Used approach A.");

View file

@ -69,7 +69,6 @@ import {
const log = createSubsystemLogger("compaction-safeguard");
// Track session managers that have already logged the missing-model warning to avoid log spam.
const missedModelWarningSessions = new WeakSet<object>();
const SPLIT_TURN_SECTION_HEADING = "**Turn Context (split turn):**";
const MAX_TOOL_FAILURES = 8;
@ -277,11 +276,7 @@ async function resolveModelAuth(
> {
let requestAuth: Awaited<ReturnType<ExtensionContext["modelRegistry"]["getApiKeyAndHeaders"]>>;
try {
const modelRegistry = ctx.modelRegistry;
if (typeof modelRegistry.getApiKeyAndHeaders !== "function") {
throw new Error("model registry auth lookup unavailable");
}
requestAuth = await modelRegistry.getApiKeyAndHeaders(model);
requestAuth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
} catch (err) {
const error = formatErrorMessage(err);
log.warn(
@ -566,11 +561,7 @@ function splitPreservedRecentTurns(params: {
messages: AgentMessage[];
recentTurnsPreserve: number;
}): { summarizableMessages: AgentMessage[]; preservedMessages: AgentMessage[] } {
const preserveTurns = clampNonNegativeInt(
params.recentTurnsPreserve,
0,
MAX_RECENT_TURNS_PRESERVE,
);
const preserveTurns = params.recentTurnsPreserve;
if (preserveTurns <= 0) {
return { summarizableMessages: params.messages, preservedMessages: [] };
}
@ -891,18 +882,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
return { cancel: true as const };
};
if (!hasRealConversation) {
// When there are no summarizable messages AND no real turn-prefix content,
// cancelling compaction leaves context unchanged but the SDK re-triggers
// _checkCompaction after every assistant response — creating a cancel loop
// that blocks cron lanes (#41981).
//
// Strategy: always return a minimal compaction result so the SDK writes a
// boundary entry. The SDK's prepareCompaction() returns undefined when the
// last entry is a compaction, which blocks immediate re-triggering within
// the same turn. After a new assistant message arrives, if the SDK triggers
// compaction again with an empty preparation, we write another boundary —
// this is bounded to at most one boundary per LLM round-trip, not a tight
// loop.
// Canceling an empty preparation re-triggers compaction on the next response (#41981).
// A compaction boundary suppresses immediate retries until another assistant turn.
log.info(
"Compaction safeguard: no real conversation messages to summarize; writing compaction boundary to suppress re-trigger loop.",
);

View file

@ -17,17 +17,17 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: !configuredEnabled, reserveTokens: 20_000 },
});
const setCompactionEnabled = vi.spyOn(settingsManager, "setCompactionEnabled");
const cfg = {
agents: { defaults: { compaction: { enabled: configuredEnabled } } },
};
const first = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg });
applyAgentCompactionSettingsFromConfig({ settingsManager, cfg });
await settingsManager.reload();
expect(settingsManager.getCompactionEnabled()).toBe(configuredEnabled);
const afterReload = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg });
applyAgentCompactionSettingsFromConfig({ settingsManager, cfg });
expect(first.didOverride).toBe(true);
expect(afterReload.didOverride).toBe(false);
expect(setCompactionEnabled).toHaveBeenCalledExactlyOnceWith(configuredEnabled);
expect(settingsManager.getCompactionEnabled()).toBe(configuredEnabled);
},
);
@ -61,9 +61,7 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
settingsManager,
cfg: { agents: { defaults: { compaction: { enabled: true } } } },
});
const result = applyAgentAutoCompactionGuard({ settingsManager, ...guardParams });
expect(result).toEqual({ supported: true, disabled: true });
applyAgentAutoCompactionGuard({ settingsManager, ...guardParams });
expect(settingsManager.getCompactionEnabled()).toBe(false);
},
);
@ -74,12 +72,11 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
});
const setCompactionEnabled = vi.spyOn(settingsManager, "setCompactionEnabled");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
cfg: { agents: { defaults: { compaction: {} } } },
});
expect(result.didOverride).toBe(false);
expect(settingsManager.getCompactionEnabled()).toBe(false);
expect(setCompactionEnabled).not.toHaveBeenCalled();
});
@ -88,68 +85,57 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
const settingsManager = SettingsManager.inMemory();
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({ settingsManager });
applyAgentCompactionSettingsFromConfig({ settingsManager });
expect(result.didOverride).toBe(true);
expect(result.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR);
expect(settingsManager.getCompactionReserveTokens()).toBe(
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
);
expect(applyOverrides).toHaveBeenCalledWith({
compaction: { reserveTokens: DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR },
});
});
it("can restore reserveTokens after a simulated resource loader reload drops them below floor", () => {
let reserve = 16_384;
const keep = 20_000;
const settingsManager = {
getCompactionReserveTokens: () => reserve,
getCompactionKeepRecentTokens: () => keep,
applyOverrides: vi.fn((overrides: { compaction: { reserveTokens?: number } }) => {
if (overrides.compaction.reserveTokens !== undefined) {
reserve = overrides.compaction.reserveTokens;
}
}),
};
const settingsManager = SettingsManager.inMemory({
compaction: { reserveTokens: 16_384 },
});
const first = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
contextTokenBudget: 100_000,
});
expect(first.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR);
expect(settingsManager.getCompactionReserveTokens()).toBe(
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
);
reserve = 16_384;
const second = applyAgentCompactionSettingsFromConfig({
settingsManager.applyOverrides({ compaction: { reserveTokens: 16_384 } });
applyAgentCompactionSettingsFromConfig({
settingsManager,
contextTokenBudget: 100_000,
});
expect(second.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR);
expect(reserve).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR);
expect(settingsManager.getCompactionReserveTokens()).toBe(
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
);
});
it("does not override when already above floor and not in safeguard mode", () => {
const settingsManager = {
getCompactionReserveTokens: () => 32_000,
getCompactionKeepRecentTokens: () => 20_000,
applyOverrides: vi.fn(),
};
const settingsManager = SettingsManager.inMemory({ compaction: { reserveTokens: 32_000 } });
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
cfg: { agents: { defaults: { compaction: { mode: "default" } } } },
});
expect(result.didOverride).toBe(false);
expect(result.compaction.reserveTokens).toBe(32_000);
expect(settingsManager.applyOverrides).not.toHaveBeenCalled();
expect(settingsManager.getCompactionReserveTokens()).toBe(32_000);
expect(applyOverrides).not.toHaveBeenCalled();
});
it("applies keepRecentTokens when explicitly configured", () => {
const settingsManager = {
getCompactionReserveTokens: () => 20_000,
getCompactionKeepRecentTokens: () => 20_000,
applyOverrides: vi.fn(),
};
const settingsManager = SettingsManager.inMemory({ compaction: { reserveTokens: 20_000 } });
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
cfg: {
agents: {
@ -162,42 +148,36 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
},
});
expect(result.compaction.keepRecentTokens).toBe(15_000);
expect(settingsManager.applyOverrides).toHaveBeenCalledWith({
expect(settingsManager.getCompactionKeepRecentTokens()).toBe(15_000);
expect(applyOverrides).toHaveBeenCalledWith({
compaction: { keepRecentTokens: 15_000 },
});
});
it("preserves current keepRecentTokens when safeguard mode leaves it unset", () => {
const settingsManager = {
getCompactionReserveTokens: () => 25_000,
getCompactionKeepRecentTokens: () => 20_000,
applyOverrides: vi.fn(),
};
const settingsManager = SettingsManager.inMemory({ compaction: { reserveTokens: 25_000 } });
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
cfg: { agents: { defaults: { compaction: { mode: "safeguard" } } } },
});
expect(result.compaction.keepRecentTokens).toBe(20_000);
expect(settingsManager.applyOverrides).not.toHaveBeenCalled();
expect(settingsManager.getCompactionKeepRecentTokens()).toBe(20_000);
expect(applyOverrides).not.toHaveBeenCalled();
});
it("treats keepRecentTokens=0 as invalid and keeps the current setting", () => {
const settingsManager = {
getCompactionReserveTokens: () => 25_000,
getCompactionKeepRecentTokens: () => 20_000,
applyOverrides: vi.fn(),
};
const settingsManager = SettingsManager.inMemory({ compaction: { reserveTokens: 25_000 } });
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
cfg: { agents: { defaults: { compaction: { mode: "safeguard", keepRecentTokens: 0 } } } },
});
expect(result.compaction.keepRecentTokens).toBe(20_000);
expect(settingsManager.applyOverrides).not.toHaveBeenCalled();
expect(settingsManager.getCompactionKeepRecentTokens()).toBe(20_000);
expect(applyOverrides).not.toHaveBeenCalled();
});
it("caps the effective reserve so small-context models do not compact at token one", () => {
@ -206,13 +186,11 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
const settingsManager = SettingsManager.inMemory();
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
contextTokenBudget: 16_384,
});
expect(result.compaction).toEqual({ reserveTokens: 4_096, keepRecentTokens: 20_000 });
expect(result.didOverride).toBe(true);
expect(applyOverrides).toHaveBeenCalledWith({
compaction: { reserveTokens: 4_096 },
});
@ -221,25 +199,21 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
reserveTokens: 4_096,
keepRecentTokens: 20_000,
});
expect(shouldCompact(1, 16_384, { enabled: true, ...result.compaction })).toBe(false);
expect(shouldCompact(1, 16_384, settingsManager.getCompactionSettings())).toBe(false);
});
it("applies capped floor when current reserve is below it on small-context models", () => {
// A smaller project reserve is raised to the context-scaled floor.
const settingsManager = {
getCompactionReserveTokens: () => 2_048,
getCompactionKeepRecentTokens: () => 20_000,
applyOverrides: vi.fn(),
};
const settingsManager = SettingsManager.inMemory({ compaction: { reserveTokens: 2_048 } });
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
contextTokenBudget: 16_384,
});
expect(result.didOverride).toBe(true);
expect(result.compaction.reserveTokens).toBe(4_096);
expect(settingsManager.applyOverrides).toHaveBeenCalledWith({
expect(settingsManager.getCompactionReserveTokens()).toBe(4_096);
expect(applyOverrides).toHaveBeenCalledWith({
compaction: { reserveTokens: 4_096 },
});
});
@ -256,20 +230,19 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
});
it("does not cap floor when context window is large enough", () => {
const settingsManager = {
getCompactionReserveTokens: () => 16_384,
getCompactionKeepRecentTokens: () => 20_000,
applyOverrides: vi.fn(),
};
const settingsManager = SettingsManager.inMemory({ compaction: { reserveTokens: 16_384 } });
const applyOverrides = vi.spyOn(settingsManager, "applyOverrides");
// The large-window default keeps its existing 20,000-token reserve.
const result = applyAgentCompactionSettingsFromConfig({
applyAgentCompactionSettingsFromConfig({
settingsManager,
contextTokenBudget: 200_000,
});
expect(result.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR);
expect(settingsManager.applyOverrides).toHaveBeenCalledWith({
expect(settingsManager.getCompactionReserveTokens()).toBe(
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
);
expect(applyOverrides).toHaveBeenCalledWith({
compaction: { reserveTokens: DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR },
});
});
@ -400,12 +373,11 @@ describe("applyAgentAutoCompactionGuard", () => {
compaction: { enabled: true, reserveTokens: 50_000 },
});
const result = applyAgentAutoCompactionGuard({
applyAgentAutoCompactionGuard({
settingsManager,
compactionMode: "safeguard",
});
expect(result).toEqual({ supported: true, disabled: true });
expect(settingsManager.getCompactionSettings()).toEqual({
enabled: false,
reserveTokens: 50_000,
@ -418,15 +390,12 @@ describe("applyAgentAutoCompactionGuard", () => {
// overflow-recovery path inside Session.prompt() for users who are not
// affected by z.ai's silent-overflow accounting.
it("leaves embedded auto-compaction alone for non-z.ai providers without engine ownership", () => {
const setCompactionEnabled = vi.fn();
const settingsManager = {
getCompactionReserveTokens: () => 20_000,
getCompactionKeepRecentTokens: () => 4_000,
applyOverrides: () => {},
setCompactionEnabled,
};
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: true, reserveTokens: 20_000, keepRecentTokens: 4_000 },
});
const setCompactionEnabled = vi.spyOn(settingsManager, "setCompactionEnabled");
const result = applyAgentAutoCompactionGuard({
applyAgentAutoCompactionGuard({
settingsManager,
contextEngineInfo: {
id: "legacy",
@ -436,22 +405,7 @@ describe("applyAgentAutoCompactionGuard", () => {
silentOverflowProneProvider: false,
});
expect(result).toEqual({ supported: true, disabled: false });
expect(settingsManager.getCompactionEnabled()).toBe(true);
expect(setCompactionEnabled).not.toHaveBeenCalled();
});
it("reports unsupported when the settings manager has no setCompactionEnabled hook", () => {
const settingsManager = {
getCompactionReserveTokens: () => 20_000,
getCompactionKeepRecentTokens: () => 4_000,
applyOverrides: () => {},
};
const result = applyAgentAutoCompactionGuard({
settingsManager,
silentOverflowProneProvider: true,
});
expect(result).toEqual({ supported: false, disabled: false });
});
});

View file

@ -5,22 +5,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ContextEngineInfo } from "../context-engine/types.js";
import { resolveEffectiveCompactionReserveTokens } from "./agent-compaction-constants.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import type { SettingsManager } from "./sessions/settings-manager.js";
export const DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000;
type AgentSettingsManagerLike = {
getCompactionEnabled?: () => boolean;
getCompactionReserveTokens: () => number;
getCompactionKeepRecentTokens: () => number;
applyOverrides: (overrides: {
compaction: {
reserveTokens?: number;
keepRecentTokens?: number;
};
}) => void;
setCompactionEnabled?: (enabled: boolean) => void;
};
function toPositiveInt(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
@ -30,14 +18,11 @@ function toPositiveInt(value: unknown): number | undefined {
/** Applies configured compaction reserve/keep-recent settings to an agent settings manager. */
export function applyAgentCompactionSettingsFromConfig(params: {
settingsManager: AgentSettingsManagerLike;
settingsManager: SettingsManager;
cfg?: OpenClawConfig;
/** When known, the resolved context window budget for the current model. */
contextTokenBudget?: number;
}): {
didOverride: boolean;
compaction: { reserveTokens: number; keepRecentTokens: number };
} {
}): void {
const currentReserveTokens = params.settingsManager.getCompactionReserveTokens();
const currentKeepRecentTokens = params.settingsManager.getCompactionKeepRecentTokens();
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
@ -71,24 +56,13 @@ export function applyAgentCompactionSettingsFromConfig(params: {
const shouldApplyEnabled =
configuredEnabled !== undefined &&
typeof params.settingsManager.setCompactionEnabled === "function" &&
(typeof params.settingsManager.getCompactionEnabled !== "function" ||
params.settingsManager.getCompactionEnabled() !== configuredEnabled);
params.settingsManager.getCompactionEnabled() !== configuredEnabled;
if (shouldApplyEnabled) {
params.settingsManager.setCompactionEnabled!(configuredEnabled);
params.settingsManager.setCompactionEnabled(configuredEnabled);
}
if (Object.keys(overrides).length > 0) {
params.settingsManager.applyOverrides({ compaction: overrides });
}
const didOverride = shouldApplyEnabled || Object.keys(overrides).length > 0;
return {
didOverride,
compaction: {
reserveTokens: targetReserveTokens,
keepRecentTokens: targetKeepRecentTokens,
},
};
}
/** Resolve the compaction mode after provider-backed safeguard promotion. */
@ -129,21 +103,18 @@ export function isSilentOverflowProneModel(model: {
// Reapply after resource reload: settingsManager.reload() restores the disk setting.
export function applyAgentAutoCompactionGuard(params: {
settingsManager: AgentSettingsManagerLike;
settingsManager: SettingsManager;
contextEngineInfo?: ContextEngineInfo;
compactionMode?: AgentCompactionMode;
silentOverflowProneProvider?: boolean;
}): { supported: boolean; disabled: boolean } {
}): void {
// Leave compaction with its selected owner so prompt-time runtime compaction
// cannot rewrite the transcript before OpenClaw's provider call.
const disable =
params.contextEngineInfo?.ownsCompaction === true ||
params.compactionMode === "safeguard" ||
params.silentOverflowProneProvider === true;
const hasMethod = typeof params.settingsManager.setCompactionEnabled === "function";
if (!disable || !hasMethod) {
return { supported: hasMethod, disabled: false };
if (disable) {
params.settingsManager.setCompactionEnabled(false);
}
params.settingsManager.setCompactionEnabled!(false);
return { supported: true, disabled: true };
}

View file

@ -178,10 +178,9 @@ function withToolResultText(
text: string,
fileContent?: string,
): AgentToolResult<unknown> {
const content = Array.isArray(result.content) ? result.content : [];
let replaced = false;
const nextContent: ToolContentBlock[] = content.map((block) => {
if (!replaced && block && typeof block === "object" && block.type === "text") {
const nextContent: ToolContentBlock[] = result.content.map((block) => {
if (!replaced && block.type === "text") {
replaced = true;
return Object.assign({}, block, { text });
}
@ -439,16 +438,8 @@ async function normalizeReadImageResult(
result: AgentToolResult<unknown>,
filePath: string,
): Promise<AgentToolResult<unknown>> {
const content = Array.isArray(result.content) ? result.content : [];
const image = content.find(
(b): b is ImageContentBlock =>
Boolean(b) &&
typeof b === "object" &&
b.type === "image" &&
typeof b.data === "string" &&
typeof b.mimeType === "string",
);
const content = result.content;
const image = content.find((block): block is ImageContentBlock => block.type === "image");
if (!image) {
return result;
}
@ -473,15 +464,10 @@ async function normalizeReadImageResult(
}
const nextContent = content.map((block) => {
if (block && typeof block === "object" && block.type === "image") {
if (block.type === "image") {
return Object.assign({}, block, { mimeType: sniffed });
}
if (
block &&
typeof block === "object" &&
block.type === "text" &&
typeof block.text === "string"
) {
if (block.type === "text") {
return Object.assign({}, block, { text: rewriteReadImageHeader(block.text, sniffed) });
}
return block;
@ -513,15 +499,8 @@ function normalizeReadResultDetails(
};
}
const content = Array.isArray(result.content) ? result.content : [];
const displayText = getToolResultText(result) ?? "";
const image = content.find(
(block): block is ImageContentBlock =>
Boolean(block) &&
typeof block === "object" &&
block.type === "image" &&
typeof block.mimeType === "string",
);
const image = result.content.find((block): block is ImageContentBlock => block.type === "image");
if (image) {
return {
...result,

View file

@ -206,12 +206,7 @@ type RuntimeSnapshotPublication = {
publish: () => boolean;
};
function publishRuntimeSnapshotsAfterCommit(
publication: RuntimeSnapshotPublication | undefined,
): boolean {
if (!publication) {
return true;
}
function publishRuntimeSnapshotsAfterCommit(publication: RuntimeSnapshotPublication): boolean {
// A committed write can no longer roll back, so publication failure must
// evict only the exact derived owner that could now be stale.
try {
@ -1560,8 +1555,7 @@ export function createAuthProfileStoreRuntime(
}): CommittedAuthProfileStoreSave {
const agentDir = resolveRuntimeAuthProfileAgentDir(params.agentDir);
assertAuthProfilePersistenceOwner(params.snapshot.owner, agentDir, params.stateDir);
let publishRuntimeSnapshots: RuntimeSnapshotPublication | undefined;
const owned = runAuthProfileWriteTransaction(
const { owned, publication } = runAuthProfileWriteTransaction(
agentDir,
(database, owner) => {
if (params.snapshot.owner.databasePath !== database.path) {
@ -1583,14 +1577,14 @@ export function createAuthProfileStoreRuntime(
runtimeRevision,
}),
);
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
const committedPublication = saveAuthProfileStoreInTransaction(
params.store,
agentDir,
params.options,
database,
owner,
);
return {
const ownedSnapshot = {
owner,
credentialsRaw: readPersistedAuthProfileStoreRaw(agentDir, database),
stateRaw: readPersistedAuthProfileStateRaw(agentDir, database),
@ -1598,16 +1592,13 @@ export function createAuthProfileStoreRuntime(
runtimeRevisionAtSaveEdge: runtimeAtSaveEdge.runtimeRevision,
derivedRuntimeRevisionsAtSaveEdge,
} satisfies AuthProfileStorePersistenceSnapshot;
return { owned: ownedSnapshot, publication: committedPublication };
},
{ env: params.snapshot.owner.env },
);
return {
owned,
publishRuntimeSnapshots: () => {
if (!publishRuntimeSnapshots) {
return true;
}
const publication = publishRuntimeSnapshots;
return publishRuntimeSnapshotsAfterCommit({
...publication,
publish: () => {

View file

@ -249,6 +249,30 @@ describe.each(["anthropic", undefined])(
},
);
describe.each([false, true])("CLI run rejection (cleanupFails=%s)", (cleanupFails) => {
it.each([undefined, null, 0, false])(
"rejects the thrown value %s after cleanup",
async (error) => {
const context = buildPreparedCliRunContext();
const cleanup = vi.fn(async () => {
if (cleanupFails) {
throw new Error("synthetic cleanup failure");
}
});
context.params.cleanupCliLiveSessionOnRunEnd = true;
context.preparedBackend.closeLiveSession = cleanup;
await expect(
settlePreparedCliRun({
context,
run: vi.fn().mockRejectedValue(error),
}),
).rejects.toThrow(new Error(String(error)));
expect(cleanup).toHaveBeenCalledOnce();
},
);
});
it("preserves completed result boundaries for independent final delivery", async () => {
const context = buildPreparedCliRunContext({ provider: "claude-cli" });
const result = buildCliRunResult({

View file

@ -193,18 +193,16 @@ export async function settlePreparedCliRun(params: {
}): Promise<EmbeddedAgentRunResult> {
const { context, diagnosticLifecycle, run } = params;
const runParams = context.params;
let result: EmbeddedAgentRunResult | undefined;
let runError: unknown;
let outcome: { result: EmbeddedAgentRunResult } | { error: unknown };
try {
result = await run();
outcome = { result: await run() };
} catch (error) {
runError = error;
outcome = { error };
}
const terminalRunError = runError;
let cleanupError: unknown;
let cleanupError: Error | undefined;
const recordCleanupError = (error: unknown) => {
recordAgentCleanupFailure();
cleanupError ??= error;
cleanupError ??= error instanceof Error ? error : new Error(formatErrorMessage(error));
};
if (runParams.cleanupCliLiveSessionOnRunEnd === true) {
try {
@ -220,12 +218,10 @@ export async function settlePreparedCliRun(params: {
recordCleanupError(error);
}
if (cleanupError) {
if (runError || result?.didSendViaMessagingTool === true) {
if ("error" in outcome || outcome.result.didSendViaMessagingTool === true) {
log.warn(`cli run cleanup failed after completion: ${formatErrorMessage(cleanupError)}`);
} else {
diagnosticLifecycle?.setPhase("cleanup");
runError =
cleanupError instanceof Error ? cleanupError : new Error(formatErrorMessage(cleanupError));
}
}
// Retiring a caller is not a provider failure and must not quarantine its credential.
@ -236,15 +232,15 @@ export async function settlePreparedCliRun(params: {
const profileId = context.effectiveAuthProfileId;
const authProfileStore = context.authProfileStore;
const terminal: Parameters<typeof settleCliAuthProfile>[0]["terminal"] | undefined =
terminalRunError
"error" in outcome
? {
outcome: "failure",
error: terminalRunError,
error: outcome.error,
config: runParams.config,
runId: runParams.runId,
modelId: context.modelId,
}
: result?.meta.executionTrace?.attempts?.at(-1)?.result === "success"
: outcome.result.meta.executionTrace?.attempts?.at(-1)?.result === "success"
? { outcome: "success" }
: undefined;
if (terminal) {
@ -257,10 +253,15 @@ export async function settlePreparedCliRun(params: {
});
}
}
if (runError) {
throw runError instanceof Error ? runError : new Error(formatErrorMessage(runError));
if ("error" in outcome) {
throw outcome.error instanceof Error
? outcome.error
: new Error(formatErrorMessage(outcome.error));
}
return result as EmbeddedAgentRunResult;
if (cleanupError && outcome.result.didSendViaMessagingTool !== true) {
throw cleanupError;
}
return outcome.result;
}
export function resolveCliSourceReplyMirror(params: {

View file

@ -225,16 +225,15 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
const commitMessagingToolResult = (params: {
toolName: string;
target?: MessagingToolSend;
args?: Record<string, unknown>;
args: Record<string, unknown>;
result?: unknown;
isError?: boolean;
isError: boolean;
}) => {
const deliveryFact = readEmbeddedMessageDeliveryFact(
readToolResultDetails(params.result)?.messageDelivery,
);
const delivered = deliveryFact
? deliveryFact.status === "settled" &&
(params.isError !== true || deliveryFact.partialDelivery)
? deliveryFact.status === "settled" && (!params.isError || deliveryFact.partialDelivery)
: isDeliveredMessagingToolResult(params);
if (!delivered) {
return;
@ -244,7 +243,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
if (deliveryFact?.sourceReplyDelivered === true) {
sourceReplyDelivered = true;
}
const toolArgs = params.args ?? {};
const toolArgs = params.args;
const isMessagingSend = isMessagingToolSendAction(params.toolName, toolArgs);
const content = isMessagingSend ? extractCliMessagingContent(toolArgs, params.result) : {};
const confirmedTarget =
@ -441,7 +440,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
}
const toolName = stripOpenClawMcpToolPrefix(call.toolName);
const acceptedSessionSpawn =
toolName === "sessions_spawn" && call.outcome === "completed" && "result" in call
toolName === "sessions_spawn" && call.outcome === "completed"
? normalizeAcceptedSessionSpawnResult(call.result)
: null;
if (
@ -458,7 +457,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
result: "result" in call ? call.result : undefined,
isError: call.outcome !== "completed",
});
} else if (call.outcome === "completed" && "result" in call) {
} else if (call.outcome === "completed") {
const artifact = extractToolResultMediaArtifact(call.result);
const mediaUrls = artifact
? filterToolResultMediaUrls(toolName, artifact.mediaUrls, call.result)

View file

@ -301,7 +301,7 @@ export async function executeQueuedContextEngineCompaction(input: {
// Engine-owned compaction doesn't load the transcript at this level, so
// message counts are unavailable. We pass sessionFile so hook subscribers
// can read the transcript themselves if they need exact counts.
if (hookRunner?.hasHooks?.("before_compaction") && hookRunner.runBeforeCompaction) {
if (hookRunner?.hasHooks("before_compaction")) {
try {
await hookRunner.runBeforeCompaction(
{
@ -544,12 +544,7 @@ export async function executeQueuedContextEngineCompaction(input: {
assertActive,
});
}
if (
result.ok &&
(await canContinue()) &&
hookRunner?.hasHooks?.("after_compaction") &&
hookRunner.runAfterCompaction
) {
if (result.ok && (await canContinue()) && hookRunner?.hasHooks("after_compaction")) {
try {
const afterHookCtx = {
...hookCtx,

View file

@ -1,4 +1,3 @@
import { normalizeStructuredPromptSection } from "@openclaw/ai/internal/shared";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { joinPresentTextSegments } from "../../../shared/text/join-segments.js";
import type { isCacheTtlEligibleProvider } from "../cache-ttl.js";
@ -11,30 +10,19 @@ import {
/** Custom transcript marker used to preserve cache-TTL pruning state across attempts. */
const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl";
/**
* Combines hook-provided system context with the base prompt while preserving
* stable structured-section bytes. Returning undefined when hooks add nothing
* lets callers avoid rewriting the original prompt.
*/
/** Combines already-normalized hook sections without rewriting an unchanged prompt. */
export function composeSystemPromptWithHookContext(params: {
baseSystemPrompt?: string;
prependSystemContext?: string;
appendSystemContext?: string;
}): string | undefined {
const prependSystem =
typeof params.prependSystemContext === "string"
? normalizeStructuredPromptSection(params.prependSystemContext)
: "";
const appendSystem =
typeof params.appendSystemContext === "string"
? normalizeStructuredPromptSection(params.appendSystemContext)
: "";
if (!prependSystem && !appendSystem) {
if (!params.prependSystemContext && !params.appendSystemContext) {
return undefined;
}
return joinPresentTextSegments([prependSystem, params.baseSystemPrompt, appendSystem], {
trim: true,
});
return joinPresentTextSegments(
[params.prependSystemContext, params.baseSystemPrompt, params.appendSystemContext],
{ trim: true },
);
}
/**

View file

@ -201,7 +201,7 @@ export async function recoverEmbeddedRunOverflow(
`[context-overflow-diag] sessionKey=${runParams.sessionKey ?? runParams.sessionId} ` +
`provider=${input.modelSelection.provider}/${input.modelSelection.model} source=${contextOverflowError.source} ` +
`trigger=${compactionTrigger} ` +
`messages=${input.attempt.messagesSnapshot?.length ?? 0} sessionFile=${activeSession.file} ` +
`messages=${input.attempt.messagesSnapshot.length} sessionFile=${activeSession.file} ` +
`diagId=${overflowDiagId} compactionAttempts=${input.state.overflowCompactionAttempts} ` +
`observedTokens=${observedOverflowTokens ?? "unknown"} ` +
`preflightEstimatedTokens=${preflightEstimatedPromptTokens ?? "unknown"} ` +
@ -392,13 +392,11 @@ export async function recoverEmbeddedRunOverflow(
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: input.contextTokenBudget,
});
const hasOversized = input.attempt.messagesSnapshot
? sessionLikelyHasOversizedToolResults({
messages: input.attempt.messagesSnapshot,
contextWindowTokens: input.contextTokenBudget,
maxCharsOverride: toolResultMaxChars,
})
: false;
const hasOversized = sessionLikelyHasOversizedToolResults({
messages: input.attempt.messagesSnapshot,
contextWindowTokens: input.contextTokenBudget,
maxCharsOverride: toolResultMaxChars,
});
if (hasOversized) {
input.state.toolResultTruncationAttempted = true;
log.warn(

View file

@ -6,15 +6,9 @@ import {
awaitAgentHarnessAgentEndHook,
runAgentHarnessAgentEndHook,
runAgentHarnessBeforeAgentFinalizeHook,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
} from "./lifecycle-hook-helpers.js";
import { bindAgentHarnessHookMessages } from "./lifecycle-hook-messages.js";
const createLegacyHookRunner = () => ({
hasHooks: vi.fn(() => true),
});
const EVENT = {
runId: "run-1",
sessionId: "session-1",
@ -73,36 +67,6 @@ describe("agent harness lifecycle hook helpers", () => {
},
);
it("ignores legacy hook runners that advertise llm_input without a runner method", () => {
const hookRunner = createLegacyHookRunner();
runAgentHarnessLlmInputHook({
ctx: {},
event: {},
hookRunner,
} as never);
expect(hookRunner.hasHooks).toHaveBeenCalledWith("llm_input");
});
it("ignores legacy hook runners that advertise llm_output without a runner method", () => {
const hookRunner = createLegacyHookRunner();
runAgentHarnessLlmOutputHook({
ctx: {},
event: {},
hookRunner,
} as never);
expect(hookRunner.hasHooks).toHaveBeenCalledWith("llm_output");
});
it("ignores legacy hook runners that advertise agent_end without a runner method", () => {
const hookRunner = createLegacyHookRunner();
runAgentHarnessAgentEndHook({
ctx: {},
event: {},
hookRunner,
} as never);
expect(hookRunner.hasHooks).toHaveBeenCalledWith("agent_end");
});
it("resolves after agent_end hooks settle", async () => {
let releaseHook: () => void = () => undefined;
const agentEndSettled = new Promise<void>((resolve) => {
@ -156,16 +120,6 @@ describe("agent harness lifecycle hook helpers", () => {
);
});
it("continues when legacy hook runners advertise before_agent_finalize without a runner method", async () => {
await expect(
runAgentHarnessBeforeAgentFinalizeHook({
ctx: {},
event: {},
hookRunner: createLegacyHookRunner(),
} as never),
).resolves.toEqual({ action: "continue" });
});
it("keys finalize retry budgets by context run id when the event omits run id", async () => {
const hookRunner = {
hasHooks: () => true,

View file

@ -45,16 +45,12 @@ function countFinalizeRetryBudgetEntries(budget: FinalizeRetryBudget): number {
function pruneFinalizeRetryBudget(budget: FinalizeRetryBudget): void {
while (countFinalizeRetryBudgetEntries(budget) > FINALIZE_RETRY_BUDGET_MAX_ENTRIES) {
const oldestRunId = budget.keys().next().value;
if (oldestRunId === undefined) {
return;
}
const oldestRunBudget = budget.get(oldestRunId);
const oldestRetryKey = oldestRunBudget?.keys().next().value;
if (oldestRunBudget && oldestRetryKey !== undefined) {
const [oldestRunId, oldestRunBudget] = budget.entries().next().value!;
const oldestRetryKey = oldestRunBudget.keys().next().value;
if (oldestRetryKey !== undefined) {
oldestRunBudget.delete(oldestRetryKey);
}
if (!oldestRunBudget || oldestRunBudget.size === 0) {
if (oldestRunBudget.size === 0) {
budget.delete(oldestRunId);
}
}
@ -69,7 +65,7 @@ export function runAgentHarnessLlmInputHook(
params: AgentHarnessHookParams<PluginHookLlmInputEvent>,
): void {
const hookRunner = params.hookRunner ?? getGlobalHookRunner();
if (!hookRunner?.hasHooks("llm_input") || typeof hookRunner.runLlmInput !== "function") {
if (!hookRunner?.hasHooks("llm_input")) {
return;
}
void hookRunner
@ -84,7 +80,7 @@ export function runAgentHarnessLlmOutputHook(
params: AgentHarnessHookParams<PluginHookLlmOutputEvent>,
): void {
const hookRunner = params.hookRunner ?? getGlobalHookRunner();
if (!hookRunner?.hasHooks("llm_output") || typeof hookRunner.runLlmOutput !== "function") {
if (!hookRunner?.hasHooks("llm_output")) {
return;
}
void hookRunner
@ -99,7 +95,7 @@ async function executeAgentHarnessAgentEndHook(
): Promise<void> {
const loadMessages = takeHookMessageLoader(params.event);
const hookRunner = params.hookRunner ?? getGlobalHookRunner();
if (!hookRunner?.hasHooks("agent_end") || typeof hookRunner.runAgentEnd !== "function") {
if (!hookRunner?.hasHooks("agent_end")) {
return;
}
try {
@ -138,10 +134,7 @@ export async function runAgentHarnessBeforeAgentFinalizeHook(
): Promise<AgentHarnessBeforeAgentFinalizeOutcome> {
const loadMessages = takeHookMessageLoader(params.event);
const hookRunner = params.hookRunner ?? getGlobalHookRunner();
if (
!hookRunner?.hasHooks("before_agent_finalize") ||
typeof hookRunner.runBeforeAgentFinalize !== "function"
) {
if (!hookRunner?.hasHooks("before_agent_finalize")) {
return { action: "continue" };
}
try {

View file

@ -98,12 +98,6 @@ function postNativeHookRelayBridgeRecord(params: {
const body = JSON.stringify(params.payload);
return new Promise((resolve, reject) => {
let settled = false;
const resolveOnce = (value: NativeHookRelayProcessResponse) => {
if (!settled) {
settled = true;
resolve(value);
}
};
const rejectOnce = (error: unknown) => {
if (!settled) {
settled = true;
@ -147,7 +141,8 @@ function postNativeHookRelayBridgeRecord(params: {
| { ok: true; result: NativeHookRelayProcessResponse }
| { ok: false; error?: string };
if (parsed.ok) {
resolveOnce(parsed.result);
settled = true;
resolve(parsed.result);
return;
}
rejectOnce(new Error(parsed.error || "native hook relay bridge failed"));

View file

@ -19,7 +19,7 @@ import type { AnyAgentTool } from "../tools/common.js";
type NamedTool = { name: string };
function isAgentTool(tool: NamedTool): tool is AnyAgentTool {
function isAgentTool<T extends NamedTool>(tool: T): tool is T & AnyAgentTool {
return "execute" in tool && typeof tool.execute === "function";
}
@ -61,13 +61,7 @@ export function createAgentHarnessPromptToolPolicy<T extends NamedTool>(params:
});
const allowedTools = filterTools(baselineTools, toolsAllow);
if (!catalog) {
const executableTools: AnyAgentTool[] = [];
for (const tool of allowedTools) {
if (isAgentTool(tool)) {
executableTools.push(tool);
}
}
finalizeAgentToolAvailability(executableTools);
finalizeAgentToolAvailability(allowedTools.filter(isAgentTool));
return {
tools: allowedTools,
toolSchemaDirectoryPrompt: undefined,

View file

@ -136,7 +136,7 @@ export function compileResourceField(
(!Array.isArray(accept) ||
accept.length > 32 ||
!accept.every(
(entry) =>
(entry): entry is string =>
typeof entry === "string" &&
entry.length <= 128 &&
/^(?:\.[a-z0-9.+_-]+|[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*))$/iu.test(entry),
@ -149,9 +149,7 @@ export function compileResourceField(
}
upload = {
kind,
...(Array.isArray(accept)
? { accept: accept.filter((entry): entry is string => typeof entry === "string") }
: {}),
...(accept ? { accept: [...accept] } : {}),
};
}
const resources = ownArray(input, "options", 64);

View file

@ -107,7 +107,7 @@ export function compileStructuredInputForm(params: {
);
}
const metadata = new Map<string, FieldMetadata>();
const validatedFields: Array<[string, StructuredInputRecord, FieldMetadata]> = [];
const otherFields = new Map<string, { fieldId: string; secret: boolean }>();
for (const [fieldId, rawSchema] of propertyEntries) {
if (!validFieldName(fieldId) || !isStructuredInputRecord(rawSchema)) {
@ -117,7 +117,7 @@ export function compileStructuredInputForm(params: {
if (typeof fieldMetadata === "string") {
return unsupported(`${protocol} form field ${quote(fieldId)} ${fieldMetadata}`);
}
metadata.set(fieldId, fieldMetadata);
validatedFields.push([fieldId, rawSchema, fieldMetadata]);
if (fieldMetadata.otherAnswer) {
const target = fieldMetadata.otherQuestionId;
if (!target || otherFields.has(target)) {
@ -134,14 +134,10 @@ export function compileStructuredInputForm(params: {
const usedQuestionIds = new Set<string>();
const fields: StructuredInputField[] = [];
for (const [fieldId, rawSchema] of propertyEntries) {
const fieldMetadata = metadata.get(fieldId)!;
for (const [fieldId, fieldSchema, fieldMetadata] of validatedFields) {
if (fieldMetadata.otherAnswer) {
continue;
}
if (!isStructuredInputRecord(rawSchema)) {
return unsupported(`${protocol} form field ${quote(fieldId)} has an invalid schema.`);
}
const other = otherFields.get(fieldId);
const field = compileStructuredInputField(
{
@ -151,7 +147,7 @@ export function compileStructuredInputForm(params: {
secret: fieldMetadata.secret || other?.secret === true,
otherFieldId: other?.fieldId,
},
rawSchema,
fieldSchema,
options,
);
if (typeof field === "string") {

View file

@ -201,9 +201,7 @@ function isRootMemoryPath(filePath: string, workspaceDir: string): boolean {
if (!normalized) {
return false;
}
const absolutePath = path.isAbsolute(normalized)
? path.resolve(normalized)
: path.resolve(workspaceDir, normalized);
const absolutePath = path.resolve(workspaceDir, normalized);
return absolutePath === path.join(path.resolve(workspaceDir), "MEMORY.md");
}

View file

@ -516,12 +516,6 @@ export function createModelAuthAvailabilityResolver(
profileMode(profileId),
profilePolicyFacts(provider, profileId).authRequirement,
);
const profileCredential = (
profileId: string,
credential = store.profiles[profileId],
): AuthProfileCredential | undefined => {
return credential ? runtimeCredentialOverlay(profileId, credential) : undefined;
};
const profileEligibleForReadOnlyAvailability = (
provider: string,
profileId: string,
@ -605,8 +599,12 @@ export function createModelAuthAvailabilityResolver(
if (isConfiguredAwsSdkAuthProfileForProvider({ cfg: params.cfg, provider, profileId })) {
return modeAllowed(provider, target, "aws-sdk");
}
const credential = profileCredential(profileId);
if (!credential || !profileEligibleForReadOnlyAvailability(provider, profileId, credential)) {
const storedCredential = store.profiles[profileId];
if (!storedCredential) {
return false;
}
const credential = runtimeCredentialOverlay(profileId, storedCredential);
if (!profileEligibleForReadOnlyAvailability(provider, profileId, credential)) {
return false;
}
return resolvedProfileAvailability(provider, profileId, credential, target);
@ -665,9 +663,8 @@ export function createModelAuthAvailabilityResolver(
}
const binding = target.pinnedProfileId ? { kind: "none" as const } : providerBinding(provider);
if (binding.kind === "profile") {
const credential = profileCredential(binding.profileId, binding.credential);
const credential = runtimeCredentialOverlay(binding.profileId, binding.credential);
const availability =
credential &&
!profileInCooldown(binding.profileId, target) &&
profileEligibleForReadOnlyAvailability(
binding.credential.provider,
@ -679,7 +676,7 @@ export function createModelAuthAvailabilityResolver(
return {
availability,
selectedProfileId: binding.profileId,
selectedAuthMode: credential?.type ?? binding.credential.type,
selectedAuthMode: credential.type,
evidence: "profile",
};
}

View file

@ -40,7 +40,6 @@ import {
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
import { isPreparedModelCatalogFull } from "./prepared-model-runtime.full-catalog.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
import { resolveDefaultAgentWorkspaceDir } from "./workspace.js";
export type ModelCatalogDecisionParams = {
cfg: OpenClawConfig;
@ -70,10 +69,7 @@ export function createModelCatalogDecisions(params: ModelCatalogDecisionParams)
// The Gateway owns one process-lifecycle plugin metadata snapshot. Carry it
// through the whole projection so per-model normalization cannot rediscover it.
const metadataSnapshot = params.metadataSnapshot;
const workspaceDir =
params.workspaceDir ??
resolveAgentWorkspaceDir(params.cfg, params.agentId) ??
resolveDefaultAgentWorkspaceDir();
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, params.agentId);
let authStore = params.preparedAuthStore;
const preferredProfilesByProvider = new Map<string, string>();
const personalProviders = new Set<string>();

View file

@ -361,21 +361,20 @@ export async function scanOpenRouterModels(
return pMap(
filtered,
async (entry) => {
const isFree = isFreeOpenRouterModel(entry);
let tool: ProbeResult = { ok: false, latencyMs: null, skipped: true };
let image: ProbeResult = { ok: false, latencyMs: null, skipped: true };
if (probe) {
const model: OpenAIModel = {
...baseModel,
id: entry.id,
name: entry.name || entry.id,
name: entry.name,
contextWindow: entry.contextLength ?? baseModel.contextWindow,
maxTokens: entry.maxCompletionTokens ?? baseModel.maxTokens,
input: parseModality(entry.modality),
};
tool = await probeModel(model, apiKey, timeoutMs, llmRuntime.complete, "tool");
if (model.input?.includes("image")) {
if (model.input.includes("image")) {
image = await probeModel(model, apiKey, timeoutMs, llmRuntime.complete, "image");
}
}
@ -385,7 +384,7 @@ export async function scanOpenRouterModels(
...entry,
provider: "openrouter",
modelRef: `openrouter/${entry.id}`,
isFree,
isFree: true,
tool,
image,
};

View file

@ -226,10 +226,8 @@ export function prepareWorkspacePluginRegistries(
},
)
: baseRegistry;
const prepared = (registry: PluginRegistry | undefined): PreparedWorkspacePluginRegistries => {
if (registry) {
retainRegistry(registry);
}
const prepared = (registry: PluginRegistry): PreparedWorkspacePluginRegistries => {
retainRegistry(registry);
return {
runtimePluginRegistry: registry,
primaryRegistry:

View file

@ -203,7 +203,3 @@ export function notifyPreparedModelRuntimePublication(
log.warn(`prepared model runtime publication listener failed: ${String(error)}`);
});
}
export function resetPreparedModelRuntimePublicationListenersForTest(): void {
publicationListeners.clear();
}

View file

@ -44,10 +44,7 @@ import {
type PreparedModelRuntimeSnapshot,
} from "./prepared-model-runtime.owner.js";
import { releasePreparedPluginPublication } from "./prepared-model-runtime.plugin-lifetime.js";
import {
notifyPreparedModelRuntimePublication,
resetPreparedModelRuntimePublicationListenersForTest,
} from "./prepared-model-runtime.publication-events.js";
import { notifyPreparedModelRuntimePublication } from "./prepared-model-runtime.publication-events.js";
import { PreparedModelRuntimePublicationQueue } from "./prepared-model-runtime.publication-queue.js";
import {
createPublishedModelRuntimeAccess,
@ -728,7 +725,6 @@ registerPreparedRuntimeAuthMaterializationPublisher(owners, notifyPreparedModelR
async function resetPreparedModelRuntimeSnapshotsForTest(): Promise<void> {
await closePreparedModelRuntimeSnapshots();
resetPreparedModelRuntimePublicationListenersForTest();
modelRuntimeBuildTimeoutMs = DEFAULT_MODEL_RUNTIME_BUILD_TIMEOUT_MS;
}

View file

@ -443,7 +443,7 @@ function applyResolvedAuthHeader(
auth.mode === "authorization-bearer"
? `Bearer ${auth.value}`
: `${auth.prefix ?? ""}${auth.value}`;
return Object.keys(next).length > 0 ? next : undefined;
return next;
}
function toTlsConnectOptions(

View file

@ -34,14 +34,6 @@ import type { SandboxRegistryEntry } from "./registry.js";
type ContainerExecFinalizeToken = () => Promise<void>;
function resolveContainerExecEnv(env: Record<string, string>): Record<string, string> {
const { PATH: requestedPath, ...containerEnv } = env;
if (requestedPath) {
containerEnv.OPENCLAW_PREPEND_PATH = requestedPath;
}
return containerEnv;
}
function buildContainerExecArgs(params: {
containerName: string;
command: string;
@ -73,20 +65,6 @@ function buildContainerExecArgs(params: {
return args;
}
function resolveConfiguredDockerRuntimeImage(params: {
config: CreateSandboxBackendParams["cfg"] | import("../../config/config.js").OpenClawConfig;
agentId?: string;
configLabelKind?: string;
}): string {
const sandboxCfg = resolveSandboxConfigForAgent(params.config, params.agentId);
switch (params.configLabelKind) {
case "BrowserImage":
return sandboxCfg.browser.image;
default:
return sandboxCfg.docker.image;
}
}
async function createContainerSandboxBackend(
engine: SandboxContainerEngine,
params: CreateSandboxBackendParams,
@ -226,7 +204,11 @@ function createContainerSandboxBackendHandle(params: {
GH_CONFIG_DIR: SANDBOX_GITHUB_CONFIG_DIR,
}
: requestedEnv;
const envFile = await createContainerEnvFile(resolveContainerExecEnv(env));
const { PATH: requestedPath, ...containerEnv } = env;
if (requestedPath) {
containerEnv.OPENCLAW_PREPEND_PATH = requestedPath;
}
const envFile = await createContainerEnvFile(containerEnv);
try {
params.assertCurrent?.();
const argv = [
@ -394,11 +376,11 @@ function createContainerSandboxBackendManager(
// ignore inspect failures
}
}
const configuredImage = resolveConfiguredDockerRuntimeImage({
config,
agentId,
configLabelKind: entry.configLabelKind,
});
const sandboxCfg = resolveSandboxConfigForAgent(config, agentId);
const configuredImage =
entry.configLabelKind === "BrowserImage"
? sandboxCfg.browser.image
: sandboxCfg.docker.image;
let configLabelMatch = actualConfigLabel === configuredImage;
if (runtimeEngine.id === "podman" && !configLabelMatch && actualImageId) {
try {

View file

@ -35,10 +35,7 @@ function assertValidExecRemoteCommand(command: string): void {
const pendingHeredocs: PendingHeredoc[] = [];
for (let index = 0; index < command.length; index += 1) {
const frame = frames.at(-1);
if (!frame) {
throw new Error("Malformed SSH/OpenShell exec command: parser state underflow.");
}
const frame = frames.at(-1)!;
const char = command.charAt(index);
if (frame.escaping) {
@ -156,8 +153,7 @@ function assertValidExecRemoteCommand(command: string): void {
}
}
const openFrame = frames.at(-1);
if (openFrame?.escaping) {
if (frames.at(-1)!.escaping) {
throw new Error("Malformed SSH/OpenShell exec command: trailing backslash escape.");
}
const pending = pendingHeredocs[0];

View file

@ -5,7 +5,7 @@ import {
generateBranchSummary,
} from "../runtime/index.js";
import { AgentSessionExecution } from "./agent-session-execution.js";
import { extractTextContent, normalizeBranchSummaryResult } from "./agent-session-utils.js";
import { extractTextContent } from "./agent-session-utils.js";
import { createCompactionRuntime } from "./compaction/runtime.js";
import type { ExtensionRunner, TreePreparation } from "./extensions/index.js";
import { withSessionManagerWrite } from "./session-manager-write-admission.js";
@ -116,31 +116,29 @@ export abstract class AgentSessionTree extends AgentSessionExecution {
const model = this.model!;
const { apiKey, headers } = await this.getRequiredRequestAuth(model);
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
const result = normalizeBranchSummaryResult(
await generateBranchSummary(entriesToSummarize, {
model,
apiKey,
headers,
signal: abortController.signal,
customInstructions,
replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFn,
runtime: createCompactionRuntime((usage) =>
recordSessionModelUsage(this.sessionManager, usage),
),
}),
);
if (result.aborted) {
return { cancelled: true, aborted: true };
const result = await generateBranchSummary(entriesToSummarize, {
model,
apiKey,
headers,
signal: abortController.signal,
customInstructions,
replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFn,
runtime: createCompactionRuntime((usage) =>
recordSessionModelUsage(this.sessionManager, usage),
),
});
if (!result.ok) {
if (result.error.code === "aborted") {
return { cancelled: true, aborted: true };
}
throw new Error(result.error.message);
}
if (result.error) {
throw new Error(result.error);
}
summaryText = result.summary;
summaryText = result.value.summary;
summaryDetails = {
readFiles: result.readFiles || [],
modifiedFiles: result.modifiedFiles || [],
readFiles: result.value.readFiles,
modifiedFiles: result.value.modifiedFiles,
};
} else if (extensionSummary) {
summaryText = extensionSummary.summary;

View file

@ -1,7 +1,4 @@
import type {
BranchSummaryResult as CoreBranchSummaryResult,
AgentMessage,
} from "../runtime/index.js";
import type { AgentMessage } from "../runtime/index.js";
import { estimateTokens } from "../runtime/index.js";
export function unwrapCoreResult<T>(
@ -13,26 +10,6 @@ export function unwrapCoreResult<T>(
throw result.error;
}
export function normalizeBranchSummaryResult(
result:
| { ok: true; value: CoreBranchSummaryResult }
| { ok: false; error: { code: string; message: string } },
): {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
} {
if (result.ok) {
return result.value;
}
if (result.error.code === "aborted") {
return { aborted: true, error: result.error.message };
}
return { error: result.error.message };
}
export function hasPersistedAssistantContent(content: unknown): boolean {
return (typeof content === "string" || Array.isArray(content)) && content.length > 0;
}

View file

@ -33,9 +33,14 @@ export function materializeAuthStorageStore(
return { ...store, profiles };
}
function projectAuthStorageData(store: AuthProfileStore | null): AuthStorageData {
export function projectAuthoritativeAuthStorageData(
store: AuthProfileStore,
snapshots: readonly AuthProfileStore[],
): AuthStorageData {
const materialized = materializeAuthStorageStore(store, snapshots);
assertAuthStorageSecretRefsMaterialized(materialized);
const projected: AuthStorageData = {};
for (const [profileId, credential] of Object.entries(store?.profiles ?? {})) {
for (const [profileId, credential] of Object.entries(materialized.profiles)) {
if (profileId !== `${credential.provider}:default`) {
continue;
}
@ -71,15 +76,6 @@ export function assertAuthStorageSecretRefsMaterialized(store: AuthProfileStore)
}
}
export function projectAuthoritativeAuthStorageData(
store: AuthProfileStore,
snapshots: readonly AuthProfileStore[],
): AuthStorageData {
const materialized = materializeAuthStorageStore(store, snapshots);
assertAuthStorageSecretRefsMaterialized(materialized);
return projectAuthStorageData(materialized);
}
export function applyAuthStorageData(
store: AuthProfileStore,
data: AuthStorageData,

View file

@ -49,12 +49,11 @@ function clampMaxOutputChars(value: number | undefined): number {
function appendCapturedOutput(
current: OutputCapture,
chunk: Buffer | string,
chunk: string,
maxOutputChars: number,
truncateTail: boolean,
): OutputCapture {
const text = String(chunk);
const combined = `${current.text}${text}`;
const combined = `${current.text}${chunk}`;
const overflowChars = Math.max(0, combined.length - maxOutputChars);
if (overflowChars === 0) {
return {
@ -131,14 +130,14 @@ export async function execCommand(
proc.nodeChildProcess.once("exit", () => {
childExited = true;
});
let commandSettled = false;
let settled = false;
const termination = createCommandTerminationController({
child: proc.nodeChildProcess,
cancelController,
processTree: { mode: "graceful" },
killGraceMs: FORCE_KILL_GRACE_MS,
isChildExited: () => childExited,
isCommandSettled: () => commandSettled,
isCommandSettled: () => settled,
});
terminationController = termination;
@ -150,7 +149,6 @@ export async function execCommand(
stdout: createWindowsOutputDecoder({ preserveUtf8Bom: true }),
stderr: createWindowsOutputDecoder({ preserveUtf8Bom: true }),
};
let settled = false;
const maxOutputChars = clampMaxOutputChars(options?.maxOutputChars);
const truncateOutput = options?.maxOutputChars !== undefined;
let outputLimitExceeded: "stdout" | "stderr" | undefined;
@ -165,7 +163,6 @@ export async function execCommand(
return;
}
settled = true;
commandSettled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}

View file

@ -110,24 +110,6 @@ export function retainBlockedMediaReferences(
};
}
function buildMediaGenerationReplyInstruction(params: {
status: "ok" | "error";
completionLabel: string;
}) {
if (params.status === "ok") {
return [
`The ${params.completionLabel} is ready for the original chat.`,
"Follow the current visible-reply contract with a short user-facing caption and every structured generated attachment from this event.",
"Keep internal task/session details private and do not copy the internal event text verbatim.",
].join(" ");
}
return [
`${params.completionLabel[0]?.toUpperCase() ?? "T"}${params.completionLabel.slice(1)} generation task failed for the original chat.`,
"Follow the current visible-reply contract with a concise user-facing failure message.",
"Keep internal task/session details private and do not copy the internal event text verbatim.",
].join(" ");
}
export async function wakeMediaGenerationTaskCompletion(params: {
handle: MediaGenerationTaskHandle | null;
status: "ok" | "error";
@ -218,10 +200,15 @@ export async function wakeMediaGenerationTaskCompletion(params: {
result: params.result,
...(params.attachments?.length ? { attachments: params.attachments } : {}),
...(mediaUrls.length ? { mediaUrls } : {}),
replyInstruction: buildMediaGenerationReplyInstruction({
status: params.status,
completionLabel: params.completionLabel,
}),
replyInstruction: [
params.status === "ok"
? `The ${params.completionLabel} is ready for the original chat.`
: `${params.completionLabel[0]?.toUpperCase() ?? "T"}${params.completionLabel.slice(1)} generation task failed for the original chat.`,
params.status === "ok"
? "Follow the current visible-reply contract with a short user-facing caption and every structured generated attachment from this event."
: "Follow the current visible-reply contract with a concise user-facing failure message.",
"Keep internal task/session details private and do not copy the internal event text verbatim.",
].join(" "),
},
];
const triggerMessage = formatAgentInternalEventsForPrompt(internalEvents);

View file

@ -91,10 +91,7 @@ export function createMediaGenerateProviderListActionResult<
});
const lines = providerDetails.flatMap((details, index) => {
const provider = params.providers.at(index);
if (!provider) {
return [];
}
const provider = params.providers[index]!;
const authHints = details.authEnvVars;
const capabilities = params.summarizeCapabilities(provider);
const modelLine = details.models.length > 0 ? details.models.join(", ") : "unknown";

View file

@ -231,18 +231,14 @@ function resolveCapabilityModelCandidatesForTool(params: {
const primaryProvider = resolveDefaultModelRef(params.cfg).provider;
const normalizedPrimaryProvider = normalizeProviderId(primaryProvider);
const providerIds = [...providerDefaults.keys()].toSorted();
const matchesPrimaryProvider = (providerId: string): boolean => {
const entry = providerDefaults.get(providerId);
return (
normalizeProviderId(providerId) === normalizedPrimaryProvider ||
(entry?.aliases ?? []).includes(normalizedPrimaryProvider)
);
};
const matchesPrimaryProvider = (providerId: string): boolean =>
normalizeProviderId(providerId) === normalizedPrimaryProvider ||
providerDefaults.get(providerId)!.aliases.includes(normalizedPrimaryProvider);
const orderedProviders = [
...providerIds.filter(matchesPrimaryProvider),
...providerIds.filter((providerId) => !matchesPrimaryProvider(providerId)),
];
return uniqueStrings(orderedProviders.flatMap((id) => providerDefaults.get(id)?.ref ?? []));
return uniqueStrings(orderedProviders.map((id) => providerDefaults.get(id)!.ref));
}
/**

View file

@ -342,18 +342,12 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
dir: string;
bootstrapPath: string;
state: WorkspaceSetupState;
bootstrapExists?: boolean;
bootstrapExists: boolean;
guard?: WorkspaceStateGuard;
}): Promise<WorkspaceBootstrapCompletionReconcileResult> {
const assertEvidence = captureWorkspaceStateFilesystemGuard(params.dir);
const beforeFileMutation = createWorkspaceFileMutationGuard(params.guard);
const bootstrapExists = params.bootstrapExists ?? (await pathExists(params.bootstrapPath));
if (
typeof params.state.setupCompletedAt === "string" &&
params.state.setupCompletedAt.trim().length > 0
) {
return { repaired: false, bootstrapExists, state: params.state };
}
const { bootstrapExists } = params;
if (
bootstrapExists

View file

@ -1,5 +1,5 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { readAcpSessionEntryAsync } from "../../acp/runtime/session-meta.js";
import {
@ -73,7 +73,7 @@ export async function prepareAgentContentPhase(params: {
knownAgents: string[];
assertAdmissionCurrent?: () => void;
}) {
const transcriptInputText = (params.request.message ?? "").trim();
const transcriptInputText = params.request.message.trim();
let message = params.isRawModelRun
? transcriptInputText
: annotateInterSessionPromptText(transcriptInputText, params.inputProvenance);
@ -87,11 +87,10 @@ export async function prepareAgentContentPhase(params: {
const isKnownGatewayChannel = (value: string): boolean =>
isGatewayMessageChannel(value) || isInternalNonDeliveryChannel(value);
const channelHints = normalizeStringEntries(
[params.request.channel, params.request.replyChannel].filter(
(value): value is string => typeof value === "string",
),
);
const channelHints = normalizeTrimmedStringList([
params.request.channel,
params.request.replyChannel,
]);
for (const rawChannel of channelHints) {
const normalized = normalizeMessageChannel(rawChannel);
if (normalized && normalized !== "last" && !isKnownGatewayChannel(normalized)) {

View file

@ -72,8 +72,7 @@ export function createAgentDedupeLifecycle(params: {
const acceptedAt = Date.now();
const pendingTimeoutMs = resolveAgentTimeoutMs({
cfg: params.cfg,
overrideSeconds:
typeof params.request.timeout === "number" ? params.request.timeout : undefined,
overrideSeconds: params.request.timeout,
});
setGatewayDedupeEntries({
dedupe: params.context.dedupe,

View file

@ -53,10 +53,6 @@ export async function prepareAgentRequestRouting(params: {
const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(
params.request.attachments,
);
const requestedBestEffortDeliver =
typeof params.request.bestEffortDeliver === "boolean"
? params.request.bestEffortDeliver
: undefined;
const knownAgents = listAgentIds(params.cfg);
const agentIdRaw = normalizeOptionalString(params.request.agentId) ?? "";
let agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
@ -245,7 +241,7 @@ export async function prepareAgentRequestRouting(params: {
}
return {
normalizedAttachments,
requestedBestEffortDeliver,
requestedBestEffortDeliver: params.request.bestEffortDeliver,
knownAgents,
agentId,
requestedSessionId,

View file

@ -1,4 +1,3 @@
import { resolveNonNegativeIntegerOption } from "@openclaw/normalization-core/number-coercion";
import type { AgentWaitParams } from "../../../packages/gateway-protocol/src/index.js";
import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js";
import { getAgentRunContext } from "../../infra/agent-run-registry.js";
@ -15,8 +14,8 @@ export function prepareAgentWaitForTurn(
context: Pick<GatewayRequestContext, "chatAbortControllers" | "chatQueuedTurns" | "dedupe">,
params: AgentWaitParams,
) {
const runId = (params.runId ?? "").trim();
const timeoutMs = resolveNonNegativeIntegerOption(params.timeoutMs, 30_000);
const runId = params.runId.trim();
const timeoutMs = params.timeoutMs ?? 30_000;
const source = resolveAgentWaitSource(context, runId);
const lifecycleGeneration = getAgentEventLifecycleGeneration();
const queuedResult = () => {

View file

@ -124,16 +124,6 @@ describe("resolveInFlightRunSnapshot", () => {
).toEqual({ runId: "run-1", text: "", plan: { steps: [] } });
});
it("is a no-op when chatAbortControllers is not a Map (unpopulated context)", () => {
expect(
snap({
chatAbortControllers: undefined as never,
chatRunBuffers: undefined as never,
sessionKey: "agent:main:s",
}),
).toBeUndefined();
});
it("matches a run stored under the canonical key when requested with a different key", () => {
// Abort entry holds the canonical store key; the client requests history with
// a different (requested) key for the same logical session.

View file

@ -356,11 +356,6 @@ export function resolveInFlightRunSnapshot(params: {
normalizeOptionalLowercaseString(params.defaultAgentId);
return runAgentId === requestedAgentId;
};
// Some callers/tests run without populated run state; guard like
// collectTrackedActiveSessionRuns so a missing map is a no-op, not a throw.
if (!(params.chatAbortControllers instanceof Map)) {
return undefined;
}
// Timestamp wins over insertion order; runId breaks ties deterministically.
let best: { runId: string; startedAtMs: number } | undefined;
for (const [runId, entry] of params.chatAbortControllers) {

View file

@ -12,10 +12,7 @@ import {
listControlUiLinkReaders,
listControlUiPluginDescriptors,
} from "./control-ui-plugin-tabs.js";
import {
createGatewayMethodRegistry,
createPluginGatewayMethodDescriptors,
} from "./methods/registry.js";
import { createGatewayMethodRegistry } from "./methods/registry.js";
function setup() {
const { config, registry } = createPluginRegistryFixture();
@ -56,10 +53,7 @@ function setup() {
};
setActivePluginRegistry(registry.registry);
const methods = () =>
createGatewayMethodRegistry(
createPluginGatewayMethodDescriptors(registry.registry),
registry.registry,
);
createGatewayMethodRegistry(registry.registry.gatewayMethodDescriptors, registry.registry);
const readers = (scopes: readonly string[]) => listControlUiLinkReaders(scopes, methods());
return { registry, register, readers, methods };
}

View file

@ -258,15 +258,12 @@ export function createManagedLinuxDesktop(
const prepareResources = async (): Promise<ManagedResources> => {
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-managed-desktop-"));
await fs.chmod(tempDir, 0o700);
const plaintextFile = path.join(tempDir, "password.txt");
const passwordFile = path.join(tempDir, "passwd");
try {
const password = randomBytes(12).toString("base64url").slice(0, 8);
registerSecretValueForRedaction(password);
await fs.writeFile(plaintextFile, password, { mode: 0o600, flag: "wx" });
const passwordInput = await fs.readFile(plaintextFile);
const filtered = await runPasswordTool(["tigervncpasswd", "-f"], {
input: passwordInput,
input: Buffer.from(password),
maxOutputBytes: { stdout: 64, stderr: 4_096 },
timeoutMs: 10_000,
});
@ -275,7 +272,6 @@ export function createManagedLinuxDesktop(
throw binaryError("tigervncpasswd", detail || `exit code ${filtered.code ?? "none"}`);
}
await fs.writeFile(passwordFile, filtered.stdout, { mode: 0o600, flag: "wx" });
await fs.rm(plaintextFile, { force: true });
const port = await pickPort({ port: 0, host: "127.0.0.1", exclusive: true });
const display = chooseDisplayNumber(await readDisplaySocketNames(x11SocketDir));
const env: NodeJS.ProcessEnv = {

View file

@ -244,9 +244,6 @@ export function watchClientDisconnect(
),
),
);
if (sockets.length === 0) {
return () => {};
}
const stopWatchingDisconnect = () => {
for (const socket of sockets) {
socket.off("close", handleClose);

View file

@ -10,11 +10,7 @@ import {
createPluginGatewayMethodDescriptor,
type GatewayMethodDescriptorInput,
} from "./descriptor.js";
import {
createCoreGatewayMethodDescriptors,
createGatewayMethodRegistry,
createPluginGatewayMethodDescriptors,
} from "./registry.js";
import { createCoreGatewayMethodDescriptors, createGatewayMethodRegistry } from "./registry.js";
const handler: GatewayRequestHandler = ({ respond }) => respond(true, { ok: true });
@ -169,17 +165,17 @@ describe("gateway method registry", () => {
expect(registry.getScope("exec.approvals.get")).toBe("operator.approvals");
});
it("defaults handler-only plugin registries to admin scope", () => {
const descriptors = createPluginGatewayMethodDescriptors({
gatewayHandlers: { "legacy.ping": handler },
});
it("defaults plugin methods without an explicit scope to admin", () => {
const descriptors = [
createPluginGatewayMethodDescriptor({ pluginId: "demo", name: "demo.ping", handler }),
];
const registry = createGatewayMethodRegistry(descriptors);
expect(registry.listMethods()).toEqual(["legacy.ping"]);
expect(registry.getHandler("legacy.ping")).toBe(handler);
expect(registry.getScope("legacy.ping")).toBe(ADMIN_SCOPE);
expect(registry.requiresAuthenticatedProfile("legacy.ping")).toBe(true);
expect(registry.listMethods()).toEqual(["demo.ping"]);
expect(registry.getHandler("demo.ping")).toBe(handler);
expect(registry.getScope("demo.ping")).toBe(ADMIN_SCOPE);
expect(registry.requiresAuthenticatedProfile("demo.ping")).toBe(true);
});
it("classifies every core method and defaults non-core owners fail-closed", () => {

View file

@ -1,6 +1,6 @@
import type { PluginRegistry } from "../../plugins/registry-types.js";
import { normalizePluginGatewayMethodScope } from "../../shared/gateway-method-policy.js";
import { ADMIN_SCOPE, type OperatorScope } from "../operator-scopes.js";
import type { OperatorScope } from "../operator-scopes.js";
import {
DYNAMIC_GATEWAY_METHOD_SCOPE,
type GatewayMethodDescriptor,
@ -131,21 +131,3 @@ export function createGatewayMethodDescriptorsFromHandlers(params: {
};
});
}
/** Resolves plugin method descriptors, including the legacy handler-only registry shape. */
export function createPluginGatewayMethodDescriptors(
registry: Pick<PluginRegistry, "gatewayHandlers"> &
Partial<Pick<PluginRegistry, "gatewayMethodDescriptors">>,
): GatewayMethodDescriptorInput[] {
const descriptors = registry.gatewayMethodDescriptors ?? [];
if (descriptors.length > 0) {
return [...descriptors];
}
// Older plugin registries only carried handlers, so keep them callable but assign admin scope
// until the plugin can provide explicit descriptor metadata.
return createGatewayMethodDescriptorsFromHandlers({
handlers: registry.gatewayHandlers,
owner: { kind: "plugin", pluginId: "unknown" },
defaultScope: ADMIN_SCOPE,
});
}

View file

@ -2,21 +2,16 @@
// Stores short-lived per-node prompts until connected nodes drain them.
import { randomUUID } from "node:crypto";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "@openclaw/normalization-core/number-coercion";
// Pending node work is an in-memory per-node queue for gateway prompts such as
// status/location requests. Nodes drain it opportunistically after reconnecting.
const NODE_PENDING_WORK_TYPES = ["status.request", "location.request"] as const;
/** Work item types that connected nodes understand today. */
export type NodePendingWorkType = (typeof NODE_PENDING_WORK_TYPES)[number];
export type NodePendingWorkType = "status.request" | "location.request";
const NODE_PENDING_WORK_PRIORITIES = ["default", "normal", "high"] as const;
/** Priority labels used for pending work drain ordering. */
export type NodePendingWorkPriority = (typeof NODE_PENDING_WORK_PRIORITIES)[number];
export type NodePendingWorkPriority = "default" | "normal" | "high";
type NodePendingWorkItem = {
id: string;
@ -46,8 +41,6 @@ type DrainResult = {
};
const DEFAULT_STATUS_ITEM_ID = "baseline-status";
const DEFAULT_STATUS_PRIORITY: NodePendingWorkPriority = "default";
const DEFAULT_PRIORITY: NodePendingWorkPriority = "normal";
const DEFAULT_PENDING_WORK_TTL_MS = 24 * 60 * 60_000;
const DEFAULT_MAX_ITEMS = 4;
const MAX_ITEMS = 10;
@ -76,18 +69,11 @@ function getOrCreateState(nodeId: string, pairingGeneration?: string): NodePendi
return state;
}
function pruneExpired(state: NodePendingWorkState, nowMs: number): boolean {
function pruneExpired(state: NodePendingWorkState, nowMs: number): void {
// Expiry pruning bumps revision so polling nodes can observe that work changed.
const validNowMs = asDateTimestampMs(nowMs);
if (validNowMs === undefined) {
return false;
}
let changed = false;
for (const [id, item] of state.itemsById) {
if (
item.expiresAtMs !== null &&
!isFutureDateTimestampMs(item.expiresAtMs, { nowMs: validNowMs })
) {
if (item.expiresAtMs !== null && !isFutureDateTimestampMs(item.expiresAtMs, { nowMs })) {
state.itemsById.delete(id);
changed = true;
}
@ -95,7 +81,6 @@ function pruneExpired(state: NodePendingWorkState, nowMs: number): boolean {
if (changed) {
state.revision += 1;
}
return changed;
}
function pruneExpiredRetiredGenerations(
@ -137,24 +122,20 @@ function pruneStateIfEmpty(
function sortedItems(state: NodePendingWorkState): NodePendingWorkItem[] {
// Higher priority wins, then older work, then id for deterministic paging.
return [...state.itemsById.values()].toSorted((a, b) => {
const priorityDelta = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority];
if (priorityDelta !== 0) {
return priorityDelta;
}
if (a.createdAtMs !== b.createdAtMs) {
return a.createdAtMs - b.createdAtMs;
}
return a.id.localeCompare(b.id);
});
return [...state.itemsById.values()].toSorted(
(a, b) =>
PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] ||
a.createdAtMs - b.createdAtMs ||
a.id.localeCompare(b.id),
);
}
function makeBaselineStatusItem(nowMs: number): NodePendingWorkItem {
return {
id: DEFAULT_STATUS_ITEM_ID,
type: "status.request",
priority: DEFAULT_STATUS_PRIORITY,
createdAtMs: resolveDateTimestampMs(nowMs),
priority: "default",
createdAtMs: nowMs,
expiresAtMs: null,
};
}
@ -195,7 +176,7 @@ export function enqueueNodePendingWork(params: {
const item: NodePendingWorkItem = {
id: randomUUID(),
type: params.type,
priority: params.priority ?? DEFAULT_PRIORITY,
priority: params.priority ?? "normal",
createdAtMs: nowMs,
expiresAtMs: resolvePendingWorkExpiresAtMs(params.expiresInMs, rawNowMs),
...(params.payload ? { payload: params.payload } : {}),
@ -258,22 +239,20 @@ export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): D
const maxItems = Math.min(MAX_ITEMS, Math.max(1, Math.trunc(opts.maxItems ?? DEFAULT_MAX_ITEMS)));
const explicitItems = state ? sortedItems(state) : [];
const items = explicitItems.slice(0, maxItems);
const explicitReturnedCount = items.length;
const hasExplicitStatus = explicitItems.some((item) => item.type === "status.request");
const includeBaseline = opts.includeDefaultStatus !== false && !hasExplicitStatus;
if (includeBaseline && items.length < maxItems) {
items.push(makeBaselineStatusItem(nowMs));
}
const explicitReturnedCount = items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length;
const baselineIncluded = items.some((item) => item.id === DEFAULT_STATUS_ITEM_ID);
if (state && explicitReturnedCount > 0) {
for (const item of items) {
if (item.id !== DEFAULT_STATUS_ITEM_ID) {
state.itemsById.delete(item.id);
}
state.itemsById.delete(item.id);
}
state.revision += 1;
pruneStateIfEmpty(normalizedNodeId, opts.pairingGeneration, state);
}
const baselineIncluded = includeBaseline && items.length < maxItems;
if (baselineIncluded) {
items.push(makeBaselineStatusItem(nowMs));
}
return {
revision: state?.revision ?? revision,
items,

View file

@ -792,7 +792,6 @@ export async function handleOpenAiHttpRequest(
let finalToolCalls: ReturnType<typeof readOpenAiHttpRunTerminal>["pendingToolCalls"];
let finalUsage: OpenAiChatCompletionsUsage | undefined;
let finalizeScheduled = false;
let resultResolved = false;
let closed = false;
let observedTerminalLifecycle = false;
let terminalStreamError: { message: string; type: string; code?: string } | undefined;
@ -802,10 +801,7 @@ export async function handleOpenAiHttpRequest(
if (closed || finalizeScheduled) {
return;
}
if (!resultResolved) {
return;
}
if (streamIncludeUsage && !finalUsage) {
if (!finalUsage) {
return;
}
// Agent text_end flushes run in a microtask. Keep the stream subscribed
@ -922,7 +918,6 @@ export async function handleOpenAiHttpRequest(
void (async () => {
try {
const result = await runAgentCommand();
resultResolved = true;
if (closed) {
return;
@ -960,7 +955,6 @@ export async function handleOpenAiHttpRequest(
stopReason === "tool_calls" && pendingToolCalls?.length ? pendingToolCalls : undefined;
requestFinalize();
} catch (err) {
resultResolved = true;
if (closed || abortController.signal.aborted) {
return;
}

View file

@ -2,11 +2,8 @@ import { hasHttpUrlPrefix } from "@openclaw/net-policy/url-protocol";
import type { QuestionRequestParams } from "../../packages/gateway-protocol/src/index.js";
import {
isQuestionThumbnail,
readQuestionResourceInput,
readQuestionResourcePreview,
} from "../../packages/gateway-protocol/src/question-media.js";
import { ENV_SECRET_REF_ID_RE } from "../config/types.secrets.js";
import { SECRET_STORE_ALLOWED_HOSTS_MAX } from "../secrets/store/secret-store-validation-error.js";
/** Shape rules after protocol validation; callers retain admission policy and error types. */
export function questionShapeError(
@ -34,10 +31,7 @@ export function questionShapeError(
if (question.presentation !== "form" && question.options.length > 4) {
return `question '${question.questionId}' supports at most four standard options`;
}
if (
question.resource &&
(!readQuestionResourceInput(question.resource) || question.presentation !== "form")
) {
if (question.resource && question.presentation !== "form") {
return `question '${question.questionId}' requires form presentation for resource input`;
}
if (question.resource?.userOptions && !question.resource.viewId) {
@ -54,15 +48,9 @@ export function questionShapeError(
if (questions.length !== 1 || question.options.length !== 0 || question.multiSelect) {
return `question '${question.questionId}': secret store requests require one free-text, single-select question`;
}
if (!ENV_SECRET_REF_ID_RE.test(binding.name)) {
return `question '${question.questionId}': invalid secret store entry name`;
}
if (binding.kind !== "secret") {
return `question '${question.questionId}': masked requests require kind "secret"; set environment values in Settings or the CLI`;
}
if ((binding.allowedHosts?.length ?? 0) > SECRET_STORE_ALLOWED_HOSTS_MAX) {
return `question '${question.questionId}': secret store allowed hosts exceed the limit`;
}
}
const optionLabels = new Set<string>();
const optionValues = new Set<string>();

View file

@ -893,27 +893,25 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
if (abort.signal.aborted || manuallyStopped.has(rKey) || opts.isClosing?.()) {
return;
}
const runStartAccount = () => {
const runStartAccount = async () => {
const startedAt = Date.now();
const recordDuration = () => {
channelRunDurationMs = Date.now() - startedAt;
};
try {
return withGatewayNativeApprovalRuntime(opts.getNativeApprovalRuntime?.(), () =>
startAccount({
...accountContext,
setStatus: (next) =>
isCurrentTask()
? setRuntimeFromTaskStatus(channelId, id, next, abort.signal)
: getRuntime(channelId, id),
invalidateDirectoryCache: () =>
resetDirectoryCache({ cfg, channel: channelId, accountId: id }),
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
}),
).finally(recordDuration);
} catch (error) {
recordDuration();
throw error;
return await withGatewayNativeApprovalRuntime(
opts.getNativeApprovalRuntime?.(),
() =>
startAccount({
...accountContext,
setStatus: (next) =>
isCurrentTask()
? setRuntimeFromTaskStatus(channelId, id, next, abort.signal)
: getRuntime(channelId, id),
invalidateDirectoryCache: () =>
resetDirectoryCache({ cfg, channel: channelId, accountId: id }),
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
}),
);
} finally {
channelRunDurationMs = Date.now() - startedAt;
}
};
startAccountTask = withPluginHttpRouteRegistry(

View file

@ -9,10 +9,7 @@ import { setImmediate as nextEventLoopTurn } from "node:timers/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import {
getPreparedModelCatalogOwnerSnapshot,
getPublishedPreparedModelCatalogOwnerSnapshot,
} from "../agents/prepared-model-catalog.js";
import { getPublishedPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js";
import {
getPreparedModelFullCatalogAuth,
setPreparedModelFullCatalogAuth,
@ -75,10 +72,7 @@ afterEach(async ({ task }) => {
await cleanupPreparedModelRuntimeHarness(state, task.result?.state === "fail");
});
async function createRenewalLifecycle(
minimalTestGateway = false,
allowGatewaySubagentBinding = false,
) {
async function createRenewalLifecycle() {
const config: OpenClawConfig = { agents: { entries: { main: {} } } };
mocks.configuredAgentIds = ["main"];
mocks.authStorage.getAll.mockReturnValue({
@ -101,7 +95,7 @@ async function createRenewalLifecycle(
await refreshPreparedModelRuntimeSnapshots(config, {
gatewayLifecycle: true,
catalogMode: "static",
...(allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
allowGatewaySubagentBinding: true,
});
owner = getPreparedModelRuntimeSnapshot({
config,
@ -122,7 +116,6 @@ async function createRenewalLifecycle(
});
const lifecycle = await createGatewayChatMetadataLifecycle({
getConfig: () => config,
minimalTestGateway,
log: { warn: mocks.warn } as never,
});
const sidecars = createGatewaySidecarStopOwner();
@ -139,25 +132,8 @@ async function createRenewalLifecycle(
}
describe("catalog renewal metadata broadcasts", () => {
it("prepares Gateway binding when minimal metadata finds an unbound model owner", async () => {
const harness = await createRenewalLifecycle(true, false);
try {
expect(owner.isCurrent()).toBe(false);
const bound = getPreparedModelCatalogOwnerSnapshot({
config: harness.config,
agentId: "main",
allowGatewaySubagentBinding: true,
});
expect(bound?.isCurrent()).toBe(true);
await harness.lifecycle.read({ agentId: "main" });
expect(bound?.isCurrent()).toBe(true);
} finally {
await harness.stop();
}
});
it("retains the published model owner across minimal metadata reads and catalog renewal", async () => {
const harness = await createRenewalLifecycle(true, true);
it("retains the published model owner across metadata reads and catalog renewal", async () => {
const harness = await createRenewalLifecycle();
try {
expect(owner.isCurrent()).toBe(true);
const next = structuredClone(harness.inventory);

View file

@ -106,7 +106,6 @@ afterEach(async ({ task }) => {
async function createLifecycle(getConfig: () => OpenClawConfig = () => config) {
return await createGatewayChatMetadataLifecycle({
getConfig,
minimalTestGateway: false,
log: { warn: vi.fn() } as never,
});
}

View file

@ -32,7 +32,6 @@ const mocks = vi.hoisted(() => ({
invalidate: vi.fn(),
read: vi.fn(),
readStartup: vi.fn(),
refreshPreparedModels: vi.fn(),
refresh: vi.fn(),
stop: vi.fn(),
registerAuthListener: vi.fn(),
@ -53,7 +52,6 @@ vi.mock("../agents/auth-profiles/runtime-snapshots.js", async (importOriginal) =
}));
vi.mock("../agents/prepared-model-runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../agents/prepared-model-runtime.js")>()),
refreshPreparedModelRuntimeSnapshots: mocks.refreshPreparedModels,
registerPreparedModelRuntimePublicationListener: mocks.registerModelListener,
}));
vi.mock("../skills/runtime/refresh.js", async (importOriginal) => ({
@ -98,13 +96,12 @@ beforeEach(() => {
mocks.registerSkillsListener.mockReturnValue(mocks.unregisterSkillsListener);
});
function createLifecycle(minimalTestGateway: boolean, warn = vi.fn()) {
function createLifecycle(warn = vi.fn()) {
const sidecarOwner = createGatewaySidecarStopOwner();
sidecarOwners.add(sidecarOwner);
return {
lifecycle: createGatewayChatMetadataLifecycle({
getConfig: () => config,
minimalTestGateway,
log: { warn } as never,
}),
sidecarOwner,
@ -116,16 +113,11 @@ it.each([false, true])(
"publishes usage completion (failed: %s) without rebuilding metadata and retires its listener on stop",
async (failed) => {
const broadcast = vi.fn();
const { lifecycle: pending, sidecarOwner } = createLifecycle(true);
const { lifecycle: pending, sidecarOwner } = createLifecycle();
const lifecycle = await pending;
await lifecycle.attachContext({ ...context, broadcast }, sidecarOwner.publish);
expect(mocks.createRuntime).toHaveBeenCalledWith(
expect.objectContaining({ beforeRefresh: expect.any(Function), refreshOnRead: true }),
);
expect(mocks.refresh).not.toHaveBeenCalled();
expect(mocks.registerAuthListener).not.toHaveBeenCalled();
expect(mocks.registerModelListener).not.toHaveBeenCalled();
expect(mocks.registerSkillsListener).not.toHaveBeenCalled();
expect(mocks.refresh).toHaveBeenCalledOnce();
mocks.refresh.mockClear();
expect(sidecarOwner.snapshot()).toHaveLength(1);
publishSessionCostUsageUpdated("main", failed);
expect(broadcast).toHaveBeenCalledExactlyOnceWith(
@ -142,6 +134,9 @@ it.each([false, true])(
expect(mocks.refresh).not.toHaveBeenCalled();
await sidecarOwner.stop();
expect(mocks.stop).toHaveBeenCalledOnce();
expect(mocks.unregisterAuthListener).toHaveBeenCalledOnce();
expect(mocks.unregisterModelListener).toHaveBeenCalledOnce();
expect(mocks.unregisterSkillsListener).toHaveBeenCalledOnce();
publishSessionCostUsageUpdated("main");
expect(broadcast).toHaveBeenCalledTimes(1);
},
@ -161,7 +156,7 @@ it("retires model choices at its config commit before pending metadata settles",
entered.resolve();
return release.promise;
});
const { lifecycle: pendingLifecycle, sidecarOwner } = createLifecycle(false);
const { lifecycle: pendingLifecycle, sidecarOwner } = createLifecycle();
const lifecycle = await pendingLifecycle;
const attaching = lifecycle.attachContext(ownedContext, sidecarOwner.publish);
try {
@ -256,7 +251,7 @@ async function createRealMetadataLifecycle(
return { ...runtime, refresh };
},
);
const { lifecycle: pendingLifecycle, sidecarOwner, warn } = createLifecycle(false);
const { lifecycle: pendingLifecycle, sidecarOwner, warn } = createLifecycle();
const lifecycle = await pendingLifecycle;
const attach = () =>
lifecycle.attachContext(
@ -641,7 +636,7 @@ describe("gateway chat metadata lifecycle", () => {
},
}),
);
const { lifecycle: pendingLifecycle, sidecarOwner } = createLifecycle(false);
const { lifecycle: pendingLifecycle, sidecarOwner } = createLifecycle();
const lifecycle = await pendingLifecycle;
const outcomes: unknown[] = [];
const reads: Promise<void>[] = [];
@ -720,100 +715,88 @@ describe("gateway chat metadata lifecycle", () => {
);
});
it.each([true, false])(
"joins pending metadata work before Gateway lifetime shutdown (minimal=%s)",
async (minimalTestGateway) => {
const actual = await vi.importActual<
typeof import("./server-methods/chat-metadata-runtime.js")
>("./server-methods/chat-metadata-runtime.js");
const entered = createDeferred();
const release = createDeferred();
const events: string[] = [];
const owner = createChatMetadataOwner(config, "shutdown-model");
let ownerAvailable = true;
let revision = 0;
let held = false;
const holdWork = async () => {
if (held) {
entered.resolve();
await release.promise;
events.push("work settled");
}
it("joins pending metadata work before Gateway lifetime shutdown", async () => {
const actual = await vi.importActual<
typeof import("./server-methods/chat-metadata-runtime.js")
>("./server-methods/chat-metadata-runtime.js");
const entered = createDeferred();
const release = createDeferred();
const events: string[] = [];
const owner = createChatMetadataOwner(config, "shutdown-model");
let ownerAvailable = true;
let revision = 0;
let held = false;
const holdWork = async () => {
if (held) {
entered.resolve();
await release.promise;
events.push("work settled");
}
};
const buildProjection = vi.fn(async () => {
await holdWork();
return {
modelCatalog: owner.modelCatalog.entries,
read: () => ({ models: owner.modelCatalog.entries }),
isCurrent: () => true,
};
mocks.refreshPreparedModels.mockImplementation(holdWork);
const buildProjection = vi.fn(async () => {
if (!minimalTestGateway) {
await holdWork();
}
return {
modelCatalog: owner.modelCatalog.entries,
read: () => ({ models: owner.modelCatalog.entries }),
isCurrent: () => true,
};
});
mocks.createRuntime.mockImplementation(
(params: Parameters<typeof actual.createGatewayChatMetadataRuntime>[0]) =>
actual.createGatewayChatMetadataRuntime({
...params,
deps: {
getPreparedOwner: () => (ownerAvailable ? owner : undefined),
getPreparedAuthStore: () => ({ version: 1, profiles: {} }),
getAuthStoreRevision: () => revision,
getSkillsVersion: () => 0,
getPluginRegistryVersion: () => 0,
buildCommands: async () => ({ commands: [] }),
buildProjection,
},
}),
);
const { lifecycle: pendingLifecycle, sidecarOwner, warn } = createLifecycle();
const lifecycle = await pendingLifecycle;
const broadcast = vi.fn();
await lifecycle.attachContext(
{ broadcast } as unknown as GatewayRequestContext,
sidecarOwner.publish,
);
held = true;
revision += 1;
mocks.registerModelListener.mock.calls[0]![0]({ phase: "published" });
const read = lifecycle.read({ agentId: "main" }).then(
(result) => {
events.push("read settled");
return result;
},
(error: unknown) => {
events.push("read settled");
return error;
},
);
try {
await entered.promise;
const stopping = sidecarOwner.stop().then(() => {
ownerAvailable = false;
events.push("shutdown completed");
});
mocks.createRuntime.mockImplementation(
(params: Parameters<typeof actual.createGatewayChatMetadataRuntime>[0]) =>
actual.createGatewayChatMetadataRuntime({
...params,
deps: {
getPreparedOwner: () => (ownerAvailable ? owner : undefined),
getPreparedAuthStore: () => ({ version: 1, profiles: {} }),
getAuthStoreRevision: () => revision,
getSkillsVersion: () => 0,
getPluginRegistryVersion: () => 0,
buildCommands: async () => ({ commands: [] }),
buildProjection,
},
}),
);
const {
lifecycle: pendingLifecycle,
sidecarOwner,
warn,
} = createLifecycle(minimalTestGateway);
const lifecycle = await pendingLifecycle;
const broadcast = vi.fn();
await lifecycle.attachContext(
{ broadcast } as unknown as GatewayRequestContext,
sidecarOwner.publish,
);
held = true;
revision += 1;
if (!minimalTestGateway) {
mocks.registerModelListener.mock.calls[0]![0]({ phase: "published" });
}
const read = lifecycle.read({ agentId: "main" }).then(
(result) => {
events.push("read settled");
return result;
},
(error: unknown) => {
events.push("read settled");
return error;
},
);
try {
await entered.promise;
const stopping = sidecarOwner.stop().then(() => {
ownerAvailable = false;
events.push("shutdown completed");
});
release.resolve();
await stopping;
const result = await read;
release.resolve();
await stopping;
const result = await read;
expect(events).toEqual(["work settled", "read settled", "shutdown completed"]);
expect(result).toBeInstanceOf(ChatMetadataSnapshotUnavailableError);
await expect(lifecycle.read({ agentId: "main" })).rejects.toThrow("stopped");
await expect(lifecycle.refresh()).rejects.toThrow("stopped");
await expect(lifecycle.readStartup({ agentId: "main" })).resolves.toBeUndefined();
expect(warn).not.toHaveBeenCalled();
} finally {
release.resolve();
await read;
}
},
);
expect(events).toEqual(["work settled", "read settled", "shutdown completed"]);
expect(result).toBeInstanceOf(ChatMetadataSnapshotUnavailableError);
await expect(lifecycle.read({ agentId: "main" })).rejects.toThrow("stopped");
await expect(lifecycle.refresh()).rejects.toThrow("stopped");
await expect(lifecycle.readStartup({ agentId: "main" })).resolves.toBeUndefined();
expect(warn).not.toHaveBeenCalled();
} finally {
release.resolve();
await read;
}
});
it.each(["published", "build failed", "catalog during attachment", "owner invalidated"] as const)(
"refreshes subordinate changes after %s catch-up",
@ -830,7 +813,7 @@ describe("gateway chat metadata lifecycle", () => {
return release.promise;
});
}
const { lifecycle: pending, sidecarOwner, warn } = createLifecycle(false);
const { lifecycle: pending, sidecarOwner, warn } = createLifecycle();
const lifecycle = await pending;
const attachment = lifecycle.attachContext(context, sidecarOwner.publish);
try {
@ -893,7 +876,7 @@ describe("gateway chat metadata lifecycle", () => {
])(
"refreshes catalog metadata only for a change (%j)",
async ({ phase = "catalog-published", modelFactsChanged, refreshStatusChanged, refreshes }) => {
const { lifecycle: pendingLifecycle, sidecarOwner } = createLifecycle(false);
const { lifecycle: pendingLifecycle, sidecarOwner } = createLifecycle();
const lifecycle = await pendingLifecycle;
const requestContext = { ...context, broadcast: vi.fn(), getRuntimeConfig: () => config };
let pending = true;

View file

@ -29,7 +29,6 @@ export function broadcastChatMetadataChanged(
export async function createGatewayChatMetadataLifecycle(params: {
getConfig: () => OpenClawConfig;
minimalTestGateway: boolean;
log: GatewayLogger;
}) {
let context: GatewayRequestContext | undefined;
@ -47,44 +46,6 @@ export async function createGatewayChatMetadataLifecycle(params: {
}
return context;
},
...(params.minimalTestGateway
? {
beforeRefresh: async () => {
const [
{ listAgentIds },
{ getPreparedModelCatalogOwnerSnapshot },
{ readAgentDatabaseAdmissionRefusal },
] = await Promise.all([
import("../agents/agent-scope.js"),
import("../agents/prepared-model-catalog.js"),
import("../state/agent-database-admission.js"),
]);
const config = params.getConfig();
// Catalog and skill publications can change metadata while its model owner stays current.
if (
listAgentIds(config).every(
(agentId) =>
readAgentDatabaseAdmissionRefusal(agentId) ||
getPreparedModelCatalogOwnerSnapshot({
agentId,
config,
allowGatewaySubagentBinding: true,
})?.isCurrent(),
)
) {
return;
}
const { refreshPreparedModelRuntimeSnapshots } =
await import("../agents/prepared-model-runtime.js");
await refreshPreparedModelRuntimeSnapshots(config, {
gatewayLifecycle: true,
catalogMode: "static",
allowGatewaySubagentBinding: true,
});
},
refreshOnRead: true,
}
: {}),
onChanged: (change) => {
if (context) {
broadcastChatMetadataChanged(context, change);
@ -109,10 +70,7 @@ export async function createGatewayChatMetadataLifecycle(params: {
refreshLogged(notifyIfUnchanged);
}
};
const registerRefreshListeners = async (): Promise<(() => void) | undefined> => {
if (params.minimalTestGateway) {
return undefined;
}
const registerRefreshListeners = async (): Promise<() => void> => {
const [
{ registerRuntimeAuthProfileStoreMutationListener },
{ registerPreparedModelRuntimePublicationListener },
@ -209,41 +167,37 @@ export async function createGatewayChatMetadataLifecycle(params: {
}
}
});
// Minimal Gateways still own read-triggered preparation. Every lifetime
// must join it before shutdown retires the config and model owners.
publishSidecars({
stop: async () => {
unregisterUsage();
unregisterRolePolicy();
unregister?.();
unregister();
await runtime.stop();
},
});
if (unregister) {
// Publications that complete before listener registration would otherwise be missed.
// During ordinary startup the owner is published after attachment, so an unavailable
// snapshot here is expected and the publication listener performs the first refresh.
const eventVersion = preparedModelRuntimeEventVersion;
await runtime.refresh().then(
() => {
// A successful catch-up proves availability when publication completed before the
// listener was registered. Do not overwrite a newer invalidation or failure event.
// Publications that complete before listener registration would otherwise be missed.
// During ordinary startup the owner is published after attachment, so an unavailable
// snapshot here is expected and the publication listener performs the first refresh.
const eventVersion = preparedModelRuntimeEventVersion;
await runtime.refresh().then(
() => {
// A successful catch-up proves availability when publication completed before the
// listener was registered. Do not overwrite a newer invalidation or failure event.
if (preparedModelRuntimeEventVersion === eventVersion) {
preparedModelRuntimeState = "available";
}
},
(error: unknown) => {
if (!(error instanceof ChatMetadataSnapshotUnavailableError)) {
// Capture reached a published owner before this later metadata build failed. Keep
// stable auth/skill changes able to retry unless a newer owner event says otherwise.
if (preparedModelRuntimeEventVersion === eventVersion) {
preparedModelRuntimeState = "available";
}
},
(error: unknown) => {
if (!(error instanceof ChatMetadataSnapshotUnavailableError)) {
// Capture reached a published owner before this later metadata build failed. Keep
// stable auth/skill changes able to retry unless a newer owner event says otherwise.
if (preparedModelRuntimeEventVersion === eventVersion) {
preparedModelRuntimeState = "available";
}
params.log.warn(`chat metadata catch-up refresh failed: ${String(error)}`);
}
},
);
}
params.log.warn(`chat metadata catch-up refresh failed: ${String(error)}`);
}
},
);
},
read: runtime.read,
readModelsList: runtime.readModelsList,

View file

@ -14,7 +14,6 @@ import {
createCoreGatewayMethodDescriptors,
createGatewayMethodDescriptorsFromHandlers,
createGatewayMethodRegistry,
createPluginGatewayMethodDescriptors,
isCoreGatewayMethodClassified,
type GatewayMethodRegistry,
} from "./methods/registry.js";
@ -414,7 +413,7 @@ export async function startGatewayCoreRuntime(input: {
return createGatewayMethodRegistry(
[
...coreDescriptors,
...createPluginGatewayMethodDescriptors(nextPluginRegistry),
...nextPluginRegistry.gatewayMethodDescriptors,
...createGatewayMethodDescriptorsFromHandlers({
handlers: auxHandlers,
owner: { kind: "aux", area: "gateway-extra" },

View file

@ -37,7 +37,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
shutdownRuntime,
} = runtime;
const chatMetadataLifecycle = await startupTrace.measure("gateway.chat-metadata-lifecycle", () =>
createGatewayChatMetadataLifecycle({ getConfig: getRuntimeConfig, minimalTestGateway, log }),
createGatewayChatMetadataLifecycle({ getConfig: getRuntimeConfig, log }),
);
const configRevisionProjector = await startupTrace.measure(
"gateway.config-revision-key",

View file

@ -30,7 +30,6 @@ import {
createCoreGatewayMethodDescriptors,
createGatewayMethodDescriptorsFromHandlers,
createGatewayMethodRegistry,
createPluginGatewayMethodDescriptors,
isCoreGatewayMethodClassified,
type GatewayMethodRegistry,
} from "./methods/registry.js";
@ -100,7 +99,7 @@ export function createRequestGatewayMethodRegistry(
return createGatewayMethodRegistry(
[
...createCoreGatewayMethodDescriptors(coreDescriptorHandlers),
...(gatewayPluginRegistry ? createPluginGatewayMethodDescriptors(gatewayPluginRegistry) : []),
...(gatewayPluginRegistry?.gatewayMethodDescriptors ?? []),
...createGatewayMethodDescriptorsFromHandlers({
handlers: Object.fromEntries(auxHandlers),
owner: { kind: "aux", area: "gateway-extra" },

View file

@ -49,23 +49,18 @@ import { readGatewayRequestMutationAuthority } from "./session-mutation-guards.j
import type { GatewayRequestHandler, GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams, type Validator } from "./validation.js";
type ChannelOperationParams = {
channel?: unknown;
accountId?: unknown;
};
function resolveChannelOperationParams<TParams extends ChannelOperationParams>(params: {
function resolveChannelOperationParams(params: {
method: "channels.start" | "channels.stop" | "channels.logout";
rawParams: unknown;
respond: RespondFn;
validate: Validator<TParams>;
}): { params: TParams; channelId: ChannelId; plugin: ChannelPlugin } | null {
validate: Validator<ChannelsStartParams>;
}): { params: ChannelsStartParams; channelId: ChannelId; plugin: ChannelPlugin } | null {
const rawParams = params.rawParams;
if (!assertValidParams(rawParams, params.validate, params.method, params.respond)) {
return null;
}
const rawChannel = rawParams.channel;
const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
const channelId = normalizeChannelId(rawChannel);
if (!channelId) {
params.respond(
false,
@ -187,14 +182,6 @@ function channelStatusFailureMessage(value: unknown): string | null {
return record.error;
}
function resolveChannelsStatusTimeoutMs(params: { probe: boolean; timeoutMsRaw: unknown }): number {
const fallback = params.probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000;
if (typeof params.timeoutMsRaw !== "number" || !Number.isFinite(params.timeoutMsRaw)) {
return fallback;
}
return Math.min(Math.max(1000, params.timeoutMsRaw), CHANNEL_STATUS_MAX_TIMEOUT_MS);
}
async function startChannelAccount(params: ChannelAccountParams) {
if (!params.plugin.gateway?.startAccount) {
throw new Error(`Channel ${params.channelId} does not support runtime start`);
@ -263,12 +250,15 @@ export const channelsHandlers: GatewayRequestHandlers = {
return;
}
const probe = params.probe === true;
const timeoutMs = resolveChannelsStatusTimeoutMs({ probe, timeoutMsRaw: params.timeoutMs });
const timeoutMs = Math.min(
Math.max(1000, params.timeoutMs ?? (probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000)),
CHANNEL_STATUS_MAX_TIMEOUT_MS,
);
const rawChannel = params.channel;
const cfg = context.getRuntimeConfig();
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
const requestedChannel =
typeof rawChannel === "string"
rawChannel !== undefined
? (normalizeChannelId(rawChannel) ??
plugins.find((plugin) => plugin.id === rawChannel.trim().toLowerCase())?.id)
: undefined;

View file

@ -12,7 +12,6 @@ import { renderQrPngDataUrl } from "../../media/qr-image.js";
import { renderQrTerminal } from "../../media/qr-terminal.js";
import { trimTextPreservingCode } from "../../shared/text/text-projection.js";
import { stripInlineDirectiveTagsForDelivery } from "../../utils/directive-tags.js";
import { stripEnvelopeFromMessage } from "../chat-sanitize.js";
import { isSuppressedControlReplyText } from "../control-reply-text.js";
import {
buildManagedMediaFailureBlock,
@ -96,15 +95,9 @@ export function sanitizeAssistantDisplayText(
if (!value) {
return undefined;
}
const withoutEnvelope = stripEnvelopeFromMessage(value);
const normalized = typeof withoutEnvelope === "string" ? withoutEnvelope : value;
const stripped = stripInlineDirectiveTagsForDelivery(normalized);
const stripped = stripInlineDirectiveTagsForDelivery(value);
const visible = trimTextPreservingCode(stripped.text);
return visible
? options?.preserveBoundaries && !stripped.changed
? normalized
: visible
: undefined;
return visible ? (options?.preserveBoundaries && !stripped.changed ? value : visible) : undefined;
}
export function prepareAssistantDisplayText(
@ -114,12 +107,10 @@ export function prepareAssistantDisplayText(
if (!value) {
return undefined;
}
const withoutEnvelope = stripEnvelopeFromMessage(value);
const normalized = typeof withoutEnvelope === "string" ? withoutEnvelope : value;
return normalized.trim()
return value.trim()
? options?.preserveBoundaries
? normalized
: trimTextPreservingCode(normalized)
? value
: trimTextPreservingCode(value)
: undefined;
}

View file

@ -1,9 +1,6 @@
import type { Result } from "@openclaw/normalization-core/result";
import { containsAsciiControlCharacter } from "@openclaw/normalization-core/string-normalization";
import {
MAX_HUMAN_MENTIONS,
type HumanMention,
} from "../../../packages/gateway-protocol/src/index.js";
import type { HumanMention } from "../../../packages/gateway-protocol/src/index.js";
import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
const INVALID_MENTIONS = "Selected mentions no longer match the message. Select the people again.";
@ -23,9 +20,6 @@ export function normalizeChatHumanMentions(
if (!mentions?.length) {
return { ok: true, value: undefined };
}
if (mentions.length > MAX_HUMAN_MENTIONS) {
return { ok: false, error: INVALID_MENTIONS };
}
const leadingSpace = sanitizedText.length - sanitizedText.trimStart().length;
const trimmed = sanitizedText.trim();
const normalized: HumanMention[] = [];
@ -33,8 +27,6 @@ export function normalizeChatHumanMentions(
for (const mention of mentions) {
const token = text.slice(mention.start, mention.end);
if (
!Number.isSafeInteger(mention.start) ||
!Number.isSafeInteger(mention.end) ||
mention.start < previousEnd ||
mention.end > text.length ||
mention.end <= mention.start + 1 ||

View file

@ -73,7 +73,6 @@ describe("gateway chat metadata auth deadlines", () => {
const onChanged = vi.fn();
const harness = createChatMetadataHarness(config, {
useDefaultProjection: true,
refreshOnRead: false,
onChanged,
});
harness.setOwner(owner);

View file

@ -206,60 +206,56 @@ describe("gateway chat metadata refresh", () => {
},
);
test.each([false, true])(
"notifies settled catalog status without rebuilding metadata (beforeRefresh: %s)",
async (prepareBeforeRefresh) => {
const onChanged = vi.fn();
const harness = createChatMetadataHarness(undefined, {
onChanged,
...(prepareBeforeRefresh ? { beforeRefresh: async () => {} } : {}),
test("notifies settled catalog status without rebuilding metadata", async () => {
const onChanged = vi.fn();
const harness = createChatMetadataHarness(undefined, {
onChanged,
});
const owner = harness.getPreparedOwner()!;
const catalog = owner.modelCatalog;
try {
await harness.runtime.refresh();
const original = await harness.runtime.read({ agentId: "main" });
onChanged.mockClear();
catalog.pendingProviders = ["test"];
await harness.runtime.refresh();
expect(onChanged).not.toHaveBeenCalled();
catalog.pendingProviders = undefined;
// The settlement signal must survive joining an unrelated, already-pending refresh.
await Promise.all([
harness.runtime.refresh(),
harness.runtime.refresh({ notifyIfUnchanged: true }),
]);
expect(onChanged).toHaveBeenCalledExactlyOnceWith({
modelCatalogChanged: true,
authChanged: false,
});
const owner = harness.getPreparedOwner()!;
const catalog = owner.modelCatalog;
try {
await harness.runtime.refresh();
const original = await harness.runtime.read({ agentId: "main" });
onChanged.mockClear();
catalog.pendingProviders = ["test"];
await harness.runtime.refresh();
expect(onChanged).not.toHaveBeenCalled();
expect(await harness.runtime.read({ agentId: "main" })).toEqual(original);
expect(harness.getPreparedOwner()).toBe(owner);
expect(owner.modelCatalog).toBe(catalog);
expect(harness.buildCommands).toHaveBeenCalledOnce();
expect(harness.buildProjection).toHaveBeenCalledOnce();
await harness.runtime.refresh();
expect(onChanged).toHaveBeenCalledOnce();
catalog.pendingProviders = undefined;
// The settlement signal must survive joining an unrelated, already-pending refresh.
await Promise.all([
harness.runtime.refresh(),
harness.runtime.refresh({ notifyIfUnchanged: true }),
]);
expect(onChanged).toHaveBeenCalledExactlyOnceWith({
modelCatalogChanged: true,
authChanged: false,
});
expect(await harness.runtime.read({ agentId: "main" })).toEqual(original);
expect(harness.getPreparedOwner()).toBe(owner);
expect(owner.modelCatalog).toBe(catalog);
expect(harness.buildCommands).toHaveBeenCalledOnce();
expect(harness.buildProjection).toHaveBeenCalledOnce();
await harness.runtime.refresh();
expect(onChanged).toHaveBeenCalledOnce();
catalog.refreshFailed = true;
await Promise.all([
harness.runtime.refresh(),
harness.runtime.refresh({ notifyIfUnchanged: true }),
]);
expect(onChanged).toHaveBeenCalledTimes(2);
expect(onChanged).toHaveBeenLastCalledWith({
modelCatalogChanged: true,
authChanged: false,
});
await harness.runtime.read({ agentId: "main" });
expect(harness.buildCommands).toHaveBeenCalledTimes(2);
expect(harness.buildProjection).toHaveBeenCalledTimes(2);
} finally {
await harness.runtime.stop();
}
},
);
catalog.refreshFailed = true;
await Promise.all([
harness.runtime.refresh(),
harness.runtime.refresh({ notifyIfUnchanged: true }),
]);
expect(onChanged).toHaveBeenCalledTimes(2);
expect(onChanged).toHaveBeenLastCalledWith({
modelCatalogChanged: true,
authChanged: false,
});
await harness.runtime.read({ agentId: "main" });
expect(harness.buildCommands).toHaveBeenCalledTimes(2);
expect(harness.buildProjection).toHaveBeenCalledTimes(2);
} finally {
await harness.runtime.stop();
}
});
test.each([
{ settlement: "resolve", explicitInvalidation: true },
@ -335,7 +331,7 @@ describe("gateway chat metadata refresh", () => {
test.each(["resolve", "reject"] as const)(
"discards a late projection's %s after cooldown state changes",
async (settlement) => {
const harness = createChatMetadataHarness(undefined, { refreshOnRead: false });
const harness = createChatMetadataHarness();
const blocked: AuthProfileStore = {
version: 1,
profiles: { "test:session": { type: "api_key", provider: "test", key: "not-real" } },

View file

@ -153,8 +153,6 @@ export function createChatMetadataOwner(
export function createChatMetadataHarness(
initialConfig: OpenClawConfig = { agents: { entries: { main: {} } } },
runtimeOptions: {
beforeRefresh?: () => Promise<void>;
refreshOnRead?: boolean;
useDefaultProjection?: boolean;
onChanged?: () => void;
} = {},

View file

@ -20,50 +20,19 @@ import {
describe("gateway chat metadata runtime", () => {
test("retains an unsuperseded preparation failure without silently retrying", async () => {
const failure = new Error("metadata preparation failed");
const beforeRefresh = vi.fn(async () => {
const harness = createChatMetadataHarness();
harness.getPreparedOwner.mockImplementation(() => {
throw failure;
});
const harness = createChatMetadataHarness(undefined, { beforeRefresh });
try {
await expect(harness.runtime.refresh()).rejects.toBe(failure);
await expect(harness.runtime.read({ agentId: "main" })).rejects.toBe(failure);
expect(beforeRefresh).toHaveBeenCalledOnce();
expect(harness.getPreparedOwner).toHaveBeenCalledOnce();
} finally {
await harness.runtime.stop();
}
});
test("retires a superseded preparation failure while the replacement prepares fresh facts", async () => {
const entered = createDeferred();
const release = createDeferred();
const failure = new Error("obsolete metadata preparation failed");
const beforeRefresh = vi.fn(async () => {});
beforeRefresh.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
throw failure;
});
const harness = createChatMetadataHarness(undefined, { beforeRefresh });
const oldRefresh = harness.runtime.refresh();
void oldRefresh.catch(() => undefined);
let replacement: Promise<void> | undefined;
try {
await entered.promise;
harness.runtime.invalidate();
replacement = harness.runtime.refresh();
const completed = Promise.all([oldRefresh, replacement]);
release.resolve();
await expect(completed).resolves.toEqual([undefined, undefined]);
await expect(harness.runtime.read({ agentId: "main" })).resolves.toMatchObject({
models: [expect.objectContaining({ id: "first" })],
});
} finally {
release.resolve();
await harness.runtime.stop();
await Promise.allSettled([oldRefresh, replacement]);
}
});
test("notifies once per settlement, including same-epoch recovery, without an unavailable-read loop", async () => {
const onChanged = vi.fn();
const harness = createChatMetadataHarness(undefined, { onChanged });
@ -144,16 +113,18 @@ describe("gateway chat metadata runtime", () => {
},
);
test("refreshes lazily on the first read when configured", async () => {
const beforeRefresh = vi.fn(async () => {});
const harness = createChatMetadataHarness(undefined, { beforeRefresh, refreshOnRead: true });
test("prepares a projection lazily after its owner publishes", async () => {
const harness = createChatMetadataHarness();
await expect(harness.runtime.read({ agentId: "main" })).rejects.toThrow(
"prepared chat metadata snapshot is unavailable",
);
await harness.runtime.refresh();
expect(harness.buildProjection).not.toHaveBeenCalled();
await expect(harness.runtime.read({ agentId: "main" })).resolves.toMatchObject({
models: [expect.objectContaining({ id: "first" })],
});
expect(beforeRefresh).toHaveBeenCalledOnce();
expect(harness.buildProjection).toHaveBeenCalledOnce();
});
@ -245,7 +216,7 @@ describe("gateway chat metadata runtime", () => {
});
test("ready reads never prepare or await a cold or pending exact profile", async () => {
const harness = createChatMetadataHarness(undefined, { refreshOnRead: true });
const harness = createChatMetadataHarness();
const sessionEntry = {
authProfileOverride: "test:session",
authProfileOverrideSource: "user" as const,
@ -339,27 +310,32 @@ describe("gateway chat metadata runtime", () => {
},
);
test.each(["invalidate", "pending refresh", "failed", "stale facts"] as const)(
test.each(["invalidate", "pending projection", "failed", "stale auth"] as const)(
"ready reads omit projections after %s without starting replacement work",
async (state) => {
const beforeRefresh = vi.fn(async () => {});
const harness = createChatMetadataHarness(undefined, { refreshOnRead: true, beforeRefresh });
const harness = createChatMetadataHarness();
const sessionEntry = { authProfileOverride: "test:session" };
await harness.runtime.refresh();
await harness.runtime.readStartup({ agentId: "main", sessionEntry });
const release = createDeferred();
let refresh: Promise<void> | undefined;
let reading: Promise<unknown> | undefined;
if (state === "invalidate") {
harness.runtime.invalidate();
} else if (state === "failed") {
harness.runtime.fail(new Error("owner unavailable"));
} else if (state === "stale auth") {
harness.setAuthStoreRevision(2);
} else {
harness.setSkillsVersion(2);
if (state === "pending refresh") {
beforeRefresh.mockImplementationOnce(() => release.promise);
refresh = harness.runtime.refresh();
await vi.waitFor(() => expect(beforeRefresh).toHaveBeenCalledTimes(2));
}
const entered = createDeferred();
harness.buildCommands.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
return { commands: [] };
});
harness.runtime.invalidate();
await harness.runtime.refresh();
reading = harness.runtime.read({ agentId: "main" });
await entered.promise;
}
try {
for (const entry of [undefined, sessionEntry]) {
@ -374,7 +350,8 @@ describe("gateway chat metadata runtime", () => {
expect(harness.buildProjection).toHaveBeenCalledTimes(2);
} finally {
release.resolve();
await refresh;
await reading;
await harness.runtime.stop();
}
},
);

View file

@ -81,9 +81,7 @@ function assertPreparedAgentCurrent(agent: PreparedAgentMetadata) {
export function createGatewayChatMetadataRuntime(params: {
getConfig: () => OpenClawConfig;
getContext: () => GatewayModelCatalogContext;
beforeRefresh?: () => Promise<void>;
onChanged?: (change: { modelCatalogChanged: boolean; authChanged: boolean }) => void;
refreshOnRead?: boolean;
log: {
warn: (message: string) => void;
};
@ -151,7 +149,7 @@ export function createGatewayChatMetadataRuntime(params: {
};
let pending:
| {
facts?: PreparedGenerationFacts;
facts: PreparedGenerationFacts;
promise: Promise<void>;
generationReady: Deferred;
notifyIfUnchanged: boolean;
@ -287,10 +285,6 @@ export function createGatewayChatMetadataRuntime(params: {
}
assertOpen();
try {
await params.beforeRefresh?.();
if (version !== refreshVersion) {
return;
}
const facts = captureGenerationFacts(deps);
if (current && generationFactsMatch(current.facts, facts)) {
return;
@ -326,44 +320,37 @@ export function createGatewayChatMetadataRuntime(params: {
if (stoppedError) {
return Promise.reject(stoppedError);
}
let facts: PreparedGenerationFacts | undefined;
if (params.beforeRefresh) {
if (pending) {
pending.notifyIfUnchanged ||= options.notifyIfUnchanged === true;
return pending.promise;
}
} else {
try {
facts = captureGenerationFacts(deps);
} catch (error) {
const refreshError = error instanceof Error ? error : new Error(formatErrorMessage(error));
fail(refreshError);
return Promise.reject(refreshError);
}
if (current && generationFactsMatch(current.facts, facts)) {
// A settled attempt can clear progress cached by models.list or models.snapshot readers
// without changing any prepared model/command facts. Wake them without retiring the cache.
if (options.notifyIfUnchanged) {
notifyChanged(current.facts, true);
}
return Promise.resolve();
}
if (pending?.facts && generationFactsMatch(pending.facts, facts)) {
pending.notifyIfUnchanged ||= options.notifyIfUnchanged === true;
return pending.promise;
}
if (current || pending) {
// Fence reads synchronously only after proving the published facts changed. A suspended
// session projection must not return its old success or failure while replacement builds.
invalidate(true);
let facts: PreparedGenerationFacts;
try {
facts = captureGenerationFacts(deps);
} catch (error) {
const refreshError = error instanceof Error ? error : new Error(formatErrorMessage(error));
fail(refreshError);
return Promise.reject(refreshError);
}
if (current && generationFactsMatch(current.facts, facts)) {
// A settled attempt can clear progress cached by models.list or models.snapshot readers
// without changing any prepared model/command facts. Wake them without retiring the cache.
if (options.notifyIfUnchanged) {
notifyChanged(current.facts, true);
}
return Promise.resolve();
}
if (pending && generationFactsMatch(pending.facts, facts)) {
pending.notifyIfUnchanged ||= options.notifyIfUnchanged === true;
return pending.promise;
}
if (current || pending) {
// Fence reads synchronously only after proving the published facts changed. A suspended
// session projection must not return its old success or failure while replacement builds.
invalidate(true);
}
const version = ++refreshVersion;
const promise = refreshTail.catch(() => {}).then(() => runRefresh(version));
refreshTail = promise;
const generationReady = createDeferredCore();
pending = {
...(facts ? { facts } : {}),
facts,
promise,
generationReady,
notifyIfUnchanged: options.notifyIfUnchanged === true,
@ -431,7 +418,7 @@ export function createGatewayChatMetadataRuntime(params: {
// Unavailable means the prepared owner was missing, not that publication failed.
// Retry capture so a later published owner is not hidden behind lastError.
const retryUnavailableOwner = lastError instanceof ChatMetadataSnapshotUnavailableError;
if (!generation && (params.refreshOnRead || retryUnavailableOwner)) {
if (!generation && retryUnavailableOwner) {
await refresh();
generation = current;
}
@ -441,34 +428,10 @@ export function createGatewayChatMetadataRuntime(params: {
}
throw new ChatMetadataSnapshotUnavailableError();
}
if (!params.refreshOnRead && !authStoresCurrent(generation)) {
if (!authStoresCurrent(generation)) {
await refresh();
continue;
}
if (params.refreshOnRead) {
let latest: PreparedGenerationFacts | undefined;
try {
latest = captureGenerationFacts(deps);
} catch {
await refresh();
generation = current;
}
if (latest && generation && !generationFactsMatch(generation.facts, latest)) {
await refresh();
generation = current;
}
}
if (!generation) {
throw new ChatMetadataSnapshotUnavailableError();
}
if (params.refreshOnRead) {
const latest = captureGenerationFacts(deps);
if (!generationFactsMatch(generation.facts, latest)) {
throw new ChatMetadataSnapshotUnavailableError(
"prepared chat metadata snapshot is stale while its replacement is publishing",
);
}
}
try {
const readProjection = await project(generation);
// Lazy projections may outlive their generation. Never return an obsolete success after
@ -644,15 +607,6 @@ export function createGatewayChatMetadataRuntime(params: {
if (!generation || replacement || pending || !isCurrentGeneration(generation)) {
return undefined;
}
if (params.refreshOnRead) {
try {
if (!generationFactsMatch(generation.facts, captureGenerationFacts(deps))) {
return undefined;
}
} catch {
return undefined;
}
}
const agentId = normalizeAgentId(readParams.agentId);
const neutral = generation.neutralProjectionByAgentId.get(agentId);
const session = hasSessionContext

View file

@ -47,14 +47,14 @@ type ChatSendOriginatingRoute = {
};
export type ChatSendExplicitOrigin = {
originatingChannel?: string;
originatingTo?: string;
originatingChannel: string;
originatingTo: string;
accountId?: string;
messageThreadId?: string;
};
export function normalizeExplicitChatSendOrigin(
params: ChatSendExplicitOrigin,
params: Partial<ChatSendExplicitOrigin>,
): { ok: true; value?: ChatSendExplicitOrigin } | { ok: false; error: string } {
const originatingChannel = normalizeOptionalString(params.originatingChannel);
const originatingTo = normalizeOptionalString(params.originatingTo);
@ -99,7 +99,7 @@ export function resolveChatSendOriginatingRoute(params: {
mainKey?: string;
sessionKey: string;
}): ChatSendOriginatingRoute {
if (params.explicitOrigin?.originatingChannel && params.explicitOrigin.originatingTo) {
if (params.explicitOrigin) {
return {
originatingChannel: params.explicitOrigin.originatingChannel,
originatingTo: params.explicitOrigin.originatingTo,
@ -199,15 +199,14 @@ function isAcpSessionKey(sessionKey: string | undefined): boolean {
}
function resolveExplicitOriginBinding(origin: ChatSendExplicitOrigin | undefined) {
if (!origin?.originatingChannel || !origin.originatingTo || !origin.accountId) {
if (!origin?.accountId) {
return undefined;
}
const channel = normalizeMessageChannel(origin.originatingChannel);
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
if (origin.originatingChannel === INTERNAL_MESSAGE_CHANNEL) {
return undefined;
}
return getSessionBindingService().resolveByConversation({
channel,
channel: origin.originatingChannel,
accountId: origin.accountId,
conversationId: origin.originatingTo,
});
@ -226,14 +225,11 @@ export function explicitOriginTargetsPluginBinding(
}
export function normalizeOptionalChatSystemReceipt(
value: unknown,
value: string | undefined,
): { ok: true; receipt?: string } | { ok: false; error: string } {
if (value == null) {
return { ok: true };
}
if (typeof value !== "string") {
return { ok: false, error: "systemProvenanceReceipt must be a string" };
}
const sanitized = sanitizeChatSendMessageInput(value);
if (!sanitized.ok) {
return sanitized;

View file

@ -66,10 +66,7 @@ export function emitOperatorChatSendServerTiming(params: {
dispatchStartedAtMs?: number;
extra?: Record<string, string | number>;
}) {
const connId =
typeof params.client?.connId === "string" && params.client.connId.trim()
? params.client.connId.trim()
: undefined;
const connId = params.client?.connId?.trim();
if (!connId || !isOperatorUiClient(params.client?.connect?.client)) {
return;
}

View file

@ -7,9 +7,7 @@ export async function readPreparedServerMethodModelCatalog(
options?: { agentId?: string },
): Promise<PreparedGatewayModelCatalog | undefined> {
try {
return context.readPreparedGatewayModelCatalog
? await context.readPreparedGatewayModelCatalog(options)
: undefined;
return await context.readPreparedGatewayModelCatalog?.(options);
} catch {
// Catalog metadata decorates these responses; owner selection or lifecycle
// races must not make the primary roster/session RPC unavailable.

View file

@ -1,7 +1,4 @@
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
@ -230,7 +227,7 @@ export const pushHandlers = {
return;
}
const nodeId = normalizeStringifiedOptionalString(params.nodeId) ?? "";
const nodeId = normalizeOptionalString(params.nodeId) ?? "";
if (!nodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;

View file

@ -97,23 +97,6 @@ const datePartsToEndMs = (
return undefined;
};
// Invalid explicit dates must not fall through to the unrelated default range.
const findInvalidExplicitDate = (params: {
startDate?: unknown;
endDate?: unknown;
}): "startDate" | "endDate" | undefined => {
for (const field of ["startDate", "endDate"] as const) {
const raw = params[field];
if (raw === undefined || raw === null || (typeof raw === "string" && raw.trim() === "")) {
continue;
}
if (parseDateParts(raw) === undefined) {
return field;
}
}
return undefined;
};
/**
* Parse a UTC offset string in the format UTC+H, UTC-H, UTC+HH, UTC-HH, UTC+H:MM, UTC-HH:MM.
* Returns the UTC offset in minutes (east-positive), or undefined if invalid.
@ -266,12 +249,22 @@ export const resolveDateRange = (
},
resolvedInterpretation?: DateInterpretation,
): DateRangeResolution => {
const invalidDate = findInvalidExplicitDate(params);
if (invalidDate) {
return {
ok: false,
error: `invalid ${invalidDate}: expected a valid YYYY-MM-DD calendar date`,
};
const dates = {
startDate: parseDateParts(params.startDate),
endDate: parseDateParts(params.endDate),
};
for (const field of ["startDate", "endDate"] as const) {
const raw = params[field];
if (
raw != null &&
(typeof raw !== "string" || raw.trim() !== "") &&
dates[field] === undefined
) {
return {
ok: false,
error: `invalid ${field}: expected a valid YYYY-MM-DD calendar date`,
};
}
}
const now = new Date();
@ -288,8 +281,7 @@ export const resolveDateRange = (
return { ok: false, error: "calendar day does not exist in requested time zone" };
}
const startDateParts = parseDateParts(params.startDate);
const endDateParts = parseDateParts(params.endDate);
const { startDate: startDateParts, endDate: endDateParts } = dates;
// Explicit date windows are atomic. A single boundary must not silently
// fall through to the unrelated default 30-day range.
if ((startDateParts === undefined) !== (endDateParts === undefined)) {

View file

@ -248,7 +248,7 @@ export const usageHandlers: GatewayRequestHandlers = {
const visibilityIdentity = sessionCap && profileId ? `${profileId}:${sessionCap}` : undefined;
const { startMs, endMs, includeUntimestamped } = range;
const dayBucket = resolveDayBucket(dateInterpretation);
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const limit = p.limit ?? 50;
const includeContextWeight = p.includeContextWeight ?? false;
const creatorKey = normalizeOptionalString(p.creatorKey);
const specificKey = normalizeOptionalString(p.key) ?? null;

View file

@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
@ -120,7 +119,7 @@ export const wizardHandlers: GatewayRequestHandlers = {
runHostedWizard((runtime) =>
context.channelWizardRunner(
{
channel: readStringValue(params.channel),
channel: params.channel,
onConfigured: (accounts) => wizardSession.setConfiguredAccounts(accounts),
// Durable effects (plugin installs, config commit) must finish
// even if the client cancels mid-write.
@ -138,7 +137,7 @@ export const wizardHandlers: GatewayRequestHandlers = {
context.wizardRunner(
{
mode: params.mode,
workspace: readStringValue(params.workspace),
workspace: params.workspace,
installDaemon: params.installDaemon,
},
runtime,
@ -173,7 +172,7 @@ export const wizardHandlers: GatewayRequestHandlers = {
return;
}
try {
const validationError = await session.answer(answer.stepId ?? "", answer.value);
const validationError = await session.answer(answer.stepId, answer.value);
if (validationError) {
respond(
true,

View file

@ -442,9 +442,7 @@ export function createGatewaySubagentRuntime(
return { runId, sessionKey, ...(runtime ? { runtime } : {}) };
},
async waitForRun(params) {
const payload = await dispatchGatewayMethodInProcess<
Omit<AgentWaitResult, "status"> & { status?: string }
>(
const payload = await dispatchGatewayMethodInProcess<AgentWaitResult>(
"agent.wait",
{
runId: params.runId,
@ -452,16 +450,9 @@ export function createGatewaySubagentRuntime(
},
{ resolveGatewayContext },
);
const { status: rawStatus, error, ...metadata } = payload;
let status = rawStatus;
if (status === "completed" || status === "succeeded") {
status = "ok";
} else if (status === "error" && error?.trim().toLowerCase() === "completed") {
status = "ok";
}
if (status !== "ok" && status !== "error" && status !== "timeout" && status !== "pending") {
throw new Error(`Gateway agent.wait returned unexpected status: ${rawStatus}`);
}
const { status: waitStatus, error, ...metadata } = payload;
const status =
waitStatus === "error" && error?.trim().toLowerCase() === "completed" ? "ok" : waitStatus;
return {
...metadata,
status,

View file

@ -14,6 +14,7 @@ import * as questionChannel from "../infra/question-channel-runtime.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { createDeferredCore } from "../shared/deferred.js";
import { issueOperatorToken } from "./device-authz.test-helpers.js";
import { createPluginGatewayMethodDescriptor } from "./methods/descriptor.js";
import { observeHeldGatewayWorkDrain } from "./server-held-work.test-support.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
import { resetTestPluginRegistry, setTestPluginRegistry } from "./test-helpers.plugin-registry.js";
@ -37,6 +38,13 @@ async function startObserverGateway() {
captured.resolve(context);
respond(true, { captured: true });
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "observer-proof",
name: "test.observer-context",
handler: registry.gatewayHandlers["test.observer-context"],
}),
);
setTestPluginRegistry(registry);
let gateway: GatewayHarness | undefined;
let admin: WebSocket | undefined;

View file

@ -24,6 +24,7 @@ import {
import { getAsyncWorkSignal } from "../shared/async-work-scope.js";
import { createDeferredCore } from "../shared/deferred.js";
import * as agentJobs from "./agent-turn/agent-job.js";
import { createPluginGatewayMethodDescriptor } from "./methods/descriptor.js";
import { observeHeldGatewayWorkDrain } from "./server-held-work.test-support.js";
import {
getTestPluginRegistry,
@ -212,6 +213,13 @@ describe("public Gateway close request lifetime", () => {
handlerRuns.push(work);
return work;
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "lifetime-proof",
name: "test.lifetime",
handler: registry.gatewayHandlers["test.lifetime"],
}),
);
setTestPluginRegistry(registry);
let gateway: GatewayHarness | undefined;
let ws: WebSocket | undefined;
@ -410,6 +418,13 @@ describe("public Gateway close request lifetime", () => {
invoked++;
respond(true, { executed: true });
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "lifetime-proof",
name: "test.lifetime",
handler: registry.gatewayHandlers["test.lifetime"],
}),
);
registry.typedHooks.push({
pluginId: "lifetime-proof",
hookName: "gateway_stop",

View file

@ -16,6 +16,7 @@ import type { InternalSessionEntry, SessionDiffBaseline } from "../config/sessio
import { LegacyPluginSdkResourceHost } from "../plugins/legacy-sdk-resource-host.js";
import { ensureSessionDiffBaseline } from "../sessions/session-diff-baseline.js";
import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js";
import { AsyncWorkScope } from "../shared/async-work-scope.js";
import { createDeferredCore } from "../shared/deferred.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withLocalGatewayRequestScope } from "./local-request-context.js";
@ -53,22 +54,32 @@ afterEach(() => {
async function resetFromCaller(key: string, current: () => boolean) {
const { getRuntimeConfig } = await getGatewayConfigModule();
const resources = new LegacyPluginSdkResourceHost();
const work = new AsyncWorkScope();
// The RPC deadline does not cancel accepted work; authority tests control that clock.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
return await resources.run(() =>
withLocalGatewayRequestScope({ deps: {} as CliDeps, getRuntimeConfig }, () =>
withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:reset-requester",
operationalRunInstance: { instanceId: "reset-instance", runId: "reset-run" },
receiptAuthority: current,
},
() => callAgentToolGatewayRequest({ method: "sessions.reset", params: { key } }),
return await work.run(() =>
resources.run(() =>
withLocalGatewayRequestScope({ deps: {} as CliDeps, getRuntimeConfig }, () =>
withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:reset-requester",
operationalRunInstance: { instanceId: "reset-instance", runId: "reset-run" },
receiptAuthority: current,
},
() => callAgentToolGatewayRequest({ method: "sessions.reset", params: { key } }),
),
),
),
);
} finally {
await resources.close();
try {
await work.runWhenIdle(() => resources.close());
} finally {
vi.useRealTimers();
await work.drain();
}
}
}

View file

@ -20,6 +20,7 @@ import {
import { createDeferredCore, type Deferred } from "../../../shared/deferred.js";
import { acquireTestPortBlock } from "../../../test-utils/port-claims.js";
import type { AgentRuntimeIdentity } from "../../agent-runtime-identity-token.js";
import { createPluginGatewayMethodDescriptor } from "../../methods/descriptor.js";
import {
connectOk,
installGatewayTestHooks,
@ -488,6 +489,13 @@ describe("authenticated WebSocket request trace dispatch", () => {
observation.after = getActiveDiagnosticTraceContext();
respond(true, { traced: true });
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "request-dispatch-proof",
name: "test.trace",
handler: registry.gatewayHandlers["test.trace"],
}),
);
setTestPluginRegistry(registry);
const token = "gateway-request-trace-test-token";
@ -576,6 +584,13 @@ describe("authenticated WebSocket request trace dispatch", () => {
},
});
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "request-dispatch-proof",
name: "test.serialize",
handler: registry.gatewayHandlers["test.serialize"],
}),
);
setTestPluginRegistry(registry);
const token = "gateway-response-serialization-test-token";

View file

@ -11,6 +11,7 @@ import {
import { createEmptyPluginRegistry } from "../../../plugins/registry-empty.js";
import { createDeferredCore } from "../../../shared/deferred.js";
import { acquireTestPortBlock } from "../../../test-utils/port-claims.js";
import { createPluginGatewayMethodDescriptor } from "../../methods/descriptor.js";
import { MAX_PREAUTH_PAYLOAD_BYTES } from "../../server-constants.js";
import {
connectOk,
@ -133,6 +134,13 @@ describe("authenticated operator request starts", () => {
completed.push(req.id);
respond(true);
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "request-start-proof",
name: "test.trace",
handler: registry.gatewayHandlers["test.trace"],
}),
);
setTestPluginRegistry(registry);
const token = "gateway-operator-start-fairness-test-token";
let capture: ReturnType<typeof captureGatewayConnection> | undefined;
@ -222,6 +230,13 @@ describe("authenticated operator request starts", () => {
}
respond(true);
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "request-start-proof",
name: "test.trace",
handler: registry.gatewayHandlers["test.trace"],
}),
);
setTestPluginRegistry(registry);
const token = "gateway-operator-control-test-token";
const portClaim = await acquireTestPortBlock({ offsets: [0, 1, 2, 3, 4] });
@ -288,6 +303,13 @@ describe("authenticated operator request starts", () => {
}
respond(true);
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "request-start-proof",
name: "test.trace",
handler: registry.gatewayHandlers["test.trace"],
}),
);
setTestPluginRegistry(registry);
const token = "gateway-operator-close-test-token";
const portClaim = await acquireTestPortBlock({ offsets: [0, 1, 2, 3, 4] });
@ -405,6 +427,13 @@ describe("authenticated operator request starts", () => {
registry.gatewayHandlers["test.echo"] = async ({ req, respond }) => {
respond(true, { echoed: (req.params as { text: string }).text });
};
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "request-start-proof",
name: "test.echo",
handler: registry.gatewayHandlers["test.echo"],
}),
);
setTestPluginRegistry(registry);
const token = "gateway-frame-limit-test-token";
let capture: ReturnType<typeof captureGatewayConnection> | undefined;

View file

@ -203,9 +203,8 @@ function collectArtifactsFromMessage(
}
}
}
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
const block = asOptionalRecord(content[contentIndex]);
if (!block || !isArtifactBlock(block)) {
for (const [contentIndex, block] of content.entries()) {
if (!isArtifactBlock(block)) {
continue;
}
// Fallback titles participate in existing artifact IDs. Count omitted roles

View file

@ -852,12 +852,6 @@ export async function createGatewaySession(
: "trusted initial session state requires an authorized owner",
);
}
if (
params.initialEntry?.modelSelectionLocked !== undefined &&
!params.initialEntry.modelSelectionLocked
) {
return invalidSessionRequest("initial modelSelectionLocked must be true when provided");
}
const catalogResolvedModel = params.catalogTarget
? resolveSessionModelRef(params.cfg, patched.entry, target.agentId)
: undefined;

View file

@ -23,8 +23,8 @@ export function compareSessionEntryPairs(
return bPinnedAt - aPinnedAt;
}
}
const aTimestamp = sortBy === "activity" ? sessionActivityTimestamp(a[1]) : a[1]?.[sortBy];
const bTimestamp = sortBy === "activity" ? sessionActivityTimestamp(b[1]) : b[1]?.[sortBy];
const aTimestamp = sortBy === "activity" ? sessionActivityTimestamp(a[1]) : a[1][sortBy];
const bTimestamp = sortBy === "activity" ? sessionActivityTimestamp(b[1]) : b[1][sortBy];
const byTimestamp = (bTimestamp ?? 0) - (aTimestamp ?? 0);
if (byTimestamp !== 0) {
return byTimestamp;

View file

@ -112,9 +112,10 @@ import {
} from "./session-reset-acp.js";
import { deleteIncognitoSessionForReset } from "./session-reset-incognito.js";
import { notifyGatewaySessionReset } from "./session-reset-notifications.js";
import { resolveSessionResetTarget, resolveLifecycleAgentId } from "./session-reset-target.js";
import { resolveSessionResetTarget } from "./session-reset-target.js";
import { readGatewayBeforeResetPluginHookMessages } from "./session-reset-transcript.js";
import { loadSessionEntry, resolveGatewaySessionStoreTarget } from "./session-utils.js";
import type { GatewaySessionStoreTarget } from "./session-utils-store.types.js";
import { loadSessionEntry } from "./session-utils.js";
import type { SessionWorkerPlacementContext } from "./session-worker-placement-context.js";
import {
resolveSessionWorkerPlacementMutationError,
@ -123,14 +124,12 @@ import {
async function resetSessionAgentHarnesses(params: {
cfg: OpenClawConfig;
key: string;
target: { agentId?: string; canonicalKey?: string };
target: Pick<GatewaySessionStoreTarget, "agentId" | "canonicalKey">;
entry: SessionEntry;
reason: "reset" | "deleted";
assertCurrent?: () => void;
}): Promise<void> {
const agentId = resolveLifecycleAgentId(params.cfg, params.target.agentId);
const sessionKey = params.target.canonicalKey ?? params.key;
const { agentId, canonicalKey: sessionKey } = params.target;
params.assertCurrent?.();
await using owners = await acquireAgentRuntimeCleanupRegistries(
resolveAgentDir(params.cfg, agentId),
@ -208,7 +207,7 @@ export async function emitSessionUnboundLifecycleEvent(params: {
async function ensureSessionRuntimeCleanup(params: {
cfg: OpenClawConfig;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
target: GatewaySessionStoreTarget;
sessionId?: string;
sessionLifecycleRevision?: string;
assertCurrent?: () => void;
@ -242,7 +241,7 @@ async function ensureSessionRuntimeCleanup(params: {
await stopSessionResetSubagents({
cfg: params.cfg,
sessionKey: params.target.canonicalKey,
agentId: resolveLifecycleAgentId(params.cfg, params.target.agentId),
agentId: params.target.agentId,
assertCurrent,
});
} catch (error) {
@ -265,7 +264,7 @@ async function ensureSessionRuntimeCleanup(params: {
clearFinishedSessionsForScopes([...queueKeys, params.key]);
clearSessionResetRuntimeState(queueKeys, {
activeReplySessionId: params.sessionId,
agentId: resolveLifecycleAgentId(params.cfg, params.target.agentId),
agentId: params.target.agentId,
sessionKey: params.target.canonicalKey,
assertCurrent,
});
@ -369,7 +368,7 @@ async function ensureSessionRuntimeCleanup(params: {
export async function cleanupSessionBeforeMutation(params: {
cfg: OpenClawConfig;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
target: GatewaySessionStoreTarget;
entry: SessionEntry | undefined;
legacyKey?: string;
canonicalKey?: string;
@ -391,7 +390,7 @@ export async function cleanupSessionBeforeMutation(params: {
cfg: params.cfg,
registry: getActivePluginRegistry(),
reason: params.reason === "session-reset" ? "reset" : "delete",
sessionKey: params.target.canonicalKey ?? params.key,
sessionKey: params.target.canonicalKey,
// Unscoped keys can exist in several agent stores; this lifecycle owns only its target.
sessionStoreTargets: [params.target],
shouldCleanup: () => {
@ -405,7 +404,7 @@ export async function cleanupSessionBeforeMutation(params: {
`plugin host cleanup failed for ${failure.pluginId}/${failure.hookId}: ${String(failure.error)}`,
);
}
const parentSessionKey = params.target.canonicalKey ?? params.canonicalKey ?? params.key;
const parentSessionKey = params.target.canonicalKey;
const parentAcpError = await closeAcpRuntimeForSession({
cfg: params.cfg,
sessionKey: parentSessionKey,
@ -417,7 +416,7 @@ export async function cleanupSessionBeforeMutation(params: {
params.assertCurrent?.();
await closeChildAcpRuntimesForParent({
cfg: params.cfg,
parentKey: params.target.canonicalKey ?? params.canonicalKey ?? params.key,
parentKey: parentSessionKey,
parentAgentId: params.target.agentId,
reason: params.reason,
assertCurrent: params.assertCurrent,
@ -431,7 +430,6 @@ export async function cleanupSessionBeforeMutation(params: {
// store can expose a successor generation to a new turn.
await resetSessionAgentHarnesses({
cfg: params.cfg,
key: params.key,
target: params.target,
entry: params.entry,
reason: params.reason === "session-reset" ? "reset" : "deleted",
@ -446,7 +444,7 @@ export async function emitGatewayBeforeResetPluginHook(params: {
cfg: OpenClawConfig;
key: string;
messages?: unknown[];
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
target: GatewaySessionStoreTarget;
storePath: string;
entry?: SessionEntry;
reason: "new" | "reset";
@ -456,9 +454,9 @@ export async function emitGatewayBeforeResetPluginHook(params: {
return;
}
const sessionKey = params.target.canonicalKey ?? params.key;
const sessionKey = params.target.canonicalKey;
const sessionId = params.entry?.sessionId;
const agentId = resolveLifecycleAgentId(params.cfg, params.target.agentId);
const agentId = params.target.agentId;
const sessionFile = sessionId
? formatSqliteSessionFileMarker({ agentId, sessionId, storePath: params.storePath })
: undefined;
@ -840,7 +838,7 @@ export async function performGatewaySessionReset(params: {
? normalizeOptionalString(entry?.worktree?.id)
: undefined;
const resetLifecycleRevision = entry?.lifecycleRevision;
const agentId = resolveLifecycleAgentId(cfg, target.agentId);
const agentId = target.agentId;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const resetPluginRegistry = getActivePluginRegistry();
const isResetLifecycleCurrent = () => {
@ -852,20 +850,15 @@ export async function performGatewaySessionReset(params: {
}
};
let deferredAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined;
const hookEvent = createInternalHookEvent(
"command",
params.reason,
target.canonicalKey ?? params.key,
{
agentId,
sessionEntry: entry,
previousSessionEntry: entry,
commandSource: params.commandSource,
cfg,
storePath,
workspaceDir,
},
);
const hookEvent = createInternalHookEvent("command", params.reason, target.canonicalKey, {
agentId,
sessionEntry: entry,
previousSessionEntry: entry,
commandSource: params.commandSource,
cfg,
storePath,
workspaceDir,
});
await triggerInternalHook(hookEvent);
params.assertCurrent?.();
params.assertAuthorizedInstance?.();
@ -887,7 +880,7 @@ export async function performGatewaySessionReset(params: {
if (runtimeCleanupError) {
return { ok: false, error: runtimeCleanupError };
}
const parentSessionKey = target.canonicalKey ?? canonicalKey ?? params.key;
const parentSessionKey = target.canonicalKey;
const parentAcpError = await closeAcpRuntimeForSession({
cfg,
sessionKey: parentSessionKey,
@ -906,7 +899,7 @@ export async function performGatewaySessionReset(params: {
cfg,
registry: resetPluginRegistry,
reason: "reset",
sessionKey: target.canonicalKey ?? params.key,
sessionKey: target.canonicalKey,
skipPersistentSessionState: true,
});
for (const failure of pluginCleanup.failures) {
@ -916,14 +909,13 @@ export async function performGatewaySessionReset(params: {
}
await closeChildAcpRuntimesForParent({
cfg,
parentKey: target.canonicalKey ?? canonicalKey ?? params.key,
parentKey: parentSessionKey,
parentAgentId: target.agentId,
reason: "session-reset",
});
if (entry?.sessionId) {
await resetSessionAgentHarnesses({
cfg,
key: params.key,
target,
entry,
reason: "reset",
@ -931,10 +923,10 @@ export async function performGatewaySessionReset(params: {
}
const beforeResetMessages = getGlobalHookRunner()?.hasHooks("before_reset")
? await readGatewayBeforeResetPluginHookMessages({
agentId: resolveLifecycleAgentId(cfg, target.agentId ?? requestedAgentId),
agentId,
entry,
sessionId: entry?.sessionId,
sessionKey: target.canonicalKey ?? params.key,
sessionKey: target.canonicalKey,
storePath,
})
: undefined;
@ -1290,7 +1282,7 @@ export async function performGatewaySessionReset(params: {
});
},
() => {
const resetSessionKey = target.canonicalKey ?? params.key;
const resetSessionKey = target.canonicalKey;
handleSessionStateSessionReset(resetSessionKey);
notifyGatewaySessionReset(resetSessionKey, target.agentId);
emitGatewaySessionEndPluginHook({
@ -1329,7 +1321,7 @@ export async function performGatewaySessionReset(params: {
if (hadExistingEntry) {
postCommitActions.push(() =>
emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? params.key,
targetSessionKey: target.canonicalKey,
reason: "session-reset",
}),
);
@ -1356,11 +1348,11 @@ export async function performGatewaySessionReset(params: {
}
clearBootstrapSnapshotOnSessionBoundary({
boundaryAppended: resetBoundaryAppended,
sessionKey: target.canonicalKey ?? params.key,
sessionKey: target.canonicalKey,
});
if (createdNewEntry) {
await recordSessionCreated(cfg, {
sessionKey: target.canonicalKey ?? params.key,
sessionKey: target.canonicalKey,
agentId,
entry: mutation.nextEntry,
});

View file

@ -1,4 +1,4 @@
import { listAgentIds, resolveAmbientOwnerAgentId } from "../agents/agent-scope.js";
import { listAgentIds } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { resolveRequestedSessionAgentInput } from "./session-request-agent.js";
@ -40,7 +40,3 @@ export async function resolveSessionResetTarget(
});
return { ok: true as const, cfg, target, storePath: target.storePath, requestedAgentId };
}
export function resolveLifecycleAgentId(cfg: OpenClawConfig, agentId?: string): string {
return normalizeAgentId(agentId ?? resolveAmbientOwnerAgentId(cfg));
}

View file

@ -231,9 +231,8 @@ export function readStoreChildSessionLinks(params: {
}): SessionChildLink[] | undefined {
const children: SessionChildLink[] = [];
// One store pass discovers both persisted navigation and runtime-only controller links.
for (const key of Object.keys(params.store)) {
const entry = params.store[key];
if (!entry || key === params.key || !params.key) {
for (const [key, entry] of Object.entries(params.store)) {
if (key === params.key || !params.key) {
continue;
}
const runs = params.subagentRunsByChildSessionKey.get(key.trim()) ?? [];

View file

@ -130,7 +130,7 @@ export function readSessionRowInputs(params: {
modelCatalog: params.modelCatalog,
lightweightListRow: lightweight,
});
const freshSessionTotalTokens = asNonNegativeFiniteNumber(resolveFreshSessionTotalTokens(entry));
const freshSessionTotalTokens = resolveFreshSessionTotalTokens(entry);
const usageByFallbackModel =
params.skipTranscriptUsageFallback !== true
? resolveTranscriptUsageFallbacks({

View file

@ -60,9 +60,8 @@ export async function startTalkRealtimeAgentConsult(
request.client?.connect?.scopes,
request.client,
);
let acknowledgedRunId: string | undefined;
const chatResponse = await new Promise<
{ ok: true; result: unknown } | { ok: false; error: ErrorShape } | undefined
return await new Promise<
{ ok: true; runId: string; idempotencyKey: string } | { ok: false; error: ErrorShape }
>((resolve) => {
let acknowledged = false;
const chatSendOptions = {
@ -104,43 +103,48 @@ export async function startTalkRealtimeAgentConsult(
},
respond: (ok: boolean, result?: unknown, error?: ErrorShape) => {
acknowledged = true;
if (ok && !terminalTalkChatSendAckError(result)) {
const candidateRunId = asNullableRecord(result)?.runId;
const runId = typeof candidateRunId === "string" ? candidateRunId : idempotencyKey;
try {
if (params.relaySessionId && params.connId) {
registerTalkRealtimeRelayAgentRun({
relaySessionId: params.relaySessionId,
connId: params.connId,
sessionKey: params.sessionTarget.canonicalKey,
runId,
callId: params.callId,
});
}
params.onRunStarted?.(runId);
acknowledgedRunId = runId;
} catch (registrationError) {
abortChatRunById(request.context, {
runId,
sessionKey: params.sessionTarget.canonicalKey,
stopReason: "voice session binding failed",
});
resolve({
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, formatForLog(registrationError)),
});
return;
}
const ackError = ok
? terminalTalkChatSendAckError(result)
: (error ?? errorShape(ErrorCodes.UNAVAILABLE, "chat.send failed without error"));
if (ackError) {
resolve({ ok: false, error: ackError });
return;
}
const candidateRunId = asNullableRecord(result)?.runId;
const runId = typeof candidateRunId === "string" ? candidateRunId : idempotencyKey;
try {
if (params.relaySessionId && params.connId) {
registerTalkRealtimeRelayAgentRun({
relaySessionId: params.relaySessionId,
connId: params.connId,
sessionKey: params.sessionTarget.canonicalKey,
runId,
callId: params.callId,
});
}
params.onRunStarted?.(runId);
resolve(
runId
? { ok: true, runId, idempotencyKey }
: {
ok: false,
error: errorShape(
ErrorCodes.UNAVAILABLE,
"chat.send did not acknowledge an active run",
),
},
);
} catch (registrationError) {
abortChatRunById(request.context, {
runId,
sessionKey: params.sessionTarget.canonicalKey,
stopReason: "voice session binding failed",
});
resolve({
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, formatForLog(registrationError)),
});
}
resolve(
ok
? { ok: true, result }
: {
ok: false,
error:
error ?? errorShape(ErrorCodes.UNAVAILABLE, "chat.send failed without error"),
},
);
},
} satisfies GatewayRequestHandlerOptions;
// Speech owns reusable history; keep consult scaffolding only in the lossless archive.
@ -152,7 +156,13 @@ export async function startTalkRealtimeAgentConsult(
void Promise.resolve(chatSendResult).then(
() => {
if (!acknowledged) {
resolve(undefined);
resolve({
ok: false,
error: errorShape(
ErrorCodes.UNAVAILABLE,
"chat.send did not return a realtime tool result",
),
});
}
},
(error: unknown) => {
@ -169,25 +179,4 @@ export async function startTalkRealtimeAgentConsult(
},
);
});
if (!chatResponse) {
return {
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, "chat.send did not return a realtime tool result"),
};
}
if (!chatResponse.ok) {
return { ok: false, error: chatResponse.error };
}
const terminalAckError = terminalTalkChatSendAckError(chatResponse.result);
if (terminalAckError) {
return { ok: false, error: terminalAckError };
}
if (!acknowledgedRunId) {
return {
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, "chat.send did not acknowledge an active run"),
};
}
return { ok: true, runId: acknowledgedRunId, idempotencyKey };
}

View file

@ -150,7 +150,7 @@ async function persistTestSessionConfig(): Promise<void> {
config = parsed.parsed as Record<string, unknown>;
}
} catch {
config = {};
// Fixture setup accepts absent or malformed files.
}
parsedConfigs.set(configPath, config);
const session =
@ -407,27 +407,16 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
});
await fs.mkdir(stateDir, { recursive: true });
}
if (options.uniqueConfigRoot) {
const suiteRoot = path.join(tempHome, ".openclaw-test-suite");
await fs.mkdir(suiteRoot, { recursive: true });
tempConfigRoot = path.join(suiteRoot, `case-${suiteConfigRootSeq++}`);
await fs.rm(tempConfigRoot, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 25,
});
await fs.mkdir(tempConfigRoot, { recursive: true });
} else {
tempConfigRoot = path.join(tempHome, ".openclaw-test");
await fs.rm(tempConfigRoot, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 25,
});
await fs.mkdir(tempConfigRoot, { recursive: true });
}
tempConfigRoot = options.uniqueConfigRoot
? path.join(tempHome, ".openclaw-test-suite", `case-${suiteConfigRootSeq++}`)
: path.join(tempHome, ".openclaw-test");
await fs.rm(tempConfigRoot, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 25,
});
await fs.mkdir(tempConfigRoot, { recursive: true });
setTestConfigRoot(tempConfigRoot);
tempControlUiRoot = path.join(tempHome, ".openclaw-test-control-ui");
await fs.rm(tempControlUiRoot, {
@ -442,7 +431,6 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
"<!doctype html><title>openclaw-test-control-ui</title>\n",
"utf-8",
);
setTestConfigRoot(tempConfigRoot);
resetConfigRuntimeState();
invalidateSessionSharingSnapshot();
resetTestPluginRegistry();
@ -506,13 +494,34 @@ export async function prepareGatewayReplyRuntimeForTest(options?: {
force?: boolean;
config?: OpenClawConfig;
}): Promise<void> {
if (
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY !== "1" ||
(!options?.force && gatewayReplyRuntimePrepared)
) {
if (process.env.OPENCLAW_TEST_MINIMAL_GATEWAY !== "1") {
return;
}
const config = publishGatewayTestConfig(options?.config);
if (!options?.force && gatewayReplyRuntimePrepared) {
const [
{ listAgentIds },
{ getPreparedModelCatalogOwnerSnapshot },
{ readAgentDatabaseAdmissionRefusal },
] = await Promise.all([
import("../agents/agent-scope-config.js"),
import("../agents/prepared-model-catalog.js"),
import("../state/agent-database-admission.js"),
]);
if (
listAgentIds(config).every(
(agentId) =>
readAgentDatabaseAdmissionRefusal(agentId) ||
getPreparedModelCatalogOwnerSnapshot({
agentId,
config,
allowGatewaySubagentBinding: true,
})?.isCurrent(),
)
) {
return;
}
}
const preparedRuntime = await import("../agents/prepared-model-runtime.js");
await preparedRuntime.refreshPreparedModelRuntimeSnapshots(config, {
gatewayLifecycle: true,
@ -629,8 +638,7 @@ const CONNECT_CHALLENGE_TRACKED_KEY = "__openclawTestConnectChallengeTracked";
type TrackedWs = WebSocket & Record<string, unknown>;
export function getTrackedConnectChallengeNonce(ws: WebSocket): string | undefined {
const tracked = (ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY];
return typeof tracked === "string" && tracked.trim().length > 0 ? tracked.trim() : undefined;
return normalizeOptionalString((ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY]);
}
export function trackConnectChallengeNonce(ws: WebSocket): void {
@ -938,25 +946,16 @@ export async function readConnectChallengeNonce(
(o) => o.type === "event" && o.event === "connect.challenge",
timeoutMs,
);
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
if (typeof nonce === "string" && nonce.trim().length > 0) {
(ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY] = nonce.trim();
return nonce.trim();
const nonce = normalizeOptionalString(evt.payload?.nonce);
if (nonce) {
(ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY] = nonce;
}
return undefined;
return nonce;
} catch {
return undefined;
}
}
function resolveAuthTokenForSignature(opts?: {
token?: string;
bootstrapToken?: string;
deviceToken?: string;
}) {
return opts?.token ?? opts?.bootstrapToken ?? opts?.deviceToken;
}
type ConnectReqClient = {
id: string;
displayName?: string;
@ -1102,11 +1101,7 @@ export async function connectReq(
const bootstrapToken = normalizeOptionalString(opts?.bootstrapToken);
const deviceToken = normalizeOptionalString(opts?.deviceToken);
const password = opts?.password ?? defaultPassword;
const authTokenForSignature = resolveAuthTokenForSignature({
token,
bootstrapToken,
deviceToken,
});
const authTokenForSignature = token ?? bootstrapToken ?? deviceToken;
const requestedScopes = Array.isArray(opts?.scopes)
? opts.scopes
: role === "operator"
@ -1251,7 +1246,7 @@ export async function rpcReq<T extends Record<string, unknown>>(
if (hasUnsyncedGatewayTestSessionConfig()) {
await persistTestSessionConfig();
}
if (method === "agent" || method === "chat.send") {
if (method === "agent" || method === "chat.send" || method === "chat.metadata") {
await prepareGatewayReplyRuntimeForTest();
}
const { randomUUID } = await import("node:crypto");

View file

@ -14,14 +14,14 @@ type WorkerInferenceFrameContext = {
seq: number;
};
const TERMINAL_ERROR_MESSAGES = new Map<WorkerInferenceErrorReason, string>([
["model-not-approved", "Model is not approved"],
["invalid-context", "Inference context is invalid"],
["epoch-mismatch", "Inference ownership changed"],
["session-not-attached", "Session is not attached"],
["provider-error", "Provider request failed"],
["cancelled", "Inference cancelled"],
]);
const TERMINAL_ERROR_MESSAGES: Record<WorkerInferenceErrorReason, string> = {
"model-not-approved": "Model is not approved",
"invalid-context": "Inference context is invalid",
"epoch-mismatch": "Inference ownership changed",
"session-not-attached": "Session is not attached",
"provider-error": "Provider request failed",
cancelled: "Inference cancelled",
};
export function terminalError(
reason: WorkerInferenceErrorReason,
@ -37,7 +37,7 @@ export function terminalError(
return {
type: "error",
reason,
message: errorMessage ?? TERMINAL_ERROR_MESSAGES.get(reason) ?? "Provider request failed",
message: errorMessage ?? TERMINAL_ERROR_MESSAGES[reason],
...(usage ? { usage } : {}),
};
}

View file

@ -43,19 +43,16 @@ import { WorkerRunnerCapacityError, WorkerRunnerUnavailableError } from "./tunne
import { boundedWorkerError } from "./worker-error.js";
export function nodeWorkerSpawnResultFromReceipt(
receipt: NodeWorkerSupervisorReceipt,
receipt: TerminalNodeWorkerSupervisorReceipt,
): SpawnResult {
if (isTerminalReceipt(receipt)) {
return {
stdout: receipt.state === "completed" ? receipt.resultJson : "",
stderr: receipt.state === "completed" ? "" : receipt.errorText,
code: receipt.state === "completed" ? 0 : 1,
signal: null,
killed: receipt.state === "cancelled" || receipt.state === "interrupted",
termination: "exit",
};
}
throw new Error("node worker launch returned without a terminal receipt");
return {
stdout: receipt.state === "completed" ? receipt.resultJson : "",
stderr: receipt.state === "completed" ? "" : receipt.errorText,
code: receipt.state === "completed" ? 0 : 1,
signal: null,
killed: receipt.state === "cancelled" || receipt.state === "interrupted",
termination: "exit",
};
}
const DEFAULT_RPC_TIMEOUT_MS = 30_000;

View file

@ -121,18 +121,6 @@ describe("isVolatileBackupPath", () => {
expect(isVolatileBackupPath(`${winStateDir}\\sessions\\..\\config.jsonl`, winPlan)).toBe(false);
});
it("matches tar filter paths when node-tar omits the leading slash", () => {
expect(
isVolatileBackupPath("opt/openclaw/state/agents/main/sessions/transcript.jsonl", plan),
).toBe(true);
expect(
isVolatileBackupPath(
"opt/openclaw/state/logs/config-audit.jsonl.migrated.raw.quarantined-copy",
plan,
),
).toBe(true);
});
it.runIf(process.platform !== "win32")(
"does not resolve a Windows anchor into a relative POSIX directory",
() => {

View file

@ -60,16 +60,6 @@ function isManagedBrowserSingletonPath(filePosix: string, stateDirPosix: string)
);
}
function filePathCandidates(input: string): string[] {
const normalized = normalizePosix(input);
if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
return [normalized];
}
// node-tar may pass absolute input paths to filters without the leading
// slash, even when the source list used absolute paths.
return [normalized, normalizePosix(`/${normalized}`)];
}
type VolatileFilterPlan = {
/** Canonical state directories the filter should treat as volatile anchors. */
stateDirs: string[];
@ -79,7 +69,7 @@ export function isVolatileBackupPath(absolutePath: string, plan: VolatileFilterP
if (!absolutePath) {
return false;
}
const candidates = filePathCandidates(absolutePath);
const filePosix = normalizePosix(absolutePath);
for (const stateDir of plan.stateDirs) {
if (!stateDir) {
@ -87,51 +77,49 @@ export function isVolatileBackupPath(absolutePath: string, plan: VolatileFilterP
}
const stateDirPosix = normalizePosix(stateDir);
for (const filePosix of candidates) {
if (
isUnder(filePosix, stateDirPosix) &&
isLegacyAuditMigrationBackupPath(filePosix, stateDirPosix)
) {
return true;
}
if (isManagedBrowserSingletonPath(filePosix, stateDirPosix)) {
return true;
}
for (const parts of [
["sandbox", "skills-workspaces"],
// Rebuildable bundles bridge open Control UI documents across updates.
["cache", "control-ui-assets"],
["tmp", "plugin-captures"],
]) {
if (isUnder(filePosix, path.posix.join(stateDirPosix, ...parts))) {
return true;
}
}
if (
hasExtension(filePosix, [".jsonl", ".log"]) &&
(isAgentSessionTranscriptPath(filePosix, stateDirPosix) ||
[["sessions"], ["cron", "runs"], ["logs"]].some((parts) =>
isUnder(filePosix, path.posix.join(stateDirPosix, ...parts)),
))
) {
return true;
}
for (const queueDir of ["delivery-queue", "session-delivery-queue"]) {
const queueRoot = path.posix.join(stateDirPosix, queueDir);
if (
isUnder(filePosix, stateDirPosix) &&
isLegacyAuditMigrationBackupPath(filePosix, stateDirPosix)
isUnder(filePosix, queueRoot) &&
hasExtension(filePosix, [".json", ".delivered", ".tmp"])
) {
return true;
}
if (isManagedBrowserSingletonPath(filePosix, stateDirPosix)) {
return true;
}
}
for (const parts of [
["sandbox", "skills-workspaces"],
// Rebuildable bundles bridge open Control UI documents across updates.
["cache", "control-ui-assets"],
["tmp", "plugin-captures"],
]) {
if (isUnder(filePosix, path.posix.join(stateDirPosix, ...parts))) {
return true;
}
}
if (
hasExtension(filePosix, [".jsonl", ".log"]) &&
(isAgentSessionTranscriptPath(filePosix, stateDirPosix) ||
[["sessions"], ["cron", "runs"], ["logs"]].some((parts) =>
isUnder(filePosix, path.posix.join(stateDirPosix, ...parts)),
))
) {
return true;
}
for (const queueDir of ["delivery-queue", "session-delivery-queue"]) {
const queueRoot = path.posix.join(stateDirPosix, queueDir);
if (
isUnder(filePosix, queueRoot) &&
hasExtension(filePosix, [".json", ".delivered", ".tmp"])
) {
return true;
}
}
if (isUnder(filePosix, stateDirPosix) && isTransientBackupPath(filePosix)) {
return true;
}
if (isUnder(filePosix, stateDirPosix) && isTransientBackupPath(filePosix)) {
return true;
}
}

View file

@ -322,7 +322,7 @@ async function discoverWideAreaViaTailnetDns(
return;
}
const budget = remainingMs();
if (budget <= 0 || !ip) {
if (budget <= 0) {
return;
}
try {
@ -497,26 +497,22 @@ export async function discoverGatewayBeacons(
if (!discover) {
return [];
}
try {
const perDomain = await Promise.allSettled(
domains.map((domain) => discover(domain, timeoutMs, run)),
const perDomain = await Promise.allSettled(
domains.map((domain) => discover(domain, timeoutMs, run)),
);
const discovered = perDomain.flatMap((result) =>
result.status === "fulfilled" ? result.value : [],
);
if (
platform === "darwin" &&
wideAreaDomain &&
domains.includes(wideAreaDomain) &&
!discovered.some((beacon) => beacon.domain === wideAreaDomain)
) {
const fallback = await discoverWideAreaViaTailnetDns(wideAreaDomain, timeoutMs, run).catch(
() => [],
);
const discovered = perDomain.flatMap((result) =>
result.status === "fulfilled" ? result.value : [],
);
if (
platform === "darwin" &&
wideAreaDomain &&
domains.includes(wideAreaDomain) &&
!discovered.some((beacon) => beacon.domain === wideAreaDomain)
) {
const fallback = await discoverWideAreaViaTailnetDns(wideAreaDomain, timeoutMs, run).catch(
() => [],
);
return [...discovered, ...fallback];
}
return discovered;
} catch {
return [];
return [...discovered, ...fallback];
}
return discovered;
}

View file

@ -1,5 +1,3 @@
// Interpreter inline-eval detection recognizes flags and positional program
// forms that execute command text without reading a script file.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeExecutableToken } from "../exec-wrapper-resolution.js";
@ -25,7 +23,6 @@ type InterpreterFlagSpec = {
names: readonly string[];
exactFlags: ReadonlySet<string>;
rawExactFlags?: ReadonlyMap<string, string>;
rawPrefixFlags?: readonly PrefixFlagSpec[];
abbreviatedFlags?: readonly AbbreviatedFlagSpec[];
joinedExactFlags?: ReadonlySet<string>;
joinedFlagDenyExact?: ReadonlySet<string>;
@ -91,7 +88,6 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
// gawk before 4.0 accepted "--s" for "--source"; modern releases reject it
// as ambiguous with "--sandbox", so the older executable case sets the floor.
abbreviatedFlags: [{ label: "--source", full: "--source", min: "--s" }],
prefixFlags: [{ label: "--source", prefix: "--source=" }],
},
{
names: ["ruby"],
@ -257,22 +253,15 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
names: ["make", "gmake"],
exactFlags: new Set(["-f", "--file", "--makefile", "--eval"]),
rawExactFlags: new Map([["-E", "-E"]]),
rawPrefixFlags: [{ label: "-E", prefix: "-E" }],
// GNU make keeps "--e" ambiguous with "--environment-overrides";
// "--ev" is the shortest unique spelling of "--eval".
abbreviatedFlags: [{ label: "--eval", full: "--eval", min: "--ev" }],
prefixFlags: [
{ label: "-f", prefix: "-f" },
{ label: "--file", prefix: "--file=" },
{ label: "--makefile", prefix: "--makefile=" },
{ label: "--eval", prefix: "--eval=" },
],
prefixFlags: [{ label: "-f", prefix: "-f" }],
},
{
names: ["sed", "gsed"],
exactFlags: new Set(),
rawExactFlags: new Map([["-e", "-e"]]),
rawPrefixFlags: [{ label: "-e", prefix: "-e" }],
},
];
@ -466,12 +455,7 @@ export function detectInterpreterInlineEvalArgv(
}
break;
}
const rawFlag =
spec.rawExactFlags?.get(token) ||
matchJoinedRawExactFlag(spec, token) ||
spec.rawPrefixFlags?.find(
({ prefix }) => token.startsWith(prefix) && token.length > prefix.length,
)?.label;
const rawFlag = spec.rawExactFlags?.get(token) || matchJoinedRawExactFlag(spec, token);
if (rawFlag) {
return createInlineEvalHit(executable, argv, rawFlag);
}

View file

@ -28,9 +28,11 @@ import type {
SourceSpan,
} from "./types.js";
type RecordedCommandStep = CommandStep & { id: string };
type MutableExplanation = {
shapes: Set<CommandShape>;
commands: CommandStep[];
commands: RecordedCommandStep[];
operatorSources: OperatorSource[];
risks: CommandRisk[];
hasParseError: boolean;
@ -79,7 +81,7 @@ const ROOT_SPAN_BASE: SpanBase = {};
type CommandTopologyBucket = {
context: CommandContext;
parentCommandId?: string;
commands: CommandStep[];
commands: RecordedCommandStep[];
};
type OperatorSource = {
@ -405,11 +407,7 @@ function decodeAnsiCStringWithOffsets(text: string): DecodedShellText {
function hasDynamicWordPart(root: TreeSitterNode): boolean {
const pending = [root];
while (pending.length > 0) {
const node = pending.pop();
if (!node) {
break;
}
for (let node = pending.pop(); node; node = pending.pop()) {
if (DYNAMIC_WORD_NODE_TYPES.has(node.type)) {
return true;
}
@ -551,11 +549,7 @@ function appendTestCommandArguments(
state: WalkState,
): void {
const pending = [root];
while (pending.length > 0) {
const node = pending.pop();
if (!node) {
break;
}
for (let node = pending.pop(); node; node = pending.pop()) {
if (node.type === "test_operator" || COMMAND_ARGUMENT_NODE_TYPES.has(node.type)) {
appendCommandArgument(node, parsed, state);
continue;
@ -899,7 +893,7 @@ async function visitNode(
});
} else if (parsed) {
const commandId = `command-${output.commands.length}`;
const step: CommandStep = {
const step: RecordedCommandStep = {
id: commandId,
context,
executable: parsed.argv[0] ?? "",
@ -976,11 +970,7 @@ async function walk(
// Shell syntax is model-controlled, so keep depth-first traversal off the call stack.
const pending: WalkFrame[] = [{ node: root, context: rootContext, state: rootState }];
while (pending.length > 0) {
const frame = pending.pop();
if (!frame) {
break;
}
for (let frame = pending.pop(); frame; frame = pending.pop()) {
const { node, context, state } = frame;
const childContext = await visitNode(node, output, context, state);
for (let index = node.namedChildren.length - 1; index >= 0; index -= 1) {
@ -996,12 +986,9 @@ function commandBucketKey(command: CommandStep): string {
return `${command.context}\0${command.parentCommandId ?? ""}`;
}
function commandTopologyBuckets(commands: CommandStep[]): CommandTopologyBucket[] {
function commandTopologyBuckets(commands: RecordedCommandStep[]): CommandTopologyBucket[] {
const buckets = new Map<string, CommandTopologyBucket>();
for (const command of commands) {
if (!command.id) {
continue;
}
const key = commandBucketKey(command);
const bucket = buckets.get(key);
if (bucket) {
@ -1043,14 +1030,11 @@ function operatorSourceForBucket(
function commandSourceRanges(
source: string,
commands: readonly CommandStep[],
commands: readonly RecordedCommandStep[],
): Map<string, CommandSourceRange> | null {
const ranges = new Map<string, CommandSourceRange>();
let cursor = 0;
for (const command of commands) {
if (!command.id) {
return null;
}
const startIndex = source.indexOf(command.text, cursor);
if (startIndex < 0) {
return null;
@ -1091,7 +1075,7 @@ function topologyOperatorFromSeparator(
function resolveOperators(
source: string,
commands: CommandStep[],
commands: RecordedCommandStep[],
operatorSources: readonly OperatorSource[],
): CommandOperator[] {
const operators: CommandOperator[] = [];
@ -1101,11 +1085,10 @@ function resolveOperators(
const bucketRanges = bucketOperatorSource
? commandSourceRanges(bucketOperatorSource.source, bucket.commands)
: null;
for (let index = 0; index < bucket.commands.length - 1; index += 1) {
const fromCommand = bucket.commands[index];
for (const [index, fromCommand] of bucket.commands.entries()) {
const toCommand = bucket.commands[index + 1];
if (!fromCommand?.id || !toCommand?.id) {
continue;
if (!toCommand) {
break;
}
let separatorSource = source;
let separatorStart = fromCommand.span.endIndex;

View file

@ -272,14 +272,6 @@ export function createExecApprovalChannelRuntime<
replayPromise = promise;
};
const waitForPendingApprovalReplay = async (): Promise<void> => {
const replay = replayPromise;
if (!replay) {
return;
}
await replay.catch(() => {});
};
return {
async start(): Promise<void> {
if (started) {
@ -407,7 +399,7 @@ export function createExecApprovalChannelRuntime<
gatewayRuntime = undefined;
gatewayClient?.stop();
gatewayClient = null;
await waitForPendingApprovalReplay();
await replayPromise?.catch(() => {});
if (!wasActive) {
await adapter.onStopped?.();
return;

View file

@ -1,4 +1,3 @@
// Evaluates exec approval allowlists and safe-bin usage.
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
@ -738,20 +737,16 @@ function evaluateAuthorizationCandidate(params: {
}
function evaluateAuthorizationPlan(params: {
plan: ExecAuthorizationPlan;
plan: Extract<ExecAuthorizationPlan, { ok: true }>;
context: ExecAllowlistContext;
}): ExecAllowlistAnalysis {
const result: ExecAllowlistAnalysis = {
...emptyExecAllowlistEvaluation(),
analysisOk: params.plan.ok,
allowlistSatisfied: params.plan.ok,
analysisOk: true,
allowlistSatisfied: true,
segments: [],
authorizationPlan: params.plan,
};
if (!params.plan.ok) {
return result;
}
const skillBins = params.context.skillBins ?? [];
const allowSkills = params.context.autoAllowSkills === true && skillBins.length > 0;
const skillBinTrust = buildSkillBinTrustIndex(skillBins);
@ -1236,38 +1231,6 @@ export function resolveAllowAlwaysPatternEntries(params: {
return patterns;
}
/**
* Evaluates allowlist for shell commands (including &&, ||, ;) and returns analysis metadata.
*/
function evaluateShellAllowlist(
params: {
command: string;
env?: NodeJS.ProcessEnv;
} & ExecAllowlistContext,
): ExecAllowlistAnalysis {
const analysisFailure = (): ExecAllowlistAnalysis => ({
analysisOk: false,
...emptyExecAllowlistEvaluation(),
segments: [],
});
// Keep allowlist analysis conservative: line-continuation semantics are shell-dependent
// and can rewrite token boundaries at runtime.
if (hasShellLineContinuation(params.command) || !isWindowsPlatform(params.platform)) {
return analysisFailure();
}
const analysis = analyzeWindowsShellCommand(params);
if (!analysis.ok) {
return analysisFailure();
}
return {
analysisOk: true,
...evaluateExecAllowlist({ ...params, analysis, allowShellBuiltins: true }),
segments: analysis.segments,
};
}
export async function evaluateShellAllowlistWithAuthorization(
params: {
command: string;
@ -1275,7 +1238,18 @@ export async function evaluateShellAllowlistWithAuthorization(
} & ExecAllowlistContext,
): Promise<ExecAllowlistAnalysis> {
if (isWindowsPlatform(params.platform)) {
return evaluateShellAllowlist(params);
// Shell line continuations can rewrite token boundaries at runtime.
const analysis = hasShellLineContinuation(params.command)
? undefined
: analyzeWindowsShellCommand(params);
if (!analysis?.ok) {
return { analysisOk: false, ...emptyExecAllowlistEvaluation(), segments: [] };
}
return {
analysisOk: true,
...evaluateExecAllowlist({ ...params, analysis, allowShellBuiltins: true }),
segments: analysis.segments,
};
}
const allowlistContext = { ...params, allowShellBuiltins: true };
const authorizationPlan = await planShellAuthorization({ ...params });

Some files were not shown because too many files have changed in this diff Show more