docs: refresh otel config baseline

This commit is contained in:
Jesse Merhi 2026-06-19 15:59:55 +10:00 committed by GitHub
parent 86f67ef076
commit efa2ef07ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1000 additions and 224 deletions

View file

@ -11,7 +11,7 @@ import {
CodexNativeSubagentMonitor,
registerCodexNativeSubagentMonitor,
} from "./native-subagent-monitor.js";
import type { CodexServerNotification } from "./protocol.js";
import type { CodexServerNotification, JsonValue } from "./protocol.js";
function createClient() {
const handlers = new Set<(notification: CodexServerNotification) => Promise<void> | void>();
@ -158,6 +158,27 @@ function nativeCompletionNotification(params: {
};
}
function childTurnCompletedNotification(params: {
status: "completed" | "failed" | "interrupted";
error?: string;
turnId?: string;
items?: JsonValue[];
}): CodexServerNotification {
const turnId = params.turnId ?? "child-turn";
return {
method: "turn/completed",
params: {
threadId: "child-thread",
turn: {
id: turnId,
status: params.status,
...(params.items ? { items: params.items } : {}),
...(params.error ? { error: { message: params.error } } : {}),
},
},
};
}
describe("CodexNativeSubagentMonitor", () => {
it("keeps native subagent task mirroring alive on the shared client", async () => {
const client = createClient();
@ -314,12 +335,10 @@ describe("CodexNativeSubagentMonitor", () => {
);
});
it("delivers child agent-message completion when a native subagent becomes idle", async () => {
it("delivers a completed child turn with its final agent message", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
codexHome: "/tmp/codex-home",
});
const monitor = new CodexNativeSubagentMonitor(client, runtime);
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
@ -329,17 +348,36 @@ describe("CodexNativeSubagentMonitor", () => {
await notifyChildStarted(client);
await client.notify({
method: "item/completed",
method: "item/started",
params: {
threadId: "child-thread",
turnId: "child-turn",
item: {
type: "agentMessage",
id: "msg-child-final",
phase: "final_answer",
text: "child final result",
text: "",
},
},
});
await client.notify({
method: "item/agentMessage/delta",
params: {
threadId: "child-thread",
turnId: "child-turn",
itemId: "msg-child-final",
delta: "child ",
},
});
await client.notify({
method: "item/agentMessage/delta",
params: {
threadId: "child-thread",
turnId: "child-turn",
itemId: "msg-child-final",
delta: "final result",
},
});
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
@ -351,6 +389,9 @@ describe("CodexNativeSubagentMonitor", () => {
},
});
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
await client.notify(childTurnCompletedNotification({ status: "completed" }));
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: "codex-thread:child-thread",
@ -362,7 +403,7 @@ describe("CodexNativeSubagentMonitor", () => {
expect.objectContaining({
childSessionId: "child-thread",
status: "succeeded",
statusLabel: "agent_message",
statusLabel: "turn_completed",
result: "child final result",
}),
);
@ -370,12 +411,10 @@ describe("CodexNativeSubagentMonitor", () => {
client.close();
});
it("does not deliver commentary-only child messages as native subagent completion", async () => {
it("does not deliver a commentary delta when the completion snapshot is absent", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
codexHome: "/tmp/codex-home",
});
const monitor = new CodexNativeSubagentMonitor(client, runtime);
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
@ -384,10 +423,66 @@ describe("CodexNativeSubagentMonitor", () => {
});
await notifyChildStarted(client);
await client.notify({
method: "item/started",
params: {
threadId: "child-thread",
turnId: "child-turn",
item: {
type: "agentMessage",
id: "msg-child-commentary",
phase: "commentary",
text: "",
},
},
});
await client.notify({
method: "item/agentMessage/delta",
params: {
threadId: "child-thread",
turnId: "child-turn",
itemId: "msg-child-commentary",
delta: "checking now",
},
});
await client.notify(childTurnCompletedNotification({ status: "completed" }));
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
result: "Codex native subagent completed without a final assistant message.",
}),
);
client.close();
});
it("does not complete commentary-only child messages before a terminal turn", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime);
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
taskRuntimeScope: createTaskScope(),
agentId: "main",
});
await notifyChildStarted(client);
await client.notify({
method: "item/agentMessage/delta",
params: {
threadId: "child-thread",
turnId: "child-turn",
itemId: "msg-child-commentary",
delta: "checking now",
},
});
await client.notify({
method: "item/completed",
params: {
threadId: "child-thread",
turnId: "child-turn",
item: {
type: "agentMessage",
id: "msg-child-commentary",
@ -407,6 +502,162 @@ describe("CodexNativeSubagentMonitor", () => {
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
await client.notify(childTurnCompletedNotification({ status: "completed" }));
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
result: "Codex native subagent completed without a final assistant message.",
}),
);
client.close();
});
it("delivers a completed child turn with its snapshot-only final message", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime);
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
taskRuntimeScope: createTaskScope(),
agentId: "main",
});
await notifyChildStarted(client);
await client.notify(
childTurnCompletedNotification({
status: "completed",
items: [
{
id: "msg-child-snapshot",
type: "agentMessage",
phase: "final_answer",
text: "snapshot final result",
},
],
}),
);
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
result: "snapshot final result",
}),
);
client.close();
});
it("reconciles transcript text for a completed child turn without a final message", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-subagent-"));
const codexHome = path.join(tempDir, "codex-home");
const transcriptDir = path.join(codexHome, "sessions", "2026", "06", "09");
await fs.mkdir(transcriptDir, { recursive: true });
await fs.writeFile(
path.join(transcriptDir, "rollout-2026-06-09T10-11-12-child-thread.jsonl"),
[
JSON.stringify({
type: "session_meta",
payload: {
source: {
subagent: {
thread_spawn: {
parent_thread_id: "parent-thread",
depth: 1,
},
},
},
},
}),
JSON.stringify({
timestamp: "2026-06-09T10:12:00.000Z",
type: "event_msg",
payload: {
type: "task_complete",
last_agent_message: "child turn transcript result",
completed_at: 1781009520,
},
}),
"",
].join("\n"),
);
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
codexHome,
transcriptPollDelaysMs: [60_000],
});
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
taskRuntimeScope: createTaskScope(),
agentId: "main",
});
await notifyChildStarted(client);
await client.notify(childTurnCompletedNotification({ status: "completed" }));
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
statusLabel: "task_complete",
result: "child turn transcript result",
}),
);
client.close();
});
it("does not reuse an interrupted child turn's message after resuming", async () => {
const client = createClient();
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime);
monitor.registerParent({
parentThreadId: "parent-thread",
requesterSessionKey: "agent:main:discord:channel:C123",
taskRuntimeScope: createTaskScope(),
agentId: "main",
});
await notifyChildStarted(client);
await client.notify({
method: "item/completed",
params: {
threadId: "child-thread",
turnId: "child-turn",
item: {
type: "agentMessage",
id: "msg-child-partial",
text: "partial child result",
},
},
});
await client.notify({
method: "thread/status/changed",
params: {
threadId: "child-thread",
status: { type: "idle" },
},
});
await client.notify(childTurnCompletedNotification({ status: "interrupted" }));
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
await client.notify(
childTurnCompletedNotification({ status: "completed", turnId: "resumed-child-turn" }),
);
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1);
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
status: "succeeded",
result: "Codex native subagent completed without a final assistant message.",
}),
);
client.close();
});

