mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor: remove 3,543 lines of redundant runtime and tests (#115961)
* refactor: remove 3,543 lines of redundant runtime and tests * refactor: ratchet production environment variable budget
This commit is contained in:
committed by
GitHub
parent
190307137a
commit
5bfc65d7f4
@@ -144,7 +144,6 @@ extensions/matrix/src/matrix/monitor/handler.test.ts
|
||||
extensions/matrix/src/matrix/sdk.test.ts
|
||||
extensions/matrix/src/matrix/sdk/verification-manager.ts
|
||||
extensions/matrix/src/matrix/send.test.ts
|
||||
extensions/matrix/src/onboarding.ts
|
||||
extensions/mattermost/src/channel.test.ts
|
||||
extensions/mattermost/src/channel.ts
|
||||
extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts
|
||||
@@ -519,7 +518,6 @@ src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
|
||||
src/auto-reply/reply/commands-acp.test.ts
|
||||
src/auto-reply/reply/commands-approve.test.ts
|
||||
src/auto-reply/reply/commands-models.ts
|
||||
src/auto-reply/reply/commands-session.ts
|
||||
src/auto-reply/reply/commands-status.test.ts
|
||||
src/auto-reply/reply/directive-handling.impl.ts
|
||||
src/auto-reply/reply/directive-handling.model.test.ts
|
||||
|
||||
@@ -225,27 +225,16 @@ async function buildTimeoutRecallResult(params: {
|
||||
: undefined;
|
||||
const searchDebug =
|
||||
params.searchDebug ?? subagentPartialData.searchDebug ?? transcriptState?.searchDebug;
|
||||
if (
|
||||
const cannotUsePartial =
|
||||
summary.length === 0 ||
|
||||
isUnavailableMemorySearchDebug(searchDebug) ||
|
||||
!subagentPartialData.settled ||
|
||||
params.hasUnavailableMemorySearchResult ||
|
||||
subagentPartialData.hasUnavailableMemorySearchResult ||
|
||||
transcriptState?.hasUnavailableMemorySearchResult
|
||||
) {
|
||||
return {
|
||||
status: "timeout",
|
||||
elapsedMs: params.elapsedMs,
|
||||
summary: null,
|
||||
searchDebug,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "timeout_partial",
|
||||
elapsedMs: params.elapsedMs,
|
||||
summary,
|
||||
searchDebug,
|
||||
};
|
||||
transcriptState?.hasUnavailableMemorySearchResult;
|
||||
return cannotUsePartial
|
||||
? { status: "timeout", elapsedMs: params.elapsedMs, summary: null, searchDebug }
|
||||
: { status: "timeout_partial", elapsedMs: params.elapsedMs, summary, searchDebug };
|
||||
}
|
||||
|
||||
function buildSubagentRecallResult(params: {
|
||||
@@ -261,39 +250,18 @@ function buildSubagentRecallResult(params: {
|
||||
const hasUsableMemoryResult =
|
||||
params.subagentResult.hasUsableMemoryResult === true ||
|
||||
params.fallbackHasUsableMemoryResult === true;
|
||||
const hasUnavailableMemorySearchResult =
|
||||
params.subagentResult.hasUnavailableMemorySearchResult === true;
|
||||
const canUseSummary = hasUsableMemoryResult;
|
||||
return summary.length > 0 && canUseSummary
|
||||
? {
|
||||
status: "ok",
|
||||
elapsedMs: params.elapsedMs,
|
||||
rawReply,
|
||||
summary,
|
||||
searchDebug,
|
||||
}
|
||||
: resultStatus === "failed"
|
||||
? {
|
||||
status: "failed",
|
||||
elapsedMs: params.elapsedMs,
|
||||
summary: null,
|
||||
searchDebug,
|
||||
}
|
||||
if (summary.length > 0 && hasUsableMemoryResult) {
|
||||
return { status: "ok", elapsedMs: params.elapsedMs, rawReply, summary, searchDebug };
|
||||
}
|
||||
const status =
|
||||
resultStatus === "failed"
|
||||
? "failed"
|
||||
: resultStatus === "unavailable" ||
|
||||
isUnavailableMemorySearchDebug(searchDebug) ||
|
||||
hasUnavailableMemorySearchResult
|
||||
? {
|
||||
status: "unavailable",
|
||||
elapsedMs: params.elapsedMs,
|
||||
summary: null,
|
||||
searchDebug,
|
||||
}
|
||||
: {
|
||||
status: "no_relevant_memory",
|
||||
elapsedMs: params.elapsedMs,
|
||||
summary: null,
|
||||
searchDebug,
|
||||
};
|
||||
params.subagentResult.hasUnavailableMemorySearchResult === true
|
||||
? "unavailable"
|
||||
: "no_relevant_memory";
|
||||
return { status, elapsedMs: params.elapsedMs, summary: null, searchDebug };
|
||||
}
|
||||
|
||||
function resetActiveMemoryTranscriptForTests(): void {
|
||||
|
||||
@@ -175,25 +175,21 @@ async function streamActiveMemoryTranscriptRecords(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveToolResultMessage(value: unknown): Record<string, unknown> | undefined {
|
||||
const record = asRecord(value);
|
||||
const message = asRecord(record?.message) ?? (record?.role === "toolResult" ? record : undefined);
|
||||
return message && normalizeOptionalString(message.role) === "toolResult" ? message : undefined;
|
||||
}
|
||||
|
||||
function extractActiveMemorySearchDebugFromSessionRecord(
|
||||
value: unknown,
|
||||
): ActiveMemorySearchDebug | undefined {
|
||||
const record = asRecord(value);
|
||||
const nestedMessage = asRecord(record?.message);
|
||||
const recordToolName = normalizeLowercaseStringOrEmpty(record?.toolName);
|
||||
const topLevelMessage =
|
||||
record?.role === "toolResult" ||
|
||||
recordToolName === "memory_search" ||
|
||||
recordToolName === "memory_recall"
|
||||
? record
|
||||
: undefined;
|
||||
const message = nestedMessage ?? topLevelMessage;
|
||||
const message = resolveToolResultMessage(value);
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
const role = normalizeOptionalString(message.role);
|
||||
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
|
||||
if (role !== "toolResult" || (toolName !== "memory_search" && toolName !== "memory_recall")) {
|
||||
if (toolName !== "memory_search" && toolName !== "memory_recall") {
|
||||
return undefined;
|
||||
}
|
||||
const details = asRecord(message.details);
|
||||
@@ -221,16 +217,12 @@ function extractActiveMemorySearchDebugFromSessionRecord(
|
||||
}
|
||||
|
||||
function extractToolResultNameFromSessionRecord(value: unknown): string | undefined {
|
||||
const record = asRecord(value);
|
||||
const nestedMessage = asRecord(record?.message);
|
||||
const topLevelMessage = record?.role === "toolResult" ? record : undefined;
|
||||
const message = nestedMessage ?? topLevelMessage;
|
||||
const message = resolveToolResultMessage(value);
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
const role = normalizeOptionalString(message.role);
|
||||
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
|
||||
return role === "toolResult" && toolName ? toolName : undefined;
|
||||
return toolName || undefined;
|
||||
}
|
||||
|
||||
function hasUnavailableMemoryResultInSessionRecord(
|
||||
@@ -240,11 +232,8 @@ function hasUnavailableMemoryResultInSessionRecord(
|
||||
...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW,
|
||||
],
|
||||
): boolean {
|
||||
const record = asRecord(value);
|
||||
const nestedMessage = asRecord(record?.message);
|
||||
const topLevelMessage = record?.role === "toolResult" ? record : undefined;
|
||||
const message = nestedMessage ?? topLevelMessage;
|
||||
if (!message || normalizeOptionalString(message.role) !== "toolResult") {
|
||||
const message = resolveToolResultMessage(value);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
|
||||
@@ -263,11 +252,8 @@ function hasTerminalUnavailableMemoryResultInSessionRecord(
|
||||
value: unknown,
|
||||
toolsAllow: readonly string[],
|
||||
): boolean {
|
||||
const record = asRecord(value);
|
||||
const nestedMessage = asRecord(record?.message);
|
||||
const topLevelMessage = record?.role === "toolResult" ? record : undefined;
|
||||
const message = nestedMessage ?? topLevelMessage;
|
||||
if (!message || normalizeOptionalString(message.role) !== "toolResult") {
|
||||
const message = resolveToolResultMessage(value);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
|
||||
@@ -328,17 +314,8 @@ function hasUsableMemoryResultInSessionRecord(
|
||||
...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW,
|
||||
],
|
||||
): boolean {
|
||||
const record = asRecord(value);
|
||||
const nestedMessage = asRecord(record?.message);
|
||||
const recordToolName = normalizeLowercaseStringOrEmpty(record?.toolName);
|
||||
const topLevelMessage =
|
||||
record?.role === "toolResult" ||
|
||||
recordToolName === "memory_search" ||
|
||||
recordToolName === "memory_recall"
|
||||
? record
|
||||
: undefined;
|
||||
const message = nestedMessage ?? topLevelMessage;
|
||||
if (!message || normalizeOptionalString(message.role) !== "toolResult") {
|
||||
const message = resolveToolResultMessage(value);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
|
||||
|
||||
@@ -109,30 +109,27 @@ function canonicalizeKnownClaudeCliModelId(modelId: string): string | null {
|
||||
}
|
||||
|
||||
function upgradeOldClaudeModelId(normalized: string): string | null {
|
||||
if (hasRetiredVersionPrefix(normalized, "claude-opus-5")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-opus-4-8") || normalized.startsWith("claude-opus-4.8")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-opus-4-7") || normalized.startsWith("claude-opus-4.7")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-opus-4-6") || normalized.startsWith("claude-opus-4.6")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-sonnet-4-6") || normalized.startsWith("claude-sonnet-4.6")) {
|
||||
return null;
|
||||
}
|
||||
// claude-haiku-4-5 is a current production model and must not be migrated.
|
||||
if (normalized.startsWith("claude-haiku-4-5") || normalized.startsWith("claude-haiku-4.5")) {
|
||||
// Current Claude families, including Haiku, must never be migrated.
|
||||
if (
|
||||
hasRetiredVersionPrefix(normalized, "claude-opus-5") ||
|
||||
[
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4.6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4.5",
|
||||
].some((prefix) => normalized.startsWith(prefix))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalized === "claude-opus-4" ||
|
||||
hasAnyRetiredVersionPrefix(normalized, [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4.5",
|
||||
"claude-opus-4-1",
|
||||
@@ -167,24 +164,21 @@ function upgradeOldClaudeModelId(normalized: string): string | null {
|
||||
) {
|
||||
return "claude-sonnet-4-6";
|
||||
}
|
||||
if (
|
||||
normalized === "opus-4.5" ||
|
||||
normalized === "opus-4.1" ||
|
||||
normalized === "opus-4" ||
|
||||
normalized === "opus-3"
|
||||
) {
|
||||
if (["opus-4.5", "opus-4.1", "opus-4", "opus-3"].includes(normalized)) {
|
||||
return "claude-opus-5";
|
||||
}
|
||||
if (
|
||||
normalized === "sonnet-4.5" ||
|
||||
normalized === "sonnet-4.1" ||
|
||||
normalized === "sonnet-4.0" ||
|
||||
normalized === "sonnet-4" ||
|
||||
normalized === "sonnet-3.7" ||
|
||||
normalized === "sonnet-3.5" ||
|
||||
normalized === "sonnet-3" ||
|
||||
normalized === "haiku-3.5" ||
|
||||
normalized === "haiku-3"
|
||||
[
|
||||
"sonnet-4.5",
|
||||
"sonnet-4.1",
|
||||
"sonnet-4.0",
|
||||
"sonnet-4",
|
||||
"sonnet-3.7",
|
||||
"sonnet-3.5",
|
||||
"sonnet-3",
|
||||
"haiku-3.5",
|
||||
"haiku-3",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return "claude-sonnet-4-6";
|
||||
}
|
||||
|
||||
@@ -208,61 +208,32 @@ function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown>
|
||||
return base;
|
||||
}
|
||||
|
||||
function collectClaudeCliRuntimeRefs(
|
||||
model: string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
): string[] {
|
||||
const refs = new Set<string>();
|
||||
if (typeof model === "string") {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(model)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
if (typeof model?.primary === "string") {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(model.primary)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
for (const fallback of model?.fallbacks ?? []) {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(fallback)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function collectClaudeCliRuntimeRefsFromModelMap(
|
||||
models: Record<string, unknown> | undefined,
|
||||
): string[] {
|
||||
const refs = new Set<string>();
|
||||
for (const key of Object.keys(models ?? {})) {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(key)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function collectClaudeCliRuntimeRefsFromConfig(config: OpenClawConfig): string[] {
|
||||
const refs = new Set<string>(
|
||||
collectClaudeCliRuntimeRefs(
|
||||
config.agents?.defaults?.model as
|
||||
| string
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined,
|
||||
),
|
||||
);
|
||||
for (const ref of collectClaudeCliRuntimeRefsFromModelMap(config.agents?.defaults?.models)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
for (const agent of config.agents?.list ?? []) {
|
||||
for (const ref of collectClaudeCliRuntimeRefs(
|
||||
agent.model as string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
for (const ref of collectClaudeCliRuntimeRefsFromModelMap(agent.models)) {
|
||||
refs.add(ref);
|
||||
type ClaudeCliModelSelection = string | { primary?: string; fallbacks?: string[] } | undefined;
|
||||
const selections: Array<{
|
||||
model: ClaudeCliModelSelection;
|
||||
models: Record<string, unknown> | undefined;
|
||||
}> = [
|
||||
{
|
||||
model: config.agents?.defaults?.model as ClaudeCliModelSelection,
|
||||
models: config.agents?.defaults?.models,
|
||||
},
|
||||
...(config.agents?.list ?? []).map((agent) => ({
|
||||
model: agent.model as ClaudeCliModelSelection,
|
||||
models: agent.models,
|
||||
})),
|
||||
];
|
||||
const refs = new Set<string>();
|
||||
for (const { model, models } of selections) {
|
||||
const selected =
|
||||
typeof model === "string" ? [model] : [model?.primary, ...(model?.fallbacks ?? [])];
|
||||
for (const rawRef of [...selected, ...Object.keys(models ?? {})]) {
|
||||
if (typeof rawRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(rawRef)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
|
||||
@@ -120,40 +120,14 @@ describe("anthropic provider replay hooks", () => {
|
||||
expect(registerSessionCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes Claude Opus 5 CLI metadata without downgrading its API contract", () => {
|
||||
expect(
|
||||
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-opus-5"),
|
||||
).toMatchObject({
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5 (Claude CLI)",
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
mediaInput: {
|
||||
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes Claude Sonnet 5 CLI metadata without downgrading its API contract", () => {
|
||||
expect(
|
||||
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-sonnet-5"),
|
||||
).toMatchObject({
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5 (Claude CLI)",
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
mediaInput: {
|
||||
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes Claude Fable 5 CLI metadata without downgrading its API contract", () => {
|
||||
expect(
|
||||
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-fable-5"),
|
||||
).toMatchObject({
|
||||
id: "claude-fable-5",
|
||||
name: "Claude Fable 5 (Claude CLI)",
|
||||
it.each([
|
||||
["Opus", "claude-opus-5"],
|
||||
["Sonnet", "claude-sonnet-5"],
|
||||
["Fable", "claude-fable-5"],
|
||||
])("publishes Claude %s 5 CLI metadata without downgrading its API contract", (family, id) => {
|
||||
expect(buildClaudeCliCatalogEntries().find((model) => model.id === id)).toMatchObject({
|
||||
id,
|
||||
name: `Claude ${family} 5 (Claude CLI)`,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
mediaInput: {
|
||||
@@ -281,29 +255,15 @@ describe("anthropic provider replay hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults provider api through plugin config normalization", async () => {
|
||||
it.each([
|
||||
["provider", "anthropic"],
|
||||
["Claude CLI provider", "claude-cli"],
|
||||
])("defaults %s api through plugin config normalization", async (_label, providerId) => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
expect(
|
||||
requireRecord(
|
||||
provider.normalizeConfig?.({
|
||||
provider: "anthropic",
|
||||
providerConfig: {
|
||||
models: [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
|
||||
},
|
||||
} as never),
|
||||
"normalized config",
|
||||
).api,
|
||||
).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("defaults Claude CLI provider api through plugin config normalization", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
expect(
|
||||
requireRecord(
|
||||
provider.normalizeConfig?.({
|
||||
provider: "claude-cli",
|
||||
provider: providerId,
|
||||
providerConfig: {
|
||||
models: [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
|
||||
},
|
||||
@@ -1348,7 +1308,29 @@ describe("anthropic provider replay hooks", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preflights non-interactive setup-token input without writing credentials", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "preflights non-interactive setup-token input without writing credentials",
|
||||
opts: {},
|
||||
},
|
||||
{
|
||||
name: "rejects setup-token ref storage during non-interactive preflight",
|
||||
opts: { secretInputMode: "ref" }, // pragma: allowlist secret
|
||||
error:
|
||||
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
|
||||
},
|
||||
{
|
||||
name: "rejects invalid setup-token expiry during non-interactive preflight",
|
||||
opts: { tokenExpiresIn: "nope" },
|
||||
error: "Invalid --token-expires-in",
|
||||
partialError: true,
|
||||
},
|
||||
] as Array<{
|
||||
name: string;
|
||||
opts: Record<string, string>;
|
||||
error?: string;
|
||||
partialError?: boolean;
|
||||
}>)("$name", async ({ opts, error, partialError }) => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
|
||||
if (!setupTokenAuth?.validateNonInteractive) {
|
||||
@@ -1360,64 +1342,20 @@ describe("anthropic provider replay hooks", () => {
|
||||
authChoice: "setup-token",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: { token: ANTHROPIC_SETUP_TOKEN },
|
||||
opts: { token: ANTHROPIC_SETUP_TOKEN, ...opts },
|
||||
runtime,
|
||||
resolveApiKey: vi.fn(async () => null),
|
||||
});
|
||||
|
||||
expect(valid).toBe(true);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects setup-token ref storage during non-interactive preflight", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
|
||||
if (!setupTokenAuth?.validateNonInteractive) {
|
||||
throw new Error("expected setup-token reset preflight");
|
||||
expect(valid).toBe(!error);
|
||||
if (error) {
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
partialError ? expect.stringContaining(error) : error,
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
} else {
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
}
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
const valid = await setupTokenAuth.validateNonInteractive({
|
||||
authChoice: "setup-token",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: {
|
||||
token: ANTHROPIC_SETUP_TOKEN,
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
},
|
||||
runtime,
|
||||
resolveApiKey: vi.fn(async () => null),
|
||||
});
|
||||
|
||||
expect(valid).toBe(false);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("rejects invalid setup-token expiry during non-interactive preflight", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
|
||||
if (!setupTokenAuth?.validateNonInteractive) {
|
||||
throw new Error("expected setup-token reset preflight");
|
||||
}
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
const valid = await setupTokenAuth.validateNonInteractive({
|
||||
authChoice: "setup-token",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: { token: ANTHROPIC_SETUP_TOKEN, tokenExpiresIn: "nope" },
|
||||
runtime,
|
||||
resolveApiKey: vi.fn(async () => null),
|
||||
});
|
||||
|
||||
expect(valid).toBe(false);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Invalid --token-expires-in"),
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("omits setup-token expiry when duration overflows the Date range", async () => {
|
||||
|
||||
@@ -498,35 +498,18 @@ describe("Google image-generation provider", () => {
|
||||
expect(postJsonRequestOptions(postJsonRequestSpy).allowPrivateNetwork).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes a configured bare Google host to the v1beta API root", async () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
const fetchMock = installGoogleFetchMock();
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-image",
|
||||
it.each([
|
||||
{
|
||||
name: "normalizes a configured bare Google host to the v1beta API root",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
prompt: "draw a cat",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const request = fetchRequest(fetchMock);
|
||||
expect(request.url).toBe(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image:generateContent",
|
||||
);
|
||||
expect(typeof request.method).toBe("string");
|
||||
});
|
||||
|
||||
it("strips a configured /openai suffix before calling the native Gemini image API", async () => {
|
||||
},
|
||||
{
|
||||
name: "strips a configured /openai suffix before calling the native Gemini image API",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
prompt: "draw a fox",
|
||||
},
|
||||
])("$name", async ({ baseUrl, prompt }) => {
|
||||
mockGoogleApiKeyAuth();
|
||||
const fetchMock = installGoogleFetchMock();
|
||||
|
||||
@@ -534,12 +517,12 @@ describe("Google image-generation provider", () => {
|
||||
await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-image",
|
||||
prompt: "draw a fox",
|
||||
prompt,
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
baseUrl,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -681,42 +681,20 @@ describe("Google speech provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("honors configured private-network opt-in for Google TTS", async () => {
|
||||
installGoogleTtsRequestMock();
|
||||
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
await provider.synthesize({
|
||||
text: "hello",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
request: { allowPrivateNetwork: true },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
providerConfig: { apiKey: "google-test-key" },
|
||||
it.each([
|
||||
{
|
||||
name: "honors configured private-network opt-in for Google TTS",
|
||||
target: "audio-file",
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
const requestConfig = expectRecordFields(
|
||||
requireFirstRecordArg(resolveProviderHttpRequestConfigMock, "Google TTS HTTP config request"),
|
||||
{
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
);
|
||||
expectRecordFields(requestConfig.request, { allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("honors configured private-network opt-in for Google telephony TTS", async () => {
|
||||
},
|
||||
{
|
||||
name: "honors configured private-network opt-in for Google telephony TTS",
|
||||
target: "telephony",
|
||||
},
|
||||
] as const)("$name", async ({ target }) => {
|
||||
installGoogleTtsRequestMock();
|
||||
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
await provider.synthesizeTelephony?.({
|
||||
const request = {
|
||||
text: "hello",
|
||||
cfg: {
|
||||
models: {
|
||||
@@ -731,7 +709,12 @@ describe("Google speech provider", () => {
|
||||
},
|
||||
providerConfig: { apiKey: "google-test-key" },
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
};
|
||||
if (target === "telephony") {
|
||||
await provider.synthesizeTelephony?.(request);
|
||||
} else {
|
||||
await provider.synthesize({ ...request, target });
|
||||
}
|
||||
|
||||
const requestConfig = expectRecordFields(
|
||||
requireFirstRecordArg(resolveProviderHttpRequestConfigMock, "Google TTS HTTP config request"),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Matrix setup module handles plugin onboarding behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
|
||||
import {
|
||||
type ChannelSetupWizardAdapter,
|
||||
formatDocsLink,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
normalizeUniqueStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import {
|
||||
@@ -30,11 +30,11 @@ import {
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./matrix/client/url-validation.js";
|
||||
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
import type { RuntimeEnv, WizardPrompter } from "./runtime-api.js";
|
||||
import { moveSingleMatrixAccountConfigToNamedAccount } from "./setup-config.js";
|
||||
import { resolveMatrixSetupDmAllowFrom } from "./setup-dm-policy.js";
|
||||
import { createMatrixSetupDmPolicy } from "./setup-dm-policy.js";
|
||||
import type { CoreConfig, MatrixConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
@@ -61,16 +61,6 @@ function isMatrixInviteAutoJoinTarget(entry: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatrixInviteAutoJoinTargets(entries: string[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
entries
|
||||
.map((entry) => normalizeOptionalString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
|
||||
return normalizeAccountId(
|
||||
normalizeOptionalString(accountId) || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
|
||||
@@ -181,24 +171,6 @@ async function promptMatrixAllowFrom(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function setMatrixGroupPolicy(
|
||||
cfg: CoreConfig,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
accountId?: string,
|
||||
) {
|
||||
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
|
||||
groupPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: string) {
|
||||
const groups = Object.fromEntries(roomKeys.map((key) => [key, { enabled: true }]));
|
||||
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
|
||||
groups,
|
||||
rooms: null,
|
||||
});
|
||||
}
|
||||
|
||||
function setMatrixAutoJoin(
|
||||
cfg: CoreConfig,
|
||||
autoJoin: MatrixInviteAutoJoinPolicy,
|
||||
@@ -276,7 +248,7 @@ async function configureMatrixInviteAutoJoin(params: {
|
||||
return entries.length > 0 ? undefined : "Required";
|
||||
},
|
||||
});
|
||||
const allowlist = normalizeMatrixInviteAutoJoinTargets(splitSetupEntries(rawAllowlist));
|
||||
const allowlist = normalizeUniqueStringEntries(splitSetupEntries(rawAllowlist));
|
||||
const invalidEntries = allowlist.filter((entry) => !isMatrixInviteAutoJoinTarget(entry));
|
||||
if (allowlist.length === 0 || invalidEntries.length > 0) {
|
||||
await params.prompter.note(
|
||||
@@ -325,7 +297,9 @@ async function configureMatrixAccessPrompts(params: {
|
||||
});
|
||||
if (accessConfig) {
|
||||
if (accessConfig.policy !== "allowlist") {
|
||||
next = setMatrixGroupPolicy(next, accessConfig.policy, params.accountId);
|
||||
next = updateMatrixAccountConfig(next, params.accountId, {
|
||||
groupPolicy: accessConfig.policy,
|
||||
});
|
||||
} else {
|
||||
let roomKeys = accessConfig.entries;
|
||||
if (accessConfig.entries.length > 0) {
|
||||
@@ -387,8 +361,11 @@ async function configureMatrixAccessPrompts(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
next = setMatrixGroupPolicy(next, "allowlist", params.accountId);
|
||||
next = setMatrixGroupRooms(next, roomKeys, params.accountId);
|
||||
next = updateMatrixAccountConfig(next, params.accountId, {
|
||||
groupPolicy: "allowlist",
|
||||
groups: Object.fromEntries(roomKeys.map((key) => [key, { enabled: true }])),
|
||||
rooms: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,43 +376,7 @@ async function configureMatrixAccessPrompts(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy = createChannelDmPolicy({
|
||||
label: "Matrix",
|
||||
channel,
|
||||
policyPath: "dm.policy",
|
||||
allowFromPath: "dm.allowFrom",
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const accountIdResolved = resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountId);
|
||||
const config = resolveMatrixAccountConfig({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountIdResolved,
|
||||
});
|
||||
return {
|
||||
accountId: accountIdResolved,
|
||||
config: { dmPolicy: config.dm?.policy, allowFrom: config.dm?.allowFrom, dm: config.dm },
|
||||
};
|
||||
},
|
||||
resolveConfigKeys: ({ cfg, account }) => ({
|
||||
policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"),
|
||||
allowFromKey: resolveMatrixConfigFieldPath(
|
||||
cfg as CoreConfig,
|
||||
account.accountId,
|
||||
"dm.allowFrom",
|
||||
),
|
||||
}),
|
||||
resolveAllowFrom: ({ policy, account }) =>
|
||||
resolveMatrixSetupDmAllowFrom(policy, account.config.allowFrom),
|
||||
buildPatch: ({ account, policy, allowFrom }) => ({
|
||||
dm: { ...account.config.dm, policy, allowFrom },
|
||||
}),
|
||||
applyPatch: ({ cfg, account, patch }) =>
|
||||
updateMatrixAccountConfig(
|
||||
cfg as CoreConfig,
|
||||
account.accountId,
|
||||
patch as Parameters<typeof updateMatrixAccountConfig>[2],
|
||||
),
|
||||
promptAllowFrom: promptMatrixAllowFrom,
|
||||
});
|
||||
const dmPolicy = createMatrixSetupDmPolicy(promptMatrixAllowFrom);
|
||||
|
||||
type MatrixConfigureIntent = "update" | "add-account";
|
||||
|
||||
@@ -690,44 +631,21 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
selectionHint: !sdkReady ? "install Matrix deps" : configured ? "configured" : "needs auth",
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
runtime,
|
||||
prompter,
|
||||
forceAllowFrom,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
}) =>
|
||||
configure: async (params) =>
|
||||
await runMatrixConfigure({
|
||||
cfg: cfg as CoreConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
forceAllowFrom,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
...params,
|
||||
cfg: params.cfg as CoreConfig,
|
||||
intent: "update",
|
||||
}),
|
||||
configureInteractive: async ({
|
||||
cfg,
|
||||
runtime,
|
||||
prompter,
|
||||
forceAllowFrom,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
configured,
|
||||
}) => {
|
||||
if (!configured) {
|
||||
configureInteractive: async (params) => {
|
||||
if (!params.configured) {
|
||||
return await runMatrixConfigure({
|
||||
cfg: cfg as CoreConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
forceAllowFrom,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
...params,
|
||||
cfg: params.cfg as CoreConfig,
|
||||
intent: "update",
|
||||
});
|
||||
}
|
||||
const action = await prompter.select({
|
||||
const action = await params.prompter.select({
|
||||
message: "Matrix already configured. What do you want to do?",
|
||||
options: [
|
||||
{ value: "update", label: "Modify settings" },
|
||||
@@ -740,12 +658,8 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
return "skip";
|
||||
}
|
||||
return await runMatrixConfigure({
|
||||
cfg: cfg as CoreConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
forceAllowFrom,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
...params,
|
||||
cfg: params.cfg as CoreConfig,
|
||||
intent: action === "add-account" ? "add-account" : "update",
|
||||
});
|
||||
},
|
||||
@@ -767,4 +681,3 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
},
|
||||
}),
|
||||
};
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
|
||||
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
|
||||
// Matrix plugin module implements setup core behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
@@ -9,15 +7,13 @@ import {
|
||||
type ChannelSetupAdapter,
|
||||
type ChannelSetupWizardAdapter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { applyMatrixSetupAccountConfig, validateMatrixSetupInput } from "./setup-config.js";
|
||||
import {
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
singleAccountKeysToMove,
|
||||
} from "./setup-contract.js";
|
||||
import { resolveMatrixSetupDmAllowFrom } from "./setup-dm-policy.js";
|
||||
import { createMatrixSetupDmPolicy } from "./setup-dm-policy.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
@@ -27,12 +23,6 @@ function resolveMatrixSetupAccountId(params: { accountId?: string; name?: string
|
||||
return normalizeAccountId(params.accountId?.trim() || params.name?.trim() || DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
function resolveMatrixSetupWizardAccountId(cfg: CoreConfig, accountId?: string): string {
|
||||
return normalizeAccountId(
|
||||
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export function createMatrixSetupWizardProxy(
|
||||
loadWizardModule: () => Promise<MatrixSetupWizardModule>,
|
||||
): ChannelSetupWizardAdapter {
|
||||
@@ -58,45 +48,9 @@ export function createMatrixSetupWizardProxy(
|
||||
)(ctx);
|
||||
},
|
||||
afterConfigWritten: async (ctx) => await (await loadWizard()).afterConfigWritten?.(ctx),
|
||||
dmPolicy: createChannelDmPolicy({
|
||||
label: "Matrix",
|
||||
channel,
|
||||
policyPath: "dm.policy",
|
||||
allowFromPath: "dm.allowFrom",
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const accountIdResolved = resolveMatrixSetupWizardAccountId(cfg as CoreConfig, accountId);
|
||||
const config = resolveMatrixAccountConfig({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountIdResolved,
|
||||
});
|
||||
return {
|
||||
accountId: accountIdResolved,
|
||||
config: { dmPolicy: config.dm?.policy, allowFrom: config.dm?.allowFrom, dm: config.dm },
|
||||
};
|
||||
},
|
||||
resolveConfigKeys: ({ cfg, account }) => ({
|
||||
policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"),
|
||||
allowFromKey: resolveMatrixConfigFieldPath(
|
||||
cfg as CoreConfig,
|
||||
account.accountId,
|
||||
"dm.allowFrom",
|
||||
),
|
||||
}),
|
||||
resolveAllowFrom: ({ policy, account }) =>
|
||||
resolveMatrixSetupDmAllowFrom(policy, account.config.allowFrom),
|
||||
buildPatch: ({ account, policy, allowFrom }) => ({
|
||||
dm: { ...account.config.dm, policy, allowFrom },
|
||||
}),
|
||||
applyPatch: ({ cfg, account, patch }) =>
|
||||
updateMatrixAccountConfig(
|
||||
cfg as CoreConfig,
|
||||
account.accountId,
|
||||
patch as Parameters<typeof updateMatrixAccountConfig>[2],
|
||||
) as OpenClawConfig,
|
||||
promptAllowFrom: async (params) => {
|
||||
const promptAllowFrom = (await loadWizard()).dmPolicy?.promptAllowFrom;
|
||||
return promptAllowFrom ? await promptAllowFrom(params) : params.cfg;
|
||||
},
|
||||
dmPolicy: createMatrixSetupDmPolicy(async (params) => {
|
||||
const promptAllowFrom = (await loadWizard()).dmPolicy?.promptAllowFrom;
|
||||
return promptAllowFrom ? await promptAllowFrom(params) : params.cfg;
|
||||
}),
|
||||
disable: (cfg) => ({
|
||||
...(cfg as CoreConfig),
|
||||
|
||||
@@ -1,16 +1,67 @@
|
||||
// Matrix plugin module implements setup dm policy behavior.
|
||||
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
|
||||
import type { DmPolicy } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { addWildcardAllowFrom, normalizeAllowFromEntries } from "openclaw/plugin-sdk/setup";
|
||||
import type { MatrixConfig } from "./types.js";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeAllowFromEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import type { CoreConfig, MatrixConfig } from "./types.js";
|
||||
|
||||
type MatrixDmAllowFrom = NonNullable<MatrixConfig["dm"]>["allowFrom"];
|
||||
|
||||
export function resolveMatrixSetupDmAllowFrom(
|
||||
policy: DmPolicy,
|
||||
allowFrom: MatrixDmAllowFrom,
|
||||
): string[] {
|
||||
function resolveMatrixSetupDmAllowFrom(policy: DmPolicy, allowFrom: MatrixDmAllowFrom): string[] {
|
||||
if (policy === "open") {
|
||||
return addWildcardAllowFrom(allowFrom);
|
||||
}
|
||||
return normalizeAllowFromEntries(allowFrom ?? []).filter((entry) => entry !== "*");
|
||||
}
|
||||
|
||||
export function createMatrixSetupDmPolicy(
|
||||
promptAllowFrom: NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>,
|
||||
): ChannelSetupDmPolicy {
|
||||
return createChannelDmPolicy({
|
||||
label: "Matrix",
|
||||
channel: "matrix",
|
||||
policyPath: "dm.policy",
|
||||
allowFromPath: "dm.allowFrom",
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
const resolvedAccountId = normalizeAccountId(
|
||||
accountId?.trim() || resolveDefaultMatrixAccountId(resolvedCfg) || DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
const config = resolveMatrixAccountConfig({
|
||||
cfg: resolvedCfg,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
config: { dmPolicy: config.dm?.policy, allowFrom: config.dm?.allowFrom, dm: config.dm },
|
||||
};
|
||||
},
|
||||
resolveConfigKeys: ({ cfg, account }) => ({
|
||||
policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"),
|
||||
allowFromKey: resolveMatrixConfigFieldPath(
|
||||
cfg as CoreConfig,
|
||||
account.accountId,
|
||||
"dm.allowFrom",
|
||||
),
|
||||
}),
|
||||
resolveAllowFrom: ({ policy, account }) =>
|
||||
resolveMatrixSetupDmAllowFrom(policy, account.config.allowFrom),
|
||||
buildPatch: ({ account, policy, allowFrom }) => ({
|
||||
dm: { ...account.config.dm, policy, allowFrom },
|
||||
}),
|
||||
applyPatch: ({ cfg, account, patch }) =>
|
||||
updateMatrixAccountConfig(
|
||||
cfg as CoreConfig,
|
||||
account.accountId,
|
||||
patch as Parameters<typeof updateMatrixAccountConfig>[2],
|
||||
),
|
||||
promptAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
openOpenClawAgentDatabase,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../test-helpers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
@@ -314,6 +318,7 @@ describe("memory index", () => {
|
||||
await closeAllMemorySearchManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
clearRegistry();
|
||||
managersForCleanup.clear();
|
||||
restoreMemoryIndexStateDir();
|
||||
@@ -344,6 +349,7 @@ describe("memory index", () => {
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line.\nZebra memory line.",
|
||||
|
||||
@@ -593,67 +593,13 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
texts: string[],
|
||||
generation?: MemorySemanticProviderGeneration,
|
||||
): Promise<number[][]> {
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const provider = generation?.provider ?? this.provider;
|
||||
if (!provider) {
|
||||
throw new Error("Cannot embed batch in FTS-only mode (no embedding provider)");
|
||||
}
|
||||
try {
|
||||
return await this.withProviderUse(
|
||||
provider,
|
||||
async () =>
|
||||
await runMemoryEmbeddingBatchRetryWithSplit({
|
||||
items: texts,
|
||||
run: async (batchTexts) => {
|
||||
const timeoutMs = this.resolveEmbeddingTimeout(
|
||||
"batch",
|
||||
provider,
|
||||
generation?.runtime,
|
||||
);
|
||||
log.debug("memory embeddings: batch start", {
|
||||
provider: provider.id,
|
||||
items: batchTexts.length,
|
||||
timeoutMs,
|
||||
});
|
||||
const result = await runEmbeddingOperationWithTimeout({
|
||||
timeoutMs,
|
||||
message: `memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
run: async (signal) => await provider.embedBatch(batchTexts, { signal }),
|
||||
});
|
||||
log.debug("memory embeddings: batch completed", {
|
||||
provider: provider.id,
|
||||
items: batchTexts.length,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
isRetryable: isRetryableMemoryEmbeddingError,
|
||||
isSplittable: isSplittableMemoryEmbeddingTransportError,
|
||||
waitForRetry: async (delayMs) => {
|
||||
await this.waitForEmbeddingRetry(delayMs, "retrying");
|
||||
},
|
||||
maxAttempts: EMBEDDING_RETRY_MAX_ATTEMPTS,
|
||||
baseDelayMs: EMBEDDING_RETRY_BASE_DELAY_MS,
|
||||
onSplit: ({ itemCount, splitAt }) => {
|
||||
log.warn(
|
||||
`memory embeddings transport failed after retries; splitting batch of ${itemCount} into ${splitAt} + ${itemCount - splitAt}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
log.debug("memory embeddings: batch failed", {
|
||||
provider: provider.id,
|
||||
error: formatErrorMessage(err),
|
||||
});
|
||||
this.markLocalEmbeddingProviderDegraded(err);
|
||||
throw createMemoryEmbeddingOperationError({
|
||||
operation: "batch",
|
||||
providerId: provider.id,
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
return await this.runProviderBatchWithRetry({
|
||||
items: texts,
|
||||
generation,
|
||||
operation: "batch",
|
||||
run: async (provider, batchTexts, signal) =>
|
||||
await provider.embedBatch(batchTexts, { signal }),
|
||||
});
|
||||
}
|
||||
|
||||
protected async embedBatchInputsWithRetry(
|
||||
@@ -671,47 +617,87 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
generation,
|
||||
);
|
||||
}
|
||||
return await this.runProviderBatchWithRetry({
|
||||
items: inputs,
|
||||
generation,
|
||||
operation: "structured-batch",
|
||||
run: async (_provider, batchInputs, signal) =>
|
||||
await embedBatchInputs(batchInputs, { signal }),
|
||||
});
|
||||
}
|
||||
|
||||
private async runProviderBatchWithRetry<T>(params: {
|
||||
items: T[];
|
||||
generation?: MemorySemanticProviderGeneration;
|
||||
operation: "batch" | "structured-batch";
|
||||
run: (provider: EmbeddingProvider, items: T[], signal: AbortSignal) => Promise<number[][]>;
|
||||
}): Promise<number[][]> {
|
||||
if (params.items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const provider = params.generation?.provider ?? this.provider;
|
||||
if (!provider) {
|
||||
throw new Error("Cannot embed batch in FTS-only mode (no embedding provider)");
|
||||
}
|
||||
const structured = params.operation === "structured-batch";
|
||||
const label = structured ? "structured batch" : "batch";
|
||||
try {
|
||||
return await this.withProviderUse(
|
||||
provider,
|
||||
async () =>
|
||||
await runMemoryEmbeddingBatchRetryWithSplit({
|
||||
items: inputs,
|
||||
run: async (batchInputs) => {
|
||||
items: params.items,
|
||||
run: async (batchItems) => {
|
||||
const timeoutMs = this.resolveEmbeddingTimeout(
|
||||
"batch",
|
||||
provider,
|
||||
generation?.runtime,
|
||||
params.generation?.runtime,
|
||||
);
|
||||
log.debug("memory embeddings: structured batch start", {
|
||||
log.debug(`memory embeddings: ${label} start`, {
|
||||
provider: provider.id,
|
||||
items: batchInputs.length,
|
||||
items: batchItems.length,
|
||||
timeoutMs,
|
||||
});
|
||||
return await runEmbeddingOperationWithTimeout({
|
||||
const result = await runEmbeddingOperationWithTimeout({
|
||||
timeoutMs,
|
||||
message: `memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
run: async (signal) => await embedBatchInputs(batchInputs, { signal }),
|
||||
run: async (signal) => await params.run(provider, batchItems, signal),
|
||||
});
|
||||
if (!structured) {
|
||||
log.debug("memory embeddings: batch completed", {
|
||||
provider: provider.id,
|
||||
items: batchItems.length,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
isRetryable: isRetryableMemoryEmbeddingError,
|
||||
isSplittable: isSplittableMemoryEmbeddingTransportError,
|
||||
waitForRetry: async (delayMs) => {
|
||||
await this.waitForEmbeddingRetry(delayMs, "retrying structured batch");
|
||||
await this.waitForEmbeddingRetry(
|
||||
delayMs,
|
||||
structured ? "retrying structured batch" : "retrying",
|
||||
);
|
||||
},
|
||||
maxAttempts: EMBEDDING_RETRY_MAX_ATTEMPTS,
|
||||
baseDelayMs: EMBEDDING_RETRY_BASE_DELAY_MS,
|
||||
onSplit: ({ itemCount, splitAt }) => {
|
||||
log.warn(
|
||||
`memory embeddings transport failed after retries; splitting structured batch of ${itemCount} into ${splitAt} + ${itemCount - splitAt}`,
|
||||
`memory embeddings transport failed after retries; splitting ${label} of ${itemCount} into ${splitAt} + ${itemCount - splitAt}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!structured) {
|
||||
log.debug("memory embeddings: batch failed", {
|
||||
provider: provider.id,
|
||||
error: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
this.markLocalEmbeddingProviderDegraded(err);
|
||||
throw createMemoryEmbeddingOperationError({
|
||||
operation: "structured-batch",
|
||||
operation: params.operation,
|
||||
providerId: provider.id,
|
||||
cause: err,
|
||||
});
|
||||
|
||||
@@ -1498,27 +1498,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
activeProjectKeys?: string[];
|
||||
}): Promise<MemorySearchResult[]> {
|
||||
const limit = Math.max(1, Math.min(512, Math.floor(opts?.limit ?? 512)));
|
||||
return readCuratedMemoryTriggerCandidates(this.db, limit, opts?.activeProjectKeys).map(
|
||||
(row) => {
|
||||
const result: MemorySearchResult = {
|
||||
path: row.path,
|
||||
startLine: row.start_line,
|
||||
endLine: row.end_line,
|
||||
score: 0,
|
||||
snippet: row.text,
|
||||
source: "memory",
|
||||
};
|
||||
if (typeof row.importance === "number") {
|
||||
result.importance = row.importance;
|
||||
}
|
||||
if (typeof row.triggers === "string" && row.triggers.trim()) {
|
||||
result.triggers = row.triggers.trim();
|
||||
}
|
||||
if (typeof row.project_key === "string" && row.project_key.trim()) {
|
||||
result.projectKey = row.project_key.trim();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
return this.toCuratedMemorySearchResults(
|
||||
readCuratedMemoryTriggerCandidates(this.db, limit, opts?.activeProjectKeys),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1527,7 +1508,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
limit?: number;
|
||||
}): Promise<MemorySearchResult[]> {
|
||||
const limit = Math.max(1, Math.min(512, Math.floor(opts.limit ?? 48)));
|
||||
return readCuratedProjectMemoryCandidates(this.db, limit, opts.activeProjectKeys).map((row) => {
|
||||
return this.toCuratedMemorySearchResults(
|
||||
readCuratedProjectMemoryCandidates(this.db, limit, opts.activeProjectKeys),
|
||||
);
|
||||
}
|
||||
|
||||
private toCuratedMemorySearchResults(
|
||||
rows: ReturnType<typeof readCuratedMemoryTriggerCandidates>,
|
||||
): MemorySearchResult[] {
|
||||
return rows.map((row) => {
|
||||
const result: MemorySearchResult = {
|
||||
path: row.path,
|
||||
startLine: row.start_line,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Memory Core QMD runtime cache helpers.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
|
||||
|
||||
const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE = "qmd-runtime-cache.collection-validation";
|
||||
const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
|
||||
"qmd-runtime-cache.multi-collection-probe";
|
||||
const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
|
||||
const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
|
||||
const QMD_RUNTIME_CACHE_MAX_ENTRIES = 1_000;
|
||||
const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
|
||||
const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
|
||||
|
||||
@@ -39,11 +39,14 @@ export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextB
|
||||
sources: readonly string[];
|
||||
};
|
||||
|
||||
type QmdRuntimeCacheCollectionValidationEntry = {
|
||||
type QmdRuntimeCacheEntryBase = {
|
||||
version: 1;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
keyHash: string;
|
||||
};
|
||||
|
||||
type QmdRuntimeCacheCollectionValidationEntry = QmdRuntimeCacheEntryBase & {
|
||||
validation: {
|
||||
ok: true;
|
||||
collectionConfigHash: string;
|
||||
@@ -51,11 +54,7 @@ type QmdRuntimeCacheCollectionValidationEntry = {
|
||||
};
|
||||
};
|
||||
|
||||
type QmdRuntimeCacheMultiCollectionProbeEntry = {
|
||||
version: 1;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
keyHash: string;
|
||||
type QmdRuntimeCacheMultiCollectionProbeEntry = QmdRuntimeCacheEntryBase & {
|
||||
multiCollectionProbe: {
|
||||
supported: boolean;
|
||||
};
|
||||
@@ -72,15 +71,6 @@ function normalizeText(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
|
||||
return {
|
||||
name: normalizeText(collection.name),
|
||||
kind: collection.kind,
|
||||
pathHash: normalizePathIdentity(collection.path),
|
||||
pattern: normalizeText(collection.pattern),
|
||||
};
|
||||
}
|
||||
|
||||
function hashText(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -98,7 +88,10 @@ function sortedUnique(values: readonly string[]): string[] {
|
||||
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
|
||||
const normalized = collections
|
||||
.map((collection) => ({
|
||||
...normalizeCollection(collection),
|
||||
name: normalizeText(collection.name),
|
||||
kind: collection.kind,
|
||||
pathHash: normalizePathIdentity(collection.path),
|
||||
pattern: normalizeText(collection.pattern),
|
||||
}))
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
@@ -126,59 +119,32 @@ function buildRuntimeCacheContextRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCollectionValidationCacheContextInput(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
...buildRuntimeCacheContextRecord(params),
|
||||
collectionConfigHash: buildCollectionConfigHash(params.collections),
|
||||
});
|
||||
}
|
||||
|
||||
function buildMultiCollectionProbeCacheContextInput(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): string {
|
||||
return JSON.stringify(buildRuntimeCacheContextRecord(params));
|
||||
}
|
||||
|
||||
function buildQmdCollectionValidationCacheContextHash(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
): string {
|
||||
return hashText(buildCollectionValidationCacheContextInput(params));
|
||||
return hashText(
|
||||
JSON.stringify({
|
||||
...buildRuntimeCacheContextRecord(params),
|
||||
collectionConfigHash: buildCollectionConfigHash(params.collections),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildQmdMultiCollectionProbeCacheContextHash(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): string {
|
||||
return hashText(buildMultiCollectionProbeCacheContextInput(params));
|
||||
return hashText(JSON.stringify(buildRuntimeCacheContextRecord(params)));
|
||||
}
|
||||
|
||||
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
|
||||
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
|
||||
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
|
||||
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
|
||||
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
|
||||
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
|
||||
return memoryCoreWorkspaceEntryKey(
|
||||
params.workspaceDir,
|
||||
`qmd-runtime-cache.collection-validation:${buildQmdCollectionValidationCacheContextHash(params)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
|
||||
return memoryCoreWorkspaceEntryKey(
|
||||
params.workspaceDir,
|
||||
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(params)}`,
|
||||
);
|
||||
function resolveQmdRuntimeCache<T extends QmdRuntimeCacheEntryBase>(
|
||||
workspaceDir: string,
|
||||
namespace: string,
|
||||
keyHash: string,
|
||||
): { key: string; store: PluginStateKeyedStore<T> } {
|
||||
return {
|
||||
key: memoryCoreWorkspaceEntryKey(workspaceDir, `${namespace}:${keyHash}`),
|
||||
store: openMemoryCoreStateStore<T>({ namespace, maxEntries: QMD_RUNTIME_CACHE_MAX_ENTRIES }),
|
||||
};
|
||||
}
|
||||
|
||||
type QmdRuntimeCacheEnvelope = {
|
||||
@@ -194,11 +160,8 @@ function normalizeCacheEntryEnvelope(
|
||||
nowMs: number,
|
||||
expectedKeyHash: string,
|
||||
): QmdRuntimeCacheEnvelope | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
|
||||
const record = asOptionalRecord(value);
|
||||
if (!record || record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -238,18 +201,13 @@ function normalizeCollectionValidationEntry(
|
||||
}
|
||||
const { record, createdAtMs, expiresAtMs, keyHash } = envelope;
|
||||
|
||||
const validation = record.validation;
|
||||
if (typeof validation !== "object" || validation === null) {
|
||||
return undefined;
|
||||
}
|
||||
const validationRecord = validation as Record<string, unknown>;
|
||||
if (validationRecord.ok !== true) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof validationRecord.collectionConfigHash !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof validationRecord.collectionCount !== "number") {
|
||||
const validation = asOptionalRecord(record.validation);
|
||||
if (
|
||||
!validation ||
|
||||
validation.ok !== true ||
|
||||
typeof validation.collectionConfigHash !== "string" ||
|
||||
typeof validation.collectionCount !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -260,8 +218,8 @@ function normalizeCollectionValidationEntry(
|
||||
keyHash,
|
||||
validation: {
|
||||
ok: true,
|
||||
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
|
||||
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
|
||||
collectionConfigHash: normalizeText(validation.collectionConfigHash),
|
||||
collectionCount: Math.max(0, Math.floor(validation.collectionCount)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -277,12 +235,8 @@ function normalizeMultiCollectionProbeEntry(
|
||||
}
|
||||
const { record, createdAtMs, expiresAtMs, keyHash } = envelope;
|
||||
|
||||
const probe = record.multiCollectionProbe;
|
||||
if (typeof probe !== "object" || probe === null) {
|
||||
return undefined;
|
||||
}
|
||||
const probeRecord = probe as Record<string, unknown>;
|
||||
if (typeof probeRecord.supported !== "boolean") {
|
||||
const probe = asOptionalRecord(record.multiCollectionProbe);
|
||||
if (!probe || typeof probe.supported !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -292,55 +246,57 @@ function normalizeMultiCollectionProbeEntry(
|
||||
expiresAtMs,
|
||||
keyHash,
|
||||
multiCollectionProbe: {
|
||||
supported: probeRecord.supported,
|
||||
supported: probe.supported,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
|
||||
async function readQmdRuntimeCache<T extends QmdRuntimeCacheEntryBase>(params: {
|
||||
workspaceDir: string;
|
||||
namespace: string;
|
||||
keyHash: string;
|
||||
nowMs: number;
|
||||
normalize: (value: unknown, nowMs: number, keyHash: string) => T | undefined;
|
||||
}): Promise<QmdRuntimeCacheResult<T>> {
|
||||
try {
|
||||
const store = collectionValidationStore();
|
||||
const key = collectionValidationEntryKey(params);
|
||||
const expectedKeyHash = buildQmdCollectionValidationCacheContextHash(params);
|
||||
const { store, key } = resolveQmdRuntimeCache<T>(
|
||||
params.workspaceDir,
|
||||
params.namespace,
|
||||
params.keyHash,
|
||||
);
|
||||
const raw = await store.lookup(key);
|
||||
if (!raw) {
|
||||
return { state: "miss" };
|
||||
}
|
||||
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
|
||||
const validated = raw && params.normalize(raw, params.nowMs, params.keyHash);
|
||||
return validated ? { state: "hit", value: validated } : { state: "miss" };
|
||||
} catch {
|
||||
return { state: "miss" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<boolean> {
|
||||
async function writeQmdRuntimeCache<T extends QmdRuntimeCacheEntryBase>(params: {
|
||||
workspaceDir: string;
|
||||
namespace: string;
|
||||
keyHash: string;
|
||||
nowMs: number;
|
||||
ttlMs: number;
|
||||
payload: Omit<T, keyof QmdRuntimeCacheEntryBase>;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const key = collectionValidationEntryKey(params);
|
||||
const keyHash = buildQmdCollectionValidationCacheContextHash(params);
|
||||
const collectionConfigHash = buildCollectionConfigHash(params.collections);
|
||||
const createdAtMs = Math.max(0, Math.floor(nowMs));
|
||||
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
|
||||
const store = collectionValidationStore();
|
||||
const { store, key } = resolveQmdRuntimeCache<T>(
|
||||
params.workspaceDir,
|
||||
params.namespace,
|
||||
params.keyHash,
|
||||
);
|
||||
const createdAtMs = Math.max(0, Math.floor(params.nowMs));
|
||||
await store.register(
|
||||
key,
|
||||
{
|
||||
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
|
||||
createdAtMs,
|
||||
expiresAtMs: createdAtMs + ttlMs,
|
||||
keyHash,
|
||||
validation: {
|
||||
ok: true,
|
||||
collectionConfigHash,
|
||||
collectionCount: params.collections.length,
|
||||
},
|
||||
},
|
||||
{ ttlMs },
|
||||
expiresAtMs: createdAtMs + params.ttlMs,
|
||||
keyHash: params.keyHash,
|
||||
...params.payload,
|
||||
} as T,
|
||||
{ ttlMs: params.ttlMs },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -348,23 +304,50 @@ export async function writeQmdCollectionValidationCache(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
|
||||
return await readQmdRuntimeCache({
|
||||
workspaceDir: params.workspaceDir,
|
||||
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
|
||||
keyHash: buildQmdCollectionValidationCacheContextHash(params),
|
||||
nowMs,
|
||||
normalize: normalizeCollectionValidationEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<boolean> {
|
||||
return await writeQmdRuntimeCache<QmdRuntimeCacheCollectionValidationEntry>({
|
||||
workspaceDir: params.workspaceDir,
|
||||
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
|
||||
keyHash: buildQmdCollectionValidationCacheContextHash(params),
|
||||
nowMs,
|
||||
ttlMs: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
|
||||
payload: {
|
||||
validation: {
|
||||
ok: true,
|
||||
collectionConfigHash: buildCollectionConfigHash(params.collections),
|
||||
collectionCount: params.collections.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function readQmdMultiCollectionProbeCache(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
|
||||
try {
|
||||
const store = multiCollectionProbeStore();
|
||||
const key = multiCollectionProbeEntryKey(params);
|
||||
const expectedKeyHash = buildQmdMultiCollectionProbeCacheContextHash(params);
|
||||
const raw = await store.lookup(key);
|
||||
if (!raw) {
|
||||
return { state: "miss" };
|
||||
}
|
||||
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
|
||||
return validated ? { state: "hit", value: validated } : { state: "miss" };
|
||||
} catch {
|
||||
return { state: "miss" };
|
||||
}
|
||||
return await readQmdRuntimeCache({
|
||||
workspaceDir: params.workspaceDir,
|
||||
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
keyHash: buildQmdMultiCollectionProbeCacheContextHash(params),
|
||||
nowMs,
|
||||
normalize: normalizeMultiCollectionProbeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeQmdMultiCollectionProbeCache(
|
||||
@@ -372,37 +355,26 @@ export async function writeQmdMultiCollectionProbeCache(
|
||||
supported: boolean,
|
||||
nowMs = Date.now(),
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = multiCollectionProbeEntryKey(params);
|
||||
const keyHash = buildQmdMultiCollectionProbeCacheContextHash(params);
|
||||
const createdAtMs = Math.max(0, Math.floor(nowMs));
|
||||
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
|
||||
const store = multiCollectionProbeStore();
|
||||
await store.register(
|
||||
key,
|
||||
{
|
||||
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
|
||||
createdAtMs,
|
||||
expiresAtMs: createdAtMs + ttlMs,
|
||||
keyHash,
|
||||
multiCollectionProbe: {
|
||||
supported,
|
||||
},
|
||||
},
|
||||
{ ttlMs },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return await writeQmdRuntimeCache<QmdRuntimeCacheMultiCollectionProbeEntry>({
|
||||
workspaceDir: params.workspaceDir,
|
||||
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
keyHash: buildQmdMultiCollectionProbeCacheContextHash(params),
|
||||
nowMs,
|
||||
ttlMs: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
|
||||
payload: { multiCollectionProbe: { supported } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearQmdMultiCollectionProbeCache(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const store = multiCollectionProbeStore();
|
||||
await store.delete(multiCollectionProbeEntryKey(params));
|
||||
const { store, key } = resolveQmdRuntimeCache<QmdRuntimeCacheMultiCollectionProbeEntry>(
|
||||
params.workspaceDir,
|
||||
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
buildQmdMultiCollectionProbeCacheContextHash(params),
|
||||
);
|
||||
await store.delete(key);
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
resolveMemoryBackendConfig,
|
||||
type MemoryEmbeddingProbeResult,
|
||||
type MemorySearchManager,
|
||||
type MemorySearchRuntimeDebug,
|
||||
type MemorySource,
|
||||
type MemorySyncParams,
|
||||
type ResolvedQmdConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
|
||||
const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache");
|
||||
type Maybe<T> = T | null;
|
||||
type MemoryManagerSearchOptions = Parameters<MemorySearchManager["search"]>[1];
|
||||
type QmdManagerRuntimeConfig = {
|
||||
workspaceDir: string;
|
||||
syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
|
||||
@@ -642,20 +641,7 @@ class BorrowedMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
lexicalOnly?: boolean;
|
||||
activeProjectKeys?: string[];
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
sources?: MemorySource[];
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
async search(query: string, opts?: MemoryManagerSearchOptions) {
|
||||
return await this.inner.search(query, opts);
|
||||
}
|
||||
|
||||
@@ -824,20 +810,7 @@ class FallbackMemoryManager implements MemorySearchManager {
|
||||
private readonly onClose?: () => void,
|
||||
) {}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
lexicalOnly?: boolean;
|
||||
activeProjectKeys?: string[];
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
sources?: MemorySource[];
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
async search(query: string, opts?: MemoryManagerSearchOptions) {
|
||||
this.ensureOpen();
|
||||
if (!this.primaryFailed) {
|
||||
try {
|
||||
@@ -913,26 +886,13 @@ class FallbackMemoryManager implements MemorySearchManager {
|
||||
if (!this.primaryFailed) {
|
||||
return this.deps.primary.status();
|
||||
}
|
||||
const fallbackStatus = this.fallback?.status();
|
||||
const fallbackStatus = this.fallback?.status() ?? this.deps.primary.status();
|
||||
const fallbackInfo = { from: "qmd", reason: this.lastError ?? "unknown" };
|
||||
if (fallbackStatus) {
|
||||
const custom = fallbackStatus.custom ?? {};
|
||||
return {
|
||||
...fallbackStatus,
|
||||
fallback: fallbackInfo,
|
||||
custom: {
|
||||
...custom,
|
||||
fallback: { disabled: true, reason: this.lastError ?? "unknown" },
|
||||
},
|
||||
};
|
||||
}
|
||||
const primaryStatus = this.deps.primary.status();
|
||||
const custom = primaryStatus.custom ?? {};
|
||||
return {
|
||||
...primaryStatus,
|
||||
...fallbackStatus,
|
||||
fallback: fallbackInfo,
|
||||
custom: {
|
||||
...custom,
|
||||
...fallbackStatus.custom,
|
||||
fallback: { disabled: true, reason: this.lastError ?? "unknown" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type { fetchWithTimeoutGuarded, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const DEFAULT_MINIMAX_MEDIA_BASE_URL = "https://api.minimax.io";
|
||||
|
||||
export type MinimaxBaseResp = {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
|
||||
export type MinimaxRequestPolicy = Pick<
|
||||
Parameters<typeof postJsonRequest>[0],
|
||||
"allowPrivateNetwork" | "dispatcherPolicy"
|
||||
>;
|
||||
|
||||
export function resolveMinimaxMediaBaseUrl(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
providerId: string,
|
||||
): string {
|
||||
const configured = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
|
||||
try {
|
||||
return configured ? new URL(configured).origin : DEFAULT_MINIMAX_MEDIA_BASE_URL;
|
||||
} catch {
|
||||
return DEFAULT_MINIMAX_MEDIA_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMinimaxBaseResp(
|
||||
baseResp: MinimaxBaseResp | undefined,
|
||||
context: string,
|
||||
): void {
|
||||
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
|
||||
throw new Error(
|
||||
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMinimaxGuardedRequestOptions(
|
||||
policy: MinimaxRequestPolicy,
|
||||
): Parameters<typeof fetchWithTimeoutGuarded>[4] | undefined {
|
||||
return policy.allowPrivateNetwork || policy.dispatcherPolicy
|
||||
? {
|
||||
...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
|
||||
...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
@@ -21,19 +21,21 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
assertMinimaxBaseResp,
|
||||
DEFAULT_MINIMAX_MEDIA_BASE_URL,
|
||||
resolveMinimaxGuardedRequestOptions,
|
||||
resolveMinimaxMediaBaseUrl,
|
||||
type MinimaxBaseResp,
|
||||
type MinimaxRequestPolicy,
|
||||
} from "./media-provider-runtime.js";
|
||||
|
||||
const DEFAULT_MINIMAX_MUSIC_BASE_URL = "https://api.minimax.io";
|
||||
const DEFAULT_MINIMAX_MUSIC_MODEL = "music-2.6";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 300_000;
|
||||
const STREAM_ENVELOPE_MAX_BYTES_MULTIPLIER = 5;
|
||||
const STREAM_ENVELOPE_OVERHEAD_BYTES = 64 * 1024;
|
||||
|
||||
type MinimaxBaseResp = {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
|
||||
type MinimaxMusicCreateResponse = {
|
||||
task_id?: string;
|
||||
audio?: string;
|
||||
@@ -55,47 +57,6 @@ type MinimaxMusicStreamFrame = {
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
type MinimaxRequestPolicy = Pick<
|
||||
Parameters<typeof postJsonRequest>[0],
|
||||
"allowPrivateNetwork" | "dispatcherPolicy"
|
||||
>;
|
||||
|
||||
function resolveMinimaxMusicBaseUrl(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
providerId: string,
|
||||
): string {
|
||||
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
|
||||
if (!direct) {
|
||||
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
|
||||
}
|
||||
try {
|
||||
return new URL(direct).origin;
|
||||
} catch {
|
||||
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: string): void {
|
||||
if (!baseResp || typeof baseResp.status_code !== "number" || baseResp.status_code === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMinimaxGuardedRequestOptions(
|
||||
policy: MinimaxRequestPolicy,
|
||||
): Parameters<typeof fetchWithTimeoutGuarded>[4] | undefined {
|
||||
if (!policy.allowPrivateNetwork && !policy.dispatcherPolicy) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
|
||||
...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeHexAudioWithLimit(data: string, maxBytes: number): Buffer {
|
||||
const trimmed = data.trim();
|
||||
if (!/^[0-9a-f]+$/iu.test(trimmed) || trimmed.length % 2 !== 0) {
|
||||
@@ -338,8 +299,8 @@ function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider
|
||||
});
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolveMinimaxMusicBaseUrl(req.cfg, providerId),
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_MUSIC_BASE_URL,
|
||||
baseUrl: resolveMinimaxMediaBaseUrl(req.cfg, providerId),
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_MEDIA_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
},
|
||||
|
||||
@@ -27,8 +27,15 @@ import type {
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import {
|
||||
assertMinimaxBaseResp,
|
||||
DEFAULT_MINIMAX_MEDIA_BASE_URL,
|
||||
resolveMinimaxGuardedRequestOptions,
|
||||
resolveMinimaxMediaBaseUrl,
|
||||
type MinimaxBaseResp,
|
||||
type MinimaxRequestPolicy,
|
||||
} from "./media-provider-runtime.js";
|
||||
|
||||
const DEFAULT_MINIMAX_VIDEO_BASE_URL = "https://api.minimax.io";
|
||||
const DEFAULT_MINIMAX_VIDEO_MODEL = "MiniMax-Hailuo-2.3";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 1_200_000;
|
||||
@@ -45,11 +52,6 @@ const MINIMAX_MODEL_ALLOWED_RESOLUTIONS: Readonly<Record<string, readonly string
|
||||
};
|
||||
const MINIMAX_RESOLUTION_ORDER = ["480P", "720P", "768P", "1080P"] as const;
|
||||
|
||||
type MinimaxBaseResp = {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
|
||||
type MinimaxCreateResponse = {
|
||||
task_id?: string;
|
||||
base_resp?: MinimaxBaseResp;
|
||||
@@ -71,40 +73,11 @@ type MinimaxFileRetrieveResponse = {
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
type MinimaxRequestPolicy = Pick<
|
||||
Parameters<typeof postJsonRequest>[0],
|
||||
"allowPrivateNetwork" | "dispatcherPolicy"
|
||||
>;
|
||||
|
||||
type MinimaxResponseHandle = {
|
||||
response: Response;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
function resolveMinimaxVideoBaseUrl(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
providerId: string,
|
||||
): string {
|
||||
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
|
||||
if (!direct) {
|
||||
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
|
||||
}
|
||||
try {
|
||||
return new URL(direct).origin;
|
||||
} catch {
|
||||
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: string): void {
|
||||
if (!baseResp || typeof baseResp.status_code !== "number" || baseResp.status_code === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMinimaxRequestTimeoutMs(
|
||||
timeoutMs: ProviderOperationTimeoutMs | undefined,
|
||||
): number | undefined {
|
||||
@@ -114,18 +87,6 @@ function resolveMinimaxRequestTimeoutMs(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveMinimaxGuardedRequestOptions(
|
||||
policy: MinimaxRequestPolicy,
|
||||
): Parameters<typeof fetchWithTimeoutGuarded>[4] | undefined {
|
||||
if (!policy.allowPrivateNetwork && !policy.dispatcherPolicy) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
|
||||
...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMinimaxResponse(params: {
|
||||
stage: ProviderOperationRetryStage;
|
||||
url: string;
|
||||
@@ -489,8 +450,8 @@ function buildMinimaxVideoProvider(providerId: string): VideoGenerationProvider
|
||||
});
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolveMinimaxVideoBaseUrl(req.cfg, providerId),
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_VIDEO_BASE_URL,
|
||||
baseUrl: resolveMinimaxMediaBaseUrl(req.cfg, providerId),
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_MEDIA_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createStandardChannelSetupStatus,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createSetupTranslator,
|
||||
setSetupChannelEnabled,
|
||||
type ChannelSetupAdapter,
|
||||
type ChannelSetupWizard,
|
||||
type WizardPrompter,
|
||||
@@ -14,19 +15,11 @@ import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
const channel = "msteams" as const;
|
||||
|
||||
export const msteamsSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
applyAccountConfig: ({ cfg }) => setSetupChannelEnabled(cfg, channel, true),
|
||||
};
|
||||
|
||||
export const msteamsSetupContract = defineChannelSetupContract({
|
||||
@@ -34,32 +27,23 @@ export const msteamsSetupContract = defineChannelSetupContract({
|
||||
legacyAdapter: msteamsSetupAdapter,
|
||||
});
|
||||
|
||||
const channel = "msteams" as const;
|
||||
|
||||
async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{
|
||||
appId: string;
|
||||
appPassword: string;
|
||||
tenantId: string;
|
||||
}> {
|
||||
const appId = (
|
||||
await prompter.text({
|
||||
message: t("wizard.msteams.appIdPrompt"),
|
||||
validate: (value) => (value?.trim() ? undefined : t("common.required")),
|
||||
})
|
||||
).trim();
|
||||
const appPassword = (
|
||||
await prompter.text({
|
||||
message: t("wizard.msteams.appPasswordPrompt"),
|
||||
validate: (value) => (value?.trim() ? undefined : t("common.required")),
|
||||
})
|
||||
).trim();
|
||||
const tenantId = (
|
||||
await prompter.text({
|
||||
message: t("wizard.msteams.tenantIdPrompt"),
|
||||
validate: (value) => (value?.trim() ? undefined : t("common.required")),
|
||||
})
|
||||
).trim();
|
||||
return { appId, appPassword, tenantId };
|
||||
const promptRequired = async (message: string) =>
|
||||
(
|
||||
await prompter.text({
|
||||
message,
|
||||
validate: (value) => (value?.trim() ? undefined : t("common.required")),
|
||||
})
|
||||
).trim();
|
||||
return {
|
||||
appId: await promptRequired(t("wizard.msteams.appIdPrompt")),
|
||||
appPassword: await promptRequired(t("wizard.msteams.appPasswordPrompt")),
|
||||
tenantId: await promptRequired(t("wizard.msteams.tenantIdPrompt")),
|
||||
};
|
||||
}
|
||||
|
||||
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createTopLevelChannelDmPolicy,
|
||||
createTopLevelChannelGroupPolicySetter,
|
||||
mergeAllowFromEntries,
|
||||
patchTopLevelChannelConfigSection,
|
||||
setSetupChannelEnabled,
|
||||
splitSetupEntries,
|
||||
createSetupTranslator,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
type OpenClawConfig,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import type { MSTeamsTeamConfig } from "../runtime-api.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import {
|
||||
parseMSTeamsTeamEntry,
|
||||
@@ -131,17 +131,7 @@ function setMSTeamsTeamsAllowlist(
|
||||
teams[teamKey] = existing;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
teams: teams as Record<string, MSTeamsTeamConfig>,
|
||||
},
|
||||
},
|
||||
};
|
||||
return patchTopLevelChannelConfigSection({ cfg, channel, enabled: true, patch: { teams } });
|
||||
}
|
||||
|
||||
function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getOpenRouterModelCapabilities,
|
||||
loadOpenRouterModelCapabilities,
|
||||
} from "openclaw/plugin-sdk/provider-stream-family";
|
||||
import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
@@ -88,12 +89,6 @@ function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model:
|
||||
};
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function sanitizePromptModelId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
asOptionalRecord,
|
||||
asPositiveSafeInteger,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const OPENROUTER_MODELS_ENDPOINT = `${OPENROUTER_BASE_URL}/models`;
|
||||
@@ -35,12 +40,8 @@ const OPENROUTER_KIMI_K2_5_COST = {
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined): string {
|
||||
return (baseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
const normalized = baseUrl?.trim().replace(/\/+$/, "");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -97,25 +98,6 @@ export function buildOpenrouterProvider(): ModelProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readPositiveInteger(
|
||||
record: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown> | undefined, key: string): string[] {
|
||||
const value = record?.[key];
|
||||
return Array.isArray(value)
|
||||
@@ -138,7 +120,7 @@ function readOpenRouterModalities(
|
||||
if (explicit.length > 0) {
|
||||
return explicit;
|
||||
}
|
||||
const modality = readString(architecture, "modality");
|
||||
const modality = normalizeOptionalString(architecture?.modality);
|
||||
if (!modality) {
|
||||
return [];
|
||||
}
|
||||
@@ -147,20 +129,20 @@ function readOpenRouterModalities(
|
||||
}
|
||||
|
||||
function buildOpenRouterLiveModel(row: unknown): ModelDefinitionConfig | undefined {
|
||||
const record = readRecord(row);
|
||||
const id = readString(record, "id");
|
||||
const architecture = readRecord(record?.architecture);
|
||||
const record = asOptionalRecord(row);
|
||||
const id = normalizeOptionalString(record?.id);
|
||||
const architecture = asOptionalRecord(record?.architecture);
|
||||
const outputModalities = readOpenRouterModalities(architecture, "output");
|
||||
if (!id || (outputModalities.length > 0 && !outputModalities.includes("text"))) {
|
||||
return undefined;
|
||||
}
|
||||
const inputModalities = readOpenRouterModalities(architecture, "input");
|
||||
const supportedParameters = readStringArray(record, "supported_parameters");
|
||||
const topProvider = readRecord(record?.top_provider);
|
||||
const pricing = readRecord(record?.pricing);
|
||||
const topProvider = asOptionalRecord(record?.top_provider);
|
||||
const pricing = asOptionalRecord(record?.pricing);
|
||||
return {
|
||||
id,
|
||||
name: readString(record, "name") ?? id,
|
||||
name: normalizeOptionalString(record?.name) ?? id,
|
||||
reasoning:
|
||||
supportedParameters.includes("reasoning") ||
|
||||
supportedParameters.includes("include_reasoning"),
|
||||
@@ -172,24 +154,17 @@ function buildOpenRouterLiveModel(row: unknown): ModelDefinitionConfig | undefin
|
||||
cacheWrite: readTokenPrice(pricing, "input_cache_write"),
|
||||
},
|
||||
contextWindow:
|
||||
readPositiveInteger(topProvider, "context_length") ??
|
||||
readPositiveInteger(record, "context_length") ??
|
||||
asPositiveSafeInteger(topProvider?.context_length) ??
|
||||
asPositiveSafeInteger(record?.context_length) ??
|
||||
OPENROUTER_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens:
|
||||
readPositiveInteger(topProvider, "max_completion_tokens") ??
|
||||
readPositiveInteger(record, "max_completion_tokens") ??
|
||||
readPositiveInteger(record, "max_output_tokens") ??
|
||||
asPositiveSafeInteger(topProvider?.max_completion_tokens) ??
|
||||
asPositiveSafeInteger(record?.max_completion_tokens) ??
|
||||
asPositiveSafeInteger(record?.max_output_tokens) ??
|
||||
OPENROUTER_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOpenRouterLiveModels(rows: readonly unknown[]): ModelDefinitionConfig[] {
|
||||
const models = rows
|
||||
.map(buildOpenRouterLiveModel)
|
||||
.filter((model): model is ModelDefinitionConfig => Boolean(model));
|
||||
return [...new Map(models.map((model) => [model.id, model])).values()];
|
||||
}
|
||||
|
||||
export async function buildOpenrouterLiveProvider(params: {
|
||||
apiKey?: string;
|
||||
discoveryApiKey?: string;
|
||||
@@ -212,15 +187,18 @@ export async function buildOpenrouterLiveProvider(params: {
|
||||
ttlMs: OPENROUTER_MODELS_CACHE_TTL_MS,
|
||||
auditContext: "openrouter-model-discovery",
|
||||
projectRows: (rows, fallbackProvider) => {
|
||||
const liveModels = parseOpenRouterLiveModels(rows);
|
||||
const liveModels = rows.flatMap((row) => {
|
||||
const model = buildOpenRouterLiveModel(row);
|
||||
return model ? [model] : [];
|
||||
});
|
||||
if (liveModels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const models = new Map(fallbackProvider.models.map((model) => [model.id, model]));
|
||||
for (const model of liveModels) {
|
||||
models.set(model.id, model);
|
||||
}
|
||||
return [...models.values()].toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
return [
|
||||
...new Map(
|
||||
[...fallbackProvider.models, ...liveModels].map((model) => [model.id, model]),
|
||||
).values(),
|
||||
].toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,6 +68,45 @@ function streamedJsonResponse(payload: unknown): Response {
|
||||
);
|
||||
}
|
||||
|
||||
function mockPixVerseVideoSubmit(videoId = 123) {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: videoId },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
}
|
||||
|
||||
function mockPixVerseVideoTask(
|
||||
params: {
|
||||
videoId?: number;
|
||||
videoUrl?: string;
|
||||
seed?: number;
|
||||
outputWidth?: number;
|
||||
outputHeight?: number;
|
||||
} = {},
|
||||
) {
|
||||
const videoId = params.videoId ?? 123;
|
||||
mockPixVerseVideoSubmit(videoId);
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: {
|
||||
id: videoId,
|
||||
status: 1,
|
||||
url: params.videoUrl ?? "https://media.pixverse.ai/out.mp4",
|
||||
...(params.seed === undefined ? {} : { seed: params.seed }),
|
||||
...(params.outputWidth === undefined ? {} : { outputWidth: params.outputWidth }),
|
||||
...(params.outputHeight === undefined ? {} : { outputHeight: params.outputHeight }),
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
}
|
||||
|
||||
// Drives an unbounded JSON body (>16 MiB, no Content-Length) so the bounded
|
||||
// reader has to cancel the stream instead of buffering it all. A hard ceiling
|
||||
// guards the test from hanging if the reader ever fails to cancel.
|
||||
@@ -105,29 +144,7 @@ describe("pixverse video generation provider", () => {
|
||||
});
|
||||
|
||||
it("submits text-to-video, polls status, and returns the output URL", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: {
|
||||
id: 123,
|
||||
status: 1,
|
||||
url: "https://media.pixverse.ai/out.mp4",
|
||||
seed: 42,
|
||||
outputWidth: 960,
|
||||
outputHeight: 540,
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask({ seed: 42, outputWidth: 960, outputHeight: 540 });
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
@@ -191,22 +208,7 @@ describe("pixverse video generation provider", () => {
|
||||
});
|
||||
|
||||
it("drops malformed seed values before creating videos", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask();
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
@@ -223,27 +225,7 @@ describe("pixverse video generation provider", () => {
|
||||
});
|
||||
|
||||
it("drops malformed response seed metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: {
|
||||
id: 123,
|
||||
status: 1,
|
||||
url: "https://media.pixverse.ai/out.mp4",
|
||||
seed: 1.5,
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask({ seed: 1.5 });
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
@@ -263,14 +245,7 @@ describe("pixverse video generation provider", () => {
|
||||
});
|
||||
|
||||
it("rejects fractional video ids before polling", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123.5 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockPixVerseVideoSubmit(123.5);
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await expect(
|
||||
@@ -338,22 +313,7 @@ describe("pixverse video generation provider", () => {
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 789 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 789, status: 1, url: "https://media.pixverse.ai/i2v.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask({ videoId: 789, videoUrl: "https://media.pixverse.ai/i2v.mp4" });
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
@@ -398,22 +358,7 @@ describe("pixverse video generation provider", () => {
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 222 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 222, status: 1, url: "https://media.pixverse.ai/remote.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask({ videoId: 222, videoUrl: "https://media.pixverse.ai/remote.mp4" });
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
@@ -452,14 +397,7 @@ describe("pixverse video generation provider", () => {
|
||||
});
|
||||
|
||||
it("reports PixVerse moderation failures from status polling", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 333 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockPixVerseVideoSubmit(333);
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
@@ -480,44 +418,44 @@ describe("pixverse video generation provider", () => {
|
||||
).rejects.toThrow("PixVerse video generation failed content moderation");
|
||||
});
|
||||
|
||||
it("uses configured baseUrl", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "uses configured baseUrl",
|
||||
providerConfig: { baseUrl: "https://proxy.example/openapi/v2" },
|
||||
expectedBaseUrl: "https://proxy.example/openapi/v2",
|
||||
prompt: "custom base",
|
||||
},
|
||||
{
|
||||
name: "uses the configured CN API region",
|
||||
providerConfig: { region: "cn" },
|
||||
expectedBaseUrl: "https://app-api.pixverseai.cn/openapi/v2",
|
||||
prompt: "cn endpoint",
|
||||
},
|
||||
{
|
||||
name: "prefers configured baseUrl over API region",
|
||||
providerConfig: { baseUrl: "https://proxy.example/openapi/v2", region: "cn" },
|
||||
expectedBaseUrl: "https://proxy.example/openapi/v2",
|
||||
prompt: "custom base",
|
||||
},
|
||||
])("$name", async ({ providerConfig, expectedBaseUrl, prompt }) => {
|
||||
mockPixVerseVideoTask();
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "custom base",
|
||||
prompt,
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
},
|
||||
pixverse: providerConfig,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://proxy.example/openapi/v2/video/result/123",
|
||||
);
|
||||
expect(firstPostJsonRequest().url).toBe(`${expectedBaseUrl}/video/text/generate`);
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(`${expectedBaseUrl}/video/result/123`);
|
||||
});
|
||||
|
||||
it("uses the guarded provider transport for status polling", async () => {
|
||||
@@ -528,22 +466,7 @@ describe("pixverse video generation provider", () => {
|
||||
headers: new Headers({ "API-KEY": "provider-key", "X-Proxy": "enabled" }),
|
||||
dispatcherPolicy,
|
||||
} as never);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask();
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
@@ -569,22 +492,7 @@ describe("pixverse video generation provider", () => {
|
||||
allowPrivateNetwork: true,
|
||||
headers: { "X-Proxy": "enabled" },
|
||||
};
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
mockPixVerseVideoTask();
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
@@ -609,14 +517,7 @@ describe("pixverse video generation provider", () => {
|
||||
});
|
||||
|
||||
it("uses a fresh trace id for each status poll", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockPixVerseVideoSubmit();
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
@@ -649,87 +550,4 @@ describe("pixverse video generation provider", () => {
|
||||
expect(secondHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
expect(secondHeaders?.get("Ai-trace-id")).not.toBe(firstHeaders?.get("Ai-trace-id"));
|
||||
});
|
||||
|
||||
it("uses the configured CN API region", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "cn endpoint",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
region: "cn",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe(
|
||||
"https://app-api.pixverseai.cn/openapi/v2/video/text/generate",
|
||||
);
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://app-api.pixverseai.cn/openapi/v2/video/result/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers configured baseUrl over API region", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "custom base",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
region: "cn",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://proxy.example/openapi/v2/video/result/123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,18 +135,10 @@ function createSlackTokenCredential(params: {
|
||||
allowEnv: ({ accountId }: { accountId: string }) =>
|
||||
Boolean(params.preferredEnvVar) && accountId === DEFAULT_ACCOUNT_ID,
|
||||
resolveAccount: ({ cfg, accountId }) => inspectSlackAccount({ cfg, accountId }),
|
||||
resolvedValue: (account) => {
|
||||
if (params.inputKey === "botToken") {
|
||||
return normalizeOptionalString(account.botToken);
|
||||
}
|
||||
if (params.inputKey === "appToken") {
|
||||
return normalizeOptionalString(account.appToken);
|
||||
}
|
||||
if (params.inputKey === "userToken") {
|
||||
return normalizeOptionalString(account.userToken);
|
||||
}
|
||||
return normalizeSecretInputString(account.config.signingSecret);
|
||||
},
|
||||
resolvedValue: (account) =>
|
||||
params.inputKey === "signingSecret"
|
||||
? normalizeSecretInputString(account.config.signingSecret)
|
||||
: normalizeOptionalString(account[params.inputKey]),
|
||||
envValue: ({ accountId }) =>
|
||||
params.preferredEnvVar && accountId === DEFAULT_ACCOUNT_ID
|
||||
? normalizeOptionalString(process.env[params.preferredEnvVar])
|
||||
|
||||
@@ -110,6 +110,34 @@ function requireFetchInitCall(index: number): {
|
||||
};
|
||||
}
|
||||
|
||||
function mockXaiVideoRequest(requestId: string) {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: { json: async () => ({ request_id: requestId }) },
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
}
|
||||
|
||||
function mockXaiVideoTask(params: {
|
||||
requestId: string;
|
||||
videoUrl: string;
|
||||
videoBytes: string;
|
||||
mimeType?: string;
|
||||
}) {
|
||||
mockXaiVideoRequest(params.requestId);
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: params.requestId,
|
||||
status: "done",
|
||||
video: { url: params.videoUrl },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": params.mimeType ?? "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from(params.videoBytes),
|
||||
});
|
||||
}
|
||||
|
||||
function streamedVideoResponse(bytes: string, contentType = "video/mp4"): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
@@ -316,26 +344,12 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("creates, polls, and downloads a generated video", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req_123",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
mockXaiVideoTask({
|
||||
requestId: "req_123",
|
||||
videoUrl: "https://cdn.x.ai/video.mp4",
|
||||
videoBytes: "webm-bytes",
|
||||
mimeType: "video/webm",
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_123",
|
||||
status: "done",
|
||||
video: { url: "https://cdn.x.ai/video.mp4" },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/webm" }),
|
||||
arrayBuffer: async () => Buffer.from("webm-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildXaiVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
@@ -383,26 +397,11 @@ describe("xai video generation provider", () => {
|
||||
}),
|
||||
dispatcherPolicy: dispatcherPolicy as never,
|
||||
}));
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req_policy",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
mockXaiVideoTask({
|
||||
requestId: "req_policy",
|
||||
videoUrl: "https://cdn.x.ai/policy.mp4",
|
||||
videoBytes: "video-bytes",
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_policy",
|
||||
status: "done",
|
||||
video: { url: "https://cdn.x.ai/policy.mp4" },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("video-bytes"),
|
||||
});
|
||||
|
||||
await buildXaiVideoGenerationProvider().generateVideo({
|
||||
provider: "xai",
|
||||
@@ -466,12 +465,7 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("rejects generated video downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({ request_id: "req_too_large" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockXaiVideoRequest("req_too_large");
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
@@ -575,12 +569,7 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("treats unknown xAI poll statuses as continue-polling and returns when terminal", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({ request_id: "req_unknown_then_done" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockXaiVideoRequest("req_unknown_then_done");
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
@@ -619,12 +608,7 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("treats `cancelled` as a terminal failure", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({ request_id: "req_cancelled" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockXaiVideoRequest("req_cancelled");
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_cancelled",
|
||||
@@ -644,12 +628,7 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("rejects completed xAI poll responses without output URLs as malformed", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({ request_id: "req_no_video" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockXaiVideoRequest("req_no_video");
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_no_video",
|
||||
@@ -670,14 +649,7 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("normalizes the xAI 'pending' poll status to 'processing' and keeps polling until done", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req_pending",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mockXaiVideoRequest("req_pending");
|
||||
fetchWithTimeoutMock
|
||||
// First poll: in-progress payload mirroring xAI's real shape
|
||||
.mockResolvedValueOnce({
|
||||
@@ -720,26 +692,11 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("sends a single unroled image as xAI first-frame image-to-video", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req_image",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
mockXaiVideoTask({
|
||||
requestId: "req_image",
|
||||
videoUrl: "https://cdn.x.ai/image-video.mp4",
|
||||
videoBytes: "image-video-bytes",
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_image",
|
||||
status: "done",
|
||||
video: { url: "https://cdn.x.ai/image-video.mp4" },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("image-video-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildXaiVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
@@ -762,26 +719,11 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("sends reference_image roles through xAI reference_images mode", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req_refs",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
mockXaiVideoTask({
|
||||
requestId: "req_refs",
|
||||
videoUrl: "https://cdn.x.ai/reference-video.mp4",
|
||||
videoBytes: "reference-video-bytes",
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_refs",
|
||||
status: "done",
|
||||
video: { url: "https://cdn.x.ai/reference-video.mp4" },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("reference-video-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildXaiVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
@@ -833,26 +775,11 @@ describe("xai video generation provider", () => {
|
||||
});
|
||||
|
||||
it("routes video inputs to the extension endpoint when duration is set", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req_extend",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
mockXaiVideoTask({
|
||||
requestId: "req_extend",
|
||||
videoUrl: "https://cdn.x.ai/extended.mp4",
|
||||
videoBytes: "extended-bytes",
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
request_id: "req_extend",
|
||||
status: "done",
|
||||
video: { url: "https://cdn.x.ai/extended.mp4" },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("extended-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildXaiVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
|
||||
@@ -973,6 +973,28 @@ export function collectDatabaseFirstLegacyStoreViolations(
|
||||
}
|
||||
}
|
||||
|
||||
function visitObjectLiteralProperties(objectName, initializer, onProperty, onSpread = null) {
|
||||
const objectLiteral = unwrapExpression(initializer);
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) {
|
||||
return;
|
||||
}
|
||||
for (const property of objectLiteral.properties) {
|
||||
if (ts.isSpreadAssignment(property)) {
|
||||
onSpread?.(unwrapExpression(property.expression));
|
||||
continue;
|
||||
}
|
||||
const isAssignment = ts.isPropertyAssignment(property);
|
||||
const name = isAssignment
|
||||
? propertyNameText(property.name)
|
||||
: ts.isShorthandPropertyAssignment(property)
|
||||
? property.name.text
|
||||
: null;
|
||||
if (name) {
|
||||
onProperty(`${objectName}.${name}`, isAssignment ? property.initializer : property.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegacyPathIdentifier(name) {
|
||||
return scopeForRead(legacyPathScopes, name)?.get(name) === true;
|
||||
}
|
||||
@@ -1719,32 +1741,9 @@ export function collectDatabaseFirstLegacyStoreViolations(
|
||||
scope = lastScope(fsWriteAliasScopes),
|
||||
conditionalWrite = false,
|
||||
) {
|
||||
const objectLiteral = unwrapExpression(initializer);
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) {
|
||||
return;
|
||||
}
|
||||
for (const property of objectLiteral.properties) {
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = propertyNameText(property.name);
|
||||
if (name) {
|
||||
setFsWriteObjectAlias(
|
||||
scope,
|
||||
`${objectName}.${name}`,
|
||||
legacyFsWriteName(property.initializer),
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(property)) {
|
||||
setFsWriteObjectAlias(
|
||||
scope,
|
||||
`${objectName}.${property.name.text}`,
|
||||
resolveFsWriteAlias(property.name.text),
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
visitObjectLiteralProperties(objectName, initializer, (name, value) => {
|
||||
setFsWriteObjectAlias(scope, name, legacyFsWriteName(value), conditionalWrite);
|
||||
});
|
||||
}
|
||||
|
||||
function clearFsSafeStoreObjectAliases(storeScope, jsonStoreScope, objectName) {
|
||||
@@ -1813,47 +1812,29 @@ export function collectDatabaseFirstLegacyStoreViolations(
|
||||
jsonStoreScope = lastScope(fsSafeJsonStoreScopes),
|
||||
conditionalWrite = false,
|
||||
) {
|
||||
const objectLiteral = unwrapExpression(initializer);
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) {
|
||||
return;
|
||||
}
|
||||
for (const property of objectLiteral.properties) {
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = propertyNameText(property.name);
|
||||
if (name) {
|
||||
setFsSafeStoreObjectAlias(
|
||||
storeScope,
|
||||
jsonStoreScope,
|
||||
`${objectName}.${name}`,
|
||||
isFsSafeStoreExpression(property.initializer),
|
||||
expressionContainsFsSafeJsonStoreLegacyPath(property.initializer),
|
||||
conditionalWrite,
|
||||
);
|
||||
if (ts.isObjectLiteralExpression(unwrapExpression(property.initializer))) {
|
||||
registerFsSafeStoreObjectAliases(
|
||||
`${objectName}.${name}`,
|
||||
property.initializer,
|
||||
storeScope,
|
||||
jsonStoreScope,
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(property)) {
|
||||
visitObjectLiteralProperties(
|
||||
objectName,
|
||||
initializer,
|
||||
(name, value) => {
|
||||
setFsSafeStoreObjectAlias(
|
||||
storeScope,
|
||||
jsonStoreScope,
|
||||
`${objectName}.${property.name.text}`,
|
||||
resolveFsSafeStore(property.name.text),
|
||||
resolveFsSafeJsonStore(property.name.text),
|
||||
name,
|
||||
isFsSafeStoreExpression(value),
|
||||
expressionContainsFsSafeJsonStoreLegacyPath(value),
|
||||
conditionalWrite,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (ts.isSpreadAssignment(property)) {
|
||||
const spreadExpression = unwrapExpression(property.expression);
|
||||
if (ts.isObjectLiteralExpression(unwrapExpression(value))) {
|
||||
registerFsSafeStoreObjectAliases(
|
||||
name,
|
||||
value,
|
||||
storeScope,
|
||||
jsonStoreScope,
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
},
|
||||
(spreadExpression) => {
|
||||
if (ts.isIdentifier(spreadExpression)) {
|
||||
copyFsSafeStoreObjectAliases(
|
||||
objectName,
|
||||
@@ -1870,8 +1851,8 @@ export function collectDatabaseFirstLegacyStoreViolations(
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setFsModuleObjectProperty(scope, name, isFsModule, conditionalWrite) {
|
||||
@@ -1893,32 +1874,9 @@ export function collectDatabaseFirstLegacyStoreViolations(
|
||||
scope = lastScope(fsModulePropertyScopes),
|
||||
conditionalWrite = false,
|
||||
) {
|
||||
const objectLiteral = unwrapExpression(initializer);
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) {
|
||||
return;
|
||||
}
|
||||
for (const property of objectLiteral.properties) {
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = propertyNameText(property.name);
|
||||
if (name) {
|
||||
setFsModuleObjectProperty(
|
||||
scope,
|
||||
`${objectName}.${name}`,
|
||||
isFsModuleExpression(property.initializer),
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(property)) {
|
||||
setFsModuleObjectProperty(
|
||||
scope,
|
||||
`${objectName}.${property.name.text}`,
|
||||
resolveFsModuleBinding(property.name.text),
|
||||
conditionalWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
visitObjectLiteralProperties(objectName, initializer, (name, value) => {
|
||||
setFsModuleObjectProperty(scope, name, isFsModuleExpression(value), conditionalWrite);
|
||||
});
|
||||
}
|
||||
|
||||
function collectFsModuleBindingsFromBinding(node) {
|
||||
@@ -4091,30 +4049,9 @@ export function collectDatabaseFirstLegacyStoreViolations(
|
||||
initializer,
|
||||
scope = lastScope(bodyFsWriteAliasScopes),
|
||||
) {
|
||||
const objectLiteral = unwrapExpression(initializer);
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) {
|
||||
return;
|
||||
}
|
||||
for (const property of objectLiteral.properties) {
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = propertyNameText(property.name);
|
||||
if (name) {
|
||||
setBodyFsWriteObjectAlias(
|
||||
scope,
|
||||
`${objectName}.${name}`,
|
||||
legacyWrapperFsWriteName(property.initializer),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(property)) {
|
||||
setBodyFsWriteObjectAlias(
|
||||
scope,
|
||||
`${objectName}.${property.name.text}`,
|
||||
resolveBodyFsWriteAlias(property.name.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
visitObjectLiteralProperties(objectName, initializer, (name, value) => {
|
||||
setBodyFsWriteObjectAlias(scope, name, legacyWrapperFsWriteName(value));
|
||||
});
|
||||
}
|
||||
|
||||
function isWrapperFsBindingExpression(expression) {
|
||||
|
||||
@@ -364,59 +364,51 @@ async function killSubagentRun(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function cascadeKillChildren(params: {
|
||||
async function killSubagentRunTree(params: {
|
||||
cfg: OpenClawConfig;
|
||||
parentChildSessionKey: string;
|
||||
runs: Iterable<SubagentRunRecord>;
|
||||
cache: Map<string, Record<string, SessionEntry>>;
|
||||
seenChildSessionKeys?: Set<string>;
|
||||
seenChildSessionKeys: Set<string>;
|
||||
controllerSessionKey?: string;
|
||||
}): Promise<{ killed: number; labels: string[] }> {
|
||||
const childRunsBySessionKey = new Map<string, SubagentRunRecord>();
|
||||
for (const run of listSubagentRunsForController(params.parentChildSessionKey)) {
|
||||
const childKey = run.childSessionKey?.trim();
|
||||
if (!childKey) {
|
||||
continue;
|
||||
}
|
||||
const latest = getLatestSubagentRunByChildSessionKey(childKey);
|
||||
const latestControllerSessionKey =
|
||||
latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
|
||||
if (
|
||||
!latest ||
|
||||
latest.runId !== run.runId ||
|
||||
latestControllerSessionKey !== params.parentChildSessionKey
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
childRunsBySessionKey.set(childKey, run);
|
||||
}
|
||||
const childRuns = Array.from(childRunsBySessionKey.values());
|
||||
const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set<string>();
|
||||
let killed = 0;
|
||||
const labels: string[] = [];
|
||||
|
||||
for (const run of childRuns) {
|
||||
for (const run of params.runs) {
|
||||
const childKey = run.childSessionKey?.trim();
|
||||
if (!childKey || seenChildSessionKeys.has(childKey)) {
|
||||
if (!childKey || params.seenChildSessionKeys.has(childKey)) {
|
||||
continue;
|
||||
}
|
||||
seenChildSessionKeys.add(childKey);
|
||||
const latest = getLatestSubagentRunByChildSessionKey(childKey);
|
||||
if (!latest || latest.runId !== run.runId) {
|
||||
continue;
|
||||
}
|
||||
const latestControllerSessionKey =
|
||||
latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim();
|
||||
if (params.controllerSessionKey && latestControllerSessionKey !== params.controllerSessionKey) {
|
||||
continue;
|
||||
}
|
||||
params.seenChildSessionKeys.add(childKey);
|
||||
const entry = params.controllerSessionKey ? run : latest;
|
||||
|
||||
if (!run.endedAt || run.pauseReason === "sessions_yield") {
|
||||
if (!entry.endedAt || entry.pauseReason === "sessions_yield") {
|
||||
const stopResult = await killSubagentRun({
|
||||
cfg: params.cfg,
|
||||
entry: run,
|
||||
entry,
|
||||
cache: params.cache,
|
||||
});
|
||||
if (stopResult.killed) {
|
||||
killed += 1;
|
||||
labels.push(resolveSubagentLabel(run));
|
||||
labels.push(resolveSubagentLabel(entry));
|
||||
}
|
||||
}
|
||||
|
||||
const cascade = await cascadeKillChildren({
|
||||
const cascade = await killSubagentRunTree({
|
||||
cfg: params.cfg,
|
||||
parentChildSessionKey: childKey,
|
||||
runs: listSubagentRunsForController(childKey),
|
||||
cache: params.cache,
|
||||
seenChildSessionKeys,
|
||||
seenChildSessionKeys: params.seenChildSessionKeys,
|
||||
controllerSessionKey: childKey,
|
||||
});
|
||||
killed += cascade.killed;
|
||||
labels.push(...cascade.labels);
|
||||
@@ -425,6 +417,21 @@ async function cascadeKillChildren(params: {
|
||||
return { killed, labels };
|
||||
}
|
||||
|
||||
async function cascadeKillChildren(params: {
|
||||
cfg: OpenClawConfig;
|
||||
parentChildSessionKey: string;
|
||||
cache: Map<string, Record<string, SessionEntry>>;
|
||||
seenChildSessionKeys?: Set<string>;
|
||||
}): Promise<{ killed: number; labels: string[] }> {
|
||||
return killSubagentRunTree({
|
||||
cfg: params.cfg,
|
||||
runs: listSubagentRunsForController(params.parentChildSessionKey),
|
||||
cache: params.cache,
|
||||
seenChildSessionKeys: params.seenChildSessionKeys ?? new Set<string>(),
|
||||
controllerSessionKey: params.parentChildSessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
/** Kills every currently controlled child run and its descendants. */
|
||||
export async function killAllControlledSubagentRuns(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -439,39 +446,13 @@ export async function killAllControlledSubagentRuns(params: {
|
||||
labels: [],
|
||||
};
|
||||
}
|
||||
const cache = new Map<string, Record<string, SessionEntry>>();
|
||||
const seenChildSessionKeys = new Set<string>();
|
||||
const killedLabels: string[] = [];
|
||||
let killed = 0;
|
||||
for (const entry of params.runs) {
|
||||
const childKey = entry.childSessionKey?.trim();
|
||||
if (!childKey || seenChildSessionKeys.has(childKey)) {
|
||||
continue;
|
||||
}
|
||||
const currentEntry = getLatestSubagentRunByChildSessionKey(childKey);
|
||||
if (!currentEntry || currentEntry.runId !== entry.runId) {
|
||||
continue;
|
||||
}
|
||||
seenChildSessionKeys.add(childKey);
|
||||
|
||||
if (!currentEntry.endedAt || currentEntry.pauseReason === "sessions_yield") {
|
||||
const stopResult = await killSubagentRun({ cfg: params.cfg, entry: currentEntry, cache });
|
||||
if (stopResult.killed) {
|
||||
killed += 1;
|
||||
killedLabels.push(resolveSubagentLabel(currentEntry));
|
||||
}
|
||||
}
|
||||
|
||||
const cascade = await cascadeKillChildren({
|
||||
cfg: params.cfg,
|
||||
parentChildSessionKey: childKey,
|
||||
cache,
|
||||
seenChildSessionKeys,
|
||||
});
|
||||
killed += cascade.killed;
|
||||
killedLabels.push(...cascade.labels);
|
||||
}
|
||||
return { status: "ok" as const, killed, labels: killedLabels };
|
||||
const result = await killSubagentRunTree({
|
||||
cfg: params.cfg,
|
||||
runs: params.runs,
|
||||
cache: new Map<string, Record<string, SessionEntry>>(),
|
||||
seenChildSessionKeys: new Set<string>(),
|
||||
});
|
||||
return { status: "ok" as const, ...result };
|
||||
}
|
||||
|
||||
/** Kills one controlled subagent run and any active descendants. */
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
countActiveDescendantRunsFromRuns,
|
||||
countActiveRunsForSessionFromRuns,
|
||||
countPendingDescendantRunsFromRuns,
|
||||
getLatestSubagentRunByChildSessionKeyFromRuns,
|
||||
getSubagentRunByChildSessionKeyFromRuns,
|
||||
listDescendantRunsForRequesterFromRuns,
|
||||
listRunsForControllerFromRuns,
|
||||
} from "./subagent-registry-queries.js";
|
||||
import { markRequesterTurnYieldedInRuns } from "./subagent-registry-requester-yield.js";
|
||||
import type { SubagentRunRecord, SwarmStructuredOutputState } from "./subagent-registry.types.js";
|
||||
import { compareSubagentRunGeneration } from "./subagent-run-generation.js";
|
||||
|
||||
export function createSubagentRegistryPublicApi(config: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
@@ -256,17 +256,12 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latest: SubagentRunRecord | null = null;
|
||||
for (const entry of deps().getSubagentRunsSnapshotForChildSession(runs, key).values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || compareSubagentRunGeneration(entry, latest) > 0) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
return (
|
||||
getLatestSubagentRunByChildSessionKeyFromRuns(
|
||||
deps().getSubagentRunsSnapshotForChildSession(runs, key),
|
||||
key,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-admits a delivered child batch after its requester explicitly yields. */
|
||||
|
||||
@@ -139,13 +139,7 @@ export function buildSubagentRunReadIndexFromRuns(params: {
|
||||
}): SubagentRunReadIndex {
|
||||
const { runs } = params;
|
||||
const now = params.now ?? Date.now();
|
||||
const inMemoryDisplayByChildSessionKey = new Map<
|
||||
string,
|
||||
{
|
||||
latestInMemoryActive: SubagentRunRecord | null;
|
||||
latestInMemoryEnded: SubagentRunRecord | null;
|
||||
}
|
||||
>();
|
||||
const inMemoryDisplayByChildSessionKey = new Map<string, SubagentRunRecord>();
|
||||
const latestSnapshotActiveByChildSessionKey = new Map<string, SubagentRunRecord>();
|
||||
const latestSnapshotEndedByChildSessionKey = new Map<string, SubagentRunRecord>();
|
||||
const latestRunByChildSessionKey = new Map<string, LatestRunPair>();
|
||||
@@ -158,26 +152,7 @@ export function buildSubagentRunReadIndexFromRuns(params: {
|
||||
if (!childSessionKey) {
|
||||
continue;
|
||||
}
|
||||
let display = inMemoryDisplayByChildSessionKey.get(childSessionKey);
|
||||
if (!display) {
|
||||
display = { latestInMemoryActive: null, latestInMemoryEnded: null };
|
||||
inMemoryDisplayByChildSessionKey.set(childSessionKey, display);
|
||||
}
|
||||
if (hasSubagentRunEnded(entry)) {
|
||||
if (
|
||||
!display.latestInMemoryEnded ||
|
||||
compareSubagentRunGeneration(entry, display.latestInMemoryEnded) > 0
|
||||
) {
|
||||
display.latestInMemoryEnded = entry;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!display.latestInMemoryActive ||
|
||||
compareSubagentRunGeneration(entry, display.latestInMemoryActive) > 0
|
||||
) {
|
||||
display.latestInMemoryActive = entry;
|
||||
}
|
||||
rememberLatestRunEntry(inMemoryDisplayByChildSessionKey, childSessionKey, entry);
|
||||
}
|
||||
|
||||
for (const [runId, entry] of runs.entries()) {
|
||||
@@ -218,22 +193,8 @@ export function buildSubagentRunReadIndexFromRuns(params: {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const inMemoryDisplay = inMemoryDisplayByChildSessionKey.get(key);
|
||||
if (inMemoryDisplay) {
|
||||
const latestInMemoryEnded = inMemoryDisplay.latestInMemoryEnded;
|
||||
const latestInMemoryActive = inMemoryDisplay.latestInMemoryActive;
|
||||
if (latestInMemoryEnded || latestInMemoryActive) {
|
||||
if (
|
||||
latestInMemoryEnded &&
|
||||
(!latestInMemoryActive ||
|
||||
compareSubagentRunGeneration(latestInMemoryEnded, latestInMemoryActive) > 0)
|
||||
) {
|
||||
return latestInMemoryEnded;
|
||||
}
|
||||
return latestInMemoryActive ?? latestInMemoryEnded;
|
||||
}
|
||||
}
|
||||
return (
|
||||
inMemoryDisplayByChildSessionKey.get(key) ??
|
||||
latestSnapshotActiveByChildSessionKey.get(key) ??
|
||||
latestSnapshotEndedByChildSessionKey.get(key) ??
|
||||
null
|
||||
@@ -290,7 +251,8 @@ export function buildSubagentRunReadIndexFromRuns(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestRunForChildSession(
|
||||
/** Returns the latest-generation run for a child session. */
|
||||
export function getLatestSubagentRunByChildSessionKeyFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
childSessionKey: string,
|
||||
): SubagentRunRecord | undefined {
|
||||
@@ -315,7 +277,7 @@ export function isSubagentSessionRunActiveFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
childSessionKey: string,
|
||||
): boolean {
|
||||
const latest = findLatestRunForChildSession(runs, childSessionKey);
|
||||
const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey);
|
||||
return Boolean(latest && isLiveUnendedSubagentRun(latest));
|
||||
}
|
||||
|
||||
@@ -357,7 +319,7 @@ export function resolveRequesterForChildSessionFromRuns(
|
||||
requesterSessionKey: string;
|
||||
requesterOrigin?: DeliveryContext;
|
||||
} | null {
|
||||
const latest = findLatestRunForChildSession(runs, childSessionKey);
|
||||
const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey);
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
@@ -372,7 +334,7 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
childSessionKey: string,
|
||||
): boolean {
|
||||
const latest = findLatestRunForChildSession(runs, childSessionKey);
|
||||
const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey);
|
||||
return Boolean(
|
||||
latest &&
|
||||
latest.spawnMode !== "session" &&
|
||||
@@ -436,10 +398,10 @@ function forEachDescendantRun(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
visitor: (runId: string, entry: SubagentRunRecord) => void | boolean,
|
||||
): boolean {
|
||||
): void {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([root]);
|
||||
@@ -459,7 +421,10 @@ function forEachDescendantRun(
|
||||
}
|
||||
}
|
||||
for (const [runId, entry] of latestByChildSessionKey.values()) {
|
||||
const latestForChildSession = findLatestRunForChildSession(runs, entry.childSessionKey);
|
||||
const latestForChildSession = getLatestSubagentRunByChildSessionKeyFromRuns(
|
||||
runs,
|
||||
entry.childSessionKey,
|
||||
);
|
||||
if (
|
||||
!latestForChildSession ||
|
||||
latestForChildSession.runId !== runId ||
|
||||
@@ -469,7 +434,7 @@ function forEachDescendantRun(
|
||||
}
|
||||
// A visitor may stop the traversal early by returning true.
|
||||
if (visitor(runId, entry) === true) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
const childKey = entry.childSessionKey.trim();
|
||||
if (!childKey || visited.has(childKey)) {
|
||||
@@ -479,7 +444,6 @@ function forEachDescendantRun(
|
||||
pending.push(childKey);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Counts live descendants under a requester/session tree. */
|
||||
@@ -488,15 +452,11 @@ export function countActiveDescendantRunsFromRuns(
|
||||
rootSessionKey: string,
|
||||
): number {
|
||||
let count = 0;
|
||||
if (
|
||||
!forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
|
||||
if (isLiveUnendedSubagentRun(entry)) {
|
||||
count += 1;
|
||||
}
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
|
||||
if (isLiveUnendedSubagentRun(entry)) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -511,26 +471,22 @@ function countPendingDescendantRunsInternal(
|
||||
): number {
|
||||
const excludedRunId = options?.excludeRunId?.trim();
|
||||
let count = 0;
|
||||
if (
|
||||
!forEachDescendantRun(runs, rootSessionKey, (runId, entry) => {
|
||||
if (runId === excludedRunId) {
|
||||
return undefined;
|
||||
forEachDescendantRun(runs, rootSessionKey, (runId, entry) => {
|
||||
if (runId === excludedRunId) {
|
||||
return false;
|
||||
}
|
||||
const runPending = hasSubagentRunEnded(entry)
|
||||
? typeof entry.cleanupCompletedAt !== "number" &&
|
||||
!(options?.treatSuspendedDeliveryAsSettled === true && isDeliverySuspended(entry))
|
||||
: isLiveUnendedSubagentRun(entry);
|
||||
if (runPending) {
|
||||
count += 1;
|
||||
if (options?.stopAtFirst === true) {
|
||||
return true;
|
||||
}
|
||||
const runPending = hasSubagentRunEnded(entry)
|
||||
? typeof entry.cleanupCompletedAt !== "number" &&
|
||||
!(options?.treatSuspendedDeliveryAsSettled === true && isDeliverySuspended(entry))
|
||||
: isLiveUnendedSubagentRun(entry);
|
||||
if (runPending) {
|
||||
count += 1;
|
||||
if (options?.stopAtFirst === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -577,12 +533,8 @@ export function listDescendantRunsForRequesterFromRuns(
|
||||
rootSessionKey: string,
|
||||
): SubagentRunRecord[] {
|
||||
const descendants: SubagentRunRecord[] = [];
|
||||
if (
|
||||
!forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
|
||||
descendants.push(entry);
|
||||
})
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
|
||||
descendants.push(entry);
|
||||
});
|
||||
return descendants;
|
||||
}
|
||||
|
||||
@@ -259,4 +259,36 @@ describe("subagent registry read index", () => {
|
||||
|
||||
expect(index.getDisplaySubagentRun(childSessionKey)?.runId).toBe("run-memory-older-ended");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "active over active", olderEndedAt: undefined, newerEndedAt: undefined },
|
||||
{ label: "ended over active", olderEndedAt: undefined, newerEndedAt: 250 },
|
||||
{ label: "active over ended", olderEndedAt: 150, newerEndedAt: undefined },
|
||||
{ label: "ended over ended", olderEndedAt: 150, newerEndedAt: 250 },
|
||||
])("selects the latest in-memory generation: $label", ({ olderEndedAt, newerEndedAt }) => {
|
||||
const childSessionKey = "agent:main:subagent:display-generation";
|
||||
const persisted = makeRun({
|
||||
runId: "run-persisted",
|
||||
childSessionKey,
|
||||
generation: 3,
|
||||
});
|
||||
const older = makeRun({
|
||||
runId: "run-memory-older",
|
||||
childSessionKey,
|
||||
generation: 1,
|
||||
endedAt: olderEndedAt,
|
||||
});
|
||||
const newer = makeRun({
|
||||
runId: "run-memory-newer",
|
||||
childSessionKey,
|
||||
generation: 2,
|
||||
endedAt: newerEndedAt,
|
||||
});
|
||||
const index = buildSubagentRunReadIndexFromRuns({
|
||||
runs: toRunMap([persisted]),
|
||||
inMemoryRuns: [newer, older],
|
||||
});
|
||||
|
||||
expect(index.getDisplaySubagentRun(childSessionKey)).toBe(newer);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildLatestSubagentRunReadIndexFromRuns,
|
||||
buildSubagentRunReadIndexFromRuns,
|
||||
countActiveDescendantRunsFromRuns,
|
||||
getLatestSubagentRunByChildSessionKeyFromRuns,
|
||||
getSubagentRunByChildSessionKeyFromRuns,
|
||||
listDescendantRunsForRequesterFromRuns,
|
||||
listRunsForControllerFromRuns,
|
||||
@@ -86,38 +87,22 @@ export function getSessionDisplaySubagentRunByChildSessionKey(
|
||||
return null;
|
||||
}
|
||||
|
||||
let latestInMemoryActive: SubagentRunRecord | null = null;
|
||||
let latestInMemoryEnded: SubagentRunRecord | null = null;
|
||||
let latestInMemory: SubagentRunRecord | null = null;
|
||||
for (const entry of subagentRuns.values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.endedAt === "number") {
|
||||
if (!latestInMemoryEnded || compareSubagentRunGeneration(entry, latestInMemoryEnded) > 0) {
|
||||
latestInMemoryEnded = entry;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!latestInMemoryActive || compareSubagentRunGeneration(entry, latestInMemoryActive) > 0) {
|
||||
latestInMemoryActive = entry;
|
||||
if (!latestInMemory || compareSubagentRunGeneration(entry, latestInMemory) > 0) {
|
||||
latestInMemory = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestInMemoryEnded || latestInMemoryActive) {
|
||||
// Fresh in-memory terminal state is more accurate than an older active snapshot row.
|
||||
if (
|
||||
latestInMemoryEnded &&
|
||||
(!latestInMemoryActive ||
|
||||
compareSubagentRunGeneration(latestInMemoryEnded, latestInMemoryActive) > 0)
|
||||
) {
|
||||
return latestInMemoryEnded;
|
||||
}
|
||||
return latestInMemoryActive ?? latestInMemoryEnded;
|
||||
}
|
||||
|
||||
return getSubagentRunByChildSessionKeyFromRuns(
|
||||
getSubagentRunsSnapshotForChildSession(subagentRuns, key),
|
||||
key,
|
||||
// Fresh in-memory terminal state is more accurate than an older active snapshot row.
|
||||
return (
|
||||
latestInMemory ??
|
||||
getSubagentRunByChildSessionKeyFromRuns(
|
||||
getSubagentRunsSnapshotForChildSession(subagentRuns, key),
|
||||
key,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,15 +115,10 @@ export function getLatestSubagentRunByChildSessionKey(
|
||||
return null;
|
||||
}
|
||||
|
||||
let latest: SubagentRunRecord | null = null;
|
||||
for (const entry of getSubagentRunsSnapshotForChildSession(subagentRuns, key).values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || compareSubagentRunGeneration(entry, latest) > 0) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
return (
|
||||
getLatestSubagentRunByChildSessionKeyFromRuns(
|
||||
getSubagentRunsSnapshotForChildSession(subagentRuns, key),
|
||||
key,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ import { resolveSwarmConfig } from "./swarm-config.js";
|
||||
import { validateStructuredOutputSchema } from "./swarm-output-schema.js";
|
||||
import { reserveSwarmRun } from "./swarm-scheduler.js";
|
||||
|
||||
function resolveConfiguredAgentIds(cfg: OpenClawConfig): string[] {
|
||||
return listAgentIds(cfg);
|
||||
}
|
||||
|
||||
type ResolvedSubagentSpawnRequest = {
|
||||
request: {
|
||||
taskName?: string;
|
||||
@@ -63,6 +59,13 @@ type ResolveSubagentSpawnRequestResult =
|
||||
| { ok: false; result: SpawnSubagentResult }
|
||||
| { ok: true; resolved: ResolvedSubagentSpawnRequest };
|
||||
|
||||
function rejectSubagentSpawnRequest(
|
||||
status: "error" | "forbidden",
|
||||
error: string,
|
||||
): ResolveSubagentSpawnRequestResult {
|
||||
return { ok: false, result: { status, error } };
|
||||
}
|
||||
|
||||
export function resolveSubagentSpawnRequest(
|
||||
params: SpawnSubagentParams,
|
||||
ctx: SpawnSubagentContext,
|
||||
@@ -73,13 +76,7 @@ export function resolveSubagentSpawnRequest(
|
||||
): ResolveSubagentSpawnRequestResult {
|
||||
const taskNameResult = normalizeSubagentTaskName(params.taskName);
|
||||
if (taskNameResult.error) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "error",
|
||||
error: taskNameResult.error,
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest("error", taskNameResult.error);
|
||||
}
|
||||
const taskName = taskNameResult.taskName;
|
||||
const requestedAgentId = requestedAgent.initial;
|
||||
@@ -89,13 +86,10 @@ export function resolveSubagentSpawnRequest(
|
||||
// through normalizeAgentId and become "agent-not-found--xyz", which later
|
||||
// creates ghost workspace directories and triggers cascading cron loops (#31311).
|
||||
if (requestedAgentId && !isValidAgentId(requestedAgentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "error",
|
||||
error: `Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}. Use agents_list to discover valid targets.`,
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
`Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}. Use agents_list to discover valid targets.`,
|
||||
);
|
||||
}
|
||||
const requestThreadBinding = params.thread === true;
|
||||
const spawnMode = resolveSpawnMode({
|
||||
@@ -103,24 +97,17 @@ export function resolveSubagentSpawnRequest(
|
||||
threadRequested: requestThreadBinding,
|
||||
});
|
||||
if (params.collect && (requestThreadBinding || spawnMode === "session")) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "error",
|
||||
error: "sessions_spawn collect=true requires mode=run and thread=false.",
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
"sessions_spawn collect=true requires mode=run and thread=false.",
|
||||
);
|
||||
}
|
||||
if (spawnMode === "session" && !requestThreadBinding) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "error",
|
||||
error:
|
||||
'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' +
|
||||
'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.',
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' +
|
||||
'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.',
|
||||
);
|
||||
}
|
||||
const cleanup =
|
||||
spawnMode === "session"
|
||||
@@ -175,30 +162,24 @@ export function resolveSubagentSpawnRequest(
|
||||
params.fastMode !== undefined ||
|
||||
params.groupId !== undefined;
|
||||
if (hasSwarmParams && !swarmConfig.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "forbidden",
|
||||
error: "sessions_spawn swarm parameters require tools.swarm.enabled=true.",
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"forbidden",
|
||||
"sessions_spawn swarm parameters require tools.swarm.enabled=true.",
|
||||
);
|
||||
}
|
||||
if (params.outputSchema && !params.collect) {
|
||||
return {
|
||||
ok: false,
|
||||
result: { status: "error", error: "sessions_spawn outputSchema requires collect=true." },
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
"sessions_spawn outputSchema requires collect=true.",
|
||||
);
|
||||
}
|
||||
if (params.groupId !== undefined && !params.collect) {
|
||||
return {
|
||||
ok: false,
|
||||
result: { status: "error", error: "sessions_spawn groupId requires collect=true." },
|
||||
};
|
||||
return rejectSubagentSpawnRequest("error", "sessions_spawn groupId requires collect=true.");
|
||||
}
|
||||
if (params.outputSchema) {
|
||||
const schemaError = validateStructuredOutputSchema(params.outputSchema);
|
||||
if (schemaError) {
|
||||
return { ok: false, result: { status: "error", error: schemaError } };
|
||||
return rejectSubagentSpawnRequest("error", schemaError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,19 +190,16 @@ export function resolveSubagentSpawnRequest(
|
||||
: requestedAgentId;
|
||||
if (usingDefaultAgentId) {
|
||||
if (!isValidAgentId(effectiveRequestedAgentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "error",
|
||||
error: `tools.swarm.defaultAgentId contains invalid agentId "${effectiveRequestedAgentId}".`,
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
`tools.swarm.defaultAgentId contains invalid agentId "${effectiveRequestedAgentId}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const targetAgentId = effectiveRequestedAgentId
|
||||
? normalizeAgentId(effectiveRequestedAgentId)
|
||||
: requesterAgentId;
|
||||
const configuredAgentIds = resolveConfiguredAgentIds(cfg);
|
||||
const configuredAgentIds = listAgentIds(cfg);
|
||||
const explicitSwarmGroupId = normalizeOptionalString(params.groupId);
|
||||
const requesterRunId = normalizeOptionalString(ctx.requesterRunId);
|
||||
const swarmGroupId = params.collect
|
||||
@@ -256,25 +234,18 @@ export function resolveSubagentSpawnRequest(
|
||||
};
|
||||
const admission = resolveAdmission();
|
||||
if (!admission.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "forbidden",
|
||||
error:
|
||||
usingDefaultAgentId && !admission.governingCap?.startsWith("tools.swarm.")
|
||||
? `tools.swarm.defaultAgentId is unavailable: ${admission.error}`
|
||||
: admission.error,
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"forbidden",
|
||||
usingDefaultAgentId && !admission.governingCap?.startsWith("tools.swarm.")
|
||||
? `tools.swarm.defaultAgentId is unavailable: ${admission.error}`
|
||||
: admission.error,
|
||||
);
|
||||
}
|
||||
if (params.collect && !swarmGroupId) {
|
||||
return {
|
||||
ok: false,
|
||||
result: {
|
||||
status: "error",
|
||||
error: "sessions_spawn collect=true requires a requesting run id when groupId is omitted.",
|
||||
},
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
"sessions_spawn collect=true requires a requesting run id when groupId is omitted.",
|
||||
);
|
||||
}
|
||||
const childDepth = admission.childSessionPatch?.spawnDepth ?? 1;
|
||||
const maxSpawnDepth = admission.maxSpawnDepth ?? childDepth;
|
||||
@@ -300,10 +271,10 @@ export function resolveSubagentSpawnRequest(
|
||||
.map((entry) => entry.schedulerSlotId ?? entry.runId),
|
||||
})
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
result: { status: "error", error: "sessions_spawn could not reserve swarm FIFO order." },
|
||||
};
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
"sessions_spawn could not reserve swarm FIFO order.",
|
||||
);
|
||||
}
|
||||
reservationPending = true;
|
||||
}
|
||||
|
||||
@@ -67,8 +67,6 @@ import { activateSwarmRun, removeQueuedSwarmRun } from "./swarm-scheduler.js";
|
||||
|
||||
export { SUBAGENT_SPAWN_CONTEXT_MODES, SUBAGENT_SPAWN_MODES } from "./subagent-spawn.types.js";
|
||||
|
||||
const SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS = 60_000;
|
||||
|
||||
function sanitizeMountPathHint(value?: string): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
@@ -220,15 +218,10 @@ export async function spawnSubagentDirect(
|
||||
resolvedModel,
|
||||
});
|
||||
if (runtimeModelPersistError) {
|
||||
try {
|
||||
await callSubagentGateway({
|
||||
method: "sessions.delete",
|
||||
params: { key: childSessionKey, emitLifecycleHooks: false },
|
||||
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
await cleanupProvisionalSession(childSessionKey, {
|
||||
emitLifecycleHooks: false,
|
||||
deleteTranscript: true,
|
||||
});
|
||||
return {
|
||||
status: "error",
|
||||
error: runtimeModelPersistError,
|
||||
@@ -253,15 +246,10 @@ export async function spawnSubagentDirect(
|
||||
},
|
||||
});
|
||||
if (bindResult.status === "error") {
|
||||
try {
|
||||
await callSubagentGateway({
|
||||
method: "sessions.delete",
|
||||
params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false },
|
||||
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
await cleanupProvisionalSession(childSessionKey, {
|
||||
emitLifecycleHooks: false,
|
||||
deleteTranscript: true,
|
||||
});
|
||||
return {
|
||||
status: "error",
|
||||
error: bindResult.error,
|
||||
@@ -544,6 +532,7 @@ export async function spawnSubagentDirect(
|
||||
};
|
||||
}
|
||||
childRunId = pipelineResult.runId;
|
||||
let collectorSessionKey: string | undefined;
|
||||
if (params.collect && swarmGroupId && swarmSchedulerGroupKey) {
|
||||
let launchTerminationConfirmed = false;
|
||||
activateSwarmRun({
|
||||
@@ -614,34 +603,11 @@ export async function spawnSubagentDirect(
|
||||
},
|
||||
});
|
||||
swarmReservationPending = false;
|
||||
emitSessionLifecycleEvent({
|
||||
sessionKey: childSessionKey,
|
||||
reason: "create",
|
||||
parentSessionKey: requesterInternalKey,
|
||||
label: label || undefined,
|
||||
});
|
||||
const acceptedNote = resolveSubagentSpawnAcceptedNote({
|
||||
spawnMode,
|
||||
agentSessionKey: ctx.agentSessionKey,
|
||||
});
|
||||
return {
|
||||
status: "accepted",
|
||||
childSessionKey,
|
||||
sessionKey: childSessionKey,
|
||||
runId: childRunId,
|
||||
mode: spawnMode,
|
||||
taskName,
|
||||
note: preparedSpawnContext.forkFallbackNote
|
||||
? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}`
|
||||
: acceptedNote,
|
||||
...resolvedModelMetadata,
|
||||
modelApplied: resolvedModel ? modelApplied : undefined,
|
||||
attachments: attachmentsReceipt,
|
||||
};
|
||||
collectorSessionKey = childSessionKey;
|
||||
} else {
|
||||
await emitSpawnLifecycleHooks(childRunId);
|
||||
}
|
||||
|
||||
await emitSpawnLifecycleHooks(childRunId);
|
||||
|
||||
// Emit lifecycle event so the gateway can broadcast sessions.changed to SSE subscribers.
|
||||
emitSessionLifecycleEvent({
|
||||
sessionKey: childSessionKey,
|
||||
@@ -657,6 +623,7 @@ export async function spawnSubagentDirect(
|
||||
return {
|
||||
status: "accepted",
|
||||
childSessionKey,
|
||||
...(collectorSessionKey ? { sessionKey: collectorSessionKey } : {}),
|
||||
runId: childRunId,
|
||||
mode: spawnMode,
|
||||
taskName,
|
||||
|
||||
@@ -18,6 +18,20 @@ type ResolveCommandConversationParams = {
|
||||
fallbackTo?: string;
|
||||
};
|
||||
|
||||
type IdleLifecycleScenario = {
|
||||
name: string;
|
||||
createParams: (commandBody: string) => HandleCommandsParams;
|
||||
createBinding: () => SessionBindingRecord;
|
||||
updateBinding: ReturnType<typeof vi.fn>;
|
||||
expectedConversation?: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId: string;
|
||||
threadId: string;
|
||||
};
|
||||
};
|
||||
|
||||
function firstText(values: Array<string | undefined>): string | undefined {
|
||||
return values.map((value) => value?.trim() ?? "").find(Boolean) || undefined;
|
||||
}
|
||||
@@ -118,59 +132,49 @@ const hoisted = vi.hoisted(() => {
|
||||
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const sessionBindingResolveByConversationMock = vi.fn();
|
||||
function createRuntimeChannel(
|
||||
id: string,
|
||||
resolveCommandConversation: (params: ResolveCommandConversationParams) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null,
|
||||
setIdleTimeoutBySessionKey: typeof setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMaxAgeBySessionKey: typeof setThreadBindingMaxAgeBySessionKeyMock,
|
||||
) {
|
||||
return {
|
||||
plugin: {
|
||||
id,
|
||||
meta: {},
|
||||
config: { hasPersistedAuthState: () => false },
|
||||
bindings: { resolveCommandConversation },
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
setIdleTimeoutBySessionKey,
|
||||
setMaxAgeBySessionKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const runtimeChannelRegistry = {
|
||||
channels: [
|
||||
{
|
||||
plugin: {
|
||||
id: threadChannel,
|
||||
meta: {},
|
||||
config: {
|
||||
hasPersistedAuthState: () => false,
|
||||
},
|
||||
bindings: {
|
||||
resolveCommandConversation: resolveThreadCommandConversation,
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
setIdleTimeoutBySessionKey: setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMaxAgeBySessionKey: setThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugin: {
|
||||
id: roomChannel,
|
||||
meta: {},
|
||||
config: {
|
||||
hasPersistedAuthState: () => false,
|
||||
},
|
||||
bindings: {
|
||||
resolveCommandConversation: resolveRoomCommandConversation,
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugin: {
|
||||
id: topicChannel,
|
||||
meta: {},
|
||||
config: {
|
||||
hasPersistedAuthState: () => false,
|
||||
},
|
||||
bindings: {
|
||||
resolveCommandConversation: resolveTopicCommandConversation,
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
setIdleTimeoutBySessionKey: setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMaxAgeBySessionKey: setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
createRuntimeChannel(
|
||||
threadChannel,
|
||||
resolveThreadCommandConversation,
|
||||
setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setThreadBindingMaxAgeBySessionKeyMock,
|
||||
),
|
||||
createRuntimeChannel(
|
||||
roomChannel,
|
||||
resolveRoomCommandConversation,
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
),
|
||||
createRuntimeChannel(
|
||||
topicChannel,
|
||||
resolveTopicCommandConversation,
|
||||
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
),
|
||||
],
|
||||
};
|
||||
return {
|
||||
@@ -214,28 +218,15 @@ vi.mock("../../channels/plugins/conversation-bindings.js", () => ({
|
||||
accountId?: string | null;
|
||||
idleTimeoutMs: number;
|
||||
}) => {
|
||||
if (params.channelId === THREAD_CHANNEL) {
|
||||
return hoisted.setThreadBindingIdleTimeoutBySessionKeyMock({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
idleTimeoutMs: params.idleTimeoutMs,
|
||||
});
|
||||
}
|
||||
if (params.channelId === ROOM_CHANNEL) {
|
||||
return hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
idleTimeoutMs: params.idleTimeoutMs,
|
||||
});
|
||||
}
|
||||
if (params.channelId === TOPIC_CHANNEL) {
|
||||
return hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
idleTimeoutMs: params.idleTimeoutMs,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
return (
|
||||
hoisted.runtimeChannelRegistry.channels
|
||||
.find((entry) => entry.plugin.id === params.channelId)
|
||||
?.plugin.conversationBindings.setIdleTimeoutBySessionKey({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
idleTimeoutMs: params.idleTimeoutMs,
|
||||
}) ?? []
|
||||
);
|
||||
},
|
||||
setChannelConversationBindingMaxAgeBySessionKey: (params: {
|
||||
channelId: string;
|
||||
@@ -243,28 +234,15 @@ vi.mock("../../channels/plugins/conversation-bindings.js", () => ({
|
||||
accountId?: string | null;
|
||||
maxAgeMs: number;
|
||||
}) => {
|
||||
if (params.channelId === THREAD_CHANNEL) {
|
||||
return hoisted.setThreadBindingMaxAgeBySessionKeyMock({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
});
|
||||
}
|
||||
if (params.channelId === ROOM_CHANNEL) {
|
||||
return hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
});
|
||||
}
|
||||
if (params.channelId === TOPIC_CHANNEL) {
|
||||
return hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
return (
|
||||
hoisted.runtimeChannelRegistry.channels
|
||||
.find((entry) => entry.plugin.id === params.channelId)
|
||||
?.plugin.conversationBindings.setMaxAgeBySessionKey({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
accountId: params.accountId,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
}) ?? []
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -398,72 +376,60 @@ function createRoomCommandParams(commandBody: string, overrides?: Record<string,
|
||||
});
|
||||
}
|
||||
|
||||
function createThreadBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
function createLifecycleBinding(
|
||||
conversation: SessionBindingRecord["conversation"],
|
||||
overrides?: Partial<SessionBindingRecord>,
|
||||
): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "default:thread-1",
|
||||
bindingId: `default:${conversation.conversationId}`,
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
conversation,
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createThreadBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return createLifecycleBinding(
|
||||
{
|
||||
channel: THREAD_CHANNEL,
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "thread-1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
function createTopicBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "default:-100200300:topic:77",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
return createLifecycleBinding(
|
||||
{
|
||||
channel: TOPIC_CHANNEL,
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
function createRoomBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "default:$thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
return createLifecycleBinding(
|
||||
{
|
||||
channel: ROOM_CHANNEL,
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
function createRoomTriggerBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
@@ -510,30 +476,67 @@ describe("/session idle and /session max-age", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets idle timeout for the focused thread-chat session", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding());
|
||||
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
it.each([
|
||||
{
|
||||
name: "sets idle timeout for the focused thread-chat session",
|
||||
createParams: createThreadCommandParams,
|
||||
createBinding: createThreadBinding,
|
||||
updateBinding: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
},
|
||||
{
|
||||
name: "sets idle timeout for focused topic-chat conversations",
|
||||
createParams: createTopicCommandParams,
|
||||
createBinding: createTopicBinding,
|
||||
updateBinding: hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
},
|
||||
{
|
||||
name: "sets idle timeout for focused room-chat threads",
|
||||
createParams: createRoomThreadCommandParams,
|
||||
createBinding: createRoomBinding,
|
||||
updateBinding: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
},
|
||||
{
|
||||
name: "sets idle timeout for the triggering room-chat always-thread turn",
|
||||
createParams: createRoomTriggerThreadCommandParams,
|
||||
createBinding: createRoomTriggerBinding,
|
||||
updateBinding: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
expectedConversation: {
|
||||
channel: ROOM_CHANNEL,
|
||||
accountId: "default",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example.org",
|
||||
threadId: "$root",
|
||||
},
|
||||
]);
|
||||
},
|
||||
] satisfies IdleLifecycleScenario[])(
|
||||
"$name",
|
||||
async ({
|
||||
createParams,
|
||||
createBinding,
|
||||
updateBinding,
|
||||
expectedConversation,
|
||||
}: IdleLifecycleScenario) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createBinding());
|
||||
updateBinding.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(createThreadCommandParams("/session idle 2h"), true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
const result = await handleSessionCommand(createParams("/session idle 2h"), true);
|
||||
if (expectedConversation) {
|
||||
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith(
|
||||
expectedConversation,
|
||||
);
|
||||
}
|
||||
expectIdleTimeoutSetReply(updateBinding, result?.reply?.text ?? "", 2 * 60 * 60 * 1000, "2h");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsafe bare-hour lifecycle durations", async () => {
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding());
|
||||
@@ -607,183 +610,62 @@ describe("/session idle and /session max-age", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets max age for the focused thread-chat session", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "sets max age for the focused thread-chat session",
|
||||
createParams: createThreadCommandParams,
|
||||
createBinding: createThreadBinding,
|
||||
updateBinding: hoisted.setThreadBindingMaxAgeBySessionKeyMock,
|
||||
boundAt: undefined,
|
||||
expiry: "2026-02-20T03:00:00.000Z",
|
||||
},
|
||||
{
|
||||
name: "sets max age for focused room-chat threads",
|
||||
createParams: createRoomThreadCommandParams,
|
||||
createBinding: createRoomBinding,
|
||||
updateBinding: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
boundAt: "2026-02-19T22:00:00.000Z",
|
||||
expiry: "2026-02-20T01:00:00.000Z",
|
||||
},
|
||||
{
|
||||
name: "reports topic-chat max-age expiry from the original bind time",
|
||||
createParams: createTopicCommandParams,
|
||||
createBinding: createTopicBinding,
|
||||
updateBinding: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
boundAt: "2026-02-19T22:00:00.000Z",
|
||||
expiry: "2026-02-20T01:00:00.000Z",
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
createParams: (commandBody: string) => HandleCommandsParams;
|
||||
createBinding: (overrides?: Partial<SessionBindingRecord>) => SessionBindingRecord;
|
||||
updateBinding: ReturnType<typeof vi.fn>;
|
||||
boundAt: string | undefined;
|
||||
expiry: string;
|
||||
}>)("$name", async ({ createParams, createBinding, updateBinding, boundAt, expiry }) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding());
|
||||
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createThreadCommandParams("/session max-age 3h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Max age set to 3h");
|
||||
expect(text).toContain("2026-02-20T03:00:00.000Z");
|
||||
});
|
||||
|
||||
it("sets idle timeout for focused topic-chat conversations", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createTopicBinding());
|
||||
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(createTopicCommandParams("/session idle 2h"), true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets idle timeout for focused room-chat threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomBinding());
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createRoomThreadCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets idle timeout for the triggering room-chat always-thread turn", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomTriggerBinding());
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createRoomTriggerThreadCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
|
||||
channel: ROOM_CHANNEL,
|
||||
accountId: "default",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example.org",
|
||||
threadId: "$root",
|
||||
});
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets max age for focused room-chat threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomBinding({ boundAt }));
|
||||
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt,
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createRoomThreadCommandParams("/session max-age 3h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Max age set to 3h");
|
||||
expect(text).toContain("2026-02-20T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("reports topic-chat max-age expiry from the original bind time", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
|
||||
const bindingTime = boundAt ? Date.parse(boundAt) : Date.now();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createTopicBinding({ boundAt }),
|
||||
createBinding({ boundAt: bindingTime }),
|
||||
);
|
||||
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
|
||||
updateBinding.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt,
|
||||
boundAt: bindingTime,
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createTopicCommandParams("/session max-age 3h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
|
||||
const result = await handleSessionCommand(createParams("/session max-age 3h"), true);
|
||||
expect(updateBinding).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Max age set to 3h");
|
||||
expect(text).toContain("2026-02-20T01:00:00.000Z");
|
||||
expect(result?.reply?.text).toContain("Max age set to 3h");
|
||||
expect(result?.reply?.text).toContain(expiry);
|
||||
});
|
||||
|
||||
it("disables max age when set to off", async () => {
|
||||
|
||||
@@ -39,6 +39,16 @@ type FastModeStateMockResult = {
|
||||
source: "session" | "agent" | "config" | "default";
|
||||
fastAutoOnSeconds?: number;
|
||||
};
|
||||
type UsageFooterScenario = {
|
||||
name: string;
|
||||
command: string;
|
||||
targetUsage?: "off" | "tokens" | "full";
|
||||
wrapperUsage?: "off" | "tokens" | "full";
|
||||
configDefault?: "off" | "tokens" | "full";
|
||||
shareTargetEntry?: boolean;
|
||||
expectedUsage: "off" | "tokens" | "full" | undefined;
|
||||
expectedText: string;
|
||||
};
|
||||
const resolveFastModeStateMock = vi.hoisted(() =>
|
||||
vi.fn<() => FastModeStateMockResult>(() => ({
|
||||
mode: true,
|
||||
@@ -220,146 +230,92 @@ describe("handleUsageCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the target session entry from sessionStore for /usage footer mode", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "prefers the target session entry from sessionStore for /usage footer mode",
|
||||
command: "/usage",
|
||||
targetUsage: "tokens",
|
||||
wrapperUsage: "off",
|
||||
expectedUsage: "full",
|
||||
expectedText: "⚙️ Usage footer: full.",
|
||||
},
|
||||
{
|
||||
name: "updates usage footer mode as a session preference",
|
||||
command: "/usage tokens",
|
||||
targetUsage: "full",
|
||||
shareTargetEntry: true,
|
||||
expectedUsage: "tokens",
|
||||
expectedText: "⚙️ Usage footer: tokens.",
|
||||
},
|
||||
{
|
||||
name: "persists an explicit /usage off so a configured default cannot re-enable it",
|
||||
command: "/usage off",
|
||||
targetUsage: "tokens",
|
||||
expectedUsage: "off",
|
||||
expectedText: "⚙️ Usage footer: off.",
|
||||
},
|
||||
{
|
||||
name: "no-arg toggle uses the effective mode (config default) when session is unset",
|
||||
command: "/usage",
|
||||
configDefault: "tokens",
|
||||
expectedUsage: "full",
|
||||
expectedText: "⚙️ Usage footer: full.",
|
||||
},
|
||||
{
|
||||
name: "/usage reset clears the session override so the config default takes over",
|
||||
command: "/usage reset",
|
||||
targetUsage: "off",
|
||||
expectedUsage: undefined,
|
||||
expectedText: "⚙️ Usage footer: reset to default.",
|
||||
},
|
||||
{
|
||||
name: "/usage inherit (alias) clears the session override",
|
||||
command: "/usage inherit",
|
||||
targetUsage: "full",
|
||||
expectedUsage: undefined,
|
||||
expectedText: "⚙️ Usage footer: reset to default.",
|
||||
},
|
||||
{
|
||||
name: "explicit off is stored and not treated as unset — config default cannot override it",
|
||||
command: "/usage",
|
||||
targetUsage: "off",
|
||||
configDefault: "tokens",
|
||||
expectedUsage: "tokens",
|
||||
expectedText: "⚙️ Usage footer: tokens.",
|
||||
},
|
||||
] satisfies UsageFooterScenario[])("$name", async (scenario: UsageFooterScenario) => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage";
|
||||
params.sessionEntry = {
|
||||
sessionId: "wrapper-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "off",
|
||||
};
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "tokens",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: full.");
|
||||
});
|
||||
|
||||
it("updates usage footer mode as a session preference", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage tokens";
|
||||
params.sessionEntry = {
|
||||
params.command.commandBodyNormalized = scenario.command;
|
||||
if (scenario.configDefault) {
|
||||
params.cfg = { ...params.cfg, messages: { responseUsage: scenario.configDefault } };
|
||||
}
|
||||
const targetEntry: NonNullable<HandleCommandsParams["sessionEntry"]> = {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "full",
|
||||
...(scenario.targetUsage ? { responseUsage: scenario.targetUsage } : {}),
|
||||
};
|
||||
params.sessionStore = { [params.sessionKey]: params.sessionEntry };
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens.");
|
||||
expect(params.sessionEntry.responseUsage).toBe("tokens");
|
||||
});
|
||||
|
||||
it("persists an explicit /usage off so a configured default cannot re-enable it", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage off";
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
params.sessionStore = { [params.sessionKey]: targetEntry };
|
||||
if (scenario.shareTargetEntry) {
|
||||
params.sessionEntry = targetEntry;
|
||||
} else if (scenario.wrapperUsage) {
|
||||
params.sessionEntry = {
|
||||
sessionId: "wrapper-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "tokens",
|
||||
},
|
||||
};
|
||||
responseUsage: scenario.wrapperUsage,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: off.");
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("off");
|
||||
});
|
||||
|
||||
it("no-arg toggle uses the effective mode (config default) when session is unset", async () => {
|
||||
// When session has no override, the effective mode is the config default.
|
||||
// The toggle should cycle from that effective value, not from "off".
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage";
|
||||
params.cfg = {
|
||||
...params.cfg,
|
||||
messages: { responseUsage: "tokens" },
|
||||
} as OpenClawConfig;
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
// responseUsage is absent — session inherits config default "tokens"
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
// Effective current = "tokens" (from config), so cycle → "full"
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: full.");
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("full");
|
||||
});
|
||||
|
||||
it("/usage reset clears the session override so the config default takes over", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage reset";
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "off",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
|
||||
// responseUsage is deleted (undefined) — session now inherits the config default
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("/usage inherit (alias) clears the session override", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage inherit";
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "full",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("explicit off is stored and not treated as unset — config default cannot override it", async () => {
|
||||
// This verifies the three-state distinction: "off" vs undefined.
|
||||
// When session has explicit "off", the effective value is "off" regardless of config.
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage";
|
||||
params.cfg = {
|
||||
...params.cfg,
|
||||
messages: { responseUsage: "tokens" },
|
||||
} as OpenClawConfig;
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "off", // explicit off — stays off despite config default "tokens"
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
// Effective current = "off" (explicit, not inherited), so cycle → "tokens"
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens.");
|
||||
expect(result?.reply?.text).toBe(scenario.expectedText);
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe(scenario.expectedUsage);
|
||||
if (scenario.shareTargetEntry) {
|
||||
expect(params.sessionEntry?.responseUsage).toBe(scenario.expectedUsage);
|
||||
}
|
||||
if (scenario.wrapperUsage) {
|
||||
expect(params.sessionEntry?.responseUsage).toBe(scenario.wrapperUsage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,11 @@ import {
|
||||
persistSessionEntry,
|
||||
sessionEntryPersistenceConflictReply,
|
||||
} from "./commands-session-store.js";
|
||||
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
HandleCommandsParams,
|
||||
} from "./commands-types.js";
|
||||
import { resolveConversationBindingContextFromAcpCommand } from "./conversation-binding-input.js";
|
||||
|
||||
const SESSION_COMMAND_PREFIX = "/session";
|
||||
@@ -63,6 +67,10 @@ const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none
|
||||
const SESSION_ACTION_IDLE = "idle";
|
||||
const SESSION_ACTION_MAX_AGE = "max-age";
|
||||
|
||||
function sessionCommandReply(text: string): CommandHandlerResult {
|
||||
return { shouldContinue: false, reply: { text } };
|
||||
}
|
||||
|
||||
function buildRestartCommandSentinel(params: HandleCommandsParams): RestartSentinelPayload | null {
|
||||
const sessionKey = normalizeOptionalString(params.sessionKey);
|
||||
if (!sessionKey) {
|
||||
@@ -214,10 +222,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
|
||||
return null;
|
||||
}
|
||||
if (!params.isGroup) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Group activation only applies to group chats." },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Group activation only applies to group chats.");
|
||||
}
|
||||
const unauthorizedResult = rejectUnauthorizedCommand(params, "/activation");
|
||||
if (unauthorizedResult) {
|
||||
@@ -228,10 +233,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
|
||||
return nonOwnerResult;
|
||||
}
|
||||
if (!activationCommand.mode) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /activation mention|always" },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Usage: /activation mention|always");
|
||||
}
|
||||
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
|
||||
params.sessionEntry.groupActivation = activationCommand.mode;
|
||||
@@ -245,12 +247,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
|
||||
return sessionEntryPersistenceConflictReply();
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Group activation set to ${activationCommand.mode}.`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(`⚙️ Group activation set to ${activationCommand.mode}.`);
|
||||
};
|
||||
|
||||
export const handleSendPolicyCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
@@ -270,10 +267,7 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
|
||||
return nonOwnerResult;
|
||||
}
|
||||
if (!sendPolicyCommand.mode) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /send on|off|inherit" },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Usage: /send on|off|inherit");
|
||||
}
|
||||
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
|
||||
if (sendPolicyCommand.mode === "inherit") {
|
||||
@@ -291,10 +285,7 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
|
||||
: sendPolicyCommand.mode === "allow"
|
||||
? "on"
|
||||
: "off";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Send policy set to ${label}.` },
|
||||
};
|
||||
return sessionCommandReply(`⚙️ Send policy set to ${label}.`);
|
||||
};
|
||||
|
||||
export const handleUsageCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
@@ -375,19 +366,13 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
const last30Suffix = last30Missing > 0 ? " (partial)" : "";
|
||||
const last30Line = `Last 30d ${last30Cost ?? "n/a"}${last30Suffix}`;
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}` },
|
||||
};
|
||||
return sessionCommandReply(`💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}`);
|
||||
}
|
||||
|
||||
const isReset = rawArgs ? isSessionDefaultDirectiveValue(rawArgs) : false;
|
||||
|
||||
if (rawArgs && !requested && !isReset) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full|reset|cost" },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Usage: /usage off|tokens|full|reset|cost");
|
||||
}
|
||||
|
||||
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
|
||||
@@ -406,10 +391,7 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
return sessionEntryPersistenceConflictReply();
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage footer: reset to default." },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Usage footer: reset to default.");
|
||||
}
|
||||
|
||||
const replyChannel = params.command.channel;
|
||||
@@ -435,12 +417,7 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Usage footer: ${next}.`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(`⚙️ Usage footer: ${next}.`);
|
||||
};
|
||||
|
||||
export const handleFastCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
@@ -472,17 +449,14 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
|
||||
agentId: sessionAgentId,
|
||||
sessionEntry: targetSessionEntry,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: formatFastModeCurrentStatus({
|
||||
mode: state.mode,
|
||||
source: state.source,
|
||||
fastAutoOnSeconds: state.fastAutoOnSeconds,
|
||||
label: "⚙️ Current fast mode",
|
||||
}),
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
formatFastModeCurrentStatus({
|
||||
mode: state.mode,
|
||||
source: state.source,
|
||||
fastAutoOnSeconds: state.fastAutoOnSeconds,
|
||||
label: "⚙️ Current fast mode",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
|
||||
@@ -502,15 +476,9 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
|
||||
return sessionEntryPersistenceConflictReply();
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Fast mode reset to default." },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Fast mode reset to default.");
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /fast status|auto|on|off|default" },
|
||||
};
|
||||
return sessionCommandReply("⚙️ Usage: /fast status|auto|on|off|default");
|
||||
}
|
||||
|
||||
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
|
||||
@@ -526,15 +494,11 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
nextMode === "auto"
|
||||
? "⚙️ Fast mode set to auto."
|
||||
: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
nextMode === "auto"
|
||||
? "⚙️ Fast mode set to auto."
|
||||
: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.`,
|
||||
);
|
||||
};
|
||||
|
||||
export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
@@ -556,10 +520,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
const tokens = rest.split(/\s+/).filter(Boolean);
|
||||
const action = normalizeOptionalLowercaseString(tokens[0]);
|
||||
if (action !== SESSION_ACTION_IDLE && action !== SESSION_ACTION_MAX_AGE) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: resolveSessionCommandUsage() },
|
||||
};
|
||||
return sessionCommandReply(resolveSessionCommandUsage());
|
||||
}
|
||||
|
||||
const channelId =
|
||||
@@ -583,19 +544,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
!commandSupportsCurrentConversationBinding ||
|
||||
!commandSupportsLifecycleUpdate
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
"⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age must be run inside a focused conversation.",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
"⚠️ /session idle and /session max-age must be run inside a focused conversation.",
|
||||
);
|
||||
}
|
||||
const resolvedChannelId = bindingContext.channel || channelId;
|
||||
const conversationBindings = resolvedChannelId
|
||||
@@ -609,22 +564,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
? typeof conversationBindings?.setIdleTimeoutBySessionKey === "function"
|
||||
: typeof conversationBindings?.setMaxAgeBySessionKey === "function";
|
||||
if (!resolvedChannelId || !supportsCurrentConversationBinding || !supportsLifecycleUpdate) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
"⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
|
||||
);
|
||||
}
|
||||
|
||||
const sessionBindingService = getSessionBindingService();
|
||||
|
||||
const activeBinding = sessionBindingService.resolveByConversation(bindingContext);
|
||||
if (!activeBinding) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ This conversation is not currently focused." },
|
||||
};
|
||||
return sessionCommandReply("ℹ️ This conversation is not currently focused.");
|
||||
}
|
||||
|
||||
const idleTimeoutMs = resolveSessionBindingDurationMs(
|
||||
@@ -647,17 +596,11 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
Number.isFinite(idleExpiresAt) &&
|
||||
idleExpiresAt > Date.now()
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `ℹ️ Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
`ℹ️ Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ Idle timeout is currently disabled for this focused session." },
|
||||
};
|
||||
return sessionCommandReply("ℹ️ Idle timeout is currently disabled for this focused session.");
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -665,38 +608,26 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
Number.isFinite(maxAgeExpiresAt) &&
|
||||
maxAgeExpiresAt > Date.now()
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `ℹ️ Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
`ℹ️ Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ Max age is currently disabled for this focused session." },
|
||||
};
|
||||
return sessionCommandReply("ℹ️ Max age is currently disabled for this focused session.");
|
||||
}
|
||||
|
||||
const senderId = normalizeOptionalString(params.command.senderId) ?? "";
|
||||
const boundBy = resolveSessionBindingBoundBy(activeBinding);
|
||||
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
`⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
|
||||
);
|
||||
}
|
||||
|
||||
let durationMs: number;
|
||||
try {
|
||||
durationMs = parseSessionDurationMs(durationArgRaw);
|
||||
} catch {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: resolveSessionCommandUsage() },
|
||||
};
|
||||
return sessionCommandReply(resolveSessionCommandUsage());
|
||||
}
|
||||
|
||||
const updatedBindings =
|
||||
@@ -714,27 +645,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
maxAgeMs: durationMs,
|
||||
});
|
||||
if (updatedBindings.length === 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
action === SESSION_ACTION_IDLE
|
||||
? "⚠️ Failed to update idle timeout for the current binding."
|
||||
: "⚠️ Failed to update max age for the current binding.",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
action === SESSION_ACTION_IDLE
|
||||
? "⚠️ Failed to update idle timeout for the current binding."
|
||||
: "⚠️ Failed to update max age for the current binding.",
|
||||
);
|
||||
}
|
||||
|
||||
if (durationMs <= 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
action === SESSION_ACTION_IDLE
|
||||
? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`
|
||||
: `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
action === SESSION_ACTION_IDLE
|
||||
? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`
|
||||
: `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextExpiry = resolveUpdatedBindingExpiry({
|
||||
@@ -746,15 +669,11 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
? formatSessionExpiry(nextExpiry)
|
||||
: "n/a";
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
action === SESSION_ACTION_IDLE
|
||||
? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).`
|
||||
: `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
action === SESSION_ACTION_IDLE
|
||||
? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).`
|
||||
: `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`,
|
||||
);
|
||||
};
|
||||
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
@@ -774,12 +693,7 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
|
||||
return nonOwner;
|
||||
}
|
||||
if (!isRestartEnabled(params.cfg)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /restart is disabled (commands.restart=false).",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply("⚠️ /restart is disabled (commands.restart=false).");
|
||||
}
|
||||
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
|
||||
const sentinelPayload = buildRestartCommandSentinel(params);
|
||||
@@ -805,12 +719,9 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚙️ Restarting OpenClaw in-process (SIGUSR1); back in a few seconds.",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
"⚙️ Restarting OpenClaw in-process (SIGUSR1); back in a few seconds.",
|
||||
);
|
||||
}
|
||||
let sentinelWritten = false;
|
||||
try {
|
||||
@@ -820,12 +731,9 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`failed to write /restart sentinel: ${String(err)}`);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ Restart failed: could not persist the post-restart acknowledgement.",
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
"⚠️ Restart failed: could not persist the post-restart acknowledgement.",
|
||||
);
|
||||
}
|
||||
const restartMethod = triggerOpenClawRestart();
|
||||
if (!restartMethod.ok) {
|
||||
@@ -833,20 +741,11 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
|
||||
await clearRestartSentinel();
|
||||
}
|
||||
const detail = restartMethod.detail ? ` Details: ${restartMethod.detail}` : "";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Restart failed (${restartMethod.method}).${detail}`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(`⚠️ Restart failed (${restartMethod.method}).${detail}`);
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Restarting OpenClaw via ${restartMethod.method}; give me a few seconds to come back online.`,
|
||||
},
|
||||
};
|
||||
return sessionCommandReply(
|
||||
`⚙️ Restarting OpenClaw via ${restartMethod.method}; give me a few seconds to come back online.`,
|
||||
);
|
||||
};
|
||||
|
||||
export { handleAbortTrigger, handleStopCommand };
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -158,4 +158,27 @@ describe("projectSafeChannelAccountSnapshotFields", () => {
|
||||
const withoutFlag = projectSafeChannelAccountSnapshotFields({ connected: false });
|
||||
expect(withoutFlag).not.toHaveProperty("terminalDisconnect");
|
||||
});
|
||||
|
||||
it("preserves false, zero, and nullable fields without exposing invalid credential metadata", () => {
|
||||
const snapshot = projectSafeChannelAccountSnapshotFields({
|
||||
running: false,
|
||||
connected: false,
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
lastOutboundAt: null,
|
||||
ingressUnavailable: false,
|
||||
activeRuns: 0,
|
||||
token: "must-not-leak",
|
||||
tokenStatus: "unexpected-status",
|
||||
});
|
||||
|
||||
expect(snapshot).toStrictEqual({
|
||||
running: false,
|
||||
connected: false,
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
lastOutboundAt: null,
|
||||
activeRuns: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,23 +45,21 @@ export function redactChannelAccountSnapshotBaseUrl<T extends Partial<ChannelAcc
|
||||
return redactChannelStatusSummaryBaseUrl(snapshot);
|
||||
}
|
||||
|
||||
function readBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||
return asBoolean(record[key]);
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return asFiniteNumber(value);
|
||||
}
|
||||
|
||||
function readNullableNumber(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number | null | undefined {
|
||||
if (record[key] === null) {
|
||||
return null;
|
||||
return record[key] === null ? null : asFiniteNumber(record[key]);
|
||||
}
|
||||
|
||||
function setSnapshotField<TKey extends keyof ChannelAccountSnapshot>(
|
||||
snapshot: Partial<ChannelAccountSnapshot>,
|
||||
key: TKey,
|
||||
value: ChannelAccountSnapshot[TKey],
|
||||
): void {
|
||||
if (value !== undefined) {
|
||||
snapshot[key] = value;
|
||||
}
|
||||
return readNumber(record, key);
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
|
||||
@@ -195,34 +193,32 @@ export function projectCredentialSnapshotFields(
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
const tokenSource = normalizeOptionalString(record.tokenSource);
|
||||
const botTokenSource = normalizeOptionalString(record.botTokenSource);
|
||||
const appTokenSource = normalizeOptionalString(record.appTokenSource);
|
||||
const signingSecretSource = normalizeOptionalString(record.signingSecretSource);
|
||||
|
||||
// Only project source/status fields. Token-like values stay out of account snapshots even when
|
||||
// callers pass full runtime account objects.
|
||||
return {
|
||||
...(tokenSource ? { tokenSource } : {}),
|
||||
...(botTokenSource ? { botTokenSource } : {}),
|
||||
...(appTokenSource ? { appTokenSource } : {}),
|
||||
...(signingSecretSource ? { signingSecretSource } : {}),
|
||||
...(readCredentialStatus(record, "tokenStatus")
|
||||
? { tokenStatus: readCredentialStatus(record, "tokenStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "botTokenStatus")
|
||||
? { botTokenStatus: readCredentialStatus(record, "botTokenStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "appTokenStatus")
|
||||
? { appTokenStatus: readCredentialStatus(record, "appTokenStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "signingSecretStatus")
|
||||
? { signingSecretStatus: readCredentialStatus(record, "signingSecretStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "userTokenStatus")
|
||||
? { userTokenStatus: readCredentialStatus(record, "userTokenStatus") }
|
||||
: {}),
|
||||
};
|
||||
type CredentialSnapshotFields = Pick<
|
||||
Partial<ChannelAccountSnapshot>,
|
||||
| "tokenSource"
|
||||
| "botTokenSource"
|
||||
| "appTokenSource"
|
||||
| "signingSecretSource"
|
||||
| "tokenStatus"
|
||||
| "botTokenStatus"
|
||||
| "appTokenStatus"
|
||||
| "signingSecretStatus"
|
||||
| "userTokenStatus"
|
||||
>;
|
||||
const snapshot: CredentialSnapshotFields = {};
|
||||
// Only the explicit source/status allowlist crosses the credential boundary.
|
||||
for (const key of [
|
||||
"tokenSource",
|
||||
"botTokenSource",
|
||||
"appTokenSource",
|
||||
"signingSecretSource",
|
||||
] as const) {
|
||||
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
|
||||
}
|
||||
for (const key of CREDENTIAL_STATUS_KEYS) {
|
||||
setSnapshotField(snapshot, key, readCredentialStatus(record, key));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,81 +234,51 @@ export function projectSafeChannelAccountSnapshotFields(
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
const name = normalizeOptionalString(record.name);
|
||||
const statusState = normalizeOptionalString(record.statusState);
|
||||
const healthState = normalizeOptionalString(record.healthState);
|
||||
const mode = normalizeOptionalString(record.mode);
|
||||
const dmPolicy = normalizeOptionalString(record.dmPolicy);
|
||||
const baseUrl = normalizeOptionalString(record.baseUrl);
|
||||
const cliPath = normalizeOptionalString(record.cliPath);
|
||||
const dbPath = normalizeOptionalString(record.dbPath);
|
||||
const snapshot: Partial<ChannelAccountSnapshot> = {};
|
||||
setSnapshotField(snapshot, "name", normalizeOptionalString(record.name));
|
||||
for (const key of ["linked", "running", "connected", "restartPending"] as const) {
|
||||
setSnapshotField(snapshot, key, asBoolean(record[key]));
|
||||
}
|
||||
setSnapshotField(snapshot, "reconnectAttempts", asFiniteNumber(record.reconnectAttempts));
|
||||
setSnapshotField(snapshot, "lastConnectedAt", readNullableNumber(record, "lastConnectedAt"));
|
||||
setSnapshotField(snapshot, "lastInboundAt", asFiniteNumber(record.lastInboundAt));
|
||||
for (const key of ["lastOutboundAt", "lastMessageAt", "lastEventAt"] as const) {
|
||||
setSnapshotField(snapshot, key, readNullableNumber(record, key));
|
||||
}
|
||||
setSnapshotField(
|
||||
snapshot,
|
||||
"lastTransportActivityAt",
|
||||
asFiniteNumber(record.lastTransportActivityAt),
|
||||
);
|
||||
for (const key of ["statusState", "healthState"] as const) {
|
||||
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
|
||||
}
|
||||
// False or absent ingress means unknown, never evidence that ingress is healthy.
|
||||
if (asBoolean(record.ingressUnavailable) === true) {
|
||||
snapshot.ingressUnavailable = true;
|
||||
}
|
||||
for (const key of ["terminalDisconnect", "busy"] as const) {
|
||||
setSnapshotField(snapshot, key, asBoolean(record[key]));
|
||||
}
|
||||
setSnapshotField(snapshot, "activeRuns", asFiniteNumber(record.activeRuns));
|
||||
for (const key of ["lastRunActivityAt", "activeRunStartedAt"] as const) {
|
||||
setSnapshotField(snapshot, key, readNullableNumber(record, key));
|
||||
}
|
||||
for (const key of ["mode", "dmPolicy"] as const) {
|
||||
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
|
||||
}
|
||||
setSnapshotField(snapshot, "allowFrom", readStringArray(record, "allowFrom"));
|
||||
Object.assign(snapshot, projectCredentialSnapshotFields(account));
|
||||
|
||||
return {
|
||||
...(name ? { name } : {}),
|
||||
...(readBoolean(record, "linked") !== undefined
|
||||
? { linked: readBoolean(record, "linked") }
|
||||
: {}),
|
||||
...(readBoolean(record, "running") !== undefined
|
||||
? { running: readBoolean(record, "running") }
|
||||
: {}),
|
||||
...(readBoolean(record, "connected") !== undefined
|
||||
? { connected: readBoolean(record, "connected") }
|
||||
: {}),
|
||||
...(readBoolean(record, "restartPending") !== undefined
|
||||
? { restartPending: readBoolean(record, "restartPending") }
|
||||
: {}),
|
||||
...(readNumber(record, "reconnectAttempts") !== undefined
|
||||
? { reconnectAttempts: readNumber(record, "reconnectAttempts") }
|
||||
: {}),
|
||||
...(readNullableNumber(record, "lastConnectedAt") !== undefined
|
||||
? { lastConnectedAt: readNullableNumber(record, "lastConnectedAt") }
|
||||
: {}),
|
||||
...(readNumber(record, "lastInboundAt") !== undefined
|
||||
? { lastInboundAt: readNumber(record, "lastInboundAt") }
|
||||
: {}),
|
||||
...(readNullableNumber(record, "lastOutboundAt") !== undefined
|
||||
? { lastOutboundAt: readNullableNumber(record, "lastOutboundAt") }
|
||||
: {}),
|
||||
...(readNullableNumber(record, "lastMessageAt") !== undefined
|
||||
? { lastMessageAt: readNullableNumber(record, "lastMessageAt") }
|
||||
: {}),
|
||||
...(readNullableNumber(record, "lastEventAt") !== undefined
|
||||
? { lastEventAt: readNullableNumber(record, "lastEventAt") }
|
||||
: {}),
|
||||
...(readNumber(record, "lastTransportActivityAt") !== undefined
|
||||
? { lastTransportActivityAt: readNumber(record, "lastTransportActivityAt") }
|
||||
: {}),
|
||||
...(statusState ? { statusState } : {}),
|
||||
...(healthState ? { healthState } : {}),
|
||||
// Only a proven-dead ingress crosses this boundary; `false`/absent both mean
|
||||
// "nothing known", so never project a negative and invent a healthy claim.
|
||||
...(readBoolean(record, "ingressUnavailable") === true ? { ingressUnavailable: true } : {}),
|
||||
...(readBoolean(record, "terminalDisconnect") !== undefined
|
||||
? { terminalDisconnect: readBoolean(record, "terminalDisconnect") }
|
||||
: {}),
|
||||
...(readBoolean(record, "busy") !== undefined ? { busy: readBoolean(record, "busy") } : {}),
|
||||
...(readNumber(record, "activeRuns") !== undefined
|
||||
? { activeRuns: readNumber(record, "activeRuns") }
|
||||
: {}),
|
||||
...(readNullableNumber(record, "lastRunActivityAt") !== undefined
|
||||
? { lastRunActivityAt: readNullableNumber(record, "lastRunActivityAt") }
|
||||
: {}),
|
||||
...(readNullableNumber(record, "activeRunStartedAt") !== undefined
|
||||
? { activeRunStartedAt: readNullableNumber(record, "activeRunStartedAt") }
|
||||
: {}),
|
||||
...(mode ? { mode } : {}),
|
||||
...(dmPolicy ? { dmPolicy } : {}),
|
||||
...(readStringArray(record, "allowFrom")
|
||||
? { allowFrom: readStringArray(record, "allowFrom") }
|
||||
: {}),
|
||||
...projectCredentialSnapshotFields(account),
|
||||
// Base URLs are useful diagnostics, but embedded credentials must not cross this boundary.
|
||||
...(baseUrl ? { baseUrl: stripUrlUserInfo(redactSensitiveUrlLikeString(baseUrl)) } : {}),
|
||||
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
|
||||
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }
|
||||
: {}),
|
||||
...(cliPath ? { cliPath } : {}),
|
||||
...(dbPath ? { dbPath } : {}),
|
||||
...(readNumber(record, "port") !== undefined ? { port: readNumber(record, "port") } : {}),
|
||||
};
|
||||
const baseUrl = normalizeOptionalString(record.baseUrl);
|
||||
if (baseUrl) {
|
||||
// Preserve diagnostics without allowing URL-embedded credentials to escape.
|
||||
snapshot.baseUrl = stripUrlUserInfo(redactSensitiveUrlLikeString(baseUrl));
|
||||
}
|
||||
setSnapshotField(snapshot, "allowUnmentionedGroups", asBoolean(record.allowUnmentionedGroups));
|
||||
for (const key of ["cliPath", "dbPath"] as const) {
|
||||
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
|
||||
}
|
||||
setSnapshotField(snapshot, "port", asFiniteNumber(record.port));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,22 @@ function seedChannelPkg(
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default { register() {} };\n", "utf8");
|
||||
}
|
||||
|
||||
function seedGeneratedChannelCatalog(
|
||||
root: string,
|
||||
params: {
|
||||
packageName: string;
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
blurb: string;
|
||||
},
|
||||
): void {
|
||||
const { packageName, ...channel } = params;
|
||||
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||
entries: [{ name: packageName, openclaw: { channel } }],
|
||||
});
|
||||
}
|
||||
|
||||
describe("listBundledChannelCatalogEntries", () => {
|
||||
it("reads bundled channel metadata from the extensions dir returned by resolveBundledPluginsDir", () => {
|
||||
// Regression gate for the onboard crash on globally installed CLI: in a
|
||||
@@ -144,20 +160,12 @@ describe("listBundledChannelCatalogEntries", () => {
|
||||
docsPath: "/channels/telegram",
|
||||
label: "Telegram",
|
||||
});
|
||||
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/qqbot",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "qqbot",
|
||||
label: "QQ Bot",
|
||||
docsPath: "/channels/qqbot",
|
||||
blurb: "downloadable channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
seedGeneratedChannelCatalog(root, {
|
||||
packageName: "@openclaw/qqbot",
|
||||
id: "qqbot",
|
||||
label: "QQ Bot",
|
||||
docsPath: "/channels/qqbot",
|
||||
blurb: "downloadable channel",
|
||||
});
|
||||
useBundledPluginsDir(extensionsRoot);
|
||||
|
||||
@@ -176,20 +184,12 @@ describe("listBundledChannelCatalogEntries", () => {
|
||||
label: "Matrix",
|
||||
markdownCapable: true,
|
||||
});
|
||||
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/matrix",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
docsPath: "/channels/matrix",
|
||||
blurb: "stale generated entry",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
seedGeneratedChannelCatalog(root, {
|
||||
packageName: "@openclaw/matrix",
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
docsPath: "/channels/matrix",
|
||||
blurb: "stale generated entry",
|
||||
});
|
||||
useBundledPluginsDir(extensionsRoot);
|
||||
|
||||
@@ -203,20 +203,12 @@ describe("listBundledChannelCatalogEntries", () => {
|
||||
// that case the loader should consult the shipped channel-catalog.json
|
||||
// rather than report zero bundled channels.
|
||||
const root = seedRoot("bcr-fallback-undefined-");
|
||||
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/fallback",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "fallback-channel",
|
||||
label: "Fallback",
|
||||
docsPath: "/channels/fallback",
|
||||
blurb: "fallback blurb",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
seedGeneratedChannelCatalog(root, {
|
||||
packageName: "@openclaw/fallback",
|
||||
id: "fallback-channel",
|
||||
label: "Fallback",
|
||||
docsPath: "/channels/fallback",
|
||||
blurb: "fallback blurb",
|
||||
});
|
||||
useBundledPluginsDir(undefined);
|
||||
|
||||
@@ -232,20 +224,12 @@ describe("listBundledChannelCatalogEntries", () => {
|
||||
const root = seedRoot("bcr-fallback-empty-");
|
||||
const extensionsRoot = path.join(root, "dist", "extensions");
|
||||
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/fallback",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "fallback-channel",
|
||||
label: "Fallback",
|
||||
docsPath: "/channels/fallback",
|
||||
blurb: "fallback blurb",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
seedGeneratedChannelCatalog(root, {
|
||||
packageName: "@openclaw/fallback",
|
||||
id: "fallback-channel",
|
||||
label: "Fallback",
|
||||
docsPath: "/channels/fallback",
|
||||
blurb: "fallback blurb",
|
||||
});
|
||||
useBundledPluginsDir(extensionsRoot);
|
||||
|
||||
|
||||
@@ -42,40 +42,24 @@ export function createAccountListHelpers<
|
||||
function hasImplicitDefaultAccount(cfg: OpenClawConfig): boolean {
|
||||
// Legacy single-account configs and env-only setup imply the default account even when
|
||||
// channels.<id>.accounts is absent.
|
||||
if (options?.hasImplicitDefaultAccount?.(cfg)) {
|
||||
return true;
|
||||
}
|
||||
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
|
||||
for (const key of options?.implicitDefaultAccount?.channelKeys ?? []) {
|
||||
if (hasConfiguredAccountValue(channel?.[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of options?.implicitDefaultAccount?.envVars ?? []) {
|
||||
if (hasConfiguredAccountValue(process.env[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return Boolean(
|
||||
options?.hasImplicitDefaultAccount?.(cfg) ||
|
||||
options?.implicitDefaultAccount?.channelKeys?.some((key) =>
|
||||
hasConfiguredAccountValue(channel?.[key]),
|
||||
) ||
|
||||
options?.implicitDefaultAccount?.envVars?.some((key) =>
|
||||
hasConfiguredAccountValue(process.env[key]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined {
|
||||
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
|
||||
const preferred = normalizeOptionalAccountId(
|
||||
// The canonical default resolver validates this preference against the same listed ids.
|
||||
return normalizeOptionalAccountId(
|
||||
typeof channel?.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
if (!preferred) {
|
||||
return undefined;
|
||||
}
|
||||
const ids = listAccountIds(cfg);
|
||||
if (options?.allowUnlistedDefaultAccount) {
|
||||
return preferred;
|
||||
}
|
||||
// Reject stale defaultAccount values unless the channel explicitly supports external ids.
|
||||
if (ids.some((id) => normalizeAccountId(id) === preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
@@ -159,20 +143,17 @@ export function listCombinedAccountIds(params: {
|
||||
fallbackAccountIdWhenEmpty?: string | undefined;
|
||||
}): string[] {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const id of params.configuredAccountIds) {
|
||||
if (id) {
|
||||
ids.add(id);
|
||||
for (const accountIds of [
|
||||
params.configuredAccountIds,
|
||||
params.additionalAccountIds ?? [],
|
||||
params.implicitAccountId ? [params.implicitAccountId] : [],
|
||||
]) {
|
||||
for (const accountId of accountIds) {
|
||||
if (accountId) {
|
||||
ids.add(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of params.additionalAccountIds ?? []) {
|
||||
if (id) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
if (params.implicitAccountId) {
|
||||
ids.add(params.implicitAccountId);
|
||||
}
|
||||
|
||||
if (ids.size === 0 && params.fallbackAccountIdWhenEmpty) {
|
||||
return [params.fallbackAccountIdWhenEmpty];
|
||||
|
||||
@@ -71,6 +71,38 @@ function mockAlphaDistExtensionRuntime() {
|
||||
}));
|
||||
}
|
||||
|
||||
function writeAlphaSdkAliasDistFixture(pluginDir: string, label: string) {
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.js"),
|
||||
[
|
||||
'import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";',
|
||||
"export default defineBundledChannelEntry({",
|
||||
" id: 'alpha',",
|
||||
" name: 'Alpha',",
|
||||
" description: 'Alpha',",
|
||||
" importMetaUrl: import.meta.url,",
|
||||
" plugin: { specifier: './plugin.js', exportName: 'plugin' },",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "plugin.js"),
|
||||
[
|
||||
"export const plugin = {",
|
||||
" id: 'alpha',",
|
||||
` meta: { id: 'alpha', label: '${label}' },`,
|
||||
" capabilities: {},",
|
||||
" config: {},",
|
||||
"};",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function collectBundledChannelEntrypointOffenders(
|
||||
bundledPluginRoots: string[],
|
||||
isOffender: (source: string, filePath: string) => boolean,
|
||||
@@ -403,36 +435,8 @@ describe("bundled channel entry shape guards", () => {
|
||||
it("falls back through the cached loader for package-local dist entries needing SDK aliases", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-package-dist-"));
|
||||
const pluginDir = path.join(root, "extensions", "alpha", "dist");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
writeAlphaSdkAliasDistFixture(pluginDir, "Package dist Alpha");
|
||||
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.js"),
|
||||
[
|
||||
'import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";',
|
||||
"export default defineBundledChannelEntry({",
|
||||
" id: 'alpha',",
|
||||
" name: 'Alpha',",
|
||||
" description: 'Alpha',",
|
||||
" importMetaUrl: import.meta.url,",
|
||||
" plugin: { specifier: './plugin.js', exportName: 'plugin' },",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "plugin.js"),
|
||||
[
|
||||
"export const plugin = {",
|
||||
" id: 'alpha',",
|
||||
" meta: { id: 'alpha', label: 'Package dist Alpha' },",
|
||||
" capabilities: {},",
|
||||
" config: {},",
|
||||
"};",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.doMock("./bundled-root.js", () => ({
|
||||
resolveBundledChannelRootScope: () => ({
|
||||
@@ -470,36 +474,8 @@ describe("bundled channel entry shape guards", () => {
|
||||
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const pluginsRoot = path.join(root, "bundled-plugins");
|
||||
const pluginDir = path.join(pluginsRoot, "alpha", "dist");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
writeAlphaSdkAliasDistFixture(pluginDir, "Direct dist Alpha");
|
||||
fs.writeFileSync(path.join(pluginsRoot, "package.json"), '{"type":"module"}\n', "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.js"),
|
||||
[
|
||||
'import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";',
|
||||
"export default defineBundledChannelEntry({",
|
||||
" id: 'alpha',",
|
||||
" name: 'Alpha',",
|
||||
" description: 'Alpha',",
|
||||
" importMetaUrl: import.meta.url,",
|
||||
" plugin: { specifier: './plugin.js', exportName: 'plugin' },",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "plugin.js"),
|
||||
[
|
||||
"export const plugin = {",
|
||||
" id: 'alpha',",
|
||||
" meta: { id: 'alpha', label: 'Direct dist Alpha' },",
|
||||
" capabilities: {},",
|
||||
" config: {},",
|
||||
"};",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({
|
||||
listBundledChannelPluginMetadata: () => [alphaChannelMetadata()],
|
||||
|
||||
@@ -201,28 +201,6 @@ function loadCatalogEntriesFromPaths(
|
||||
return entries;
|
||||
}
|
||||
|
||||
function loadOfficialCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
|
||||
const entries: ExternalCatalogEntry[] = [];
|
||||
for (const resolvedPath of paths) {
|
||||
const cached = officialCatalogEntriesByPath.get(resolvedPath);
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
entries.push(...cached);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const payload = tryReadJsonSync(resolvedPath);
|
||||
if (payload === null) {
|
||||
officialCatalogEntriesByPath.set(resolvedPath, null);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseCatalogEntries(payload);
|
||||
officialCatalogEntriesByPath.set(resolvedPath, parsed);
|
||||
entries.push(...parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
|
||||
if (options.officialCatalogPaths && options.officialCatalogPaths.length > 0) {
|
||||
return normalizeStringEntries(options.officialCatalogPaths);
|
||||
@@ -251,10 +229,12 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
|
||||
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
|
||||
const builtInEntries = listOfficialExternalChannelCatalogEntries();
|
||||
const officialPaths = resolveOfficialCatalogPaths(options);
|
||||
const fileEntries =
|
||||
const fileEntries = loadCatalogEntriesFromPaths(
|
||||
officialPaths,
|
||||
options.officialCatalogPaths && options.officialCatalogPaths.length > 0
|
||||
? loadCatalogEntriesFromPaths(officialPaths)
|
||||
: loadOfficialCatalogEntriesFromPaths(officialPaths);
|
||||
? undefined
|
||||
: officialCatalogEntriesByPath,
|
||||
);
|
||||
return [...builtInEntries, ...fileEntries]
|
||||
.map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true }))
|
||||
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
|
||||
@@ -473,6 +453,12 @@ export function listRawChannelPluginCatalogEntries(
|
||||
discovery: options.discovery,
|
||||
});
|
||||
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
|
||||
const rememberCatalogEntry = (entry: ChannelPluginCatalogEntry, priority: number) => {
|
||||
const existing = resolved.get(entry.id);
|
||||
if (!existing || priority < existing.priority) {
|
||||
resolved.set(entry.id, { entry, priority });
|
||||
}
|
||||
};
|
||||
|
||||
for (const candidate of manifestEntries) {
|
||||
if (
|
||||
@@ -493,19 +479,11 @@ export function listRawChannelPluginCatalogEntries(
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const priority = ORIGIN_PRIORITY[candidate.origin] ?? 99;
|
||||
const existing = resolved.get(entry.id);
|
||||
if (!existing || priority < existing.priority) {
|
||||
resolved.set(entry.id, { entry, priority });
|
||||
}
|
||||
rememberCatalogEntry(entry, ORIGIN_PRIORITY[candidate.origin] ?? 99);
|
||||
}
|
||||
|
||||
for (const entry of loadOfficialCatalogEntries(options)) {
|
||||
const priority = FALLBACK_CATALOG_PRIORITY;
|
||||
const existing = resolved.get(entry.id);
|
||||
if (!existing || priority < existing.priority) {
|
||||
resolved.set(entry.id, { entry, priority });
|
||||
}
|
||||
rememberCatalogEntry(entry, FALLBACK_CATALOG_PRIORITY);
|
||||
}
|
||||
|
||||
const externalEntries = loadExternalCatalogEntries(options)
|
||||
@@ -514,11 +492,7 @@ export function listRawChannelPluginCatalogEntries(
|
||||
for (const entry of externalEntries) {
|
||||
// External catalogs are the supported override seam for shipped fallback
|
||||
// metadata, but discovered plugins should still win when they are present.
|
||||
const priority = EXTERNAL_CATALOG_PRIORITY;
|
||||
const existing = resolved.get(entry.id);
|
||||
if (!existing || priority < existing.priority) {
|
||||
resolved.set(entry.id, { entry, priority });
|
||||
}
|
||||
rememberCatalogEntry(entry, EXTERNAL_CATALOG_PRIORITY);
|
||||
}
|
||||
|
||||
return Array.from(resolved.values())
|
||||
|
||||
@@ -53,20 +53,6 @@ export function toDirectoryEntries(kind: "user" | "group", ids: string[]): Chann
|
||||
return entries;
|
||||
}
|
||||
|
||||
function collectDirectoryIdsFromEntries(params: {
|
||||
entries?: readonly unknown[];
|
||||
normalizeId?: (entry: string) => string | null | undefined;
|
||||
}): string[] {
|
||||
return collectDirectoryIds(params.entries ?? [], params.normalizeId);
|
||||
}
|
||||
|
||||
function collectDirectoryIdsFromMapKeys(params: {
|
||||
groups?: Record<string, unknown>;
|
||||
normalizeId?: (entry: string) => string | null | undefined;
|
||||
}): string[] {
|
||||
return collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeId);
|
||||
}
|
||||
|
||||
function collectDirectoryIds(
|
||||
values: Iterable<unknown>,
|
||||
normalizeId?: (entry: string) => string | null | undefined,
|
||||
@@ -225,12 +211,7 @@ export function listDirectoryUserEntriesFromAllowFrom(params: {
|
||||
limit?: number | null;
|
||||
normalizeId?: (entry: string) => string | null | undefined;
|
||||
}): ChannelDirectoryEntry[] {
|
||||
const ids = uniqueStrings(
|
||||
collectDirectoryIdsFromEntries({
|
||||
entries: params.allowFrom,
|
||||
normalizeId: params.normalizeId,
|
||||
}),
|
||||
);
|
||||
const ids = uniqueStrings(collectDirectoryIds(params.allowFrom ?? [], params.normalizeId));
|
||||
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
|
||||
}
|
||||
|
||||
@@ -246,14 +227,8 @@ export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: {
|
||||
normalizeMapKeyId?: (entry: string) => string | null | undefined;
|
||||
}): ChannelDirectoryEntry[] {
|
||||
const ids = uniqueStrings([
|
||||
...collectDirectoryIdsFromEntries({
|
||||
entries: params.allowFrom,
|
||||
normalizeId: params.normalizeAllowFromId,
|
||||
}),
|
||||
...collectDirectoryIdsFromMapKeys({
|
||||
groups: params.map,
|
||||
normalizeId: params.normalizeMapKeyId,
|
||||
}),
|
||||
...collectDirectoryIds(params.allowFrom ?? [], params.normalizeAllowFromId),
|
||||
...collectDirectoryIds(Object.keys(params.map ?? {}), params.normalizeMapKeyId),
|
||||
]);
|
||||
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
|
||||
}
|
||||
@@ -268,10 +243,7 @@ export function listDirectoryGroupEntriesFromMapKeys(params: {
|
||||
normalizeId?: (entry: string) => string | null | undefined;
|
||||
}): ChannelDirectoryEntry[] {
|
||||
const ids = uniqueStrings(
|
||||
collectDirectoryIdsFromMapKeys({
|
||||
groups: params.groups,
|
||||
normalizeId: params.normalizeId,
|
||||
}),
|
||||
collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeId),
|
||||
);
|
||||
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
|
||||
}
|
||||
@@ -288,14 +260,8 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: {
|
||||
normalizeAllowFromId?: (entry: string) => string | null | undefined;
|
||||
}): ChannelDirectoryEntry[] {
|
||||
const ids = uniqueStrings([
|
||||
...collectDirectoryIdsFromMapKeys({
|
||||
groups: params.groups,
|
||||
normalizeId: params.normalizeMapKeyId,
|
||||
}),
|
||||
...collectDirectoryIdsFromEntries({
|
||||
entries: params.allowFrom,
|
||||
normalizeId: params.normalizeAllowFromId,
|
||||
}),
|
||||
...collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeMapKeyId),
|
||||
...collectDirectoryIds(params.allowFrom ?? [], params.normalizeAllowFromId),
|
||||
]);
|
||||
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
|
||||
}
|
||||
|
||||
@@ -318,51 +318,6 @@ export function listCrossChannelSchemaSupportedMessageActions(
|
||||
return resolved.actions.filter((action) => !schemaBlockedActions.has(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists message capabilities advertised across registered channel plugins.
|
||||
*/
|
||||
function listChannelMessageCapabilities(params: {
|
||||
cfg: OpenClawConfig;
|
||||
preparedMessageToolCatalog?: PreparedMessageToolCatalog;
|
||||
}): ChannelMessageCapability[] {
|
||||
const capabilities = new Set<ChannelMessageCapability>();
|
||||
const channels = listMessageActionDiscoveryChannels(params.preparedMessageToolCatalog);
|
||||
for (const plugin of channels) {
|
||||
for (const capability of resolveMessageActionDiscoveryForPlugin({
|
||||
pluginId: plugin.id,
|
||||
actions: plugin.actions,
|
||||
context: { cfg: params.cfg },
|
||||
includeCapabilities: true,
|
||||
}).capabilities) {
|
||||
capabilities.add(capability);
|
||||
}
|
||||
}
|
||||
return Array.from(capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists message capabilities advertised by the current channel.
|
||||
*/
|
||||
function listChannelMessageCapabilitiesForChannel(
|
||||
params: ChannelMessageActionDiscoveryParams,
|
||||
): ChannelMessageCapability[] {
|
||||
const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(
|
||||
params.channel,
|
||||
params.preparedMessageToolCatalog,
|
||||
);
|
||||
if (!pluginActions) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
resolveMessageActionDiscoveryForPlugin({
|
||||
pluginId: pluginActions.pluginId,
|
||||
actions: pluginActions.actions,
|
||||
context: createMessageActionDiscoveryContext(params),
|
||||
includeCapabilities: true,
|
||||
}).capabilities,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges schema properties while preserving the first plugin to define a key.
|
||||
*/
|
||||
@@ -474,10 +429,18 @@ export function channelSupportsMessageCapability(
|
||||
capability: ChannelMessageCapability,
|
||||
preparedMessageToolCatalog?: PreparedMessageToolCatalog,
|
||||
): boolean {
|
||||
return listChannelMessageCapabilities({
|
||||
cfg,
|
||||
preparedMessageToolCatalog,
|
||||
}).includes(capability);
|
||||
const discoveredCapabilities = listMessageActionDiscoveryChannels(preparedMessageToolCatalog).map(
|
||||
(plugin) =>
|
||||
resolveMessageActionDiscoveryForPlugin({
|
||||
pluginId: plugin.id,
|
||||
actions: plugin.actions,
|
||||
context: { cfg },
|
||||
includeCapabilities: true,
|
||||
}).capabilities,
|
||||
);
|
||||
return discoveredCapabilities.some((pluginCapabilities) =>
|
||||
pluginCapabilities.includes(capability),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,5 +450,17 @@ export function channelSupportsMessageCapabilityForChannel(
|
||||
params: ChannelMessageActionDiscoveryParams,
|
||||
capability: ChannelMessageCapability,
|
||||
): boolean {
|
||||
return listChannelMessageCapabilitiesForChannel(params).includes(capability);
|
||||
const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(
|
||||
params.channel,
|
||||
params.preparedMessageToolCatalog,
|
||||
);
|
||||
if (!pluginActions) {
|
||||
return false;
|
||||
}
|
||||
return resolveMessageActionDiscoveryForPlugin({
|
||||
pluginId: pluginActions.pluginId,
|
||||
actions: pluginActions.actions,
|
||||
context: createMessageActionDiscoveryContext(params),
|
||||
includeCapabilities: true,
|
||||
}).capabilities.includes(capability);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,23 @@ function activateMessageActionTestRegistry() {
|
||||
);
|
||||
}
|
||||
|
||||
function activateDiscoveredMessageActionPlugin(params: {
|
||||
id: ChannelPlugin["id"];
|
||||
label: string;
|
||||
describeMessageTool: NonNullable<ChannelPlugin["actions"]>["describeMessageTool"];
|
||||
}) {
|
||||
const plugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: { listAccountIds: () => ["default"] },
|
||||
}),
|
||||
actions: { describeMessageTool: params.describeMessageTool },
|
||||
};
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: params.id, source: "test", plugin }]));
|
||||
}
|
||||
|
||||
describe("message action capability checks", () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
@@ -116,73 +133,39 @@ describe("message action capability checks", () => {
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: plugin.id, source: "test", plugin }]));
|
||||
const preparedMessageToolCatalog = getPreparedMessageToolCatalog();
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const supportsAccountCapability = (accountId: string, capability: ChannelMessageCapability) =>
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg, channel: plugin.id, accountId, preparedMessageToolCatalog },
|
||||
capability,
|
||||
);
|
||||
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg, channel: plugin.id, accountId: "first", preparedMessageToolCatalog },
|
||||
"presentation",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg, channel: plugin.id, accountId: "second", preparedMessageToolCatalog },
|
||||
"presentation",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg, channel: plugin.id, accountId: "second", preparedMessageToolCatalog },
|
||||
"delivery-pin",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(supportsAccountCapability("first", "presentation")).toBe(true);
|
||||
expect(supportsAccountCapability("second", "presentation")).toBe(false);
|
||||
expect(supportsAccountCapability("second", "delivery-pin")).toBe(true);
|
||||
});
|
||||
|
||||
it("checks per-channel capabilities", () => {
|
||||
activateMessageActionTestRegistry();
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const supportsCapability = (
|
||||
channel: string | undefined,
|
||||
capability: ChannelMessageCapability,
|
||||
) => channelSupportsMessageCapabilityForChannel({ cfg, channel }, capability);
|
||||
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "demo-buttons" },
|
||||
"presentation",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
|
||||
"presentation",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "demo-buttons" },
|
||||
"delivery-pin",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
|
||||
"delivery-pin",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel({ cfg: {} as OpenClawConfig }, "delivery-pin"),
|
||||
).toBe(false);
|
||||
expect(supportsCapability("demo-buttons", "presentation")).toBe(true);
|
||||
expect(supportsCapability("demo-cards", "presentation")).toBe(false);
|
||||
expect(supportsCapability("demo-buttons", "delivery-pin")).toBe(false);
|
||||
expect(supportsCapability("demo-cards", "delivery-pin")).toBe(true);
|
||||
expect(supportsCapability(undefined, "delivery-pin")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes channel aliases for per-channel capability checks", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "demo-cards",
|
||||
source: "test",
|
||||
plugin: createMessageActionsPlugin({
|
||||
id: "demo-cards",
|
||||
aliases: ["demo-cards-alias"],
|
||||
capabilities: ["delivery-pin"],
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const plugin = createMessageActionsPlugin({
|
||||
id: "demo-cards",
|
||||
aliases: ["demo-cards-alias"],
|
||||
capabilities: ["delivery-pin"],
|
||||
});
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: plugin.id, source: "test", plugin }]));
|
||||
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
@@ -193,30 +176,19 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("uses unified message tool discovery for actions, capabilities, and schema", () => {
|
||||
const unifiedPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-unified",
|
||||
label: "Demo Unified",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-unified",
|
||||
label: "Demo Unified",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["react"],
|
||||
capabilities: ["presentation"],
|
||||
schema: {
|
||||
properties: {
|
||||
components: Type.Array(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["react"],
|
||||
capabilities: ["presentation"],
|
||||
schema: {
|
||||
properties: {
|
||||
components: Type.Array(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "demo-unified", source: "test", plugin: unifiedPlugin }]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(channelSupportsMessageCapability({} as OpenClawConfig, "presentation")).toBe(true);
|
||||
expect(
|
||||
@@ -228,36 +200,23 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("keeps contributed schema properties optional so only action stays required", () => {
|
||||
const contributingPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-contrib",
|
||||
label: "Demo Contrib",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-contrib",
|
||||
label: "Demo Contrib",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["send"],
|
||||
schema: {
|
||||
properties: {
|
||||
// Non-optional TypeBox schema: plugin forgot Type.Optional.
|
||||
components: Type.Array(Type.String()),
|
||||
// Cloning strips typebox's non-enumerable `~optional` marker;
|
||||
// mirrors serialized/external plugin contributions.
|
||||
chatRef: structuredClone(Type.Optional(Type.String())),
|
||||
media: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["send"],
|
||||
schema: {
|
||||
properties: {
|
||||
// Non-optional TypeBox schema: plugin forgot Type.Optional.
|
||||
components: Type.Array(Type.String()),
|
||||
// Cloning strips typebox's non-enumerable `~optional` marker;
|
||||
// mirrors serialized/external plugin contributions.
|
||||
chatRef: structuredClone(Type.Optional(Type.String())),
|
||||
media: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "demo-contrib", source: "test", plugin: contributingPlugin },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const properties = resolveChannelMessageToolSchemaProperties({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -270,32 +229,19 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("filters only actions that depend on current-channel-only schema", () => {
|
||||
const scopedSchemaPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-scoped-schema",
|
||||
label: "Demo Scoped Schema",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-scoped-schema",
|
||||
label: "Demo Scoped Schema",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["read", "list-pins", "unpin"],
|
||||
schema: {
|
||||
actions: ["unpin"],
|
||||
properties: {
|
||||
pinnedMessageId: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["read", "list-pins", "unpin"],
|
||||
schema: {
|
||||
actions: ["unpin"],
|
||||
properties: {
|
||||
pinnedMessageId: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "demo-scoped-schema", source: "test", plugin: scopedSchemaPlugin },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
listCrossChannelSchemaSupportedMessageActions({
|
||||
@@ -306,31 +252,18 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("keeps unscoped current-channel schema conservative for cross-channel actions", () => {
|
||||
const unscopedSchemaPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-unscoped-schema",
|
||||
label: "Demo Unscoped Schema",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-unscoped-schema",
|
||||
label: "Demo Unscoped Schema",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["read", "unpin"],
|
||||
schema: {
|
||||
properties: {
|
||||
pinnedMessageId: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["read", "unpin"],
|
||||
schema: {
|
||||
properties: {
|
||||
pinnedMessageId: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "demo-unscoped-schema", source: "test", plugin: unscopedSchemaPlugin },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
listCrossChannelSchemaSupportedMessageActions({
|
||||
@@ -341,36 +274,19 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("treats empty current-channel schema action lists as blocking no cross-channel actions", () => {
|
||||
const emptyScopedSchemaPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-empty-scoped-schema",
|
||||
label: "Demo Empty Scoped Schema",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-empty-scoped-schema",
|
||||
label: "Demo Empty Scoped Schema",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["read", "list-pins"],
|
||||
schema: {
|
||||
actions: [],
|
||||
properties: {
|
||||
optionalChannelOnlyValue: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["read", "list-pins"],
|
||||
schema: {
|
||||
actions: [],
|
||||
properties: {
|
||||
optionalChannelOnlyValue: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "demo-empty-scoped-schema",
|
||||
source: "test",
|
||||
plugin: emptyScopedSchemaPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
listCrossChannelSchemaSupportedMessageActions({
|
||||
@@ -381,34 +297,23 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("derives plugin-owned media-source params for the current action", () => {
|
||||
const mediaPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-media",
|
||||
label: "Demo Media",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-media",
|
||||
label: "Demo Media",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["send", "set-profile"],
|
||||
mediaSourceParams: {
|
||||
"set-profile": ["avatarUrl", "avatarPath"],
|
||||
},
|
||||
schema: {
|
||||
properties: {
|
||||
avatarUrl: Type.Optional(Type.String({ description: "Remote avatar URL" })),
|
||||
avatarPath: Type.Optional(Type.String({ description: "Local avatar path" })),
|
||||
displayName: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["send", "set-profile"],
|
||||
mediaSourceParams: {
|
||||
"set-profile": ["avatarUrl", "avatarPath"],
|
||||
},
|
||||
schema: {
|
||||
properties: {
|
||||
avatarUrl: Type.Optional(Type.String({ description: "Remote avatar URL" })),
|
||||
avatarPath: Type.Optional(Type.String({ description: "Local avatar path" })),
|
||||
displayName: Type.Optional(Type.String()),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "demo-media", source: "test", plugin: mediaPlugin }]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveChannelMessageToolMediaSourceParamKeys({
|
||||
@@ -427,25 +332,14 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("keeps flat media-source param discovery for backward compatibility", () => {
|
||||
const mediaPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-media-flat",
|
||||
label: "Demo Media Flat",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
},
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-media-flat",
|
||||
label: "Demo Media Flat",
|
||||
describeMessageTool: () => ({
|
||||
actions: ["set-profile"],
|
||||
mediaSourceParams: ["avatarUrl", "avatarPath"],
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["set-profile"],
|
||||
mediaSourceParams: ["avatarUrl", "avatarPath"],
|
||||
}),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "demo-media-flat", source: "test", plugin: mediaPlugin }]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveChannelMessageToolMediaSourceParamKeys({
|
||||
@@ -457,24 +351,13 @@ describe("message action capability checks", () => {
|
||||
});
|
||||
|
||||
it("skips crashing action/capability discovery paths and logs once", () => {
|
||||
const crashingPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "demo-crashing",
|
||||
label: "Demo Crashing",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
describeMessageTool: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
activateDiscoveredMessageActionPlugin({
|
||||
id: "demo-crashing",
|
||||
label: "Demo Crashing",
|
||||
describeMessageTool: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "demo-crashing", source: "test", plugin: crashingPlugin }]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(channelSupportsMessageCapability({} as OpenClawConfig, "presentation")).toBe(false);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -34,6 +34,26 @@ function pluginIds(plugins: ReturnType<typeof listReadOnlyChannelPluginsForConfi
|
||||
return plugins.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
function createExternalChannelTestConfig(params: {
|
||||
pluginDir: string;
|
||||
pluginId?: string;
|
||||
channels?: Record<string, Record<string, unknown>> | null;
|
||||
}): Parameters<typeof listReadOnlyChannelPluginsForConfig>[0] {
|
||||
return {
|
||||
...(params.channels === null
|
||||
? {}
|
||||
: {
|
||||
channels: params.channels ?? {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
}),
|
||||
plugins: {
|
||||
load: { paths: [params.pluginDir] },
|
||||
allow: [params.pluginId ?? "external-chat"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modulePathEndsWith(modulePath: string, suffix: string): boolean {
|
||||
const normalized = modulePath.startsWith("file:") ? fileURLToPath(modulePath) : modulePath;
|
||||
return normalized.replace(/\\/g, "/").endsWith(suffix);
|
||||
@@ -507,15 +527,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
it("uses package channel metadata without loading setup or full runtime", () => {
|
||||
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -530,15 +542,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
it("loads configured external channel setup metadata without importing full runtime", () => {
|
||||
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -560,25 +564,12 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
it("uses activation source config to discover channel setup metadata after secret stripping", () => {
|
||||
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
channels: { "external-chat": {} },
|
||||
}),
|
||||
{
|
||||
channels: {
|
||||
"external-chat": {},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
activationSourceConfig: {
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never,
|
||||
activationSourceConfig: createExternalChannelTestConfig({ pluginDir }),
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
includeSetupFallbackPlugins: true,
|
||||
@@ -590,15 +581,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
|
||||
it("reuses default read-only channel plugin resolution for the same config", () => {
|
||||
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
|
||||
const cfg = {
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never;
|
||||
const cfg = createExternalChannelTestConfig({ pluginDir });
|
||||
|
||||
const first = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
includePersistedAuthState: false,
|
||||
@@ -688,12 +671,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
|
||||
manifestChannelConfig: true,
|
||||
});
|
||||
const cfg = {
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never;
|
||||
const cfg = createExternalChannelTestConfig({ pluginDir, channels: null });
|
||||
|
||||
const first = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
includePersistedAuthState: false,
|
||||
@@ -712,15 +690,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
|
||||
it("clears cached read-only channel plugin resolution with plugin metadata lifecycle caches", () => {
|
||||
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
|
||||
const cfg = {
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never;
|
||||
const cfg = createExternalChannelTestConfig({ pluginDir });
|
||||
|
||||
const first = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
includePersistedAuthState: false,
|
||||
@@ -750,15 +720,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
setupChannelId: "external-chat-plugin",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -781,16 +743,14 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
setupChannelId: "alpha-chat",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: {
|
||||
"alpha-chat": { token: "configured" },
|
||||
"beta-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -829,15 +789,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
setupChannelId: "alpha-chat",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"beta-chat": { token: "beta-token" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: { "beta-chat": { token: "beta-token" } },
|
||||
}),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -869,15 +825,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
setupEntry: false,
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -907,7 +855,9 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
channelId: "external-chat",
|
||||
manifestChannelConfig: true,
|
||||
});
|
||||
const cfg = {
|
||||
const cfg = createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: {
|
||||
"external-chat": {
|
||||
defaultAccount: "Ops Team",
|
||||
@@ -917,11 +867,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never;
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -943,15 +889,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
setupRequiresRuntime: false,
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -994,15 +932,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
manifestChannelDescription: "manifest\u001b[2K config",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1025,13 +955,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
manifestChannelConfig: true,
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: Object.fromEntries([[unsafeChannelId, { token: "configured" }]]),
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1055,17 +983,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
}) as Record<string, unknown>;
|
||||
inheritedAccounts.default = { token: "default-token" };
|
||||
inheritedAccounts.named = { token: "named-token" };
|
||||
const cfg = {
|
||||
channels: {
|
||||
"external-chat": {
|
||||
accounts: inheritedAccounts,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never;
|
||||
const cfg = createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: { "external-chat": { accounts: inheritedAccounts } },
|
||||
});
|
||||
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1090,21 +1012,15 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
channelId: "external-chat",
|
||||
manifestChannelConfig: true,
|
||||
});
|
||||
const cfg = {
|
||||
const cfg = createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: {
|
||||
"external-chat": {
|
||||
accounts: {
|
||||
"constructor ": {
|
||||
token: "blocked-token",
|
||||
},
|
||||
},
|
||||
accounts: { "constructor ": { token: "blocked-token" } },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never;
|
||||
});
|
||||
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1126,7 +1042,9 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
channelId: "external-chat",
|
||||
manifestChannelConfig: true,
|
||||
});
|
||||
const cfg = {
|
||||
const cfg = createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: {
|
||||
"external-chat": {
|
||||
accounts: {
|
||||
@@ -1137,11 +1055,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never;
|
||||
});
|
||||
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1165,15 +1079,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
manifestChannelConfig: true,
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1193,12 +1099,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
channelId: "external-chat",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: null,
|
||||
}),
|
||||
{
|
||||
env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1215,15 +1120,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
channelId: "external-chat",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { enabled: false },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: { "external-chat": { enabled: false } },
|
||||
}),
|
||||
{
|
||||
env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" },
|
||||
includePersistedAuthState: false,
|
||||
@@ -1336,12 +1237,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
channelId: "external-chat",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({
|
||||
pluginDir,
|
||||
pluginId: "external-chat-plugin",
|
||||
channels: null,
|
||||
}),
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1404,15 +1304,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
setupChannelId: "spoofed-chat",
|
||||
});
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includeSetupFallbackPlugins: true,
|
||||
@@ -1437,15 +1329,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
|
||||
);
|
||||
|
||||
const result = resolveReadOnlyChannelPluginsForConfig(
|
||||
{
|
||||
channels: {
|
||||
"external-chat": { token: "configured" },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["external-chat-plugin"],
|
||||
},
|
||||
} as never,
|
||||
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
|
||||
{
|
||||
env: { ...process.env },
|
||||
includeSetupFallbackPlugins: true,
|
||||
|
||||
@@ -745,23 +745,6 @@ function addSetupChannelPlugins(
|
||||
if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (ownedMissingChannelIds.includes(setup.plugin.id)) {
|
||||
addChannelPlugins(byId, [setup.plugin], {
|
||||
onlyIds: new Set(ownedMissingChannelIds),
|
||||
allowOverwrite: false,
|
||||
});
|
||||
addChannelPlugins(
|
||||
byId,
|
||||
ownedMissingChannelIds
|
||||
.filter((channelId) => channelId !== setup.plugin.id)
|
||||
.map((channelId) => cloneChannelPluginForChannelId(setup.plugin, channelId)),
|
||||
{
|
||||
onlyIds: new Set(ownedMissingChannelIds),
|
||||
allowOverwrite: false,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter(
|
||||
isSafeManifestChannelId,
|
||||
);
|
||||
@@ -831,17 +814,6 @@ function listBundledChannelManifestRecords(
|
||||
return records.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0);
|
||||
}
|
||||
|
||||
function listPluginIdsForChannels(
|
||||
records: readonly PluginManifestRecord[],
|
||||
channelIds: readonly string[],
|
||||
): string[] {
|
||||
const requestedChannelIds = new Set(channelIds);
|
||||
return records
|
||||
.filter((plugin) => plugin.channels.some((channelId) => requestedChannelIds.has(channelId)))
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveExternalReadOnlyChannelPluginIds(params: {
|
||||
cfg: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
@@ -965,9 +937,14 @@ export function resolveReadOnlyChannelPluginsForConfig(
|
||||
const bundledManifestMissingChannelIds = configuredChannelIds.filter(
|
||||
(channelId) => !byId.has(channelId),
|
||||
);
|
||||
const bundledManifestMissingChannelIdSet = new Set(bundledManifestMissingChannelIds);
|
||||
addManifestChannelPlugins(byId, bundledManifestRecords, {
|
||||
pluginIds: new Set(
|
||||
listPluginIdsForChannels(bundledManifestRecords, bundledManifestMissingChannelIds),
|
||||
bundledManifestRecords.flatMap((record) =>
|
||||
record.channels.some((channelId) => bundledManifestMissingChannelIdSet.has(channelId))
|
||||
? [record.id]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
channelIds: bundledManifestMissingChannelIds,
|
||||
});
|
||||
|
||||
@@ -25,22 +25,14 @@ export function listChannelPlugins(): ChannelPlugin[] {
|
||||
* Returns a loaded channel plugin without falling back to bundled metadata.
|
||||
*/
|
||||
export function getLoadedChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
|
||||
const resolvedId = normalizeOptionalString(id) ?? "";
|
||||
if (!resolvedId) {
|
||||
return undefined;
|
||||
}
|
||||
return getLoadedChannelPluginById(resolvedId) as ChannelPlugin | undefined;
|
||||
return getLoadedChannelPluginById(id) as ChannelPlugin | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package/install origin for a loaded channel plugin.
|
||||
*/
|
||||
export function getLoadedChannelPluginOrigin(id: ChannelId): string | undefined {
|
||||
const resolvedId = normalizeOptionalString(id) ?? "";
|
||||
if (!resolvedId) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeOptionalString(getLoadedChannelPluginEntryById(resolvedId)?.origin) ?? undefined;
|
||||
return normalizeOptionalString(getLoadedChannelPluginEntryById(id)?.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,33 +100,20 @@ function projectChannelAccountState(state: ChannelAccountState): {
|
||||
lastError: state.failure,
|
||||
};
|
||||
case "unconfigured":
|
||||
return {
|
||||
configured: false,
|
||||
running: false,
|
||||
stateReason: state.reason,
|
||||
lastError: state.failure,
|
||||
};
|
||||
case "unlinked":
|
||||
return {
|
||||
configured: true,
|
||||
linked: false,
|
||||
configured: state.kind === "unlinked",
|
||||
...(state.kind === "unlinked" ? { linked: false } : {}),
|
||||
running: false,
|
||||
stateReason: state.reason,
|
||||
lastError: state.failure,
|
||||
};
|
||||
case "running":
|
||||
return {
|
||||
configured: true,
|
||||
...(state.linked ? { linked: true } : {}),
|
||||
running: true,
|
||||
...(typeof state.connected === "boolean" ? { connected: state.connected } : {}),
|
||||
lastError: state.failure,
|
||||
};
|
||||
case "stopped":
|
||||
return {
|
||||
configured: true,
|
||||
...(state.linked ? { linked: true } : {}),
|
||||
running: false,
|
||||
running: state.kind === "running",
|
||||
...(typeof state.connected === "boolean" ? { connected: state.connected } : {}),
|
||||
lastError: state.failure,
|
||||
};
|
||||
|
||||
@@ -243,6 +243,29 @@ function primeSuccessfulPluginPersistence(pluginId = "demo") {
|
||||
return { cfg, enabledCfg };
|
||||
}
|
||||
|
||||
function primeSuccessfulClawHubPluginInstall(
|
||||
params: {
|
||||
explicitVersion?: boolean;
|
||||
trust?: Parameters<typeof createClawHubInstallResult>[0]["trust"];
|
||||
} = {},
|
||||
) {
|
||||
const result = primeSuccessfulPluginPersistence("demo");
|
||||
parseClawHubPluginSpec.mockReturnValue({
|
||||
name: "demo",
|
||||
...(params.explicitVersion ? { version: "1.2.3" } : {}),
|
||||
});
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
...(params.trust ? { trust: params.trust } : {}),
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function createPathHookPackInstalledConfig(tmpRoot: string): OpenClawConfig {
|
||||
return {
|
||||
hooks: {
|
||||
@@ -1543,16 +1566,7 @@ describe("plugins cli install", () => {
|
||||
);
|
||||
|
||||
it("does not show the non-ClawHub warning for explicit ClawHub installs", async () => {
|
||||
primeSuccessfulPluginPersistence("demo");
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
}),
|
||||
);
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
|
||||
|
||||
expect(runtimeLogsContain("outside ClawHub review")).toBe(false);
|
||||
@@ -1560,27 +1574,7 @@ describe("plugins cli install", () => {
|
||||
});
|
||||
|
||||
it("installs ClawHub plugins and persists source metadata", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
}),
|
||||
);
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
const { enabledCfg } = primeSuccessfulClawHubPluginInstall();
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
|
||||
|
||||
@@ -1613,21 +1607,7 @@ describe("plugins cli install", () => {
|
||||
});
|
||||
|
||||
it("does not report a ClawHub install when durable persistence fails", async () => {
|
||||
loadConfig.mockReturnValue(createEmptyPluginConfig());
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
}),
|
||||
);
|
||||
enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") });
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: createEnabledPluginConfig("demo"),
|
||||
warnings: [],
|
||||
});
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
writeConfigFile.mockRejectedValueOnce(new Error("persistence failed"));
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow(
|
||||
@@ -1638,27 +1618,14 @@ describe("plugins cli install", () => {
|
||||
});
|
||||
|
||||
it("passes ClawHub risk acknowledgement to explicit ClawHub installs", async () => {
|
||||
loadConfig.mockReturnValue(createEmptyPluginConfig());
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
trust: {
|
||||
disposition: "review-required",
|
||||
scanStatus: "suspicious",
|
||||
reasons: ["payload_strings"],
|
||||
checkedAt: "2026-05-14T18:00:00.000Z",
|
||||
acknowledgedAt: "2026-05-14T18:00:03.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") });
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: createEnabledPluginConfig("demo"),
|
||||
warnings: [],
|
||||
primeSuccessfulClawHubPluginInstall({
|
||||
trust: {
|
||||
disposition: "review-required",
|
||||
scanStatus: "suspicious",
|
||||
reasons: ["payload_strings"],
|
||||
checkedAt: "2026-05-14T18:00:00.000Z",
|
||||
acknowledgedAt: "2026-05-14T18:00:03.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]);
|
||||
@@ -1714,24 +1681,7 @@ describe("plugins cli install", () => {
|
||||
|
||||
it("passes the active profile extensions dir to ClawHub installs", async () => {
|
||||
const extensionsDir = useProfileExtensionsDir();
|
||||
const cfg = createEmptyPluginConfig();
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
}),
|
||||
);
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
|
||||
|
||||
@@ -1859,29 +1809,7 @@ describe("plugins cli install", () => {
|
||||
});
|
||||
|
||||
it("passes force through as overwrite mode for ClawHub installs", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
}),
|
||||
);
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(enabledCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]);
|
||||
|
||||
@@ -1890,28 +1818,7 @@ describe("plugins cli install", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit ClawHub versions pinned in install records", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo", version: "1.2.3" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "official",
|
||||
}),
|
||||
);
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
primeSuccessfulClawHubPluginInstall({ explicitVersion: true });
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo@1.2.3"]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHostedMarketplaceFeedFixture } from "./plugins-marketplace-feed.test-support.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const defaultRuntime = {
|
||||
@@ -72,53 +73,26 @@ describe("plugins marketplace entries", () => {
|
||||
it("lists entries from an explicitly selected marketplace feed as JSON", async () => {
|
||||
const config = {};
|
||||
mocks.getRuntimeConfig.mockReturnValue(config);
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted-snapshot",
|
||||
entries: [
|
||||
{
|
||||
name: "@acme/calendar",
|
||||
version: "1.2.3",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { trust: "official" },
|
||||
install: {
|
||||
candidates: [{ sourceRef: "public-npm", package: "@acme/calendar", version: "1.2.3" }],
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
|
||||
createHostedMarketplaceFeedFixture({
|
||||
source: "hosted-snapshot",
|
||||
entries: [
|
||||
{
|
||||
name: "@acme/calendar",
|
||||
version: "1.2.3",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{ sourceRef: "public-npm", package: "@acme/calendar", version: "1.2.3" },
|
||||
],
|
||||
},
|
||||
openclaw: { plugin: { id: "acme-calendar", label: "Acme Calendar" } },
|
||||
},
|
||||
openclaw: {
|
||||
plugin: { id: "acme-calendar", label: "Acme Calendar" },
|
||||
},
|
||||
},
|
||||
],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
snapshot: {
|
||||
body: "{}",
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
savedAt: "2026-06-23T01:02:03.000Z",
|
||||
},
|
||||
trust: {
|
||||
mode: "signed",
|
||||
signedBy: "acme-root-2026",
|
||||
signatureCount: 1,
|
||||
threshold: 1,
|
||||
verifiedAt: "2026-06-23T01:02:03.000Z",
|
||||
},
|
||||
error: "hosted catalog feed offline mode",
|
||||
});
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true, json: true });
|
||||
@@ -190,6 +164,30 @@ describe("plugins marketplace entries", () => {
|
||||
expect(output).not.toContain("#frag");
|
||||
});
|
||||
|
||||
it("keeps replacement metacharacters literal while redacting feed URLs", async () => {
|
||||
const publicUrl = ["https://", "feed.example.invalid", "/$&"].join("");
|
||||
const privateQuery = ["marker=", ["test", "-", "secret"].join("")].join("");
|
||||
const rawUrl = [publicUrl, "?", privateQuery].join("");
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "bundled-fallback",
|
||||
entries: [],
|
||||
error: `hosted catalog feed fetch failed for ${rawUrl}`,
|
||||
metadata: { url: rawUrl, status: 503 },
|
||||
});
|
||||
|
||||
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceEntriesCommand({ feedUrl: rawUrl, json: true });
|
||||
|
||||
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ url: publicUrl }),
|
||||
error: `hosted catalog feed fetch failed for ${publicUrl}`,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(mocks.defaultRuntime.writeJson.mock.calls)).not.toContain(privateQuery);
|
||||
});
|
||||
|
||||
it("prints bundled fallback entries without failing", async () => {
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
@@ -224,39 +222,9 @@ describe("plugins marketplace entries", () => {
|
||||
|
||||
it("prints bounded signed feed trust state in text output", async () => {
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted-snapshot",
|
||||
entries: [],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
snapshot: {
|
||||
body: "{}",
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
savedAt: "2026-06-23T01:02:03.000Z",
|
||||
},
|
||||
trust: {
|
||||
mode: "signed",
|
||||
signedBy: "acme-root-2026",
|
||||
signatureCount: 1,
|
||||
threshold: 1,
|
||||
verifiedAt: "2026-06-23T01:02:03.000Z",
|
||||
},
|
||||
error: "hosted catalog feed offline mode",
|
||||
});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
|
||||
createHostedMarketplaceFeedFixture({ source: "hosted-snapshot" }),
|
||||
);
|
||||
|
||||
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceEntriesCommand({ offline: true });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHostedMarketplaceFeedFixture } from "./plugins-marketplace-feed.test-support.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const defaultRuntime = {
|
||||
@@ -67,30 +68,12 @@ describe("plugins marketplace refresh", () => {
|
||||
it("refreshes an explicitly selected marketplace feed and prints JSON", async () => {
|
||||
const config = {};
|
||||
mocks.getRuntimeConfig.mockReturnValue(config);
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
|
||||
createHostedMarketplaceFeedFixture({
|
||||
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
|
||||
etag: '"abc"',
|
||||
},
|
||||
trust: {
|
||||
mode: "signed",
|
||||
signedBy: "acme-root-2026",
|
||||
signatureCount: 1,
|
||||
threshold: 1,
|
||||
verifiedAt: "2026-06-23T00:01:02.000Z",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceRefreshCommand({
|
||||
@@ -130,29 +113,9 @@ describe("plugins marketplace refresh", () => {
|
||||
|
||||
it("prints bounded signed feed trust state in text output", async () => {
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [{ name: "@acme/calendar" }],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
},
|
||||
trust: {
|
||||
mode: "signed",
|
||||
signedBy: "acme-root-2026",
|
||||
signatureCount: 1,
|
||||
threshold: 1,
|
||||
verifiedAt: "2026-06-23T00:01:02.000Z",
|
||||
},
|
||||
});
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
|
||||
createHostedMarketplaceFeedFixture({ entries: [{ name: "@acme/calendar" }] }),
|
||||
);
|
||||
|
||||
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceRefreshCommand({});
|
||||
@@ -168,22 +131,13 @@ describe("plugins marketplace refresh", () => {
|
||||
it("normalizes bare SHA-256 pins before refreshing", async () => {
|
||||
const config = {};
|
||||
mocks.getRuntimeConfig.mockReturnValue(config);
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [{ name: "@acme/calendar" }],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
|
||||
createHostedMarketplaceFeedFixture({
|
||||
entries: [{ name: "@acme/calendar" }],
|
||||
checksum: "sha256:abcdef",
|
||||
},
|
||||
});
|
||||
includeTrust: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceRefreshCommand({
|
||||
@@ -307,30 +261,13 @@ describe("plugins marketplace refresh", () => {
|
||||
diagnostics: { flags: ["timeline"] },
|
||||
};
|
||||
mocks.getRuntimeConfig.mockReturnValue(config);
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata: {
|
||||
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
|
||||
createHostedMarketplaceFeedFixture({
|
||||
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
|
||||
url: "https://user:secret@packages.acme.example/openclaw/feed?token=leak#frag",
|
||||
status: 200,
|
||||
checksum: "feed-sha",
|
||||
etag: '"abc"',
|
||||
},
|
||||
trust: {
|
||||
mode: "signed",
|
||||
signedBy: "acme-root-2026",
|
||||
signatureCount: 1,
|
||||
threshold: 1,
|
||||
verifiedAt: "2026-06-23T00:01:02.000Z",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
|
||||
await runPluginMarketplaceRefreshCommand({
|
||||
|
||||
@@ -53,13 +53,7 @@ function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number {
|
||||
}
|
||||
|
||||
function formatRegistryState(state: "missing" | "fresh" | "stale"): string {
|
||||
if (state === "fresh") {
|
||||
return theme.success(state);
|
||||
}
|
||||
if (state === "stale") {
|
||||
return theme.warn(state);
|
||||
}
|
||||
return theme.warn(state);
|
||||
return state === "fresh" ? theme.success(state) : theme.warn(state);
|
||||
}
|
||||
|
||||
function reportMissingPlugin(id: string) {
|
||||
@@ -67,10 +61,6 @@ function reportMissingPlugin(id: string) {
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
function matchesPluginId(plugin: { id: string }, id: string) {
|
||||
return plugin.id === id;
|
||||
}
|
||||
|
||||
function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: string }): boolean {
|
||||
return (
|
||||
entry.level === "warn" &&
|
||||
@@ -207,7 +197,7 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise<void> {
|
||||
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||
const report = buildPluginRegistrySnapshotReport({ config: cfg });
|
||||
id = normalizePluginId(id);
|
||||
if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) {
|
||||
if (!report.plugins.some((plugin) => plugin.id === id)) {
|
||||
return reportMissingPlugin(id);
|
||||
}
|
||||
const enableResult = enableExplicitlySelectedPluginInConfig(cfg, id, {
|
||||
@@ -272,7 +262,7 @@ async function runPluginsDisableCommandUnlocked(idInput: string): Promise<void>
|
||||
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||
const report = buildPluginRegistrySnapshotReport({ config: cfg });
|
||||
id = normalizePluginId(id);
|
||||
if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) {
|
||||
if (!report.plugins.some((plugin) => plugin.id === id)) {
|
||||
return reportMissingPlugin(id);
|
||||
}
|
||||
const next = setPluginEnabledInConfig(cfg, id, false, {
|
||||
@@ -540,25 +530,15 @@ function classifyMarketplaceFeedFallback(error: string | undefined): string | un
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
if (text.includes("offline mode")) {
|
||||
return "offline";
|
||||
}
|
||||
if (text.includes("checksum mismatch")) {
|
||||
return "checksum_mismatch";
|
||||
}
|
||||
if (text.includes("schema")) {
|
||||
return "schema";
|
||||
}
|
||||
if (/http\s+304/u.test(text)) {
|
||||
return "not_modified";
|
||||
}
|
||||
if (/http\s+\d{3}/u.test(text)) {
|
||||
return "http_error";
|
||||
}
|
||||
if (text.includes("timed out") || text.includes("timeout")) {
|
||||
return "timeout";
|
||||
}
|
||||
return "error";
|
||||
const categories = [
|
||||
[/offline mode/u, "offline"],
|
||||
[/checksum mismatch/u, "checksum_mismatch"],
|
||||
[/schema/u, "schema"],
|
||||
[/http\s+304/u, "not_modified"],
|
||||
[/http\s+\d{3}/u, "http_error"],
|
||||
[/timed out|timeout/u, "timeout"],
|
||||
] as const;
|
||||
return categories.find(([pattern]) => pattern.test(text))?.[1] ?? "error";
|
||||
}
|
||||
|
||||
function emitMarketplaceFeedTelemetry(params: {
|
||||
@@ -678,10 +658,6 @@ function redactMarketplaceFeedUrl(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function replaceAllLiteral(value: string, search: string, replacement: string): string {
|
||||
return search ? value.split(search).join(replacement) : value;
|
||||
}
|
||||
|
||||
function redactMarketplaceOutputText(
|
||||
value: string,
|
||||
rawUrls: readonly (string | undefined)[],
|
||||
@@ -691,7 +667,7 @@ function redactMarketplaceOutputText(
|
||||
if (!rawUrl) {
|
||||
continue;
|
||||
}
|
||||
redacted = replaceAllLiteral(redacted, rawUrl, redactMarketplaceFeedUrl(rawUrl));
|
||||
redacted = redacted.replaceAll(rawUrl, () => redactMarketplaceFeedUrl(rawUrl));
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
@@ -742,6 +718,37 @@ function formatMarketplaceFeedTrust(trust: MarketplaceFeedTrustPayload): string
|
||||
return `${trust.mode} by ${trust.signedBy} (${trust.signatureCount}/${trust.threshold}) verified ${trust.verifiedAt}`;
|
||||
}
|
||||
|
||||
function formatMarketplaceFeedLines(
|
||||
payload: MarketplaceRefreshPayload,
|
||||
options: { includeChecksum?: boolean } = {},
|
||||
): string[] {
|
||||
const lines = [
|
||||
`${theme.muted("Source:")} ${formatMarketplaceRefreshSource(payload.source)}`,
|
||||
`${theme.muted("Entries:")} ${payload.entries}`,
|
||||
];
|
||||
if (payload.feed) {
|
||||
lines.push(
|
||||
`${theme.muted("Feed:")} ${payload.feed.id} ${theme.muted(`sequence ${payload.feed.sequence}`)}`,
|
||||
);
|
||||
}
|
||||
if (payload.metadata?.url) {
|
||||
lines.push(`${theme.muted("URL:")} ${payload.metadata.url}`);
|
||||
}
|
||||
if (options.includeChecksum && payload.metadata?.checksum) {
|
||||
lines.push(`${theme.muted("SHA-256:")} ${payload.metadata.checksum}`);
|
||||
}
|
||||
if (payload.snapshot?.savedAt) {
|
||||
lines.push(`${theme.muted("Snapshot:")} ${payload.snapshot.savedAt}`);
|
||||
}
|
||||
if (payload.trust) {
|
||||
lines.push(`${theme.muted("Trust:")} ${formatMarketplaceFeedTrust(payload.trust)}`);
|
||||
}
|
||||
if (payload.error) {
|
||||
lines.push(`${theme.muted("Fallback reason:")} ${payload.error}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function shouldFailPinnedMarketplaceRefresh(params: {
|
||||
expectedSha256?: string;
|
||||
source: MarketplaceRefreshPayload["source"];
|
||||
@@ -818,31 +825,7 @@ export async function runPluginMarketplaceEntriesCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
theme.muted("Source:") + " " + formatMarketplaceRefreshSource(summary.source),
|
||||
theme.muted("Entries:") + " " + String(entries.length),
|
||||
];
|
||||
if (summary.feed) {
|
||||
lines.push(
|
||||
theme.muted("Feed:") +
|
||||
" " +
|
||||
summary.feed.id +
|
||||
" " +
|
||||
theme.muted("sequence " + String(summary.feed.sequence)),
|
||||
);
|
||||
}
|
||||
if (summary.metadata?.url) {
|
||||
lines.push(theme.muted("URL:") + " " + summary.metadata.url);
|
||||
}
|
||||
if (summary.snapshot?.savedAt) {
|
||||
lines.push(theme.muted("Snapshot:") + " " + summary.snapshot.savedAt);
|
||||
}
|
||||
if (summary.trust) {
|
||||
lines.push(theme.muted("Trust:") + " " + formatMarketplaceFeedTrust(summary.trust));
|
||||
}
|
||||
if (summary.error) {
|
||||
lines.push(theme.muted("Fallback reason:") + " " + summary.error);
|
||||
}
|
||||
const lines = formatMarketplaceFeedLines(summary);
|
||||
if (entries.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(...entries.map(formatMarketplaceEntryLine));
|
||||
@@ -892,30 +875,7 @@ export async function runPluginMarketplaceRefreshCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${theme.muted("Source:")} ${formatMarketplaceRefreshSource(payload.source)}`,
|
||||
`${theme.muted("Entries:")} ${payload.entries}`,
|
||||
];
|
||||
if (payload.feed) {
|
||||
lines.push(
|
||||
`${theme.muted("Feed:")} ${payload.feed.id} ${theme.muted(`sequence ${payload.feed.sequence}`)}`,
|
||||
);
|
||||
}
|
||||
if (payload.metadata?.url) {
|
||||
lines.push(`${theme.muted("URL:")} ${payload.metadata.url}`);
|
||||
}
|
||||
if (payload.metadata?.checksum) {
|
||||
lines.push(`${theme.muted("SHA-256:")} ${payload.metadata.checksum}`);
|
||||
}
|
||||
if (payload.snapshot?.savedAt) {
|
||||
lines.push(`${theme.muted("Snapshot:")} ${payload.snapshot.savedAt}`);
|
||||
}
|
||||
if (payload.trust) {
|
||||
lines.push(`${theme.muted("Trust:")} ${formatMarketplaceFeedTrust(payload.trust)}`);
|
||||
}
|
||||
if (payload.error) {
|
||||
lines.push(`${theme.muted("Fallback reason:")} ${payload.error}`);
|
||||
}
|
||||
const lines = formatMarketplaceFeedLines(payload, { includeChecksum: true });
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
if (failedPinnedRefresh) {
|
||||
defaultRuntime.error(formatPinnedMarketplaceRefreshFailure(payload));
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js";
|
||||
import { resolveRegistryUpdateChannel } from "../infra/update-channels.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js";
|
||||
import { VERSION } from "../version.js";
|
||||
@@ -182,6 +183,45 @@ function primeBravePluginRecordUpdate(config: OpenClawConfig) {
|
||||
return { previousRecords, nextRecords };
|
||||
}
|
||||
|
||||
async function expectSkippedClawHubPluginUpdate(params: {
|
||||
code: ClawHubTrustErrorCode;
|
||||
message: string;
|
||||
expectedLog: string;
|
||||
spec?: string;
|
||||
}): Promise<void> {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: params.spec ?? "clawhub:@openclaw/plugin-demo",
|
||||
clawhubPackage: "@openclaw/plugin-demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
code: params.code,
|
||||
message: params.message,
|
||||
},
|
||||
],
|
||||
changed: false,
|
||||
config,
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({ outcomes: [], changed: false, config });
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.at(-1)).toContain(params.expectedLog);
|
||||
}
|
||||
|
||||
describe("plugins cli update", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginsCliTestState();
|
||||
@@ -1363,120 +1403,31 @@ describe("plugins cli update", () => {
|
||||
});
|
||||
|
||||
it("exits non-zero when a ClawHub update is skipped for missing risk acknowledgement", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
|
||||
clawhubPackage: "@openclaw/plugin-demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
|
||||
message:
|
||||
"Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.",
|
||||
},
|
||||
],
|
||||
changed: false,
|
||||
config: cfg,
|
||||
await expectSkippedClawHubPluginUpdate({
|
||||
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
|
||||
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
|
||||
message:
|
||||
"Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.",
|
||||
expectedLog: "--acknowledge-clawhub-risk",
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.at(-1)).toContain("--acknowledge-clawhub-risk");
|
||||
});
|
||||
|
||||
it("exits non-zero when a ClawHub update is skipped because the target release is blocked", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/plugin-demo",
|
||||
clawhubPackage: "@openclaw/plugin-demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
code: "clawhub_download_blocked",
|
||||
message:
|
||||
"Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.",
|
||||
},
|
||||
],
|
||||
changed: false,
|
||||
config: cfg,
|
||||
await expectSkippedClawHubPluginUpdate({
|
||||
code: "clawhub_download_blocked",
|
||||
message:
|
||||
"Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.",
|
||||
expectedLog: "ClawHub blocked this release",
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.at(-1)).toContain("ClawHub blocked this release");
|
||||
});
|
||||
|
||||
it("exits non-zero when a ClawHub update is skipped because security data is unavailable", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/plugin-demo",
|
||||
clawhubPackage: "@openclaw/plugin-demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
code: "clawhub_security_unavailable",
|
||||
message:
|
||||
'Skipped demo ClawHub update: ClawHub security data for "@openclaw/plugin-demo@1.1.0" is unavailable, so OpenClaw left the existing installed plugin unchanged. Try again later or choose a different version.',
|
||||
},
|
||||
],
|
||||
changed: false,
|
||||
config: cfg,
|
||||
await expectSkippedClawHubPluginUpdate({
|
||||
code: "clawhub_security_unavailable",
|
||||
message:
|
||||
'Skipped demo ClawHub update: ClawHub security data for "@openclaw/plugin-demo@1.1.0" is unavailable, so OpenClaw left the existing installed plugin unchanged. Try again later or choose a different version.',
|
||||
expectedLog: "security data",
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.at(-1)).toContain("security data");
|
||||
});
|
||||
|
||||
it("exits non-zero when a hook pack update reports an error", async () => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
type HostedMarketplaceFeedFixtureOptions = {
|
||||
source?: "hosted" | "hosted-snapshot";
|
||||
entries?: Record<string, unknown>[];
|
||||
url?: string;
|
||||
checksum?: string;
|
||||
etag?: string;
|
||||
includeTrust?: boolean;
|
||||
};
|
||||
|
||||
export function createHostedMarketplaceFeedFixture(
|
||||
options: HostedMarketplaceFeedFixtureOptions = {},
|
||||
) {
|
||||
const source = options.source ?? "hosted";
|
||||
const metadata = {
|
||||
url: options.url ?? "https://packages.acme.example/openclaw/feed",
|
||||
status: 200,
|
||||
checksum: options.checksum ?? "feed-sha",
|
||||
...(options.etag ? { etag: options.etag } : {}),
|
||||
};
|
||||
const verifiedAt =
|
||||
source === "hosted-snapshot" ? "2026-06-23T01:02:03.000Z" : "2026-06-23T00:01:02.000Z";
|
||||
return {
|
||||
source,
|
||||
entries: options.entries ?? [],
|
||||
feed: {
|
||||
schemaVersion: 1,
|
||||
id: "acme-marketplace",
|
||||
generatedAt: "2026-06-23T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [],
|
||||
},
|
||||
metadata,
|
||||
...(source === "hosted-snapshot"
|
||||
? {
|
||||
snapshot: { body: "{}", metadata, savedAt: verifiedAt },
|
||||
error: "hosted catalog feed offline mode",
|
||||
}
|
||||
: {}),
|
||||
...(options.includeTrust !== false
|
||||
? {
|
||||
trust: {
|
||||
mode: "signed" as const,
|
||||
signedBy: "acme-root-2026",
|
||||
signatureCount: 1,
|
||||
threshold: 1,
|
||||
verifiedAt,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,19 @@ import { listRawChannelPluginCatalogEntries } from "../../../channels/plugins/ca
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import { compareOpenClawReleaseVersions } from "../../../infra/npm-registry-spec.js";
|
||||
import type { UpdateChannel } from "../../../infra/update-channels.js";
|
||||
import {
|
||||
normalizeUpdateChannel,
|
||||
resolveRegistryUpdateChannel,
|
||||
type UpdateChannel,
|
||||
} from "../../../infra/update-channels.js";
|
||||
import {
|
||||
resolveDefaultPluginExtensionsDir,
|
||||
resolvePluginInstallDir,
|
||||
} from "../../../plugins/install-paths.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
|
||||
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
|
||||
import { readLegacyNpmPluginDeclaration } from "../../../plugins/legacy-npm-declaration.js";
|
||||
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
|
||||
import type { PluginPackageInstall } from "../../../plugins/manifest.js";
|
||||
import {
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
@@ -27,6 +34,7 @@ import {
|
||||
import {
|
||||
collectConfiguredChannelIds,
|
||||
collectConfiguredPluginIds,
|
||||
collectEffectiveConfiguredChannelOwnerPluginIds,
|
||||
} from "./missing-configured-plugin-install.ids.js";
|
||||
|
||||
export type DownloadableInstallCandidate = {
|
||||
@@ -44,6 +52,92 @@ export type BundledPluginPackageDescriptor = {
|
||||
packageName?: string;
|
||||
};
|
||||
|
||||
/** Keep doctor diagnostics and actual package repair on the same discovery snapshot. */
|
||||
export async function resolveConfiguredPluginInstallContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
configuredPluginIds: ReadonlySet<string>;
|
||||
configuredChannelIds: ReadonlySet<string>;
|
||||
blockedPluginIds?: ReadonlySet<string>;
|
||||
baselineRecords?: Record<string, PluginInstallRecord>;
|
||||
}) {
|
||||
const snapshot = loadManifestMetadataSnapshot({ config: params.cfg, env: params.env });
|
||||
const currentBundledPlugins = loadInstalledPluginIndex({
|
||||
config: params.cfg,
|
||||
env: params.env,
|
||||
installRecords: {},
|
||||
}).plugins.filter((plugin) => plugin.origin === "bundled");
|
||||
const knownIds = new Set([
|
||||
...snapshot.plugins.map((plugin) => plugin.id),
|
||||
...currentBundledPlugins.map((plugin) => plugin.pluginId),
|
||||
]);
|
||||
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
snapshot,
|
||||
configuredChannelIds: params.configuredChannelIds,
|
||||
});
|
||||
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
|
||||
...snapshot.plugins
|
||||
.filter((plugin) => plugin.origin === "bundled")
|
||||
.map((plugin) => [plugin.id, plugin] as const),
|
||||
...currentBundledPlugins.map(
|
||||
(plugin) => [plugin.pluginId, { packageName: plugin.packageName }] as const,
|
||||
),
|
||||
]);
|
||||
const configuredPluginIdsWithStaleDescriptors =
|
||||
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
|
||||
snapshot,
|
||||
configuredPluginIds: params.configuredPluginIds,
|
||||
configuredChannelIds: params.configuredChannelIds,
|
||||
});
|
||||
const records =
|
||||
params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env: params.env }));
|
||||
const updateChannel = resolveRegistryUpdateChannel({
|
||||
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
|
||||
currentVersion: VERSION,
|
||||
});
|
||||
const installedPluginIdsWithRepairablePackageDiagnostics =
|
||||
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
});
|
||||
const installedPluginIdsWithStaleVersionBoundRuntimePackages =
|
||||
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
configuredPluginIds: params.configuredPluginIds,
|
||||
updateChannel,
|
||||
});
|
||||
const installedPluginIdsWithRepairablePackages = new Set([
|
||||
...installedPluginIdsWithRepairablePackageDiagnostics,
|
||||
...installedPluginIdsWithStaleVersionBoundRuntimePackages,
|
||||
]);
|
||||
const officialReplacementPluginIds = new Set(
|
||||
collectOfficialReplacementInstallCandidates({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
repairablePluginIds: installedPluginIdsWithRepairablePackages,
|
||||
configuredPluginIds: params.configuredPluginIds,
|
||||
configuredChannelIds: params.configuredChannelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds: params.blockedPluginIds,
|
||||
}).keys(),
|
||||
);
|
||||
return {
|
||||
knownIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
bundledPluginsById,
|
||||
configuredPluginIdsWithStaleDescriptors,
|
||||
records,
|
||||
updateChannel,
|
||||
installedPluginIdsWithRepairablePackageDiagnostics,
|
||||
installedPluginIdsWithStaleVersionBoundRuntimePackages,
|
||||
installedPluginIdsWithRepairablePackages,
|
||||
officialReplacementPluginIds,
|
||||
};
|
||||
}
|
||||
|
||||
const MISSING_CHANNEL_CONFIG_DESCRIPTOR_DIAGNOSTIC = "without channelConfigs metadata";
|
||||
const REPAIRABLE_PACKAGE_ENTRY_DIAGNOSTIC_MARKERS = [
|
||||
"extension entry escapes package directory",
|
||||
@@ -62,6 +156,33 @@ function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | un
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setDownloadableInstallCandidate(params: {
|
||||
candidates: Map<string, DownloadableInstallCandidate>;
|
||||
pluginId: string;
|
||||
label: string;
|
||||
install: PluginPackageInstall;
|
||||
trustedSourceLinkedOfficialInstall?: boolean;
|
||||
}): void {
|
||||
const npmSpec = params.install.npmSpec?.trim();
|
||||
const clawhubSpec = resolveCandidateClawHubSpec(params.install);
|
||||
if (!npmSpec && !clawhubSpec) {
|
||||
return;
|
||||
}
|
||||
params.candidates.set(params.pluginId, {
|
||||
pluginId: params.pluginId,
|
||||
label: params.label,
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(params.install.expectedIntegrity
|
||||
? { expectedIntegrity: params.install.expectedIntegrity }
|
||||
: {}),
|
||||
...(params.trustedSourceLinkedOfficialInstall
|
||||
? { trustedSourceLinkedOfficialInstall: true }
|
||||
: {}),
|
||||
...(params.install.defaultChoice ? { defaultChoice: params.install.defaultChoice } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function collectDownloadableInstallCandidates(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -110,23 +231,12 @@ export function collectDownloadableInstallCandidates(params: {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const npmSpec = entry.install.npmSpec?.trim();
|
||||
const clawhubSpec = resolveCandidateClawHubSpec(entry.install);
|
||||
if (!npmSpec && !clawhubSpec) {
|
||||
continue;
|
||||
}
|
||||
candidates.set(pluginId, {
|
||||
setDownloadableInstallCandidate({
|
||||
candidates,
|
||||
pluginId,
|
||||
label: entry.meta.label,
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(entry.install.expectedIntegrity
|
||||
? { expectedIntegrity: entry.install.expectedIntegrity }
|
||||
: {}),
|
||||
...(entry.trustedSourceLinkedOfficialInstall
|
||||
? { trustedSourceLinkedOfficialInstall: true }
|
||||
: {}),
|
||||
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
|
||||
install: entry.install,
|
||||
trustedSourceLinkedOfficialInstall: entry.trustedSourceLinkedOfficialInstall,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,21 +251,12 @@ export function collectDownloadableInstallCandidates(params: {
|
||||
if (params.blockedPluginIds?.has(entry.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const npmSpec = entry.install.npmSpec?.trim();
|
||||
const clawhubSpec = resolveCandidateClawHubSpec(entry.install);
|
||||
if (!npmSpec && !clawhubSpec) {
|
||||
continue;
|
||||
}
|
||||
candidates.set(entry.pluginId, {
|
||||
setDownloadableInstallCandidate({
|
||||
candidates,
|
||||
pluginId: entry.pluginId,
|
||||
label: entry.label,
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(entry.install.expectedIntegrity
|
||||
? { expectedIntegrity: entry.install.expectedIntegrity }
|
||||
: {}),
|
||||
...(entry.origin === "bundled" ? { trustedSourceLinkedOfficialInstall: true } : {}),
|
||||
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
|
||||
install: entry.install,
|
||||
trustedSourceLinkedOfficialInstall: entry.origin === "bundled",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,19 +272,12 @@ export function collectDownloadableInstallCandidates(params: {
|
||||
if (!install) {
|
||||
continue;
|
||||
}
|
||||
const npmSpec = install.npmSpec?.trim();
|
||||
const clawhubSpec = resolveCandidateClawHubSpec(install);
|
||||
if (!npmSpec && !clawhubSpec) {
|
||||
continue;
|
||||
}
|
||||
candidates.set(pluginId, {
|
||||
setDownloadableInstallCandidate({
|
||||
candidates,
|
||||
pluginId,
|
||||
label: resolveOfficialExternalPluginLabel(entry),
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
||||
install,
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +408,7 @@ export function collectUpdateDeferredPluginIds(params: {
|
||||
return pluginIds;
|
||||
}
|
||||
|
||||
export function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: {
|
||||
function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: {
|
||||
snapshot: PluginMetadataSnapshot;
|
||||
configuredPluginIds: ReadonlySet<string>;
|
||||
configuredChannelIds: ReadonlySet<string>;
|
||||
@@ -337,7 +431,7 @@ export function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(pa
|
||||
return stalePluginIds;
|
||||
}
|
||||
|
||||
export function collectInstalledPluginIdsWithRepairablePackageDiagnostics(params: {
|
||||
function collectInstalledPluginIdsWithRepairablePackageDiagnostics(params: {
|
||||
snapshot: PluginMetadataSnapshot;
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
}): Set<string> {
|
||||
@@ -404,7 +498,7 @@ function betaCompanionMatchesCurrentStableVersion(params: {
|
||||
return Boolean(installedBase && currentBase && installedBase === currentBase);
|
||||
}
|
||||
|
||||
export function collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages(params: {
|
||||
function collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages(params: {
|
||||
snapshot: PluginMetadataSnapshot;
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
configuredPluginIds: ReadonlySet<string>;
|
||||
@@ -464,7 +558,7 @@ function isConfiguredPluginRepairTarget(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectOfficialReplacementInstallCandidates(params: {
|
||||
function collectOfficialReplacementInstallCandidates(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
repairablePluginIds: ReadonlySet<string>;
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../../../flows/health-checks.js";
|
||||
import { resolveCompatibilityHostVersion } from "../../../version.js";
|
||||
import {
|
||||
normalizeUpdateChannel,
|
||||
resolveRegistryUpdateChannel,
|
||||
} from "../../../infra/update-channels.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
|
||||
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
|
||||
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
|
||||
import { resolveCompatibilityHostVersion, VERSION } from "../../../version.js";
|
||||
import {
|
||||
type BundledPluginPackageDescriptor,
|
||||
collectDownloadableInstallCandidates,
|
||||
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors,
|
||||
collectInstalledPluginIdsWithRepairablePackageDiagnostics,
|
||||
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages,
|
||||
collectOfficialReplacementInstallCandidates,
|
||||
collectUpdateDeferredPluginIds,
|
||||
resolveConfiguredPluginInstallContext,
|
||||
} from "./missing-configured-plugin-install.candidates.js";
|
||||
import {
|
||||
collectBlockedPluginIds,
|
||||
collectConfiguredChannelIds,
|
||||
collectConfiguredPluginIds,
|
||||
collectEffectiveConfiguredChannelOwnerPluginIds,
|
||||
} from "./missing-configured-plugin-install.ids.js";
|
||||
import {
|
||||
resolveCandidateInstallSpec,
|
||||
@@ -103,75 +91,25 @@ export async function detectConfiguredPluginInstallHealthIssues(params: {
|
||||
const pluginIds = collectConfiguredPluginIds(params.cfg, env);
|
||||
const channelIds = collectConfiguredChannelIds(params.cfg, env);
|
||||
const blockedPluginIds = collectBlockedPluginIds(params.cfg);
|
||||
const snapshot = loadManifestMetadataSnapshot({
|
||||
config: params.cfg,
|
||||
env,
|
||||
});
|
||||
const currentBundledPlugins = loadInstalledPluginIndex({
|
||||
config: params.cfg,
|
||||
env,
|
||||
installRecords: {},
|
||||
}).plugins.filter((plugin) => plugin.origin === "bundled");
|
||||
const knownIds = new Set([
|
||||
...snapshot.plugins.map((plugin) => plugin.id),
|
||||
...currentBundledPlugins.map((plugin) => plugin.pluginId),
|
||||
]);
|
||||
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
snapshot,
|
||||
configuredChannelIds: channelIds,
|
||||
});
|
||||
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
|
||||
...snapshot.plugins
|
||||
.filter((plugin) => plugin.origin === "bundled")
|
||||
.map((plugin) => [plugin.id, plugin] as const),
|
||||
...currentBundledPlugins.map(
|
||||
(plugin) =>
|
||||
[
|
||||
plugin.pluginId,
|
||||
{
|
||||
packageName: plugin.packageName,
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
]);
|
||||
const staleDescriptorPluginIds = collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
|
||||
snapshot,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
});
|
||||
const records = params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env }));
|
||||
const updateChannel = resolveRegistryUpdateChannel({
|
||||
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
|
||||
currentVersion: VERSION,
|
||||
});
|
||||
const repairablePackageDiagnosticPluginIds =
|
||||
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
});
|
||||
const staleVersionBoundRuntimePluginIds =
|
||||
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
configuredPluginIds: pluginIds,
|
||||
updateChannel,
|
||||
});
|
||||
const repairableInstalledPluginIds = new Set([
|
||||
...repairablePackageDiagnosticPluginIds,
|
||||
...staleVersionBoundRuntimePluginIds,
|
||||
]);
|
||||
const officialReplacementInstallCandidates = collectOfficialReplacementInstallCandidates({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
repairablePluginIds: repairableInstalledPluginIds,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
const {
|
||||
knownIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
bundledPluginsById,
|
||||
configuredPluginIdsWithStaleDescriptors: staleDescriptorPluginIds,
|
||||
records,
|
||||
updateChannel,
|
||||
installedPluginIdsWithRepairablePackageDiagnostics: repairablePackageDiagnosticPluginIds,
|
||||
installedPluginIdsWithStaleVersionBoundRuntimePackages: staleVersionBoundRuntimePluginIds,
|
||||
installedPluginIdsWithRepairablePackages: repairableInstalledPluginIds,
|
||||
officialReplacementPluginIds,
|
||||
} = await resolveConfiguredPluginInstallContext({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
blockedPluginIds,
|
||||
baselineRecords: params.baselineRecords,
|
||||
});
|
||||
const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys());
|
||||
const deferredPluginIds = new Set<string>();
|
||||
const reportedPluginIds = new Set<string>();
|
||||
const issues: ConfiguredPluginInstallHealthIssue[] = [];
|
||||
@@ -336,114 +274,83 @@ export async function detectConfiguredPluginInstallHealthIssues(params: {
|
||||
return issues.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||
}
|
||||
|
||||
const CONFIGURED_PLUGIN_INSTALL_ISSUE_DETAILS = {
|
||||
"missing-install-record": {
|
||||
message: (pluginId: string) => `Configured plugin ${pluginId} is not installed.`,
|
||||
fixHint: "",
|
||||
action: "would-install-configured-plugin",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
"missing-installed-payload": {
|
||||
message: (pluginId: string) =>
|
||||
`Configured plugin ${pluginId} has an install record but its package payload is missing.`,
|
||||
fixHint: "Run `openclaw doctor --fix` to reinstall the configured plugin package.",
|
||||
action: "would-reinstall-configured-plugin",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
"repairable-installed-plugin": {
|
||||
message: (pluginId: string) =>
|
||||
`Configured plugin ${pluginId} has a repairable package install problem.`,
|
||||
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin package.",
|
||||
action: "would-repair-configured-plugin-install",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
"stale-version-bound-runtime": {
|
||||
message: (pluginId: string) =>
|
||||
`Configured runtime plugin ${pluginId} is older than this OpenClaw version.`,
|
||||
fixHint: "Run `openclaw doctor --fix` to refresh the configured runtime plugin.",
|
||||
action: "would-refresh-configured-runtime-plugin",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
"stale-channel-config-descriptor": {
|
||||
message: (pluginId: string) =>
|
||||
`Configured plugin ${pluginId} has stale channel config metadata.`,
|
||||
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin install metadata.",
|
||||
action: "would-repair-configured-plugin-install",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
"deferred-package-manager-repair": {
|
||||
message: (pluginId: string) =>
|
||||
`Configured plugin ${pluginId} package repair is deferred until the package update finishes.`,
|
||||
fixHint: "Rerun `openclaw doctor --fix` after the package update completes.",
|
||||
action: "would-defer-configured-plugin-install-repair",
|
||||
dryRunSafe: true,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
ConfiguredPluginInstallHealthIssue["kind"],
|
||||
{
|
||||
message: (pluginId: string) => string;
|
||||
fixHint: string;
|
||||
action: string;
|
||||
dryRunSafe: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export function configuredPluginInstallIssueToHealthFinding(
|
||||
issue: ConfiguredPluginInstallHealthIssue,
|
||||
): HealthFinding {
|
||||
const target = issue.pluginId;
|
||||
switch (issue.kind) {
|
||||
case "missing-install-record":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} is not installed.`,
|
||||
target,
|
||||
fixHint: `Run \`openclaw doctor --fix\` to install ${issue.installSpec}.`,
|
||||
};
|
||||
case "missing-installed-payload":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} has an install record but its package payload is missing.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to reinstall the configured plugin package.",
|
||||
};
|
||||
case "repairable-installed-plugin":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} has a repairable package install problem.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin package.",
|
||||
};
|
||||
case "stale-version-bound-runtime":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured runtime plugin ${issue.pluginId} is older than this OpenClaw version.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to refresh the configured runtime plugin.",
|
||||
};
|
||||
case "stale-channel-config-descriptor":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} has stale channel config metadata.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin install metadata.",
|
||||
};
|
||||
case "deferred-package-manager-repair":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} package repair is deferred until the package update finishes.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Rerun `openclaw doctor --fix` after the package update completes.",
|
||||
};
|
||||
}
|
||||
return assertNeverConfiguredPluginInstallIssue(issue);
|
||||
const detail = CONFIGURED_PLUGIN_INSTALL_ISSUE_DETAILS[issue.kind];
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: detail.message(issue.pluginId),
|
||||
target: issue.pluginId,
|
||||
...("installPath" in issue && issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint:
|
||||
issue.kind === "missing-install-record"
|
||||
? `Run \`openclaw doctor --fix\` to install ${issue.installSpec}.`
|
||||
: detail.fixHint,
|
||||
};
|
||||
}
|
||||
|
||||
export function configuredPluginInstallIssueToRepairEffect(
|
||||
issue: ConfiguredPluginInstallHealthIssue,
|
||||
): HealthRepairEffect {
|
||||
switch (issue.kind) {
|
||||
case "missing-install-record":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-install-configured-plugin",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "missing-installed-payload":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-reinstall-configured-plugin",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "repairable-installed-plugin":
|
||||
case "stale-channel-config-descriptor":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-repair-configured-plugin-install",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "stale-version-bound-runtime":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-refresh-configured-runtime-plugin",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "deferred-package-manager-repair":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-defer-configured-plugin-install-repair",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: true,
|
||||
};
|
||||
}
|
||||
return assertNeverConfiguredPluginInstallIssue(issue);
|
||||
}
|
||||
|
||||
function assertNeverConfiguredPluginInstallIssue(issue: never): never {
|
||||
throw new Error(
|
||||
`Unhandled configured plugin install issue kind: ${String((issue as { kind?: unknown }).kind)}`,
|
||||
);
|
||||
const detail = CONFIGURED_PLUGIN_INSTALL_ISSUE_DETAILS[issue.kind];
|
||||
return {
|
||||
kind: "package",
|
||||
action: detail.action,
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: detail.dryRunSafe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,34 +2,20 @@ import { rm } from "node:fs/promises";
|
||||
import { stripAnsi } from "../../../../packages/terminal-core/src/ansi.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import {
|
||||
normalizeUpdateChannel,
|
||||
resolveRegistryUpdateChannel,
|
||||
} from "../../../infra/update-channels.js";
|
||||
import type { ClawHubRiskAcknowledgementRequest } from "../../../plugins/clawhub.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../../../plugins/installed-plugin-index-records.js";
|
||||
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
|
||||
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
|
||||
import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
|
||||
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { resolveCompatibilityHostVersion, VERSION } from "../../../version.js";
|
||||
import { resolveCompatibilityHostVersion } from "../../../version.js";
|
||||
import {
|
||||
type BundledPluginPackageDescriptor,
|
||||
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors,
|
||||
collectDownloadableInstallCandidates,
|
||||
collectInstalledPluginIdsWithRepairablePackageDiagnostics,
|
||||
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages,
|
||||
collectOfficialReplacementInstallCandidates,
|
||||
collectUpdateDeferredPluginIds,
|
||||
resolveConfiguredPluginInstallContext,
|
||||
} from "./missing-configured-plugin-install.candidates.js";
|
||||
import {
|
||||
collectBlockedPluginIds,
|
||||
collectConfiguredChannelIds,
|
||||
collectConfiguredPluginIds,
|
||||
collectEffectiveConfiguredChannelOwnerPluginIds,
|
||||
} from "./missing-configured-plugin-install.ids.js";
|
||||
import {
|
||||
appendClawHubRiskAcknowledgementGuidance,
|
||||
@@ -147,76 +133,25 @@ async function repairMissingPluginInstalls(params: {
|
||||
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
|
||||
}): Promise<RepairMissingPluginInstallsResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const snapshot = loadManifestMetadataSnapshot({
|
||||
config: params.cfg,
|
||||
env,
|
||||
});
|
||||
const currentBundledPlugins = loadInstalledPluginIndex({
|
||||
config: params.cfg,
|
||||
env,
|
||||
installRecords: {},
|
||||
}).plugins.filter((plugin) => plugin.origin === "bundled");
|
||||
const knownIds = new Set([
|
||||
...snapshot.plugins.map((plugin) => plugin.id),
|
||||
...currentBundledPlugins.map((plugin) => plugin.pluginId),
|
||||
]);
|
||||
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
|
||||
const {
|
||||
knownIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
bundledPluginsById,
|
||||
configuredPluginIdsWithStaleDescriptors,
|
||||
records,
|
||||
updateChannel,
|
||||
installedPluginIdsWithRepairablePackageDiagnostics,
|
||||
installedPluginIdsWithStaleVersionBoundRuntimePackages,
|
||||
installedPluginIdsWithRepairablePackages,
|
||||
officialReplacementPluginIds,
|
||||
} = await resolveConfiguredPluginInstallContext({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
snapshot,
|
||||
configuredChannelIds: params.channelIds,
|
||||
});
|
||||
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
|
||||
...snapshot.plugins
|
||||
.filter((plugin) => plugin.origin === "bundled")
|
||||
.map((plugin) => [plugin.id, plugin] as const),
|
||||
...currentBundledPlugins.map(
|
||||
(plugin) =>
|
||||
[
|
||||
plugin.pluginId,
|
||||
{
|
||||
packageName: plugin.packageName,
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
]);
|
||||
const configuredPluginIdsWithStaleDescriptors =
|
||||
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
|
||||
snapshot,
|
||||
configuredPluginIds: params.pluginIds,
|
||||
configuredChannelIds: params.channelIds,
|
||||
});
|
||||
const records = params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env }));
|
||||
const updateChannel = resolveRegistryUpdateChannel({
|
||||
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
|
||||
currentVersion: VERSION,
|
||||
});
|
||||
const installedPluginIdsWithRepairablePackageDiagnostics =
|
||||
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
});
|
||||
const installedPluginIdsWithStaleVersionBoundRuntimePackages =
|
||||
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
configuredPluginIds: params.pluginIds,
|
||||
updateChannel,
|
||||
});
|
||||
const installedPluginIdsWithRepairablePackages = new Set([
|
||||
...installedPluginIdsWithRepairablePackageDiagnostics,
|
||||
...installedPluginIdsWithStaleVersionBoundRuntimePackages,
|
||||
]);
|
||||
const officialReplacementInstallCandidates = collectOfficialReplacementInstallCandidates({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
repairablePluginIds: installedPluginIdsWithRepairablePackages,
|
||||
configuredPluginIds: params.pluginIds,
|
||||
configuredChannelIds: params.channelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds: params.blockedPluginIds,
|
||||
baselineRecords: params.baselineRecords,
|
||||
});
|
||||
const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys());
|
||||
const changes: string[] = [];
|
||||
const notices: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/types.js";
|
||||
import type { OpenClawConfig, PluginsConfig } from "../../../config/types.js";
|
||||
import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js";
|
||||
import {
|
||||
@@ -1028,52 +1028,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("installs missing configured non-channel plugins from the official external catalog", async () => {
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
pluginId: "diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
version: "2026.5.2",
|
||||
resolution: {
|
||||
integrity: "sha512-otel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "diagnostics-otel",
|
||||
label: "Diagnostics OpenTelemetry",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"diagnostics-otel": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
|
||||
spec: expectedNpmInstallSpec("@openclaw/diagnostics-otel"),
|
||||
expectedPluginId: "diagnostics-otel",
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
`Installed missing configured plugin "diagnostics-otel" from ${expectedNpmInstallSpec("@openclaw/diagnostics-otel")}.`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs official plugins at the exact extended-stable core version", async () => {
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
@@ -1117,103 +1071,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("installs the official llama.cpp plugin for configured local memory embeddings", async () => {
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
pluginId: "llama-cpp",
|
||||
npmSpec: "@openclaw/llama-cpp-provider",
|
||||
version: "2026.6.2",
|
||||
resolution: {
|
||||
resolvedAt: "2026-06-08T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "llama-cpp",
|
||||
label: "llama.cpp Provider",
|
||||
openclaw: {
|
||||
plugin: { id: "llama-cpp", label: "llama.cpp Provider" },
|
||||
contracts: { embeddingProviders: ["local"] },
|
||||
install: {
|
||||
npmSpec: "@openclaw/llama-cpp-provider",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/llama-cpp-provider",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
memory: {
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
defaults: {},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
|
||||
spec: expectedNpmInstallSpec("@openclaw/llama-cpp-provider"),
|
||||
expectedPluginId: "llama-cpp",
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
`Installed missing configured plugin "llama-cpp" from ${expectedNpmInstallSpec("@openclaw/llama-cpp-provider")}.`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let runtime fallback metadata override official catalog install specs", async () => {
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
pluginId: "acpx",
|
||||
npmSpec: "@openclaw/acpx",
|
||||
version: "2026.5.2-beta.2",
|
||||
}),
|
||||
);
|
||||
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "acpx",
|
||||
label: "ACPX Runtime",
|
||||
install: {
|
||||
npmSpec: "@openclaw/acpx",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
|
||||
spec: expectedNpmInstallSpec("@openclaw/acpx"),
|
||||
expectedPluginId: "acpx",
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
`Installed missing configured plugin "acpx" from ${expectedNpmInstallSpec("@openclaw/acpx")}.`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not install disabled configured plugin entries", async () => {
|
||||
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
@@ -1251,6 +1108,13 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
"disabled configured channel",
|
||||
{ channels: { matrix: { enabled: false, homeserver: "https://matrix.example.org" } } },
|
||||
],
|
||||
[
|
||||
"matching disabled plugin entry",
|
||||
{
|
||||
plugins: { entries: { matrix: { enabled: false } } },
|
||||
channels: { matrix: { homeserver: "https://matrix.example.org" } },
|
||||
},
|
||||
],
|
||||
])("does not install channel plugins for a %s", async (_label, cfg) => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
@@ -1276,40 +1140,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(result).toEqual({ changes: [], warnings: [], records: {} });
|
||||
});
|
||||
|
||||
it("does not install channel plugins when the matching plugin entry is disabled", async () => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "matrix",
|
||||
pluginId: "matrix",
|
||||
meta: { label: "Matrix" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/plugin-matrix@1.2.3",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
matrix: { enabled: false },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
matrix: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ changes: [], warnings: [], records: {} });
|
||||
});
|
||||
|
||||
it("does not download configured channel plugins that are still bundled", async () => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
@@ -2985,7 +2815,31 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(result).toEqual({ changes: [], warnings: [], records: {} });
|
||||
});
|
||||
|
||||
it("does not install a channel catalog plugin when a configured plugin already owns that channel", async () => {
|
||||
it.each<{ name: string; plugins: PluginsConfig; installs: boolean }>([
|
||||
{
|
||||
name: "does not install a channel catalog plugin when a configured plugin already owns that channel",
|
||||
plugins: { entries: { "openclaw-lark": { enabled: true } } },
|
||||
installs: false,
|
||||
},
|
||||
{
|
||||
name: "still installs a channel catalog plugin when the configured owner is blocked by the allowlist",
|
||||
plugins: {
|
||||
allow: ["some-other-plugin"],
|
||||
entries: { "openclaw-lark": { enabled: true } },
|
||||
},
|
||||
installs: true,
|
||||
},
|
||||
{
|
||||
name: "still installs a channel catalog plugin when that plugin is explicitly configured",
|
||||
plugins: {
|
||||
entries: {
|
||||
feishu: { enabled: true },
|
||||
"openclaw-lark": { enabled: true },
|
||||
},
|
||||
},
|
||||
installs: true,
|
||||
},
|
||||
])("$name", async ({ plugins, installs }) => {
|
||||
mocks.loadPluginMetadataSnapshot.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
@@ -3014,167 +2868,35 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
if (installs) {
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
pluginId: "feishu",
|
||||
npmSpec: "@openclaw/feishu",
|
||||
version: "2026.5.2",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-lark": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins,
|
||||
channels: {
|
||||
feishu: {
|
||||
footer: {
|
||||
model: false,
|
||||
},
|
||||
},
|
||||
feishu: { footer: { model: false } },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ changes: [], warnings: [], records: {} });
|
||||
});
|
||||
|
||||
it("still installs a channel catalog plugin when the configured owner is blocked by the allowlist", async () => {
|
||||
mocks.loadPluginMetadataSnapshot.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "openclaw-lark",
|
||||
origin: "config",
|
||||
channels: ["feishu"],
|
||||
channelConfigs: {
|
||||
feishu: {
|
||||
schema: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "feishu",
|
||||
pluginId: "feishu",
|
||||
meta: { label: "Feishu" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/feishu",
|
||||
},
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
pluginId: "feishu",
|
||||
npmSpec: "@openclaw/feishu",
|
||||
version: "2026.5.2",
|
||||
}),
|
||||
);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["some-other-plugin"],
|
||||
entries: {
|
||||
"openclaw-lark": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
footer: {
|
||||
model: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
|
||||
spec: expectedNpmInstallSpec("@openclaw/feishu"),
|
||||
expectedPluginId: "feishu",
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
`Installed missing configured plugin "feishu" from ${expectedNpmInstallSpec("@openclaw/feishu")}.`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("still installs a channel catalog plugin when that plugin is explicitly configured", async () => {
|
||||
mocks.loadPluginMetadataSnapshot.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "openclaw-lark",
|
||||
origin: "config",
|
||||
channels: ["feishu"],
|
||||
channelConfigs: {
|
||||
feishu: {
|
||||
schema: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "feishu",
|
||||
pluginId: "feishu",
|
||||
meta: { label: "Feishu" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/feishu",
|
||||
},
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
|
||||
successfulInstall({
|
||||
pluginId: "feishu",
|
||||
npmSpec: "@openclaw/feishu",
|
||||
version: "2026.5.2",
|
||||
}),
|
||||
);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
"openclaw-lark": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
footer: {
|
||||
model: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
if (!installs) {
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ changes: [], warnings: [], records: {} });
|
||||
return;
|
||||
}
|
||||
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
|
||||
spec: expectedNpmInstallSpec("@openclaw/feishu"),
|
||||
expectedPluginId: "feishu",
|
||||
@@ -3423,11 +3145,23 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("adds actionable acknowledgement guidance for risky persisted ClawHub repair failures", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "adds actionable acknowledgement guidance for risky persisted ClawHub repair failures",
|
||||
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
|
||||
release: "@openclaw/plugin-demo@1.0.0",
|
||||
expectedSpec: "clawhub:@openclaw/plugin-demo@1.0.0",
|
||||
},
|
||||
{
|
||||
name: "prefixes legacy persisted ClawHub package records in acknowledgement guidance",
|
||||
release: "@openclaw/plugin-demo@latest",
|
||||
expectedSpec: "clawhub:@openclaw/plugin-demo",
|
||||
},
|
||||
])("$name", async ({ spec, release, expectedSpec }) => {
|
||||
const records = {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
|
||||
...(spec ? { spec } : {}),
|
||||
clawhubPackage: "@openclaw/plugin-demo",
|
||||
installPath: "/missing/demo",
|
||||
},
|
||||
@@ -3441,8 +3175,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
|
||||
message:
|
||||
'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.0.0" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.',
|
||||
message: `Skipped demo ClawHub update: ClawHub release "${release}" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -3461,48 +3194,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
});
|
||||
|
||||
expect(result.warnings[0]).toContain(
|
||||
"openclaw plugins install clawhub:@openclaw/plugin-demo@1.0.0 --acknowledge-clawhub-risk",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes legacy persisted ClawHub package records in acknowledgement guidance", async () => {
|
||||
const records = {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
clawhubPackage: "@openclaw/plugin-demo",
|
||||
installPath: "/missing/demo",
|
||||
},
|
||||
};
|
||||
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
|
||||
mocks.updateNpmInstalledPlugins.mockResolvedValue({
|
||||
changed: false,
|
||||
config: { plugins: { installs: records } },
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
|
||||
message:
|
||||
'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@latest" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.warnings[0]).toContain(
|
||||
"openclaw plugins install clawhub:@openclaw/plugin-demo --acknowledge-clawhub-risk",
|
||||
`openclaw plugins install ${expectedSpec} --acknowledge-clawhub-risk`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4086,6 +3778,60 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "installs missing configured non-channel plugins from the official external catalog",
|
||||
pluginId: "diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
version: "2026.5.2",
|
||||
entry: {
|
||||
id: "diagnostics-otel",
|
||||
label: "Diagnostics OpenTelemetry",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
defaultChoice: "npm" as const,
|
||||
},
|
||||
},
|
||||
cfg: { plugins: { entries: { "diagnostics-otel": { enabled: true } } } },
|
||||
useManifestResolvers: false,
|
||||
},
|
||||
{
|
||||
name: "installs the official llama.cpp plugin for configured local memory embeddings",
|
||||
pluginId: "llama-cpp",
|
||||
npmSpec: "@openclaw/llama-cpp-provider",
|
||||
version: "2026.6.2",
|
||||
entry: {
|
||||
id: "llama-cpp",
|
||||
label: "llama.cpp Provider",
|
||||
openclaw: {
|
||||
plugin: { id: "llama-cpp", label: "llama.cpp Provider" },
|
||||
contracts: { embeddingProviders: ["local"] },
|
||||
install: {
|
||||
npmSpec: "@openclaw/llama-cpp-provider",
|
||||
defaultChoice: "npm" as const,
|
||||
},
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/llama-cpp-provider",
|
||||
defaultChoice: "npm" as const,
|
||||
},
|
||||
},
|
||||
cfg: { memory: { search: { provider: "local" } }, agents: { defaults: {} } },
|
||||
useManifestResolvers: false,
|
||||
},
|
||||
{
|
||||
name: "does not let runtime fallback metadata override official catalog install specs",
|
||||
pluginId: "acpx",
|
||||
npmSpec: "@openclaw/acpx",
|
||||
version: "2026.5.2-beta.2",
|
||||
entry: {
|
||||
id: "acpx",
|
||||
label: "ACPX Runtime",
|
||||
install: { npmSpec: "@openclaw/acpx", defaultChoice: "npm" as const },
|
||||
},
|
||||
cfg: { acp: { backend: "acpx" } },
|
||||
useManifestResolvers: false,
|
||||
},
|
||||
{
|
||||
name: "installs a configured external web search plugin from provider-only config",
|
||||
pluginId: "brave",
|
||||
@@ -4174,6 +3920,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expectedPluginId: pluginId,
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
});
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(result.changes).toEqual([
|
||||
`Installed missing configured plugin "${pluginId}" from ${expectedNpmInstallSpec(npmSpec)}.`,
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Applies immutable path removals to config-like objects.
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
@@ -8,17 +9,6 @@ import type { OpenClawConfig } from "./types.js";
|
||||
const MANAGED_CONFIG_UNSET_PATHS = [["plugins", "installs"]] as const;
|
||||
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
|
||||
|
||||
function isWritePlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function coerceConfig(value: unknown): OpenClawConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
function unsetPathForWriteAt(
|
||||
value: unknown,
|
||||
pathSegments: string[],
|
||||
@@ -53,7 +43,7 @@ function unsetPathForWriteAt(
|
||||
return { changed: true, value: next };
|
||||
}
|
||||
|
||||
if (isBlockedObjectKey(segment) || !isWritePlainObject(value) || !Object.hasOwn(value, segment)) {
|
||||
if (isBlockedObjectKey(segment) || !isRecord(value) || !Object.hasOwn(value, segment)) {
|
||||
return { changed: false, value };
|
||||
}
|
||||
if (isLeaf) {
|
||||
@@ -95,8 +85,8 @@ function unsetPathForWrite(
|
||||
if (result.value === WRITE_PRUNED_OBJECT) {
|
||||
return { changed: true, next: {} };
|
||||
}
|
||||
if (isWritePlainObject(result.value)) {
|
||||
return { changed: true, next: coerceConfig(result.value) };
|
||||
if (isRecord(result.value)) {
|
||||
return { changed: true, next: result.value as OpenClawConfig };
|
||||
}
|
||||
return { changed: false, next: root };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Detects dangerous config names used by validation and warnings.
|
||||
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { asBoolean } from "../utils/boolean.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
|
||||
@@ -18,13 +19,6 @@ type DangerousNameMatchingResolverInput = {
|
||||
accountConfig?: DangerousNameMatchingConfig | null | undefined;
|
||||
};
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Returns true only for the explicit dangerous name-matching opt-in flag. */
|
||||
export function isDangerousNameMatchingEnabled(
|
||||
config: DangerousNameMatchingConfig | null | undefined,
|
||||
@@ -48,12 +42,12 @@ export function collectProviderDangerousNameMatchingScopes(
|
||||
provider: string,
|
||||
): ProviderDangerousNameMatchingScope[] {
|
||||
const scopes: ProviderDangerousNameMatchingScope[] = [];
|
||||
const channels = asObjectRecord(cfg.channels);
|
||||
const channels = asNullableRecord(cfg.channels);
|
||||
if (!channels) {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
const providerCfg = asObjectRecord(channels[provider]);
|
||||
const providerCfg = asNullableRecord(channels[provider]);
|
||||
if (!providerCfg) {
|
||||
return scopes;
|
||||
}
|
||||
@@ -69,13 +63,13 @@ export function collectProviderDangerousNameMatchingScopes(
|
||||
dangerousFlagPath: providerDangerousFlagPath,
|
||||
});
|
||||
|
||||
const accounts = asObjectRecord(providerCfg.accounts);
|
||||
const accounts = asNullableRecord(providerCfg.accounts);
|
||||
if (!accounts) {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(accounts)) {
|
||||
const account = asObjectRecord(accounts[key]);
|
||||
const account = asNullableRecord(accounts[key]);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Loads gateway dispatch config from runtime state and files.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import { applyConfigEnvVars } from "./config-env-vars.js";
|
||||
import { resolveConfigEnvVars } from "./env-substitution.js";
|
||||
@@ -29,15 +30,11 @@ type GatewayDispatchConfigReadOptions = {
|
||||
logger?: Pick<Console, "warn" | "error">;
|
||||
};
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneConfigValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => cloneConfigValue(entry));
|
||||
}
|
||||
if (!isPlainRecord(value)) {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -48,7 +45,7 @@ function cloneConfigValue(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function projectGatewayDispatchConfig(value: unknown): OpenClawConfig {
|
||||
if (!isPlainRecord(value)) {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
const projected: Record<string, unknown> = {};
|
||||
@@ -95,7 +92,7 @@ function resolveIncludesForGatewayDispatch(
|
||||
}
|
||||
|
||||
function resolveGatewayDispatchEnvVars(config: unknown, env: NodeJS.ProcessEnv): unknown {
|
||||
if (isPlainRecord(config) && Object.hasOwn(config, "env")) {
|
||||
if (isRecord(config) && Object.hasOwn(config, "env")) {
|
||||
applyConfigEnvVars(config as OpenClawConfig, env);
|
||||
}
|
||||
return resolveConfigEnvVars(config, env, { onMissing: () => undefined });
|
||||
|
||||
@@ -2,12 +2,12 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { asOptionalRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import JSON5 from "json5";
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { collectErrorGraphCandidates, extractErrorCode } from "../infra/errors.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import {
|
||||
applyConfigEnvVars,
|
||||
@@ -59,10 +59,7 @@ export function resolveConfigSnapshotHash(snapshot: {
|
||||
}
|
||||
|
||||
export function coerceConfig(value: unknown): OpenClawConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as OpenClawConfig;
|
||||
return (asOptionalRecord(value) ?? {}) as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function hasConfigMeta(value: unknown): boolean {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import {
|
||||
asOptionalObjectRecord,
|
||||
asOptionalRecord,
|
||||
isRecord,
|
||||
} from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { listAgentEntries } from "../agents/agent-scope-config.js";
|
||||
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
|
||||
@@ -30,7 +35,6 @@ import {
|
||||
collectConfiguredWorkerProviderIds,
|
||||
listBundledWorkerProviderOwners,
|
||||
} from "../plugins/worker-provider-registry.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { isChannelConfigured } from "./channel-configured.js";
|
||||
import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js";
|
||||
import type {
|
||||
@@ -342,17 +346,6 @@ function isAutoEnableConfiguredChannelSignal(params: {
|
||||
return isChannelConfigured(params.cfg, params.channelId, params.env);
|
||||
}
|
||||
|
||||
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return (
|
||||
Boolean(entries) &&
|
||||
typeof entries === "object" &&
|
||||
Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebSearchProviderSelection(cfg: OpenClawConfig): boolean {
|
||||
const provider = cfg.tools?.web?.search?.provider;
|
||||
return (
|
||||
@@ -362,27 +355,23 @@ function hasConfiguredWebSearchProviderSelection(cfg: OpenClawConfig): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return (
|
||||
Boolean(entries) &&
|
||||
typeof entries === "object" &&
|
||||
Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredSpeechProviderSelection(cfg: OpenClawConfig): boolean {
|
||||
return collectConfiguredSpeechProviderIds(cfg).size > 0;
|
||||
}
|
||||
|
||||
function hasConfiguredPluginConfigEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
function hasConfiguredPluginConfigEntry(
|
||||
cfg: OpenClawConfig,
|
||||
configKey?: "webSearch" | "webFetch",
|
||||
): boolean {
|
||||
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
|
||||
return (
|
||||
Boolean(entries) &&
|
||||
typeof entries === "object" &&
|
||||
Object.values(entries).some((entry) => isRecord(entry) && isRecord(entry.config))
|
||||
entries !== undefined &&
|
||||
Object.values(entries).some(
|
||||
(entry) =>
|
||||
isRecord(entry) &&
|
||||
isRecord(entry.config) &&
|
||||
(configKey === undefined || isRecord(entry.config[configKey])),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,8 +398,8 @@ function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
|
||||
}
|
||||
|
||||
function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] {
|
||||
const entries = cfg.plugins?.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
|
||||
if (!entries) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(entries)
|
||||
@@ -419,8 +408,8 @@ function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] {
|
||||
}
|
||||
|
||||
function hasOwnPluginEntry(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return Boolean(entries) && typeof entries === "object" && Object.hasOwn(entries, pluginId);
|
||||
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
|
||||
return entries !== undefined && Object.hasOwn(entries, pluginId);
|
||||
}
|
||||
|
||||
function isPluginEntryExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
@@ -497,8 +486,8 @@ function hasSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean {
|
||||
}
|
||||
|
||||
function hasPluginEntries(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return Boolean(entries) && typeof entries === "object" && Object.keys(entries).length > 0;
|
||||
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
|
||||
return entries !== undefined && Object.keys(entries).length > 0;
|
||||
}
|
||||
|
||||
function hasPluginAllowlistWithMaterialEntries(cfg: OpenClawConfig): boolean {
|
||||
@@ -509,8 +498,8 @@ function hasPluginAllowlistWithMaterialEntries(cfg: OpenClawConfig): boolean {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const entries = cfg.plugins?.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
|
||||
if (!entries) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(entries).some(hasMaterialPluginEntryConfig);
|
||||
@@ -605,8 +594,8 @@ export function resolvePluginAutoEnableReadiness(
|
||||
}
|
||||
if (
|
||||
hasConfiguredWebSearchProviderSelection(cfg) ||
|
||||
hasConfiguredWebSearchPluginEntry(cfg) ||
|
||||
hasConfiguredWebFetchPluginEntry(cfg)
|
||||
hasConfiguredPluginConfigEntry(cfg, "webSearch") ||
|
||||
hasConfiguredPluginConfigEntry(cfg, "webFetch")
|
||||
) {
|
||||
return { mayNeedAutoEnable: true, configuredChannelIds };
|
||||
}
|
||||
@@ -808,13 +797,7 @@ function isPluginExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): bool
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
if (builtInChannelId) {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = channels?.[builtInChannelId];
|
||||
if (
|
||||
channelConfig &&
|
||||
typeof channelConfig === "object" &&
|
||||
!Array.isArray(channelConfig) &&
|
||||
(channelConfig as { enabled?: unknown }).enabled === false
|
||||
) {
|
||||
if (asOptionalRecord(channels?.[builtInChannelId])?.enabled === false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -857,7 +840,7 @@ function disableImplicitPreferredOverPlugin(params: {
|
||||
entries: {
|
||||
...params.config.plugins?.entries,
|
||||
[params.pluginId]: {
|
||||
...(existingEntry && typeof existingEntry === "object" ? existingEntry : {}),
|
||||
...asOptionalObjectRecord(existingEntry),
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
@@ -867,13 +850,7 @@ function disableImplicitPreferredOverPlugin(params: {
|
||||
|
||||
function isBuiltInChannelAlreadyEnabled(cfg: OpenClawConfig, channelId: string): boolean {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = channels?.[channelId];
|
||||
return (
|
||||
Boolean(channelConfig) &&
|
||||
typeof channelConfig === "object" &&
|
||||
!Array.isArray(channelConfig) &&
|
||||
(channelConfig as { enabled?: unknown }).enabled === true
|
||||
);
|
||||
return asOptionalRecord(channels?.[channelId])?.enabled === true;
|
||||
}
|
||||
|
||||
function resolveAutoEnableChannelId(params: {
|
||||
@@ -919,17 +896,12 @@ function registerPluginEntry(
|
||||
const builtInChannelId = resolveAutoEnableChannelId({ entry, manifestRegistry });
|
||||
if (builtInChannelId) {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const existing = channels?.[builtInChannelId];
|
||||
const existingRecord =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? (existing as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
[builtInChannelId]: {
|
||||
...existingRecord,
|
||||
...asOptionalRecord(channels?.[builtInChannelId]),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
@@ -983,8 +955,8 @@ function materializeConfiguredPluginEntryAllowlist(params: {
|
||||
}): OpenClawConfig {
|
||||
let next = params.config;
|
||||
const allow = next.plugins?.allow;
|
||||
const entries = next.plugins?.entries;
|
||||
if (!Array.isArray(allow) || allow.length === 0 || !entries || typeof entries !== "object") {
|
||||
const entries = asOptionalObjectRecord(next.plugins?.entries);
|
||||
if (!Array.isArray(allow) || allow.length === 0 || !entries) {
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
+14
-25
@@ -10,30 +10,19 @@ import {
|
||||
mapSensitivePaths,
|
||||
} from "./schema.hints.js";
|
||||
import { FIELD_LABELS } from "./schema.labels.js";
|
||||
import { asSchemaObject, cloneSchema } from "./schema.shared.js";
|
||||
import {
|
||||
asSchemaObject,
|
||||
cloneSchema,
|
||||
type ConfigJsonSchemaObject as JsonSchemaObject,
|
||||
} from "./schema.shared.js";
|
||||
import { applyDerivedTags } from "./schema.tags.js";
|
||||
import { applyResolvedConfigTierHints } from "./schema.tiers.js";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
type ConfigSchema = Record<string, unknown>;
|
||||
|
||||
type JsonSchemaObject = Record<string, unknown> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
allOf?: JsonSchemaObject[];
|
||||
};
|
||||
|
||||
const LEGACY_HIDDEN_PUBLIC_PATHS = ["hooks.internal.handlers"] as const;
|
||||
|
||||
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
|
||||
asSchemaObject(value) as JsonSchemaObject | null;
|
||||
|
||||
/**
|
||||
* Recursively walk a JSON Schema object and apply field docs using dot-path
|
||||
* matching. Existing titles/descriptions (for example from Zod metadata) are
|
||||
@@ -43,7 +32,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
|
||||
const props = node.properties;
|
||||
if (props) {
|
||||
for (const [key, child] of Object.entries(props)) {
|
||||
const childObj = asJsonSchemaObject(child);
|
||||
const childObj = asSchemaObject(child);
|
||||
if (!childObj) {
|
||||
continue;
|
||||
}
|
||||
@@ -54,7 +43,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
|
||||
}
|
||||
// Handle additionalProperties (wildcard keys like "models.providers.*")
|
||||
if (node.additionalProperties && typeof node.additionalProperties === "object") {
|
||||
const addObj = asJsonSchemaObject(node.additionalProperties);
|
||||
const addObj = asSchemaObject(node.additionalProperties);
|
||||
if (addObj) {
|
||||
const wildcardPrefixes = prefixes.map((prefix) => (prefix ? `${prefix}.*` : "*"));
|
||||
applyNodeDocumentation(addObj, wildcardPrefixes);
|
||||
@@ -64,7 +53,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
|
||||
// Handle array items. Help/labels may use either "[]" notation
|
||||
// (bindings[].type) or wildcard "*" notation (agents.list.*.skills).
|
||||
if (node.items) {
|
||||
const itemsObj = asJsonSchemaObject(node.items);
|
||||
const itemsObj = asSchemaObject(node.items);
|
||||
if (itemsObj) {
|
||||
const itemPrefixes = Array.from(
|
||||
new Set(
|
||||
@@ -85,7 +74,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
|
||||
const branches = node[keyword];
|
||||
if (Array.isArray(branches)) {
|
||||
for (const branch of branches) {
|
||||
const branchObj = asJsonSchemaObject(branch);
|
||||
const branchObj = asSchemaObject(branch);
|
||||
if (branchObj) {
|
||||
applyFieldDocumentation(branchObj, prefixes);
|
||||
}
|
||||
@@ -118,7 +107,7 @@ type BaseConfigSchemaStablePayload = Omit<BaseConfigSchemaResponse, "generatedAt
|
||||
|
||||
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asJsonSchemaObject(next);
|
||||
const root = asSchemaObject(next);
|
||||
if (!root || !root.properties) {
|
||||
return next;
|
||||
}
|
||||
@@ -128,7 +117,7 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
if (Array.isArray(root.required)) {
|
||||
root.required = root.required.filter((key) => key !== "$schema");
|
||||
}
|
||||
const channelsNode = asJsonSchemaObject(root.properties.channels);
|
||||
const channelsNode = asSchemaObject(root.properties.channels);
|
||||
if (channelsNode) {
|
||||
channelsNode.properties = {};
|
||||
channelsNode.required = [];
|
||||
@@ -138,14 +127,14 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
}
|
||||
|
||||
function stripObjectPropertyPath(schema: ConfigSchema, path: readonly string[]): void {
|
||||
const root = asJsonSchemaObject(schema);
|
||||
const root = asSchemaObject(schema);
|
||||
if (!root || path.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let current: JsonSchemaObject | null = root;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
current = asJsonSchemaObject(current?.properties?.[segment]);
|
||||
current = asSchemaObject(current?.properties?.[segment]);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
@@ -197,7 +186,7 @@ function computeBaseConfigSchemaStablePayload(): BaseConfigSchemaStablePayload {
|
||||
unrepresentable: "any",
|
||||
});
|
||||
schema.title = "OpenClawConfig";
|
||||
const schemaRoot = asJsonSchemaObject(schema);
|
||||
const schemaRoot = asSchemaObject(schema);
|
||||
if (schemaRoot) {
|
||||
applyFieldDocumentation(schemaRoot);
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,12 +1,17 @@
|
||||
// Provides shared JSON schema helpers for generated config metadata.
|
||||
type JsonSchemaObject = {
|
||||
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
|
||||
export type ConfigJsonSchemaObject = Record<string, unknown> & {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
allOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: Record<string, ConfigJsonSchemaObject>;
|
||||
required?: string[];
|
||||
additionalProperties?: ConfigJsonSchemaObject | boolean;
|
||||
items?: ConfigJsonSchemaObject | ConfigJsonSchemaObject[];
|
||||
anyOf?: ConfigJsonSchemaObject[];
|
||||
allOf?: ConfigJsonSchemaObject[];
|
||||
oneOf?: ConfigJsonSchemaObject[];
|
||||
};
|
||||
|
||||
/** Deep-clone schema payloads before callers mutate plugin or base schema fragments. */
|
||||
@@ -15,15 +20,12 @@ export function cloneSchema<T>(value: T): T {
|
||||
}
|
||||
|
||||
/** Narrow unknown JSON-schema fragments to non-array objects. */
|
||||
export function asSchemaObject(value: unknown): object | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
export function asSchemaObject(value: unknown): ConfigJsonSchemaObject | null {
|
||||
return asNullableRecord(value);
|
||||
}
|
||||
|
||||
/** Return whether a schema node exposes nested fields through properties, items, or unions. */
|
||||
export function schemaHasChildren(schema: JsonSchemaObject): boolean {
|
||||
export function schemaHasChildren(schema: ConfigJsonSchemaObject): boolean {
|
||||
if (schema.properties && Object.keys(schema.properties).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+48
-77
@@ -1,15 +1,5 @@
|
||||
import type { ConfigUiHints } from "../shared/config-ui-hints-types.js";
|
||||
import { asSchemaObject } from "./schema.shared.js";
|
||||
|
||||
type JsonSchemaObject = Record<string, unknown> & {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
allOf?: JsonSchemaObject[];
|
||||
};
|
||||
import { asSchemaObject, type ConfigJsonSchemaObject } from "./schema.shared.js";
|
||||
|
||||
const ROOT_TIER_PATHS = `
|
||||
accessGroups acp agents approvals attachments auth bindings broadcast browser channels
|
||||
@@ -235,7 +225,7 @@ function createTierMatcher(hints: ConfigUiHints): (path: string) => boolean | un
|
||||
};
|
||||
}
|
||||
|
||||
function isNumericSchema(schema: JsonSchemaObject): boolean {
|
||||
function isNumericSchema(schema: ConfigJsonSchemaObject): boolean {
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
return types.includes("number") || types.includes("integer");
|
||||
}
|
||||
@@ -256,6 +246,46 @@ function mergeTierHint(hints: ConfigUiHints, path: string, advanced: boolean): v
|
||||
hints[path] = current ? { ...current, advanced } : { advanced };
|
||||
}
|
||||
|
||||
function visitSchemaNodes<T>(
|
||||
schema: unknown,
|
||||
initialState: T,
|
||||
visit: (node: ConfigJsonSchemaObject, path: string, state: T) => T,
|
||||
): void {
|
||||
const visited = new WeakMap<object, Set<string>>();
|
||||
const walk = (value: unknown, path: string, state: T): void => {
|
||||
const node = asSchemaObject(value);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const previousPaths = visited.get(node);
|
||||
if (previousPaths?.has(path)) {
|
||||
return;
|
||||
}
|
||||
if (previousPaths) {
|
||||
previousPaths.add(path);
|
||||
} else {
|
||||
visited.set(node, new Set([path]));
|
||||
}
|
||||
const nextState = visit(node, path, state);
|
||||
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
||||
walk(child, path ? `${path}.${key}` : key, nextState);
|
||||
}
|
||||
if (node.additionalProperties && typeof node.additionalProperties === "object") {
|
||||
walk(node.additionalProperties, path ? `${path}.*` : "*", nextState);
|
||||
}
|
||||
const items = Array.isArray(node.items) ? node.items : node.items ? [node.items] : [];
|
||||
for (const item of items) {
|
||||
walk(item, path ? `${path}.*` : "*", nextState);
|
||||
}
|
||||
for (const branches of [node.anyOf, node.oneOf, node.allOf]) {
|
||||
for (const branch of branches ?? []) {
|
||||
walk(branch, path, nextState);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(schema, "", initialState);
|
||||
}
|
||||
|
||||
/** Add authored common/advanced tier boundaries to the base hint map. */
|
||||
export function applyConfigTierHints(
|
||||
hints: ConfigUiHints,
|
||||
@@ -283,21 +313,7 @@ function applyNumericTuningTierHints(
|
||||
): ConfigUiHints {
|
||||
const next = { ...hints };
|
||||
const authoredTier = createTierMatcher(hints);
|
||||
const visited = new WeakMap<object, Set<string>>();
|
||||
const visit = (value: unknown, path: string): void => {
|
||||
const node = asSchemaObject(value) as JsonSchemaObject | null;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const prior = visited.get(node);
|
||||
if (prior?.has(path)) {
|
||||
return;
|
||||
}
|
||||
if (prior) {
|
||||
prior.add(path);
|
||||
} else {
|
||||
visited.set(node, new Set([path]));
|
||||
}
|
||||
visitSchemaNodes(schema, undefined, (node, path) => {
|
||||
if (
|
||||
path &&
|
||||
isNumericSchema(node) &&
|
||||
@@ -306,23 +322,8 @@ function applyNumericTuningTierHints(
|
||||
) {
|
||||
mergeTierHint(next, path, true);
|
||||
}
|
||||
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
||||
visit(child, path ? `${path}.${key}` : key);
|
||||
}
|
||||
if (node.additionalProperties && typeof node.additionalProperties === "object") {
|
||||
visit(node.additionalProperties, path ? `${path}.*` : "*");
|
||||
}
|
||||
const items = Array.isArray(node.items) ? node.items : node.items ? [node.items] : [];
|
||||
for (const item of items) {
|
||||
visit(item, path ? `${path}.*` : "*");
|
||||
}
|
||||
for (const branches of [node.anyOf, node.oneOf, node.allOf]) {
|
||||
for (const branch of branches ?? []) {
|
||||
visit(branch, path);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(schema, "");
|
||||
return undefined;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -334,22 +335,8 @@ export function applyResolvedConfigTierHints(
|
||||
const tierHints = applyNumericTuningTierHints(schema, hints);
|
||||
const next = { ...tierHints };
|
||||
const matchTier = createTierMatcher(tierHints);
|
||||
const visited = new WeakMap<object, Set<string>>();
|
||||
|
||||
const visit = (value: unknown, path: string, inheritedTier: boolean): void => {
|
||||
const node = asSchemaObject(value) as JsonSchemaObject | null;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const previousPaths = visited.get(node);
|
||||
if (previousPaths?.has(path)) {
|
||||
return;
|
||||
}
|
||||
if (previousPaths) {
|
||||
previousPaths.add(path);
|
||||
} else {
|
||||
visited.set(node, new Set([path]));
|
||||
}
|
||||
visitSchemaNodes(schema, true, (_node, path, inheritedTier) => {
|
||||
const advanced = path
|
||||
? resolveTier({
|
||||
inheritedTier,
|
||||
@@ -359,23 +346,7 @@ export function applyResolvedConfigTierHints(
|
||||
if (path) {
|
||||
mergeTierHint(next, path, advanced);
|
||||
}
|
||||
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
||||
visit(child, path ? `${path}.${key}` : key, advanced);
|
||||
}
|
||||
if (node.additionalProperties && typeof node.additionalProperties === "object") {
|
||||
visit(node.additionalProperties, path ? `${path}.*` : "*", advanced);
|
||||
}
|
||||
const items = Array.isArray(node.items) ? node.items : node.items ? [node.items] : [];
|
||||
for (const item of items) {
|
||||
visit(item, path ? `${path}.*` : "*", advanced);
|
||||
}
|
||||
for (const branches of [node.anyOf, node.oneOf, node.allOf]) {
|
||||
for (const branch of branches ?? []) {
|
||||
visit(branch, path, advanced);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(schema, "", true);
|
||||
return advanced;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
+15
-28
@@ -11,6 +11,7 @@ import { applySensitiveHints, applySensitiveUrlHints } from "./schema.hints.js";
|
||||
import {
|
||||
asSchemaObject,
|
||||
cloneSchema,
|
||||
type ConfigJsonSchemaObject as JsonSchemaObject,
|
||||
findWildcardHintMatch,
|
||||
schemaHasChildren,
|
||||
} from "./schema.shared.js";
|
||||
@@ -21,20 +22,6 @@ type ConfigSchema = Record<string, unknown>;
|
||||
|
||||
type JsonSchemaNode = Record<string, unknown>;
|
||||
|
||||
type JsonSchemaObject = JsonSchemaNode & {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
allOf?: JsonSchemaObject[];
|
||||
};
|
||||
|
||||
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
|
||||
asSchemaObject(value) as JsonSchemaObject | null;
|
||||
|
||||
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const LOOKUP_SCHEMA_STRING_KEYS = new Set([
|
||||
"$id",
|
||||
@@ -240,7 +227,7 @@ function collectExtensionHintKeys(
|
||||
};
|
||||
|
||||
const collectSchemaKeys = (schema: unknown, basePath: string) => {
|
||||
const node = asJsonSchemaObject(schema);
|
||||
const node = asSchemaObject(schema);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
@@ -407,14 +394,14 @@ function applyHeartbeatTargetHints(
|
||||
|
||||
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asJsonSchemaObject(next);
|
||||
const pluginsNode = asJsonSchemaObject(root?.properties?.plugins);
|
||||
const entriesNode = asJsonSchemaObject(pluginsNode?.properties?.entries);
|
||||
const root = asSchemaObject(next);
|
||||
const pluginsNode = asSchemaObject(root?.properties?.plugins);
|
||||
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
|
||||
if (!entriesNode) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const entryBase = asJsonSchemaObject(entriesNode.additionalProperties);
|
||||
const entryBase = asSchemaObject(entriesNode.additionalProperties);
|
||||
const entryProperties = entriesNode.properties ?? {};
|
||||
entriesNode.properties = entryProperties;
|
||||
|
||||
@@ -425,9 +412,9 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
|
||||
const entrySchema = entryBase
|
||||
? cloneSchema(entryBase)
|
||||
: ({ type: "object" } as JsonSchemaObject);
|
||||
const entryObject = asJsonSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
|
||||
const baseConfigSchema = asJsonSchemaObject(entryObject.properties?.config);
|
||||
const pluginSchema = asJsonSchemaObject(plugin.configSchema);
|
||||
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
|
||||
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
|
||||
const pluginSchema = asSchemaObject(plugin.configSchema);
|
||||
const nextConfigSchema =
|
||||
baseConfigSchema &&
|
||||
pluginSchema &&
|
||||
@@ -448,8 +435,8 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
|
||||
|
||||
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asJsonSchemaObject(next);
|
||||
const channelsNode = asJsonSchemaObject(root?.properties?.channels);
|
||||
const root = asSchemaObject(next);
|
||||
const channelsNode = asSchemaObject(root?.properties?.channels);
|
||||
if (!channelsNode) {
|
||||
return next;
|
||||
}
|
||||
@@ -460,8 +447,8 @@ function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]
|
||||
if (!channel.configSchema) {
|
||||
continue;
|
||||
}
|
||||
const existing = asJsonSchemaObject(channelProps[channel.id]);
|
||||
const incoming = asJsonSchemaObject(channel.configSchema);
|
||||
const existing = asSchemaObject(channelProps[channel.id]);
|
||||
const incoming = asSchemaObject(channel.configSchema);
|
||||
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
|
||||
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
|
||||
} else {
|
||||
@@ -677,7 +664,7 @@ function resolveLookupChildSchema(
|
||||
|
||||
const properties = schema.properties;
|
||||
if (properties && Object.hasOwn(properties, segment)) {
|
||||
return asJsonSchemaObject(properties[segment]);
|
||||
return asSchemaObject(properties[segment]);
|
||||
}
|
||||
|
||||
const itemIndex = parseConfigPathArrayIndex(segment);
|
||||
@@ -836,7 +823,7 @@ export function lookupConfigSchema(
|
||||
return null;
|
||||
}
|
||||
|
||||
let current = asJsonSchemaObject(response.schema);
|
||||
let current = asSchemaObject(response.schema);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asNullableObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { unsupportedSecretRefSurfacePolicy } from "../secrets/unsupported-surface-policy.js";
|
||||
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
|
||||
import type { ConfigValidationIssue } from "./types.js";
|
||||
@@ -16,13 +17,6 @@ type JsonSchemaLike = Record<string, unknown>;
|
||||
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
|
||||
const SECRETREF_POLICY_DOC_URL = "https://docs.openclaw.ai/reference/secretref-credential-surface";
|
||||
|
||||
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
return value as UnknownIssueRecord;
|
||||
}
|
||||
|
||||
function toConfigPathSegments(path: unknown): ConfigPathSegment[] {
|
||||
if (!Array.isArray(path)) {
|
||||
return [];
|
||||
@@ -48,15 +42,11 @@ export function withConfigIssuePath(
|
||||
return issue;
|
||||
}
|
||||
|
||||
function asJsonSchemaLike(value: unknown): JsonSchemaLike | null {
|
||||
return value && typeof value === "object" ? (value as JsonSchemaLike) : null;
|
||||
}
|
||||
|
||||
function lookupJsonSchemaNode(
|
||||
schema: unknown,
|
||||
pathSegments: readonly ConfigPathSegment[],
|
||||
): JsonSchemaLike | null {
|
||||
let current = asJsonSchemaLike(schema);
|
||||
let current = asNullableObjectRecord(schema);
|
||||
for (const segment of pathSegments) {
|
||||
if (!current) {
|
||||
return null;
|
||||
@@ -64,23 +54,23 @@ function lookupJsonSchemaNode(
|
||||
if (typeof segment === "number") {
|
||||
const items = current.items;
|
||||
if (Array.isArray(items)) {
|
||||
current = asJsonSchemaLike(items[segment] ?? items[0]);
|
||||
current = asNullableObjectRecord(items[segment] ?? items[0]);
|
||||
continue;
|
||||
}
|
||||
current = asJsonSchemaLike(items);
|
||||
current = asNullableObjectRecord(items);
|
||||
continue;
|
||||
}
|
||||
const properties = asJsonSchemaLike(current.properties);
|
||||
const properties = asNullableObjectRecord(current.properties);
|
||||
const next =
|
||||
(properties && asJsonSchemaLike(properties[segment])) ||
|
||||
asJsonSchemaLike(current.additionalProperties);
|
||||
(properties && asNullableObjectRecord(properties[segment])) ||
|
||||
asNullableObjectRecord(current.additionalProperties);
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function collectAllowedValuesFromJsonSchemaNode(schema: unknown): AllowedValuesCollection {
|
||||
const node = asJsonSchemaLike(schema);
|
||||
const node = asNullableObjectRecord(schema);
|
||||
if (!node) {
|
||||
return { values: [], incomplete: false, hasValues: false };
|
||||
}
|
||||
@@ -173,7 +163,7 @@ function appendNumericBoundHint(message: string, record: UnknownIssueRecord): st
|
||||
}
|
||||
|
||||
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
|
||||
const record = toIssueRecord(issue);
|
||||
const record = asNullableObjectRecord(issue);
|
||||
if (!record) {
|
||||
return { values: [], incomplete: false, hasValues: false };
|
||||
}
|
||||
@@ -266,7 +256,7 @@ function extractBindingsSpecificUnionIssue(
|
||||
if (!Array.isArray(errGroup)) {
|
||||
continue;
|
||||
}
|
||||
const branch = errGroup.map(toIssueRecord).filter(Boolean) as UnknownIssueRecord[];
|
||||
const branch = errGroup.map(asNullableObjectRecord).filter(Boolean) as UnknownIssueRecord[];
|
||||
if (branch.length === 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -319,7 +309,7 @@ function extractBindingsSpecificUnionIssue(
|
||||
}
|
||||
|
||||
export function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
|
||||
const record = toIssueRecord(issue);
|
||||
const record = asNullableObjectRecord(issue);
|
||||
const pathSegments = toConfigPathSegments(record?.path);
|
||||
const path = formatConfigPath(pathSegments);
|
||||
const message = typeof record?.message === "string" ? record.message : "Invalid input";
|
||||
@@ -350,9 +340,7 @@ export function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue
|
||||
}
|
||||
|
||||
function isObjectSecretRefCandidate(value: unknown): boolean {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && !Array.isArray(value) && coerceSecretRef(value),
|
||||
);
|
||||
return isRecord(value) && Boolean(coerceSecretRef(value));
|
||||
}
|
||||
|
||||
function formatUnsupportedMutableSecretRefMessage(path: string): string {
|
||||
|
||||
+141
-429
@@ -273,440 +273,152 @@ describe("applyJobPatch", () => {
|
||||
expect(job.delivery?.accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists agentTurn payload.lightContext updates when editing existing jobs", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-light-context", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
lightContext: true,
|
||||
};
|
||||
it.each([
|
||||
{
|
||||
name: "persists agentTurn payload.lightContext updates when editing existing jobs",
|
||||
id: "job-light-context",
|
||||
initial: { lightContext: true },
|
||||
patch: { lightContext: false },
|
||||
expected: { lightContext: false },
|
||||
},
|
||||
{
|
||||
name: "persists agentTurn payload.fallbacks updates when editing existing jobs",
|
||||
id: "job-fallbacks",
|
||||
initial: { fallbacks: ["openrouter/gpt-4.1-mini"] },
|
||||
patch: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
|
||||
expected: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
|
||||
},
|
||||
{
|
||||
name: "clears agentTurn payload.fallbacks when patch requests null",
|
||||
id: "job-fallbacks-clear",
|
||||
initial: { fallbacks: ["openrouter/gpt-4.1-mini"] },
|
||||
patch: { fallbacks: null },
|
||||
expected: { fallbacks: undefined },
|
||||
},
|
||||
{
|
||||
name: "omits null payload.fallbacks when replacing a non-agent payload",
|
||||
id: "job-fallbacks-kind-switch",
|
||||
replace: true,
|
||||
patch: { fallbacks: null },
|
||||
expected: { fallbacks: undefined },
|
||||
},
|
||||
{
|
||||
name: "persists agentTurn payload.toolsAllow updates when editing existing jobs",
|
||||
id: "job-tools",
|
||||
initial: { toolsAllow: ["exec"] },
|
||||
patch: { toolsAllow: ["read", "write"] },
|
||||
expected: { toolsAllow: ["read", "write"] },
|
||||
},
|
||||
{
|
||||
name: "clears the default toolsAllow flag when editing to an explicit restriction",
|
||||
id: "job-tools-explicit",
|
||||
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
patch: { toolsAllow: ["read"], toolsAllowIsDefault: true },
|
||||
expected: { toolsAllow: ["read"], toolsAllowIsDefault: undefined },
|
||||
},
|
||||
{
|
||||
name: "preserves the default toolsAllow flag when a full payload edit keeps the default list",
|
||||
id: "job-tools-default-edit",
|
||||
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
patch: { message: "do it later", toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
expected: { message: "do it later", toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
},
|
||||
{
|
||||
name: "preserves the default toolsAllow flag when a self-edit echoes the default list",
|
||||
id: "job-tools-default-echo",
|
||||
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
patch: { message: "do it later", toolsAllow: ["exec", "read"] },
|
||||
expected: { message: "do it later", toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
},
|
||||
{
|
||||
name: "stores an explicit wildcard when a patch clears agentTurn payload.toolsAllow",
|
||||
id: "job-tools-clear",
|
||||
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
patch: { toolsAllow: null },
|
||||
expected: { toolsAllow: ["*"], toolsAllowIsDefault: undefined },
|
||||
},
|
||||
{
|
||||
name: "clears agentTurn payload.model when patch requests null",
|
||||
id: "job-model-clear",
|
||||
initial: { model: "openai/gpt-5.5" },
|
||||
patch: { model: null },
|
||||
expected: { message: "do it", model: undefined },
|
||||
},
|
||||
{
|
||||
name: "omits null model when patch builds a replacement agentTurn payload",
|
||||
id: "job-model-replace",
|
||||
replaceMain: true,
|
||||
patch: { model: null },
|
||||
expected: { message: "do it", model: undefined },
|
||||
},
|
||||
{
|
||||
name: "persists agentTurn payload.thinking updates when editing existing jobs",
|
||||
id: "job-thinking",
|
||||
initial: { thinking: "high" },
|
||||
patch: { thinking: "low" },
|
||||
expected: { thinking: "low" },
|
||||
},
|
||||
{
|
||||
name: "clears agentTurn payload.thinking when patch requests null",
|
||||
id: "job-thinking-clear",
|
||||
initial: { thinking: "high" },
|
||||
patch: { thinking: null },
|
||||
expected: { message: "do it", thinking: undefined },
|
||||
},
|
||||
{
|
||||
name: "omits null thinking when patch builds a replacement agentTurn payload",
|
||||
id: "job-thinking-replace",
|
||||
replaceMain: true,
|
||||
patch: { thinking: null },
|
||||
expected: { message: "do it", thinking: undefined },
|
||||
},
|
||||
{
|
||||
name: "applies payload.lightContext when replacing payload kind via patch",
|
||||
id: "job-light-context-switch",
|
||||
replace: true,
|
||||
patch: { lightContext: true },
|
||||
expected: { lightContext: true },
|
||||
},
|
||||
{
|
||||
name: "carries payload.fallbacks when replacing payload kind via patch",
|
||||
id: "job-fallbacks-switch",
|
||||
replace: true,
|
||||
patch: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
|
||||
expected: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
|
||||
},
|
||||
{
|
||||
name: "carries payload.toolsAllow when replacing payload kind via patch",
|
||||
id: "job-tools-switch",
|
||||
replace: true,
|
||||
patch: { toolsAllow: ["exec", "read"] },
|
||||
expected: { toolsAllow: ["exec", "read"] },
|
||||
},
|
||||
{
|
||||
name: "carries payload.toolsAllow default flag when replacing payload kind via patch",
|
||||
id: "job-tools-default-switch",
|
||||
replace: true,
|
||||
patch: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
expected: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
|
||||
},
|
||||
])("$name", ({ id, initial, patch, expected, replace, replaceMain }) => {
|
||||
const job = replaceMain
|
||||
? createMainSystemEventJob(id, { mode: "none" })
|
||||
: createIsolatedAgentTurnJob(id, { mode: "announce", channel: "telegram" });
|
||||
job.payload =
|
||||
replace || replaceMain
|
||||
? { kind: "systemEvent", text: replaceMain ? "ping" : "tick" }
|
||||
: { kind: "agentTurn", message: "do it", ...initial };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
lightContext: false,
|
||||
},
|
||||
});
|
||||
...(replaceMain ? { sessionTarget: "isolated" } : {}),
|
||||
payload: { kind: "agentTurn", message: "do it", ...patch },
|
||||
} as CronJobPatch);
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.lightContext).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("persists agentTurn payload.fallbacks updates when editing existing jobs", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-fallbacks", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
fallbacks: ["openrouter/gpt-4.1-mini"],
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.fallbacks).toEqual(["anthropic/claude-haiku-3-5", "openai/gpt-5"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears agentTurn payload.fallbacks when patch requests null", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-fallbacks-clear", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
fallbacks: ["openrouter/gpt-4.1-mini"],
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
fallbacks: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.fallbacks).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits null payload.fallbacks when replacing a non-agent payload", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-fallbacks-kind-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = { kind: "systemEvent", text: "tick" };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
fallbacks: null,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = job.payload as CronJob["payload"];
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
if (payload.kind === "agentTurn") {
|
||||
expect(payload.fallbacks).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists agentTurn payload.toolsAllow updates when editing existing jobs", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec"],
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["read", "write"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.toolsAllow).toEqual(["read", "write"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the default toolsAllow flag when editing to an explicit restriction", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-explicit", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["read"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.toolsAllow).toEqual(["read"]);
|
||||
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the default toolsAllow flag when a full payload edit keeps the default list", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-default-edit", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it later",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it later");
|
||||
expect(job.payload.toolsAllow).toEqual(["exec", "read"]);
|
||||
expect(job.payload.toolsAllowIsDefault).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the default toolsAllow flag when a self-edit echoes the default list", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-default-echo", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it later",
|
||||
toolsAllow: ["exec", "read"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it later");
|
||||
expect(job.payload.toolsAllow).toEqual(["exec", "read"]);
|
||||
expect(job.payload.toolsAllowIsDefault).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores an explicit wildcard when a patch clears agentTurn payload.toolsAllow", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-clear", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.toolsAllow).toEqual(["*"]);
|
||||
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears agentTurn payload.model when patch requests null", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-model-clear", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
model: "openai/gpt-5.5",
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
model: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it");
|
||||
expect(job.payload.model).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits null model when patch builds a replacement agentTurn payload", () => {
|
||||
const job = createMainSystemEventJob("job-model-replace", { mode: "none" });
|
||||
|
||||
applyJobPatch(job, {
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
model: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it");
|
||||
expect(job.payload.model).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists agentTurn payload.thinking updates when editing existing jobs", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-thinking", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
thinking: "high",
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
thinking: "low",
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.thinking).toBe("low");
|
||||
}
|
||||
});
|
||||
|
||||
it("clears agentTurn payload.thinking when patch requests null", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-thinking-clear", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
thinking: "high",
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
thinking: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it");
|
||||
expect(job.payload.thinking).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits null thinking when patch builds a replacement agentTurn payload", () => {
|
||||
const job = createMainSystemEventJob("job-thinking-replace", { mode: "none" });
|
||||
|
||||
applyJobPatch(job, {
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
thinking: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it");
|
||||
expect(job.payload.thinking).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies payload.lightContext when replacing payload kind via patch", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-light-context-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = { kind: "systemEvent", text: "ping" };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
lightContext: true,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = job.payload as CronJob["payload"];
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
if (payload.kind === "agentTurn") {
|
||||
expect(payload.lightContext).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries payload.fallbacks when replacing payload kind via patch", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-fallbacks-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = { kind: "systemEvent", text: "ping" };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"],
|
||||
},
|
||||
});
|
||||
|
||||
const payload = job.payload as CronJob["payload"];
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
if (payload.kind === "agentTurn") {
|
||||
expect(payload.fallbacks).toEqual(["anthropic/claude-haiku-3-5", "openai/gpt-5"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries payload.toolsAllow when replacing payload kind via patch", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = { kind: "systemEvent", text: "ping" };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
},
|
||||
});
|
||||
|
||||
const payload = job.payload as CronJob["payload"];
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
if (payload.kind === "agentTurn") {
|
||||
expect(payload.toolsAllow).toEqual(["exec", "read"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries payload.toolsAllow default flag when replacing payload kind via patch", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-default-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = { kind: "systemEvent", text: "ping" };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = job.payload as CronJob["payload"];
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
if (payload.kind === "agentTurn") {
|
||||
expect(payload.toolsAllow).toEqual(["exec", "read"]);
|
||||
expect(payload.toolsAllowIsDefault).toBe(true);
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(job.payload[key as keyof typeof job.payload]).toEqual(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -453,21 +453,16 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob;
|
||||
}
|
||||
|
||||
if (!isJobEnabled(job)) {
|
||||
if (job.state.startupCatchupAtMs !== undefined) {
|
||||
job.state.startupCatchupAtMs = undefined;
|
||||
changed = true;
|
||||
}
|
||||
if (job.state.pacedNextRunAtMs !== undefined) {
|
||||
job.state.pacedNextRunAtMs = undefined;
|
||||
changed = true;
|
||||
}
|
||||
if (job.state.forcePreservedNextRunAtMs !== undefined) {
|
||||
job.state.forcePreservedNextRunAtMs = undefined;
|
||||
changed = true;
|
||||
}
|
||||
if (job.state.nextRunAtMs !== undefined) {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
changed = true;
|
||||
for (const key of [
|
||||
"startupCatchupAtMs",
|
||||
"pacedNextRunAtMs",
|
||||
"forcePreservedNextRunAtMs",
|
||||
"nextRunAtMs",
|
||||
] as const) {
|
||||
if (job.state[key] !== undefined) {
|
||||
job.state[key] = undefined;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
job.state.queuedAtMs !== undefined &&
|
||||
@@ -611,7 +606,6 @@ function recomputeJobNextRunAtMs(params: {
|
||||
/** Recomputes missing, due, or repairable next-run timestamps for all schedulable jobs. */
|
||||
export function recomputeNextRuns(state: CronServiceState): boolean {
|
||||
return walkSchedulableJobs(state, ({ job, nowMs: now }) => {
|
||||
let changed = false;
|
||||
// Only recompute if nextRunAtMs is missing or already past-due.
|
||||
// Preserving a still-future nextRunAtMs avoids accidentally advancing
|
||||
// a job that hasn't fired yet (e.g. during restart recovery).
|
||||
@@ -621,15 +615,11 @@ export function recomputeNextRuns(state: CronServiceState): boolean {
|
||||
hasScheduledNextRunAtMs(nextRun) &&
|
||||
job.state.forcePreservedNextRunAtMs === nextRun;
|
||||
const isDueOrMissing = !hasScheduledNextRunAtMs(nextRun) || now >= nextRun;
|
||||
if (
|
||||
return (
|
||||
!hasForcePreservedNextRun &&
|
||||
(isDueOrMissing || shouldRepairFutureCronNextRunAtMs({ state, job, nowMs: now }))
|
||||
) {
|
||||
if (recomputeJobNextRunAtMs({ state, job, nowMs: now })) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
(isDueOrMissing || shouldRepairFutureCronNextRunAtMs({ state, job, nowMs: now })) &&
|
||||
recomputeJobNextRunAtMs({ state, job, nowMs: now })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -693,9 +683,7 @@ export function recomputeNextRunsForMaintenance(
|
||||
}
|
||||
|
||||
if (!hasScheduledNextRunAtMs(job.state.nextRunAtMs)) {
|
||||
if (recomputeJob(job, now)) {
|
||||
changed = true;
|
||||
}
|
||||
changed = recomputeJob(job, now) || changed;
|
||||
} else if (
|
||||
repairFutureCronNextRunAtMs &&
|
||||
!hasPendingStartupCatchup &&
|
||||
@@ -703,9 +691,7 @@ export function recomputeNextRunsForMaintenance(
|
||||
!hasForcePreservedNextRun &&
|
||||
shouldRepairFutureCronNextRunAtMs({ state, job, nowMs: now })
|
||||
) {
|
||||
if (recomputeJob(job, now)) {
|
||||
changed = true;
|
||||
}
|
||||
changed = recomputeJob(job, now) || changed;
|
||||
} else if (
|
||||
recomputeExpired &&
|
||||
!hasForcePreservedNextRun &&
|
||||
@@ -727,9 +713,7 @@ export function recomputeNextRunsForMaintenance(
|
||||
now < backoffUntilMs &&
|
||||
job.state.nextRunAtMs < backoffUntilMs;
|
||||
if (alreadyExecutedSlot || isStaleBackoffSlot) {
|
||||
if (recomputeJob(job, now)) {
|
||||
changed = true;
|
||||
}
|
||||
changed = recomputeJob(job, now) || changed;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
@@ -740,21 +724,14 @@ export function recomputeNextRunsForMaintenance(
|
||||
|
||||
/** Returns the next enabled wake timestamp from the in-memory cron store. */
|
||||
export function nextWakeAtMs(state: CronServiceState) {
|
||||
const jobs = state.store?.jobs ?? [];
|
||||
const enabled = jobs.filter(
|
||||
(j) => isJobEnabled(j) && hasScheduledNextRunAtMs(j.state.nextRunAtMs),
|
||||
);
|
||||
if (enabled.length === 0) {
|
||||
return undefined;
|
||||
let nextWake: number | undefined;
|
||||
for (const job of state.store?.jobs ?? []) {
|
||||
const nextRun = job.state.nextRunAtMs;
|
||||
if (isJobEnabled(job) && hasScheduledNextRunAtMs(nextRun)) {
|
||||
nextWake = nextWake === undefined ? nextRun : Math.min(nextWake, nextRun);
|
||||
}
|
||||
}
|
||||
const first = enabled[0]?.state.nextRunAtMs;
|
||||
if (!hasScheduledNextRunAtMs(first)) {
|
||||
return undefined;
|
||||
}
|
||||
return enabled.reduce((min, j) => {
|
||||
const next = j.state.nextRunAtMs;
|
||||
return hasScheduledNextRunAtMs(next) ? Math.min(min, next) : min;
|
||||
}, first);
|
||||
return nextWake;
|
||||
}
|
||||
|
||||
/** Applies one canonical server-authored authority envelope to a tool-bearing job. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { readCronJobScratchState, writeCronJobScratch } from "../scratch-store.j
|
||||
import { createCronStreamSourceIdentity } from "../stream-schedule.js";
|
||||
import type { CronJob } from "../types.js";
|
||||
import { failureNotificationDeliveryFromJobState } from "./failure-alerts.js";
|
||||
import { findJobOrThrow, isJobEnabled, nextWakeAtMs } from "./jobs.js";
|
||||
import { findJobOrThrow, isJobEnabled, nextWakeAtMs, resolveJobLastRunStatus } from "./jobs.js";
|
||||
import { sortCronJobs } from "./list-page-sort.js";
|
||||
import type {
|
||||
CronJobsEnabledFilter,
|
||||
@@ -251,10 +251,6 @@ function resolveLastRunStatusFilter(opts?: CronListPageOptions): CronJobsLastRun
|
||||
return "all";
|
||||
}
|
||||
|
||||
function resolveJobLastRunStatus(job: CronJob): CronJobsLastRunStatusFilter {
|
||||
return job.state.lastRunStatus ?? job.state.lastStatus ?? "unknown";
|
||||
}
|
||||
|
||||
/** Lists a filtered, sorted, bounded page of cron jobs for CLI/RPC callers. */
|
||||
export async function listPage(state: CronServiceState, opts?: CronListPageOptions) {
|
||||
return await locked(state, async () => {
|
||||
@@ -283,7 +279,10 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio
|
||||
if (scheduleKindFilter !== "all" && job.schedule.kind !== scheduleKindFilter) {
|
||||
return false;
|
||||
}
|
||||
if (lastRunStatusFilter !== "all" && resolveJobLastRunStatus(job) !== lastRunStatusFilter) {
|
||||
if (
|
||||
lastRunStatusFilter !== "all" &&
|
||||
(resolveJobLastRunStatus(job) ?? "unknown") !== lastRunStatusFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
|
||||
@@ -109,11 +109,7 @@ export function armTimer(state: CronServiceState) {
|
||||
// Intentionally avoid an `async` timer callback:
|
||||
// Vitest's fake-timer helpers can await async callbacks, which would block
|
||||
// tests that simulate long-running jobs. Runtime behavior is unchanged.
|
||||
state.timer = setTimeout(() => {
|
||||
void onTimer(state).catch((err: unknown) => {
|
||||
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
||||
});
|
||||
}, clampedDelay);
|
||||
setCronTimer(state, clampedDelay);
|
||||
state.deps.log.debug(
|
||||
{ nextAt, delayMs: clampedDelay, clamped: delay > MAX_TIMER_DELAY_MS },
|
||||
"cron: timer armed",
|
||||
@@ -127,11 +123,15 @@ function armRunningRecheckTimer(state: CronServiceState) {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
setCronTimer(state, MAX_TIMER_DELAY_MS);
|
||||
}
|
||||
|
||||
function setCronTimer(state: CronServiceState, delayMs: number): void {
|
||||
state.timer = setTimeout(() => {
|
||||
void onTimer(state).catch((err: unknown) => {
|
||||
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
||||
});
|
||||
}, MAX_TIMER_DELAY_MS);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/** Handles one cron timer tick under the process-wide root work admission. */
|
||||
|
||||
@@ -56,14 +56,6 @@ export function resolveCronNextRunWithLowerBound(params: {
|
||||
return Math.max(params.naturalNext, params.lowerBoundMs);
|
||||
}
|
||||
|
||||
function resolveRetryConfig() {
|
||||
return {
|
||||
maxAttempts: DEFAULT_MAX_TRANSIENT_RETRIES,
|
||||
backoffMs: DEFAULT_ERROR_BACKOFF_SCHEDULE_MS.slice(0, 3),
|
||||
retryOn: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTransientCronRetryDecision(params: {
|
||||
cronConfig?: CronConfig;
|
||||
error: string | undefined;
|
||||
@@ -72,7 +64,6 @@ export function resolveTransientCronRetryDecision(params: {
|
||||
executionStarted?: boolean;
|
||||
consecutiveErrors: number | undefined;
|
||||
}): TransientCronRetryDecision {
|
||||
const retryConfig = resolveRetryConfig();
|
||||
if (params.errorClassification?.kind === "permanent") {
|
||||
return {
|
||||
retryable: false,
|
||||
@@ -82,7 +73,7 @@ export function resolveTransientCronRetryDecision(params: {
|
||||
}
|
||||
const retryHint = resolveCronExecutionRetryHint({
|
||||
error: params.error,
|
||||
retryOn: retryConfig.retryOn,
|
||||
retryOn: undefined,
|
||||
classifiedReason:
|
||||
params.errorClassification?.kind === "reason"
|
||||
? params.errorClassification.reason
|
||||
@@ -98,7 +89,7 @@ export function resolveTransientCronRetryDecision(params: {
|
||||
reason: "permanent error",
|
||||
};
|
||||
}
|
||||
if (consecutiveErrors > retryConfig.maxAttempts) {
|
||||
if (consecutiveErrors > DEFAULT_MAX_TRANSIENT_RETRIES) {
|
||||
return {
|
||||
retryable: false,
|
||||
consecutiveErrors,
|
||||
@@ -110,7 +101,10 @@ export function resolveTransientCronRetryDecision(params: {
|
||||
retryable: true,
|
||||
consecutiveErrors,
|
||||
retryCategory: retryHint.category,
|
||||
backoffMs: errorBackoffMs(consecutiveErrors, retryConfig.backoffMs),
|
||||
backoffMs: errorBackoffMs(
|
||||
consecutiveErrors,
|
||||
DEFAULT_ERROR_BACKOFF_SCHEDULE_MS.slice(0, DEFAULT_MAX_TRANSIENT_RETRIES),
|
||||
),
|
||||
reason: "transient retry",
|
||||
};
|
||||
}
|
||||
@@ -119,9 +113,8 @@ export function resolveDisabledHeartbeatOneShotRetryDecision(params: {
|
||||
cronConfig?: CronConfig;
|
||||
consecutiveSkipped: number | undefined;
|
||||
}): DisabledHeartbeatOneShotRetryDecision {
|
||||
const retryConfig = resolveRetryConfig();
|
||||
const consecutiveSkipped = params.consecutiveSkipped ?? 0;
|
||||
if (consecutiveSkipped > retryConfig.maxAttempts) {
|
||||
if (consecutiveSkipped > DEFAULT_MAX_TRANSIENT_RETRIES) {
|
||||
return {
|
||||
retryable: false,
|
||||
consecutiveSkipped,
|
||||
@@ -131,7 +124,10 @@ export function resolveDisabledHeartbeatOneShotRetryDecision(params: {
|
||||
return {
|
||||
retryable: true,
|
||||
consecutiveSkipped,
|
||||
backoffMs: errorBackoffMs(consecutiveSkipped, retryConfig.backoffMs),
|
||||
backoffMs: errorBackoffMs(
|
||||
consecutiveSkipped,
|
||||
DEFAULT_ERROR_BACKOFF_SCHEDULE_MS.slice(0, DEFAULT_MAX_TRANSIENT_RETRIES),
|
||||
),
|
||||
reason: "disabled heartbeat retry",
|
||||
};
|
||||
}
|
||||
|
||||
+74
-85
@@ -987,6 +987,60 @@ export function createGatewayHttpServer(opts: {
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
function handleBudgetedGatewayWebSocketUpgrade(params: {
|
||||
req: IncomingMessage;
|
||||
socket: import("node:stream").Duplex;
|
||||
head: Buffer;
|
||||
wss: WebSocketServer;
|
||||
preauthConnectionBudget: PreauthConnectionBudget;
|
||||
preauthBudgetKey: string | undefined;
|
||||
ingressName: "Gateway" | "Worker";
|
||||
prepareSocket?: (socket: GatewayIngressWebSocket) => void;
|
||||
}): void {
|
||||
const { req, socket, head, wss, preauthConnectionBudget, preauthBudgetKey, ingressName } = params;
|
||||
if (isGatewayWorkAdmissionClosed()) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, `${ingressName} websocket admission closed`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (wss.listenerCount("connection") === 0) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, `${ingressName} websocket handlers unavailable`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!preauthConnectionBudget.acquire(preauthBudgetKey)) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let budgetTransferred = false;
|
||||
// The upgrade owns its budget until the connection handler explicitly claims the socket.
|
||||
const releaseUpgradeBudget = () => {
|
||||
if (!budgetTransferred) {
|
||||
budgetTransferred = true;
|
||||
preauthConnectionBudget.release(preauthBudgetKey);
|
||||
}
|
||||
};
|
||||
socket.once("close", releaseUpgradeBudget);
|
||||
try {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const ingressSocket = ws as GatewayIngressWebSocket;
|
||||
ingressSocket["__openclawPreauthBudgetKey"] = preauthBudgetKey;
|
||||
params.prepareSocket?.(ingressSocket);
|
||||
wss.emit("connection", ws, req);
|
||||
if (ingressSocket["__openclawPreauthBudgetClaimed"]) {
|
||||
budgetTransferred = true;
|
||||
socket.off("close", releaseUpgradeBudget);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
socket.off("close", releaseUpgradeBudget);
|
||||
releaseUpgradeBudget();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Attaches WebSocket and plugin-upgrade routing to an already-created HTTP server. */
|
||||
export function attachGatewayUpgradeHandler(opts: {
|
||||
httpServer: HttpServer;
|
||||
@@ -1104,57 +1158,17 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
// Plugin-owned upgrade routes have already had the opportunity to claim the socket.
|
||||
// Core Gateway upgrades must stop at the HTTP boundary so a client cannot hold an
|
||||
// untracked pre-connect socket after suspension or restart admission closes.
|
||||
if (isGatewayWorkAdmissionClosed()) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Gateway websocket admission closed");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const preauthBudgetKey = requestClientIp;
|
||||
if (wss.listenerCount("connection") === 0) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Gateway websocket handlers unavailable");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!preauthConnectionBudget.acquire(preauthBudgetKey)) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
let budgetTransferred = false;
|
||||
// The socket owns the preauth budget until the WebSocket connection handler claims it;
|
||||
// close/error paths release here to avoid leaking unauthenticated connection slots.
|
||||
const releaseUpgradeBudget = () => {
|
||||
if (budgetTransferred) {
|
||||
return;
|
||||
}
|
||||
budgetTransferred = true;
|
||||
preauthConnectionBudget.release(preauthBudgetKey);
|
||||
};
|
||||
socket.once("close", releaseUpgradeBudget);
|
||||
try {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
(
|
||||
ws as unknown as import("ws").WebSocket & {
|
||||
__openclawPreauthBudgetClaimed?: boolean;
|
||||
__openclawPreauthBudgetKey?: string;
|
||||
}
|
||||
)["__openclawPreauthBudgetKey"] = preauthBudgetKey;
|
||||
wss.emit("connection", ws, req);
|
||||
const budgetClaimed = Boolean(
|
||||
(
|
||||
ws as unknown as import("ws").WebSocket & {
|
||||
__openclawPreauthBudgetClaimed?: boolean;
|
||||
}
|
||||
)["__openclawPreauthBudgetClaimed"],
|
||||
);
|
||||
if (budgetClaimed) {
|
||||
budgetTransferred = true;
|
||||
socket.off("close", releaseUpgradeBudget);
|
||||
}
|
||||
handleBudgetedGatewayWebSocketUpgrade({
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
wss,
|
||||
preauthConnectionBudget,
|
||||
preauthBudgetKey: requestClientIp,
|
||||
ingressName: "Gateway",
|
||||
});
|
||||
} catch {
|
||||
socket.off("close", releaseUpgradeBudget);
|
||||
releaseUpgradeBudget();
|
||||
throw new Error("gateway websocket upgrade failed");
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
@@ -1174,46 +1188,21 @@ export function attachWorkerGatewayUpgradeHandler(params: {
|
||||
log?: { warn: (message: string) => void };
|
||||
}): void {
|
||||
params.httpServer.on("upgrade", (req, socket, head) => {
|
||||
if (isGatewayWorkAdmissionClosed()) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Worker websocket admission closed");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const preauthBudgetKey = req.socket.remoteAddress;
|
||||
if (params.wss.listenerCount("connection") === 0) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Worker websocket handlers unavailable");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!params.preauthConnectionBudget.acquire(preauthBudgetKey)) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
let budgetTransferred = false;
|
||||
const releaseUpgradeBudget = () => {
|
||||
if (budgetTransferred) {
|
||||
return;
|
||||
}
|
||||
budgetTransferred = true;
|
||||
params.preauthConnectionBudget.release(preauthBudgetKey);
|
||||
};
|
||||
socket.once("close", releaseUpgradeBudget);
|
||||
try {
|
||||
params.wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const workerSocket = ws as GatewayIngressWebSocket;
|
||||
workerSocket[GATEWAY_WS_CONNECTION_KIND_PROPERTY] = "worker";
|
||||
workerSocket[GATEWAY_WS_PREAUTH_BUDGET_PROPERTY] = params.preauthConnectionBudget;
|
||||
workerSocket["__openclawPreauthBudgetKey"] = preauthBudgetKey;
|
||||
params.wss.emit("connection", ws, req);
|
||||
if (workerSocket["__openclawPreauthBudgetClaimed"]) {
|
||||
budgetTransferred = true;
|
||||
socket.off("close", releaseUpgradeBudget);
|
||||
}
|
||||
handleBudgetedGatewayWebSocketUpgrade({
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
wss: params.wss,
|
||||
preauthConnectionBudget: params.preauthConnectionBudget,
|
||||
preauthBudgetKey: req.socket.remoteAddress,
|
||||
ingressName: "Worker",
|
||||
prepareSocket: (workerSocket) => {
|
||||
workerSocket[GATEWAY_WS_CONNECTION_KIND_PROPERTY] = "worker";
|
||||
workerSocket[GATEWAY_WS_PREAUTH_BUDGET_PROPERTY] = params.preauthConnectionBudget;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
socket.off("close", releaseUpgradeBudget);
|
||||
releaseUpgradeBudget();
|
||||
params.log?.warn(
|
||||
`worker websocket upgrade failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
@@ -1090,6 +1090,60 @@ async function runNonStreamingChatSend(params: {
|
||||
return chatCall?.[1] as Record<string, any> | undefined;
|
||||
}
|
||||
|
||||
async function expectUnpersistedAgentRunFinal(params: {
|
||||
transcriptPrefix: string;
|
||||
idempotencyKey: string;
|
||||
payload: (typeof mockState.dispatchedReplies)[number]["payload"];
|
||||
staleAudio?: boolean;
|
||||
}) {
|
||||
const transcriptDir = await createTranscriptFixture(params.transcriptPrefix);
|
||||
const staleAudioPath = path.join(transcriptDir, "stale.mp3");
|
||||
mockState.config = { agents: { defaults: { workspace: transcriptDir } } };
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.dispatchedReplies = [
|
||||
{
|
||||
kind: "final",
|
||||
payload: {
|
||||
...params.payload,
|
||||
...(params.staleAudio
|
||||
? {
|
||||
mediaUrl: staleAudioPath,
|
||||
mediaUrls: [staleAudioPath],
|
||||
trustedLocalMedia: true,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
const { send } = createChatRequestFixture();
|
||||
await send({ idempotencyKey: params.idempotencyKey, expectBroadcast: false, waitFor: "dedupe" });
|
||||
|
||||
// Agent-run delivery is a live projection; message_end alone owns persisted assistant turns.
|
||||
expect(findAssistantTranscriptUpdates()).toStrictEqual([]);
|
||||
expect(
|
||||
readTranscriptJsonLines(mockState.transcriptPath).filter(
|
||||
(entry) =>
|
||||
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
|
||||
(entry as { role?: string }).role === "assistant",
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
}
|
||||
|
||||
async function expectImageOnlyFinal(params: {
|
||||
transcriptPrefix: string;
|
||||
idempotencyKey: string;
|
||||
finalPayload: NonNullable<typeof mockState.finalPayload>;
|
||||
}) {
|
||||
await createTranscriptFixture(params.transcriptPrefix);
|
||||
mockState.finalPayload = params.finalPayload;
|
||||
const { send } = createChatRequestFixture();
|
||||
const payload = await send({ idempotencyKey: params.idempotencyKey });
|
||||
const content = getMessageContent(payload);
|
||||
expect(getMessage(payload)?.role).toBe("assistant");
|
||||
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
|
||||
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
|
||||
}
|
||||
|
||||
describe("chat directive tag stripping for non-streaming final payloads", () => {
|
||||
afterEach(() => {
|
||||
mockState.config = {};
|
||||
@@ -2111,85 +2165,22 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
});
|
||||
|
||||
it("does not mirror agent-run stale media final text from live delivery", async () => {
|
||||
const transcriptDir = await createTranscriptFixture("openclaw-chat-send-agent-stale-tts-");
|
||||
const staleAudioPath = path.join(transcriptDir, "stale.mp3");
|
||||
mockState.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: transcriptDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.dispatchedReplies = [
|
||||
{
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "Text-only test: one clean reply, no TTS, no media, no tool narration.",
|
||||
mediaUrl: staleAudioPath,
|
||||
mediaUrls: [staleAudioPath],
|
||||
trustedLocalMedia: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const { send } = createChatRequestFixture();
|
||||
|
||||
await send({
|
||||
await expectUnpersistedAgentRunFinal({
|
||||
transcriptPrefix: "openclaw-chat-send-agent-stale-tts-",
|
||||
idempotencyKey: "idem-stale-agent-media",
|
||||
expectBroadcast: false,
|
||||
waitFor: "dedupe",
|
||||
payload: {
|
||||
text: "Text-only test: one clean reply, no TTS, no media, no tool narration.",
|
||||
},
|
||||
staleAudio: true,
|
||||
});
|
||||
|
||||
const assistantUpdates = findAssistantTranscriptUpdates();
|
||||
// Agent-run delivery is a live projection; message_end owns persisted
|
||||
// assistant transcript entries, including stale media/text final payloads.
|
||||
expect(assistantUpdates).toStrictEqual([]);
|
||||
const transcriptLines = readTranscriptJsonLines(mockState.transcriptPath);
|
||||
const assistantEntries = transcriptLines.filter(
|
||||
(entry) =>
|
||||
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
|
||||
(entry as { role?: string }).role === "assistant",
|
||||
);
|
||||
expect(assistantEntries).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not mirror normal agent-run final text from live delivery", async () => {
|
||||
const transcriptDir = await createTranscriptFixture("openclaw-chat-send-agent-text-only-");
|
||||
mockState.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: transcriptDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.dispatchedReplies = [
|
||||
{
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "It's 11:52 AM EDT.",
|
||||
},
|
||||
},
|
||||
];
|
||||
const { send } = createChatRequestFixture();
|
||||
|
||||
await send({
|
||||
await expectUnpersistedAgentRunFinal({
|
||||
transcriptPrefix: "openclaw-chat-send-agent-text-only-",
|
||||
idempotencyKey: "idem-agent-text-only",
|
||||
expectBroadcast: false,
|
||||
waitFor: "dedupe",
|
||||
payload: { text: "It's 11:52 AM EDT." },
|
||||
});
|
||||
|
||||
const assistantUpdates = findAssistantTranscriptUpdates();
|
||||
// Normal agent-run final text must not be mirrored into JSONL by WebChat;
|
||||
// The agent runtime persists the model-visible assistant turn from message_end.
|
||||
expect(assistantUpdates).toStrictEqual([]);
|
||||
const transcriptLines = readTranscriptJsonLines(mockState.transcriptPath);
|
||||
const assistantEntries = transcriptLines.filter(
|
||||
(entry) =>
|
||||
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
|
||||
(entry as { role?: string }).role === "assistant",
|
||||
);
|
||||
expect(assistantEntries).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("broadcasts agent-run internal-ui source replies without duplicating transcript", async () => {
|
||||
@@ -4819,54 +4810,27 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
});
|
||||
|
||||
it("preserves media-only final replies in the final broadcast message", async () => {
|
||||
await createTranscriptFixture("openclaw-chat-send-media-only-final-");
|
||||
mockState.finalPayload = { mediaUrl: "data:image/png;base64,cG5n" };
|
||||
const { send } = createChatRequestFixture();
|
||||
|
||||
const payload = await send({
|
||||
await expectImageOnlyFinal({
|
||||
transcriptPrefix: "openclaw-chat-send-media-only-final-",
|
||||
idempotencyKey: "idem-media-only-final",
|
||||
finalPayload: { mediaUrl: "data:image/png;base64,cG5n" },
|
||||
});
|
||||
|
||||
const content = getMessageContent(payload);
|
||||
expect(getMessage(payload)?.role).toBe("assistant");
|
||||
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
|
||||
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
|
||||
});
|
||||
|
||||
it("strips NO_REPLY from transcript text when final replies only carry media", async () => {
|
||||
await createTranscriptFixture("openclaw-chat-send-media-only-silent-final-");
|
||||
mockState.finalPayload = {
|
||||
text: "NO_REPLY",
|
||||
mediaUrl: "data:image/png;base64,cG5n",
|
||||
};
|
||||
const { send } = createChatRequestFixture();
|
||||
|
||||
const payload = await send({
|
||||
await expectImageOnlyFinal({
|
||||
transcriptPrefix: "openclaw-chat-send-media-only-silent-final-",
|
||||
idempotencyKey: "idem-media-only-silent-final",
|
||||
finalPayload: { text: "NO_REPLY", mediaUrl: "data:image/png;base64,cG5n" },
|
||||
});
|
||||
|
||||
const content = getMessageContent(payload);
|
||||
expect(getMessage(payload)?.role).toBe("assistant");
|
||||
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
|
||||
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
|
||||
});
|
||||
|
||||
it("preserves reply tags in transcript updates for media replies while stripping them from the broadcast", async () => {
|
||||
await createTranscriptFixture("openclaw-chat-send-media-reply-tags-");
|
||||
mockState.finalPayload = {
|
||||
replyToCurrent: true,
|
||||
mediaUrl: "data:image/png;base64,cG5n",
|
||||
};
|
||||
const { send } = createChatRequestFixture();
|
||||
|
||||
const payload = await send({
|
||||
await expectImageOnlyFinal({
|
||||
transcriptPrefix: "openclaw-chat-send-media-reply-tags-",
|
||||
idempotencyKey: "idem-media-reply-tags",
|
||||
finalPayload: { replyToCurrent: true, mediaUrl: "data:image/png;base64,cG5n" },
|
||||
});
|
||||
|
||||
const content = getMessageContent(payload);
|
||||
expect(getMessage(payload)?.role).toBe("assistant");
|
||||
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
|
||||
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
|
||||
const transcriptUpdate = mockState.emittedTranscriptUpdates.find(
|
||||
(update) =>
|
||||
typeof update.message === "object" &&
|
||||
|
||||
@@ -2802,6 +2802,97 @@ describe("exec approval handlers", () => {
|
||||
return getRequestedExecApprovalPayload(broadcasts);
|
||||
}
|
||||
|
||||
async function startAcceptedExecApproval(params: {
|
||||
handlers: ExecApprovalHandlers;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
context: ReturnType<typeof createExecApprovalFixture>["context"];
|
||||
request: Record<string, unknown>;
|
||||
client?: ExecApprovalRequestArgs["client"];
|
||||
}) {
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers: params.handlers,
|
||||
respond: params.respond,
|
||||
context: params.context,
|
||||
params: params.request,
|
||||
client: params.client,
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(params.respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
return { requestPromise };
|
||||
}
|
||||
|
||||
async function expectRejectedExecApprovalRequest(
|
||||
params: Record<string, unknown>,
|
||||
message: string,
|
||||
) {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
await requestExecApproval({ handlers, respond, context, params });
|
||||
expect(mockCallArg(respond)).toBe(false);
|
||||
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(respond, 0, 2), { message });
|
||||
}
|
||||
|
||||
async function expectUnavailableAllowAlways(
|
||||
requestParams: Record<string, unknown>,
|
||||
fallbackDecision: "allow-once" | "deny",
|
||||
) {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: requestParams,
|
||||
});
|
||||
const { id } = await waitForRequestedExecApprovalPayload(broadcasts);
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
decision: "allow-always",
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
expect(mockCallArg(resolveRespond)).toBe(false);
|
||||
expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
|
||||
message: "allow-always is unavailable for this command",
|
||||
});
|
||||
|
||||
const fallbackRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
decision: fallbackDecision,
|
||||
respond: fallbackRespond,
|
||||
context,
|
||||
});
|
||||
await requestPromise;
|
||||
expect(fallbackRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
}
|
||||
|
||||
async function expectDroppedApprovalCommandSpans(config?: OpenClawConfig) {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture(
|
||||
config ? { config } : undefined,
|
||||
);
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
timeoutMs: 10,
|
||||
command: "ls | python -c 'print(1)'",
|
||||
commandSpans: [
|
||||
{ startIndex: 0, endIndex: 2 },
|
||||
{ startIndex: 5, endIndex: 11 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { request } = getRequestedExecApprovalPayload(broadcasts);
|
||||
expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
|
||||
expect(request["commandSpans"]).toBeUndefined();
|
||||
}
|
||||
|
||||
function createForwardingExecApprovalFixture(opts?: {
|
||||
iosPushDelivery?: {
|
||||
handleRequested: ReturnType<typeof vi.fn>;
|
||||
@@ -2879,55 +2970,29 @@ describe("exec approval handlers", () => {
|
||||
});
|
||||
|
||||
it("rejects host=node approval requests without nodeId", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
nodeId: undefined,
|
||||
},
|
||||
});
|
||||
expect(mockCallArg(respond)).toBe(false);
|
||||
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(respond, 0, 2), {
|
||||
message: "nodeId is required for host=node",
|
||||
});
|
||||
await expectRejectedExecApprovalRequest(
|
||||
{ nodeId: undefined },
|
||||
"nodeId is required for host=node",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects host=node approval requests without systemRunPlan", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
systemRunPlan: undefined,
|
||||
},
|
||||
});
|
||||
expect(mockCallArg(respond)).toBe(false);
|
||||
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(respond, 0, 2), {
|
||||
message: "systemRunPlan is required for host=node",
|
||||
});
|
||||
await expectRejectedExecApprovalRequest(
|
||||
{ systemRunPlan: undefined },
|
||||
"systemRunPlan is required for host=node",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only approval commands without trimming display text", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
await expectRejectedExecApprovalRequest(
|
||||
{
|
||||
command: " ",
|
||||
host: "gateway",
|
||||
nodeId: undefined,
|
||||
systemRunPlan: undefined,
|
||||
},
|
||||
});
|
||||
expect(mockCallArg(respond)).toBe(false);
|
||||
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(respond, 0, 2), { message: "command is required" });
|
||||
"command is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects approval requests when the command display would be truncated", async () => {
|
||||
@@ -3128,11 +3193,11 @@ describe("exec approval handlers", () => {
|
||||
|
||||
it("lists pending exec approvals", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-list-1",
|
||||
twoPhase: true,
|
||||
host: "gateway",
|
||||
@@ -3140,9 +3205,6 @@ describe("exec approval handlers", () => {
|
||||
nodeId: undefined,
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
const listRespond = vi.fn();
|
||||
await listExecApprovals({ handlers, respond: listRespond });
|
||||
@@ -3256,20 +3318,17 @@ describe("exec approval handlers", () => {
|
||||
scopes: ["operator.approvals"],
|
||||
});
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-reviewer-untrusted",
|
||||
twoPhase: true,
|
||||
approvalReviewerDeviceIds: ["device-ios-reviewer"],
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
expect(
|
||||
manager.getSnapshot("approval-reviewer-untrusted")?.approvalReviewerDeviceIds,
|
||||
@@ -3317,20 +3376,17 @@ describe("exec approval handlers", () => {
|
||||
scopes: ["operator.approvals"],
|
||||
});
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-reviewer-runtime",
|
||||
twoPhase: true,
|
||||
approvalReviewerDeviceIds: ["device-ios-reviewer"],
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
expect(manager.getSnapshot("approval-reviewer-runtime")?.approvalReviewerDeviceIds).toEqual([
|
||||
"device-ios-reviewer",
|
||||
@@ -3389,20 +3445,17 @@ describe("exec approval handlers", () => {
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-reviewer-runtime-admin",
|
||||
twoPhase: true,
|
||||
approvalReviewerDeviceIds: ["device-ios-reviewer"],
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
@@ -3435,20 +3488,17 @@ describe("exec approval handlers", () => {
|
||||
approvalRuntime: true,
|
||||
});
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-reviewer-runtime-runtime",
|
||||
twoPhase: true,
|
||||
approvalReviewerDeviceIds: ["device-ios-reviewer"],
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
@@ -3484,12 +3534,12 @@ describe("exec approval handlers", () => {
|
||||
approvalRuntime: true,
|
||||
agentRuntimeIdentity: { agentId: "main", sessionKey: "agent:main:main" },
|
||||
});
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-auto-review",
|
||||
twoPhase: true,
|
||||
systemRunPlan: {
|
||||
@@ -3498,9 +3548,6 @@ describe("exec approval handlers", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
@@ -3535,15 +3582,12 @@ describe("exec approval handlers", () => {
|
||||
approvalRuntime: true,
|
||||
agentRuntimeIdentity: { agentId: "other", sessionKey: "agent:other:main" },
|
||||
});
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: { id: "approval-auto-review-mismatch", twoPhase: true },
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
request: { id: "approval-auto-review-mismatch", twoPhase: true },
|
||||
});
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
@@ -3582,20 +3626,17 @@ describe("exec approval handlers", () => {
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
const { requestPromise } = await startAcceptedExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
client: requesterClient,
|
||||
params: {
|
||||
request: {
|
||||
id: "approval-reviewer-runtime-no-scope",
|
||||
twoPhase: true,
|
||||
approvalReviewerDeviceIds: ["device-ios-reviewer"],
|
||||
},
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
|
||||
});
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
@@ -3738,84 +3779,14 @@ describe("exec approval handlers", () => {
|
||||
});
|
||||
|
||||
it("rejects allow-always when the request ask mode is always", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { twoPhase: true, ask: "always" },
|
||||
});
|
||||
const { id } = await waitForRequestedExecApprovalPayload(broadcasts);
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
decision: "allow-always",
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
|
||||
expect(mockCallArg(resolveRespond)).toBe(false);
|
||||
expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
|
||||
message: "allow-always is unavailable for this command",
|
||||
});
|
||||
|
||||
const denyRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
decision: "deny",
|
||||
respond: denyRespond,
|
||||
context,
|
||||
});
|
||||
|
||||
await requestPromise;
|
||||
expect(denyRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
await expectUnavailableAllowAlways({ twoPhase: true, ask: "always" }, "deny");
|
||||
});
|
||||
|
||||
it("rejects allow-always when the request marks it unavailable", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
twoPhase: true,
|
||||
unavailableDecisions: ["allow-always"],
|
||||
},
|
||||
});
|
||||
const { id } = await waitForRequestedExecApprovalPayload(broadcasts);
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
decision: "allow-always",
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
|
||||
expect(mockCallArg(resolveRespond)).toBe(false);
|
||||
expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
|
||||
message: "allow-always is unavailable for this command",
|
||||
});
|
||||
|
||||
const allowOnceRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
decision: "allow-once",
|
||||
respond: allowOnceRespond,
|
||||
context,
|
||||
});
|
||||
|
||||
await requestPromise;
|
||||
expect(allowOnceRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
await expectUnavailableAllowAlways(
|
||||
{ twoPhase: true, unavailableDecisions: ["allow-always"] },
|
||||
"allow-once",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps baseline decisions available when allow-always is unavailable", async () => {
|
||||
@@ -4093,45 +4064,13 @@ describe("exec approval handlers", () => {
|
||||
});
|
||||
|
||||
it("drops command spans by default", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
timeoutMs: 10,
|
||||
command: "ls | python -c 'print(1)'",
|
||||
commandSpans: [
|
||||
{ startIndex: 0, endIndex: 2 },
|
||||
{ startIndex: 5, endIndex: 11 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { request } = getRequestedExecApprovalPayload(broadcasts);
|
||||
expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
|
||||
expect(request["commandSpans"]).toBeUndefined();
|
||||
await expectDroppedApprovalCommandSpans();
|
||||
});
|
||||
|
||||
it("drops command spans when command highlighting is disabled", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
|
||||
config: { tools: { exec: { commandHighlighting: false } } },
|
||||
await expectDroppedApprovalCommandSpans({
|
||||
tools: { exec: { commandHighlighting: false } },
|
||||
});
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
timeoutMs: 10,
|
||||
command: "ls | python -c 'print(1)'",
|
||||
commandSpans: [
|
||||
{ startIndex: 0, endIndex: 2 },
|
||||
{ startIndex: 5, endIndex: 11 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { request } = getRequestedExecApprovalPayload(broadcasts);
|
||||
expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
|
||||
expect(request["commandSpans"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops command spans when command display sanitization changes offsets", async () => {
|
||||
|
||||
@@ -20,9 +20,105 @@ import {
|
||||
loadAccessorSessionEntryForGatewayTarget,
|
||||
requireSessionKey,
|
||||
} from "./sessions-shared.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
function respondInvalidWorkerSession(respond: RespondFn, message: string): void {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, message));
|
||||
}
|
||||
|
||||
function resolveWorkerSessionTarget(params: {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
profileId?: string;
|
||||
context: GatewayRequestContext;
|
||||
respond: RespondFn;
|
||||
}) {
|
||||
const cfg = params.context.getRuntimeConfig();
|
||||
const requestedAgent = resolveRequestedGlobalAgentId(cfg, params.key, params.agentId);
|
||||
if (!requestedAgent.ok) {
|
||||
params.respond(false, undefined, requestedAgent.error);
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
params.profileId !== undefined &&
|
||||
!Object.hasOwn(cfg.cloudWorkers?.profiles ?? {}, params.profileId)
|
||||
) {
|
||||
respondInvalidWorkerSession(
|
||||
params.respond,
|
||||
`cloud worker profile is not configured: ${params.profileId}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const target = loadAccessorSessionEntryForGatewayTarget({
|
||||
key: params.key,
|
||||
cfg,
|
||||
agentId: requestedAgent.agentId,
|
||||
});
|
||||
const entry = target.entry;
|
||||
const sessionId = normalizeOptionalString(entry?.sessionId);
|
||||
if (!entry || !sessionId) {
|
||||
respondInvalidWorkerSession(params.respond, `session not found: ${params.key}`);
|
||||
return undefined;
|
||||
}
|
||||
return { cfg, target, entry, sessionId };
|
||||
}
|
||||
|
||||
function hasManagedSessionWorktree(params: {
|
||||
entry: NonNullable<ReturnType<typeof loadAccessorSessionEntryForGatewayTarget>["entry"]>;
|
||||
sessionKey: string;
|
||||
method: "sessions.dispatch" | "sessions.reclaim";
|
||||
respond: RespondFn;
|
||||
}): boolean {
|
||||
const worktree = managedWorktrees.findLiveByOwner("session", params.sessionKey);
|
||||
if (
|
||||
params.entry.worktree?.id &&
|
||||
worktree &&
|
||||
worktree.id === params.entry.worktree.id &&
|
||||
worktree.ownerId === params.sessionKey
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const article = params.method === "sessions.dispatch" ? "a" : "the";
|
||||
respondInvalidWorkerSession(
|
||||
params.respond,
|
||||
`${params.method} requires ${article} session-owned managed worktree`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function respondWorkerPlacement(params: {
|
||||
respond: RespondFn;
|
||||
key: string;
|
||||
sessionId: string;
|
||||
placement: Parameters<typeof projectWorkerSessionPlacement>[0];
|
||||
}): void {
|
||||
params.respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: params.key,
|
||||
sessionId: params.sessionId,
|
||||
placement: projectWorkerSessionPlacement(params.placement),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function respondWorkerDispatchError(error: unknown, respond: RespondFn): void {
|
||||
if (error instanceof SessionMutationAuthorizationChangedError) {
|
||||
throw error;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
|
||||
formatErrorMessage(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
"sessions.dispatch": async ({ params, respond, context, sessionMutationAuthorization }) => {
|
||||
if (!assertValidParams(params, validateSessionsDispatchParams, "sessions.dispatch", respond)) {
|
||||
@@ -35,51 +131,22 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
const dispatchService = context.workerPlacementDispatchService;
|
||||
const placementReader = context.workerSessionPlacementService;
|
||||
if (!dispatchService || !placementReader) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "cloud worker dispatch is not configured"),
|
||||
);
|
||||
respondInvalidWorkerSession(respond, "cloud worker dispatch is not configured");
|
||||
return;
|
||||
}
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, params.agentId);
|
||||
if (!requestedAgent.ok) {
|
||||
respond(false, undefined, requestedAgent.error);
|
||||
return;
|
||||
}
|
||||
if (!Object.hasOwn(cfg.cloudWorkers?.profiles ?? {}, params.profileId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`cloud worker profile is not configured: ${params.profileId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const target = loadAccessorSessionEntryForGatewayTarget({
|
||||
const resolved = resolveWorkerSessionTarget({
|
||||
key,
|
||||
cfg,
|
||||
agentId: requestedAgent.agentId,
|
||||
agentId: params.agentId,
|
||||
profileId: params.profileId,
|
||||
context,
|
||||
respond,
|
||||
});
|
||||
const entry = target.entry;
|
||||
const sessionId = normalizeOptionalString(entry?.sessionId);
|
||||
if (!entry || !sessionId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
|
||||
);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { cfg, target, entry, sessionId } = resolved;
|
||||
if (entry.archivedAt !== undefined) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "cannot dispatch an archived session"),
|
||||
);
|
||||
respondInvalidWorkerSession(respond, "cannot dispatch an archived session");
|
||||
return;
|
||||
}
|
||||
const sessionRuntime = resolveWorkerPlacementSessionRuntime({
|
||||
@@ -89,13 +156,9 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: target.canonicalKey,
|
||||
});
|
||||
if (!isWorkerPlacementSessionRuntimeSupported(sessionRuntime)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`cloud worker dispatch requires the OpenClaw runtime, not ${sessionRuntime}`,
|
||||
),
|
||||
respondInvalidWorkerSession(
|
||||
respond,
|
||||
`cloud worker dispatch requires the OpenClaw runtime, not ${sessionRuntime}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -105,31 +168,20 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
existingPlacement.state !== "local" &&
|
||||
existingPlacement.state !== "reclaimed"
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`session cannot dispatch from placement ${existingPlacement.state}`,
|
||||
),
|
||||
respondInvalidWorkerSession(
|
||||
respond,
|
||||
`session cannot dispatch from placement ${existingPlacement.state}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey);
|
||||
if (
|
||||
!target.entry?.worktree?.id ||
|
||||
!worktree ||
|
||||
worktree.id !== target.entry.worktree.id ||
|
||||
worktree.ownerId !== target.canonicalKey
|
||||
!hasManagedSessionWorktree({
|
||||
entry,
|
||||
sessionKey: target.canonicalKey,
|
||||
method: "sessions.dispatch",
|
||||
respond,
|
||||
})
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"sessions.dispatch requires a session-owned managed worktree",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -142,28 +194,9 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
agentId: target.target.agentId,
|
||||
profileId: params.profileId,
|
||||
});
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: target.canonicalKey,
|
||||
sessionId,
|
||||
placement: projectWorkerSessionPlacement(placement),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
respondWorkerPlacement({ respond, key: target.canonicalKey, sessionId, placement });
|
||||
} catch (error) {
|
||||
if (error instanceof SessionMutationAuthorizationChangedError) {
|
||||
throw error;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
|
||||
formatErrorMessage(error),
|
||||
),
|
||||
);
|
||||
respondWorkerDispatchError(error, respond);
|
||||
}
|
||||
},
|
||||
"sessions.reclaim": async ({ params, respond, context, sessionMutationAuthorization }) => {
|
||||
@@ -177,73 +210,44 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
const placementService = context.workerPlacementDispatchService;
|
||||
const placementReader = context.workerSessionPlacementService;
|
||||
if (!placementService?.reclaim || !placementReader) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "cloud worker stop is not configured"),
|
||||
);
|
||||
respondInvalidWorkerSession(respond, "cloud worker stop is not configured");
|
||||
return;
|
||||
}
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, params.agentId);
|
||||
if (!requestedAgent.ok) {
|
||||
respond(false, undefined, requestedAgent.error);
|
||||
return;
|
||||
}
|
||||
const target = loadAccessorSessionEntryForGatewayTarget({
|
||||
const resolved = resolveWorkerSessionTarget({
|
||||
key,
|
||||
cfg,
|
||||
agentId: requestedAgent.agentId,
|
||||
agentId: params.agentId,
|
||||
context,
|
||||
respond,
|
||||
});
|
||||
const sessionId = normalizeOptionalString(target.entry?.sessionId);
|
||||
if (!target.entry || !sessionId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
|
||||
);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { target, entry, sessionId } = resolved;
|
||||
const existingPlacement = placementReader.getMany([sessionId]).get(sessionId);
|
||||
if (existingPlacement?.state === "reclaimed") {
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: target.canonicalKey,
|
||||
sessionId,
|
||||
placement: projectWorkerSessionPlacement(existingPlacement),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
respondWorkerPlacement({
|
||||
respond,
|
||||
key: target.canonicalKey,
|
||||
sessionId,
|
||||
placement: existingPlacement,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (existingPlacement?.state !== "active") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`session cannot stop cloud worker from placement ${existingPlacement?.state ?? "local"}`,
|
||||
),
|
||||
respondInvalidWorkerSession(
|
||||
respond,
|
||||
`session cannot stop cloud worker from placement ${existingPlacement?.state ?? "local"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey);
|
||||
if (
|
||||
!target.entry.worktree?.id ||
|
||||
!worktree ||
|
||||
worktree.id !== target.entry.worktree.id ||
|
||||
worktree.ownerId !== target.canonicalKey
|
||||
!hasManagedSessionWorktree({
|
||||
entry,
|
||||
sessionKey: target.canonicalKey,
|
||||
method: "sessions.reclaim",
|
||||
respond,
|
||||
})
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"sessions.reclaim requires the session-owned managed worktree",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -253,28 +257,9 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: target.canonicalKey,
|
||||
agentId: target.target.agentId,
|
||||
});
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: target.canonicalKey,
|
||||
sessionId,
|
||||
placement: projectWorkerSessionPlacement(placement),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
respondWorkerPlacement({ respond, key: target.canonicalKey, sessionId, placement });
|
||||
} catch (error) {
|
||||
if (error instanceof SessionMutationAuthorizationChangedError) {
|
||||
throw error;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
|
||||
formatErrorMessage(error),
|
||||
),
|
||||
);
|
||||
respondWorkerDispatchError(error, respond);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
writeSessionCostUsageRollup,
|
||||
} from "./session-cost-usage-cache.sqlite.js";
|
||||
import {
|
||||
listUsageCountedTranscriptFiles,
|
||||
listUsageCountedTranscriptStats,
|
||||
resolveUsageCostTranscriptFile,
|
||||
type UsageCostTranscriptFile,
|
||||
} from "./session-cost-usage-collection.js";
|
||||
@@ -590,7 +590,7 @@ export async function refreshCostUsageCacheForAgent(params: {
|
||||
const rows = readSessionCostUsageRollupRows(params.agentId, databasePath);
|
||||
const rawValues = new Map(rows.map((row) => [row.key, row.valueJson]));
|
||||
const rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath, rows);
|
||||
const discoveredFiles = await listUsageCountedTranscriptFiles(
|
||||
const discoveredFiles = await listUsageCountedTranscriptStats(
|
||||
params.agentId,
|
||||
params.sessionsDir ? { sessionsDir: params.sessionsDir } : undefined,
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "./session-cost-usage-aggregation.js";
|
||||
import { isSessionCostUsageRefreshRunning } from "./session-cost-usage-cache.sqlite.js";
|
||||
import {
|
||||
listUsageCountedTranscriptFiles,
|
||||
listUsageCountedTranscriptStats,
|
||||
resolveUsageCostTranscriptFile,
|
||||
USAGE_COST_TRANSCRIPT_STAT_CONCURRENCY,
|
||||
} from "./session-cost-usage-collection.js";
|
||||
@@ -72,7 +72,7 @@ export async function loadCostUsageSummary(params: {
|
||||
});
|
||||
const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config, agentDir);
|
||||
const rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath);
|
||||
const files = await listUsageCountedTranscriptFiles(params.agentId);
|
||||
const files = await listUsageCountedTranscriptStats(params.agentId);
|
||||
return buildCostUsageSummaryFromRollups({
|
||||
rollups,
|
||||
files,
|
||||
@@ -99,7 +99,7 @@ export async function loadCostUsageSummaryFromCache(params: {
|
||||
const databasePath = resolveUsageCostCacheDatabasePath(params.agentId);
|
||||
const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config, agentDir);
|
||||
let rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath);
|
||||
let files = await listUsageCountedTranscriptFiles(params.agentId);
|
||||
let files = await listUsageCountedTranscriptStats(params.agentId);
|
||||
const staleFiles = getUsageCostStaleRollupFiles({ rollups, files });
|
||||
if (params.requestRefresh !== false && staleFiles.length > 0) {
|
||||
const cachedFiles = countUsableUsageCostRollups({ rollups, files });
|
||||
@@ -111,7 +111,7 @@ export async function loadCostUsageSummaryFromCache(params: {
|
||||
startMs: params.startMs,
|
||||
});
|
||||
rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath);
|
||||
files = await listUsageCountedTranscriptFiles(params.agentId);
|
||||
files = await listUsageCountedTranscriptStats(params.agentId);
|
||||
if (result === "refreshed" && getUsageCostStaleRollupFiles({ rollups, files }).length > 0) {
|
||||
requestCostUsageCacheRefresh({ config: params.config, agentId: params.agentId });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
materializeSessionArchiveForRead,
|
||||
@@ -29,18 +29,12 @@ import {
|
||||
readTranscriptStatsSync,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js";
|
||||
import { selectVisibleTranscriptEvents } from "../config/sessions/transcript-visible-events.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js";
|
||||
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
|
||||
import {
|
||||
createUsageCostResolver,
|
||||
parseUsageCostTranscriptEntry,
|
||||
type UsageCostResolver,
|
||||
} from "./session-cost-usage-pricing.js";
|
||||
import type { ParsedUsageEntry } from "./session-cost-usage.types.js";
|
||||
|
||||
export const USAGE_COST_TRANSCRIPT_STAT_CONCURRENCY = 32;
|
||||
|
||||
@@ -181,13 +175,6 @@ function formatCanonicalUsageCostSqliteMarker(marker: SqliteSessionFileMarker):
|
||||
return formatSqliteSessionFileMarker({ ...marker, storePath });
|
||||
}
|
||||
|
||||
export async function listUsageCountedTranscriptFiles(
|
||||
agentId: string,
|
||||
params?: { sessionsDir?: string },
|
||||
): Promise<UsageCostTranscriptFile[]> {
|
||||
return await listUsageCountedTranscriptStats(agentId, params);
|
||||
}
|
||||
|
||||
export async function listUsageCountedTranscriptStats(
|
||||
agentId: string,
|
||||
params?: { minMtimeMs?: number; sessionsDir?: string },
|
||||
@@ -252,45 +239,6 @@ export async function resolveUsageCostTranscriptFile(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function* readJsonlRecords(
|
||||
filePath: string,
|
||||
startOffset = 0,
|
||||
endOffset?: number,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
if (endOffset !== undefined && endOffset <= startOffset) {
|
||||
return;
|
||||
}
|
||||
const streamOptions: Parameters<typeof fs.createReadStream>[1] = {
|
||||
encoding: "utf-8",
|
||||
start: Math.max(0, startOffset),
|
||||
};
|
||||
if (endOffset !== undefined) {
|
||||
streamOptions.end = endOffset - 1;
|
||||
}
|
||||
const fileStream = fs.createReadStream(filePath, streamOptions);
|
||||
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
continue;
|
||||
}
|
||||
yield parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
fileStream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function loadSqliteUsageTranscriptEvents(
|
||||
marker: SqliteSessionFileMarker,
|
||||
): Record<string, unknown>[] {
|
||||
@@ -300,16 +248,11 @@ function loadSqliteUsageTranscriptEvents(
|
||||
sessionId: marker.sessionId,
|
||||
storePath: marker.storePath,
|
||||
}),
|
||||
).filter(
|
||||
(event): event is Record<string, unknown> =>
|
||||
Boolean(event) && typeof event === "object" && !Array.isArray(event),
|
||||
);
|
||||
).filter(isRecord);
|
||||
}
|
||||
|
||||
export async function* readTranscriptRecords(
|
||||
filePath: string,
|
||||
startOffset = 0,
|
||||
endOffset?: number,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
const marker = parseSqliteSessionFileMarker(filePath);
|
||||
if (marker) {
|
||||
@@ -318,14 +261,21 @@ export async function* readTranscriptRecords(
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Discovery normalizes compressed archives to their materialized cache, so
|
||||
// this branch only serves direct callers that pass a raw .zst path; those
|
||||
// callers never carry persisted offsets, keeping the range space coherent.
|
||||
if (filePath.endsWith(SESSION_ARCHIVE_ZSTD_SUFFIX)) {
|
||||
yield* readJsonlRecords(materializeSessionArchiveForRead(filePath), startOffset, endOffset);
|
||||
return;
|
||||
// Durable byte-offset scans own their checkpoint reader. Diagnostic history
|
||||
// shares the canonical transcript stream and materializes archive bytes once.
|
||||
const transcriptPath = filePath.endsWith(SESSION_ARCHIVE_ZSTD_SUFFIX)
|
||||
? materializeSessionArchiveForRead(filePath)
|
||||
: filePath;
|
||||
for await (const line of streamSessionTranscriptLines(transcriptPath)) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (isRecord(parsed)) {
|
||||
yield parsed;
|
||||
}
|
||||
} catch {
|
||||
// Historical transcripts can contain malformed records.
|
||||
}
|
||||
}
|
||||
yield* readJsonlRecords(filePath, startOffset, endOffset);
|
||||
}
|
||||
|
||||
export async function* readTranscriptRecordsBestEffort(
|
||||
@@ -339,35 +289,6 @@ export async function* readTranscriptRecordsBestEffort(
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanUsageFile(params: {
|
||||
filePath: string;
|
||||
config?: OpenClawConfig;
|
||||
resolveCost?: UsageCostResolver;
|
||||
startOffset?: number;
|
||||
endOffset?: number;
|
||||
onEntry: (entry: ParsedUsageEntry) => void;
|
||||
}): Promise<void> {
|
||||
const resolveCost = params.resolveCost ?? createUsageCostResolver({ config: params.config });
|
||||
for await (const parsed of readTranscriptRecords(
|
||||
params.filePath,
|
||||
params.startOffset,
|
||||
params.endOffset,
|
||||
)) {
|
||||
const entry = parseUsageCostTranscriptEntry(parsed, resolveCost);
|
||||
if (!entry?.usage) {
|
||||
continue;
|
||||
}
|
||||
params.onEntry({
|
||||
usage: entry.usage,
|
||||
costTotal: entry.costTotal,
|
||||
costBreakdown: entry.costBreakdown,
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
timestamp: entry.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExistingUsageSessionFile(params: {
|
||||
sessionId?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
|
||||
@@ -27,13 +27,13 @@ import {
|
||||
readTranscriptRecordsBestEffort,
|
||||
resolveExistingUsageSessionFile,
|
||||
resolveUsageCostTranscriptFile,
|
||||
scanUsageFile,
|
||||
} from "./session-cost-usage-collection.js";
|
||||
import {
|
||||
computeUsageTokenTotals,
|
||||
createUsageCostResolver,
|
||||
extractCostBreakdown,
|
||||
parseTimestamp,
|
||||
parseUsageCostTranscriptEntry,
|
||||
shouldRecomputeRecordedZeroCost,
|
||||
} from "./session-cost-usage-pricing.js";
|
||||
import { createUsageDayKeyFormatter } from "./session-cost-usage-projection.js";
|
||||
@@ -234,32 +234,25 @@ export async function loadSessionUsageTimeSeries(params: {
|
||||
const agentDir = resolveUsageCostAgentDir(params.config, params.agentId);
|
||||
const resolveCost = createUsageCostResolver({ config: params.config, agentDir });
|
||||
|
||||
await scanUsageFile({
|
||||
filePath: sessionFile,
|
||||
config: params.config,
|
||||
resolveCost,
|
||||
onEntry: (entry) => {
|
||||
const ts = entry.timestamp?.getTime();
|
||||
if (!ts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { input, output, cacheRead, cacheWrite, totalTokens } = computeUsageTokenTotals(
|
||||
entry.usage,
|
||||
);
|
||||
const cost = entry.costTotal ?? 0;
|
||||
|
||||
points.push({
|
||||
timestamp: ts,
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens,
|
||||
cost,
|
||||
});
|
||||
},
|
||||
});
|
||||
for await (const record of readTranscriptRecords(sessionFile)) {
|
||||
const entry = parseUsageCostTranscriptEntry(record, resolveCost);
|
||||
const timestamp = entry?.timestamp?.getTime();
|
||||
if (!entry?.usage || !timestamp) {
|
||||
continue;
|
||||
}
|
||||
const { input, output, cacheRead, cacheWrite, totalTokens } = computeUsageTokenTotals(
|
||||
entry.usage,
|
||||
);
|
||||
points.push({
|
||||
timestamp,
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens,
|
||||
cost: entry.costTotal ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
let cumulativeTokens = 0;
|
||||
|
||||
@@ -8,15 +8,6 @@ import type {
|
||||
|
||||
export type CostBreakdown = Partial<Usage["cost"]>;
|
||||
|
||||
export type ParsedUsageEntry = {
|
||||
usage: NormalizedUsage;
|
||||
costTotal?: number;
|
||||
costBreakdown?: CostBreakdown;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
timestamp?: Date;
|
||||
};
|
||||
|
||||
export type ParsedTranscriptEntry = {
|
||||
message: Record<string, unknown>;
|
||||
role?: "user" | "assistant";
|
||||
|
||||
@@ -151,18 +151,23 @@ function normalizeRestartRecoveryFields(entry: SessionEntry): SessionEntry {
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
|
||||
if (entry.pluginExtensions === undefined) {
|
||||
function normalizeLegacyPluginState(
|
||||
entry: SessionEntry,
|
||||
key: "pluginExtensions" | "pluginExtensionSlotKeys",
|
||||
normalizeValue: (value: unknown) => PluginJsonValue | undefined,
|
||||
): SessionEntry {
|
||||
const state = entry[key];
|
||||
if (state === undefined) {
|
||||
return entry;
|
||||
}
|
||||
if (!isRecord(entry.pluginExtensions)) {
|
||||
if (!isRecord(state)) {
|
||||
const next = { ...entry };
|
||||
delete next.pluginExtensions;
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
let changed = false;
|
||||
const normalizedExtensions: Record<string, Record<string, PluginJsonValue>> = {};
|
||||
for (const [rawPluginId, rawPluginState] of Object.entries(entry.pluginExtensions)) {
|
||||
const normalizedState: Record<string, Record<string, PluginJsonValue>> = {};
|
||||
for (const [rawPluginId, rawPluginState] of Object.entries(state)) {
|
||||
const pluginId = normalizeRecordKey(rawPluginId);
|
||||
if (!pluginId || !isRecord(rawPluginState)) {
|
||||
changed = true;
|
||||
@@ -172,76 +177,43 @@ function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
|
||||
const normalizedPluginState: Record<string, PluginJsonValue> = {};
|
||||
for (const [rawNamespace, rawValue] of Object.entries(rawPluginState)) {
|
||||
const namespace = normalizeRecordKey(rawNamespace);
|
||||
if (!namespace || !isPluginJsonValue(rawValue)) {
|
||||
const value = normalizeValue(rawValue);
|
||||
if (!namespace || value === undefined) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
changed ||= namespace !== rawNamespace;
|
||||
normalizedPluginState[namespace] = rawValue;
|
||||
changed ||= namespace !== rawNamespace || value !== rawValue;
|
||||
normalizedPluginState[namespace] = value;
|
||||
}
|
||||
if (Object.keys(normalizedPluginState).length === 0) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
normalizedExtensions[pluginId] = normalizedPluginState;
|
||||
normalizedState[pluginId] = normalizedPluginState;
|
||||
}
|
||||
if (!changed) {
|
||||
return entry;
|
||||
}
|
||||
const next = { ...entry };
|
||||
if (Object.keys(normalizedExtensions).length > 0) {
|
||||
next.pluginExtensions = normalizedExtensions;
|
||||
if (Object.keys(normalizedState).length > 0) {
|
||||
Object.assign(next, { [key]: normalizedState });
|
||||
} else {
|
||||
delete next.pluginExtensions;
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
|
||||
return normalizeLegacyPluginState(entry, "pluginExtensions", (value) =>
|
||||
isPluginJsonValue(value) ? value : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginExtensionSlotKeys(entry: SessionEntry): SessionEntry {
|
||||
if (entry.pluginExtensionSlotKeys === undefined) {
|
||||
return entry;
|
||||
}
|
||||
if (!isRecord(entry.pluginExtensionSlotKeys)) {
|
||||
const next = { ...entry };
|
||||
delete next.pluginExtensionSlotKeys;
|
||||
return next;
|
||||
}
|
||||
let changed = false;
|
||||
const normalizedSlotKeys: Record<string, Record<string, string>> = {};
|
||||
for (const [rawPluginId, rawPluginSlots] of Object.entries(entry.pluginExtensionSlotKeys)) {
|
||||
const pluginId = normalizeRecordKey(rawPluginId);
|
||||
if (!pluginId || !isRecord(rawPluginSlots)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
changed ||= pluginId !== rawPluginId;
|
||||
const normalizedPluginSlots: Record<string, string> = {};
|
||||
for (const [rawNamespace, rawSlotKey] of Object.entries(rawPluginSlots)) {
|
||||
const namespace = normalizeRecordKey(rawNamespace);
|
||||
const slotKey = normalizeSessionEntrySlotKey(rawSlotKey);
|
||||
if (!namespace || !slotKey.ok) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
changed ||= namespace !== rawNamespace || slotKey.key !== rawSlotKey;
|
||||
normalizedPluginSlots[namespace] = slotKey.key;
|
||||
}
|
||||
if (Object.keys(normalizedPluginSlots).length === 0) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
normalizedSlotKeys[pluginId] = normalizedPluginSlots;
|
||||
}
|
||||
if (!changed) {
|
||||
return entry;
|
||||
}
|
||||
const next = { ...entry };
|
||||
if (Object.keys(normalizedSlotKeys).length > 0) {
|
||||
next.pluginExtensionSlotKeys = normalizedSlotKeys;
|
||||
} else {
|
||||
delete next.pluginExtensionSlotKeys;
|
||||
}
|
||||
return next;
|
||||
return normalizeLegacyPluginState(entry, "pluginExtensionSlotKeys", (value) => {
|
||||
const slotKey = normalizeSessionEntrySlotKey(value);
|
||||
return slotKey.ok ? slotKey.key : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
|
||||
|
||||
@@ -674,6 +674,44 @@ describe("migrateOrphanedSessionKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "commented JSON5 with trailing commas",
|
||||
raw: `// operator-authored session history\n{\n 'agent:ops:work': { sessionId: 'abc-123', updatedAt: 1000, },\n}\n`,
|
||||
},
|
||||
{
|
||||
name: "an escaped canonical session key",
|
||||
raw: '{"agent:ops:w\\u006frk":{"sessionId":"abc-123","updatedAt":1000}}\n',
|
||||
},
|
||||
])("preserves the exact bytes of $name", async ({ raw }) => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const storePath = opsSessionStorePath(stateDir);
|
||||
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
||||
fs.writeFileSync(storePath, raw);
|
||||
|
||||
expect(await migrateFixtureState(stateDir)).toEqual({ changes: [], warnings: [] });
|
||||
expect(fs.readFileSync(storePath, "utf-8")).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes an escaped legacy session key using the shared JSON5 parser", async () => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const storePath = opsSessionStorePath(stateDir);
|
||||
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
'{"agent:main:m\\u0061in":{"sessionId":"escaped-legacy","updatedAt":1000}}\n',
|
||||
);
|
||||
|
||||
const result = await migrateFixtureState(stateDir);
|
||||
|
||||
expect(result.changes).toHaveLength(1);
|
||||
expect(requireStoreEntry(readStore(storePath), "agent:ops:work").sessionId).toBe(
|
||||
"escaped-legacy",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing store files gracefully", async () => {
|
||||
await withStateFixture(async ({ stateDir }) => {
|
||||
const result = await migrateFixtureState(stateDir);
|
||||
|
||||
@@ -462,158 +462,16 @@ export function resolveStaleLegacySessionFile(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function skipJson5Trivia(raw: string, index: number): number {
|
||||
let i = index;
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i];
|
||||
if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && raw[i + 1] === "/") {
|
||||
i += 2;
|
||||
while (i < raw.length && raw[i] !== "\n") {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && raw[i + 1] === "*") {
|
||||
i += 2;
|
||||
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) {
|
||||
i++;
|
||||
}
|
||||
return i < raw.length ? i + 2 : i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function readJson5String(raw: string, index: number): { value: string; next: number } | null {
|
||||
const quote = raw[index];
|
||||
if (quote !== '"' && quote !== "'") {
|
||||
return null;
|
||||
}
|
||||
let i = index + 1;
|
||||
let value = "";
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i];
|
||||
if (ch === quote) {
|
||||
return { value, next: i + 1 };
|
||||
}
|
||||
if (ch === "\\") {
|
||||
return null;
|
||||
}
|
||||
value += ch;
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readJson5BareKey(raw: string, index: number): { value: string; next: number } | null {
|
||||
let i = index;
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i];
|
||||
if (
|
||||
ch === ":" ||
|
||||
ch === " " ||
|
||||
ch === "\n" ||
|
||||
ch === "\r" ||
|
||||
ch === "\t" ||
|
||||
ch === "," ||
|
||||
ch === "}" ||
|
||||
ch === "{" ||
|
||||
ch === "[" ||
|
||||
ch === "]"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i === index) {
|
||||
return null;
|
||||
}
|
||||
return { value: raw.slice(index, i), next: i };
|
||||
}
|
||||
|
||||
function listTopLevelSessionStoreKeys(raw: string): string[] | null {
|
||||
let i = skipJson5Trivia(raw, 0);
|
||||
if (raw[i] !== "{") {
|
||||
return null;
|
||||
}
|
||||
i++;
|
||||
const keys: string[] = [];
|
||||
let depth = 1;
|
||||
let expectingKey = true;
|
||||
|
||||
while (i < raw.length) {
|
||||
i = skipJson5Trivia(raw, i);
|
||||
const ch = raw[i];
|
||||
if (ch === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (depth === 1 && ch === "}") {
|
||||
return keys;
|
||||
}
|
||||
if (depth === 1 && expectingKey) {
|
||||
const key = ch === '"' || ch === "'" ? readJson5String(raw, i) : readJson5BareKey(raw, i);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
i = skipJson5Trivia(raw, key.next);
|
||||
if (raw[i] !== ":") {
|
||||
return null;
|
||||
}
|
||||
keys.push(key.value);
|
||||
i++;
|
||||
expectingKey = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
const str = readJson5String(raw, i);
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
i = str.next;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{" || ch === "[") {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}" || ch === "]") {
|
||||
depth--;
|
||||
i++;
|
||||
if (depth < 1) {
|
||||
return keys;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (depth === 1 && ch === ",") {
|
||||
expectingKey = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionStoreTextMayNeedCanonicalization(params: {
|
||||
raw: string;
|
||||
function sessionStoreMayNeedCanonicalization(params: {
|
||||
store: Record<string, SessionEntryLike>;
|
||||
storeAgentIds: Iterable<string>;
|
||||
mainKey: string;
|
||||
scope?: SessionScope;
|
||||
preserveForeignMainAliases?: boolean;
|
||||
}): boolean {
|
||||
const keys = listTopLevelSessionStoreKeys(params.raw);
|
||||
if (!keys) {
|
||||
return true;
|
||||
}
|
||||
const storeAgentIds = new Set([...params.storeAgentIds].map((id) => normalizeAgentId(id)));
|
||||
const hasNonMainAgent = [...storeAgentIds].some((id) => id !== DEFAULT_AGENT_ID);
|
||||
for (const key of keys) {
|
||||
for (const key of Object.keys(params.store)) {
|
||||
const rawKey = key.trim();
|
||||
if (rawKey !== key) {
|
||||
return true;
|
||||
@@ -813,16 +671,17 @@ export async function migrateOrphanedSessionKeys(params: {
|
||||
const pluginForeignMainAliasRisk = [...storeAgentIds].some(
|
||||
(id) => pluginAgentIdSet.has(id) && id !== DEFAULT_AGENT_ID,
|
||||
);
|
||||
let raw: string;
|
||||
let parsed: ReturnType<typeof parseSessionStoreJson5>;
|
||||
try {
|
||||
raw = fs.readFileSync(storePath, "utf-8");
|
||||
parsed = parseSessionStoreJson5(fs.readFileSync(storePath, "utf-8"));
|
||||
} catch (err) {
|
||||
warnings.push(`Could not read ${storePath}: ${String(err)}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!sessionStoreTextMayNeedCanonicalization({
|
||||
raw,
|
||||
!parsed.ok ||
|
||||
!sessionStoreMayNeedCanonicalization({
|
||||
store: parsed.store,
|
||||
storeAgentIds,
|
||||
mainKey,
|
||||
scope,
|
||||
@@ -831,17 +690,6 @@ export async function migrateOrphanedSessionKeys(params: {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let parsed: ReturnType<typeof readSessionStoreJson5>;
|
||||
try {
|
||||
parsed = parseSessionStoreJson5(raw);
|
||||
} catch (err) {
|
||||
warnings.push(`Could not read ${storePath}: ${String(err)}`);
|
||||
continue;
|
||||
}
|
||||
if (!parsed.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A physical store can have several owners. Canonicalize valid scoped rows
|
||||
// within their declared owner on every pass so iteration order cannot move
|
||||
// one agent's history into another namespace.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Covers plugin discovery threading and concurrency behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginDiscoveryResult } from "./discovery.js";
|
||||
import * as installedPluginIndexRecordReader from "./installed-plugin-index-record-reader.js";
|
||||
|
||||
const discoverOpenClawPluginsMock = vi.fn();
|
||||
|
||||
@@ -54,4 +55,19 @@ describe("discovery threading", () => {
|
||||
});
|
||||
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves explicit candidate diagnostics without loading persisted install records", () => {
|
||||
const readInstallRecords = vi.spyOn(
|
||||
installedPluginIndexRecordReader,
|
||||
"loadInstalledPluginIndexInstallRecordsSync",
|
||||
);
|
||||
const diagnostics = [{ level: "warn" as const, message: "explicit candidate diagnostic" }];
|
||||
|
||||
const result = resolveInstalledPluginIndexRegistry({ candidates: [], diagnostics });
|
||||
|
||||
expect(result.registry.diagnostics).toEqual(diagnostics);
|
||||
expect(result.discovery).toBeUndefined();
|
||||
expect(readInstallRecords).not.toHaveBeenCalled();
|
||||
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+155
-280
@@ -938,6 +938,153 @@ function shouldSkipIncompatiblePackagePluginApi(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
type PluginDirectoryDiscoveryParams = {
|
||||
dir: string;
|
||||
rootRealPath: string | undefined;
|
||||
origin: PluginOrigin;
|
||||
env: NodeJS.ProcessEnv;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
requireBuiltRuntimeEntry?: boolean;
|
||||
managedPluginDirs?: Set<string>;
|
||||
candidates: PluginCandidate[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
seen: Set<string>;
|
||||
realpathCache: Map<string, string>;
|
||||
packageManifestCache?: Map<string, PackageManifest | null>;
|
||||
};
|
||||
|
||||
function discoverPluginDirectory(params: PluginDirectoryDiscoveryParams): boolean {
|
||||
const { dir, rootRealPath } = params;
|
||||
const requireBuiltRuntimeEntry =
|
||||
params.requireBuiltRuntimeEntry ??
|
||||
isManagedPluginDir({
|
||||
dir,
|
||||
realpath: rootRealPath,
|
||||
managedPluginDirs: params.managedPluginDirs,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
||||
origin: params.origin,
|
||||
rootDir: dir,
|
||||
env: params.env,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
const manifest = readCandidatePackageManifest({
|
||||
dir,
|
||||
origin: params.origin,
|
||||
rejectHardlinks,
|
||||
...(rootRealPath !== undefined ? { rootRealPath } : {}),
|
||||
packageManifestCache: params.packageManifestCache,
|
||||
});
|
||||
if (
|
||||
shouldSkipIncompatiblePackagePluginApi({
|
||||
origin: params.origin,
|
||||
manifest,
|
||||
packageDir: dir,
|
||||
env: params.env,
|
||||
diagnostics: params.diagnostics,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||
if (
|
||||
pushInvalidPackageExtensionDiagnostic({
|
||||
resolution: extensionResolution,
|
||||
source: dir,
|
||||
diagnostics: params.diagnostics,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||
const candidateManifest = resolveCandidateManifest(dir, rejectHardlinks, rootRealPath);
|
||||
const manifestId = candidateManifest?.manifest.id;
|
||||
const setupSource = resolvePackageSetupSource({
|
||||
packageDir: dir,
|
||||
...(rootRealPath !== undefined ? { packageRootRealPath: rootRealPath } : {}),
|
||||
manifest,
|
||||
origin: params.origin,
|
||||
requireBuiltRuntimeEntry,
|
||||
sourceLabel: dir,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
const addPackageCandidate = (source: string, idHint: string): void => {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
idHint,
|
||||
source,
|
||||
...(setupSource ? { setupSource } : {}),
|
||||
rootDir: dir,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: dir,
|
||||
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
||||
requiredPluginSource: candidateManifest?.manifestPath,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
};
|
||||
|
||||
if (extensions.length > 0) {
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
|
||||
packageDir: dir,
|
||||
...(rootRealPath !== undefined ? { packageRootRealPath: rootRealPath } : {}),
|
||||
manifest,
|
||||
extensions,
|
||||
origin: params.origin,
|
||||
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
|
||||
requireBuiltRuntimeEntry,
|
||||
sourceLabel: dir,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
for (const source of resolvedRuntimeSources) {
|
||||
addPackageCandidate(
|
||||
source,
|
||||
deriveIdHint({
|
||||
filePath: source,
|
||||
manifestId,
|
||||
packageName: manifest?.name,
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
discoverBundleInRoot({
|
||||
rootDir: dir,
|
||||
origin: params.origin,
|
||||
env: params.env,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
realpathCache: params.realpathCache,
|
||||
}) === "added"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
|
||||
.map((candidate) => path.join(dir, candidate))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
if (indexFile && isExtensionFile(indexFile)) {
|
||||
addPackageCandidate(indexFile, manifestId ?? path.basename(dir));
|
||||
return true;
|
||||
}
|
||||
return addLegacyNpmDeclarationDiagnostic({ pluginDir: dir, diagnostics: params.diagnostics });
|
||||
}
|
||||
|
||||
function discoverInDirectory(params: {
|
||||
dir: string;
|
||||
origin: PluginOrigin;
|
||||
@@ -1017,146 +1164,11 @@ function discoverInDirectory(params: {
|
||||
if (params.skipRootDirKeys?.has(fullPathDirKey)) {
|
||||
continue;
|
||||
}
|
||||
const requireBuiltRuntimeEntry =
|
||||
params.requireBuiltRuntimeEntry ??
|
||||
isManagedPluginDir({
|
||||
if (
|
||||
discoverPluginDirectory({
|
||||
...params,
|
||||
dir: fullPath,
|
||||
realpath: fullPathRealPath,
|
||||
managedPluginDirs: params.managedPluginDirs,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
||||
origin: params.origin,
|
||||
rootDir: fullPath,
|
||||
env: params.env,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
const manifest = readCandidatePackageManifest({
|
||||
dir: fullPath,
|
||||
origin: params.origin,
|
||||
rejectHardlinks,
|
||||
...(fullPathRealPath !== undefined ? { rootRealPath: fullPathRealPath } : {}),
|
||||
packageManifestCache: params.packageManifestCache,
|
||||
});
|
||||
if (
|
||||
shouldSkipIncompatiblePackagePluginApi({
|
||||
origin: params.origin,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
env: params.env,
|
||||
diagnostics: params.diagnostics,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||
if (
|
||||
pushInvalidPackageExtensionDiagnostic({
|
||||
resolution: extensionResolution,
|
||||
source: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||
const candidateManifest = resolveCandidateManifest(fullPath, rejectHardlinks, fullPathRealPath);
|
||||
const manifestId = candidateManifest?.manifest.id;
|
||||
const setupSource = resolvePackageSetupSource({
|
||||
packageDir: fullPath,
|
||||
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
|
||||
manifest,
|
||||
origin: params.origin,
|
||||
requireBuiltRuntimeEntry,
|
||||
sourceLabel: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
|
||||
if (extensions.length > 0) {
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
|
||||
packageDir: fullPath,
|
||||
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
|
||||
manifest,
|
||||
extensions,
|
||||
origin: params.origin,
|
||||
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
|
||||
requireBuiltRuntimeEntry,
|
||||
sourceLabel: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
for (const resolved of resolvedRuntimeSources) {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
idHint: deriveIdHint({
|
||||
filePath: resolved,
|
||||
manifestId,
|
||||
packageName: manifest?.name,
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
source: resolved,
|
||||
...(setupSource ? { setupSource } : {}),
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
||||
requiredPluginSource: candidateManifest?.manifestPath,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const bundleDiscovery = discoverBundleInRoot({
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
env: params.env,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
if (bundleDiscovery === "added") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
|
||||
.map((candidate) => path.join(fullPath, candidate))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
if (indexFile && isExtensionFile(indexFile)) {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
idHint: manifestId ?? entry.name,
|
||||
source: indexFile,
|
||||
...(setupSource ? { setupSource } : {}),
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
||||
requiredPluginSource: candidateManifest?.manifestPath,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
addLegacyNpmDeclarationDiagnostic({
|
||||
pluginDir: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rootRealPath: fullPathRealPath,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
@@ -1294,148 +1306,11 @@ function discoverFromPath(params: {
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const resolvedRealPath = safeRealpathSync(resolved, params.realpathCache) ?? undefined;
|
||||
const requireBuiltRuntimeEntry =
|
||||
params.requireBuiltRuntimeEntry ??
|
||||
isManagedPluginDir({
|
||||
if (
|
||||
discoverPluginDirectory({
|
||||
...params,
|
||||
dir: resolved,
|
||||
realpath: resolvedRealPath,
|
||||
managedPluginDirs: params.managedPluginDirs,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
||||
origin: params.origin,
|
||||
rootDir: resolved,
|
||||
env: params.env,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
const manifest = readCandidatePackageManifest({
|
||||
dir: resolved,
|
||||
origin: params.origin,
|
||||
rejectHardlinks,
|
||||
...(resolvedRealPath !== undefined ? { rootRealPath: resolvedRealPath } : {}),
|
||||
packageManifestCache: params.packageManifestCache,
|
||||
});
|
||||
if (
|
||||
shouldSkipIncompatiblePackagePluginApi({
|
||||
origin: params.origin,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
env: params.env,
|
||||
diagnostics: params.diagnostics,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||
if (
|
||||
pushInvalidPackageExtensionDiagnostic({
|
||||
resolution: extensionResolution,
|
||||
source: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||
const candidateManifest = resolveCandidateManifest(resolved, rejectHardlinks, resolvedRealPath);
|
||||
const manifestId = candidateManifest?.manifest.id;
|
||||
const setupSource = resolvePackageSetupSource({
|
||||
packageDir: resolved,
|
||||
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
|
||||
manifest,
|
||||
origin: params.origin,
|
||||
requireBuiltRuntimeEntry,
|
||||
sourceLabel: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
|
||||
if (extensions.length > 0) {
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
|
||||
packageDir: resolved,
|
||||
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
|
||||
manifest,
|
||||
extensions,
|
||||
origin: params.origin,
|
||||
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
|
||||
requireBuiltRuntimeEntry,
|
||||
sourceLabel: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
for (const source of resolvedRuntimeSources) {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
idHint: deriveIdHint({
|
||||
filePath: source,
|
||||
manifestId,
|
||||
packageName: manifest?.name,
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
source,
|
||||
...(setupSource ? { setupSource } : {}),
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
||||
requiredPluginSource: candidateManifest?.manifestPath,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bundleDiscovery = discoverBundleInRoot({
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
env: params.env,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
if (bundleDiscovery === "added") {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
|
||||
.map((candidate) => path.join(resolved, candidate))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
if (indexFile && isExtensionFile(indexFile)) {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
seen: params.seen,
|
||||
idHint: manifestId ?? path.basename(resolved),
|
||||
source: indexFile,
|
||||
...(setupSource ? { setupSource } : {}),
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
||||
requiredPluginSource: candidateManifest?.manifestPath,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
addLegacyNpmDeclarationDiagnostic({
|
||||
pluginDir: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rootRealPath: safeRealpathSync(resolved, params.realpathCache) ?? undefined,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -15,34 +15,22 @@ export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginI
|
||||
candidates: readonly PluginCandidate[];
|
||||
discovery?: PluginDiscoveryResult;
|
||||
} {
|
||||
if (params.candidates) {
|
||||
return {
|
||||
candidates: params.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
const suppliedCandidates = params.candidates;
|
||||
const installRecords = suppliedCandidates
|
||||
? params.installRecords
|
||||
: (params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env }));
|
||||
const discovery = suppliedCandidates
|
||||
? { candidates: suppliedCandidates, diagnostics: params.diagnostics ?? [] }
|
||||
: (params.discovery ??
|
||||
discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalizePluginsConfig(params.config?.plugins).loadPaths,
|
||||
env: params.env,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
installRecords: params.installRecords,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizePluginsConfig(params.config?.plugins);
|
||||
const installRecords =
|
||||
params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env });
|
||||
const discovery =
|
||||
params.discovery ??
|
||||
discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
env: params.env,
|
||||
installRecords,
|
||||
});
|
||||
installRecords,
|
||||
}));
|
||||
return {
|
||||
candidates: discovery.candidates,
|
||||
discovery,
|
||||
...(suppliedCandidates ? {} : { discovery }),
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import {
|
||||
discoverOpenClawPlugins,
|
||||
type PluginCandidate,
|
||||
@@ -65,7 +64,8 @@ export function resolvePluginLoadDiscovery(params: {
|
||||
env: context.env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
installRecords: nonEmptyInstallRecords(context.installRecords),
|
||||
installRecords:
|
||||
Object.keys(context.installRecords).length > 0 ? context.installRecords : undefined,
|
||||
});
|
||||
pushDiagnostics(params.diagnostics, manifestRegistry.diagnostics);
|
||||
warnWhenAllowlistIsOpen({
|
||||
@@ -108,9 +108,3 @@ export function resolvePluginLoadDiscovery(params: {
|
||||
);
|
||||
return { discovery, manifestRegistry, orderedCandidates, manifestBySource, provenance };
|
||||
}
|
||||
|
||||
function nonEmptyInstallRecords(
|
||||
records: Record<string, PluginInstallRecord>,
|
||||
): Record<string, PluginInstallRecord> | undefined {
|
||||
return Object.keys(records).length > 0 ? records : undefined;
|
||||
}
|
||||
|
||||
@@ -107,19 +107,6 @@ function freezeSnapshotValue<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function freezePluginMetadataSnapshot(snapshot: PluginMetadataSnapshot): PluginMetadataSnapshot {
|
||||
return freezeSnapshotValue(snapshot);
|
||||
}
|
||||
|
||||
function resolvePluginMetadataControlPlaneFingerprint(
|
||||
params: Pick<LoadPluginMetadataSnapshotParams, "config" | "env" | "workspaceDir"> & {
|
||||
index?: InstalledPluginIndex;
|
||||
policyHash?: string;
|
||||
},
|
||||
): string {
|
||||
return resolvePluginControlPlaneFingerprint(params);
|
||||
}
|
||||
|
||||
function indexesMatch(
|
||||
left: InstalledPluginIndex | undefined,
|
||||
right: InstalledPluginIndex | undefined,
|
||||
@@ -189,7 +176,7 @@ export function isPluginMetadataSnapshotCompatible(params: {
|
||||
params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) &&
|
||||
(!params.snapshot.configFingerprint ||
|
||||
params.snapshot.configFingerprint ===
|
||||
resolvePluginMetadataControlPlaneFingerprint({
|
||||
resolvePluginControlPlaneFingerprint({
|
||||
config: params.config,
|
||||
env,
|
||||
index: params.index ?? params.snapshot.index,
|
||||
@@ -317,7 +304,7 @@ export function loadPluginMetadataSnapshot(
|
||||
);
|
||||
return measureDiagnosticsTimelineSpanSync(
|
||||
"plugins.metadata.freeze",
|
||||
() => freezePluginMetadataSnapshot(snapshot),
|
||||
() => freezeSnapshotValue(snapshot),
|
||||
{
|
||||
phase: activeTimelineSpan?.phase ?? "startup",
|
||||
config: params.config,
|
||||
@@ -411,7 +398,7 @@ function loadPluginMetadataSnapshotImpl(
|
||||
return {
|
||||
policyHash: index.policyHash,
|
||||
registrySource: registryResult.source,
|
||||
configFingerprint: resolvePluginMetadataControlPlaneFingerprint({
|
||||
configFingerprint: resolvePluginControlPlaneFingerprint({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
index,
|
||||
|
||||
@@ -499,13 +499,13 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
// never reuse security-sensitive symlink or plugin-root resolutions.
|
||||
const realpathCache = new Map<string, string>();
|
||||
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
|
||||
const disabledByCaller = params.preferPersisted === false;
|
||||
const persistedReadsEnabled = !disabledByCaller;
|
||||
const persistedInstallRecordReadsEnabled = persistedReadsEnabled;
|
||||
let persistedIndex: InstalledPluginIndex | null;
|
||||
if (persistedInstallRecordReadsEnabled) {
|
||||
persistedIndex = readPersistedInstalledPluginIndexSync(params);
|
||||
if (persistedReadsEnabled && persistedIndex) {
|
||||
const persistedReadsEnabled = params.preferPersisted !== false;
|
||||
const pushStaleSourceDiagnostic = (message: string): void => {
|
||||
diagnostics.push({ level: "warn", code: "persisted-registry-stale-source", message });
|
||||
};
|
||||
if (persistedReadsEnabled) {
|
||||
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
|
||||
if (persistedIndex) {
|
||||
if (
|
||||
params.config &&
|
||||
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
|
||||
@@ -517,49 +517,31 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
} else if (hasMissingPersistedPluginSource(persistedIndex)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (
|
||||
hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache)
|
||||
) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasStalePersistedPluginDiagnostics(persistedIndex)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasMissingConfigPathActivationMetadata(persistedIndex)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (
|
||||
hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
||||
persistedIndex,
|
||||
@@ -567,12 +549,9 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
env,
|
||||
)
|
||||
) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else {
|
||||
const persistedResult: PluginRegistrySnapshotResult = {
|
||||
snapshot: persistedIndex,
|
||||
@@ -581,7 +560,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
};
|
||||
return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult);
|
||||
}
|
||||
} else if (persistedReadsEnabled) {
|
||||
} else {
|
||||
diagnostics.push({
|
||||
level: "info",
|
||||
code: "persisted-registry-missing",
|
||||
@@ -592,9 +571,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
|
||||
const derived = loadInstalledPluginIndexWithDiscovery({
|
||||
...params,
|
||||
installRecords: persistedInstallRecordReadsEnabled
|
||||
? params.installRecords
|
||||
: (params.installRecords ?? {}),
|
||||
installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}),
|
||||
});
|
||||
return rememberPluginRegistrySnapshotMemo(memoKey, {
|
||||
snapshot: derived.index,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "../../i18n/index.ts";
|
||||
import {
|
||||
createClient,
|
||||
createContext,
|
||||
createGateway,
|
||||
createPluginsRouteData,
|
||||
createPluginsRouteLocation,
|
||||
createResult,
|
||||
mountPage,
|
||||
@@ -29,20 +30,12 @@ describe("PluginsPage routing", () => {
|
||||
it("applies the Discover path from route data", async () => {
|
||||
const { client } = createClient(async () => createResult());
|
||||
const harness = createGateway(client);
|
||||
const routeData: PluginsRouteData = {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
result: createResult(),
|
||||
error: null,
|
||||
location: createPluginsRouteLocation("/settings/plugins/discover"),
|
||||
};
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
routeData,
|
||||
const routeData: PluginsRouteData = createPluginsRouteData(
|
||||
harness.gateway,
|
||||
createResult(),
|
||||
createPluginsRouteLocation("/settings/plugins/discover"),
|
||||
);
|
||||
const { page } = await mountPage(createContext(harness.gateway), routeData);
|
||||
|
||||
expect(page.activeTab).toBe("discover");
|
||||
const tabGroup = page.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
|
||||
@@ -70,17 +63,12 @@ describe("PluginsPage routing", () => {
|
||||
async (sourceUrl, expectedTab, expectedUrl) => {
|
||||
const { client } = createClient(async () => createResult());
|
||||
const harness = createGateway(client);
|
||||
const context = createContext(
|
||||
const context = createContext(harness.gateway);
|
||||
const routeData: PluginsRouteData = createPluginsRouteData(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
createResult(),
|
||||
createPluginsRouteLocation(sourceUrl),
|
||||
);
|
||||
const routeData: PluginsRouteData = {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
result: createResult(),
|
||||
error: null,
|
||||
location: createPluginsRouteLocation(sourceUrl),
|
||||
};
|
||||
const { page } = await mountPage(context, routeData);
|
||||
|
||||
expect(page.activeTab).toBe(expectedTab);
|
||||
@@ -117,17 +105,8 @@ describe("PluginsPage routing", () => {
|
||||
it("pushes catalog tab paths and restores the tab from history", async () => {
|
||||
const { client } = createClient(async () => createResult());
|
||||
const harness = createGateway(client);
|
||||
const context = createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
);
|
||||
const routeData: PluginsRouteData = {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
};
|
||||
const context = createContext(harness.gateway);
|
||||
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway);
|
||||
const { page } = await mountPage(context, routeData);
|
||||
|
||||
clickHubTab(page, "skills");
|
||||
|
||||
@@ -72,6 +72,14 @@ export function createPluginsRouteLocation(url = "/settings/plugins"): RouteLoca
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginsRouteData(
|
||||
gateway: ApplicationGateway,
|
||||
result: PluginListResult | null = createResult(),
|
||||
location = createPluginsRouteLocation(),
|
||||
): PluginsRouteData {
|
||||
return { gateway, gatewaySnapshot: gateway.snapshot, location, result, error: null };
|
||||
}
|
||||
|
||||
export function createClient(handler: RequestHandler) {
|
||||
const request = vi.fn(handler);
|
||||
return {
|
||||
@@ -186,7 +194,7 @@ export function createRuntimeConfigHarness(
|
||||
|
||||
export function createContext(
|
||||
gateway: ApplicationGateway,
|
||||
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"],
|
||||
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"] = vi.fn(async () => undefined),
|
||||
runtimeConfigState: RuntimeConfigTestState = {
|
||||
configFormDirty: false,
|
||||
lastError: null,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createContext,
|
||||
createGateway,
|
||||
createPlugin,
|
||||
createPluginsRouteData,
|
||||
createPluginsRouteLocation,
|
||||
createResult,
|
||||
createRuntimeConfigHarness,
|
||||
@@ -38,21 +39,9 @@ describe("PluginsPage", () => {
|
||||
const { client, request } = createClient(async () => createResult());
|
||||
const harness = createGateway(client);
|
||||
const result = createResult();
|
||||
const routeData: PluginsRouteData = {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result,
|
||||
error: null,
|
||||
};
|
||||
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway, result);
|
||||
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
routeData,
|
||||
);
|
||||
const { page } = await mountPage(createContext(harness.gateway), routeData);
|
||||
|
||||
expect(page.result).toBe(result);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
@@ -65,12 +54,7 @@ describe("PluginsPage", () => {
|
||||
throw new Error("catalog unavailable");
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
);
|
||||
const { page } = await mountPage(createContext(harness.gateway));
|
||||
|
||||
await waitForFast(() =>
|
||||
expect(page.querySelector(".plugins-page-error")?.textContent).toContain(
|
||||
@@ -121,21 +105,9 @@ describe("PluginsPage", () => {
|
||||
const result = createResult(
|
||||
createPlugin({ id: "remote-icon", name: "FireCrawl", hasIcon: true }),
|
||||
);
|
||||
const routeData: PluginsRouteData = {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result,
|
||||
error: null,
|
||||
};
|
||||
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway, result);
|
||||
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
routeData,
|
||||
);
|
||||
const { page } = await mountPage(createContext(harness.gateway), routeData);
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(
|
||||
@@ -187,17 +159,8 @@ describe("PluginsPage", () => {
|
||||
);
|
||||
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result,
|
||||
error: null,
|
||||
},
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway, result),
|
||||
);
|
||||
|
||||
await waitForFast(() => expect(fetchMock).toHaveBeenCalledOnce());
|
||||
@@ -216,20 +179,8 @@ describe("PluginsPage", () => {
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const routeData: PluginsRouteData = {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
};
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
routeData,
|
||||
);
|
||||
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway);
|
||||
const { page } = await mountPage(createContext(harness.gateway), routeData);
|
||||
|
||||
harness.emit(client, false);
|
||||
harness.emit(client, true);
|
||||
@@ -252,17 +203,8 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway),
|
||||
);
|
||||
|
||||
clickHubTab(page, "discover");
|
||||
@@ -298,17 +240,12 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
createResult(),
|
||||
createPluginsRouteLocation("/settings/plugins/discover"),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation("/settings/plugins/discover"),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
const search = page.querySelector<HTMLInputElement>("#plugins-global-search")!;
|
||||
search.value = "first";
|
||||
@@ -364,13 +301,7 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const { page } = await mountPage(
|
||||
createContext(harness.gateway, refreshConfig, runtimeConfigState),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
createPluginsRouteData(harness.gateway),
|
||||
);
|
||||
|
||||
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
|
||||
@@ -393,17 +324,8 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway),
|
||||
);
|
||||
|
||||
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
|
||||
@@ -435,17 +357,8 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway),
|
||||
);
|
||||
|
||||
clickHubTab(page, "discover");
|
||||
@@ -485,17 +398,8 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway),
|
||||
);
|
||||
|
||||
page.querySelector<HTMLButtonElement>(".plugins-refresh")?.click();
|
||||
@@ -537,13 +441,7 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const { page } = await mountPage(
|
||||
createContext(harness.gateway, refreshConfig, runtimeConfigState),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: createResult(),
|
||||
error: null,
|
||||
},
|
||||
createPluginsRouteData(harness.gateway),
|
||||
);
|
||||
|
||||
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
|
||||
@@ -587,13 +485,10 @@ describe("PluginsPage", () => {
|
||||
const refreshConfig = vi.fn(async () => {
|
||||
await replacementClient.request("config.get", {});
|
||||
});
|
||||
const { page } = await mountPage(createContext(harness.gateway, refreshConfig), {
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: disabledResult,
|
||||
error: null,
|
||||
});
|
||||
const { page } = await mountPage(
|
||||
createContext(harness.gateway, refreshConfig),
|
||||
createPluginsRouteData(harness.gateway, disabledResult),
|
||||
);
|
||||
|
||||
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
|
||||
expect(page.busy["plugin:workboard"]).toBe(true);
|
||||
@@ -638,17 +533,12 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const { page } = await mountPage(
|
||||
createContext(
|
||||
harness.gateway,
|
||||
vi.fn(async () => undefined),
|
||||
),
|
||||
{
|
||||
gateway: harness.gateway,
|
||||
gatewaySnapshot: harness.gateway.snapshot,
|
||||
location: createPluginsRouteLocation(),
|
||||
result: { plugins: [createPlugin(), removable], diagnostics: [], mutationAllowed: true },
|
||||
error: null,
|
||||
},
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway, {
|
||||
plugins: [createPlugin(), removable],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await clickRowAction(page, '[data-plugin-id="community-thing"]', "Remove");
|
||||
|
||||
@@ -716,7 +716,18 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
return errors.length > 0 ? errors.join(" ") : null;
|
||||
}
|
||||
|
||||
private async install(rowKey: string, request: PluginInstallRequest) {
|
||||
private async runPluginMutation<Result>(
|
||||
rowKey: string,
|
||||
mutate: (client: GatewayBrowserClient) => Promise<Result>,
|
||||
onSuccess: (
|
||||
result: Result,
|
||||
client: GatewayBrowserClient,
|
||||
isCurrent: () => boolean,
|
||||
) => Promise<void>,
|
||||
onError: (error: unknown) => void = (error) => {
|
||||
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
|
||||
},
|
||||
): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client || !this.canMutate() || this.busy[rowKey]) {
|
||||
return;
|
||||
@@ -730,127 +741,99 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
this.setBusy(rowKey, true);
|
||||
this.setMessage(rowKey, null);
|
||||
try {
|
||||
const result = await installPlugin(client, request);
|
||||
const result = await mutate(client);
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
this.applyMutationResult(result);
|
||||
this.setMessage(rowKey, {
|
||||
kind: "success",
|
||||
text: mutationSuccessMessage("installed", result),
|
||||
});
|
||||
await this.refreshAfterMutation(client);
|
||||
await onSuccess(result, client, isCurrent);
|
||||
} catch (error) {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
if (isCurrent()) {
|
||||
onError(error);
|
||||
}
|
||||
const trust = readPluginInstallTrustError(error);
|
||||
const packageName = request.source === "clawhub" ? request.packageName : null;
|
||||
if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) {
|
||||
} finally {
|
||||
if (this.mutationTokens.get(rowKey) === mutationToken) {
|
||||
this.mutationTokens.delete(rowKey);
|
||||
this.setBusy(rowKey, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async install(rowKey: string, request: PluginInstallRequest): Promise<void> {
|
||||
await this.runPluginMutation(
|
||||
rowKey,
|
||||
(client) => installPlugin(client, request),
|
||||
async (result, client) => {
|
||||
this.applyMutationResult(result);
|
||||
this.setMessage(rowKey, {
|
||||
kind: "error",
|
||||
text: trust?.warning ?? t("pluginsPage.defaultRiskWarning"),
|
||||
acknowledge: {
|
||||
packageName,
|
||||
...(trust?.version ? { version: trust.version } : {}),
|
||||
},
|
||||
kind: "success",
|
||||
text: mutationSuccessMessage("installed", result),
|
||||
});
|
||||
} else {
|
||||
await this.refreshAfterMutation(client);
|
||||
},
|
||||
(error) => {
|
||||
const trust = readPluginInstallTrustError(error);
|
||||
const packageName = request.source === "clawhub" ? request.packageName : null;
|
||||
if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) {
|
||||
this.setMessage(rowKey, {
|
||||
kind: "error",
|
||||
text: trust?.warning ?? t("pluginsPage.defaultRiskWarning"),
|
||||
acknowledge: {
|
||||
packageName,
|
||||
...(trust?.version ? { version: trust.version } : {}),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
|
||||
}
|
||||
} finally {
|
||||
if (this.mutationTokens.get(rowKey) === mutationToken) {
|
||||
this.mutationTokens.delete(rowKey);
|
||||
this.setBusy(rowKey, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async updateEnabled(pluginId: string, enabled: boolean, key = pluginRowKey(pluginId)) {
|
||||
const client = this.client;
|
||||
if (!client || !this.canMutate() || this.busy[key]) {
|
||||
return;
|
||||
}
|
||||
const sourceGeneration = this.sourceGeneration;
|
||||
const mutationToken = ++this.mutationToken;
|
||||
this.mutationTokens.set(key, mutationToken);
|
||||
const isCurrent = () =>
|
||||
this.isCurrentSource(client, sourceGeneration) &&
|
||||
this.mutationTokens.get(key) === mutationToken;
|
||||
this.setBusy(key, true);
|
||||
this.setMessage(key, null);
|
||||
try {
|
||||
const result = await setPluginEnabled(client, pluginId, enabled);
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
this.applyMutationResult(result);
|
||||
this.setMessage(key, {
|
||||
kind: "success",
|
||||
text: mutationSuccessMessage(enabled ? "enabled" : "disabled", result),
|
||||
});
|
||||
if (enabled) {
|
||||
this.pinEnabledPluginRoute(pluginId);
|
||||
}
|
||||
await this.refreshAfterMutation(client);
|
||||
if (isCurrent() && !result.restartRequired) {
|
||||
// Plugin-provided tabs are projected in the connection hello. Re-handshake
|
||||
// after the registry refresh so sidebar navigation reflects this mutation.
|
||||
this.context.gateway.connect();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
this.setMessage(key, { kind: "error", text: errorMessage(error) });
|
||||
}
|
||||
} finally {
|
||||
if (this.mutationTokens.get(key) === mutationToken) {
|
||||
this.mutationTokens.delete(key);
|
||||
this.setBusy(key, false);
|
||||
}
|
||||
}
|
||||
private async updateEnabled(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
key = pluginRowKey(pluginId),
|
||||
): Promise<void> {
|
||||
await this.runPluginMutation(
|
||||
key,
|
||||
(client) => setPluginEnabled(client, pluginId, enabled),
|
||||
async (result, client, isCurrent) => {
|
||||
this.applyMutationResult(result);
|
||||
this.setMessage(key, {
|
||||
kind: "success",
|
||||
text: mutationSuccessMessage(enabled ? "enabled" : "disabled", result),
|
||||
});
|
||||
if (enabled) {
|
||||
this.pinEnabledPluginRoute(pluginId);
|
||||
}
|
||||
await this.refreshAfterMutation(client);
|
||||
if (isCurrent() && !result.restartRequired) {
|
||||
// Plugin tabs come from hello; reconnect after the registry refresh.
|
||||
this.context.gateway.connect();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async uninstall(pluginId: string, rowKey: string) {
|
||||
const client = this.client;
|
||||
if (!client || !this.canMutate() || this.busy[rowKey]) {
|
||||
return;
|
||||
}
|
||||
const sourceGeneration = this.sourceGeneration;
|
||||
const mutationToken = ++this.mutationToken;
|
||||
this.mutationTokens.set(rowKey, mutationToken);
|
||||
const isCurrent = () =>
|
||||
this.isCurrentSource(client, sourceGeneration) &&
|
||||
this.mutationTokens.get(rowKey) === mutationToken;
|
||||
this.setBusy(rowKey, true);
|
||||
this.setMessage(rowKey, null);
|
||||
try {
|
||||
const result = await uninstallPlugin(client, pluginId);
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
this.setPendingRemoval(rowKey, false);
|
||||
// The uninstalled row disappears after refresh, so the restart reminder
|
||||
// lives in the page-level notice instead of the vanishing row.
|
||||
this.pageNotice = {
|
||||
kind: "success",
|
||||
text: [
|
||||
t("pluginsPage.removedRestart", { name: result.pluginId }),
|
||||
...(result.warnings ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
};
|
||||
await this.refreshAfterMutation(client);
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
|
||||
}
|
||||
} finally {
|
||||
if (this.mutationTokens.get(rowKey) === mutationToken) {
|
||||
this.mutationTokens.delete(rowKey);
|
||||
this.setBusy(rowKey, false);
|
||||
}
|
||||
}
|
||||
private async uninstall(pluginId: string, rowKey: string): Promise<void> {
|
||||
await this.runPluginMutation(
|
||||
rowKey,
|
||||
(client) => uninstallPlugin(client, pluginId),
|
||||
async (result, client) => {
|
||||
this.setPendingRemoval(rowKey, false);
|
||||
// Removal hides its row, so keep the restart reminder on the page.
|
||||
this.pageNotice = {
|
||||
kind: "success",
|
||||
text: [
|
||||
t("pluginsPage.removedRestart", { name: result.pluginId }),
|
||||
...(result.warnings ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
};
|
||||
await this.refreshAfterMutation(client);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async mutateMcpServers(params: {
|
||||
|
||||
@@ -281,15 +281,16 @@ function renderArtTile(
|
||||
name: string,
|
||||
iconUrl?: string,
|
||||
onIconError?: () => void,
|
||||
className = "plugins-tile",
|
||||
): TemplateResult {
|
||||
const art = pluginArtPath(slug);
|
||||
if (art) {
|
||||
return html`<span class="plugins-tile">
|
||||
return html`<span class=${className}>
|
||||
<img src=${art} alt="" loading="lazy" decoding="async" />
|
||||
</span>`;
|
||||
}
|
||||
if (iconUrl) {
|
||||
return html`<span class="plugins-tile">
|
||||
return html`<span class=${className}>
|
||||
<img
|
||||
class="plugins-icon"
|
||||
src=${iconUrl}
|
||||
@@ -303,7 +304,7 @@ function renderArtTile(
|
||||
const [from, to] = pluginFallbackGradient(slug);
|
||||
const monogram = pluginMonogram(name);
|
||||
return html`<span
|
||||
class="plugins-tile plugins-tile--fallback"
|
||||
class=${`${className} ${className}--fallback`}
|
||||
style=${`--plugins-art-a:${from};--plugins-art-b:${to}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -573,7 +574,11 @@ function renderInstalledFilter(props: PluginsViewProps) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps): TemplateResult {
|
||||
function renderPluginRow(
|
||||
plugin: PluginCatalogItem,
|
||||
props: PluginsViewProps,
|
||||
includePackageName = false,
|
||||
): TemplateResult {
|
||||
const key = pluginRowKey(plugin.id);
|
||||
const busy = props.busy[key] ?? false;
|
||||
return html`
|
||||
@@ -596,7 +601,9 @@ function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps):
|
||||
<h3 class="settings-row__title">
|
||||
${plugin.name}
|
||||
${plugin.version
|
||||
? html`<span class="plugins-version">v${plugin.version}</span>`
|
||||
? includePackageName
|
||||
? html`<span class="plugins-version">v${plugin.version}</span>`
|
||||
: html`<span class="plugins-version">v${plugin.version}</span>`
|
||||
: nothing}
|
||||
</h3>
|
||||
<span class="settings-row__desc">
|
||||
@@ -604,13 +611,14 @@ function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps):
|
||||
</span>
|
||||
${renderMetaLine([
|
||||
plugin.origin ? originLabel(plugin.origin) : nothing,
|
||||
plugin.packageName
|
||||
includePackageName && plugin.packageName
|
||||
? html`<span class="plugins-meta__mono">${plugin.packageName}</span>`
|
||||
: nothing,
|
||||
])}
|
||||
</div>
|
||||
<div class="settings-row__control">
|
||||
${rowStateStatus(plugin)} ${renderCatalogActions(plugin, props, busy, key)}
|
||||
${plugin.installed ? rowStateStatus(plugin) : nothing}
|
||||
${renderCatalogActions(plugin, props, busy, key)}
|
||||
</div>
|
||||
${plugin.error
|
||||
? html`<div class="plugins-row-message plugins-row-message--error" role="alert">
|
||||
@@ -732,7 +740,7 @@ function renderInstalled(props: PluginsViewProps) {
|
||||
repeat(
|
||||
group.plugins,
|
||||
(plugin) => plugin.id,
|
||||
(plugin) => renderInstalledRow(plugin, props),
|
||||
(plugin) => renderPluginRow(plugin, props, true),
|
||||
),
|
||||
),
|
||||
)}
|
||||
@@ -742,51 +750,6 @@ function renderInstalled(props: PluginsViewProps) {
|
||||
|
||||
/* ---------------------------------- discover tab ---------------------------------- */
|
||||
|
||||
function renderCatalogRow(plugin: PluginCatalogItem, props: PluginsViewProps): TemplateResult {
|
||||
const key = pluginRowKey(plugin.id);
|
||||
const busy = props.busy[key] ?? false;
|
||||
return html`
|
||||
<article
|
||||
class="settings-row plugins-item plugins-item--clickable"
|
||||
data-plugin-id=${plugin.id}
|
||||
data-plugin-source=${plugin.origin ?? "unknown"}
|
||||
data-plugin-status=${plugin.state}
|
||||
aria-busy=${busy ? "true" : "false"}
|
||||
@click=${(event: Event) => {
|
||||
if (!fromInteractiveChild(event)) {
|
||||
props.onShowDetails(plugin.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${renderArtTile(plugin.id, plugin.name, props.iconUrls[plugin.id], () =>
|
||||
props.onIconError(plugin.id),
|
||||
)}
|
||||
<div class="settings-row__text">
|
||||
<h3 class="settings-row__title">
|
||||
${plugin.name}
|
||||
${plugin.version
|
||||
? html`<span class="plugins-version">v${plugin.version}</span>`
|
||||
: nothing}
|
||||
</h3>
|
||||
<span class="settings-row__desc">
|
||||
${plugin.description || t("pluginsPage.optionalCapability")}
|
||||
</span>
|
||||
${renderMetaLine([plugin.origin ? originLabel(plugin.origin) : nothing])}
|
||||
</div>
|
||||
<div class="settings-row__control">
|
||||
${plugin.installed ? rowStateStatus(plugin) : nothing}
|
||||
${renderCatalogActions(plugin, props, busy, key)}
|
||||
</div>
|
||||
${plugin.error
|
||||
? html`<div class="plugins-row-message plugins-row-message--error" role="alert">
|
||||
${plugin.error}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderRowMessage(key, props.messages[key], busy, props)}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConnectorRow(
|
||||
connector: ConnectorSuggestion,
|
||||
props: PluginsViewProps,
|
||||
@@ -978,8 +941,8 @@ function renderClawHubGroup(props: PluginsViewProps) {
|
||||
|
||||
function renderDiscover(props: PluginsViewProps) {
|
||||
const shelves = discoverShelves(props.result?.plugins ?? [], props.query);
|
||||
const featuredRows = shelves.featured.map((plugin) => renderCatalogRow(plugin, props));
|
||||
const officialRows = shelves.official.map((plugin) => renderCatalogRow(plugin, props));
|
||||
const featuredRows = shelves.featured.map((plugin) => renderPluginRow(plugin, props));
|
||||
const officialRows = shelves.official.map((plugin) => renderPluginRow(plugin, props));
|
||||
const clawHub = renderClawHubGroup(props);
|
||||
if (!featuredRows.length && !officialRows.length && !shelves.connectors.length) {
|
||||
return html`
|
||||
@@ -1064,8 +1027,12 @@ function renderDetailOverlay(props: PluginsViewProps) {
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
${renderDetailCover(plugin.id, plugin.name, props.iconUrls[plugin.id], () =>
|
||||
props.onIconError(plugin.id),
|
||||
${renderArtTile(
|
||||
plugin.id,
|
||||
plugin.name,
|
||||
props.iconUrls[plugin.id],
|
||||
() => props.onIconError(plugin.id),
|
||||
"plugins-cover",
|
||||
)}
|
||||
<div class="plugins-detail__body">
|
||||
<div class="plugins-detail__title">
|
||||
@@ -1144,41 +1111,6 @@ function renderDetailOverlay(props: PluginsViewProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDetailCover(
|
||||
slug: string,
|
||||
name: string,
|
||||
iconUrl?: string,
|
||||
onIconError?: () => void,
|
||||
): TemplateResult {
|
||||
const art = pluginArtPath(slug);
|
||||
if (art) {
|
||||
return html`<span class="plugins-cover">
|
||||
<img src=${art} alt="" loading="lazy" decoding="async" />
|
||||
</span>`;
|
||||
}
|
||||
if (iconUrl) {
|
||||
return html`<span class="plugins-cover">
|
||||
<img
|
||||
class="plugins-icon"
|
||||
src=${iconUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error=${onIconError}
|
||||
/>
|
||||
</span>`;
|
||||
}
|
||||
const [from, to] = pluginFallbackGradient(slug);
|
||||
const monogram = pluginMonogram(name);
|
||||
return html`<span
|
||||
class="plugins-cover plugins-cover--fallback"
|
||||
style=${`--plugins-art-a:${from};--plugins-art-b:${to}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
${monogram ? html`<span>${monogram}</span>` : icons.puzzle}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/* ---------------------------------- page shell ---------------------------------- */
|
||||
|
||||
function renderEmpty(title: string, body: string, mood?: "sleepy" | "curious") {
|
||||
|
||||
Reference in New Issue
Block a user