Compare commits

..

90 Commits

Author SHA1 Message Date
Vincent Koc
e0cfdbf166 fix(tui): continue goal commands after creation 2026-05-30 12:44:49 +01:00
Peter Steinberger
ec7e3eaf64 fix(ui): guard chat picker session timestamps 2026-05-30 07:15:40 -04:00
Vincent Koc
8bcdab8933 refactor: share oauth identity safety check 2026-05-30 13:14:10 +02:00
Peter Steinberger
c2f0d811e7 fix(ui): guard next run weekday formatting 2026-05-30 07:12:51 -04:00
Peter Steinberger
8f3d3a549d fix(ui): guard usage chart timestamps 2026-05-30 07:10:21 -04:00
Peter Steinberger
d389a52494 fix(ui): centralize invalid date formatting 2026-05-30 07:07:13 -04:00
Vincent Koc
346b14a51a fix(test): route conventional script tests 2026-05-30 13:00:33 +02:00
Vincent Koc
ffa2da8478 fix(test): skip broad changed import scans 2026-05-30 13:00:33 +02:00
Vincent Koc
61a768be75 fix(test): route script library changes 2026-05-30 13:00:33 +02:00
Vincent Koc
3d8a77a113 fix(test): route package tooling changes 2026-05-30 13:00:33 +02:00
Vincent Koc
a6a358f1a6 fix(test): route ci tooling changes 2026-05-30 13:00:33 +02:00
Vincent Koc
131dc4eaeb fix(test): route workflow helper changes 2026-05-30 13:00:33 +02:00
Vincent Koc
022fd55bad fix(test): route crabbox changed tests 2026-05-30 13:00:33 +02:00
Vincent Koc
d9820e4098 fix(ci): disable crabbox on-demand fallback 2026-05-30 13:00:33 +02:00
Vincent Koc
a4ebdc9aa1 fix(test): guard run-with-env help 2026-05-30 13:00:32 +02:00
Vincent Koc
cf2461f7f6 fix(test): guard live runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
f5f829db79 fix(test): guard tsdown runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
a06daab97e fix(test): guard build runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
09f094057a fix(test): guard verify runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
9def042fab fix(test): guard check runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
f6adea5757 fix(test): guard force runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
78f4a5c05f fix(tooling): ignore inline type-only re-exports 2026-05-30 13:00:32 +02:00
Vincent Koc
731a7af9c5 fix(test): keep wrapper help metadata-only 2026-05-30 13:00:32 +02:00
Vincent Koc
ffa4342a6a fix(test): route docker e2e script targets 2026-05-30 13:00:32 +02:00
Vincent Koc
550a134cf9 fix(tooling): forward oxlint shard cancellation 2026-05-30 13:00:32 +02:00
Vincent Koc
1b43e84d0d fix(test): batch explicit source route resolution 2026-05-30 13:00:32 +02:00
Vincent Koc
31f0635f4f fix(test): route explicit source targets narrowly 2026-05-30 13:00:31 +02:00
Vincent Koc
1c65e2e7c1 fix(tooling): bound oxlint shard stalls 2026-05-30 13:00:31 +02:00
Vincent Koc
b6f3fe7938 fix(test): route explicit helper targets narrowly 2026-05-30 13:00:31 +02:00
Vincent Koc
d65b3a68aa perf(cli): keep plugins JSON list on snapshot path 2026-05-30 13:00:31 +02:00
Vincent Koc
e2b54fecd8 fix(doctor): reuse lazy state migration import 2026-05-30 13:00:31 +02:00
Vincent Koc
b8067d073a fix(extensions): keep subagent hook facades lazy 2026-05-30 13:00:31 +02:00
Vincent Koc
e420c001d0 perf(policy): cache doctor file reads 2026-05-30 13:00:31 +02:00
Vincent Koc
44b6b79a66 perf(plugin-sdk): cache runtime helper imports 2026-05-30 13:00:31 +02:00
Vincent Koc
3ef2935ac9 perf(browser): reuse chrome mcp import 2026-05-30 13:00:31 +02:00
Vincent Koc
fced29de17 perf(extensions): cache meeting runtime loaders 2026-05-30 13:00:31 +02:00
Vincent Koc
4f074c3235 perf(extensions): cache plugin runtime loaders 2026-05-30 13:00:31 +02:00
Vincent Koc
5df00520cb perf(extensions): cache provider runtime imports 2026-05-30 13:00:30 +02:00
Vincent Koc
b2c85bc0a2 perf(browser): cache registration runtime import 2026-05-30 13:00:30 +02:00
Vincent Koc
5e2e78a75a perf(wizard): cache setup migration imports 2026-05-30 13:00:30 +02:00
Vincent Koc
2196f107da perf(gateway): cache post-attach startup imports 2026-05-30 13:00:30 +02:00
Vincent Koc
ff56a2d7b3 perf(gateway): cache plugin bootstrap imports 2026-05-30 13:00:30 +02:00
Vincent Koc
24cff8a3bc perf(gateway): share model catalog module loader 2026-05-30 13:00:30 +02:00
Vincent Koc
b495ac2abb perf(gateway): cache remote skills startup import 2026-05-30 13:00:30 +02:00
Vincent Koc
3f2585424d perf(gateway): cache plugin HTTP imports 2026-05-30 13:00:30 +02:00
Vincent Koc
9d1a3007d9 perf(gateway): cache model catalog imports 2026-05-30 13:00:30 +02:00
Vincent Koc
b5c163dffa test(doctor): complete browser health mock 2026-05-30 13:00:30 +02:00
Vincent Koc
ee0cf9e5bb perf(gateway): cache session event imports 2026-05-30 13:00:30 +02:00
Vincent Koc
37fdfa0e0b perf(doctor): cache health contribution imports 2026-05-30 13:00:30 +02:00
Vincent Koc
d550b804b8 perf(doctor): cache core check imports 2026-05-30 13:00:30 +02:00
Vincent Koc
05988500bc perf(crestodian): cache operation imports 2026-05-30 13:00:29 +02:00
Vincent Koc
b01290cf64 perf(cli): cache command ownership imports 2026-05-30 13:00:29 +02:00
Vincent Koc
117f6fb254 test(agents): complete provider runtime mock 2026-05-30 13:00:29 +02:00
Vincent Koc
c363816fea perf(cli): cache runtime startup imports 2026-05-30 13:00:29 +02:00
Vincent Koc
aeed31cdb1 perf(cli): cache root help imports 2026-05-30 13:00:29 +02:00
Vincent Koc
58c8c022c5 perf(entry): cache root help module imports 2026-05-30 13:00:29 +02:00
Vincent Koc
2cfae61743 perf(onboarding): split ClawHub install error codes 2026-05-30 13:00:29 +02:00
Vincent Koc
c6b4daf426 perf(health): remove duplicate config import 2026-05-30 13:00:29 +02:00
Vincent Koc
348fabe04d perf(auto-reply): remove reset model duplicate import 2026-05-30 13:00:29 +02:00
Vincent Koc
6c83e8e7e4 perf(models): cache provider index catalog import 2026-05-30 13:00:29 +02:00
Vincent Koc
817b6259c4 perf(agents): cache live model runtime import 2026-05-30 13:00:29 +02:00
Vincent Koc
959af0fa5b perf(cli): cache secrets command imports 2026-05-30 13:00:29 +02:00
Vincent Koc
669b26a3dc perf(cli): cache routed command imports 2026-05-30 13:00:28 +02:00
Vincent Koc
67c139fc36 perf(cli): cache status command imports 2026-05-30 13:00:28 +02:00
Vincent Koc
8b6829e1bc perf(cli): cache plugin runtime imports 2026-05-30 13:00:28 +02:00
Vincent Koc
86e6fbcf52 perf(cli): cache agent bind command import 2026-05-30 13:00:28 +02:00
Vincent Koc
9b4b3aa348 perf(cli): cache plugins command imports 2026-05-30 13:00:28 +02:00
Vincent Koc
51ab2c0d79 perf(cli): cache models runtime import 2026-05-30 13:00:28 +02:00
Vincent Koc
bdd9c70787 perf(cli): cache devices runtime import 2026-05-30 13:00:28 +02:00
Vincent Koc
1ff95ff3e6 perf(doctor): cache health config import 2026-05-30 13:00:28 +02:00
Peter Steinberger
7c5b55c5ff fix(ui): ignore invalid reset timestamps 2026-05-30 07:00:01 -04:00
Vincent Koc
b0d6076208 refactor: share setup dashboard open flow 2026-05-30 12:55:19 +02:00
Peter Steinberger
4385e57dce fix(doctor): tolerate invalid cron atMs 2026-05-30 06:54:58 -04:00
Vincent Koc
eb45c1c623 fix(scripts): report missing workflow linter fallback 2026-05-30 12:52:54 +02:00
Peter Steinberger
adf981de89 fix(imessage): tolerate invalid catchup cursor timestamps 2026-05-30 06:46:09 -04:00
Peter Steinberger
023a101b91 fix(heartbeat): tolerate invalid commitment due timestamps 2026-05-30 06:41:16 -04:00
Peter Steinberger
8b92aca27f refactor: extract media understanding common package (#88297)
* refactor: extract media understanding common package

* test: move media understanding format test
2026-05-30 12:40:49 +02:00
Peter Steinberger
b13fb788b5 fix(commitments): tolerate invalid due timestamps 2026-05-30 06:36:49 -04:00
Vincent Koc
87c0ee7685 refactor: share config observe recovery restore helpers 2026-05-30 12:35:36 +02:00
Peter Steinberger
eef32e94c7 fix(memory-wiki): tolerate invalid source mtimes 2026-05-30 06:33:13 -04:00
Peter Steinberger
1350efcfd8 fix(acp): tolerate invalid status timestamps 2026-05-30 06:27:44 -04:00
Peter Steinberger
e7ef051149 fix(slack): tolerate invalid interaction datetimes 2026-05-30 06:23:39 -04:00
Peter Steinberger
2b5ddf8f2a fix(acp): tolerate invalid session timestamps 2026-05-30 06:19:44 -04:00
Vincent Koc
6f655573d3 refactor: share parallels smoke lifecycle 2026-05-30 12:18:46 +02:00
Peter Steinberger
8aabf45ddb fix(memory-wiki): tolerate invalid chatgpt timestamps 2026-05-30 06:16:03 -04:00
Peter Steinberger
4d4748e807 fix(voice-call): tolerate invalid ended timestamps 2026-05-30 06:10:40 -04:00
Peter Steinberger
439c09668e fix(ui): ignore invalid usage export timestamps 2026-05-30 06:06:19 -04:00
Peter Steinberger
54bbe87cd5 fix(ui): ignore invalid chat export timestamps 2026-05-30 06:02:38 -04:00
Peter Steinberger
6804b7cb71 fix(matrix): ignore invalid device timestamps 2026-05-30 05:59:10 -04:00
Peter Steinberger
63470e99f0 fix(session): tolerate invalid lifecycle expiry 2026-05-30 05:53:24 -04:00
201 changed files with 6236 additions and 2359 deletions

View File

@@ -6,18 +6,10 @@ class: standard
capacity:
market: spot
strategy: most-available
fallback: on-demand-after-120s
# Fail closed instead of silently falling back to on-demand while the
# Azure-backed billing account is the default runner path.
fallback: spot-only
hints: true
availabilityZones:
- eu-west-1a
- eu-west-1b
- eu-west-1c
regions:
- eu-west-1
- eu-west-2
- eu-central-1
- us-east-1
- us-west-2
actions:
workflow: .github/workflows/crabbox-hydrate.yml
# Default AWS hydration uses local Actions replay. Use
@@ -37,6 +29,8 @@ blacksmith:
job: check
ref: main
aws:
# AWS-specific overrides still pin direct `--provider aws` runs without
# leaking AWS region names into the Azure default capacity fallback list.
region: eu-west-1
rootGB: 400
sync:

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
@@ -33,6 +34,14 @@ Docs: https://docs.openclaw.ai
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, and single-entry store writes.
## 2026.5.28

View File

@@ -17,6 +17,13 @@ export {
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
import { hasAnthropicVertexAvailableAuth } from "./region.js";
let streamRuntimeModulePromise: Promise<typeof import("./stream-runtime.js")> | null = null;
const loadStreamRuntimeModule = async () => {
streamRuntimeModulePromise ??= import("./stream-runtime.js");
return await streamRuntimeModulePromise;
};
export function mergeImplicitAnthropicVertexProvider(params: {
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
@@ -50,7 +57,7 @@ export function createAnthropicVertexStreamFn(
baseURL?: string,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
);
return async (model, context, options) => {
@@ -64,7 +71,7 @@ export function createAnthropicVertexStreamFnForModel(
env: NodeJS.ProcessEnv = process.env,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
);
return async (...args) => {

View File

@@ -15,6 +15,15 @@ import { BrowserToolSchema } from "./src/browser-tool.schema.js";
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
let browserRegistrationRuntimeModulePromise: Promise<
typeof import("./register.runtime.js")
> | null = null;
const loadBrowserRegistrationRuntimeModule = async () => {
browserRegistrationRuntimeModulePromise ??= import("./register.runtime.js");
return await browserRegistrationRuntimeModulePromise;
};
function isTruthyEnvValue(value: string | undefined): boolean {
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
}
@@ -51,7 +60,7 @@ function createLazyBrowserTool(opts?: {
].join(" "),
parameters: BrowserToolSchema,
execute: async (toolCallId, args, signal, onUpdate) => {
const { createBrowserTool } = await import("./register.runtime.js");
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
const tool = createBrowserTool(opts);
return await tool.execute(toolCallId, args, signal, onUpdate);
},
@@ -65,7 +74,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
command: "browser.proxy",
cap: "browser",
handle: async (paramsJSON) => {
const { runBrowserProxyCommand } = await import("./register.runtime.js");
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
return await runBrowserProxyCommand(paramsJSON);
},
},
@@ -73,7 +82,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
async (ctx) => {
const { collectBrowserSecurityAuditFindings } = await import("./register.runtime.js");
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
return collectBrowserSecurityAuditFindings(ctx);
},
];
@@ -82,7 +91,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
let service: OpenClawPluginService | null = null;
const loadService = async () => {
if (!service) {
const { createBrowserPluginService } = await import("./register.runtime.js");
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
service = createBrowserPluginService();
}
return service;
@@ -124,7 +133,7 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
api.registerGatewayMethod(
BROWSER_REQUEST_GATEWAY_METHOD,
async (opts) => {
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
return await handleBrowserGatewayRequest(opts);
},
{

View File

@@ -1,6 +1,6 @@
import { redactCdpUrl } from "../cdp.helpers.js";
import { snapshotAria } from "../cdp.js";
import { getChromeMcpPid } from "../chrome-mcp.js";
import { getChromeMcpPid, takeChromeMcpSnapshot } from "../chrome-mcp.js";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { resolveManagedBrowserHeadlessMode } from "../config.js";
import { buildBrowserDoctorReport } from "../doctor.js";
@@ -227,7 +227,6 @@ async function runBrowserLiveProbe(req: BrowserRequest, ctx: BrowserRouteContext
try {
const tab = await profileCtx.ensureTabAvailable();
if (capabilities.usesChromeMcp) {
const { takeChromeMcpSnapshot } = await import("../chrome-mcp.js");
await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,

View File

@@ -13,13 +13,20 @@ export type CodexAppServerClientFactory = (
config?: AuthProfileOrderConfig,
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
const loadSharedClientModule = async () => {
sharedClientModulePromise ??= import("./shared-client.js");
return await sharedClientModulePromise;
};
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
) =>
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);
@@ -29,6 +36,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
agentDir,
config,
) =>
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);

View File

@@ -251,6 +251,13 @@ function applyNewAppSecurityPolicy(
// Scan-to-create flow
// ---------------------------------------------------------------------------
let appRegistrationModulePromise: Promise<typeof import("./app-registration.js")> | null = null;
const loadAppRegistrationModule = async () => {
appRegistrationModulePromise ??= import("./app-registration.js");
return await appRegistrationModulePromise;
};
async function promptFeishuDomain(params: {
prompter: WizardPrompter;
initialValue?: FeishuDomain;
@@ -281,7 +288,7 @@ async function runScanToCreate(
domain: FeishuDomain,
): Promise<AppRegistrationResult | null> {
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
await import("./app-registration.js");
await loadAppRegistrationModule();
try {
await initAppRegistration(domain);
} catch {
@@ -392,7 +399,7 @@ async function runNewAppFlow(params: {
// Fetch openId via API for manual flow.
if (appId && appSecretProbeValue) {
const { getAppOwnerOpenId } = await import("./app-registration.js");
const { getAppOwnerOpenId } = await loadAppRegistrationModule();
scanOpenId = await getAppOwnerOpenId({
appId,
appSecret: appSecretProbeValue,

View File

@@ -23,9 +23,3 @@ export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
handleFeishuSubagentEnded(event);
});
}
export {
handleFeishuSubagentDeliveryTarget,
handleFeishuSubagentEnded,
handleFeishuSubagentSpawning,
} from "./src/subagent-hooks.js";

View File

@@ -37,6 +37,19 @@ import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
let googleMeetCreateModulePromise: Promise<typeof import("./src/create.js")> | null = null;
let googleMeetCliModulePromise: Promise<typeof import("./src/cli.js")> | null = null;
const loadGoogleMeetCreateModule = async () => {
googleMeetCreateModulePromise ??= import("./src/create.js");
return await googleMeetCreateModulePromise;
};
const loadGoogleMeetCliModule = async () => {
googleMeetCliModulePromise ??= import("./src/cli.js");
return await googleMeetCliModulePromise;
};
const googleMeetConfigSchema = {
parse(value: unknown) {
return resolveGoogleMeetConfig(value);
@@ -493,7 +506,7 @@ async function createMeetFromParams(params: {
runtime: OpenClawPluginApi["runtime"];
raw: Record<string, unknown>;
}) {
const create = await import("./src/create.js");
const create = await loadGoogleMeetCreateModule();
return create.createMeetFromParams(params);
}
@@ -503,7 +516,7 @@ async function createAndJoinMeetFromParams(params: {
raw: Record<string, unknown>;
ensureRuntime: () => Promise<GoogleMeetRuntime>;
}) {
const create = await import("./src/create.js");
const create = await loadGoogleMeetCreateModule();
return create.createAndJoinMeetFromParams(params);
}
@@ -615,7 +628,7 @@ async function exportGoogleMeetBundleFromParams(
}),
]);
const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } =
await import("./src/cli.js");
await loadGoogleMeetCliModule();
const calendarId = normalizeOptionalString(raw.calendarId);
const request = {
...(resolved.meeting ? { meeting: resolved.meeting } : {}),
@@ -1189,7 +1202,7 @@ export default definePluginEntry({
api.registerCli(
async ({ program }) => {
const { registerGoogleMeetCli } = await import("./src/cli.js");
const { registerGoogleMeetCli } = await loadGoogleMeetCliModule();
registerGoogleMeetCli({
program,
config,

View File

@@ -20,6 +20,13 @@ const ENV_VARS = [
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
] as const;
let oauthRuntimeModulePromise: Promise<typeof import("./oauth.runtime.js")> | null = null;
const loadOauthRuntimeModule = async () => {
oauthRuntimeModulePromise ??= import("./oauth.runtime.js");
return await oauthRuntimeModulePromise;
};
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
@@ -58,7 +65,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
try {
const { loginGeminiCliOAuth } = await import("./oauth.runtime.js");
const { loginGeminiCliOAuth } = await loadOauthRuntimeModule();
const result = await loginGeminiCliOAuth({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
@@ -122,7 +129,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
refreshOAuth: async (cred) => {
const { refreshGeminiCliOAuthToken } = await import("./oauth.runtime.js");
const { refreshGeminiCliOAuthToken } = await loadOauthRuntimeModule();
return await refreshGeminiCliOAuthToken(cred);
},
resolveUsageAuth: async (ctx) => {

View File

@@ -24,7 +24,7 @@ import {
rememberIMessageReplyCache,
type IMessageChatContext,
} from "./monitor-reply-cache.js";
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
import { getCachedIMessagePrivateApiStatus, probeIMessagePrivateApi } from "./probe.js";
import { parseIMessageTarget, type IMessageTarget } from "./targets.js";
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
@@ -417,7 +417,6 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
// status adapter, which doesn't fire eagerly on first dispatch. Run
// an inline probe so the first react/send-rich attempt after `imsg
// launch` succeeds without requiring a manual `channels status`.
const { probeIMessagePrivateApi } = await import("./probe.js");
privateApiStatus = await probeIMessagePrivateApi(
cliPathForProbe,
account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runIMessageCatchup } from "./catchup-bridge.js";
import { resolveCatchupConfig } from "./catchup.js";
import { resolveCatchupConfig, saveIMessageCatchupCursor } from "./catchup.js";
import type { IMessagePayload } from "./types.js";
type RpcCall = {
@@ -157,6 +157,32 @@ describe("runIMessageCatchup", () => {
expect(summary.replayed).toBe(1);
});
it("does not crash on Date-invalid persisted cursor timestamps", async () => {
const log = vi.fn();
await saveIMessageCatchupCursor("default", {
lastSeenMs: 8_700_000_000_000_000,
lastSeenRowid: 10,
});
const { client, calls } = makeFakeClient(() => {
throw new Error("unexpected rpc");
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async () => {},
runtime: { log },
});
expect(summary.querySucceeded).toBe(false);
expect(calls).toEqual([]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining("imessage catchup: invalid since timestamp"),
);
});
it("returns querySucceeded=false when chats.list throws", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));

View File

@@ -1,3 +1,4 @@
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { warn } from "openclaw/plugin-sdk/runtime-env";
import type { IMessageRpcClient } from "../client.js";
import {
@@ -88,6 +89,11 @@ export async function runIMessageCatchup(
const payloadByGuid = new Map<string, IMessagePayload>();
const fetchFn: CatchupFetchFn = async ({ sinceMs, sinceRowid, limit }) => {
const sinceISO = timestampMsToIsoString(sinceMs);
if (!sinceISO) {
warnLog(`imessage catchup: invalid since timestamp ${sinceMs}`);
return { resolved: false, rows: [] };
}
let chatsResult: { chats?: ChatsListEntry[] } | undefined;
try {
chatsResult = await client.request<{ chats?: ChatsListEntry[] }>(
@@ -100,7 +106,6 @@ export async function runIMessageCatchup(
return { resolved: false, rows: [] };
}
const chats = chatsResult?.chats ?? [];
const sinceISO = new Date(sinceMs).toISOString();
const collected: IMessageCatchupRow[] = [];
const perChatLimit = Math.min(limit, PER_CHAT_HISTORY_LIMIT_CAP);
let historyFetchFailed = false;

View File

@@ -85,6 +85,12 @@ const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
);
let matrixDoctorModulePromise: Promise<typeof import("./doctor.js")> | null = null;
const loadMatrixDoctorModule = async () => {
matrixDoctorModulePromise ??= import("./doctor.js");
return await matrixDoctorModulePromise;
};
const meta = {
id: "matrix",
@@ -118,9 +124,9 @@ const matrixDoctor: ChannelDoctorAdapter = {
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await (await import("./doctor.js")).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) =>
await (await import("./doctor.js")).cleanStaleMatrixPluginConfig(cfg),
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
};
const listMatrixDirectoryPeersFromConfig =

View File

@@ -950,6 +950,28 @@ describe("matrix CLI verification commands", () => {
expect(console.log).toHaveBeenCalledWith("- BritdXC6iL (OpenClaw Gateway)");
});
it("omits invalid matrix device last seen timestamps", async () => {
listMatrixOwnDevicesMock.mockResolvedValue([
{
deviceId: "DEVICE123",
displayName: "OpenClaw Gateway",
lastSeenIp: "127.0.0.1",
lastSeenTs: 8_700_000_000_000_000,
current: true,
},
]);
const program = buildProgram();
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
expect(console.log).toHaveBeenCalledWith("Account: poe");
expect(console.log).toHaveBeenCalledWith("- DEVICE123 (current, OpenClaw Gateway)");
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
expect(
consoleLogMock.mock.calls.some(([message]) => String(message).startsWith(" Last seen:")),
).toBe(false);
});
it("prunes stale matrix gateway devices", async () => {
pruneMatrixStaleGatewayDevicesMock.mockResolvedValue({
before: [

View File

@@ -1,6 +1,6 @@
import type { Command } from "commander";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { parseStrictInteger, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
@@ -216,8 +216,9 @@ function printMatrixOwnDevices(
console.log(
`- ${formatMatrixCliText(device.deviceId)}${labels.length ? ` (${labels.join(", ")})` : ""}`,
);
if (device.lastSeenTs) {
printTimestamp(" Last seen", new Date(device.lastSeenTs).toISOString());
const lastSeenAt = timestampMsToIsoString(device.lastSeenTs);
if (lastSeenAt) {
printTimestamp(" Last seen", lastSeenAt);
}
if (device.lastSeenIp) {
console.log(` Last IP: ${formatMatrixCliText(device.lastSeenIp)}`);

View File

@@ -23,9 +23,3 @@ export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
return handleMatrixSubagentDeliveryTarget(event);
});
}
export {
handleMatrixSubagentDeliveryTarget,
handleMatrixSubagentEnded,
handleMatrixSubagentSpawning,
} from "./src/matrix/subagent-hooks.js";

View File

@@ -6,6 +6,7 @@ import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
@@ -203,7 +204,7 @@ function isoFromUnix(raw: unknown): string | undefined {
if (!Number.isFinite(numeric)) {
return undefined;
}
return new Date(numeric * 1000).toISOString();
return timestampMsToIsoString(numeric * 1000);
}
function cleanMessageText(value: string): string {

View File

@@ -605,4 +605,30 @@ cli note
.then((entries) => entries.filter((entry) => entry !== "index.md")),
).resolves.toStrictEqual([]);
});
it("imports ChatGPT exports with out-of-range Unix timestamps", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
const conversationsPath = path.join(exportDir, "conversations.json");
const conversations = JSON.parse(await fs.readFile(conversationsPath, "utf8")) as Array<
Record<string, unknown>
>;
conversations[0].update_time = 9_000_000_000_000;
await fs.writeFile(conversationsPath, `${JSON.stringify(conversations, null, 2)}\n`, "utf8");
const result = await runWikiChatGptImport({
config,
exportPath: exportDir,
json: true,
});
expect(result.createdCount).toBe(1);
const sourceFile = (await fs.readdir(path.join(rootDir, "sources"))).find(
(entry) => entry !== "index.md",
);
expect(sourceFile).toBeDefined();
const pageContent = await fs.readFile(path.join(rootDir, "sources", sourceFile ?? ""), "utf8");
expect(pageContent).toContain("- Created: 2024-04-06T00:26:40.000Z");
expect(pageContent).toContain("- Updated: 2024-04-06T00:26:40.000Z");
});
});

View File

@@ -0,0 +1,48 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeImportedSourcePage } from "./source-page-shared.js";
describe("writeImportedSourcePage", () => {
let suiteRoot: string;
beforeEach(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-source-page-"));
});
afterEach(async () => {
vi.useRealTimers();
await fs.rm(suiteRoot, { recursive: true, force: true });
});
it("falls back when the source mtime is outside the Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z"));
const sourcePath = path.join(suiteRoot, "source.txt");
await fs.writeFile(sourcePath, "source body", "utf8");
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
entries: {},
version: 1,
};
const result = await writeImportedSourcePage({
vaultRoot: suiteRoot,
syncKey: "unsafe:source",
sourcePath,
sourceUpdatedAtMs: 8_700_000_000_000_000,
sourceSize: 11,
renderFingerprint: "fingerprint",
pagePath: "pages/source.md",
group: "unsafe-local",
state,
buildRendered: (raw, updatedAt) => `updatedAt: ${updatedAt}\n${raw}`,
});
await expect(fs.readFile(path.join(suiteRoot, "pages/source.md"), "utf8")).resolves.toBe(
"updatedAt: 2026-05-01T12:00:00.000Z\nsource body",
);
expect(result).toEqual({ pagePath: "pages/source.md", changed: true, created: true });
expect(state.entries["unsafe:source"]?.sourceUpdatedAtMs).toBe(8_700_000_000_000_000);
});
});

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import {
setImportedSourceEntry,
@@ -48,7 +49,7 @@ export async function writeImportedSourcePage(params: {
throw error;
});
const created = !pageStat;
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
const updatedAt = timestampMsToIsoString(params.sourceUpdatedAtMs) ?? new Date().toISOString();
const shouldSkip = await shouldSkipImportedSourceWrite({
vaultRoot: params.vaultRoot,
syncKey: params.syncKey,

View File

@@ -22,6 +22,13 @@ import {
} from "../policy-state.js";
import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js";
let fsPromisesModulePromise: Promise<typeof import("node:fs/promises")> | null = null;
const loadFsPromisesModule = async () => {
fsPromisesModulePromise ??= import("node:fs/promises");
return await fsPromisesModulePromise;
};
const CHECK_IDS = {
policyAttestationMismatch: "policy/attestation-hash-mismatch",
policyDeniedChannelProvider: "policy/channels-denied-provider",
@@ -5268,7 +5275,7 @@ async function readPolicyFile(
const displayName = policyDisplayName(ctx);
const path = resolveWorkspacePath(ctx, policyPathSetting(ctx));
try {
const fs = await import("node:fs/promises");
const fs = await loadFsPromisesModule();
return {
raw: await fs.readFile(path, "utf-8"),
path,
@@ -5289,7 +5296,7 @@ async function readWorkspaceFile(
): Promise<{ raw: string; path: string } | null> {
const path = resolveWorkspacePath(ctx, fileName);
try {
const fs = await import("node:fs/promises");
const fs = await loadFsPromisesModule();
return { raw: await fs.readFile(path, "utf-8"), path };
} catch (err) {
if (isNotFound(err)) {

View File

@@ -38,6 +38,14 @@ import {
import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js";
import { getBridgeLogger } from "./logger.js";
let mediaRuntimeModulePromise: Promise<typeof import("openclaw/plugin-sdk/media-runtime")> | null =
null;
const loadMediaRuntimeModule = async () => {
mediaRuntimeModulePromise ??= import("openclaw/plugin-sdk/media-runtime");
return await mediaRuntimeModulePromise;
};
function createBuiltinAdapter(): PlatformAdapter {
return {
async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise<void> {
@@ -52,7 +60,7 @@ function createBuiltinAdapter(): PlatformAdapter {
},
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
const { readRemoteMediaBuffer } = await loadMediaRuntimeModule();
const result = await readRemoteMediaBuffer({ url, filePathHint: filename });
const fs = await import("node:fs");
const path = await import("node:path");
@@ -65,7 +73,7 @@ function createBuiltinAdapter(): PlatformAdapter {
},
async fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
const { readRemoteMediaBuffer } = await loadMediaRuntimeModule();
const result = await readRemoteMediaBuffer({
url: options.url,
filePathHint: options.filePathHint,

View File

@@ -37,6 +37,14 @@ function loadGatewayModule(): Promise<typeof import("./bridge/gateway.js")> {
return gatewayModulePromise;
}
let outboundMessagingModulePromise:
| Promise<typeof import("./engine/messaging/outbound.js")>
| undefined;
function loadOutboundMessagingModule(): Promise<typeof import("./engine/messaging/outbound.js")> {
outboundMessagingModulePromise ??= import("./engine/messaging/outbound.js");
return outboundMessagingModulePromise;
}
function createQQBotSendReceipt(params: {
messageId?: string;
target: string;
@@ -69,7 +77,7 @@ async function sendQQBotText(params: {
// platform adapter, etc.) have executed before engine code runs.
await loadGatewayModule();
const account = resolveQQBotAccount(params.cfg, params.accountId);
const { sendText } = await import("./engine/messaging/outbound.js");
const { sendText } = await loadOutboundMessagingModule();
const result = await sendText({
to: params.to,
text: params.text,
@@ -100,7 +108,7 @@ async function sendQQBotMedia(params: {
// Same guard as sendText — ensure adapters are registered.
await loadGatewayModule();
const account = resolveQQBotAccount(params.cfg, params.accountId);
const { sendMedia } = await import("./engine/messaging/outbound.js");
const { sendMedia } = await loadOutboundMessagingModule();
const result = await sendMedia({
to: params.to,
text: params.text ?? "",

View File

@@ -4,7 +4,10 @@ import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import { resolveCommandAuthorization } from "openclaw/plugin-sdk/command-auth-native";
import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime";
import { parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
import {
parseStrictFiniteNumber,
timestampMsToIsoString,
} from "openclaw/plugin-sdk/number-runtime";
import {
normalizeOptionalString,
normalizeUniqueTrimmedStringList,
@@ -319,7 +322,10 @@ function formatInteractionSelectionLabel(params: {
return params.summary.selectedTime;
}
if (typeof params.summary.selectedDateTime === "number") {
return new Date(params.summary.selectedDateTime * 1000).toISOString();
const selectedDateTime = timestampMsToIsoString(params.summary.selectedDateTime * 1000);
if (selectedDateTime) {
return selectedDateTime;
}
}
if (params.summary.richTextPreview) {
return params.summary.richTextPreview;

View File

@@ -2075,6 +2075,54 @@ describe("registerSlackInteractionEvents", () => {
expect(payload.selectedDateTime).toBe(1_771_700_200);
});
it("falls back when Slack datetime selection is outside Date range", async () => {
const { ctx, app, getHandler } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
const ack = vi.fn().mockResolvedValue(undefined);
await handler({
ack,
body: {
user: { id: "U333" },
channel: { id: "C3" },
message: {
ts: "555.669",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "datetime_block",
elements: [{ type: "datetimepicker", action_id: "openclaw:datetime" }],
},
],
},
},
action: {
type: "datetimepicker",
action_id: "openclaw:datetime",
block_id: "datetime_block",
selected_date_time: 9_000_000_000_000,
},
});
expectRecordFields(chatUpdateCall(app), {
channel: "C3",
ts: "555.669",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: ":white_check_mark: *openclaw:datetime* selected by <@U333>",
},
],
},
],
});
});
it("captures workflow button trigger metadata", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, getHandler } = createContext();

View File

@@ -583,6 +583,37 @@ describe("voice-call plugin", () => {
expect(runtimeStub.manager.speak).not.toHaveBeenCalled();
});
it("reports stale call history with invalid ended timestamps", async () => {
runtimeStub.manager.getCall = vi.fn(() => undefined);
runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
runtimeStub.manager.getCallHistory = vi.fn(async () => [
createCallRecord({
callId: "call-1",
providerCallId: "CA123",
state: "completed",
endReason: "completed",
endedAt: Number.POSITIVE_INFINITY,
}),
]);
const { methods } = setup({ provider: "mock" });
const handler = methods.get("voicecall.speak") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: { callId: "CA123", message: "hello" }, respond });
const [ok, , error] = firstRespondCall(respond);
expect(ok).toBe(false);
expect(error?.message).toContain("call is not active");
expect(error?.message).toContain("last state=completed");
expect(error?.message).toContain("endReason=completed");
expect(error?.message).not.toContain("endedAt=");
});
it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
const { methods } = setup({
enabled: true,

View File

@@ -1,5 +1,6 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { Type } from "typebox";
import {
@@ -347,10 +348,11 @@ export default definePluginEntry({
if (!call) {
return undefined;
}
const endedAt = timestampMsToIsoString(call.endedAt);
const details = [
`last state=${call.state}`,
call.endReason ? `endReason=${call.endReason}` : undefined,
call.endedAt ? `endedAt=${new Date(call.endedAt).toISOString()}` : undefined,
endedAt ? `endedAt=${endedAt}` : undefined,
].filter(Boolean);
return `call is not active (${details.join(", ")})`;
};

View File

@@ -0,0 +1,7 @@
//#region packages/media-understanding-common/src/active-model.d.ts
type ActiveMediaModel = {
provider: string;
model?: string;
};
//#endregion
export { ActiveMediaModel };

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,14 @@
import { MediaUnderstandingCapability } from "./types.mjs";
//#region packages/media-understanding-common/src/defaults.d.ts
declare const DEFAULT_MAX_CHARS = 500;
declare const DEFAULT_MAX_CHARS_BY_CAPABILITY: Record<MediaUnderstandingCapability, number | undefined>;
declare const DEFAULT_MAX_BYTES: Record<MediaUnderstandingCapability, number>;
declare const DEFAULT_TIMEOUT_SECONDS: Record<MediaUnderstandingCapability, number>;
declare const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string>;
declare const DEFAULT_VIDEO_MAX_BASE64_BYTES: number;
declare const CLI_OUTPUT_MAX_BUFFER: number;
declare const DEFAULT_MEDIA_CONCURRENCY = 2;
declare const MIN_AUDIO_FILE_BYTES = 1024;
//#endregion
export { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES };

View File

@@ -0,0 +1,29 @@
//#region packages/media-understanding-common/src/defaults.ts
const MB = 1024 * 1024;
const DEFAULT_MAX_CHARS = 500;
const DEFAULT_MAX_CHARS_BY_CAPABILITY = {
image: 500,
audio: void 0,
video: 500
};
const DEFAULT_MAX_BYTES = {
image: 10 * MB,
audio: 20 * MB,
video: 50 * MB
};
const DEFAULT_TIMEOUT_SECONDS = {
image: 60,
audio: 60,
video: 120
};
const DEFAULT_PROMPT = {
image: "Describe the image.",
audio: "Transcribe the audio.",
video: "Describe the video."
};
const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
const CLI_OUTPUT_MAX_BUFFER = 5 * MB;
const DEFAULT_MEDIA_CONCURRENCY = 2;
const MIN_AUDIO_FILE_BYTES = 1024;
//#endregion
export { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES };

View File

@@ -0,0 +1,9 @@
//#region packages/media-understanding-common/src/errors.d.ts
type MediaUnderstandingSkipReason = "maxBytes" | "timeout" | "unsupported" | "empty" | "blocked" | "tooSmall";
declare class MediaUnderstandingSkipError extends Error {
readonly reason: MediaUnderstandingSkipReason;
constructor(reason: MediaUnderstandingSkipReason, message: string);
}
declare function isMediaUnderstandingSkipError(err: unknown): err is MediaUnderstandingSkipError;
//#endregion
export { MediaUnderstandingSkipError, isMediaUnderstandingSkipError };

View File

@@ -0,0 +1,13 @@
//#region packages/media-understanding-common/src/errors.ts
var MediaUnderstandingSkipError = class extends Error {
constructor(reason, message) {
super(message);
this.reason = reason;
this.name = "MediaUnderstandingSkipError";
}
};
function isMediaUnderstandingSkipError(err) {
return err instanceof MediaUnderstandingSkipError;
}
//#endregion
export { MediaUnderstandingSkipError, isMediaUnderstandingSkipError };

View File

@@ -0,0 +1,11 @@
import { MediaUnderstandingOutput } from "./types.mjs";
//#region packages/media-understanding-common/src/format.d.ts
declare function extractMediaUserText(body?: string): string | undefined;
declare function formatMediaUnderstandingBody(params: {
body?: string;
outputs: MediaUnderstandingOutput[];
}): string;
declare function formatAudioTranscripts(outputs: MediaUnderstandingOutput[]): string;
//#endregion
export { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody };

View File

@@ -0,0 +1,47 @@
//#region packages/media-understanding-common/src/format.ts
const MEDIA_PLACEHOLDER_RE = /^<media:[^>]+>(\s*\([^)]*\))?$/i;
const MEDIA_PLACEHOLDER_TOKEN_RE = /^<media:[^>]+>(\s*\([^)]*\))?\s*/i;
function extractMediaUserText(body) {
const trimmed = body?.trim() ?? "";
if (!trimmed) return;
if (MEDIA_PLACEHOLDER_RE.test(trimmed)) return;
return trimmed.replace(MEDIA_PLACEHOLDER_TOKEN_RE, "").trim() || void 0;
}
function formatSection(title, kind, text, userText) {
const lines = [`[${title}]`];
if (userText) lines.push(`User text:\n${userText}`);
lines.push(`${kind}:\n${text}`);
return lines.join("\n");
}
function formatMediaUnderstandingBody(params) {
const outputs = params.outputs.filter((output) => output.text.trim());
if (outputs.length === 0) return params.body ?? "";
const userText = extractMediaUserText(params.body);
const sections = [];
if (userText && outputs.length > 1) sections.push(`User text:\n${userText}`);
const counts = /* @__PURE__ */ new Map();
for (const output of outputs) counts.set(output.kind, (counts.get(output.kind) ?? 0) + 1);
const seen = /* @__PURE__ */ new Map();
for (const output of outputs) {
const count = counts.get(output.kind) ?? 1;
const next = (seen.get(output.kind) ?? 0) + 1;
seen.set(output.kind, next);
const suffix = count > 1 ? ` ${next}/${count}` : "";
if (output.kind === "audio.transcription") {
sections.push(formatSection(`Audio${suffix}`, "Transcript", output.text, outputs.length === 1 ? userText : void 0));
continue;
}
if (output.kind === "image.description") {
sections.push(formatSection(`Image${suffix}`, "Description", output.text, outputs.length === 1 ? userText : void 0));
continue;
}
sections.push(formatSection(`Video${suffix}`, "Description", output.text, outputs.length === 1 ? userText : void 0));
}
return sections.join("\n\n").trim();
}
function formatAudioTranscripts(outputs) {
if (outputs.length === 1) return outputs[0].text;
return outputs.map((output, index) => `Audio ${index + 1}:\n${output.text}`).join("\n\n");
}
//#endregion
export { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody };

View File

@@ -0,0 +1,11 @@
import { ActiveMediaModel } from "./active-model.mjs";
import { MediaAttachment, MediaUnderstandingCapability, MediaUnderstandingCapabilityRegistry, MediaUnderstandingKind, MediaUnderstandingOutput, MediaUnderstandingProvider } from "./types.mjs";
import { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES } from "./defaults.mjs";
import { MediaUnderstandingSkipError, isMediaUnderstandingSkipError } from "./errors.mjs";
import { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody } from "./format.mjs";
import { OpenAiCompatibleVideoPayload, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString } from "./openai-compatible-video.mjs";
import { extractGeminiResponse } from "./output-extract.mjs";
import { normalizeMediaExecutionProviderId, normalizeMediaProviderId } from "./provider-id.mjs";
import { providerSupportsCapability } from "./provider-supports.mjs";
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.mjs";
export { ActiveMediaModel, CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES, MediaAttachment, MediaUnderstandingCapability, MediaUnderstandingCapabilityRegistry, MediaUnderstandingKind, MediaUnderstandingOutput, MediaUnderstandingProvider, MediaUnderstandingSkipError, OpenAiCompatibleVideoPayload, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, estimateBase64Size, extractGeminiResponse, extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody, isMediaUnderstandingSkipError, normalizeMediaExecutionProviderId, normalizeMediaProviderId, providerSupportsCapability, resolveMediaUnderstandingString, resolveVideoMaxBase64Bytes };

View File

@@ -0,0 +1,11 @@
import "./active-model.mjs";
import { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES } from "./defaults.mjs";
import { MediaUnderstandingSkipError, isMediaUnderstandingSkipError } from "./errors.mjs";
import { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody } from "./format.mjs";
import { buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString } from "./openai-compatible-video.mjs";
import { extractGeminiResponse } from "./output-extract.mjs";
import { normalizeMediaExecutionProviderId, normalizeMediaProviderId } from "./provider-id.mjs";
import { providerSupportsCapability } from "./provider-supports.mjs";
import "./types.mjs";
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.mjs";
export { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES, MediaUnderstandingSkipError, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, estimateBase64Size, extractGeminiResponse, extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody, isMediaUnderstandingSkipError, normalizeMediaExecutionProviderId, normalizeMediaProviderId, providerSupportsCapability, resolveMediaUnderstandingString, resolveVideoMaxBase64Bytes };

View File

@@ -0,0 +1,37 @@
//#region packages/media-understanding-common/src/openai-compatible-video.d.ts
type OpenAiCompatibleVideoPayload = {
choices?: Array<{
message?: {
content?: string | Array<{
text?: string;
}>;
reasoning_content?: string;
};
}>;
};
declare function resolveMediaUnderstandingString(value: string | undefined, fallback: string): string;
declare function coerceOpenAiCompatibleVideoText(payload: OpenAiCompatibleVideoPayload): string | null;
declare function buildOpenAiCompatibleVideoRequestBody(params: {
model: string;
prompt: string;
mime: string;
buffer: Buffer;
}): {
model: string;
messages: {
role: string;
content: ({
type: string;
text: string;
video_url?: undefined;
} | {
type: string;
video_url: {
url: string;
};
text?: undefined;
})[];
}[];
};
//#endregion
export { OpenAiCompatibleVideoPayload, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString };

View File

@@ -0,0 +1,32 @@
//#region packages/media-understanding-common/src/openai-compatible-video.ts
function resolveMediaUnderstandingString(value, fallback) {
return value?.trim() || fallback;
}
function coerceOpenAiCompatibleVideoText(payload) {
const message = payload.choices?.[0]?.message;
if (!message) return null;
if (typeof message.content === "string" && message.content.trim()) return message.content.trim();
if (Array.isArray(message.content)) {
const text = message.content.map((part) => part.text?.trim() ?? "").filter(Boolean).join("\n");
if (text) return text;
}
if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) return message.reasoning_content.trim();
return null;
}
function buildOpenAiCompatibleVideoRequestBody(params) {
return {
model: params.model,
messages: [{
role: "user",
content: [{
type: "text",
text: params.prompt
}, {
type: "video_url",
video_url: { url: `data:${params.mime};base64,${params.buffer.toString("base64")}` }
}]
}]
};
}
//#endregion
export { buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString };

View File

@@ -0,0 +1,4 @@
//#region packages/media-understanding-common/src/output-extract.d.ts
declare function extractGeminiResponse(raw: string): string | null;
//#endregion
export { extractGeminiResponse };

View File

@@ -0,0 +1,21 @@
//#region packages/media-understanding-common/src/output-extract.ts
function extractLastJsonObject(raw) {
const trimmed = raw.trim();
const start = trimmed.lastIndexOf("{");
if (start === -1) return null;
const slice = trimmed.slice(start);
try {
return JSON.parse(slice);
} catch {
return null;
}
}
function extractGeminiResponse(raw) {
const payload = extractLastJsonObject(raw);
if (!payload || typeof payload !== "object") return null;
const response = payload.response;
if (typeof response !== "string") return null;
return response.trim() || null;
}
//#endregion
export { extractGeminiResponse };

View File

@@ -0,0 +1,5 @@
//#region packages/media-understanding-common/src/provider-id.d.ts
declare function normalizeMediaProviderId(id: string): string;
declare function normalizeMediaExecutionProviderId(id: string): string;
//#endregion
export { normalizeMediaExecutionProviderId, normalizeMediaProviderId };

View File

@@ -0,0 +1,18 @@
//#region packages/media-understanding-common/src/provider-id.ts
function normalizeProviderId(provider) {
return provider.trim().toLowerCase();
}
function normalizeMediaProviderId(id) {
const normalized = normalizeProviderId(id);
if (normalized === "gemini") return "google";
if (normalized === "minimax-cn") return "minimax";
if (normalized === "minimax-portal-cn") return "minimax-portal";
return normalized;
}
function normalizeMediaExecutionProviderId(id) {
const normalized = normalizeProviderId(id);
if (normalized === "minimax-cn" || normalized === "minimax-portal-cn") return normalized;
return normalizeMediaProviderId(normalized);
}
//#endregion
export { normalizeMediaExecutionProviderId, normalizeMediaProviderId };

View File

@@ -0,0 +1,6 @@
import { MediaUnderstandingCapability, MediaUnderstandingProvider } from "./types.mjs";
//#region packages/media-understanding-common/src/provider-supports.d.ts
declare function providerSupportsCapability(provider: MediaUnderstandingProvider | undefined, capability: MediaUnderstandingCapability): boolean;
//#endregion
export { providerSupportsCapability };

View File

@@ -0,0 +1,9 @@
//#region packages/media-understanding-common/src/provider-supports.ts
function providerSupportsCapability(provider, capability) {
if (!provider) return false;
if (capability === "audio") return Boolean(provider.transcribeAudio);
if (capability === "image") return Boolean(provider.describeImage);
return Boolean(provider.describeVideo);
}
//#endregion
export { providerSupportsCapability };

View File

@@ -0,0 +1,31 @@
//#region packages/media-understanding-common/src/types.d.ts
type MediaUnderstandingKind = "audio.transcription" | "video.description" | "image.description";
type MediaUnderstandingCapability = "image" | "audio" | "video";
type MediaUnderstandingCapabilityRegistry = Map<string, {
capabilities?: MediaUnderstandingCapability[];
}>;
type MediaAttachment = {
path?: string;
url?: string;
mime?: string;
index: number;
alreadyTranscribed?: boolean;
};
type MediaUnderstandingOutput = {
kind: MediaUnderstandingKind;
attachmentIndex: number;
text: string;
provider: string;
model?: string;
};
type MediaUnderstandingProvider = {
id: string;
capabilities?: MediaUnderstandingCapability[];
transcribeAudio?: unknown;
describeVideo?: unknown;
describeImage?: unknown;
describeImages?: unknown;
extractStructured?: unknown;
};
//#endregion
export { MediaAttachment, MediaUnderstandingCapability, MediaUnderstandingCapabilityRegistry, MediaUnderstandingKind, MediaUnderstandingOutput, MediaUnderstandingProvider };

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,5 @@
//#region packages/media-understanding-common/src/video.d.ts
declare function estimateBase64Size(bytes: number): number;
declare function resolveVideoMaxBase64Bytes(maxBytes: number): number;
//#endregion
export { estimateBase64Size, resolveVideoMaxBase64Bytes };

View File

@@ -0,0 +1,11 @@
import { DEFAULT_VIDEO_MAX_BASE64_BYTES } from "./defaults.mjs";
//#region packages/media-understanding-common/src/video.ts
function estimateBase64Size(bytes) {
return Math.ceil(bytes / 3) * 4;
}
function resolveVideoMaxBase64Bytes(maxBytes) {
const expanded = Math.floor(maxBytes * (4 / 3));
return Math.min(expanded, DEFAULT_VIDEO_MAX_BASE64_BYTES);
}
//#endregion
export { estimateBase64Size, resolveVideoMaxBase64Bytes };

View File

@@ -0,0 +1,71 @@
{
"name": "@openclaw/media-understanding-common",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./active-model": {
"types": "./dist/active-model.d.mts",
"import": "./dist/active-model.mjs",
"default": "./dist/active-model.mjs"
},
"./defaults": {
"types": "./dist/defaults.d.mts",
"import": "./dist/defaults.mjs",
"default": "./dist/defaults.mjs"
},
"./errors": {
"types": "./dist/errors.d.mts",
"import": "./dist/errors.mjs",
"default": "./dist/errors.mjs"
},
"./format": {
"types": "./dist/format.d.mts",
"import": "./dist/format.mjs",
"default": "./dist/format.mjs"
},
"./openai-compatible-video": {
"types": "./dist/openai-compatible-video.d.mts",
"import": "./dist/openai-compatible-video.mjs",
"default": "./dist/openai-compatible-video.mjs"
},
"./output-extract": {
"types": "./dist/output-extract.d.mts",
"import": "./dist/output-extract.mjs",
"default": "./dist/output-extract.mjs"
},
"./provider-id": {
"types": "./dist/provider-id.d.mts",
"import": "./dist/provider-id.mjs",
"default": "./dist/provider-id.mjs"
},
"./provider-supports": {
"types": "./dist/provider-supports.d.mts",
"import": "./dist/provider-supports.mjs",
"default": "./dist/provider-supports.mjs"
},
"./types": {
"types": "./dist/types.d.mts",
"import": "./dist/types.mjs",
"default": "./dist/types.mjs"
},
"./video": {
"types": "./dist/video.d.mts",
"import": "./dist/video.mjs",
"default": "./dist/video.mjs"
}
},
"scripts": {
"build": "tsdown src/index.ts src/active-model.ts src/defaults.ts src/errors.ts src/format.ts src/openai-compatible-video.ts src/output-extract.ts src/provider-id.ts src/provider-supports.ts src/types.ts src/video.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
}
}

View File

@@ -0,0 +1,4 @@
export type ActiveMediaModel = {
provider: string;
model?: string;
};

View File

@@ -0,0 +1,32 @@
import type { MediaUnderstandingCapability } from "./types.js";
const MB = 1024 * 1024;
export const DEFAULT_MAX_CHARS = 500;
export const DEFAULT_MAX_CHARS_BY_CAPABILITY: Record<
MediaUnderstandingCapability,
number | undefined
> = {
image: DEFAULT_MAX_CHARS,
audio: undefined,
video: DEFAULT_MAX_CHARS,
};
export const DEFAULT_MAX_BYTES: Record<MediaUnderstandingCapability, number> = {
image: 10 * MB,
audio: 20 * MB,
video: 50 * MB,
};
export const DEFAULT_TIMEOUT_SECONDS: Record<MediaUnderstandingCapability, number> = {
image: 60,
audio: 60,
video: 120,
};
export const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string> = {
image: "Describe the image.",
audio: "Transcribe the audio.",
video: "Describe the video.",
};
export const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
export const CLI_OUTPUT_MAX_BUFFER = 5 * MB;
export const DEFAULT_MEDIA_CONCURRENCY = 2;
export const MIN_AUDIO_FILE_BYTES = 1024;

View File

@@ -0,0 +1,21 @@
type MediaUnderstandingSkipReason =
| "maxBytes"
| "timeout"
| "unsupported"
| "empty"
| "blocked"
| "tooSmall";
export class MediaUnderstandingSkipError extends Error {
readonly reason: MediaUnderstandingSkipReason;
constructor(reason: MediaUnderstandingSkipReason, message: string) {
super(message);
this.reason = reason;
this.name = "MediaUnderstandingSkipError";
}
}
export function isMediaUnderstandingSkipError(err: unknown): err is MediaUnderstandingSkipError {
return err instanceof MediaUnderstandingSkipError;
}

View File

@@ -0,0 +1,98 @@
import type { MediaUnderstandingOutput } from "./types.js";
const MEDIA_PLACEHOLDER_RE = /^<media:[^>]+>(\s*\([^)]*\))?$/i;
const MEDIA_PLACEHOLDER_TOKEN_RE = /^<media:[^>]+>(\s*\([^)]*\))?\s*/i;
export function extractMediaUserText(body?: string): string | undefined {
const trimmed = body?.trim() ?? "";
if (!trimmed) {
return undefined;
}
if (MEDIA_PLACEHOLDER_RE.test(trimmed)) {
return undefined;
}
const cleaned = trimmed.replace(MEDIA_PLACEHOLDER_TOKEN_RE, "").trim();
return cleaned || undefined;
}
function formatSection(
title: string,
kind: "Transcript" | "Description",
text: string,
userText?: string,
): string {
const lines = [`[${title}]`];
if (userText) {
lines.push(`User text:\n${userText}`);
}
lines.push(`${kind}:\n${text}`);
return lines.join("\n");
}
export function formatMediaUnderstandingBody(params: {
body?: string;
outputs: MediaUnderstandingOutput[];
}): string {
const outputs = params.outputs.filter((output) => output.text.trim());
if (outputs.length === 0) {
return params.body ?? "";
}
const userText = extractMediaUserText(params.body);
const sections: string[] = [];
if (userText && outputs.length > 1) {
sections.push(`User text:\n${userText}`);
}
const counts = new Map<MediaUnderstandingOutput["kind"], number>();
for (const output of outputs) {
counts.set(output.kind, (counts.get(output.kind) ?? 0) + 1);
}
const seen = new Map<MediaUnderstandingOutput["kind"], number>();
for (const output of outputs) {
const count = counts.get(output.kind) ?? 1;
const next = (seen.get(output.kind) ?? 0) + 1;
seen.set(output.kind, next);
const suffix = count > 1 ? ` ${next}/${count}` : "";
if (output.kind === "audio.transcription") {
sections.push(
formatSection(
`Audio${suffix}`,
"Transcript",
output.text,
outputs.length === 1 ? userText : undefined,
),
);
continue;
}
if (output.kind === "image.description") {
sections.push(
formatSection(
`Image${suffix}`,
"Description",
output.text,
outputs.length === 1 ? userText : undefined,
),
);
continue;
}
sections.push(
formatSection(
`Video${suffix}`,
"Description",
output.text,
outputs.length === 1 ? userText : undefined,
),
);
}
return sections.join("\n\n").trim();
}
export function formatAudioTranscripts(outputs: MediaUnderstandingOutput[]): string {
if (outputs.length === 1) {
return outputs[0].text;
}
return outputs.map((output, index) => `Audio ${index + 1}:\n${output.text}`).join("\n\n");
}

View File

@@ -0,0 +1,10 @@
export * from "./active-model.js";
export * from "./defaults.js";
export * from "./errors.js";
export * from "./format.js";
export * from "./openai-compatible-video.js";
export * from "./output-extract.js";
export * from "./provider-id.js";
export * from "./provider-supports.js";
export * from "./types.js";
export * from "./video.js";

View File

@@ -0,0 +1,66 @@
export type OpenAiCompatibleVideoPayload = {
choices?: Array<{
message?: {
content?: string | Array<{ text?: string }>;
reasoning_content?: string;
};
}>;
};
export function resolveMediaUnderstandingString(
value: string | undefined,
fallback: string,
): string {
const trimmed = value?.trim();
return trimmed || fallback;
}
export function coerceOpenAiCompatibleVideoText(
payload: OpenAiCompatibleVideoPayload,
): string | null {
const message = payload.choices?.[0]?.message;
if (!message) {
return null;
}
if (typeof message.content === "string" && message.content.trim()) {
return message.content.trim();
}
if (Array.isArray(message.content)) {
const text = message.content
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n");
if (text) {
return text;
}
}
if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) {
return message.reasoning_content.trim();
}
return null;
}
export function buildOpenAiCompatibleVideoRequestBody(params: {
model: string;
prompt: string;
mime: string;
buffer: Buffer;
}) {
return {
model: params.model,
messages: [
{
role: "user",
content: [
{ type: "text", text: params.prompt },
{
type: "video_url",
video_url: {
url: `data:${params.mime};base64,${params.buffer.toString("base64")}`,
},
},
],
},
],
};
}

View File

@@ -0,0 +1,26 @@
function extractLastJsonObject(raw: string): unknown {
const trimmed = raw.trim();
const start = trimmed.lastIndexOf("{");
if (start === -1) {
return null;
}
const slice = trimmed.slice(start);
try {
return JSON.parse(slice);
} catch {
return null;
}
}
export function extractGeminiResponse(raw: string): string | null {
const payload = extractLastJsonObject(raw);
if (!payload || typeof payload !== "object") {
return null;
}
const response = (payload as { response?: unknown }).response;
if (typeof response !== "string") {
return null;
}
const trimmed = response.trim();
return trimmed || null;
}

View File

@@ -0,0 +1,25 @@
function normalizeProviderId(provider: string): string {
return provider.trim().toLowerCase();
}
export function normalizeMediaProviderId(id: string): string {
const normalized = normalizeProviderId(id);
if (normalized === "gemini") {
return "google";
}
if (normalized === "minimax-cn") {
return "minimax";
}
if (normalized === "minimax-portal-cn") {
return "minimax-portal";
}
return normalized;
}
export function normalizeMediaExecutionProviderId(id: string): string {
const normalized = normalizeProviderId(id);
if (normalized === "minimax-cn" || normalized === "minimax-portal-cn") {
return normalized;
}
return normalizeMediaProviderId(normalized);
}

View File

@@ -0,0 +1,17 @@
import type { MediaUnderstandingCapability, MediaUnderstandingProvider } from "./types.js";
export function providerSupportsCapability(
provider: MediaUnderstandingProvider | undefined,
capability: MediaUnderstandingCapability,
): boolean {
if (!provider) {
return false;
}
if (capability === "audio") {
return Boolean(provider.transcribeAudio);
}
if (capability === "image") {
return Boolean(provider.describeImage);
}
return Boolean(provider.describeVideo);
}

View File

@@ -0,0 +1,39 @@
export type MediaUnderstandingKind =
| "audio.transcription"
| "video.description"
| "image.description";
export type MediaUnderstandingCapability = "image" | "audio" | "video";
export type MediaUnderstandingCapabilityRegistry = Map<
string,
{
capabilities?: MediaUnderstandingCapability[];
}
>;
export type MediaAttachment = {
path?: string;
url?: string;
mime?: string;
index: number;
alreadyTranscribed?: boolean;
};
export type MediaUnderstandingOutput = {
kind: MediaUnderstandingKind;
attachmentIndex: number;
text: string;
provider: string;
model?: string;
};
export type MediaUnderstandingProvider = {
id: string;
capabilities?: MediaUnderstandingCapability[];
transcribeAudio?: unknown;
describeVideo?: unknown;
describeImage?: unknown;
describeImages?: unknown;
extractStructured?: unknown;
};

View File

@@ -0,0 +1,10 @@
import { DEFAULT_VIDEO_MAX_BASE64_BYTES } from "./defaults.js";
export function estimateBase64Size(bytes: number): number {
return Math.ceil(bytes / 3) * 4;
}
export function resolveVideoMaxBase64Bytes(maxBytes: number): number {
const expanded = Math.floor(maxBytes * (4 / 3));
return Math.min(expanded, DEFAULT_VIDEO_MAX_BASE64_BYTES);
}

2
pnpm-lock.yaml generated
View File

@@ -1832,6 +1832,8 @@ importers:
packages/media-generation-core: {}
packages/media-understanding-common: {}
packages/memory-host-sdk: {}
packages/net-policy:

View File

@@ -48,6 +48,7 @@ export const BUILD_ALL_STEPS = [
"packages/plugin-sdk/package.json",
"packages/llm-core/package.json",
"packages/markdown-core/package.json",
"packages/media-understanding-common/package.json",
"packages/terminal-core/package.json",
"packages/memory-host-sdk/package.json",
"tsconfig.json",
@@ -57,6 +58,7 @@ export const BUILD_ALL_STEPS = [
"packages/markdown-core/src",
"packages/memory-host-sdk/src",
"packages/media-generation-core/src",
"packages/media-understanding-common/src",
"packages/terminal-core/src",
"src/types",
"src/video-generation/dashscope-compatible.ts",
@@ -171,6 +173,44 @@ export const BUILD_ALL_PROFILE_STEP_ENV = {
},
};
export function buildAllUsage() {
return [
"Usage: node scripts/build-all.mjs [profile]",
"",
"Builds OpenClaw artifacts for the selected profile.",
"",
"Profiles:",
...Object.keys(BUILD_ALL_PROFILES).map((profile) => ` ${profile}`),
"",
"Options:",
" -h, --help Show this help.",
].join("\n");
}
export function parseBuildAllArgs(argv) {
const args = {
help: false,
profile: "full",
};
let sawProfile = false;
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
args.help = true;
} else if (arg.startsWith("-")) {
throw new Error(`unknown argument: ${arg}\n\n${buildAllUsage()}`);
} else if (sawProfile) {
throw new Error(`unexpected argument: ${arg}\n\n${buildAllUsage()}`);
} else {
args.profile = arg;
sawProfile = true;
}
}
if (!args.help && !BUILD_ALL_PROFILES[args.profile]) {
throw new Error(`Unknown build profile: ${args.profile}\n\n${buildAllUsage()}`);
}
return args;
}
export function resolveBuildAllSteps(profile = "full") {
const labels = BUILD_ALL_PROFILES[profile];
if (!labels) {
@@ -463,44 +503,54 @@ function isMainModule() {
}
if (isMainModule()) {
const profile = process.argv[2] ?? "full";
const timings = [];
let exitCode = 0;
for (const step of resolveBuildAllSteps(profile)) {
const startedAt = performance.now();
const cacheState = resolveBuildAllStepCacheState(step);
if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) {
restoreBuildAllStepCacheOutputs(cacheState);
const durationMs = performance.now() - startedAt;
timings.push({ label: step.label, status: "cached", durationMs });
console.error(`[build-all] ${step.label} (cached) ${formatBuildAllDuration(durationMs)}`);
continue;
}
console.error(`[build-all] ${step.label}`);
const invocation = resolveBuildAllStep(step);
const result = spawnSync(invocation.command, invocation.args, invocation.options);
const durationMs = performance.now() - startedAt;
if (typeof result.status === "number") {
if (result.status !== 0) {
timings.push({ label: step.label, status: "failed", durationMs });
console.error(
`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`,
);
exitCode = result.status;
break;
}
writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheState(step));
timings.push({ label: step.label, status: "ran", durationMs });
console.error(`[build-all] ${step.label} done in ${formatBuildAllDuration(durationMs)}`);
continue;
}
timings.push({ label: step.label, status: "failed", durationMs });
console.error(`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`);
exitCode = 1;
break;
let args;
try {
args = parseBuildAllArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(2);
}
console.error(formatBuildAllTimingSummary(timings));
if (exitCode !== 0) {
process.exit(exitCode);
if (args?.help) {
console.log(buildAllUsage());
} else {
const timings = [];
let exitCode = 0;
for (const step of resolveBuildAllSteps(args.profile)) {
const startedAt = performance.now();
const cacheState = resolveBuildAllStepCacheState(step);
if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) {
restoreBuildAllStepCacheOutputs(cacheState);
const durationMs = performance.now() - startedAt;
timings.push({ label: step.label, status: "cached", durationMs });
console.error(`[build-all] ${step.label} (cached) ${formatBuildAllDuration(durationMs)}`);
continue;
}
console.error(`[build-all] ${step.label}`);
const invocation = resolveBuildAllStep(step);
const result = spawnSync(invocation.command, invocation.args, invocation.options);
const durationMs = performance.now() - startedAt;
if (typeof result.status === "number") {
if (result.status !== 0) {
timings.push({ label: step.label, status: "failed", durationMs });
console.error(
`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`,
);
exitCode = result.status;
break;
}
writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheState(step));
timings.push({ label: step.label, status: "ran", durationMs });
console.error(`[build-all] ${step.label} done in ${formatBuildAllDuration(durationMs)}`);
continue;
}
timings.push({ label: step.label, status: "failed", durationMs });
console.error(`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`);
exitCode = 1;
break;
}
console.error(formatBuildAllTimingSummary(timings));
if (exitCode !== 0) {
process.exit(exitCode);
}
}
}

View File

@@ -40,6 +40,19 @@ function isTypeOnlyImportDeclaration(node) {
);
}
function isTypeOnlyExportDeclaration(node) {
if (node.isTypeOnly === true) {
return true;
}
const clause = node.exportClause;
return (
Boolean(clause) &&
ts.isNamedExports(clause) &&
clause.elements.length > 0 &&
clause.elements.every((element) => element.isTypeOnly)
);
}
function readDeclarationName(node) {
if (
(ts.isFunctionDeclaration(node) ||
@@ -104,6 +117,15 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
addLine(staticRuntimeImports, node.moduleSpecifier.text, toLine(sourceFile, node));
}
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier) &&
!isTypeOnlyExportDeclaration(node)
) {
addLine(staticRuntimeImports, node.moduleSpecifier.text, toLine(sourceFile, node));
}
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&

View File

@@ -6,12 +6,17 @@ import { spawnSync } from "node:child_process";
const ACTIONLINT_VERSION = "1.7.11";
function commandExists(command) {
return spawnSync("bash", ["-lc", `command -v ${command}`], { stdio: "ignore" }).status === 0;
function commandExists(command, args = ["--version"]) {
const result = spawnSync(command, args, { stdio: "ignore" });
return !result.error && result.status === 0;
}
function run(command, args) {
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.error) {
console.error(`[check-workflows] failed to run ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
@@ -19,8 +24,13 @@ function run(command, args) {
if (commandExists("actionlint")) {
run("actionlint", []);
} else {
} else if (commandExists("go", ["version"])) {
run("go", ["run", `github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}`]);
} else {
console.error(
`[check-workflows] missing workflow linter: install actionlint or Go ${ACTIONLINT_VERSION} fallback support.`,
);
process.exit(1);
}
run("python3", ["scripts/check-composite-action-input-interpolation.py"]);

View File

@@ -2,15 +2,62 @@ import { performance } from "node:perf_hooks";
import { printTimingSummary } from "./lib/check-timing-summary.mjs";
import { runManagedCommand } from "./lib/managed-child-process.mjs";
export function usage() {
return [
"Usage: node scripts/check.mjs [--timed] [--include-architecture] [--include-test-types]",
"",
"Runs the local check graph: guard preflights, typecheck, lint, and policy guards.",
"",
"Options:",
" --timed Print timing summary even when checks pass.",
" --include-architecture Run architecture import-cycle checks instead of runtime cycles.",
" --include-test-types Typecheck production and test sources.",
" -h, --help Show this help.",
].join("\n");
}
export function parseCheckArgs(argv) {
const args = {
help: false,
includeArchitecture: false,
includeTestTypes: false,
timed: false,
};
for (const arg of argv) {
if (arg === "--timed") {
args.timed = true;
} else if (arg === "--include-architecture") {
args.includeArchitecture = true;
} else if (arg === "--include-test-types") {
args.includeTestTypes = true;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
}
}
return args;
}
export async function main(argv = process.argv.slice(2)) {
const timed = argv.includes("--timed");
const includeArchitecture = argv.includes("--include-architecture");
const includeTestTypes = argv.includes("--include-test-types");
let args;
try {
args = parseCheckArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
return;
}
if (args.help) {
console.log(usage());
process.exitCode = 0;
return;
}
const tailChecks = [
{ name: "webhook body guard", args: ["lint:webhook:no-low-level-body-read"] },
{ name: "runtime action config guard", args: ["check:no-runtime-action-load-config"] },
!includeArchitecture
!args.includeArchitecture
? {
name: "deprecated API usage guard",
args: ["check:deprecated-api-usage"],
@@ -19,7 +66,7 @@ export async function main(argv = process.argv.slice(2)) {
{ name: "temp path guard", args: ["check:temp-path-guardrails"] },
{ name: "pairing store guard", args: ["lint:auth:no-pairing-store-group"] },
{ name: "pairing account guard", args: ["lint:auth:pairing-account-scope"] },
includeArchitecture
args.includeArchitecture
? { name: "architecture import cycles", args: ["check:architecture"] }
: { name: "runtime import cycles", args: ["check:import-cycles"] },
].filter(Boolean);
@@ -58,8 +105,8 @@ export async function main(argv = process.argv.slice(2)) {
parallel: false,
commands: [
{
name: includeTestTypes ? "typecheck all" : "typecheck prod",
args: [includeTestTypes ? "tsgo:all" : "tsgo:prod"],
name: args.includeTestTypes ? "typecheck all" : "typecheck prod",
args: [args.includeTestTypes ? "tsgo:all" : "tsgo:prod"],
},
],
},
@@ -92,7 +139,7 @@ export async function main(argv = process.argv.slice(2)) {
}
}
if (timed || exitCode !== 0) {
if (args.timed || exitCode !== 0) {
printSummary(timings);
}

View File

@@ -6,6 +6,7 @@ import { die, run, say, sh, warn } from "./host-command.ts";
import type { HostServer } from "./types.ts";
const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024;
const HOST_SERVER_STDERR_DRAIN_MS = 5_000;
export function resolveHostIp(explicit = ""): string {
if (explicit) {
@@ -113,7 +114,7 @@ async function waitForHostServer(
while (Date.now() - startedAt < 10_000) {
if (child.exitCode != null) {
if (!childClosed) {
await Promise.race([childClose, delay(1_000)]);
await Promise.race([childClose, delay(HOST_SERVER_STDERR_DRAIN_MS)]);
}
die(`host artifact server exited early: ${stderr.trim() || `exit ${child.exitCode}`}`);
}

View File

@@ -1,14 +1,11 @@
#!/usr/bin/env -S pnpm tsx
import { mkdir, readFile, rm } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
makeTempDir,
packageBuildCommitFromTgz,
packageVersionFromTgz,
packOpenClaw,
parseBoolEnv,
parseMode,
parseProvider,
@@ -16,19 +13,15 @@ import {
posixProviderOnlyPluginIsolationScript,
repoRoot,
resolveParallelsModelTimeoutSeconds,
resolveHostIp,
resolveHostPort,
resolveLatestVersion,
resolveProviderAuth,
resolveSnapshot,
run,
say,
shellQuote,
startHostServer,
warn,
writeJson,
writeSummaryMarkdown,
type HostServer,
type Mode,
type PackageArtifact,
type Provider,
@@ -36,9 +29,19 @@ import {
type SnapshotInfo,
} from "./common.ts";
import { LinuxGuest } from "./guest-transports.ts";
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
import { resolveUbuntuVmName, waitForVmStatus } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
import {
buildCommonSmokeSummary,
expectedPackageBuildCommit,
expectedPackageTargetVersion,
extractLastOpenClawVersion,
packAndServeSmokeArtifact,
printSmokeTargetSummary,
SmokeRunController,
type SmokeHostOptions,
type SmokeRunOptions,
} from "./smoke-common.ts";
// Older published baselines predate this warning, but still need update coverage.
const BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION = "2026.5.7";
@@ -66,23 +69,13 @@ function compareOpenClawPackageVersions(left: string, right: string): number {
return 0;
}
interface LinuxOptions {
interface LinuxOptions extends SmokeHostOptions, SmokeRunOptions {
vmName: string;
vmNameExplicit: boolean;
snapshotHint: string;
mode: Mode;
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
installUrl: string;
hostPort: number;
hostPortExplicit: boolean;
hostIp?: string;
latestVersion?: string;
installVersion?: string;
targetPackageSpec?: string;
keepServer: boolean;
json: boolean;
}
interface LinuxSummary {
@@ -232,21 +225,16 @@ function parseArgs(argv: string[]): LinuxOptions {
return options;
}
class LinuxSmoke {
class LinuxSmoke extends SmokeRunController<LinuxOptions> {
private auth: ProviderAuth;
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
private hostIp = "";
private hostPort = 0;
private server: HostServer | null = null;
private runDir = "";
private tgzDir = "";
private artifact: PackageArtifact | null = null;
private latestVersion = "";
private snapshot!: SnapshotInfo;
private phases!: PhaseRunner;
private guest!: LinuxGuest;
private status = {
protected status = {
daemon: "systemd-user-unavailable",
freshAgent: "skip",
freshGateway: "skip",
@@ -259,7 +247,8 @@ class LinuxSmoke {
upgradeVersion: "skip",
};
constructor(private options: LinuxOptions) {
constructor(options: LinuxOptions) {
super(options);
this.auth = resolveProviderAuth({
apiKeyEnv: options.apiKeyEnv,
modelId: options.modelId,
@@ -276,72 +265,24 @@ class LinuxSmoke {
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
this.guest = new LinuxGuest(this.options.vmName, this.phases);
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
this.hostIp = resolveHostIp(this.options.hostIp);
this.hostPort = await resolveHostPort(
this.options.hostPort,
this.options.hostPortExplicit,
await this.prepareHost(
defaultOptions().hostPort,
this.latestVersion,
this.snapshot,
this.options.vmName,
);
say(`VM: ${this.options.vmName}`);
say(`Snapshot hint: ${this.options.snapshotHint}`);
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
say(`Latest npm version: ${this.latestVersion}`);
say(
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
[this.artifact, this.server, this.hostPort] = await packAndServeSmokeArtifact(
this.tgzDir,
this.options.targetPackageSpec,
this.hostIp,
this.hostPort,
this.artifactLabel(),
);
say(`Run logs: ${this.runDir}`);
this.artifact = await packOpenClaw({
destination: this.tgzDir,
packageSpec: this.options.targetPackageSpec,
requireControlUi: false,
});
this.server = await startHostServer({
artifactPath: this.artifact.path,
dir: this.tgzDir,
hostIp: this.hostIp,
label: this.artifactLabel(),
port: this.hostPort,
});
this.hostPort = this.server.port;
if (this.options.mode === "fresh" || this.options.mode === "both") {
await this.runLane("fresh", async () => this.runFreshLane());
}
if (this.options.mode === "upgrade" || this.options.mode === "both") {
await this.runLane("upgrade", async () => this.runUpgradeLane());
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
this.printSummary(summaryPath);
}
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
process.exitCode = 1;
}
await this.runLanesAndFinish();
} finally {
if (!this.options.keepServer) {
await this.server?.stop().catch(() => undefined);
}
if (!this.options.keepServer) {
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
}
}
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
}
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
if (name === "fresh") {
this.status.freshMain = status;
} else {
this.status.upgrade = status;
await this.cleanupArtifacts();
}
}
@@ -353,7 +294,7 @@ class LinuxSmoke {
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
}
private async runFreshLane(): Promise<void> {
protected async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("fresh.bootstrap-guest", 600, () => this.bootstrapGuest());
await this.phase("fresh.preflight", 90, () => this.logGuestPreflight());
@@ -381,7 +322,7 @@ class LinuxSmoke {
this.status.freshAgent = "pass";
}
private async runUpgradeLane(): Promise<void> {
protected async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("upgrade.bootstrap-guest", 600, () => this.bootstrapGuest());
await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
@@ -413,17 +354,10 @@ class LinuxSmoke {
this.status.upgradeAgent = "pass";
}
private async phase(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<void> {
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(): number | undefined {
return this.phases.remainingTimeoutMs();
}
private remainingPhaseTimeoutMs = (): number | undefined => this.phases.remainingTimeoutMs();
private logGuestPreflight(): void {
this.guestBash(String.raw`set -euo pipefail
@@ -434,13 +368,12 @@ printf 'preflight.umask=%s\n' "$(umask)"
printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
}
private log(text: string): void {
this.phases.append(text);
}
private log = (text: string): void => this.phases.append(text);
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
return this.guest.exec(args, options);
}
private guestExec = (
args: string[],
options: { check?: boolean; timeoutMs?: number } = {},
): string => this.guest.exec(args, options);
private guestBash(script: string): string {
return this.guest.bash(script);
@@ -540,14 +473,10 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
die("package artifact missing");
}
if (this.options.targetPackageSpec) {
const version = this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
this.verifyVersionContains(version);
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
return;
}
const commit =
this.artifact.buildCommitShort ||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
this.verifyVersionContains(commit);
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
}
private verifyVersionContains(needle: string): void {
@@ -831,39 +760,26 @@ fi`,
}
private async extractLastVersion(phaseId: string): Promise<string> {
const text = await readFile(path.join(this.runDir, `${phaseId}.log`), "utf8").catch(() => "");
return [...text.matchAll(/OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)/g)].at(-1)?.[0] ?? "";
return await extractLastOpenClawVersion(
this.runDir,
phaseId,
/(OpenClaw [^\r\n]+ \([0-9a-f]{7,}\))/g,
);
}
private async writeSummary(): Promise<string> {
protected async writeSummary(): Promise<string> {
const summaryPath = path.join(this.runDir, "summary.json");
const summary: LinuxSummary = {
currentHead:
this.artifact?.buildCommitShort ||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
daemon: this.status.daemon,
freshMain: {
agent: this.status.freshAgent,
gateway: this.status.freshGateway,
status: this.status.freshMain,
version: this.status.freshVersion,
},
installVersion: this.options.installVersion || "",
latestVersion: this.latestVersion,
mode: this.options.mode,
provider: this.options.provider,
runDir: this.runDir,
snapshotHint: this.options.snapshotHint,
snapshotId: this.snapshot.id,
targetPackageSpec: this.options.targetPackageSpec || "",
upgrade: {
agent: this.status.upgradeAgent,
gateway: this.status.upgradeGateway,
latestVersionInstalled: this.status.latestInstalledVersion,
mainVersion: this.status.upgradeVersion,
status: this.status.upgrade,
},
vm: this.options.vmName,
...buildCommonSmokeSummary({
artifact: this.artifact,
latestVersion: this.latestVersion,
options: this.options,
runDir: this.runDir,
snapshot: this.snapshot,
status: this.status,
vmName: this.options.vmName,
}),
};
await writeJson(summaryPath, summary);
await writeSummaryMarkdown({
@@ -882,14 +798,9 @@ fi`,
return summaryPath;
}
private printSummary(summaryPath: string): void {
protected printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
if (this.options.targetPackageSpec) {
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
}
if (this.options.installVersion) {
process.stdout.write(` baseline-install-version: ${this.options.installVersion}\n`);
}
printSmokeTargetSummary(this.options);
process.stdout.write(` daemon: ${this.status.daemon}\n`);
process.stdout.write(` fresh-main: ${this.status.freshMain} (${this.status.freshVersion})\n`);
process.stdout.write(

View File

@@ -0,0 +1,359 @@
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { run, say } from "./host-command.ts";
import { resolveHostIp, resolveHostPort } from "./host-server.ts";
import { startHostServer } from "./host-server.ts";
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
import {
packageBuildCommitFromTgz,
packageVersionFromTgz,
packOpenClaw,
} from "./package-artifact.ts";
import type { HostServer, Mode, PackageArtifact, Provider, SnapshotInfo } from "./types.ts";
export interface SmokeHostOptions {
hostIp?: string;
hostPort: number;
hostPortExplicit: boolean;
}
export interface SmokeRunOptions {
installVersion?: string;
json: boolean;
keepServer: boolean;
mode: Mode;
provider: Provider;
snapshotHint: string;
targetPackageSpec?: string;
}
export interface SmokeLaneStatuses {
freshAgent: string;
freshGateway: string;
freshMain: string;
freshVersion: string;
latestInstalledVersion: string;
upgrade: string;
upgradeAgent: string;
upgradeGateway: string;
upgradeVersion: string;
}
export interface CommonSmokeSummary {
currentHead: string;
freshMain: {
agent: string;
gateway: string;
status: string;
version: string;
};
installVersion: string;
latestVersion: string;
mode: Mode;
provider: Provider;
runDir: string;
snapshotHint: string;
snapshotId: string;
targetPackageSpec: string;
upgrade: {
agent: string;
gateway: string;
latestVersionInstalled: string;
mainVersion: string;
status: string;
};
vm: string;
}
export abstract class SmokeRunController<TOptions extends SmokeRunOptions & SmokeHostOptions> {
protected hostIp = "";
protected hostPort = 0;
protected runDir = "";
protected server: HostServer | null = null;
protected tgzDir = "";
protected constructor(protected options: TOptions) {}
protected abstract runFreshLane(): Promise<void>;
protected abstract runUpgradeLane(): Promise<void>;
protected abstract writeSummary(): Promise<string>;
protected abstract printSummary(summaryPath: string): void;
protected abstract status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">;
protected async prepareHost(
defaultPort: number,
latestVersion: string,
snapshot: SnapshotInfo,
vmName: string,
): Promise<void> {
[this.hostIp, this.hostPort] = await prepareSmokeRunHost(
this.options,
defaultPort,
latestVersion,
this.runDir,
snapshot,
this.options.snapshotHint,
vmName,
);
}
protected async runLanesAndFinish(): Promise<void> {
await runSmokeLanesAndFinish(
this.options.mode,
this.options.json,
this.status,
async () => this.runFreshLane(),
async () => this.runUpgradeLane(),
async () => this.writeSummary(),
(path) => this.printSummary(path),
);
}
protected async cleanupArtifacts(): Promise<void> {
await cleanupSmokeArtifacts({
keepServer: this.options.keepServer,
server: this.server,
tgzDir: this.tgzDir,
});
}
}
export async function resolveSmokeHostConfig(
options: SmokeHostOptions,
defaultPort: number,
): Promise<{ hostIp: string; hostPort: number }> {
return {
hostIp: resolveHostIp(options.hostIp),
hostPort: await resolveHostPort(options.hostPort, options.hostPortExplicit, defaultPort),
};
}
export async function prepareSmokeRunHost(
options: SmokeHostOptions,
defaultPort: number,
latestVersion: string,
runDir: string,
snapshot: SnapshotInfo,
snapshotHint: string,
vmName: string,
): Promise<readonly [hostIp: string, hostPort: number]> {
const host = await resolveSmokeHostConfig(options, defaultPort);
logSmokeRunStart({
latestVersion,
runDir,
snapshot,
snapshotHint,
vmName,
});
return [host.hostIp, host.hostPort];
}
export function logSmokeRunStart(input: {
latestVersion: string;
runDir: string;
snapshot: SnapshotInfo;
snapshotHint: string;
vmName: string;
}): void {
say(`VM: ${input.vmName}`);
say(`Snapshot hint: ${input.snapshotHint}`);
say(`Resolved snapshot: ${input.snapshot.name} [${input.snapshot.state}]`);
say(`Latest npm version: ${input.latestVersion}`);
say(`Current head: ${currentGitHeadShort()}`);
say(`Run logs: ${input.runDir}`);
}
export async function startSmokeArtifactServer(input: {
artifact: PackageArtifact;
dir: string;
hostIp: string;
label: string;
port: number;
}): Promise<{ hostPort: number; server: HostServer }> {
const server = await startHostServer({
artifactPath: input.artifact.path,
dir: input.dir,
hostIp: input.hostIp,
label: input.label,
port: input.port,
});
return { hostPort: server.port, server };
}
export async function packAndServeSmokeArtifact(
tgzDir: string,
packageSpec: string | undefined,
hostIp: string,
hostPort: number,
label: string,
requireControlUi = false,
): Promise<readonly [artifact: PackageArtifact, server: HostServer, hostPort: number]> {
const artifact = await packOpenClaw({
destination: tgzDir,
packageSpec,
requireControlUi,
});
const server = await startSmokeArtifactServer({
artifact,
dir: tgzDir,
hostIp,
label,
port: hostPort,
});
return [artifact, server.server, server.hostPort];
}
export async function runRequestedSmokeLanes(input: {
mode: Mode;
runFresh: () => Promise<void>;
runLane: (name: "fresh" | "upgrade", fn: () => Promise<void>) => Promise<void>;
runUpgrade: () => Promise<void>;
}): Promise<void> {
if (input.mode === "fresh" || input.mode === "both") {
await input.runLane("fresh", input.runFresh);
}
if (input.mode === "upgrade" || input.mode === "both") {
await input.runLane("upgrade", input.runUpgrade);
}
}
export async function runSmokeLaneWithStatus(
name: "fresh" | "upgrade",
fn: () => Promise<void>,
statuses: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
): Promise<void> {
await runSmokeLane(name, fn, (lane, status) => setSmokeLaneStatus(statuses, lane, status));
}
export function setSmokeLaneStatus(
statuses: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
name: SmokeLane,
status: SmokeLaneStatus,
): void {
if (name === "fresh") {
statuses.freshMain = status;
} else {
statuses.upgrade = status;
}
}
export async function finishSmokeRun(input: {
json: boolean;
printSummary: (summaryPath: string) => void;
status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">;
summaryPath: string;
}): Promise<void> {
if (input.json) {
process.stdout.write(await readFile(input.summaryPath, "utf8"));
} else {
input.printSummary(input.summaryPath);
}
if (input.status.freshMain === "fail" || input.status.upgrade === "fail") {
process.exitCode = 1;
}
}
export async function runSmokeLanesAndFinish(
mode: Mode,
json: boolean,
status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
runFresh: () => Promise<void>,
runUpgrade: () => Promise<void>,
writeSummary: () => Promise<string>,
printSummary: (summaryPath: string) => void,
): Promise<void> {
await runRequestedSmokeLanes({
mode,
runFresh,
runLane: async (name, fn) => runSmokeLaneWithStatus(name, fn, status),
runUpgrade,
});
await finishSmokeRun({
json,
printSummary,
status,
summaryPath: await writeSummary(),
});
}
export async function cleanupSmokeArtifacts(input: {
keepServer: boolean;
server: HostServer | null;
tgzDir: string;
}): Promise<void> {
if (input.keepServer) {
return;
}
await input.server?.stop().catch(() => undefined);
await rm(input.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
export async function expectedPackageTargetVersion(artifact: PackageArtifact): Promise<string> {
return artifact.version || (await packageVersionFromTgz(artifact.path));
}
export async function expectedPackageBuildCommit(artifact: PackageArtifact): Promise<string> {
return artifact.buildCommitShort || (await packageBuildCommitFromTgz(artifact.path)).slice(0, 7);
}
export async function extractLastOpenClawVersion(
runDir: string,
phaseName: string,
pattern: RegExp,
): Promise<string> {
const text = await readFile(path.join(runDir, `${phaseName}.log`), "utf8").catch(() => "");
return [...text.matchAll(pattern)].at(-1)?.[1] ?? "";
}
export function buildCommonSmokeSummary(input: {
artifact: PackageArtifact | null;
latestVersion: string;
options: SmokeRunOptions;
runDir: string;
snapshot: SnapshotInfo;
status: SmokeLaneStatuses;
vmName: string;
}): CommonSmokeSummary {
return {
currentHead: input.artifact?.buildCommitShort || currentGitHeadShort(),
freshMain: {
agent: input.status.freshAgent,
gateway: input.status.freshGateway,
status: input.status.freshMain,
version: input.status.freshVersion,
},
installVersion: input.options.installVersion || "",
latestVersion: input.latestVersion,
mode: input.options.mode,
provider: input.options.provider,
runDir: input.runDir,
snapshotHint: input.options.snapshotHint,
snapshotId: input.snapshot.id,
targetPackageSpec: input.options.targetPackageSpec || "",
upgrade: {
agent: input.status.upgradeAgent,
gateway: input.status.upgradeGateway,
latestVersionInstalled: input.status.latestInstalledVersion,
mainVersion: input.status.upgradeVersion,
status: input.status.upgrade,
},
vm: input.vmName,
};
}
export function printSmokeTargetSummary(input: {
includeInstallVersion?: boolean;
installVersion?: string;
targetPackageSpec?: string;
}): void {
if (input.targetPackageSpec) {
process.stdout.write(` target-package: ${input.targetPackageSpec}\n`);
}
if (input.includeInstallVersion !== false && input.installVersion) {
process.stdout.write(` baseline-install-version: ${input.installVersion}\n`);
}
}
function currentGitHeadShort(): string {
return run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim();
}

View File

@@ -1,29 +1,21 @@
#!/usr/bin/env -S pnpm tsx
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
makeTempDir,
packageBuildCommitFromTgz,
packageVersionFromTgz,
packOpenClaw,
parseMode,
parseProvider,
resolveHostIp,
resolveHostPort,
resolveLatestVersion,
resolveParallelsModelTimeoutSeconds,
resolveWindowsProviderAuth,
resolveSnapshot,
run,
say,
startHostServer,
warn,
writeSummaryMarkdown,
writeJson,
type HostServer,
type Mode,
type PackageArtifact,
type Provider,
@@ -31,7 +23,7 @@ import {
type SnapshotInfo,
} from "./common.ts";
import { runWindowsBackgroundPowerShell, WindowsGuest } from "./guest-transports.ts";
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
import { startHostServer } from "./host-server.ts";
import { waitForVmStatus } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
import { windowsProviderOnlyPluginIsolationScript } from "./plugin-isolation.ts";
@@ -41,26 +33,27 @@ import {
windowsOpenClawResolver,
windowsScopedEnvFunction,
} from "./powershell.ts";
import {
buildCommonSmokeSummary,
expectedPackageBuildCommit,
expectedPackageTargetVersion,
extractLastOpenClawVersion,
packAndServeSmokeArtifact,
printSmokeTargetSummary,
SmokeRunController,
type SmokeHostOptions,
type SmokeRunOptions,
} from "./smoke-common.ts";
import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts";
interface WindowsOptions {
interface WindowsOptions extends SmokeHostOptions, SmokeRunOptions {
vmName: string;
snapshotHint: string;
mode: Mode;
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
installUrl: string;
hostPort: number;
hostPortExplicit: boolean;
hostIp?: string;
latestVersion?: string;
installVersion?: string;
targetPackageSpec?: string;
upgradeFromPackedMain: boolean;
skipLatestRefCheck: boolean;
keepServer: boolean;
json: boolean;
}
interface WindowsSummary {
@@ -224,23 +217,17 @@ function parseArgs(argv: string[]): WindowsOptions {
return options;
}
class WindowsSmoke {
class WindowsSmoke extends SmokeRunController<WindowsOptions> {
private auth: ProviderAuth;
private hostIp = "";
private hostPort = 0;
private runDir = "";
private tgzDir = "";
private server: HostServer | null = null;
private artifact: PackageArtifact | null = null;
private minGitZipPath = "";
private latestVersion = "";
private installVersion = "";
private targetExpectVersion = "";
private snapshot!: SnapshotInfo;
private phases!: PhaseRunner;
private guest!: WindowsGuest;
private status = {
protected status = {
freshAgent: "skip",
freshGateway: "skip",
freshMain: "skip",
@@ -253,7 +240,8 @@ class WindowsSmoke {
upgradeVersion: "skip",
};
constructor(private options: WindowsOptions) {
constructor(options: WindowsOptions) {
super(options);
this.auth = resolveWindowsProviderAuth({
apiKeyEnv: options.apiKeyEnv,
modelId: options.modelId,
@@ -270,41 +258,22 @@ class WindowsSmoke {
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
this.installVersion = this.options.installVersion || this.latestVersion;
this.hostIp = resolveHostIp(this.options.hostIp);
this.hostPort = await resolveHostPort(
this.options.hostPort,
this.options.hostPortExplicit,
await this.prepareHost(
defaultOptions().hostPort,
this.latestVersion,
this.snapshot,
this.options.vmName,
);
say(`VM: ${this.options.vmName}`);
say(`Snapshot hint: ${this.options.snapshotHint}`);
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
say(`Latest npm version: ${this.latestVersion}`);
say(
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
);
say(`Run logs: ${this.runDir}`);
this.minGitZipPath = await prepareMinGitZip(this.tgzDir);
if (this.needsHostTgz()) {
this.artifact = await packOpenClaw({
destination: this.tgzDir,
packageSpec: this.options.targetPackageSpec,
requireControlUi: false,
});
if (this.options.targetPackageSpec) {
this.targetExpectVersion =
this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
}
this.server = await startHostServer({
artifactPath: this.artifact.path,
dir: this.tgzDir,
hostIp: this.hostIp,
label: this.artifactLabel(),
port: this.hostPort,
});
this.hostPort = this.server.port;
[this.artifact, this.server, this.hostPort] = await packAndServeSmokeArtifact(
this.tgzDir,
this.options.targetPackageSpec,
this.hostIp,
this.hostPort,
this.artifactLabel(),
);
}
if (!this.server) {
this.server = await startHostServer({
@@ -317,27 +286,9 @@ class WindowsSmoke {
this.hostPort = this.server.port;
}
if (this.options.mode === "fresh" || this.options.mode === "both") {
await this.runLane("fresh", async () => this.runFreshLane());
}
if (this.options.mode === "upgrade" || this.options.mode === "both") {
await this.runLane("upgrade", async () => this.runUpgradeLane());
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
this.printSummary(summaryPath);
}
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
process.exitCode = 1;
}
await this.runLanesAndFinish();
} finally {
if (!this.options.keepServer) {
await this.server?.stop().catch(() => undefined);
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
await this.cleanupArtifacts();
}
}
@@ -374,19 +325,7 @@ class WindowsSmoke {
return this.options.upgradeFromPackedMain ? "packed-main->dev" : "latest->dev";
}
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
}
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
if (name === "fresh") {
this.status.freshMain = status;
} else {
this.status.upgrade = status;
}
}
private async runFreshLane(): Promise<void> {
protected async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
await this.phase("fresh.ensure-git", 1200, () =>
@@ -408,7 +347,7 @@ class WindowsSmoke {
this.status.freshAgent = "pass";
}
private async runUpgradeLane(): Promise<void> {
protected async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
await this.phase("upgrade.ensure-git", 1200, () =>
@@ -466,33 +405,24 @@ class WindowsSmoke {
this.status.upgradeAgent = "pass";
}
private async phase(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<void> {
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
return this.phases.remainingTimeoutMs(fallbackMs);
}
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private async phaseReturns(
private phaseReturns = async (
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<boolean> {
return await this.phases.phaseReturns(name, timeoutSeconds, fn);
}
): Promise<boolean> => await this.phases.phaseReturns(name, timeoutSeconds, fn);
private log(text: string): void {
this.phases.append(text);
}
private log = (text: string): void => this.phases.append(text);
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
return this.guest.exec(args, options);
}
private guestExec = (
args: string[],
options: { check?: boolean; timeoutMs?: number } = {},
): string => this.guest.exec(args, options);
private guestPowerShell(
script: string,
@@ -620,16 +550,16 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST
private async verifyTargetVersion(): Promise<void> {
if (this.options.targetPackageSpec) {
this.verifyVersionContains(this.targetExpectVersion);
if (!this.artifact) {
die("package artifact missing");
}
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
return;
}
if (!this.artifact) {
die("package artifact missing");
}
const commit =
this.artifact.buildCommitShort ||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
this.verifyVersionContains(commit);
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
}
private verifyVersionContains(needle: string): void {
@@ -820,39 +750,25 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
}
private async extractLastVersion(phaseName: string): Promise<string> {
const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => "");
const matches = [...log.matchAll(/OpenClaw\s+([0-9][^\s]*)/gi)];
return matches.at(-1)?.[1] ?? "";
return await extractLastOpenClawVersion(this.runDir, phaseName, /OpenClaw\s+([0-9][^\s]*)/gi);
}
private async writeSummary(): Promise<string> {
const summary: WindowsSummary = {
currentHead:
this.artifact?.buildCommitShort ||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
freshMain: {
agent: this.status.freshAgent,
gateway: this.status.freshGateway,
status: this.status.freshMain,
version: this.status.freshVersion,
},
installVersion: this.options.installVersion || "",
protected async writeSummary(): Promise<string> {
const common = buildCommonSmokeSummary({
artifact: this.artifact,
latestVersion: this.latestVersion,
mode: this.options.mode,
provider: this.options.provider,
options: this.options,
runDir: this.runDir,
snapshotHint: this.options.snapshotHint,
snapshotId: this.snapshot.id,
targetPackageSpec: this.options.targetPackageSpec || "",
snapshot: this.snapshot,
status: this.status,
vmName: this.options.vmName,
});
const summary: WindowsSummary = {
...common,
upgrade: {
agent: this.status.upgradeAgent,
gateway: this.status.upgradeGateway,
latestVersionInstalled: this.status.latestInstalledVersion,
mainVersion: this.status.upgradeVersion,
...common.upgrade,
precheck: this.status.upgradePrecheck,
status: this.status.upgrade,
},
vm: this.options.vmName,
};
const summaryPath = path.join(this.runDir, "summary.json");
await writeJson(summaryPath, summary);
@@ -870,11 +786,9 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
return summaryPath;
}
private printSummary(summaryPath: string): void {
protected printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
if (this.options.targetPackageSpec) {
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
}
printSmokeTargetSummary({ ...this.options, includeInstallVersion: false });
if (this.options.upgradeFromPackedMain) {
process.stdout.write(" upgrade-from-packed-main: yes\n");
}

View File

@@ -19,6 +19,7 @@ const PLUGIN_SDK_TYPE_INPUTS = [
"packages/markdown-core/src",
"packages/memory-host-sdk/src",
"packages/media-generation-core/src",
"packages/media-understanding-common/src",
"packages/terminal-core/src",
"src/video-generation/dashscope-compatible.ts",
"src/video-generation/types.ts",

View File

@@ -12,6 +12,7 @@ const RUN_NODE_PACKAGE_SOURCE_ROOTS = [
"packages/gateway-protocol/src",
"packages/markdown-core/src",
"packages/media-generation-core/src",
"packages/media-understanding-common/src",
"packages/terminal-core/src",
"packages/net-policy/src",
];

View File

@@ -10,11 +10,18 @@ import {
const DEFAULT_WINDOWS_EXTENSION_CHUNK_SIZE = 8;
const DEFAULT_SHARD_HEARTBEAT_MS = 30_000;
const DEFAULT_SHARD_TIMEOUT_MS = 15 * 60_000;
const DEFAULT_SHARD_KILL_GRACE_MS = 5_000;
const FAST_LOCAL_CHECK_MIN_CPUS = 12;
const FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * 1024 ** 3;
const EXTENSION_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
const EXTENSIONS_DIR = "extensions";
const OXLINT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM"];
const ACTIVE_SHARD_CHILDREN = new Set();
let parentTerminationSignal = null;
let parentTerminationForceKill = null;
let parentSignalForwardingInstalled = false;
const CORE_SHARD = {
name: "core",
@@ -319,24 +326,35 @@ async function runShardsSerial({ entries, env, extraArgs, runner }) {
const results = [];
for (const shard of entries) {
results.push(await runShard({ env, extraArgs, runner, shard }));
if (isParentTerminationRequested()) {
break;
}
}
return results;
}
async function runShard({ env, extraArgs, runner, shard }) {
export async function runShard({ env, extraArgs, runner, shard }) {
console.error(`[oxlint:${shard.name}] starting`);
const startedAt = Date.now();
const heartbeatMs = resolveShardHeartbeatMs(env);
const timeoutMs = resolveShardTimeoutMs(env);
const killGraceMs = resolveShardKillGraceMs(env);
const useProcessGroup = process.platform !== "win32";
const child = spawn(process.execPath, [runner, ...shard.args, ...extraArgs], {
stdio: "inherit",
detached: useProcessGroup,
env: {
...env,
OPENCLAW_OXLINT_SKIP_LOCK: "1",
OPENCLAW_OXLINT_SKIP_PREPARE: "1",
},
});
const unregisterShardChild = registerShardChild({ child, killGraceMs, useProcessGroup });
return await new Promise((resolve) => {
let finished = false;
let timedOut = false;
let forceKill = null;
const heartbeat =
heartbeatMs > 0
? setInterval(() => {
@@ -345,10 +363,42 @@ async function runShard({ env, extraArgs, runner, shard }) {
}, heartbeatMs)
: null;
heartbeat?.unref();
const timeout =
timeoutMs > 0
? setTimeout(() => {
timedOut = true;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
console.error(
`[oxlint:${shard.name}] timed out after ${elapsedSeconds}s; terminating shard`,
);
signalChildProcess({ child, signal: "SIGTERM", useProcessGroup });
if (killGraceMs > 0) {
forceKill = setTimeout(() => {
console.error(`[oxlint:${shard.name}] did not exit cleanly; killing shard`);
signalChildProcess({ child, signal: "SIGKILL", useProcessGroup });
}, killGraceMs);
forceKill.unref();
} else {
signalChildProcess({ child, signal: "SIGKILL", useProcessGroup });
}
}, timeoutMs)
: null;
timeout?.unref();
const finish = (status) => {
if (finished) {
return;
}
finished = true;
if (heartbeat) {
clearInterval(heartbeat);
}
if (timeout) {
clearTimeout(timeout);
}
if (forceKill) {
clearTimeout(forceKill);
}
unregisterShardChild();
console.error(`[oxlint:${shard.name}] finished`);
resolve(status);
};
@@ -357,19 +407,131 @@ async function runShard({ env, extraArgs, runner, shard }) {
finish(1);
});
child.once("close", (status) => {
finish(status ?? 1);
finish(
parentTerminationSignal
? getSignalExitCode(parentTerminationSignal)
: timedOut
? 124
: (status ?? 1),
);
});
});
}
export function resolveShardHeartbeatMs(env) {
const rawValue = env.OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS;
return resolveNonNegativeEnvInt(
env,
"OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS",
DEFAULT_SHARD_HEARTBEAT_MS,
);
}
export function resolveShardTimeoutMs(env) {
return resolveNonNegativeEnvInt(
env,
"OPENCLAW_OXLINT_SHARD_TIMEOUT_MS",
DEFAULT_SHARD_TIMEOUT_MS,
);
}
export function resolveShardKillGraceMs(env) {
return resolveNonNegativeEnvInt(
env,
"OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS",
DEFAULT_SHARD_KILL_GRACE_MS,
);
}
function resolveNonNegativeEnvInt(env, key, defaultValue) {
const rawValue = env[key];
if (rawValue === undefined) {
return DEFAULT_SHARD_HEARTBEAT_MS;
return defaultValue;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) && parsedValue >= 0
? parsedValue
: DEFAULT_SHARD_HEARTBEAT_MS;
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : defaultValue;
}
function signalChildProcess({ child, signal, useProcessGroup }) {
if (!child.pid) {
return;
}
try {
if (useProcessGroup) {
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
} catch (error) {
if (error?.code !== "ESRCH") {
console.error(error);
}
}
}
function registerShardChild(entry) {
installParentSignalForwarding();
ACTIVE_SHARD_CHILDREN.add(entry);
return () => {
ACTIVE_SHARD_CHILDREN.delete(entry);
if (ACTIVE_SHARD_CHILDREN.size === 0 && parentTerminationForceKill) {
clearTimeout(parentTerminationForceKill);
parentTerminationForceKill = null;
}
};
}
function installParentSignalForwarding() {
if (parentSignalForwardingInstalled) {
return;
}
parentSignalForwardingInstalled = true;
for (const signal of PARENT_TERMINATION_SIGNALS) {
process.on(signal, () => {
parentTerminationSignal = signal;
process.exitCode = getSignalExitCode(signal);
if (ACTIVE_SHARD_CHILDREN.size === 0) {
process.exit(process.exitCode);
}
signalActiveShardChildren(signal);
scheduleParentTerminationForceKill();
});
}
process.once("exit", () => {
signalActiveShardChildren("SIGTERM");
});
}
function isParentTerminationRequested() {
return parentTerminationSignal !== null;
}
function signalActiveShardChildren(signal) {
for (const entry of ACTIVE_SHARD_CHILDREN) {
signalChildProcess({ ...entry, signal });
}
}
function scheduleParentTerminationForceKill() {
if (parentTerminationForceKill) {
return;
}
const killGraceMs = Math.max(
0,
...Array.from(ACTIVE_SHARD_CHILDREN, (entry) => entry.killGraceMs),
);
if (killGraceMs === 0) {
signalActiveShardChildren("SIGKILL");
return;
}
parentTerminationForceKill = setTimeout(() => {
parentTerminationForceKill = null;
signalActiveShardChildren("SIGKILL");
}, killGraceMs);
parentTerminationForceKill.unref();
}
function getSignalExitCode(signal) {
return signal === "SIGINT" ? 130 : 143;
}

View File

@@ -1,11 +1,24 @@
import { spawn } from "node:child_process";
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
const USAGE = "Usage: node scripts/run-with-env.mjs KEY=value [KEY=value ...] -- command [args...]";
export function isRunWithEnvHelpRequest(argv) {
for (const arg of argv) {
if (arg === "--") {
return false;
}
if (arg === "--help" || arg === "-h") {
return true;
}
}
return false;
}
export function parseRunWithEnvArgs(argv) {
const separatorIndex = argv.indexOf("--");
if (separatorIndex <= 0 || separatorIndex === argv.length - 1) {
throw new Error("usage: node scripts/run-with-env.mjs KEY=value [KEY=value ...] -- command [args...]");
throw new Error(USAGE);
}
const assignments = argv.slice(0, separatorIndex);
@@ -39,12 +52,17 @@ export function resolveSpawnCommand(command, args, execPath = process.execPath)
}
function main(argv = process.argv.slice(2)) {
if (isRunWithEnvHelpRequest(argv)) {
console.log(USAGE);
return;
}
let parsed;
try {
parsed = parseRunWithEnvArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
process.exit(2);
}
const spawnCommand = resolveSpawnCommand(parsed.command, parsed.args);

View File

@@ -2,9 +2,31 @@
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { forceFreePort, type PortProcess } from "../src/cli/ports.js";
import { resolveGatewayPort } from "../src/config/config.js";
function usage(): string {
return [
"Usage: node --import tsx scripts/test-force.ts",
"",
"Clears the configured OpenClaw gateway port, then runs the local test suite.",
"",
"Options:",
" -h, --help Show this help.",
].join("\n");
}
function parseArgs(argv: readonly string[]): { help: boolean } {
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
return { help: true };
}
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
}
return { help: false };
}
function killGatewayListeners(port: number): PortProcess[] {
try {
const killed = forceFreePort(port);
@@ -42,7 +64,13 @@ function runTests() {
process.exit(result.status ?? 1);
}
function main() {
export function main(argv: readonly string[] = process.argv.slice(2)) {
const args = parseArgs(argv);
if (args.help) {
console.log(usage());
return;
}
const port = resolveGatewayPort(undefined, process.env);
console.log(`🧹 test:force - clearing gateway on port ${port}`);
@@ -55,4 +83,11 @@ function main() {
runTests();
}
main();
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(2);
}
}

View File

@@ -1,37 +1,67 @@
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
const forwardedArgs = [];
let quietOverride;
let forceCodexHarness = false;
for (const arg of process.argv.slice(2)) {
if (arg === "--") {
continue;
}
if (arg === "--codex-harness") {
forceCodexHarness = true;
continue;
}
if (arg === "--quiet" || arg === "--quiet-live") {
quietOverride = "1";
continue;
}
if (arg === "--no-quiet" || arg === "--no-quiet-live") {
quietOverride = "0";
continue;
}
forwardedArgs.push(arg);
export function testLiveUsage() {
return [
"Usage: node scripts/test-live.mjs [options] [--] [vitest targets/args...]",
"",
"Runs live Vitest suites with OPENCLAW_LIVE_TEST=1.",
"",
"Options:",
" --codex-harness Enable the live Codex harness.",
" --quiet, --quiet-live Keep live test output quiet.",
" --no-quiet, --no-quiet-live",
" Show live test output.",
" -h, --help Show this help without starting live tests.",
].join("\n");
}
const env = {
...process.env,
CI: process.env.CI || "1",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: process.env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN || "false",
pnpm_config_verify_deps_before_run: process.env.pnpm_config_verify_deps_before_run || "false",
OPENCLAW_LIVE_TEST: process.env.OPENCLAW_LIVE_TEST || "1",
OPENCLAW_LIVE_TEST_QUIET: quietOverride ?? process.env.OPENCLAW_LIVE_TEST_QUIET ?? "1",
...(forceCodexHarness ? { OPENCLAW_LIVE_CODEX_HARNESS: "1" } : {}),
};
export function parseTestLiveArgs(argv) {
const forwardedArgs = [];
let quietOverride;
let forceCodexHarness = false;
let help = false;
for (const arg of argv) {
if (arg === "--") {
continue;
}
if (arg === "--help" || arg === "-h") {
help = true;
continue;
}
if (arg === "--codex-harness") {
forceCodexHarness = true;
continue;
}
if (arg === "--quiet" || arg === "--quiet-live") {
quietOverride = "1";
continue;
}
if (arg === "--no-quiet" || arg === "--no-quiet-live") {
quietOverride = "0";
continue;
}
forwardedArgs.push(arg);
}
return {
forceCodexHarness,
forwardedArgs,
help,
quietOverride,
};
}
export function buildTestLiveEnv(args, baseEnv = process.env) {
return {
...baseEnv,
CI: baseEnv.CI || "1",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: baseEnv.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN || "false",
pnpm_config_verify_deps_before_run: baseEnv.pnpm_config_verify_deps_before_run || "false",
OPENCLAW_LIVE_TEST: baseEnv.OPENCLAW_LIVE_TEST || "1",
OPENCLAW_LIVE_TEST_QUIET: args.quietOverride ?? baseEnv.OPENCLAW_LIVE_TEST_QUIET ?? "1",
...(args.forceCodexHarness ? { OPENCLAW_LIVE_CODEX_HARNESS: "1" } : {}),
};
}
function parsePositiveInt(value, fallback) {
if (!value) {
@@ -41,66 +71,83 @@ function parsePositiveInt(value, fallback) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
const heartbeatMs = parsePositiveInt(process.env.OPENCLAW_LIVE_WRAPPER_HEARTBEAT_MS, 20_000);
const startedAt = Date.now();
let lastOutputAt = startedAt;
const child = spawnPnpmRunner({
stdio: ["inherit", "pipe", "pipe"],
pnpmArgs: [
export function buildTestLivePnpmArgs(args) {
return [
"exec",
"vitest",
"run",
"--config",
"test/vitest/vitest.live.config.ts",
...forwardedArgs,
],
env,
});
...args.forwardedArgs,
];
}
const noteOutput = () => {
lastOutputAt = Date.now();
};
child.stdout?.on("data", (chunk) => {
noteOutput();
process.stdout.write(chunk);
});
child.stderr?.on("data", (chunk) => {
noteOutput();
process.stderr.write(chunk);
});
const heartbeat = setInterval(() => {
const now = Date.now();
if (now - lastOutputAt < heartbeatMs) {
return;
export function main(argv = process.argv.slice(2), baseEnv = process.env) {
const args = parseTestLiveArgs(argv);
if (args.help) {
console.log(testLiveUsage());
process.exit(0);
}
const elapsedSec = Math.max(1, Math.round((now - startedAt) / 1_000));
const quietSec = Math.max(1, Math.round((now - lastOutputAt) / 1_000));
process.stderr.write(
`[test:live] still running (${elapsedSec}s elapsed, ${quietSec}s since last output)\n`,
);
lastOutputAt = now;
}, heartbeatMs);
heartbeat.unref?.();
child.on("exit", (code, signal) => {
clearInterval(heartbeat);
if (signal) {
process.stderr.write(`[test:live] vitest exited via signal=${signal}\n`);
process.kill(process.pid, signal);
return;
}
if ((code ?? 1) !== 0) {
process.stderr.write(`[test:live] vitest exited code=${code ?? 1}\n`);
}
process.exit(code ?? 1);
});
const env = buildTestLiveEnv(args, baseEnv);
const heartbeatMs = parsePositiveInt(baseEnv.OPENCLAW_LIVE_WRAPPER_HEARTBEAT_MS, 20_000);
const startedAt = Date.now();
let lastOutputAt = startedAt;
child.on("error", (error) => {
clearInterval(heartbeat);
console.error(error);
process.exit(1);
});
const child = spawnPnpmRunner({
stdio: ["inherit", "pipe", "pipe"],
pnpmArgs: buildTestLivePnpmArgs(args),
env,
});
const noteOutput = () => {
lastOutputAt = Date.now();
};
child.stdout?.on("data", (chunk) => {
noteOutput();
process.stdout.write(chunk);
});
child.stderr?.on("data", (chunk) => {
noteOutput();
process.stderr.write(chunk);
});
const heartbeat = setInterval(() => {
const now = Date.now();
if (now - lastOutputAt < heartbeatMs) {
return;
}
const elapsedSec = Math.max(1, Math.round((now - startedAt) / 1_000));
const quietSec = Math.max(1, Math.round((now - lastOutputAt) / 1_000));
process.stderr.write(
`[test:live] still running (${elapsedSec}s elapsed, ${quietSec}s since last output)\n`,
);
lastOutputAt = now;
}, heartbeatMs);
heartbeat.unref?.();
child.on("exit", (code, signal) => {
clearInterval(heartbeat);
if (signal) {
process.stderr.write(`[test:live] vitest exited via signal=${signal}\n`);
process.kill(process.pid, signal);
return;
}
if ((code ?? 1) !== 0) {
process.stderr.write(`[test:live] vitest exited code=${code ?? 1}\n`);
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
clearInterval(heartbeat);
console.error(error);
process.exit(1);
});
}
if (import.meta.main) {
main();
}

View File

@@ -49,6 +49,25 @@ const releaseLockOnce = () => {
releaseLock();
};
function isWrapperMetadataRequest(args) {
for (const arg of args) {
if (arg === "--") {
return false;
}
if (arg === "--help" || arg === "-h") {
return true;
}
}
return false;
}
function printHelp() {
console.log(`Usage: node scripts/test-projects.mjs [--changed <base>] [--watch] [targets...] [-- vitest-args...]
Runs the Vitest project shards that own the requested targets. With no targets,
this runs the full local suite. Use explicit targets for local edit loops.`);
}
function cleanupVitestRunSpec(spec) {
if (!spec.includeFilePath) {
return;
@@ -192,6 +211,10 @@ async function runVitestSpecsParallel(specs, concurrency) {
async function main() {
const suiteStartedAt = performance.now();
const args = process.argv.slice(2);
if (isWrapperMetadataRequest(args)) {
printHelp();
return;
}
const baseEnv = resolveLocalVitestEnv(process.env);
const { targetArgs } = parseTestProjectsArgs(args, process.cwd());
const unmatchedExplicitTargets = findUnmatchedExplicitTestTargets(args, process.cwd());

View File

@@ -222,6 +222,10 @@ function interleaveSlowAndFastSpecs(sortedSpecs) {
return ordered;
}
function uniqueOrdered(values) {
return [...new Set(values)];
}
export function orderFullSuiteSpecsForParallelRun(specs, shardTimings = new Map()) {
const sortedSpecs = specs.toSorted((a, b) => {
const weightDelta =
@@ -361,20 +365,79 @@ const PRECISE_SOURCE_TEST_TARGETS = new Map([
]);
const BROAD_ONLY_TEST_HELPERS = new Set(["test/helpers/poll.ts"]);
const TOOLING_SOURCE_TEST_TARGETS = new Map([
[".crabbox.yaml", ["test/scripts/package-acceptance-workflow.test.ts"]],
[
".github/workflows/ci-check-testbox.yml",
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
],
[
".github/workflows/crabbox-hydrate.yml",
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
],
["scripts/build-all.mjs", ["test/scripts/build-all.test.ts"]],
["scripts/crabbox-wrapper.mjs", ["test/scripts/crabbox-wrapper.test.ts"]],
["scripts/github/barnacle-auto-response.mjs", ["test/scripts/barnacle-auto-response.test.ts"]],
["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]],
["scripts/check.mjs", ["test/scripts/check.test.ts"]],
["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]],
[
"scripts/check-changelog-attributions.mjs",
["test/scripts/check-changelog-attributions.test.ts"],
],
[
"scripts/check-composite-action-input-interpolation.py",
["test/scripts/check-composite-action-input-interpolation.test.ts"],
],
["scripts/check-dependency-pins.mjs", ["test/scripts/check-dependency-pins.test.ts"]],
["scripts/check-deadcode-unused-files.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"]],
["scripts/check-dynamic-import-warts.mjs", ["test/scripts/check-dynamic-import-warts.test.ts"]],
["scripts/check-no-conflict-markers.mjs", ["test/scripts/check-no-conflict-markers.test.ts"]],
[
"scripts/check-workflows.mjs",
[
"test/scripts/check-composite-action-input-interpolation.test.ts",
"test/scripts/check-no-conflict-markers.test.ts",
"test/scripts/ci-workflow-guards.test.ts",
],
],
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/dependency-changes-report.mjs", ["test/scripts/dependency-changes-report.test.ts"]],
[
"scripts/dependency-ownership-surface-report.mjs",
["test/scripts/dependency-ownership-surface-report.test.ts"],
],
[
"scripts/dependency-vulnerability-gate.mjs",
["test/scripts/dependency-vulnerability-gate.test.ts"],
],
[
"scripts/deadcode-unused-files.allowlist.mjs",
["test/scripts/check-deadcode-unused-files.test.ts"],
],
["scripts/docs-link-audit.mjs", ["src/scripts/docs-link-audit.test.ts"]],
["scripts/lib/arg-utils.mjs", ["test/scripts/arg-utils.test.ts"]],
[
"scripts/lib/bundled-plugin-build-entries.mjs",
["test/scripts/bundled-plugin-build-entries.test.ts"],
],
[
"scripts/lib/bundled-plugin-source-utils.mjs",
["test/scripts/bundled-plugin-source-utils.test.ts"],
],
["scripts/lib/dev-tooling-safety.ts", ["test/scripts/dev-tooling-safety.test.ts"]],
["scripts/lib/docker-e2e-container.sh", ["test/scripts/docker-build-helper.test.ts"]],
["scripts/lib/docker-e2e-package.sh", ["test/scripts/docker-build-helper.test.ts"]],
["scripts/lib/format-generated-module.mjs", ["test/scripts/format-generated-module.test.ts"]],
["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]],
["scripts/lib/local-heavy-check-runtime.mjs", ["test/scripts/local-heavy-check-runtime.test.ts"]],
["scripts/lib/managed-child-process.mjs", ["test/scripts/managed-child-process.test.ts"]],
["scripts/lib/npm-verify-exec.ts", ["test/scripts/npm-verify-exec.test.ts"]],
["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]],
["scripts/lib/source-file-scan-cache.mjs", ["test/scripts/source-file-scan-cache.test.ts"]],
["scripts/lib/test-group-report.mjs", ["test/scripts/test-group-report.test.ts"]],
["scripts/lib/ts-guard-utils.mjs", ["test/scripts/ts-guard-utils.test.ts"]],
["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]],
[
"scripts/mantis/build-telegram-evidence.mjs",
@@ -395,13 +458,33 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
],
["scripts/run-oxlint.mjs", ["test/scripts/run-oxlint.test.ts"]],
["scripts/run-oxlint-shards.mjs", ["test/scripts/run-oxlint.test.ts"]],
["scripts/run-with-env.mjs", ["test/scripts/run-with-env.test.ts"]],
["scripts/run-node.mjs", ["src/infra/run-node.test.ts"]],
["scripts/ci-run-timings.mjs", ["test/scripts/ci-run-timings.test.ts"]],
["scripts/docker-e2e.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
["scripts/docker-e2e-rerun.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
["scripts/docker-e2e-timings.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
["scripts/generate-npm-shrinkwrap.mjs", ["test/scripts/generate-npm-shrinkwrap.test.ts"]],
["scripts/kova-ci-summary.mjs", ["test/scripts/kova-ci-summary.test.ts"]],
["scripts/openclaw-npm-postpublish-verify.ts", ["test/openclaw-npm-postpublish-verify.test.ts"]],
["scripts/openclaw-npm-release-check.ts", ["test/openclaw-npm-release-check.test.ts"]],
["scripts/openclaw-prepack.ts", ["test/openclaw-prepack.test.ts"]],
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]],
["scripts/postinstall-bundled-plugins.mjs", ["test/scripts/postinstall-bundled-plugins.test.ts"]],
["scripts/prepare-git-hooks.mjs", ["test/scripts/prepare-git-hooks.test.ts"]],
[
"scripts/preinstall-package-manager-warning.mjs",
["test/scripts/preinstall-package-manager-warning.test.ts"],
],
["scripts/test-extension-batch.mjs", ["test/scripts/test-extension.test.ts"]],
["scripts/test-force.ts", ["test/scripts/test-force.test.ts"]],
["scripts/test-live.mjs", ["test/scripts/test-live.test.ts"]],
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
["scripts/verify.mjs", ["test/scripts/verify.test.ts"]],
["scripts/zai-fallback-repro.ts", ["test/scripts/zai-fallback-repro.test.ts"]],
["scripts/lib/extension-test-plan.mjs", ["test/scripts/test-extension.test.ts"]],
["scripts/lib/vitest-batch-runner.mjs", ["test/scripts/test-extension.test.ts"]],
@@ -416,7 +499,33 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
[
"scripts/e2e/kitchen-sink-plugin-docker.sh",
["test/scripts/plugin-prerelease-test-plan.test.ts"],
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/plugin-prerelease-test-plan.test.ts",
],
],
[
"scripts/e2e/kitchen-sink-rpc-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/plugin-prerelease-test-plan.test.ts",
],
],
[
"scripts/e2e/kitchen-sink-rpc-walk.mjs",
[
"test/scripts/kitchen-sink-rpc-walk.test.ts",
"test/scripts/plugin-prerelease-test-plan.test.ts",
],
],
[
"scripts/e2e/onboard-docker.sh",
["test/scripts/docker-build-helper.test.ts", "test/scripts/openclaw-test-state.test.ts"],
],
["scripts/e2e/plugin-lifecycle-matrix-docker.sh", ["test/scripts/docker-build-helper.test.ts"]],
[
"scripts/e2e/release-media-memory-docker.sh",
["test/scripts/docker-e2e-plan.test.ts", "test/scripts/release-media-memory-scenario.test.ts"],
],
["scripts/lib/vitest-shard-timings.mjs", ["test/scripts/vitest-shard-timings.test.ts"]],
[
@@ -426,6 +535,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/test-projects.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]],
@@ -626,6 +736,7 @@ const VITEST_NO_OUTPUT_RETRY_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_RETRY";
export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = String(
DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS,
);
const EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD = 12;
const GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT = 4;
const GATEWAY_SERVER_BACKED_HTTP_TEST_TARGETS = new Set([
"src/gateway/embeddings-http.test.ts",
@@ -785,6 +896,14 @@ function isTestFileTarget(arg) {
return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg);
}
function isTestSupportFileTarget(arg) {
if (/(?:^|\/)(?:test-helpers|test-support)(?:\/|$)/u.test(arg)) {
return true;
}
const basename = path.posix.basename(arg).replace(/\.[cm]?[jt]sx?$/u, "");
return /(?:^|[._-])test-(?:helpers|support)(?:[._-]|$)/u.test(basename);
}
function isLikelyFileTarget(arg) {
return /(?:^|\/)[^/]+\.[A-Za-z0-9]+$/u.test(arg);
}
@@ -863,6 +982,51 @@ function includePatternMatchesAnyFile(pattern, files) {
return files.some((file) => file === pattern || path.matchesGlob(file, pattern));
}
function resolveExplicitSourceTestTargets(targetArg, cwd, options = {}) {
const relative = toRepoRelativeTarget(targetArg, cwd);
const kind = classifyTarget(targetArg, cwd);
if (shouldUseWholeConfigTarget(kind, targetArg, cwd)) {
return null;
}
if (!isExistingFileTarget(targetArg, cwd)) {
return null;
}
if (isTestFileTarget(relative)) {
return null;
}
const preciseTargets = resolvePreciseChangedTestTargets(relative, {
cwd,
forceFullImportGraph: options.forceFullImportGraph === true,
});
if (preciseTargets && preciseTargets.length > 0) {
return [...new Set(preciseTargets)].toSorted((left, right) => left.localeCompare(right));
}
if (!isTestSupportFileTarget(relative)) {
return null;
}
return [
...new Set(
resolveAffectedTestsFromImportGraph(relative, cwd, {
forceFull: options.forceFullImportGraph === true,
}),
),
].toSorted((left, right) => left.localeCompare(right));
}
function expandExplicitSourceTestTargets(targetArgs, cwd) {
const sourceTargetCount = targetArgs.filter((targetArg) => {
const relative = toRepoRelativeTarget(targetArg, cwd);
return isExistingFileTarget(targetArg, cwd) && !isTestFileTarget(relative);
}).length;
const forceFullImportGraph = sourceTargetCount > EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD;
return targetArgs.flatMap((targetArg) => {
const targets = resolveExplicitSourceTestTargets(targetArg, cwd, {
forceFullImportGraph,
});
return targets && targets.length > 0 ? targets : [targetArg];
});
}
export function findUnmatchedExplicitTestTargets(args, cwd = process.cwd()) {
const { targetArgs } = parseTestProjectsArgs(args, cwd);
if (targetArgs.length === 0) {
@@ -907,6 +1071,17 @@ export function findUnmatchedExplicitTestTargets(args, cwd = process.cwd()) {
continue;
}
const explicitSupportTargets = resolveExplicitSourceTestTargets(targetArg, cwd);
if (explicitSupportTargets) {
if (explicitSupportTargets.length === 0) {
unmatched.push({
target: targetArg,
reason: "target-matched-no-test-files",
});
}
continue;
}
const includePattern = toScopedIncludePattern(targetArg, cwd);
if (!includePatternMatchesAnyFile(includePattern, getCandidateFiles())) {
unmatched.push({
@@ -1195,11 +1370,13 @@ function getImportGraph(cwd) {
return cachedImportGraph;
}
function resolveAffectedTestsFromImportGraph(changedPath, cwd) {
function resolveAffectedTestsFromImportGraph(changedPath, cwd, options = {}) {
const normalized = normalizePathPattern(changedPath);
const targetedTargets = resolveAffectedTestsFromTargetedImportScan(normalized, cwd);
if (targetedTargets !== null) {
return targetedTargets;
if (options.forceFull !== true) {
const targetedTargets = resolveAffectedTestsFromTargetedImportScan(normalized, cwd);
if (targetedTargets !== null) {
return targetedTargets;
}
}
const { reverseImports, testFiles } = getImportGraph(cwd);
@@ -1336,10 +1513,10 @@ function shouldKeepBroadChangedRun(changedPaths) {
);
}
function resolveToolingChangedTestTargets(changedPaths) {
function resolveToolingChangedTestTargets(changedPaths, cwd = process.cwd()) {
const targets = [];
for (const changedPath of changedPaths) {
const testTargets = resolveToolingTestTargets(changedPath);
const testTargets = resolveToolingTestTargets(changedPath, cwd);
if (!testTargets) {
return null;
}
@@ -1348,8 +1525,34 @@ function resolveToolingChangedTestTargets(changedPaths) {
return [...new Set(targets)];
}
function resolveToolingTestTargets(changedPath) {
return TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd()) {
const match = /^scripts\/(.+)\.(?:mjs|ts|js|sh|py)$/u.exec(changedPath);
if (!match) {
return null;
}
const stem = match[1];
const basename = path.posix.basename(stem);
const dashedStem = stem.replaceAll("/", "-");
const candidates = [
`test/scripts/${stem}.test.ts`,
`test/scripts/${dashedStem}.test.ts`,
`test/scripts/${basename}.test.ts`,
`src/scripts/${stem}.test.ts`,
`src/scripts/${dashedStem}.test.ts`,
`src/scripts/${basename}.test.ts`,
];
const targets = candidates.filter((candidate) => fs.existsSync(path.join(cwd, candidate)));
return targets.length > 0 ? targets : null;
}
function resolveToolingTestTargets(changedPath, cwd = process.cwd()) {
const explicitTargets =
TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
const conventionalTargets = resolveConventionalToolingTestTargets(changedPath, cwd);
if (explicitTargets && conventionalTargets) {
return uniqueOrdered([...explicitTargets, ...conventionalTargets]);
}
return explicitTargets ?? conventionalTargets;
}
function shouldUseBroadChangedTargets(env = process.env) {
@@ -1403,8 +1606,13 @@ function resolvePreciseChangedTestTargets(changedPath, options) {
if (shouldRouteChangedTargetWithoutImportGraph(changedPath)) {
return changedPath.startsWith("ui/src/") ? [changedPath] : null;
}
if (options.skipImportGraph === true) {
return null;
}
if (/^(?:src|test\/helpers|extensions|packages|ui\/src|ui\/config)\//u.test(changedPath)) {
const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd);
const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd, {
forceFull: options.forceFullImportGraph === true,
});
if (affectedTests.length > 0) {
return affectedTests;
}
@@ -1416,16 +1624,21 @@ export function resolveChangedTestTargetPlan(changedPaths, options = {}) {
if (changedPaths.length === 0) {
return { mode: "none", targets: [] };
}
const toolingTargets = resolveToolingChangedTestTargets(changedPaths);
const cwd = options.cwd ?? process.cwd();
const toolingTargets = resolveToolingChangedTestTargets(changedPaths, cwd);
if (toolingTargets) {
return { mode: "targets", targets: toolingTargets };
}
const changedLanes = detectChangedLanes(changedPaths);
const env = options.env ?? {};
const useBroadFallback = options.broad ?? shouldUseBroadChangedTargets(env);
const skipImportGraph = changedLanes.lanes.all && !useBroadFallback;
const targets = [];
for (const changedPath of changedPaths) {
const preciseTargets = resolvePreciseChangedTestTargets(changedPath, options);
const preciseTargets = resolvePreciseChangedTestTargets(changedPath, {
...options,
skipImportGraph,
});
if (preciseTargets) {
targets.push(...preciseTargets);
continue;
@@ -1778,7 +1991,8 @@ export function buildVitestRunPlans(
const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd);
const changedTargetArgs =
targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths, options) : null;
const activeTargetArgs = changedTargetArgs ?? targetArgs;
const requestedTargetArgs = changedTargetArgs ?? targetArgs;
const activeTargetArgs = expandExplicitSourceTestTargets(requestedTargetArgs, cwd);
const activeForwardedArgs =
changedTargetArgs !== null ? stripChangedArgs(forwardedArgs) : forwardedArgs;
if (changedTargetArgs !== null && activeTargetArgs.length === 0) {
@@ -1824,7 +2038,7 @@ export function buildVitestRunPlans(
);
}
const nonTargetArgs = activeForwardedArgs.filter((arg) => !activeTargetArgs.includes(arg));
const nonTargetArgs = activeForwardedArgs.filter((arg) => !requestedTargetArgs.includes(arg));
const orderedKinds = [
"unitFast",
"unitFastFakeTimers",
@@ -1948,11 +2162,13 @@ export function buildVitestRunPlans(
? null
: useWholeConfigTarget
? null
: grouped.flatMap((targetArg) => {
const lightLanePatterns = resolveLightLaneIncludePatterns(kind, targetArg, cwd);
return lightLanePatterns ?? [toScopedIncludePattern(targetArg, cwd)];
});
const scopedTargetArgs = useCliTargetArgs ? grouped : [];
: uniqueOrdered(
grouped.flatMap((targetArg) => {
const lightLanePatterns = resolveLightLaneIncludePatterns(kind, targetArg, cwd);
return lightLanePatterns ?? [toScopedIncludePattern(targetArg, cwd)];
}),
);
const scopedTargetArgs = useCliTargetArgs ? uniqueOrdered(grouped) : [];
plans.push({
config,
forwardedArgs: [...nonTargetArgs, ...scopedTargetArgs],

View File

@@ -12,7 +12,6 @@ import {
} from "./postinstall-bundled-plugins.mjs";
const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn";
const extraArgs = process.argv.slice(2);
const INEFFECTIVE_DYNAMIC_IMPORT_MARKER = "[INEFFECTIVE_DYNAMIC_IMPORT]";
const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/;
const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
@@ -355,6 +354,32 @@ function resolveTsdownEnv(env, params = {}) {
};
}
export function tsdownBuildUsage() {
return [
"Usage: node scripts/tsdown-build.mjs [tsdown args...]",
"",
"Builds OpenClaw with tsdown and validates emitted import diagnostics.",
"",
"Options:",
" -h, --help Show this help without starting tsdown.",
"",
"Other arguments are forwarded to tsdown.",
].join("\n");
}
export function parseTsdownBuildArgs(argv) {
if (argv.includes("--help") || argv.includes("-h")) {
return {
forwardedArgs: [],
help: true,
};
}
return {
forwardedArgs: argv,
help: false,
};
}
export function createTsdownOutputScanner(params = {}) {
const maxCaptureBytes = params.maxCaptureBytes ?? DEFAULT_CAPTURE_BYTES;
let captured = "";
@@ -399,13 +424,14 @@ export function createTsdownOutputScanner(params = {}) {
export function resolveTsdownBuildInvocation(params = {}) {
const env = resolveTsdownEnv(params.env ?? process.env, params);
const forwardedArgs = params.args ?? [];
const tsdownArgs = [
"--config-loader",
"unrun",
"--logLevel",
logLevel,
"--no-clean",
...extraArgs,
...forwardedArgs,
];
if (env.OPENCLAW_BUILD_ALL_NO_PNPM === "1") {
return {
@@ -540,11 +566,16 @@ function isMainModule() {
}
if (isMainModule()) {
const args = parseTsdownBuildArgs(process.argv.slice(2));
if (args.help) {
console.log(tsdownBuildUsage());
process.exit(0);
}
pruneSourceCheckoutBundledPluginNodeModules();
pruneUntrackedGeneratedSourceDeclarations();
pruneStaleRuntimeSymlinks();
cleanTsdownOutputRoots();
const invocation = resolveTsdownBuildInvocation();
const invocation = resolveTsdownBuildInvocation({ args: args.forwardedArgs });
const result = await runTsdownBuildInvocation(invocation);
if (result.status === 0 && result.hasIneffectiveDynamicImport) {

View File

@@ -7,6 +7,29 @@ const stages = [
{ name: "test", args: ["test"] },
];
export function usage() {
return [
"Usage: node scripts/verify.mjs",
"",
"Runs the full verification graph: pnpm check, then pnpm test.",
"",
"Options:",
" -h, --help Show this help.",
].join("\n");
}
export function parseVerifyArgs(argv) {
const args = { help: false };
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
}
}
return args;
}
async function runStage(stage) {
console.error(`CRABBOX_PHASE:${stage.name}`);
console.error(`[verify] ${stage.name}`);
@@ -22,7 +45,21 @@ async function runStage(stage) {
};
}
export async function main() {
export async function main(argv = process.argv.slice(2)) {
let args;
try {
args = parseVerifyArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
return;
}
if (args.help) {
console.log(usage());
process.exitCode = 0;
return;
}
const timings = [];
for (const stage of stages) {
const result = await runStage(stage);

View File

@@ -254,6 +254,33 @@ describe("acp translator stable lifecycle handlers", () => {
sessionStore.clearAllSessionsForTest();
});
it("lists Gateway sessions with invalid updated timestamps", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return createGatewaySessions([
createSessionRow({
key: "agent:main:work",
cwd: "/tmp/openclaw",
title: "Work session",
updatedAt: Number.POSITIVE_INFINITY,
}),
]);
}
return { ok: true };
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const result = await agent.listSessions(createListSessionsRequest({ cwd: "/tmp/openclaw" }));
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]?.updatedAt).toBeUndefined();
sessionStore.clearAllSessionsForTest();
});
it("rejects session/list cursors when the cwd filter changes", async () => {
const allRows = [
createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }),

View File

@@ -42,6 +42,7 @@ import {
resolveFixedWindowRateLimitInteger,
type FixedWindowRateLimiter,
} from "../infra/fixed-window-rate-limit.js";
import { timestampMsToIsoString } from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { shortenHomePath } from "../utils.js";
import {
@@ -502,10 +503,7 @@ function buildSessionMetadata(params: {
normalizeOptionalString(params.row?.displayName) ||
normalizeOptionalString(params.row?.label) ||
params.sessionKey;
const updatedAt =
typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt)
? new Date(params.row.updatedAt).toISOString()
: null;
const updatedAt = timestampMsToIsoString(params.row?.updatedAt) ?? null;
return {
title,
updatedAt,
@@ -1914,7 +1912,7 @@ export class AcpGatewayAgent implements Agent {
sessionId: session.key,
cwd,
title: session.derivedTitle ?? session.displayName ?? session.label ?? session.key,
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
updatedAt: timestampMsToIsoString(session.updatedAt),
_meta: toAcpSessionLineageMeta(session),
};
}

View File

@@ -97,12 +97,18 @@ export function hasMatchingOAuthIdentity(
return false;
}
export function isSafeToOverwriteStoredOAuthIdentity(
type OAuthIdentitySafetyPolicy = {
whenExistingCredentialMissing: boolean;
whenExistingIdentityMissing: boolean;
};
function isSafeOAuthIdentityTransition(
existing: OAuthCredential | undefined,
incoming: OAuthCredential,
policy: OAuthIdentitySafetyPolicy,
): boolean {
if (!existing || existing.type !== "oauth") {
return true;
return policy.whenExistingCredentialMissing;
}
if (existing.provider !== incoming.provider) {
return false;
@@ -111,47 +117,39 @@ export function isSafeToOverwriteStoredOAuthIdentity(
return true;
}
if (!hasOAuthIdentity(existing)) {
return false;
return policy.whenExistingIdentityMissing;
}
return hasMatchingOAuthIdentity(existing, incoming);
}
export function isSafeToOverwriteStoredOAuthIdentity(
existing: OAuthCredential | undefined,
incoming: OAuthCredential,
): boolean {
return isSafeOAuthIdentityTransition(existing, incoming, {
whenExistingCredentialMissing: true,
whenExistingIdentityMissing: false,
});
}
export function isSafeToAdoptBootstrapOAuthIdentity(
existing: OAuthCredential | undefined,
incoming: OAuthCredential,
): boolean {
if (!existing || existing.type !== "oauth") {
return true;
}
if (existing.provider !== incoming.provider) {
return false;
}
if (areOAuthCredentialsEquivalent(existing, incoming)) {
return true;
}
if (!hasOAuthIdentity(existing)) {
return true;
}
return hasMatchingOAuthIdentity(existing, incoming);
return isSafeOAuthIdentityTransition(existing, incoming, {
whenExistingCredentialMissing: true,
whenExistingIdentityMissing: true,
});
}
export function isSafeToAdoptMainStoreOAuthIdentity(
existing: OAuthCredential | undefined,
incoming: OAuthCredential,
): boolean {
if (!existing || existing.type !== "oauth") {
return false;
}
if (existing.provider !== incoming.provider) {
return false;
}
if (areOAuthCredentialsEquivalent(existing, incoming)) {
return true;
}
if (!hasOAuthIdentity(existing)) {
return true;
}
return hasMatchingOAuthIdentity(existing, incoming);
return isSafeOAuthIdentityTransition(existing, incoming, {
whenExistingCredentialMissing: false,
whenExistingIdentityMissing: true,
});
}
export function shouldBootstrapFromExternalCliCredential(params: {

View File

@@ -1,11 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { Model } from "../llm/types.js";
const providerRuntimeMocks = vi.hoisted(() => ({
prepareProviderDynamicModel: vi.fn(),
resolveProviderModernModelRef: vi.fn(),
runProviderDynamicModel: vi.fn(),
}));
vi.mock("./agent-model-discovery.js", () => ({
normalizeDiscoveredAgentModel: (value: unknown) => value,
}));
vi.mock("../plugins/provider-runtime.js", () => providerRuntimeMocks);
import { appendPrioritizedDynamicLiveModels } from "./live-model-dynamic-candidates.js";
const REGISTRY = { find: () => undefined } as never;
@@ -36,6 +44,15 @@ function model(provider: string, id: string): Model {
}
describe("appendPrioritizedDynamicLiveModels", () => {
beforeEach(() => {
providerRuntimeMocks.prepareProviderDynamicModel.mockReset();
providerRuntimeMocks.prepareProviderDynamicModel.mockResolvedValue(undefined);
providerRuntimeMocks.resolveProviderModernModelRef.mockReset();
providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(undefined);
providerRuntimeMocks.runProviderDynamicModel.mockReset();
providerRuntimeMocks.runProviderDynamicModel.mockReturnValue(undefined);
});
it("materializes prioritized refs from provider dynamic model hooks", async () => {
const resolveDynamicModel: DynamicModelResolver = vi.fn((params) =>
params.context.provider === DYNAMIC_PROVIDER && params.context.modelId === "glm-5"
@@ -130,4 +147,25 @@ describe("appendPrioritizedDynamicLiveModels", () => {
expect(prepareDynamicModel).not.toHaveBeenCalled();
expect(resolveDynamicModel).not.toHaveBeenCalled();
});
it("uses default provider runtime hooks when resolvers are not injected", async () => {
providerRuntimeMocks.runProviderDynamicModel.mockImplementation((params) =>
params.context.provider === DYNAMIC_PROVIDER && params.context.modelId === "glm-5"
? model(DYNAMIC_PROVIDER, "glm-5")
: undefined,
);
const result = await appendPrioritizedDynamicLiveModels({
models: [],
agentDir: "/tmp/openclaw-agent",
modelRegistry: REGISTRY,
refs: [{ provider: DYNAMIC_PROVIDER, id: "glm-5" }],
});
expect(result.added.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
`${DYNAMIC_PROVIDER}/glm-5`,
]);
expect(providerRuntimeMocks.prepareProviderDynamicModel).toHaveBeenCalledTimes(1);
expect(providerRuntimeMocks.runProviderDynamicModel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,25 +5,31 @@ import type {
runProviderDynamicModel,
} from "../plugins/provider-runtime.js";
import type { ProviderResolveDynamicModelContext } from "../plugins/types.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { listPrioritizedHighSignalLiveModelRefs } from "./live-model-filter.js";
import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js";
type ProviderRuntimeModule = typeof import("../plugins/provider-runtime.js");
type DynamicModelResolver = typeof runProviderDynamicModel;
type DynamicModelPreparer = typeof prepareProviderDynamicModel;
type DynamicModelNormalizer = (model: Model, agentDir: string) => Model | Promise<Model>;
const providerRuntimeLoader = createLazyImportLoader<ProviderRuntimeModule>(
() => import("../plugins/provider-runtime.js"),
);
async function prepareProviderDynamicModelDefault(
params: Parameters<DynamicModelPreparer>[0],
): Promise<void> {
const { prepareProviderDynamicModel } = await import("../plugins/provider-runtime.js");
const { prepareProviderDynamicModel } = await providerRuntimeLoader.load();
await prepareProviderDynamicModel(params);
}
async function runProviderDynamicModelDefault(
params: Parameters<DynamicModelResolver>[0],
): Promise<ReturnType<DynamicModelResolver>> {
const { runProviderDynamicModel } = await import("../plugins/provider-runtime.js");
const { runProviderDynamicModel } = await providerRuntimeLoader.load();
return runProviderDynamicModel(params);
}

View File

@@ -1770,6 +1770,33 @@ describe("/acp command", () => {
expect(hoisted.getStatusMock).toHaveBeenCalledTimes(1);
});
it("tolerates Date-invalid ACP status timestamps", async () => {
mockBoundThreadSession();
hoisted.readAcpSessionEntryMock.mockReturnValue({
...createAcpSessionEntry(),
acp: {
...createAcpSessionEntry().acp,
lastActivityAt: 8_700_000_000_000_000,
},
});
createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: defaultAcpSessionKey,
runId: "acp-run-1",
task: "Inspect ACP backlog",
status: "running",
lastEventAt: 8_700_000_000_000_000,
});
const result = await runThreadAcpCommand("/acp status", baseCfg);
expect(result?.reply?.text).toContain("ACP status:");
expect(result?.reply?.text).toContain("lastActivityAt: n/a");
expect(result?.reply?.text).not.toContain("taskUpdatedAt:");
});
it("sanitizes leaked task and runtime details in ACP status output", async () => {
mockBoundThreadSession({
identity: {

View File

@@ -8,6 +8,7 @@ import {
validateRuntimePermissionProfileInput,
} from "../../../acp/control-plane/runtime-options.js";
import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js";
import { timestampMsToIsoString } from "../../../shared/number-coercion.js";
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
import { findLatestTaskForRelatedSessionKeyForOwner } from "../../../tasks/task-owner-access.js";
import { sanitizeTaskStatusText } from "../../../tasks/task-status.js";
@@ -145,6 +146,11 @@ export async function handleAcpStatusAction(
const runtimeDetails = sanitizeTaskStatusText(status.runtimeStatus?.details, {
errorContext: true,
});
const taskUpdatedAt =
typeof linkedTask?.lastEventAt === "number"
? timestampMsToIsoString(linkedTask.lastEventAt)
: undefined;
const lastActivityAt = timestampMsToIsoString(status.lastActivityAt) ?? "n/a";
const lines = [
"ACP status:",
"-----",
@@ -162,14 +168,12 @@ export async function handleAcpStatusAction(
...(taskProgress ? [`taskProgress: ${taskProgress}`] : []),
...(taskSummary ? [`taskSummary: ${taskSummary}`] : []),
...(taskError ? [`taskError: ${taskError}`] : []),
...(typeof linkedTask.lastEventAt === "number"
? [`taskUpdatedAt: ${new Date(linkedTask.lastEventAt).toISOString()}`]
: []),
...(taskUpdatedAt ? [`taskUpdatedAt: ${taskUpdatedAt}`] : []),
]
: []),
`runtimeOptions: ${formatRuntimeOptionsText(status.runtimeOptions)}`,
`capabilities: ${formatAcpCapabilitiesText(status.capabilities.controls)}`,
`lastActivityAt: ${new Date(status.lastActivityAt).toISOString()}`,
`lastActivityAt: ${lastActivityAt}`,
...(lastError ? [`lastError: ${lastError}`] : []),
...(runtimeSummary ? [`runtime: ${runtimeSummary}`] : []),
...(runtimeDetails ? [`runtimeDetails: ${runtimeDetails}`] : []),

View File

@@ -4,8 +4,13 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getSessionEntry, upsertSessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { handleGoalCommand, parseGoalCommand } from "./commands-goal.js";
import {
formatGoalContinuationPrompt,
handleGoalCommand,
parseGoalCommand,
} from "./commands-goal.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { parseInlineDirectives } from "./directive-handling.parse.js";
const sessionKey = "agent:main:web:main";
let tempRoots: string[] = [];
@@ -76,6 +81,18 @@ describe("goal commands", () => {
});
});
it("formats command-looking continuation prompts so inline directives leave them intact", () => {
const prompt = formatGoalContinuationPrompt("ship /fast off");
expect(prompt).toBe(
`Pursue this goal exactly as written from this JSON string: "ship \\/fast off"`,
);
const directives = parseInlineDirectives(prompt);
expect(directives.cleaned).toBe(prompt);
expect(directives.hasFastDirective).toBe(false);
});
it("starts a goal from Codex-style bare /goal objective text", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
@@ -84,13 +101,118 @@ describe("goal commands", () => {
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
});
const result = await handleGoalCommand(
buildGoalParams("/goal build a 3d game", storePath),
true,
);
const params = buildGoalParams("/goal build a 3d game", storePath);
const result = await handleGoalCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("Goal started: build a 3d game");
expect(result?.shouldContinue).toBe(true);
expect(result?.reply).toBeUndefined();
expect(params.command.commandBodyNormalized).toBe("build a 3d game");
expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe("build a 3d game");
expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("build a 3d game");
});
it("wraps command-prefixed goal objectives before continuing", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
});
const slashParams = buildGoalParams("/goal start /status", storePath);
const slashResult = await handleGoalCommand(slashParams, true);
const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
expect(slashResult?.shouldContinue).toBe(true);
expect(slashParams.command.commandBodyNormalized).toBe(slashPrompt);
expect((slashParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(slashPrompt);
expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("/status");
const bangStorePath = await createStorePath();
await upsertSessionEntry({
storePath: bangStorePath,
sessionKey,
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
});
const bangParams = buildGoalParams("/goal start !npm test", bangStorePath);
const bangResult = await handleGoalCommand(bangParams, true);
const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`;
expect(bangResult?.shouldContinue).toBe(true);
expect(bangParams.command.commandBodyNormalized).toBe(bangPrompt);
expect((bangParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(bangPrompt);
expect(getSessionEntry({ storePath: bangStorePath, sessionKey })?.goal?.objective).toBe(
"!npm test",
);
});
it("resumes a goal and continues with a resume prompt", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "sess-main",
updatedAt: 1,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "finish the migration",
status: "paused",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokenStartFresh: true,
tokensUsed: 0,
continuationTurns: 0,
},
},
});
const params = buildGoalParams("/goal resume CI passed", storePath);
const result = await handleGoalCommand(params, true);
expect(result?.shouldContinue).toBe(true);
expect(params.command.commandBodyNormalized).toBe(
"Continue pursuing the current goal. Note: CI passed",
);
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
});
it("wraps command-looking resume notes before continuing", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "sess-main",
updatedAt: 1,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "finish the migration",
status: "paused",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokenStartFresh: true,
tokensUsed: 0,
continuationTurns: 0,
},
},
});
const params = buildGoalParams("/goal resume /fast off", storePath);
const result = await handleGoalCommand(params, true);
const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`;
const directives = parseInlineDirectives(prompt);
expect(result?.shouldContinue).toBe(true);
expect(params.command.commandBodyNormalized).toBe(prompt);
expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(prompt);
expect(directives.cleaned).toBe(prompt);
expect(directives.hasFastDirective).toBe(false);
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
});
});

View File

@@ -73,6 +73,61 @@ function goalReply(text: string): CommandHandlerResult {
};
}
function hasCommandLikeGoalText(trimmed: string): boolean {
return /(?:^|\s)\//.test(trimmed) || trimmed.startsWith("!");
}
function encodeGoalJsonString(trimmed: string): string {
return JSON.stringify(trimmed).replaceAll("/", "\\/");
}
export function formatGoalContinuationPrompt(objective: string): string {
const trimmed = objective.trim();
return hasCommandLikeGoalText(trimmed)
? `Pursue this goal exactly as written from this JSON string: ${encodeGoalJsonString(trimmed)}`
: trimmed;
}
export function formatGoalResumeContinuationPrompt(note: string): string {
const trimmed = note.trim();
if (!trimmed) {
return "Continue pursuing the current goal.";
}
return hasCommandLikeGoalText(trimmed)
? `Continue pursuing the current goal. Interpret this JSON string as the resume note: ${encodeGoalJsonString(trimmed)}`
: `Continue pursuing the current goal. Note: ${trimmed}`;
}
function applyGoalPromptToContext(ctx: HandleCommandsParams["ctx"], message: string): void {
const mutableCtx = ctx as HandleCommandsParams["ctx"] & {
Body?: string;
RawBody?: string;
CommandBody?: string;
BodyForCommands?: string;
BodyForAgent?: string;
BodyStripped?: string;
};
mutableCtx.Body = message;
mutableCtx.RawBody = message;
mutableCtx.CommandBody = message;
mutableCtx.BodyForCommands = message;
mutableCtx.BodyForAgent = message;
mutableCtx.BodyStripped = message;
}
function applyGoalContinuationPrompt(params: HandleCommandsParams, message: string): void {
applyGoalPromptToContext(params.ctx, message);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyGoalPromptToContext(params.rootCtx, message);
}
params.command.rawBodyNormalized = message;
params.command.commandBodyNormalized = message;
}
function goalContinuation(): CommandHandlerResult {
return { shouldContinue: true };
}
function goalErrorReply(error: unknown): CommandHandlerResult {
const message = error instanceof Error ? error.message : String(error);
return goalReply(`Goal error: ${message}`);
@@ -115,7 +170,8 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
fallbackEntry: params.sessionEntry,
});
syncGoalSessionEntry(params);
return goalReply(`Goal started: ${goal.objective}`);
applyGoalContinuationPrompt(params, formatGoalContinuationPrompt(goal.objective));
return goalContinuation();
}
case "pause": {
const goal = await updateSessionGoalStatus({
@@ -128,14 +184,16 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
return goalReply(`Goal paused: ${goal.objective}`);
}
case "resume": {
const goal = await updateSessionGoalStatus({
await updateSessionGoalStatus({
sessionKey: params.sessionKey,
storePath: params.storePath,
status: "active",
...(parsed.text ? { note: parsed.text } : {}),
});
syncGoalSessionEntry(params);
return goalReply(`Goal resumed: ${goal.objective}`);
const message = formatGoalResumeContinuationPrompt(parsed.text);
applyGoalContinuationPrompt(params, message);
return goalContinuation();
}
case "complete":
case "done": {

View File

@@ -570,6 +570,25 @@ describe("/session idle and /session max-age", () => {
expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z");
});
it("falls back when active idle timeout expiry is Date-invalid", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createThreadBinding({
metadata: {
boundBy: "user-1",
lastActivityAt: 8_700_000_000_000_000,
idleTimeoutMs: 2 * 60 * 60 * 1000,
maxAgeMs: 0,
},
}),
);
const result = await handleSessionCommand(createThreadCommandParams("/session idle"), true);
expect(result?.reply?.text).toBe(" Idle timeout active (2h, next auto-unfocus at n/a).");
});
it("sets max age for the focused thread-chat session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));

View File

@@ -21,6 +21,7 @@ import {
} from "../../infra/restart-sentinel.js";
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
import { timestampMsToIsoString } from "../../shared/number-coercion.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -87,7 +88,7 @@ function parseSessionDurationMs(raw: string): number {
}
function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
return timestampMsToIsoString(expiresAt) ?? "n/a";
}
function resolveSessionBindingDurationMs(

View File

@@ -200,6 +200,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
if (!commandResult.shouldContinue) {
return { handled: true, reply: commandResult.reply };
}
const continuationTriggerBodyNormalized = command.rawBodyNormalized;
const directiveResult = await resolveReplyDirectives({
ctx: params.ctx,
@@ -216,7 +217,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
sessionScope: sessionState.sessionScope,
groupResolution: sessionState.groupResolution,
isGroup: sessionState.isGroup,
triggerBodyNormalized: sessionState.triggerBodyNormalized,
triggerBodyNormalized: continuationTriggerBodyNormalized,
resetTriggered: false,
commandAuthorized: params.commandAuthorized,
defaultProvider: params.defaultProvider,

View File

@@ -12,6 +12,7 @@ import {
} from "./get-reply-fast-path.js";
import {
buildGetReplyCtx,
createGetReplyContinueDirectivesResult,
createGetReplySessionState,
expectResolvedTelegramTimezone,
registerGetReplyRuntimeOverrides,
@@ -28,6 +29,7 @@ function emptyAliasIndex(): ModelAliasIndex {
const mocks = vi.hoisted(() => ({
ensureAgentWorkspace: vi.fn(),
handleInlineActions: vi.fn(),
initSessionState: vi.fn(),
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
{
@@ -113,6 +115,8 @@ describe("getReplyFromConfig fast test bootstrap", () => {
resolveRuntimeCliBackends: () => [],
});
mocks.ensureAgentWorkspace.mockReset();
mocks.handleInlineActions.mockReset();
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: { text: "ok" } });
mocks.initSessionState.mockReset();
mocks.loadModelCatalog.mockReset();
mocks.loadModelCatalog.mockResolvedValue([
@@ -525,6 +529,82 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
});
it("continues native slash goal starts with the rewritten command-safe prompt", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-goal-fast-"));
const targetSessionKey = "agent:main:telegram:123";
const storePath = path.join(home, "sessions.json");
const cfg = markCompleteReplyConfig({
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
workspace: path.join(home, "workspace"),
},
},
session: { store: storePath },
} as OpenClawConfig);
const continuationPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
const continueDirectives = async (params: unknown) =>
createGetReplyContinueDirectivesResult({
body: (params as { triggerBodyNormalized: string }).triggerBodyNormalized,
abortKey: targetSessionKey,
from: "telegram:user:42",
to: "telegram:123",
senderId: "telegram:user:42",
commandSource: (params as { triggerBodyNormalized: string }).triggerBodyNormalized,
senderIsOwner: true,
resetHookTriggered: false,
});
mocks.resolveReplyDirectives
.mockImplementationOnce(continueDirectives)
.mockImplementationOnce(async (params: unknown) => {
expect((params as { triggerBodyNormalized: string }).triggerBodyNormalized).toBe(
continuationPrompt,
);
return continueDirectives(params);
});
mocks.handleInlineActions.mockImplementation(async (params: unknown) => {
expect(params).toMatchObject({
command: {
rawBodyNormalized: continuationPrompt,
commandBodyNormalized: continuationPrompt,
},
cleanedBody: continuationPrompt,
});
return {
kind: "continue",
directives: {},
abortedLastRun: false,
cleanedBody: continuationPrompt,
};
});
await expect(
getReplyFromConfig(
buildGetReplyCtx({
Body: "/goal start /status",
BodyForAgent: "/goal start /status",
RawBody: "/goal start /status",
CommandBody: "/goal start /status",
CommandSource: "native",
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: targetSessionKey,
}),
undefined,
cfg,
),
).resolves.toEqual({ text: "ok" });
const stored = JSON.parse(await fs.readFile(storePath, "utf8")) as {
sessions?: Record<string, { goal?: { objective?: string } }>;
};
expect(stored.sessions?.[targetSessionKey]?.goal?.objective).toBe("/status");
const preparedReplyParams = requirePreparedReplyParams();
expect(preparedReplyParams.command.commandBodyNormalized).toBe(continuationPrompt);
expect(preparedReplyParams.sessionCtx.BodyForAgent).toBe(continuationPrompt);
expect(mocks.handleInlineActions).toHaveBeenCalledTimes(2);
});
it("uses native command target session keys during fast bootstrap", () => {
const result = initFastReplySessionState({
ctx: buildGetReplyCtx({

View File

@@ -1,5 +1,8 @@
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { isModelKeyAllowedBySet } from "../../agents/model-selection-shared.js";
import {
buildAllowedModelSetWithFallbacks,
isModelKeyAllowedBySet,
} from "../../agents/model-selection-shared.js";
import { normalizeProviderId } from "../../agents/provider-id.js";
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -58,8 +61,6 @@ async function buildResetAllowedModelKeys(params: {
}): Promise<Set<string>> {
const rawAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {});
if (rawAllowlist.length > 0 || params.cfg.models?.providers) {
const { buildAllowedModelSetWithFallbacks } =
await import("../../agents/model-selection-shared.js");
return buildAllowedModelSetWithFallbacks(params).allowedKeys;
}

View File

@@ -17,6 +17,14 @@ type DevicesRpcOpts = {
const DEFAULT_DEVICES_TIMEOUT_MS = 10_000;
type DevicesRuntimeModule = typeof import("./devices-cli.runtime.js");
let devicesRuntimePromise: Promise<DevicesRuntimeModule> | undefined;
function loadDevicesRuntime(): Promise<DevicesRuntimeModule> {
return (devicesRuntimePromise ??= import("./devices-cli.runtime.js"));
}
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
@@ -37,7 +45,7 @@ export function registerDevicesCli(program: Command) {
.command("list")
.description("List pending and paired devices")
.action(async (opts: DevicesRpcOpts) => {
const { runDevicesListCommand } = await import("./devices-cli.runtime.js");
const { runDevicesListCommand } = await loadDevicesRuntime();
await runDevicesListCommand(opts);
}),
);
@@ -48,7 +56,7 @@ export function registerDevicesCli(program: Command) {
.description("Remove a paired device entry")
.argument("<deviceId>", "Paired device id")
.action(async (deviceId: string, opts: DevicesRpcOpts) => {
const { runDevicesRemoveCommand } = await import("./devices-cli.runtime.js");
const { runDevicesRemoveCommand } = await loadDevicesRuntime();
await runDevicesRemoveCommand(deviceId, opts);
}),
);
@@ -60,7 +68,7 @@ export function registerDevicesCli(program: Command) {
.option("--pending", "Also reject all pending pairing requests", false)
.option("--yes", "Confirm destructive clear", false)
.action(async (opts: DevicesRpcOpts) => {
const { runDevicesClearCommand } = await import("./devices-cli.runtime.js");
const { runDevicesClearCommand } = await loadDevicesRuntime();
await runDevicesClearCommand(opts);
}),
);
@@ -72,7 +80,7 @@ export function registerDevicesCli(program: Command) {
.argument("[requestId]", "Pending request id")
.option("--latest", "Show the most recent pending request to approve explicitly", false)
.action(async (requestId: string | undefined, opts: DevicesRpcOpts) => {
const { runDevicesApproveCommand } = await import("./devices-cli.runtime.js");
const { runDevicesApproveCommand } = await loadDevicesRuntime();
await runDevicesApproveCommand(requestId, opts);
}),
);
@@ -83,7 +91,7 @@ export function registerDevicesCli(program: Command) {
.description("Reject a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const { runDevicesRejectCommand } = await import("./devices-cli.runtime.js");
const { runDevicesRejectCommand } = await loadDevicesRuntime();
await runDevicesRejectCommand(requestId, opts);
}),
);
@@ -96,7 +104,7 @@ export function registerDevicesCli(program: Command) {
.requiredOption("--role <role>", "Role name")
.option("--scope <scope...>", "Scopes to attach to the token (repeatable)")
.action(async (opts: DevicesRpcOpts) => {
const { runDevicesRotateCommand } = await import("./devices-cli.runtime.js");
const { runDevicesRotateCommand } = await loadDevicesRuntime();
await runDevicesRotateCommand(opts);
}),
);
@@ -108,7 +116,7 @@ export function registerDevicesCli(program: Command) {
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.action(async (opts: DevicesRpcOpts) => {
const { runDevicesRevokeCommand } = await import("./devices-cli.runtime.js");
const { runDevicesRevokeCommand } = await loadDevicesRuntime();
await runDevicesRevokeCommand(opts);
}),
);

View File

@@ -4,10 +4,33 @@ import { theme } from "../../packages/terminal-core/src/theme.js";
type ModelsCliRuntime = typeof import("./models-cli.runtime.js");
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined;
return () => (promise ??= load());
}
const loadModelsRuntime = createModuleLoader<ModelsCliRuntime>(
() => import("./models-cli.runtime.js"),
);
const loadModelsStatusCommands = createModuleLoader(
() => import("../commands/models/list.status-command.js"),
);
const loadModelsAliasesCommands = createModuleLoader(() => import("../commands/models/aliases.js"));
const loadModelsFallbacksCommands = createModuleLoader(
() => import("../commands/models/fallbacks.js"),
);
const loadModelsImageFallbacksCommands = createModuleLoader(
() => import("../commands/models/image-fallbacks.js"),
);
const loadModelsAuthCommands = createModuleLoader(() => import("../commands/models/auth.js"));
const loadModelsAuthOrderCommands = createModuleLoader(
() => import("../commands/models/auth-order.js"),
);
async function withModelsRuntime(
action: (runtime: ModelsCliRuntime) => Promise<void>,
): Promise<void> {
const runtime = await import("./models-cli.runtime.js");
const runtime = await loadModelsRuntime();
return runtime.runModelsCommand(() => action(runtime));
}
@@ -67,7 +90,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command, opts);
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
const { modelsStatusCommand } = await loadModelsStatusCommands();
await modelsStatusCommand(
{
json: Boolean(opts.json),
@@ -91,7 +114,7 @@ export function registerModelsCli(program: Command) {
.description("Set the default model")
.argument("<model>", "Model id or alias")
.action(async (model: string, _opts: unknown, command: Command) => {
const runtime = await import("./models-cli.runtime.js");
const runtime = await loadModelsRuntime();
runtime.rejectAgentScopedModelWrite(command, "set");
await runtime.runModelsCommand(async () => {
const { modelsSetCommand } = await import("../commands/models/set.js");
@@ -104,7 +127,7 @@ export function registerModelsCli(program: Command) {
.description("Set the image model")
.argument("<model>", "Model id or alias")
.action(async (model: string, _opts: unknown, command: Command) => {
const runtime = await import("./models-cli.runtime.js");
const runtime = await loadModelsRuntime();
runtime.rejectAgentScopedModelWrite(command, "set-image");
await runtime.runModelsCommand(async () => {
const { modelsSetImageCommand } = await import("../commands/models/set-image.js");
@@ -121,7 +144,7 @@ export function registerModelsCli(program: Command) {
.option("--plain", "Plain output", false)
.action(async (opts) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsAliasesListCommand } = await import("../commands/models/aliases.js");
const { modelsAliasesListCommand } = await loadModelsAliasesCommands();
await modelsAliasesListCommand(opts, defaultRuntime);
});
});
@@ -133,7 +156,7 @@ export function registerModelsCli(program: Command) {
.argument("<model>", "Model id or alias")
.action(async (alias: string, model: string) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsAliasesAddCommand } = await import("../commands/models/aliases.js");
const { modelsAliasesAddCommand } = await loadModelsAliasesCommands();
await modelsAliasesAddCommand(alias, model, defaultRuntime);
});
});
@@ -144,7 +167,7 @@ export function registerModelsCli(program: Command) {
.argument("<alias>", "Alias name")
.action(async (alias: string) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsAliasesRemoveCommand } = await import("../commands/models/aliases.js");
const { modelsAliasesRemoveCommand } = await loadModelsAliasesCommands();
await modelsAliasesRemoveCommand(alias, defaultRuntime);
});
});
@@ -158,7 +181,7 @@ export function registerModelsCli(program: Command) {
.option("--plain", "Plain output", false)
.action(async (opts) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsFallbacksListCommand } = await import("../commands/models/fallbacks.js");
const { modelsFallbacksListCommand } = await loadModelsFallbacksCommands();
await modelsFallbacksListCommand(opts, defaultRuntime);
});
});
@@ -169,7 +192,7 @@ export function registerModelsCli(program: Command) {
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsFallbacksAddCommand } = await import("../commands/models/fallbacks.js");
const { modelsFallbacksAddCommand } = await loadModelsFallbacksCommands();
await modelsFallbacksAddCommand(model, defaultRuntime);
});
});
@@ -180,7 +203,7 @@ export function registerModelsCli(program: Command) {
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsFallbacksRemoveCommand } = await import("../commands/models/fallbacks.js");
const { modelsFallbacksRemoveCommand } = await loadModelsFallbacksCommands();
await modelsFallbacksRemoveCommand(model, defaultRuntime);
});
});
@@ -190,7 +213,7 @@ export function registerModelsCli(program: Command) {
.description("Clear all fallback models")
.action(async () => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsFallbacksClearCommand } = await import("../commands/models/fallbacks.js");
const { modelsFallbacksClearCommand } = await loadModelsFallbacksCommands();
await modelsFallbacksClearCommand(defaultRuntime);
});
});
@@ -206,8 +229,7 @@ export function registerModelsCli(program: Command) {
.option("--plain", "Plain output", false)
.action(async (opts) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsImageFallbacksListCommand } =
await import("../commands/models/image-fallbacks.js");
const { modelsImageFallbacksListCommand } = await loadModelsImageFallbacksCommands();
await modelsImageFallbacksListCommand(opts, defaultRuntime);
});
});
@@ -218,8 +240,7 @@ export function registerModelsCli(program: Command) {
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsImageFallbacksAddCommand } =
await import("../commands/models/image-fallbacks.js");
const { modelsImageFallbacksAddCommand } = await loadModelsImageFallbacksCommands();
await modelsImageFallbacksAddCommand(model, defaultRuntime);
});
});
@@ -230,8 +251,7 @@ export function registerModelsCli(program: Command) {
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsImageFallbacksRemoveCommand } =
await import("../commands/models/image-fallbacks.js");
const { modelsImageFallbacksRemoveCommand } = await loadModelsImageFallbacksCommands();
await modelsImageFallbacksRemoveCommand(model, defaultRuntime);
});
});
@@ -241,8 +261,7 @@ export function registerModelsCli(program: Command) {
.description("Clear all image fallback models")
.action(async () => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsImageFallbacksClearCommand } =
await import("../commands/models/image-fallbacks.js");
const { modelsImageFallbacksClearCommand } = await loadModelsImageFallbacksCommands();
await modelsImageFallbacksClearCommand(defaultRuntime);
});
});
@@ -271,7 +290,7 @@ export function registerModelsCli(program: Command) {
models.action(async (opts) => {
await withModelsRuntime(async ({ defaultRuntime }) => {
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
const { modelsStatusCommand } = await loadModelsStatusCommands();
await modelsStatusCommand(
{
json: Boolean(opts?.statusJson),
@@ -316,7 +335,7 @@ export function registerModelsCli(program: Command) {
.action(async (command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command) ?? resolveModelAgentOption(auth);
const { modelsAuthAddCommand } = await import("../commands/models/auth.js");
const { modelsAuthAddCommand } = await loadModelsAuthCommands();
await modelsAuthAddCommand({ agent }, defaultRuntime);
});
});
@@ -337,7 +356,7 @@ export function registerModelsCli(program: Command) {
}
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command);
const { modelsAuthLoginCommand } = await import("../commands/models/auth.js");
const { modelsAuthLoginCommand } = await loadModelsAuthCommands();
await modelsAuthLoginCommand(
{
provider: opts.provider as string | undefined,
@@ -359,7 +378,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command);
const { modelsAuthSetupTokenCommand } = await import("../commands/models/auth.js");
const { modelsAuthSetupTokenCommand } = await loadModelsAuthCommands();
await modelsAuthSetupTokenCommand(
{
provider: opts.provider as string | undefined,
@@ -383,7 +402,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command);
const { modelsAuthPasteTokenCommand } = await import("../commands/models/auth.js");
const { modelsAuthPasteTokenCommand } = await loadModelsAuthCommands();
await modelsAuthPasteTokenCommand(
{
provider: opts.provider as string | undefined,
@@ -404,7 +423,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command);
const { modelsAuthPasteApiKeyCommand } = await import("../commands/models/auth.js");
const { modelsAuthPasteApiKeyCommand } = await loadModelsAuthCommands();
await modelsAuthPasteApiKeyCommand(
{
provider: opts.provider as string | undefined,
@@ -423,7 +442,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command);
const { modelsAuthLoginCommand } = await import("../commands/models/auth.js");
const { modelsAuthLoginCommand } = await loadModelsAuthCommands();
await modelsAuthLoginCommand(
{
provider: "github-copilot",
@@ -447,7 +466,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command, opts);
const { modelsAuthOrderGetCommand } = await import("../commands/models/auth-order.js");
const { modelsAuthOrderGetCommand } = await loadModelsAuthOrderCommands();
await modelsAuthOrderGetCommand(
{
provider: opts.provider as string,
@@ -468,7 +487,7 @@ export function registerModelsCli(program: Command) {
.action(async (profileIds: string[], opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command, opts);
const { modelsAuthOrderSetCommand } = await import("../commands/models/auth-order.js");
const { modelsAuthOrderSetCommand } = await loadModelsAuthOrderCommands();
await modelsAuthOrderSetCommand(
{
provider: opts.provider as string,
@@ -488,7 +507,7 @@ export function registerModelsCli(program: Command) {
.action(async (opts, command) => {
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
const agent = resolveModelAgentOption(command, opts);
const { modelsAuthOrderClearCommand } = await import("../commands/models/auth-order.js");
const { modelsAuthOrderClearCommand } = await loadModelsAuthOrderCommands();
await modelsAuthOrderClearCommand(
{
provider: opts.provider as string,

View File

@@ -347,6 +347,25 @@ vi.mock("../plugins/status.js", () => ({
formatPluginCompatibilityNotice: (entry: { message: string }) => entry.message,
}));
vi.mock("../plugins/status-snapshot.js", () => ({
buildPluginRegistrySnapshotReport: ((
...args: Parameters<
(typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"]
>
) =>
invokeMock<
Parameters<
(typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"]
>,
ReturnType<
(typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"]
>
>(
buildPluginRegistrySnapshotReport,
...args,
)) as (typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"],
}));
vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(loadPluginManifestRegistry, ...args)) as (

View File

@@ -25,6 +25,18 @@ type PluginInstallActionOptions = {
marketplace?: string;
};
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined;
return () => (promise ??= load());
}
const loadPluginsConfigState = createModuleLoader(() => import("../plugins/config-state.js"));
const loadPluginsStatus = createModuleLoader(() => import("../plugins/status.js"));
const loadPluginsCommandHelpers = createModuleLoader(() => import("./plugins-command-helpers.js"));
const loadPluginsRegistryRefresh = createModuleLoader(
() => import("./plugins-registry-refresh.js"),
);
function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number {
return plugins.filter((plugin) => plugin.enabled).length;
}
@@ -168,12 +180,10 @@ export async function runPluginsEnableCommand(id: string): Promise<void> {
assertConfigWriteAllowedInCurrentMode();
const { enablePluginInConfig } = await import("../plugins/enable.js");
const { normalizePluginId } = await import("../plugins/config-state.js");
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const { applySlotSelectionForPlugin, logSlotWarnings } =
await import("./plugins-command-helpers.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const { normalizePluginId } = await loadPluginsConfigState();
const { buildPluginRegistrySnapshotReport } = await loadPluginsStatus();
const { applySlotSelectionForPlugin, logSlotWarnings } = await loadPluginsCommandHelpers();
const { refreshPluginRegistryAfterConfigMutation } = await loadPluginsRegistryRefresh();
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginRegistrySnapshotReport({ config: cfg });
@@ -212,11 +222,10 @@ export async function runPluginsEnableCommand(id: string): Promise<void> {
export async function runPluginsDisableCommand(id: string): Promise<void> {
assertConfigWriteAllowedInCurrentMode();
const { normalizePluginId } = await import("../plugins/config-state.js");
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const { normalizePluginId } = await loadPluginsConfigState();
const { buildPluginRegistrySnapshotReport } = await loadPluginsStatus();
const { setPluginEnabledInConfig } = await import("./plugins-config.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const { refreshPluginRegistryAfterConfigMutation } = await loadPluginsRegistryRefresh();
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginRegistrySnapshotReport({ config: cfg });
@@ -313,7 +322,7 @@ export async function runPluginsDoctorCommand(): Promise<void> {
buildPluginCompatibilityNotices,
buildPluginDiagnosticsReport,
formatPluginCompatibilityNotice,
} = await import("../plugins/status.js");
} = await loadPluginsStatus();
const {
collectStalePluginConfigWarnings,
isStalePluginAutoRepairBlocked,
@@ -428,7 +437,7 @@ export async function runPluginMarketplaceListCommand(
opts: PluginMarketplaceListOptions,
): Promise<void> {
const { listMarketplacePlugins } = await import("../plugins/marketplace.js");
const { createPluginInstallLogger } = await import("./plugins-command-helpers.js");
const { createPluginInstallLogger } = await loadPluginsCommandHelpers();
const result = await listMarketplacePlugins({
marketplace: source,
logger: createPluginInstallLogger(),

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