View file

@ -51,14 +51,11 @@ type ParentState = {
type ChildState = {
childThreadId: string;
parentThreadId: string;
assistantMessagesByTurn: Map<string, ChildAssistantMessages>;
transcriptPath?: string;
transcriptPollAttempt: number;
transcriptPollTimer?: ReturnType<typeof setTimeout>;
transcriptTerminal: boolean;
idle: boolean;
lastAgentMessage?: string;
lastAgentMessageAt?: number;
agentMessageCompletionDelivered: boolean;
pendingCompletion?: CodexNativeSubagentCompletion;
pendingCompletionEventAt?: number;
completionDeliveryAttempt: number;
@ -67,6 +64,13 @@ type ChildState = {
noFinalCompletionFallbackTimer?: ReturnType<typeof setTimeout>;
};
type ChildAssistantMessages = {
texts: Map<string, string>;
order: string[];
commentaryIds: Set<string>;
finalMessageIds: Set<string>;
};
type TranscriptCompletion = CodexNativeSubagentCompletion & {
parentThreadId?: string;
completedAt?: number;
@ -215,10 +219,9 @@ export class CodexNativeSubagentMonitor {
});
}
}
const childThreadId = this.recordChildAgentMessage(notification);
const idleChildThreadId = this.recordChildIdle(notification);
this.captureChildAssistantMessage(notification);
await this.handleChildTurnCompletion(notification);
await this.handleCompletionNotification(notification);
await this.processChildAgentMessageCompletion(childThreadId ?? idleChildThreadId);
}
private ensureParentTaskRuntime(state: ParentState): void {
@ -299,7 +302,11 @@ export class CodexNativeSubagentMonitor {
nativeCompletion.agentPath,
);
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (!childState || childState.parentThreadId !== state.parentThreadId) {
if (
!childState ||
childState.parentThreadId !== state.parentThreadId ||
childState.transcriptTerminal
) {
embeddedAgentLog.warn(
"Ignoring Codex native subagent completion for unknown child thread",
{
@ -310,21 +317,151 @@ export class CodexNativeSubagentMonitor {
continue;
}
const completion = toThreadCompletion(nativeCompletion, childState.childThreadId);
if (shouldWaitForTranscriptCompletion(completion, this.codexHome)) {
// Codex can notify `completed: null` before the child transcript exposes
// its final assistant message; poll briefly before delivering the no-final fallback.
const eventAt = Date.now();
const reconciled = await this.reconcileChildTranscript(childState.childThreadId);
if (!reconciled) {
this.scheduleTranscriptPoll(childState);
this.scheduleNoFinalCompletionFallback(state, childState, completion, eventAt);
}
continue;
}
await this.processCompletion(state, completion);
await this.processChildCompletion(state, childState, completion);
}
}
private captureChildAssistantMessage(notification: CodexServerNotification): void {
const params = isJsonObject(notification.params) ? notification.params : undefined;
const childThreadId = readString(params, "threadId")?.trim();
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (!childState || childState.transcriptTerminal) {
return;
}
if (notification.method === "item/agentMessage/delta") {
const turnId = readString(params, "turnId");
const itemId = readString(params, "itemId");
const delta = readString(params, "delta");
if (turnId && itemId && delta) {
this.recordChildAssistantMessage(childState, turnId, itemId, delta);
}
return;
}
if (notification.method !== "item/started" && notification.method !== "item/completed") {
return;
}
const turnId = readString(params, "turnId");
const item = isJsonObject(params?.item) ? params.item : undefined;
this.captureChildAssistantMessageItem(childState, turnId, item);
}
private captureChildAssistantMessageItem(
childState: ChildState,
turnId: string | undefined,
item: JsonObject | undefined,
): void {
if (readString(item, "type") !== "agentMessage") {
return;
}
const itemId = readString(item, "id");
if (!turnId || !itemId) {
return;
}
const assistantMessages = this.getChildAssistantMessages(childState, turnId);
const phase = readString(item, "phase");
if (phase === "commentary") {
assistantMessages.commentaryIds.add(itemId);
} else {
assistantMessages.finalMessageIds.add(itemId);
}
const text = readString(item, "text");
if (text) {
this.recordChildAssistantMessage(childState, turnId, itemId, text, { replace: true });
}
}
private captureChildTurnAssistantMessages(childState: ChildState, turn: JsonObject): void {
const turnId = readString(turn, "id");
if (!turnId || !Array.isArray(turn.items)) {
return;
}
for (const item of turn.items) {
this.captureChildAssistantMessageItem(
childState,
turnId,
isJsonObject(item) ? item : undefined,
);
}
}
private recordChildAssistantMessage(
childState: ChildState,
turnId: string,
itemId: string,
text: string,
options: { replace?: boolean } = {},
): void {
const assistantMessages = this.getChildAssistantMessages(childState, turnId);
if (!assistantMessages.texts.has(itemId)) {
assistantMessages.order.push(itemId);
}
const existing = assistantMessages.texts.get(itemId) ?? "";
assistantMessages.texts.set(itemId, options.replace ? text : `${existing}${text}`);
}
private getChildAssistantMessages(
childState: ChildState,
turnId: string,
): ChildAssistantMessages {
const existing = childState.assistantMessagesByTurn.get(turnId);
if (existing) {
return existing;
}
const assistantMessages: ChildAssistantMessages = {
texts: new Map<string, string>(),
order: [],
commentaryIds: new Set<string>(),
finalMessageIds: new Set<string>(),
};
childState.assistantMessagesByTurn.set(turnId, assistantMessages);
return assistantMessages;
}
private async handleChildTurnCompletion(notification: CodexServerNotification): Promise<void> {
if (notification.method !== "turn/completed") {
return;
}
const params = isJsonObject(notification.params) ? notification.params : undefined;
const childThreadId = readString(params, "threadId")?.trim();
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
const state = childState ? this.parentStates.get(childState.parentThreadId) : undefined;
const turn = isJsonObject(params?.turn) ? params.turn : undefined;
if (childState && turn && readString(turn, "status") === "interrupted") {
const turnId = readString(turn, "id");
if (turnId) {
childState.assistantMessagesByTurn.delete(turnId);
}
return;
}
if (childState && turn) {
this.captureChildTurnAssistantMessages(childState, turn);
}
const completion = childState && turn ? toChildTurnCompletion(childState, turn) : undefined;
if (!state || !childState || childState.transcriptTerminal || !completion) {
return;
}
await this.processChildCompletion(state, childState, completion);
}
private async processChildCompletion(
state: ParentState,
childState: ChildState,
completion: CodexNativeSubagentCompletion,
): Promise<void> {
if (shouldWaitForTranscriptCompletion(completion, this.codexHome)) {
// Codex can notify `completed: null` before the child transcript exposes
// its final assistant message; poll briefly before delivering the no-final fallback.
const eventAt = Date.now();
const reconciled = await this.reconcileChildTranscript(childState.childThreadId);
if (!reconciled) {
this.scheduleTranscriptPoll(childState);
this.scheduleNoFinalCompletionFallback(state, childState, completion, eventAt);
}
return;
}
await this.processCompletion(state, completion);
}
async reconcileChildTranscript(
childThreadId: string,
options: { allowTreeScan?: boolean } = {},
@ -557,10 +694,9 @@ export class CodexNativeSubagentMonitor {
childState = {
childThreadId: normalizedChildThreadId,
parentThreadId: normalizedParentThreadId,
assistantMessagesByTurn: new Map<string, ChildAssistantMessages>(),
transcriptPollAttempt: 0,
transcriptTerminal: false,
idle: false,
agentMessageCompletionDelivered: false,
completionDeliveryAttempt: 0,
};
this.childStates.set(normalizedChildThreadId, childState);
@ -570,83 +706,6 @@ export class CodexNativeSubagentMonitor {
}
}
private recordChildAgentMessage(notification: CodexServerNotification): string | undefined {
if (notification.method !== "item/completed") {
return undefined;
}
const params = isJsonObject(notification.params) ? notification.params : undefined;
const item = isJsonObject(params?.item) ? params.item : undefined;
if (!params || !item || readString(item, "type") !== "agentMessage") {
return undefined;
}
const childThreadId = readString(params, "threadId")?.trim();
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (!childState || childState.transcriptTerminal) {
return undefined;
}
// Codex app-server can report the child final answer as the child thread's
// own agentMessage without also emitting a parent subagent notification.
// Pair it with idle below so commentary does not become a false terminal.
const phase = readString(item, "phase");
if (phase === "commentary") {
return undefined;
}
const text = readString(item, "text")?.trim();
if (!text) {
return undefined;
}
childState.lastAgentMessage = text;
childState.lastAgentMessageAt = Date.now();
return childState.childThreadId;
}
private recordChildIdle(notification: CodexServerNotification): string | undefined {
if (notification.method !== "thread/status/changed") {
return undefined;
}
const params = isJsonObject(notification.params) ? notification.params : undefined;
if (!params || !isJsonObject(params.status) || readString(params.status, "type") !== "idle") {
return undefined;
}
const childThreadId = readString(params, "threadId")?.trim();
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (!childState || childState.transcriptTerminal) {
return undefined;
}
childState.idle = true;
return childState.childThreadId;
}
private async processChildAgentMessageCompletion(
childThreadId: string | undefined,
): Promise<void> {
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
if (
!childState ||
!childState.idle ||
childState.transcriptTerminal ||
childState.agentMessageCompletionDelivered ||
!childState.lastAgentMessage
) {
return;
}
const state = this.parentStates.get(childState.parentThreadId);
if (!state) {
return;
}
childState.agentMessageCompletionDelivered = true;
await this.processCompletion(
state,
{
childThreadId: childState.childThreadId,
status: "succeeded",
statusLabel: "agent_message",
result: childState.lastAgentMessage,
},
childState.lastAgentMessageAt,
);
}
private ensureChildState(parentThreadId: string, childThreadId: string): ChildState {
this.registerChildThread(parentThreadId, childThreadId);
return this.childStates.get(childThreadId.trim())!;
@ -970,6 +1029,62 @@ function buildCompletionDedupeKey(
return `${parentThreadId}:${completion.childThreadId}:${completion.status}:${hash}`;
}
function toChildTurnCompletion(
childState: ChildState,
turn: JsonObject,
): CodexNativeSubagentCompletion | undefined {
const status = readString(turn, "status");
if (status === "completed") {
const turnId = readString(turn, "id");
const result = turnId ? lastChildAssistantMessage(childState, turnId) : undefined;
return {
childThreadId: childState.childThreadId,
status: "succeeded",
statusLabel: result ? "turn_completed" : "completed_without_final_message",
result: result ?? "Codex native subagent completed without a final assistant message.",
};
}
if (status === "failed") {
return {
childThreadId: childState.childThreadId,
status: "failed",
statusLabel: "turn_failed",
result: readTurnErrorMessage(turn) ?? "Codex native subagent failed.",
};
}
return undefined;
}
function lastChildAssistantMessage(childState: ChildState, turnId: string): string | undefined {
const assistantMessages = childState.assistantMessagesByTurn.get(turnId);
if (!assistantMessages) {
return undefined;
}
for (let index = assistantMessages.order.length - 1; index >= 0; index -= 1) {
const itemId = assistantMessages.order[index];
if (
assistantMessages.finalMessageIds.has(itemId) &&
!assistantMessages.commentaryIds.has(itemId)
) {
const text = normalizeOptionalString(assistantMessages.texts.get(itemId));
if (text) {
return text;
}
}
}
return undefined;
}
function readTurnErrorMessage(turn: JsonObject): string | undefined {
const error = isJsonObject(turn.error) ? turn.error : undefined;
return (
normalizeOptionalString(readString(error, "message")) ??
normalizeOptionalString(
isJsonObject(error?.codexErrorInfo) ? readString(error.codexErrorInfo, "message") : undefined,
)
);
}
function buildParentAgentPathKey(parentThreadId: string, agentPath: string): string {
return `${parentThreadId}\0${agentPath}`;
}

View file

@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
COPILOT_DEFAULT_AGENT_ID,
COPILOT_TOKEN_PROFILE_ERROR,
normalizeCopilotHomePath,
resolveCopilotAuth,
sanitizeAgentId,
tokenFingerprint,
@ -508,18 +507,3 @@ describe("resolveCopilotAuth - defaults wiring", () => {
);
});
});
describe("normalizeCopilotHomePath", () => {
it("resolves to absolute and strips trailing separators", () => {
const normalized = normalizeCopilotHomePath("./foo/bar/");
expect(normalized).toBe(resolve("./foo/bar"));
expect(normalized.endsWith("/")).toBe(false);
expect(normalized.endsWith("\\")).toBe(false);
});
it("is idempotent", () => {
const once = normalizeCopilotHomePath("/some/path/");
const twice = normalizeCopilotHomePath(once);
expect(twice).toBe(once);
});
});

View file

@ -1,7 +1,7 @@
// Copilot plugin module implements auth bridge behavior.
import { createHash } from "node:crypto";
import { homedir as osHomedir } from "node:os";
import { join, normalize, resolve, sep } from "node:path";
import { join, resolve } from "node:path";
/**
* Pure functional auth resolver for the copilot agent runtime.
@ -307,16 +307,3 @@ export function tokenFingerprint(token: string): string {
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* Normalize a copilotHome path for cross-platform pool keying.
* Re-exported so attempt.ts / runtime.ts can share the same
* normalization without re-implementing.
*/
export function normalizeCopilotHomePath(value: string): string {
return normalize(resolve(value)).replace(new RegExp(`${escapeForRegex(sep)}+$`), "");
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
COPILOT_SDK_FALLBACK_DIR,
COPILOT_SDK_SPEC,
resetCopilotSdkCacheForTests,
loadCopilotSdk,
@ -201,10 +200,6 @@ describe("sdk-loader", () => {
expect(primaryImport).toHaveBeenCalledTimes(2);
});
it("default fallback dir points at ~/.openclaw/npm-runtime/copilot", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
it("resolves the fallback dir from OPENCLAW_STATE_DIR for relocated profiles", () => {
expect(
resolveCopilotSdkFallbackDir({
@ -220,9 +215,6 @@ describe("sdk-loader", () => {
});
describe("sdk dependency constants", () => {
it("COPILOT_SDK_FALLBACK_DIR keeps the legacy fallback path stable", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.9");
});

View file

@ -10,8 +10,6 @@ export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.en
return path.join(resolveStateDir(env), "npm-runtime", "copilot");
}
export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.9";
let cached: Promise<typeof Sdk> | undefined;

View file

@ -89,6 +89,8 @@ type ClawHubPublishablePluginPackageFilters = {
const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai";
const CLAWHUB_REQUEST_TIMEOUT_MS = 30_000;
const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 64 * 1024;
const CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const;
const CLAWHUB_MAX_RETRY_AFTER_MS = 60_000;
const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw";
const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml";
const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@ -482,43 +484,79 @@ async function hasClawHubTrustedPublisher(
`/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`,
getRegistryBaseUrl(options.registryBaseUrl),
);
const request = await fetchClawHubRequest(url, {
fetchImpl: options.fetchImpl,
requestTimeoutMs: options.requestTimeoutMs,
});
const { response } = request;
for (let attempt = 0; ; attempt += 1) {
const request = await fetchClawHubRequest(url, {
fetchImpl: options.fetchImpl,
requestTimeoutMs: options.requestTimeoutMs,
});
const { response } = request;
try {
if (!response.ok) {
throw new Error(
`Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`,
);
}
let trustedPublisherDetail: ClawHubTrustedPublisherDetail;
const text = await readBoundedResponseText(
response,
`ClawHub trusted publisher ${packageName}`,
CLAWHUB_RESPONSE_BODY_MAX_BYTES,
{
signal: request.signal,
timeoutPromise: request.timeoutPromise,
},
);
const retryRateLimit =
response.status === 429 && attempt < CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length;
try {
trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail;
} catch (error) {
throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, {
cause: error,
});
if (!retryRateLimit) {
if (!response.ok) {
throw new Error(
`Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`,
);
}
let trustedPublisherDetail: ClawHubTrustedPublisherDetail;
const text = await readBoundedResponseText(
response,
`ClawHub trusted publisher ${packageName}`,
CLAWHUB_RESPONSE_BODY_MAX_BYTES,
{
signal: request.signal,
timeoutPromise: request.timeoutPromise,
},
);
try {
trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail;
} catch (error) {
throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, {
cause: error,
});
}
return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher);
}
} finally {
request.clearTimeout();
}
return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher);
} finally {
request.clearTimeout();
await response.body?.cancel().catch(() => undefined);
await delay(clawHubRetryDelayMs(response, attempt));
}
}
function clawHubRetryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after")?.trim();
if (retryAfter) {
const retryAfterSeconds = Number(retryAfter);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
const retryAfterMs = Math.round(retryAfterSeconds * 1_000);
if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) {
return retryAfterMs;
}
}
const retryAfterAt = Date.parse(retryAfter);
if (Number.isFinite(retryAfterAt)) {
const retryAfterMs = Math.max(0, retryAfterAt - Date.now());
if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) {
return retryAfterMs;
}
}
}
return CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS[attempt] ?? 0;
}
async function delay(ms: number): Promise<void> {
await new Promise<void>((resolveDelay) => {
setTimeout(resolveDelay, ms);
});
}
function isOpenClawPluginTrustedPublisher(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
@ -581,42 +619,41 @@ export async function collectPluginClawHubReleasePlan(params?: {
assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan");
}
const planned = await Promise.all(
selectedPublishable.map(async (plugin): Promise<PluginReleasePlanItemWithPackageState> => {
const packageExists = await doesClawHubPackageExist(plugin.packageName, {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
});
const hasTrustedPublisher = packageExists
? await hasClawHubTrustedPublisher(plugin.packageName, {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
})
: false;
const alreadyPublished = packageExists
? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
})
: false;
const planned: PluginReleasePlanItemWithPackageState[] = [];
for (const plugin of selectedPublishable) {
const packageExists = await doesClawHubPackageExist(plugin.packageName, {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
});
const hasTrustedPublisher = packageExists
? await hasClawHubTrustedPublisher(plugin.packageName, {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
})
: false;
const alreadyPublished = packageExists
? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
})
: false;
return {
extensionId: plugin.extensionId,
packageDir: plugin.packageDir,
packageName: plugin.packageName,
version: plugin.version,
channel: plugin.channel,
publishTag: plugin.publishTag,
packageExists,
hasTrustedPublisher,
alreadyPublished,
artifactName: formatClawHubPackageArtifactName(plugin),
};
}),
);
planned.push({
extensionId: plugin.extensionId,
packageDir: plugin.packageDir,
packageName: plugin.packageName,
version: plugin.version,
channel: plugin.channel,
publishTag: plugin.publishTag,
packageExists,
hasTrustedPublisher,
alreadyPublished,
artifactName: formatClawHubPackageArtifactName(plugin),
});
}
const all = planned.map(stripPackageReleaseState);
return {

View file

@ -279,6 +279,7 @@ async function fetchWithRetry(
if (response.status !== 429 && response.status < 500) {
return { response, signal };
}
await cancelResponseBody(response);
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
@ -293,6 +294,10 @@ async function fetchWithRetry(
throw new Error(`${url} did not return a stable response: ${message}`);
}
async function cancelResponseBody(response: Response): Promise<void> {
await response.body?.cancel().catch(() => undefined);
}
async function fetchJsonWithRetry(url: string): Promise<unknown> {
const { response, signal } = await fetchWithRetry(
url,
@ -314,9 +319,13 @@ export async function readBoundedJsonResponse(
return parseJson(await readBoundedResponseText(response, label, maxBytes, options), label);
}
async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
export async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
const { response } = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
return response.status;
try {
return response.status;
} finally {
await cancelResponseBody(response);
}
}
async function verifyNpmPackage(

View file

@ -516,6 +516,13 @@ export function resolveNpmCommandInvocation(
if (platform === "win32" && name.endsWith(".exe")) {
return { command: npmExecPath, args: npmArgs };
}
if (platform === "win32" && name === "npm") {
return {
command: params.comSpec ?? process.env.ComSpec ?? "cmd.exe",
args: ["/d", "/s", "/c", buildCmdExeCommandLine(`${npmExecPath}.cmd`, npmArgs)],
windowsVerbatimArguments: true,
};
}
if (name.endsWith(".js") || name.endsWith(".cjs") || name.endsWith(".mjs")) {
return { command: nodeExecPath, args: [npmExecPath, ...npmArgs] };
}

View file

@ -0,0 +1,90 @@
import { formatCliCommand } from "../command-format.js";
import { getCoreCliCommandNames } from "./core-command-descriptors.js";
import { getSubCliEntries } from "./subcli-descriptors.js";
const EXPLICIT_COMMAND_ALIASES = new Map<string, string>([
["upgrade", "update"],
["udpate", "update"],
]);
const MAX_SUGGESTIONS = 3;
function uniqueSortedCommandNames(commands: Iterable<string>): string[] {
return [...new Set([...commands].filter(Boolean))].toSorted((left, right) =>
left.localeCompare(right),
);
}
export function getKnownCliCommandNames(): string[] {
return uniqueSortedCommandNames([
...getCoreCliCommandNames(),
...getSubCliEntries().map((entry) => entry.name),
]);
}
export function levenshteinDistance(left: string, right: string): number {
if (left === right) {
return 0;
}
if (left.length === 0) {
return right.length;
}
if (right.length === 0) {
return left.length;
}
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
let current = Array.from<number>({ length: right.length + 1 });
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
current[0] = leftIndex + 1;
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
current[rightIndex + 1] = Math.min(
current[rightIndex] + 1,
previous[rightIndex + 1] + 1,
previous[rightIndex] + substitutionCost,
);
}
[previous, current] = [current, previous];
}
return previous[right.length] ?? 0;
}
export function suggestCliCommands(
input: string,
candidates: Iterable<string> = getKnownCliCommandNames(),
): string[] {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const knownCommands = uniqueSortedCommandNames(candidates);
const explicitAlias = EXPLICIT_COMMAND_ALIASES.get(normalizedInput);
if (explicitAlias && knownCommands.includes(explicitAlias)) {
return [explicitAlias];
}
const maxDistance = Math.max(1, Math.floor(normalizedInput.length * 0.4));
return knownCommands
.map((command) => ({ command, distance: levenshteinDistance(normalizedInput, command) }))
.filter(({ command, distance }) => command !== normalizedInput && distance <= maxDistance)
.toSorted(
(left, right) => left.distance - right.distance || left.command.localeCompare(right.command),
)
.slice(0, MAX_SUGGESTIONS)
.map(({ command }) => command);
}
export function formatCliCommandSuggestions(input: string): string | undefined {
const suggestions = suggestCliCommands(input);
if (suggestions.length === 0) {
return undefined;
}
const commandLines = suggestions
.map((command) => ` ${formatCliCommand(`openclaw ${command}`)}`)
.join("\n");
return `Did you mean this?\n${commandLines}`;
}

View file

@ -13,6 +13,42 @@ describe("formatCliParseErrorOutput", () => {
);
});
it("suggests close known commands for unknown commands", () => {
const output = formatCliParseErrorOutput("error: unknown command 'upate'\n", {
argv: ["node", "openclaw", "upate"],
});
expect(output).toBe(
'OpenClaw does not know the command "upate".\nDid you mean this?\n openclaw update\nTry: openclaw --help\nPlugin command? openclaw plugins list\nDocs: https://docs.openclaw.ai/cli\n',
);
});
it("suggests explicit aliases for common adjacent terminology", () => {
const output = formatCliParseErrorOutput("error: unknown command 'upgrade'\n", {
argv: ["node", "openclaw", "upgrade"],
});
expect(output).toContain("Did you mean this?\n openclaw update\n");
});
it("preserves active profile context in command suggestions", () => {
const originalProfile = process.env.OPENCLAW_PROFILE;
process.env.OPENCLAW_PROFILE = "work";
try {
const output = formatCliParseErrorOutput("error: unknown command 'doctr'\n", {
argv: ["node", "openclaw", "doctr"],
});
expect(output).toContain("Did you mean this?\n openclaw --profile work doctor\n");
} finally {
if (originalProfile === undefined) {
delete process.env.OPENCLAW_PROFILE;
} else {
process.env.OPENCLAW_PROFILE = originalProfile;
}
}
});
it("points unknown options at the active command help", () => {
const output = formatCliParseErrorOutput("error: unknown option '--wat'\n", {
argv: ["node", "openclaw", "channels", "status", "--wat"],

View file

@ -3,6 +3,7 @@ import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { getCommandPathWithRootOptions } from "../argv.js";
import { formatCliCommand } from "../command-format.js";
import { formatCliCommandSuggestions } from "./command-suggestions.js";
type FormatCliParseErrorOptions = {
argv?: string[];
@ -53,6 +54,7 @@ export function formatCliParseErrorOutput(
const command = unknownCommand[1] ?? "";
return lines(
theme.error(`OpenClaw does not know the command ${quote(command)}.`),
formatCliCommandSuggestions(command),
formatHelpHint(options.argv, { root: true }),
`${theme.muted("Plugin command?")} ${theme.command(formatCliCommand("openclaw plugins list"))}`,
formatDocsHint(),

View file

@ -2148,6 +2148,41 @@ describe("runCli exit behavior", () => {
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("suggests close known commands for unowned command roots before proxy startup", async () => {
await expect(runCli(["node", "openclaw", "upate"])).rejects.toThrow(
"Did you mean this?\n openclaw update",
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("keeps suggestions out of plugin-policy diagnostics", async () => {
resolveManifestCommandAliasOwnerMock.mockReturnValueOnce({
pluginId: "codex",
kind: "runtime-slash",
cliCommand: "plugins",
});
let error: unknown;
try {
await runCli(["node", "openclaw", "codex"]);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("runtime slash command");
expect((error as Error).message).toContain("/codex");
expect((error as Error).message).not.toContain("Did you mean this?");
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("rejects unowned command roots even when --help is appended (regression for #81077)", async () => {
await expect(runCli(["node", "openclaw", "foo", "--help"])).rejects.toThrow(
'No built-in command or plugin CLI metadata owns "foo"',

View file

@ -33,6 +33,7 @@ import {
} from "./gateway-run-argv.js";
import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { formatCliCommandSuggestions } from "./program/command-suggestions.js";
import { getCoreCliCommandNames } from "./program/core-command-descriptors.js";
import { getSubCliEntries } from "./program/subcli-descriptors.js";
import {
@ -600,14 +601,25 @@ async function resolveUnownedCliPrimaryMessage(params: {
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await loadManifestCommandAliasesRuntimeModule();
const cliCommandSurfaceOwner = await resolveCliCommandSurfaceOwner(params);
return (
resolveMissingPluginCommandMessageFromPolicy(params.primary, params.config, {
const pluginPolicyMessage = resolveMissingPluginCommandMessageFromPolicy(
params.primary,
params.config,
{
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
resolveCliCommandSurfaceOwner: () => cliCommandSurfaceOwner,
}) ??
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`
},
);
if (pluginPolicyMessage) {
return pluginPolicyMessage;
}
const suggestion = formatCliCommandSuggestions(params.primary);
return [
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`,
suggestion,
]
.filter(Boolean)
.join("\n");
}
async function bootstrapCliProxyCaptureAndDispatcher(

View file

@ -349,6 +349,21 @@ describe("resolveNpmCommandInvocation", () => {
});
});
it("wraps bare Windows npm_execpath through npm.cmd", () => {
expect(
resolveNpmCommandInvocation({
comSpec: "C:\\Windows\\System32\\cmd.exe",
npmArgs: ["view", "openclaw@beta", "version"],
npmExecPath: "npm",
platform: "win32",
}),
).toEqual({
command: "C:\\Windows\\System32\\cmd.exe",
args: ["/d", "/s", "/c", "npm.cmd view openclaw@beta version"],
windowsVerbatimArguments: true,
});
});
it("wraps Windows npm_execpath command shims", () => {
expect(
resolveNpmCommandInvocation({

View file

@ -9,7 +9,7 @@ import {
writeFileSync,
} from "node:fs";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildOpenClawReleaseClawHubPlan,
buildOpenClawReleaseClawHubRuntimeState,
@ -452,6 +452,166 @@ describe("collectPluginClawHubReleasePlan", () => {
expect(canceled).toEqual(["package", "version"]);
});
it("retries a rate-limited trusted publisher lookup", async () => {
const repoDir = createTempPluginRepo();
let trustedPublisherRequests = 0;
let rateLimitedBodyCanceled = false;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const pathname = new URL(requestUrl).pathname;
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") {
return new Response("{}", { status: 200 });
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = Date.now();
return new Response(
new ReadableStream({
cancel() {
rateLimitedBodyCanceled = true;
},
}),
{ status: 429 },
);
}
retryTrustedPublisherRequestAt = Date.now();
return new Response(
JSON.stringify({
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
}),
{ status: 200 },
);
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") {
return new Response("", { status: 404 });
}
throw new Error(`Unexpected ClawHub request to ${pathname}`);
};
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(trustedPublisherRequests).toBe(2);
expect(rateLimitedBodyCanceled).toBe(true);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]);
});
it("honors an HTTP-date Retry-After header", async () => {
const repoDir = createTempPluginRepo();
const retryAfter = "Wed, 21 Oct 2030 07:28:00 GMT";
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(retryAfter) - 1_000);
let trustedPublisherRequests = 0;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const pathname = new URL(requestUrl).pathname;
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") {
return new Response("{}", { status: 200 });
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = performance.now();
return new Response("", { status: 429, headers: { "retry-after": retryAfter } });
}
retryTrustedPublisherRequestAt = performance.now();
return new Response(
JSON.stringify({
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
}),
{ status: 200 },
);
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") {
return new Response("", { status: 404 });
}
throw new Error(`Unexpected ClawHub request to ${pathname}`);
};
try {
await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
} finally {
nowSpy.mockRestore();
}
expect(trustedPublisherRequests).toBe(2);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
});
it("falls back to the bounded retry schedule for an excessive Retry-After header", async () => {
const repoDir = createTempPluginRepo();
let trustedPublisherRequests = 0;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const pathname = new URL(requestUrl).pathname;
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") {
return new Response("{}", { status: 200 });
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = Date.now();
return new Response("", { status: 429, headers: { "retry-after": "999999999999" } });
}
retryTrustedPublisherRequestAt = Date.now();
return new Response(
JSON.stringify({
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
}),
{ status: 200 },
);
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") {
return new Response("", { status: 404 });
}
throw new Error(`Unexpected ClawHub request to ${pathname}`);
};
await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(trustedPublisherRequests).toBe(2);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
});
it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({

View file

@ -1,12 +1,18 @@
// Release Beta Verifier tests cover release beta verifier script behavior.
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchStatusWithRetry,
parseNpmViewFields,
parseReleaseVerifyBetaArgs,
readBoundedJsonResponse,
runNpmViewWithRetry,
} from "../../scripts/lib/release-beta-verifier.ts";
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("parseReleaseVerifyBetaArgs", () => {
it("defaults beta verification to the matching tag and repo", () => {
expect(parseReleaseVerifyBetaArgs(["2026.5.10-beta.3"])).toEqual({
@ -152,6 +158,46 @@ describe("runNpmViewWithRetry", () => {
});
});
describe("fetchStatusWithRetry", () => {
it("cancels retryable and returned GET response bodies", async () => {
vi.useFakeTimers();
const canceled: string[] = [];
const responses = [
new Response(
new ReadableStream<Uint8Array>({
cancel() {
canceled.push("retry");
},
}),
{ status: 500 },
),
new Response(
new ReadableStream<Uint8Array>({
cancel() {
canceled.push("final");
},
}),
{ status: 200 },
),
];
const fetchImpl = vi.fn(async () => {
const response = responses.shift();
if (!response) {
throw new Error("unexpected fetch call");
}
return response;
});
vi.stubGlobal("fetch", fetchImpl);
const status = fetchStatusWithRetry("https://clawhub.test/api/v1/package", "GET");
await vi.advanceTimersByTimeAsync(1000);
await expect(status).resolves.toBe(200);
expect(canceled).toEqual(["retry", "final"]);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
});
describe("readBoundedJsonResponse", () => {
it("parses JSON bodies within the release verifier limit", async () => {
await expect(