mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 06:22:28 +08:00
Compare commits
179 Commits
fix/main-c
...
qa-fold-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963830c46a | ||
|
|
a404915620 | ||
|
|
c6d19d86c9 | ||
|
|
fecc451f41 | ||
|
|
75169f8349 | ||
|
|
1cf089ef20 | ||
|
|
c4b2dd3fe0 | ||
|
|
30889ef336 | ||
|
|
563d640d6c | ||
|
|
b988d762e8 | ||
|
|
c0d659f0d7 | ||
|
|
15a2d74320 | ||
|
|
77f07a11e7 | ||
|
|
7a0d36f3d0 | ||
|
|
0a707afb9a | ||
|
|
bdeda6553b | ||
|
|
3499b277e3 | ||
|
|
8c8857c3ef | ||
|
|
d75613e794 | ||
|
|
beb8897f49 | ||
|
|
add5f76a1e | ||
|
|
9a9f4dbefe | ||
|
|
5beaaf343c | ||
|
|
1db811282c | ||
|
|
aa23d9f34e | ||
|
|
2962c95010 | ||
|
|
80d3b132a5 | ||
|
|
1a5d84d3fe | ||
|
|
71a75b9b28 | ||
|
|
b1f562570a | ||
|
|
bdcc691745 | ||
|
|
4461e257e3 | ||
|
|
76014cfe95 | ||
|
|
498ff1fb5a | ||
|
|
ae81aa018d | ||
|
|
1706bfda2c | ||
|
|
a1201e99fc | ||
|
|
90d2f161c9 | ||
|
|
bff7134a69 | ||
|
|
e59d0b540e | ||
|
|
aa5fcf70f7 | ||
|
|
63ac2e2ce0 | ||
|
|
803064c6e0 | ||
|
|
577e5a4692 | ||
|
|
a49f3f9362 | ||
|
|
7b9ddbda99 | ||
|
|
0f83051353 | ||
|
|
4341cf24cc | ||
|
|
6a3f990140 | ||
|
|
81abc2b21b | ||
|
|
09fcafffbc | ||
|
|
2a93d7b9c5 | ||
|
|
0eaefc9050 | ||
|
|
52e01676be | ||
|
|
df68b81006 | ||
|
|
a5417b5c6c | ||
|
|
da2c7e2d2b | ||
|
|
3a14f247ad | ||
|
|
5c36001fcb | ||
|
|
05bed72a8d | ||
|
|
c2433d41a7 | ||
|
|
d368fd620c | ||
|
|
7dc7deaa13 | ||
|
|
a2ff59fdb2 | ||
|
|
b12223a79f | ||
|
|
f519ceab9c | ||
|
|
1f1b1aee6b | ||
|
|
62b2e9ef14 | ||
|
|
0f67474251 | ||
|
|
e56fd1dc04 | ||
|
|
b3968f69c9 | ||
|
|
b0df6dc10e | ||
|
|
141fb2b119 | ||
|
|
64b6488f6c | ||
|
|
e1fc4683bb | ||
|
|
85ab952956 | ||
|
|
abd5fb4494 | ||
|
|
aea050b43e | ||
|
|
85f552bf37 | ||
|
|
dafd98dd98 | ||
|
|
3632c62f85 | ||
|
|
ad5d2cbc1b | ||
|
|
7cda58c109 | ||
|
|
5c0b99ae2b | ||
|
|
979925c194 | ||
|
|
2f9f45f734 | ||
|
|
32cbaecd09 | ||
|
|
1989726eb6 | ||
|
|
2454acc287 | ||
|
|
fce5db415b | ||
|
|
2166652eb3 | ||
|
|
7a9c269541 | ||
|
|
aa893b9228 | ||
|
|
98a7741468 | ||
|
|
3df4341e5a | ||
|
|
ecac665bf3 | ||
|
|
021fd5de2b | ||
|
|
60159b9f00 | ||
|
|
165440117e | ||
|
|
fddfcbe10e | ||
|
|
7c850bdf38 | ||
|
|
2bc20f2ec5 | ||
|
|
ed500dda25 | ||
|
|
bc754b3160 | ||
|
|
b972956173 | ||
|
|
29444b26f2 | ||
|
|
7fc5a72433 | ||
|
|
a590f7f690 | ||
|
|
2252674168 | ||
|
|
60612ff492 | ||
|
|
c5623e72f3 | ||
|
|
947c21ee5a | ||
|
|
99f58ae6d6 | ||
|
|
3f0e740f83 | ||
|
|
106961b513 | ||
|
|
d0001f96f0 | ||
|
|
527bd807b9 | ||
|
|
7546231762 | ||
|
|
a977dc843d | ||
|
|
6ad7f66af2 | ||
|
|
1b4fb6291d | ||
|
|
ee69465fe9 | ||
|
|
7b329ade32 | ||
|
|
44422b2151 | ||
|
|
48b338a5a9 | ||
|
|
d4f68475fd | ||
|
|
d81ae7a441 | ||
|
|
99d8549de6 | ||
|
|
7a077ffead | ||
|
|
b980d678a4 | ||
|
|
e02e3d6971 | ||
|
|
6fa05685ea | ||
|
|
6585cb3b44 | ||
|
|
730c7269ef | ||
|
|
d72f7edf2d | ||
|
|
24b6e6ba96 | ||
|
|
c33f8c20ef | ||
|
|
1c0c072bc2 | ||
|
|
aaf335af04 | ||
|
|
ad049ef083 | ||
|
|
6dc121eb6a | ||
|
|
0742a2f37a | ||
|
|
e2c567538d | ||
|
|
5c8fa5da5c | ||
|
|
9953b85e6d | ||
|
|
048014d1ab | ||
|
|
0cd6975352 | ||
|
|
5384b91866 | ||
|
|
19ec9d8979 | ||
|
|
e65619dd0c | ||
|
|
2f0f085826 | ||
|
|
0cd8db97f9 | ||
|
|
087d999fce | ||
|
|
4514b5a387 | ||
|
|
6b82d4ecb7 | ||
|
|
f719f0cf77 | ||
|
|
8ee638236a | ||
|
|
36934fd9f5 | ||
|
|
84895e9276 | ||
|
|
a6e41a0cc1 | ||
|
|
1ede829fbf | ||
|
|
b93b07ee1b | ||
|
|
405e5072fd | ||
|
|
b79dfc739c | ||
|
|
ff4808f94d | ||
|
|
602bc0baa9 | ||
|
|
a1d278b174 | ||
|
|
0fd5dae36f | ||
|
|
984e058624 | ||
|
|
a6e4afe0fa | ||
|
|
66c62d52ad | ||
|
|
9e3ef487eb | ||
|
|
739636fc33 | ||
|
|
ccc1415f6d | ||
|
|
b1608b4a4e | ||
|
|
703dfbf453 | ||
|
|
7cd58cca2a | ||
|
|
2d603c90dc | ||
|
|
4296ecb78c |
@@ -128,14 +128,9 @@ const config = {
|
||||
"**/*.test-utils.ts",
|
||||
"test/helpers/live-image-probe.ts",
|
||||
"src/secrets/credential-matrix.ts",
|
||||
"src/gateway/live-tool-probe-utils.ts",
|
||||
"src/gateway/server.auth.shared.ts",
|
||||
"src/shared/text/assistant-visible-text.ts",
|
||||
bundledPluginFile("telegram", "src/bot/reply-threading.ts"),
|
||||
bundledPluginFile("telegram", "src/draft-chunking.ts"),
|
||||
bundledPluginFile("msteams", "src/conversation-store-memory.ts"),
|
||||
bundledPluginFile("msteams", "src/polls-store-memory.ts"),
|
||||
bundledPluginFile("voice-call", "src/providers/index.ts"),
|
||||
],
|
||||
ignore: ["packages/*/dist/**"],
|
||||
workspaces: {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
|
||||
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
|
||||
9edb033535fe1325c18b431190672dc3a826dba312e376c13c98fcf9043060dd plugin-sdk-api-baseline.json
|
||||
78f26963fe2e6d7903ce2e1067699200d825f391c0010df46f48d9abd2915e65 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -172,10 +172,12 @@ A finding includes:
|
||||
| `ocPath` | Precise `oc://` address when a check can point to one. |
|
||||
| `fixHint` | Suggested operator action or repair summary. |
|
||||
|
||||
This release registers the modernized core doctor checks on the structured
|
||||
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for bundled follow-up consumers, but plugin-backed checks only run
|
||||
after their owning package registers them in the active command path.
|
||||
Modernized core doctor checks stay attached to the ordered doctor contribution
|
||||
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
|
||||
health registry is the extension point: bundled and plugin-backed checks run
|
||||
after core doctor checks once their owning package registers them in the active
|
||||
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for those extension consumers.
|
||||
|
||||
## Check Selection
|
||||
|
||||
|
||||
@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
|
||||
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
||||
@@ -248,6 +248,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
|
||||
@@ -6,8 +6,6 @@ type SharedIniFileLoader = {
|
||||
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
|
||||
};
|
||||
|
||||
let sharedIniFileLoaderForTest: SharedIniFileLoader | null | undefined;
|
||||
|
||||
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
|
||||
}
|
||||
@@ -21,12 +19,6 @@ export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessE
|
||||
}
|
||||
|
||||
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
|
||||
if (sharedIniFileLoaderForTest !== undefined) {
|
||||
if (!sharedIniFileLoaderForTest) {
|
||||
throw new Error("AWS shared INI file loader unavailable");
|
||||
}
|
||||
return sharedIniFileLoaderForTest;
|
||||
}
|
||||
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
|
||||
}
|
||||
|
||||
@@ -40,10 +32,3 @@ export async function refreshAwsSharedConfigCacheForBedrock(
|
||||
const loader = await loadSharedIniFileLoader();
|
||||
await loader.loadSharedConfigFiles({ ignoreCache: true });
|
||||
}
|
||||
|
||||
/** Override the shared INI loader for Bedrock credential-refresh tests. */
|
||||
export function setAwsSharedIniFileLoaderForTest(
|
||||
loader: SharedIniFileLoader | null | undefined,
|
||||
): void {
|
||||
sharedIniFileLoaderForTest = loader;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,9 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setAwsSharedIniFileLoaderForTest } from "./aws-credential-refresh.js";
|
||||
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
|
||||
import { resetBedrockDiscoveryCacheForTest } from "./discovery.js";
|
||||
import amazonBedrockPlugin from "./index.js";
|
||||
import {
|
||||
resetBedrockAppProfileCacheEligibilityForTest,
|
||||
setBedrockAppProfileControlPlaneForTest,
|
||||
} from "./register.sync.runtime.js";
|
||||
|
||||
type BedrockClientResult =
|
||||
| {
|
||||
@@ -96,6 +91,10 @@ vi.mock("@aws-sdk/client-bedrock", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@smithy/shared-ini-file-loader", () => ({
|
||||
loadSharedConfigFiles: refreshSharedConfigCache,
|
||||
}));
|
||||
|
||||
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
|
||||
|
||||
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
|
||||
@@ -149,6 +148,8 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
|
||||
|
||||
const APP_INFERENCE_PROFILE_ARN =
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
|
||||
const OPUS_APP_INFERENCE_PROFILE_ARN =
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opus-temperature-profile";
|
||||
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
@@ -267,26 +268,12 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
inferenceProfileGetResults.length = 0;
|
||||
bedrockClientConfigs.length = 0;
|
||||
refreshSharedConfigCache.mockClear();
|
||||
setAwsSharedIniFileLoaderForTest({ loadSharedConfigFiles: refreshSharedConfigCache });
|
||||
sendBedrockCommand.mockClear();
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
setBedrockAppProfileControlPlaneForTest((region) => ({
|
||||
async getInferenceProfile(input) {
|
||||
class GetInferenceProfileCommand {
|
||||
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
|
||||
}
|
||||
bedrockClientConfigs.push(region ? { region } : {});
|
||||
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setBedrockAppProfileControlPlaneForTest(undefined);
|
||||
setAwsSharedIniFileLoaderForTest(undefined);
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -1501,8 +1488,8 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
|
||||
await callWrappedStreamWithPayload(
|
||||
provider,
|
||||
APP_INFERENCE_PROFILE_ARN,
|
||||
APP_INFERENCE_PROFILE_DESCRIPTOR,
|
||||
OPUS_APP_INFERENCE_PROFILE_ARN,
|
||||
makeAppInferenceProfileDescriptor(OPUS_APP_INFERENCE_PROFILE_ARN),
|
||||
{ temperature: 0.3, maxTokens: 10, cacheRetention: "short" },
|
||||
payload,
|
||||
);
|
||||
|
||||
@@ -254,27 +254,7 @@ type BedrockControlPlane = {
|
||||
}) => Promise<BedrockGetInferenceProfileResponse>;
|
||||
};
|
||||
|
||||
type BedrockControlPlaneFactory = (region: string | undefined) => BedrockControlPlane;
|
||||
|
||||
let bedrockControlPlaneOverride: BedrockControlPlaneFactory | undefined;
|
||||
|
||||
/** Reset app-profile prompt-cache eligibility state for tests. */
|
||||
export function resetBedrockAppProfileCacheEligibilityForTest(): void {
|
||||
appProfileTraitsCache.clear();
|
||||
}
|
||||
|
||||
/** Override Bedrock app-profile control-plane checks for tests. */
|
||||
export function setBedrockAppProfileControlPlaneForTest(
|
||||
controlPlane: BedrockControlPlaneFactory | undefined,
|
||||
): void {
|
||||
bedrockControlPlaneOverride = controlPlane;
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
}
|
||||
|
||||
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
|
||||
if (bedrockControlPlaneOverride) {
|
||||
return bedrockControlPlaneOverride(region);
|
||||
}
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
|
||||
const client = new BedrockClient(region ? { region } : {});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface PnpmRunnerParams {
|
||||
comSpec?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodeArgs?: string[];
|
||||
nodeExecPath?: string;
|
||||
npmExecPath?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Cross-platform pnpm command resolver used by Canvas build scripts.
|
||||
*/
|
||||
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
|
||||
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
|
||||
@@ -48,13 +49,56 @@ function isExecutableFile(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(value) {
|
||||
try {
|
||||
return statSync(value).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathEnvKey(env) {
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
}
|
||||
|
||||
function findExecutableOnPath(command, envPath, platform, env, cwd) {
|
||||
if (typeof envPath !== "string" || envPath.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const extensions =
|
||||
platform === "win32"
|
||||
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
|
||||
".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.filter(Boolean)
|
||||
.map((extension) => extension.toLowerCase())
|
||||
: [""];
|
||||
const pathImpl = platform === "win32" ? path.win32 : path;
|
||||
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
|
||||
for (const directory of envPath.split(pathDelimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const resolvedDirectory = pathImpl.isAbsolute(directory)
|
||||
? directory
|
||||
: pathImpl.resolve(cwd, directory);
|
||||
for (const extension of extensions) {
|
||||
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
|
||||
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isNodeRunnablePnpmExecPath(value) {
|
||||
if (!isPnpmExecPath(value)) {
|
||||
return false;
|
||||
}
|
||||
const { extension } = inspectExecutablePath(value);
|
||||
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
|
||||
return true;
|
||||
return isFile(value);
|
||||
}
|
||||
if (extension.length > 0) {
|
||||
return false;
|
||||
@@ -129,6 +173,22 @@ export function resolvePnpmRunner(params = {}) {
|
||||
|
||||
const pnpmArgs = params.pnpmArgs ?? [];
|
||||
const platform = params.platform ?? process.platform;
|
||||
const env = params.env ?? process.env;
|
||||
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
|
||||
if (pnpmPath) {
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args: pnpmArgs, command: pnpmPath, shell: false };
|
||||
}
|
||||
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
|
||||
if (corepackPath) {
|
||||
const args = ["pnpm", ...pnpmArgs];
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args, command: corepackPath, shell: false };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -40,6 +41,7 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -53,4 +55,79 @@ describe("canvas pnpm runner", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("prefers a direct pnpm executable over Corepack", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
|
||||
const pnpmPath = path.join(tempDir, "pnpm");
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(pnpmPath, 0o755);
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["exec", "rolldown", "-c"],
|
||||
command: pnpmPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,7 +351,6 @@ vi.mock("./send.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Feishu tests cover media plugin behavior.
|
||||
import { realpathSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
||||
@@ -17,7 +15,6 @@ const runFfmpegMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const fileCreateMock = vi.hoisted(() => vi.fn());
|
||||
const imageCreateMock = vi.hoisted(() => vi.fn());
|
||||
const imageGetMock = vi.hoisted(() => vi.fn());
|
||||
const messageCreateMock = vi.hoisted(() => vi.fn());
|
||||
const messageResourceGetMock = vi.hoisted(() => vi.fn());
|
||||
const messageReplyMock = vi.hoisted(() => vi.fn());
|
||||
@@ -55,23 +52,11 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
|
||||
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
|
||||
let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu;
|
||||
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
|
||||
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
|
||||
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
|
||||
|
||||
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
||||
expect(pathValue).not.toContain(key);
|
||||
expect(pathValue).not.toContain("..");
|
||||
|
||||
const tmpRoot = realpathSync(resolvePreferredOpenClawTmpDir());
|
||||
const resolved = path.resolve(pathValue);
|
||||
const rel = path.relative(tmpRoot, resolved);
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
}
|
||||
|
||||
function expectMediaTimeoutClientConfigured(): void {
|
||||
const options = mockCallArg<{ httpTimeoutMs?: number }>(createFeishuClientMock, 0, 0);
|
||||
expect(options.httpTimeoutMs).toBe(FEISHU_MEDIA_HTTP_TIMEOUT_MS);
|
||||
@@ -113,11 +98,25 @@ function callData<T>(
|
||||
return arg.data as T;
|
||||
}
|
||||
|
||||
async function withIsolatedHome<T>(run: () => Promise<T>): Promise<T> {
|
||||
const originalHome = process.env.HOME;
|
||||
return await withTempDir("openclaw-feishu-media-", async (tempHome) => {
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("sendMediaFeishu msg_type routing", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
downloadImageFeishu,
|
||||
downloadMessageResourceFeishu,
|
||||
saveMessageResourceFeishu,
|
||||
sanitizeFileNameForUpload,
|
||||
sendMediaFeishu,
|
||||
@@ -148,7 +147,6 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
},
|
||||
image: {
|
||||
create: imageCreateMock,
|
||||
get: imageGetMock,
|
||||
},
|
||||
message: {
|
||||
create: messageCreateMock,
|
||||
@@ -186,7 +184,6 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
contentType: "audio/ogg",
|
||||
});
|
||||
|
||||
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
|
||||
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
|
||||
runFfmpegMock.mockImplementation(async (args: string[]) => {
|
||||
await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output"));
|
||||
@@ -500,74 +497,25 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
expect(messageReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses isolated temp paths for image downloads", async () => {
|
||||
const imageKey = "img_v3_01abc123";
|
||||
let capturedPath: string | undefined;
|
||||
|
||||
imageGetMock.mockResolvedValueOnce({
|
||||
writeFile: async (tmpPath: string) => {
|
||||
capturedPath = tmpPath;
|
||||
await fs.writeFile(tmpPath, Buffer.from("image-data"));
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadImageFeishu({
|
||||
cfg: emptyConfig,
|
||||
imageKey,
|
||||
});
|
||||
|
||||
const request = mockCallArg<{ path?: { image_key?: string } }>(imageGetMock, 0, 0);
|
||||
expect(request.path).toEqual({ image_key: imageKey });
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toEqual(Buffer.from("image-data"));
|
||||
if (!capturedPath) {
|
||||
throw new Error("expected Feishu image temp path");
|
||||
}
|
||||
expectPathIsolatedToTmpRoot(capturedPath, imageKey);
|
||||
});
|
||||
|
||||
it("uses isolated temp paths for message resource downloads", async () => {
|
||||
const fileKey = "file_v3_01abc123";
|
||||
let capturedPath: string | undefined;
|
||||
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
writeFile: async (tmpPath: string) => {
|
||||
capturedPath = tmpPath;
|
||||
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey,
|
||||
type: "image",
|
||||
});
|
||||
|
||||
expect(result.buffer).toEqual(Buffer.from("resource-data"));
|
||||
if (!capturedPath) {
|
||||
throw new Error("expected Feishu resource temp path");
|
||||
}
|
||||
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
|
||||
});
|
||||
|
||||
it("rejects oversized message resource streams before buffering the rest", async () => {
|
||||
it("rejects oversized message resource streams before saving the rest", async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
getReadableStream: () => Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/Media exceeds/i);
|
||||
});
|
||||
|
||||
it("rejects oversized writeFile downloads before reading the temp file", async () => {
|
||||
it("rejects oversized writeFile resources before saving the temp file", async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
writeFile: async (tmpPath: string) => {
|
||||
await fs.writeFile(tmpPath, Buffer.alloc(8));
|
||||
@@ -575,34 +523,26 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "file_v3_01abc123",
|
||||
type: "file",
|
||||
maxBytes: 7,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/Media exceeds/i);
|
||||
});
|
||||
|
||||
it("rejects invalid image keys before calling feishu api", async () => {
|
||||
await expect(
|
||||
downloadImageFeishu({
|
||||
cfg: emptyConfig,
|
||||
imageKey: "a/../../bad",
|
||||
}),
|
||||
).rejects.toThrow("invalid image_key");
|
||||
|
||||
expect(imageGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid file keys before calling feishu api", async () => {
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_123",
|
||||
fileKey: "x/../../bad",
|
||||
type: "file",
|
||||
maxBytes: 30 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow("invalid file_key");
|
||||
|
||||
@@ -687,7 +627,7 @@ describe("sanitizeFileNameForUpload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMessageResourceFeishu", () => {
|
||||
describe("saveMessageResourceFeishu", () => {
|
||||
function httpStatusError(status: number): Error & { response: { status: number } } {
|
||||
return Object.assign(new Error(`Request failed with status code ${status}`), {
|
||||
response: { status },
|
||||
@@ -712,12 +652,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
// Regression: Feishu API only supports type=image|file for messageResource.get.
|
||||
// Audio/video resources must use type=file, not type=audio (#8746).
|
||||
it("forwards provided type=file for non-image resources", async () => {
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_audio_msg",
|
||||
fileKey: "file_key_audio",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_audio_msg",
|
||||
fileKey: "file_key_audio",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const request = mockCallArg<{
|
||||
params?: { type?: string };
|
||||
@@ -726,18 +669,21 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
expect(request.path).toEqual({ message_id: "om_audio_msg", file_key: "file_key_audio" });
|
||||
expect(request.params).toEqual({ type: "file" });
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
expect(result.saved.size).toBe("fake-audio-data".length);
|
||||
});
|
||||
|
||||
it("image uses type=image", async () => {
|
||||
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_img_msg",
|
||||
fileKey: "img_key_1",
|
||||
type: "image",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_img_msg",
|
||||
fileKey: "img_key_1",
|
||||
type: "image",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const request = mockCallArg<{
|
||||
params?: { type?: string };
|
||||
@@ -746,7 +692,7 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
expect(request.path).toEqual({ message_id: "om_img_msg", file_key: "img_key_1" });
|
||||
expect(request.params).toEqual({ type: "image" });
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
expect(result.saved.size).toBe("fake-image-data".length);
|
||||
});
|
||||
|
||||
it("extracts content-type and filename metadata from download headers", async () => {
|
||||
@@ -758,14 +704,17 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_video_msg",
|
||||
fileKey: "file_key_video",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_video_msg",
|
||||
fileKey: "file_key_video",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.buffer).toEqual(Buffer.from("fake-video-data"));
|
||||
expect(result.saved.size).toBe("fake-video-data".length);
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
expect(result.fileName).toBe("clip.mp4");
|
||||
});
|
||||
@@ -780,12 +729,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const firstRequest = mockCallArg<{
|
||||
params?: { type?: string };
|
||||
@@ -805,7 +757,7 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
file_key: "file_key_ios_video",
|
||||
});
|
||||
expect(secondRequest.params).toEqual({ type: "media" });
|
||||
expect(result.buffer).toEqual(Buffer.from("fake-ios-video-data"));
|
||||
expect(result.saved.size).toBe("fake-ios-video-data".length);
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
expect(result.fileName).toBe("ios-video.mp4");
|
||||
});
|
||||
@@ -817,12 +769,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
.mockRejectedValueOnce(new Error("media retry failed"));
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_ios_video_msg",
|
||||
fileKey: "file_key_ios_video",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(originalError);
|
||||
|
||||
expect(
|
||||
@@ -843,12 +798,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
messageResourceGetMock.mockRejectedValueOnce(originalError);
|
||||
|
||||
await expect(
|
||||
downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: scenario.messageId,
|
||||
fileKey: scenario.fileKey,
|
||||
type: scenario.type,
|
||||
}),
|
||||
withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: scenario.messageId,
|
||||
fileKey: scenario.fileKey,
|
||||
type: scenario.type,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(originalError);
|
||||
|
||||
expect(messageResourceGetMock).toHaveBeenCalledTimes(1);
|
||||
@@ -871,12 +829,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_file_msg",
|
||||
fileKey: "file_key_csv",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_file_msg",
|
||||
fileKey: "file_key_csv",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.fileName).toBe(fileName);
|
||||
});
|
||||
@@ -889,12 +850,15 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_latin1_msg",
|
||||
fileKey: "file_key_latin1",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_latin1_msg",
|
||||
fileKey: "file_key_latin1",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.fileName).toBe("café-©.txt");
|
||||
});
|
||||
@@ -907,21 +871,21 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
file_name: latin1LookingFileName,
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_json_file_msg",
|
||||
fileKey: "file_key_json",
|
||||
type: "file",
|
||||
});
|
||||
const result = await withIsolatedHome(() =>
|
||||
saveMessageResourceFeishu({
|
||||
cfg: emptyConfig,
|
||||
messageId: "om_json_file_msg",
|
||||
fileKey: "file_key_json",
|
||||
type: "file",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.fileName).toBe(latin1LookingFileName);
|
||||
});
|
||||
|
||||
it("saves message resource streams directly to the media store", async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
await withIsolatedHome(async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
getReadableStream: () => Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
|
||||
headers: {
|
||||
@@ -944,23 +908,13 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
await expect(fs.readFile(result.saved.path)).resolves.toEqual(
|
||||
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
|
||||
);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers CJK filenames from the inbound message payload fallback", async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
|
||||
const fileName = "武汉15座山登山信息汇总.csv";
|
||||
const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1");
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
await withIsolatedHome(async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
getReadableStream: () => Readable.from([Buffer.from("a,b\n1,2\n")]),
|
||||
headers: { "content-type": "text/csv" },
|
||||
@@ -976,13 +930,6 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
});
|
||||
|
||||
expect(result.saved.id).toMatch(/^武汉15座山登山信息汇总---[a-f0-9-]{36}\.csv$/);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
|
||||
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
@@ -48,17 +47,6 @@ const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
|
||||
".wma",
|
||||
]);
|
||||
|
||||
export type DownloadImageResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type DownloadMessageResourceResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export type SaveMessageResourceResult = {
|
||||
saved: SavedMedia;
|
||||
contentType?: string;
|
||||
@@ -87,10 +75,7 @@ type FeishuUploadResponse =
|
||||
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
|
||||
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
|
||||
|
||||
type FeishuDownloadResponse =
|
||||
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
|
||||
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
|
||||
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
|
||||
type FeishuDownloadResponse = Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
|
||||
|
||||
type FeishuHeaderMap = Record<string, string | string[]>;
|
||||
type FeishuMessageResourceDownloadType = "image" | "file" | "media";
|
||||
@@ -255,78 +240,6 @@ function mediaLimitError(maxBytes: number): Error {
|
||||
return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
|
||||
}
|
||||
|
||||
function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
throw mediaLimitError(maxBytes);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function readFeishuResponseBuffer(params: {
|
||||
response: FeishuDownloadResponse;
|
||||
tmpDirPrefix: string;
|
||||
errorPrefix: string;
|
||||
maxBytes: number;
|
||||
}): Promise<Buffer> {
|
||||
const { response, maxBytes } = params;
|
||||
if (Buffer.isBuffer(response)) {
|
||||
return assertBufferWithinLimit(response, maxBytes);
|
||||
}
|
||||
if (response instanceof ArrayBuffer) {
|
||||
return assertBufferWithinLimit(Buffer.from(response), maxBytes);
|
||||
}
|
||||
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: Buffer | ArrayBuffer;
|
||||
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
|
||||
};
|
||||
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
|
||||
throw new Error(
|
||||
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
|
||||
return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
|
||||
}
|
||||
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
|
||||
return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
|
||||
}
|
||||
if (typeof response.getReadableStream === "function") {
|
||||
return readByteStreamWithLimit(response.getReadableStream(), {
|
||||
maxBytes,
|
||||
onOverflow: () => mediaLimitError(maxBytes),
|
||||
});
|
||||
}
|
||||
if (typeof response.writeFile === "function") {
|
||||
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
||||
await response.writeFile(tmpPath);
|
||||
const stat = await fs.promises.stat(tmpPath);
|
||||
if (stat.size > maxBytes) {
|
||||
throw mediaLimitError(maxBytes);
|
||||
}
|
||||
return await fs.promises.readFile(tmpPath);
|
||||
});
|
||||
}
|
||||
if (responseWithOptionalFields[Symbol.asyncIterator]) {
|
||||
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
|
||||
return readByteStreamWithLimit(asyncIterable, {
|
||||
maxBytes,
|
||||
onOverflow: () => mediaLimitError(maxBytes),
|
||||
});
|
||||
}
|
||||
if (response instanceof Readable) {
|
||||
return readByteStreamWithLimit(response, {
|
||||
maxBytes,
|
||||
onOverflow: () => mediaLimitError(maxBytes),
|
||||
});
|
||||
}
|
||||
|
||||
const keys = Object.keys(response as object);
|
||||
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
|
||||
}
|
||||
|
||||
async function saveFeishuResponseMedia(params: {
|
||||
response: FeishuDownloadResponse;
|
||||
tmpDirPrefix: string;
|
||||
@@ -409,58 +322,6 @@ async function saveFeishuResponseMedia(params: {
|
||||
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from Feishu using image_key.
|
||||
* Used for downloading images sent in messages.
|
||||
*/
|
||||
export async function downloadImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
imageKey: string;
|
||||
accountId?: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<DownloadImageResult> {
|
||||
const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
|
||||
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
|
||||
if (!normalizedImageKey) {
|
||||
throw new Error("Feishu image download failed: invalid image_key");
|
||||
}
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
const response = await client.im.image.get({
|
||||
path: { image_key: normalizedImageKey },
|
||||
});
|
||||
|
||||
const buffer = await readFeishuResponseBuffer({
|
||||
response,
|
||||
tmpDirPrefix: "openclaw-feishu-img-",
|
||||
errorPrefix: "Feishu image download failed",
|
||||
maxBytes,
|
||||
});
|
||||
const meta = extractFeishuDownloadMetadata(response);
|
||||
return { buffer, contentType: meta.contentType };
|
||||
}
|
||||
|
||||
async function downloadMessageResourceWithType(params: {
|
||||
client: ReturnType<typeof createFeishuClient>;
|
||||
messageId: string;
|
||||
fileKey: string;
|
||||
type: FeishuMessageResourceDownloadType;
|
||||
maxBytes: number;
|
||||
}): Promise<DownloadMessageResourceResult> {
|
||||
const response = await params.client.im.messageResource.get({
|
||||
path: { message_id: params.messageId, file_key: params.fileKey },
|
||||
params: { type: params.type },
|
||||
});
|
||||
|
||||
const buffer = await readFeishuResponseBuffer({
|
||||
response,
|
||||
tmpDirPrefix: "openclaw-feishu-resource-",
|
||||
errorPrefix: "Feishu message resource download failed",
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
return { buffer, ...extractFeishuDownloadMetadata(response) };
|
||||
}
|
||||
|
||||
async function saveMessageResourceWithType(params: {
|
||||
client: ReturnType<typeof createFeishuClient>;
|
||||
messageId: string;
|
||||
@@ -489,51 +350,6 @@ async function saveMessageResourceWithType(params: {
|
||||
return { saved, ...meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a message resource (file/image/audio/video) from Feishu.
|
||||
* Used for downloading files, audio, and video from messages.
|
||||
*/
|
||||
export async function downloadMessageResourceFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
fileKey: string;
|
||||
type: "image" | "file";
|
||||
accountId?: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<DownloadMessageResourceResult> {
|
||||
const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
|
||||
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
|
||||
if (!normalizedFileKey) {
|
||||
throw new Error("Feishu message resource download failed: invalid file_key");
|
||||
}
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
try {
|
||||
return await downloadMessageResourceWithType({
|
||||
client,
|
||||
messageId,
|
||||
fileKey: normalizedFileKey,
|
||||
type,
|
||||
maxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
if (type !== "file" || !isHttpStatusError(err, 502)) {
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await downloadMessageResourceWithType({
|
||||
client,
|
||||
messageId,
|
||||
fileKey: normalizedFileKey,
|
||||
type: "media",
|
||||
maxBytes,
|
||||
});
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMessageResourceFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
setMemorySearchImpl,
|
||||
setMemoryWorkspaceDir,
|
||||
type MemoryReadParams,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
} from "./memory-tool-manager.test-mocks.js";
|
||||
import { testing as shortTermPromotionTesting } from "./short-term-promotion.js";
|
||||
import { createMemoryCoreTestHarness } from "./test-helpers.js";
|
||||
import { testing as memoryToolsTesting } from "./tools.js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resetMemoryToolMockState,
|
||||
setMemoryBackend,
|
||||
setMemorySearchImpl,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
} from "./memory-tool-manager.test-mocks.js";
|
||||
import { createMemorySearchTool } from "./tools.js";
|
||||
|
||||
type RecordShortTermRecallsFn = (params: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
setMemoryCustomStatus,
|
||||
setMemorySearchImpl,
|
||||
setMemorySearchManagerImpl,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
} from "./memory-tool-manager.test-mocks.js";
|
||||
import { createMemorySearchTool, testing as memoryToolsTesting } from "./tools.js";
|
||||
import { MemoryGetSchema, MemorySearchSchema } from "./tools.shared.js";
|
||||
import {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Msteams plugin module implements conversation store memory behavior.
|
||||
import {
|
||||
findPreferredDmConversationByUserId,
|
||||
mergeStoredConversationReference,
|
||||
normalizeStoredConversationId,
|
||||
toConversationStoreEntries,
|
||||
} from "./conversation-store-helpers.js";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
MSTeamsConversationStoreEntry,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
|
||||
export function createMSTeamsConversationStoreMemory(
|
||||
initial: MSTeamsConversationStoreEntry[] = [],
|
||||
): MSTeamsConversationStore {
|
||||
const map = new Map<string, StoredConversationReference>();
|
||||
for (const { conversationId, reference } of initial) {
|
||||
map.set(normalizeStoredConversationId(conversationId), reference);
|
||||
}
|
||||
|
||||
const findPreferredDmByUserId = async (
|
||||
id: string,
|
||||
): Promise<MSTeamsConversationStoreEntry | null> => {
|
||||
return findPreferredDmConversationByUserId(toConversationStoreEntries(map.entries()), id);
|
||||
};
|
||||
|
||||
return {
|
||||
upsert: async (conversationId, reference) => {
|
||||
const normalizedId = normalizeStoredConversationId(conversationId);
|
||||
map.set(
|
||||
normalizedId,
|
||||
mergeStoredConversationReference(
|
||||
map.get(normalizedId),
|
||||
reference,
|
||||
new Date().toISOString(),
|
||||
),
|
||||
);
|
||||
},
|
||||
get: async (conversationId) => {
|
||||
return map.get(normalizeStoredConversationId(conversationId)) ?? null;
|
||||
},
|
||||
list: async () => {
|
||||
return toConversationStoreEntries(map.entries());
|
||||
},
|
||||
remove: async (conversationId) => {
|
||||
return map.delete(normalizeStoredConversationId(conversationId));
|
||||
},
|
||||
findPreferredDmByUserId,
|
||||
findByUserId: findPreferredDmByUserId,
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,18 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMSTeamsConversationStoreMemory } from "./conversation-store-memory.js";
|
||||
import {
|
||||
findPreferredDmConversationByUserId,
|
||||
mergeStoredConversationReference,
|
||||
normalizeStoredConversationId,
|
||||
toConversationStoreEntries,
|
||||
} from "./conversation-store-helpers.js";
|
||||
import { createMSTeamsConversationStoreState } from "./conversation-store-state.js";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
MSTeamsConversationStoreEntry,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import { msteamsRuntimeStub } from "./test-support/runtime.js";
|
||||
|
||||
@@ -15,6 +24,39 @@ type StoreFactory = {
|
||||
createStore: () => Promise<MSTeamsConversationStore>;
|
||||
};
|
||||
|
||||
function createMemoryConversationStore(
|
||||
initial: MSTeamsConversationStoreEntry[] = [],
|
||||
): MSTeamsConversationStore {
|
||||
const map = new Map<string, StoredConversationReference>();
|
||||
for (const { conversationId, reference } of initial) {
|
||||
map.set(normalizeStoredConversationId(conversationId), reference);
|
||||
}
|
||||
|
||||
const findPreferredDmByUserId = async (
|
||||
id: string,
|
||||
): Promise<MSTeamsConversationStoreEntry | null> =>
|
||||
findPreferredDmConversationByUserId(toConversationStoreEntries(map.entries()), id);
|
||||
|
||||
return {
|
||||
upsert: async (conversationId, reference) => {
|
||||
const normalizedId = normalizeStoredConversationId(conversationId);
|
||||
map.set(
|
||||
normalizedId,
|
||||
mergeStoredConversationReference(
|
||||
map.get(normalizedId),
|
||||
reference,
|
||||
new Date().toISOString(),
|
||||
),
|
||||
);
|
||||
},
|
||||
get: async (conversationId) => map.get(normalizeStoredConversationId(conversationId)) ?? null,
|
||||
list: async () => toConversationStoreEntries(map.entries()),
|
||||
remove: async (conversationId) => map.delete(normalizeStoredConversationId(conversationId)),
|
||||
findPreferredDmByUserId,
|
||||
findByUserId: findPreferredDmByUserId,
|
||||
};
|
||||
}
|
||||
|
||||
const storeFactories: StoreFactory[] = [
|
||||
{
|
||||
name: "state",
|
||||
@@ -28,7 +70,7 @@ const storeFactories: StoreFactory[] = [
|
||||
},
|
||||
{
|
||||
name: "memory",
|
||||
createStore: async () => createMSTeamsConversationStoreMemory(),
|
||||
createStore: async () => createMemoryConversationStore(),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Msteams tests cover mentions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
|
||||
import { parseMentions } from "./mentions.js";
|
||||
|
||||
function requireFirstEntity(result: ReturnType<typeof parseMentions>) {
|
||||
const entity = result.entities[0];
|
||||
@@ -15,29 +15,12 @@ function requireOnlyEntity(result: ReturnType<typeof parseMentions>) {
|
||||
return requireFirstEntity(result);
|
||||
}
|
||||
|
||||
const mentionFreeTextCases = [
|
||||
{
|
||||
name: "parseMentions",
|
||||
assert: () => {
|
||||
const result = parseMentions("Hello world!");
|
||||
|
||||
expect(result.text).toBe("Hello world!");
|
||||
expect(result.entities).toHaveLength(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "formatMentionText",
|
||||
assert: () => {
|
||||
const mentions = [{ id: "28:xxx", name: "John" }];
|
||||
|
||||
expect(formatMentionText("Hello world", mentions)).toBe("Hello world");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("mention-free text contract", () => {
|
||||
it.each(mentionFreeTextCases)("$name handles text without mentions", ({ assert }) => {
|
||||
assert();
|
||||
it("parseMentions handles text without mentions", () => {
|
||||
const result = parseMentions("Hello world!");
|
||||
|
||||
expect(result.text).toBe("Hello world!");
|
||||
expect(result.entities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,81 +158,3 @@ describe("parseMentions", () => {
|
||||
expect(result.text).toBe("See @[docs](https://example.com) for details");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMentionEntities", () => {
|
||||
it("builds entities from mention info", () => {
|
||||
const mentions = [
|
||||
{ id: "28:aaa", name: "Alice" },
|
||||
{ id: "28:bbb", name: "Bob" },
|
||||
];
|
||||
|
||||
const entities = buildMentionEntities(mentions);
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0]).toEqual({
|
||||
type: "mention",
|
||||
text: "<at>Alice</at>",
|
||||
mentioned: {
|
||||
id: "28:aaa",
|
||||
name: "Alice",
|
||||
},
|
||||
});
|
||||
expect(entities[1]).toEqual({
|
||||
type: "mention",
|
||||
text: "<at>Bob</at>",
|
||||
mentioned: {
|
||||
id: "28:bbb",
|
||||
name: "Bob",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty list", () => {
|
||||
const entities = buildMentionEntities([]);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMentionText", () => {
|
||||
it("formats text with single mention", () => {
|
||||
const text = "Hello @John!";
|
||||
const mentions = [{ id: "28:xxx", name: "John" }];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hello <at>John</at>!");
|
||||
});
|
||||
|
||||
it("formats text with multiple mentions", () => {
|
||||
const text = "Hey @Alice and @Bob";
|
||||
const mentions = [
|
||||
{ id: "28:aaa", name: "Alice" },
|
||||
{ id: "28:bbb", name: "Bob" },
|
||||
];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hey <at>Alice</at> and <at>Bob</at>");
|
||||
});
|
||||
|
||||
it("handles case-insensitive matching", () => {
|
||||
const text = "Hey @alice and @ALICE";
|
||||
const mentions = [{ id: "28:aaa", name: "Alice" }];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hey <at>Alice</at> and <at>Alice</at>");
|
||||
});
|
||||
|
||||
it("escapes regex metacharacters in names", () => {
|
||||
const text = "Hey @John(Test) and @Alice.Smith";
|
||||
const mentions = [
|
||||
{ id: "28:xxx", name: "John(Test)" },
|
||||
{ id: "28:yyy", name: "Alice.Smith" },
|
||||
];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hey <at>John(Test)</at> and <at>Alice.Smith</at>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,6 @@ type MentionEntity = {
|
||||
};
|
||||
};
|
||||
|
||||
type MentionInfo = {
|
||||
/** User/bot ID (e.g., "28:xxx" or AAD object ID) */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether an ID looks like a valid Teams user/bot identifier.
|
||||
* Accepts:
|
||||
@@ -82,33 +75,3 @@ export function parseMentions(text: string): {
|
||||
entities,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mention entities array from a list of mentions.
|
||||
* Use this when you already have the mention info and formatted text.
|
||||
*/
|
||||
export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
|
||||
return mentions.map((mention) => ({
|
||||
type: "mention",
|
||||
text: `<at>${mention.name}</at>`,
|
||||
mentioned: {
|
||||
id: mention.id,
|
||||
name: mention.name,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format text with mentions using <at> tags.
|
||||
* This is a convenience function when you want to manually format mentions.
|
||||
*/
|
||||
export function formatMentionText(text: string, mentions: MentionInfo[]): string {
|
||||
let formatted = text;
|
||||
for (const mention of mentions) {
|
||||
// Replace @Name or @name with <at>Name</at>
|
||||
const escapedName = mention.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const namePattern = new RegExp(`@${escapedName}`, "gi");
|
||||
formatted = formatted.replace(namePattern, `<at>${mention.name}</at>`);
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Msteams tests cover monitor handler.sso plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMSTeamsSsoTokenStoreMemory } from "./sso-token-store.js";
|
||||
import {
|
||||
makeMSTeamsSsoTokenStoreKey,
|
||||
type MSTeamsSsoStoredToken,
|
||||
type MSTeamsSsoTokenStore,
|
||||
} from "./sso-token-store.js";
|
||||
import {
|
||||
type MSTeamsSsoFetch,
|
||||
handleSigninTokenExchangeInvoke,
|
||||
@@ -9,8 +13,23 @@ import {
|
||||
parseSigninVerifyStateValue,
|
||||
} from "./sso.js";
|
||||
|
||||
function createMemorySsoTokenStore(): MSTeamsSsoTokenStore {
|
||||
const tokens = new Map<string, MSTeamsSsoStoredToken>();
|
||||
return {
|
||||
async get({ connectionName, userId }) {
|
||||
return tokens.get(makeMSTeamsSsoTokenStoreKey(connectionName, userId)) ?? null;
|
||||
},
|
||||
async save(token) {
|
||||
tokens.set(makeMSTeamsSsoTokenStoreKey(token.connectionName, token.userId), { ...token });
|
||||
},
|
||||
async remove({ connectionName, userId }) {
|
||||
return tokens.delete(makeMSTeamsSsoTokenStoreKey(connectionName, userId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSsoDeps(params: { fetchImpl: MSTeamsSsoFetch }) {
|
||||
const tokenStore = createMSTeamsSsoTokenStoreMemory();
|
||||
const tokenStore = createMemorySsoTokenStore();
|
||||
const tokenProvider = {
|
||||
getAccessToken: vi.fn(async () => "bf-service-token"),
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Msteams plugin module implements polls store memory behavior.
|
||||
import {
|
||||
type MSTeamsPoll,
|
||||
type MSTeamsPollStore,
|
||||
normalizeMSTeamsPollSelections,
|
||||
} from "./polls.js";
|
||||
|
||||
export function createMSTeamsPollStoreMemory(initial: MSTeamsPoll[] = []): MSTeamsPollStore {
|
||||
const polls = new Map<string, MSTeamsPoll>();
|
||||
for (const poll of initial) {
|
||||
polls.set(poll.id, { ...poll });
|
||||
}
|
||||
|
||||
const createPoll = async (poll: MSTeamsPoll) => {
|
||||
polls.set(poll.id, { ...poll });
|
||||
};
|
||||
|
||||
const getPoll = async (pollId: string) => polls.get(pollId) ?? null;
|
||||
|
||||
const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) => {
|
||||
const poll = polls.get(params.pollId);
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeMSTeamsPollSelections(poll, params.selections);
|
||||
poll.votes[params.voterId] = normalized;
|
||||
poll.updatedAt = new Date().toISOString();
|
||||
polls.set(poll.id, poll);
|
||||
return poll;
|
||||
};
|
||||
|
||||
return { createPoll, getPoll, recordVote };
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
|
||||
import {
|
||||
buildMSTeamsPollCard,
|
||||
createMSTeamsPollStoreState,
|
||||
extractMSTeamsPollVote,
|
||||
normalizeMSTeamsPollSelections,
|
||||
type MSTeamsPoll,
|
||||
type MSTeamsPollStore,
|
||||
} from "./polls.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import { msteamsRuntimeStub } from "./test-support/runtime.js";
|
||||
@@ -112,7 +112,32 @@ const createStateStore = async () => {
|
||||
return createMSTeamsPollStoreState({ stateDir });
|
||||
};
|
||||
|
||||
const createMemoryStore = () => createMSTeamsPollStoreMemory();
|
||||
function createMemoryPollStore(initial: MSTeamsPoll[] = []): MSTeamsPollStore {
|
||||
const polls = new Map<string, MSTeamsPoll>();
|
||||
for (const poll of initial) {
|
||||
polls.set(poll.id, { ...poll });
|
||||
}
|
||||
|
||||
return {
|
||||
createPoll: async (poll) => {
|
||||
polls.set(poll.id, { ...poll });
|
||||
},
|
||||
getPoll: async (pollId) => polls.get(pollId) ?? null,
|
||||
recordVote: async ({ pollId, voterId, selections }) => {
|
||||
const poll = polls.get(pollId);
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeMSTeamsPollSelections(poll, selections);
|
||||
poll.votes[voterId] = normalized;
|
||||
poll.updatedAt = new Date().toISOString();
|
||||
polls.set(poll.id, poll);
|
||||
return poll;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createMemoryStore = () => createMemoryPollStore();
|
||||
|
||||
describe.each([
|
||||
{ name: "memory", createStore: createMemoryStore },
|
||||
@@ -339,7 +364,7 @@ describe("state poll store", () => {
|
||||
|
||||
describe("memory poll store", () => {
|
||||
it("reads seeded polls back, updates timestamps, and returns null for missing polls", async () => {
|
||||
const store = createMSTeamsPollStoreMemory([
|
||||
const store = createMemoryPollStore([
|
||||
{
|
||||
id: "poll-1",
|
||||
question: "Pick one",
|
||||
|
||||
@@ -137,19 +137,3 @@ export function createMSTeamsSsoTokenStoreFs(params?: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** In-memory store, primarily useful for tests. */
|
||||
export function createMSTeamsSsoTokenStoreMemory(): MSTeamsSsoTokenStore {
|
||||
const tokens = new Map<string, MSTeamsSsoStoredToken>();
|
||||
return {
|
||||
async get({ connectionName, userId }) {
|
||||
return tokens.get(makeMSTeamsSsoTokenStoreKey(connectionName, userId)) ?? null;
|
||||
},
|
||||
async save(token) {
|
||||
tokens.set(makeMSTeamsSsoTokenStoreKey(token.connectionName, token.userId), { ...token });
|
||||
},
|
||||
async remove({ connectionName, userId }) {
|
||||
return tokens.delete(makeMSTeamsSsoTokenStoreKey(connectionName, userId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Qa Lab tests cover docker harness plugin behavior.
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -19,6 +19,7 @@ function parseComposeServices(compose: string) {
|
||||
services?: Record<
|
||||
string,
|
||||
{
|
||||
build?: { context?: string };
|
||||
environment?: Record<string, string>;
|
||||
volumes?: string[];
|
||||
}
|
||||
@@ -156,4 +157,32 @@ describe("qa docker harness", () => {
|
||||
"docker build -t openclaw:qa-local-prebaked --build-arg OPENCLAW_EXTENSIONS=qa-channel qa-lab -f Dockerfile . @/repo/openclaw",
|
||||
]);
|
||||
});
|
||||
|
||||
it("quotes generated compose paths so shell-sensitive repo paths survive YAML parsing", async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-docker-paths-"));
|
||||
const outputDir = path.join(tempRoot, "scaffold");
|
||||
const repoRoot = path.join(tempRoot, "repo #hash");
|
||||
cleanups.push(async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
await mkdir(repoRoot, { recursive: true });
|
||||
|
||||
await writeQaDockerHarnessFiles({
|
||||
outputDir,
|
||||
repoRoot,
|
||||
gatewayToken: "qa-token",
|
||||
usePrebuiltImage: false,
|
||||
bindUiDist: true,
|
||||
});
|
||||
|
||||
const compose = await readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8");
|
||||
const services = parseComposeServices(compose);
|
||||
expect(services["qa-mock-openai"]?.build?.context).toBe("../repo #hash");
|
||||
expect(services["qa-lab"]?.volumes).toContain(
|
||||
"../repo #hash/extensions/qa-lab/web/dist:/opt/openclaw-qa-lab-ui:ro",
|
||||
);
|
||||
expect(services["openclaw-qa-gateway"]?.volumes).toContain(
|
||||
"../repo #hash:/opt/openclaw-repo:ro",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@ function toPosixRelative(fromDir: string, toPath: string): string {
|
||||
return path.relative(fromDir, toPath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function yamlDoubleQuoted(value: string) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderImageBlock(params: {
|
||||
outputDir: string;
|
||||
repoRoot: string;
|
||||
@@ -28,7 +32,7 @@ function renderImageBlock(params: {
|
||||
return ` image: ${params.imageName}\n`;
|
||||
}
|
||||
const context = toPosixRelative(params.outputDir, params.repoRoot) || ".";
|
||||
return ` build:\n context: ${context}\n dockerfile: Dockerfile\n args:\n OPENCLAW_EXTENSIONS: "qa-channel qa-lab"\n`;
|
||||
return ` build:\n context: ${yamlDoubleQuoted(context)}\n dockerfile: Dockerfile\n args:\n OPENCLAW_EXTENSIONS: "qa-channel qa-lab"\n`;
|
||||
}
|
||||
|
||||
function renderCompose(params: {
|
||||
@@ -81,7 +85,7 @@ ${imageBlock} pull_policy: never
|
||||
- "127.0.0.1:${params.qaLabPort}:${QA_LAB_INTERNAL_PORT}"
|
||||
volumes:
|
||||
- ./state:/opt/openclaw-scaffold:ro
|
||||
${params.bindUiDist ? ` - ${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro\n` : ""} healthcheck:
|
||||
${params.bindUiDist ? ` - ${yamlDoubleQuoted(`${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro`)}\n` : ""} healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
@@ -124,7 +128,7 @@ ${imageBlock} pull_policy: never
|
||||
OPENCLAW_PROFILE: ""
|
||||
volumes:
|
||||
- ./state:/opt/openclaw-scaffold:ro
|
||||
- ${repoMount}:/opt/openclaw-repo:ro
|
||||
- ${yamlDoubleQuoted(`${repoMount}:/opt/openclaw-repo:ro`)}
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runQaDockerUp } from "./docker-up.runtime.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
|
||||
type QaDockerUpDeps = NonNullable<Parameters<typeof runQaDockerUp>[1]>;
|
||||
|
||||
@@ -68,12 +69,39 @@ describe("runQaDockerUp", () => {
|
||||
expect(result.qaLabUrl).toBe("http://127.0.0.1:43124");
|
||||
expect(result.gatewayUrl).toBe("http://127.0.0.1:18889/");
|
||||
expect(result.composeFile).toBe(composeFile);
|
||||
expect(result.stopCommand).toBe(`docker compose -f ${composeFile} down`);
|
||||
expect(result.stopCommand).toBe(`docker compose -f ${shellQuote(composeFile)} down`);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("quotes the printed stop command when the compose path is shell-sensitive", async () => {
|
||||
const calls: string[] = [];
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
const outputDir = path.join(tempRoot, "mac path's qa lab");
|
||||
const repoRoot = path.resolve("/repo/openclaw");
|
||||
const composeFile = path.join(outputDir, "docker-compose.qa.yml");
|
||||
|
||||
try {
|
||||
const result = await runQaDockerUp(
|
||||
{
|
||||
repoRoot,
|
||||
outputDir,
|
||||
usePrebuiltImage: true,
|
||||
skipUiBuild: true,
|
||||
},
|
||||
createHealthyDockerDeps(calls),
|
||||
);
|
||||
|
||||
expect(result.stopCommand).toBe(`docker compose -f ${shellQuote(composeFile)} down`);
|
||||
expect(calls).toContain(
|
||||
`docker compose -f ${composeFile} down --remove-orphans @${repoRoot}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips UI build and compose --build for prebuilt images", async () => {
|
||||
const calls: string[] = [];
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
@@ -105,6 +133,77 @@ describe("runQaDockerUp", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to Corepack for the QA UI build when pnpm is unavailable", async () => {
|
||||
const calls: string[] = [];
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
const repoRoot = path.resolve("/repo/openclaw");
|
||||
const composeFile = path.join(outputDir, "docker-compose.qa.yml");
|
||||
|
||||
try {
|
||||
await runQaDockerUp(
|
||||
{
|
||||
repoRoot,
|
||||
outputDir,
|
||||
usePrebuiltImage: true,
|
||||
},
|
||||
{
|
||||
async runCommand(command, args, cwd) {
|
||||
calls.push([command, ...args, `@${cwd}`].join(" "));
|
||||
if (command === "pnpm") {
|
||||
throw Object.assign(new Error("spawn pnpm ENOENT"), { code: "ENOENT" });
|
||||
}
|
||||
if (args.join(" ").includes("ps --format json openclaw-qa-gateway")) {
|
||||
return { stdout: '{"Health":"healthy","State":"running"}\n', stderr: "" };
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
fetchImpl: vi.fn(async () => ({ ok: true })),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
`pnpm qa:lab:build @${repoRoot}`,
|
||||
`corepack pnpm qa:lab:build @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} down --remove-orphans @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} up -d @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} ps --format json openclaw-qa-gateway @${repoRoot}`,
|
||||
]);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hide real QA UI build failures behind the Corepack fallback", async () => {
|
||||
const calls: string[] = [];
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
const repoRoot = path.resolve("/repo/openclaw");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runQaDockerUp(
|
||||
{
|
||||
repoRoot,
|
||||
outputDir,
|
||||
usePrebuiltImage: true,
|
||||
},
|
||||
{
|
||||
async runCommand(command, args, cwd) {
|
||||
calls.push([command, ...args, `@${cwd}`].join(" "));
|
||||
throw Object.assign(new Error("qa lab build failed"), { code: 1 });
|
||||
},
|
||||
fetchImpl: vi.fn(async () => ({ ok: true })),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("qa lab build failed");
|
||||
|
||||
expect(calls).toEqual([`pnpm qa:lab:build @${repoRoot}`]);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a repo-root-relative default output dir when none is provided", async () => {
|
||||
const calls: string[] = [];
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-docker-root-"));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Qa Lab plugin module implements docker up behavior.
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { writeQaDockerHarnessFiles } from "./docker-harness.js";
|
||||
import {
|
||||
execCommand,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
type FetchLike,
|
||||
type RunCommand,
|
||||
} from "./docker-runtime.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
|
||||
type QaDockerUpResult = {
|
||||
outputDir: string;
|
||||
@@ -39,6 +41,37 @@ async function isQaLabDockerHealthReachable(url: string, fetchImpl: FetchLike) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingCommandError(error: unknown, command: string, seen = new Set<unknown>()): boolean {
|
||||
if (!error || seen.has(error)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(error);
|
||||
if (typeof error !== "object") {
|
||||
return formatErrorMessage(error).includes(`spawn ${command} ENOENT`);
|
||||
}
|
||||
const candidate = error as { cause?: unknown; code?: unknown; message?: unknown };
|
||||
const message = typeof candidate.message === "string" ? candidate.message : "";
|
||||
if (
|
||||
candidate.code === "ENOENT" ||
|
||||
message.includes(`spawn ${command} ENOENT`) ||
|
||||
message.includes(`${command}: command not found`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isMissingCommandError(candidate.cause, command, seen);
|
||||
}
|
||||
|
||||
async function runQaLabBuild(repoRoot: string, runCommand: RunCommand) {
|
||||
try {
|
||||
await runCommand("pnpm", ["qa:lab:build"], repoRoot);
|
||||
} catch (error) {
|
||||
if (!isMissingCommandError(error, "pnpm")) {
|
||||
throw error;
|
||||
}
|
||||
await runCommand("corepack", ["pnpm", "qa:lab:build"], repoRoot);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runQaDockerUp(
|
||||
params: {
|
||||
repoRoot?: string;
|
||||
@@ -71,7 +104,7 @@ export async function runQaDockerUp(
|
||||
const sleepImpl = deps?.sleepImpl ?? sleep;
|
||||
|
||||
if (!params.skipUiBuild) {
|
||||
await runCommand("pnpm", ["qa:lab:build"], repoRoot);
|
||||
await runQaLabBuild(repoRoot, runCommand);
|
||||
}
|
||||
|
||||
await writeQaDockerHarnessFiles({
|
||||
@@ -147,6 +180,6 @@ export async function runQaDockerUp(
|
||||
composeFile,
|
||||
qaLabUrl,
|
||||
gatewayUrl,
|
||||
stopCommand: `docker compose -f ${composeFile} down`,
|
||||
stopCommand: `docker compose -f ${shellQuote(composeFile)} down`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +204,129 @@ describe("credential lease runtime", () => {
|
||||
expect(lease.payload.driverToken).toBe("driv\u00e9r");
|
||||
});
|
||||
|
||||
it("rejects chunked convex payload markers above the configured chunk cap", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
status: "ok",
|
||||
credentialId: "cred-many-chunks",
|
||||
leaseToken: "lease-many-chunks",
|
||||
payload: {
|
||||
__openclawQaCredentialPayloadChunksV1: true,
|
||||
byteLength: 1,
|
||||
chunkCount: 3,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||
|
||||
await expect(
|
||||
acquireQaCredentialLease({
|
||||
kind: "telegram",
|
||||
source: "convex",
|
||||
role: "ci",
|
||||
env: {
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
|
||||
OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_CHUNKS: "2",
|
||||
},
|
||||
fetchImpl,
|
||||
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
|
||||
parsePayload: (payload) =>
|
||||
payload as { groupId: string; driverToken: string; sutToken: string },
|
||||
}),
|
||||
).rejects.toThrow("Chunked credential payload marker exceeds 2 chunks.");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchUrl(fetchImpl, 1)).toBe(
|
||||
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects chunked convex payload markers above the configured byte cap", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
status: "ok",
|
||||
credentialId: "cred-large-payload",
|
||||
leaseToken: "lease-large-payload",
|
||||
payload: {
|
||||
__openclawQaCredentialPayloadChunksV1: true,
|
||||
byteLength: 33,
|
||||
chunkCount: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||
|
||||
await expect(
|
||||
acquireQaCredentialLease({
|
||||
kind: "telegram",
|
||||
source: "convex",
|
||||
role: "ci",
|
||||
env: {
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
|
||||
OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES: "32",
|
||||
},
|
||||
fetchImpl,
|
||||
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
|
||||
parsePayload: (payload) =>
|
||||
payload as { groupId: string; driverToken: string; sutToken: string },
|
||||
}),
|
||||
).rejects.toThrow("Chunked credential payload marker exceeds 32 bytes.");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchUrl(fetchImpl, 1)).toBe(
|
||||
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops chunked convex payload hydration when chunk data exceeds the marker", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
status: "ok",
|
||||
credentialId: "cred-overrun",
|
||||
leaseToken: "lease-overrun",
|
||||
payload: {
|
||||
__openclawQaCredentialPayloadChunksV1: true,
|
||||
byteLength: 2,
|
||||
chunkCount: 2,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ status: "ok", data: "abc" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||
|
||||
await expect(
|
||||
acquireQaCredentialLease({
|
||||
kind: "telegram",
|
||||
source: "convex",
|
||||
role: "ci",
|
||||
env: {
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
|
||||
},
|
||||
fetchImpl,
|
||||
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
|
||||
parsePayload: (payload) =>
|
||||
payload as { groupId: string; driverToken: string; sutToken: string },
|
||||
}),
|
||||
).rejects.toThrow("Chunked credential payload exceeded declared byteLength.");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(fetchUrl(fetchImpl, 1)).toBe(
|
||||
"https://qa-cred.example.convex.site/qa-credentials/v1/payload-chunk",
|
||||
);
|
||||
expect(fetchUrl(fetchImpl, 2)).toBe(
|
||||
"https://qa-cred.example.convex.site/qa-credentials/v1/release",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults convex credential role to maintainer outside CI", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
|
||||
@@ -17,6 +17,10 @@ const DEFAULT_ENDPOINT_PREFIX = QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX;
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
|
||||
const DEFAULT_CHUNKED_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const DEFAULT_CHUNKED_PAYLOAD_MAX_CHUNKS = 4096;
|
||||
const CHUNKED_PAYLOAD_MAX_BYTES_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES";
|
||||
const CHUNKED_PAYLOAD_MAX_CHUNKS_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_CHUNKS";
|
||||
const RETRY_BACKOFF_MS = [500, 1_000, 2_000, 4_000, 5_000] as const;
|
||||
const RETRYABLE_ACQUIRE_CODES = new Set(["POOL_EXHAUSTED", "NO_CREDENTIAL_AVAILABLE"]);
|
||||
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
|
||||
@@ -55,6 +59,8 @@ type ConvexCredentialBrokerConfig = {
|
||||
httpTimeoutMs: number;
|
||||
leaseTtlMs: number;
|
||||
ownerId: string;
|
||||
payloadMaxBytes: number;
|
||||
payloadMaxChunks: number;
|
||||
payloadChunkUrl: string;
|
||||
releaseUrl: string;
|
||||
role: QaCredentialRole;
|
||||
@@ -203,6 +209,16 @@ function resolveConvexCredentialBrokerConfig(params: {
|
||||
"OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS",
|
||||
DEFAULT_HTTP_TIMEOUT_MS,
|
||||
),
|
||||
payloadMaxBytes: parsePositiveIntegerEnv(
|
||||
params.env,
|
||||
CHUNKED_PAYLOAD_MAX_BYTES_ENV,
|
||||
DEFAULT_CHUNKED_PAYLOAD_MAX_BYTES,
|
||||
),
|
||||
payloadMaxChunks: parsePositiveIntegerEnv(
|
||||
params.env,
|
||||
CHUNKED_PAYLOAD_MAX_CHUNKS_ENV,
|
||||
DEFAULT_CHUNKED_PAYLOAD_MAX_CHUNKS,
|
||||
),
|
||||
acquireUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "acquire"),
|
||||
heartbeatUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "heartbeat"),
|
||||
payloadChunkUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "payload-chunk"),
|
||||
@@ -210,7 +226,10 @@ function resolveConvexCredentialBrokerConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function parseChunkedPayloadMarker(payload: unknown) {
|
||||
function parseChunkedPayloadMarker(
|
||||
payload: unknown,
|
||||
limits: { maxBytes: number; maxChunks: number },
|
||||
) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
@@ -225,6 +244,9 @@ function parseChunkedPayloadMarker(payload: unknown) {
|
||||
) {
|
||||
throw new Error("Chunked credential payload marker has an invalid chunkCount.");
|
||||
}
|
||||
if (record.chunkCount > limits.maxChunks) {
|
||||
throw new Error(`Chunked credential payload marker exceeds ${limits.maxChunks} chunks.`);
|
||||
}
|
||||
if (
|
||||
typeof record.byteLength !== "number" ||
|
||||
!Number.isInteger(record.byteLength) ||
|
||||
@@ -232,6 +254,9 @@ function parseChunkedPayloadMarker(payload: unknown) {
|
||||
) {
|
||||
throw new Error("Chunked credential payload marker has an invalid byteLength.");
|
||||
}
|
||||
if (record.byteLength > limits.maxBytes) {
|
||||
throw new Error(`Chunked credential payload marker exceeds ${limits.maxBytes} bytes.`);
|
||||
}
|
||||
return {
|
||||
chunkCount: record.chunkCount,
|
||||
byteLength: record.byteLength,
|
||||
@@ -304,11 +329,15 @@ async function resolveConvexCredentialPayload(params: {
|
||||
fetchImpl: typeof fetch;
|
||||
kind: string;
|
||||
}) {
|
||||
const marker = parseChunkedPayloadMarker(params.acquired.payload);
|
||||
const marker = parseChunkedPayloadMarker(params.acquired.payload, {
|
||||
maxBytes: params.config.payloadMaxBytes,
|
||||
maxChunks: params.config.payloadMaxChunks,
|
||||
});
|
||||
if (!marker) {
|
||||
return params.acquired.payload;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let serializedBytes = 0;
|
||||
for (let index = 0; index < marker.chunkCount; index += 1) {
|
||||
const payload = await postConvexBroker({
|
||||
fetchImpl: params.fetchImpl,
|
||||
@@ -325,10 +354,14 @@ async function resolveConvexCredentialPayload(params: {
|
||||
},
|
||||
});
|
||||
const parsed = convexPayloadChunkSuccessSchema.parse(payload);
|
||||
serializedBytes += Buffer.byteLength(parsed.data, "utf8");
|
||||
if (serializedBytes > marker.byteLength) {
|
||||
throw new Error("Chunked credential payload exceeded declared byteLength.");
|
||||
}
|
||||
chunks.push(parsed.data);
|
||||
}
|
||||
const serialized = chunks.join("");
|
||||
if (Buffer.byteLength(serialized, "utf8") !== marker.byteLength) {
|
||||
if (serializedBytes !== marker.byteLength) {
|
||||
throw new Error("Chunked credential payload length mismatch.");
|
||||
}
|
||||
return JSON.parse(serialized) as unknown;
|
||||
|
||||
@@ -50,22 +50,19 @@ describe("qa scenario catalog", () => {
|
||||
expect(
|
||||
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
|
||||
).toEqual(requiredScenarioIds);
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution?.kind !== "flow")
|
||||
.map((scenario) => scenario.id)
|
||||
.toSorted(),
|
||||
).toStrictEqual(
|
||||
[
|
||||
"channel-message-flows",
|
||||
"control-ui-chat-flow-playwright",
|
||||
"gateway-smoke",
|
||||
"package-openclaw-for-docker",
|
||||
"plugin-lifecycle-probe",
|
||||
"qa-otel-smoke",
|
||||
"ux-matrix-evidence-dashboard",
|
||||
].toSorted(),
|
||||
const nativeExecutionScenarios = pack.scenarios.filter(
|
||||
(scenario) => scenario.execution.kind !== "flow",
|
||||
);
|
||||
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
|
||||
for (const scenario of nativeExecutionScenarios) {
|
||||
const execution = scenario.execution;
|
||||
if (execution.kind === "flow") {
|
||||
throw new Error(`expected native execution scenario: ${scenario.id}`);
|
||||
}
|
||||
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
|
||||
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
|
||||
expect(execution.flow).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution.kind === "flow")
|
||||
@@ -176,6 +173,21 @@ describe("qa scenario catalog", () => {
|
||||
expect(uxMatrix.coverage?.primary).toContain("qa.artifact-safety");
|
||||
});
|
||||
|
||||
it("loads folded HTTP API script scenarios with primary taxonomy coverage", () => {
|
||||
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.primary).toStrictEqual([
|
||||
"gateway.openai-compatible-apis",
|
||||
]);
|
||||
expect(readQaScenarioById("openai-web-search-minimal").coverage?.primary).toStrictEqual([
|
||||
"runtime.reasoning-and-cache-controls",
|
||||
]);
|
||||
expect(
|
||||
readQaScenarioById("openai-web-search-native-assertions").coverage?.primary,
|
||||
).toStrictEqual(["web-search.openai-native-web-search", "plugins.web-search-and-fetch"]);
|
||||
expect(readQaScenarioById("openwebui-openai-compatible").coverage?.primary).toStrictEqual([
|
||||
"gateway.openai-compatible-apis",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads runtime parity tier metadata for first-hour and soak lanes", () => {
|
||||
const firstHour = readQaScenarioById("runtime-first-hour-20-turn");
|
||||
const soak = readQaScenarioById("runtime-soak-100-turn");
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { validateQaEvidenceSummaryJson } from "./evidence-summary.js";
|
||||
import { readQaScenarioById, type QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
||||
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
||||
import {
|
||||
runQaTestFileScenarios,
|
||||
type QaScenarioCommandExecution,
|
||||
} from "./test-file-scenario-runner.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
const { cleanup: cleanupTempDirs, makeTempDir } = createTempDirHarness();
|
||||
|
||||
function isProcessRunning(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPid(filePath: string, timeoutMs: number) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
try {
|
||||
const pid = Number(await fs.readFile(filePath, "utf8"));
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
// retry until the process writes its pid
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`timeout waiting for pid in ${filePath}`);
|
||||
}
|
||||
|
||||
async function waitForDead(pid: number, timeoutMs: number) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`process ${pid} still alive`);
|
||||
}
|
||||
|
||||
function makeTestFileScenario(
|
||||
executionKind: "script" | "vitest" | "playwright",
|
||||
@@ -51,9 +90,10 @@ async function makeTempRepo(prefix: string) {
|
||||
|
||||
describe("qa test file scenario runner", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
await Promise.all([
|
||||
cleanupTempDirs(),
|
||||
...tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs Playwright scenarios with the repo UI e2e command and writes Playwright evidence", async () => {
|
||||
@@ -92,6 +132,7 @@ describe("qa test file scenario runner", () => {
|
||||
"--reporter=verbose",
|
||||
],
|
||||
]);
|
||||
expect(commands.map((command) => command.timeoutMs)).toEqual([undefined, undefined]);
|
||||
const evidence = validateQaEvidenceSummaryJson(
|
||||
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
|
||||
);
|
||||
@@ -168,6 +209,7 @@ describe("qa test file scenario runner", () => {
|
||||
"--reporter=verbose",
|
||||
],
|
||||
]);
|
||||
expect(commands.map((command) => command.timeoutMs)).toEqual([undefined]);
|
||||
const evidence = validateQaEvidenceSummaryJson(
|
||||
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
|
||||
);
|
||||
@@ -304,6 +346,7 @@ describe("qa test file scenario runner", () => {
|
||||
path.join(repoRoot, ".artifacts", "qa-e2e", "scenario-script", "scenario-script"),
|
||||
],
|
||||
]);
|
||||
expect(commands.map((command) => command.timeoutMs)).toEqual([30 * 60_000]);
|
||||
const evidence = validateQaEvidenceSummaryJson(
|
||||
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
|
||||
);
|
||||
@@ -335,6 +378,84 @@ describe("qa test file scenario runner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("times out script scenarios and kills descendant process groups", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const tempRoot = await makeTempDir("qa-script-timeout-");
|
||||
const scriptPath = path.join(tempRoot, "hanging-producer.ts");
|
||||
const descendantPidPath = path.join(tempRoot, "descendant.pid");
|
||||
let descendantPid: number | undefined;
|
||||
try {
|
||||
const descendantScript = [
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
"import { spawn } from 'node:child_process';",
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], { stdio: 'ignore' });`,
|
||||
`writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`,
|
||||
"process.stdout.write('script still running\\n');",
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = runQaTestFileScenarios({
|
||||
repoRoot,
|
||||
outputDir: path.join(tempRoot, "out"),
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
scenarios: [makeTestFileScenario("script", scriptPath)],
|
||||
commandTimeoutMs: 500,
|
||||
});
|
||||
descendantPid = await readPid(descendantPidPath, 2_000);
|
||||
|
||||
const result = await run;
|
||||
|
||||
expect(result.results[0]?.status).toBe("fail");
|
||||
expect(result.results[0]?.failureMessage).toMatch(/timed out after 500ms/u);
|
||||
await waitForDead(descendantPid, 2_000);
|
||||
} finally {
|
||||
if (descendantPid !== undefined && isProcessRunning(descendantPid)) {
|
||||
process.kill(descendantPid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fails script scenarios that exit cleanly after timeout termination", async () => {
|
||||
const repoRoot = process.cwd();
|
||||
const tempRoot = await makeTempDir("qa-script-timeout-clean-exit-");
|
||||
const scriptPath = path.join(tempRoot, "clean-exit-after-timeout.ts");
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
"process.stdout.write('waiting for timeout\\n');",
|
||||
"process.on('SIGTERM', () => process.exit(0));",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await runQaTestFileScenarios({
|
||||
repoRoot,
|
||||
outputDir: path.join(tempRoot, "out"),
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
scenarios: [makeTestFileScenario("script", scriptPath)],
|
||||
commandTimeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(result.results[0]?.status).toBe("fail");
|
||||
expect(result.results[0]?.failureMessage).toMatch(/timed out after 100ms/u);
|
||||
});
|
||||
|
||||
it("imports producer QA evidence artifacts from failed script scenarios", async () => {
|
||||
const repoRoot = await makeTempRepo("qa-script-failed-scenario-");
|
||||
const result = await runQaTestFileScenarios({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
|
||||
import { isRepoRootRelativeRef, toRepoRelativePath } from "./cli-paths.js";
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ export type QaTestFileScenario = QaSeedScenarioWithSource & {
|
||||
export type QaTestFileExecutionKind = "script" | "vitest" | "playwright";
|
||||
|
||||
export type QaTestFileScenarioRunParams = {
|
||||
commandTimeoutMs?: number;
|
||||
evidenceMode?: QaScorecardEvidenceMode;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
outputDir: string;
|
||||
@@ -46,10 +48,12 @@ export type QaScenarioCommandExecution = {
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type QaScenarioCommandResult = {
|
||||
exitCode: number;
|
||||
failureMessage?: string;
|
||||
signal?: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
@@ -86,6 +90,11 @@ type QaTestFileRunnerDefinition = {
|
||||
buildSteps(scenario: QaTestFileScenario, context: { outputDir: string }): QaScenarioCommandStep[];
|
||||
};
|
||||
|
||||
const DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
||||
const QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS = 2_000;
|
||||
const QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS = 500;
|
||||
const QA_TEST_FILE_COMMAND_PARENT_SIGNALS = ["SIGINT", "SIGTERM"] as const;
|
||||
|
||||
export function isQaTestFileScenario(
|
||||
scenario: QaSeedScenarioWithSource,
|
||||
): scenario is QaTestFileScenario {
|
||||
@@ -177,31 +186,203 @@ function formatCommand(step: QaScenarioCommandStep) {
|
||||
return [step.command, ...step.args].map(shellQuote).join(" ");
|
||||
}
|
||||
|
||||
function killQaScenarioWindowsProcessTree(pid: number | undefined, signal: NodeJS.Signals) {
|
||||
if (pid === undefined) {
|
||||
return false;
|
||||
}
|
||||
const args = ["/pid", String(pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
try {
|
||||
const killer = spawn("taskkill", args, {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
killer.on("error", () => undefined);
|
||||
killer.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runQaScenarioCommand(
|
||||
execution: QaScenarioCommandExecution,
|
||||
): Promise<QaScenarioCommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
const child = spawn(execution.command, execution.args, {
|
||||
cwd: execution.cwd,
|
||||
detached: useProcessGroup,
|
||||
env: execution.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const timeoutMs = execution.timeoutMs;
|
||||
let forceKillTimer: NodeJS.Timeout | undefined;
|
||||
let forceSettleTimer: NodeJS.Timeout | undefined;
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let timeoutTimer: NodeJS.Timeout | undefined;
|
||||
const readOutput = () => ({
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
});
|
||||
const commandLabel = () => path.basename(execution.command);
|
||||
const clearForcedTimers = () => {
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = undefined;
|
||||
}
|
||||
if (forceSettleTimer) {
|
||||
clearTimeout(forceSettleTimer);
|
||||
forceSettleTimer = undefined;
|
||||
}
|
||||
};
|
||||
const clearTimers = () => {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
timeoutTimer = undefined;
|
||||
}
|
||||
clearForcedTimers();
|
||||
};
|
||||
const signalChild = (signal: NodeJS.Signals) => {
|
||||
if (useProcessGroup && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// The process group may already be gone; fall back to the direct child.
|
||||
}
|
||||
}
|
||||
if (!useProcessGroup && process.platform === "win32") {
|
||||
if (killQaScenarioWindowsProcessTree(child.pid, signal)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
};
|
||||
const handleParentExit = () => {
|
||||
signalChild("SIGKILL");
|
||||
};
|
||||
const removeParentSignalHandlers = () => {
|
||||
for (const signal of QA_TEST_FILE_COMMAND_PARENT_SIGNALS) {
|
||||
process.removeListener(signal, handleParentSignal);
|
||||
}
|
||||
};
|
||||
const cleanupParentHandlers = () => {
|
||||
removeParentSignalHandlers();
|
||||
process.removeListener("exit", handleParentExit);
|
||||
};
|
||||
const handleParentSignal = (signal: (typeof QA_TEST_FILE_COMMAND_PARENT_SIGNALS)[number]) => {
|
||||
removeParentSignalHandlers();
|
||||
signalChild(signal);
|
||||
scheduleForcedCleanup({
|
||||
exitCode: 1,
|
||||
failureMessage: `${commandLabel()} interrupted by ${signal}`,
|
||||
signal,
|
||||
});
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
const isProcessGroupRunning = () => {
|
||||
if (!useProcessGroup || !child.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
};
|
||||
const finish = (
|
||||
result: Pick<QaScenarioCommandResult, "exitCode" | "failureMessage" | "signal">,
|
||||
) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimers();
|
||||
cleanupParentHandlers();
|
||||
resolve({
|
||||
...result,
|
||||
...readOutput(),
|
||||
});
|
||||
};
|
||||
const scheduleForcedCleanup = (
|
||||
result: Pick<QaScenarioCommandResult, "exitCode" | "failureMessage" | "signal">,
|
||||
) => {
|
||||
if (forceKillTimer || forceSettleTimer) {
|
||||
return;
|
||||
}
|
||||
forceKillTimer = setTimeout(() => {
|
||||
forceKillTimer = undefined;
|
||||
signalChild("SIGKILL");
|
||||
forceSettleTimer = setTimeout(() => {
|
||||
forceSettleTimer = undefined;
|
||||
const stillRunning = isProcessGroupRunning();
|
||||
const failureMessage =
|
||||
result.failureMessage ??
|
||||
(stillRunning ? `${commandLabel()} left background processes running` : undefined);
|
||||
finish({
|
||||
exitCode: stillRunning ? 1 : result.exitCode,
|
||||
signal: result.signal,
|
||||
...(failureMessage ? { failureMessage } : {}),
|
||||
});
|
||||
}, QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS);
|
||||
}, QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS);
|
||||
};
|
||||
timeoutTimer =
|
||||
timeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timeoutTimer = undefined;
|
||||
timedOut = true;
|
||||
signalChild("SIGTERM");
|
||||
scheduleForcedCleanup({
|
||||
exitCode: 1,
|
||||
failureMessage: `${commandLabel()} timed out after ${timeoutMs}ms`,
|
||||
signal: null,
|
||||
});
|
||||
}, timeoutMs);
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout.push(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr.push(chunk);
|
||||
});
|
||||
child.on("error", reject);
|
||||
process.once("exit", handleParentExit);
|
||||
for (const signal of QA_TEST_FILE_COMMAND_PARENT_SIGNALS) {
|
||||
process.once(signal, handleParentSignal);
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
clearTimers();
|
||||
cleanupParentHandlers();
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (exitCode, signal) => {
|
||||
resolve({
|
||||
exitCode: exitCode ?? (signal ? 1 : 0),
|
||||
if (!timedOut && timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
timeoutTimer = undefined;
|
||||
}
|
||||
const result = {
|
||||
exitCode: timedOut ? 1 : (exitCode ?? (signal ? 1 : 0)),
|
||||
signal,
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
});
|
||||
...(timedOut ? { failureMessage: `${commandLabel()} timed out after ${timeoutMs}ms` } : {}),
|
||||
};
|
||||
if (timedOut && !useProcessGroup && (forceKillTimer || forceSettleTimer)) {
|
||||
return;
|
||||
}
|
||||
if (isProcessGroupRunning()) {
|
||||
if (!timedOut) {
|
||||
signalChild("SIGTERM");
|
||||
}
|
||||
scheduleForcedCleanup(result);
|
||||
return;
|
||||
}
|
||||
finish(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -219,6 +400,7 @@ function buildScenarioEvidenceTarget(scenario: QaTestFileScenario) {
|
||||
}
|
||||
|
||||
async function runScenarioCommandSteps(params: {
|
||||
commandTimeoutMs: number;
|
||||
env: NodeJS.ProcessEnv;
|
||||
outputDir: string;
|
||||
repoRoot: string;
|
||||
@@ -233,11 +415,14 @@ async function runScenarioCommandSteps(params: {
|
||||
for (const step of params.steps) {
|
||||
logChunks.push(`$ ${formatCommand(step)}\n`);
|
||||
try {
|
||||
const timeoutMs =
|
||||
params.scenario.execution.kind === "script" ? params.commandTimeoutMs : undefined;
|
||||
const result = await params.runCommand({
|
||||
command: step.command,
|
||||
args: step.args,
|
||||
cwd: params.repoRoot,
|
||||
env: params.env,
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
});
|
||||
if (result.stdout) {
|
||||
logChunks.push(result.stdout);
|
||||
@@ -245,10 +430,12 @@ async function runScenarioCommandSteps(params: {
|
||||
if (result.stderr) {
|
||||
logChunks.push(result.stderr);
|
||||
}
|
||||
if (result.exitCode !== 0 || result.signal) {
|
||||
failureMessage = result.signal
|
||||
? `${path.basename(step.command)} terminated by ${result.signal}`
|
||||
: `${path.basename(step.command)} exited with ${result.exitCode}`;
|
||||
if (result.failureMessage || result.exitCode !== 0 || result.signal) {
|
||||
failureMessage =
|
||||
result.failureMessage ??
|
||||
(result.signal
|
||||
? `${path.basename(step.command)} terminated by ${result.signal}`
|
||||
: `${path.basename(step.command)} exited with ${result.exitCode}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -271,6 +458,7 @@ async function runScenarioCommandSteps(params: {
|
||||
|
||||
async function runQaTestFileScenario(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
commandTimeoutMs: number;
|
||||
outputDir: string;
|
||||
repoRoot: string;
|
||||
runCommand: QaScenarioCommandRunner;
|
||||
@@ -556,6 +744,10 @@ export async function runQaTestFileScenarios(
|
||||
}
|
||||
await fs.mkdir(params.outputDir, { recursive: true });
|
||||
const runCommand = params.runCommand ?? runQaScenarioCommand;
|
||||
const commandTimeoutMs = resolvePositiveTimerTimeoutMs(
|
||||
params.commandTimeoutMs,
|
||||
DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS,
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
...params.env,
|
||||
@@ -565,6 +757,7 @@ export async function runQaTestFileScenarios(
|
||||
results.push(
|
||||
await runQaTestFileScenario({
|
||||
env,
|
||||
commandTimeoutMs,
|
||||
outputDir: params.outputDir,
|
||||
repoRoot: params.repoRoot,
|
||||
runCommand,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Qa Matrix plugin module implements cli paths behavior.
|
||||
import path from "node:path";
|
||||
import { assertNoSymlinkParents, pathScope } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: string) {
|
||||
if (!outputDir) {
|
||||
@@ -15,3 +16,33 @@ export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: strin
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertRepoRelativePath(repoRoot: string, targetPath: string, label: string) {
|
||||
const relative = path.relative(repoRoot, targetPath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} must stay within the repo root.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureRepoBoundDirectory(repoRoot: string, targetDir: string, label: string) {
|
||||
const repoRootResolved = path.resolve(repoRoot);
|
||||
const targetResolved = path.resolve(targetDir);
|
||||
assertRepoRelativePath(repoRootResolved, targetResolved, label);
|
||||
try {
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: repoRootResolved,
|
||||
targetPath: targetResolved,
|
||||
messagePrefix: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("symlink")) {
|
||||
throw new Error(`${label} must not traverse symlinks.`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await pathScope(repoRootResolved, { label }).ensureDir(targetResolved);
|
||||
if (!result.ok) {
|
||||
throw new Error(`${label} must stay within the repo root.`);
|
||||
}
|
||||
return result.path;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Qa Matrix tests cover cli plugin behavior.
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -123,6 +123,28 @@ describe("matrix qa cli runtime", () => {
|
||||
await expectPathMissing(outputPath);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects output dirs that traverse repo-local symlinks",
|
||||
async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
|
||||
const externalOutputRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-external-"));
|
||||
tmpDirs.push(repoRoot, externalOutputRoot);
|
||||
await mkdir(path.join(repoRoot, ".artifacts"), { recursive: true });
|
||||
await symlink(externalOutputRoot, path.join(repoRoot, ".artifacts", "qa-e2e"));
|
||||
|
||||
await expect(
|
||||
runQaMatrixCommand({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/matrix",
|
||||
providerMode: "mock-openai",
|
||||
credentialSource: "env",
|
||||
}),
|
||||
).rejects.toThrow("Matrix QA output dir must not traverse symlinks.");
|
||||
|
||||
expect(runMatrixQaLive).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the Matrix QA failure when output log cleanup also fails", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
|
||||
tmpDirs.push(repoRoot);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
printLiveTransportQaArtifacts,
|
||||
startLiveTransportQaOutputTee,
|
||||
} from "openclaw/plugin-sdk/qa-runtime";
|
||||
import { ensureRepoBoundDirectory } from "./cli-paths.js";
|
||||
import { runMatrixQaLive } from "./runners/contract/runtime.js";
|
||||
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
|
||||
import { resolveLiveTransportQaRunOptions } from "./shared/live-transport-cli.runtime.js";
|
||||
@@ -57,12 +58,18 @@ export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
const outputTee = await createMatrixQaCommandOutputTee(runOptions.outputDir);
|
||||
const outputDir = await ensureRepoBoundDirectory(
|
||||
runOptions.repoRoot,
|
||||
runOptions.outputDir,
|
||||
"Matrix QA output dir",
|
||||
);
|
||||
const checkedRunOptions = { ...runOptions, outputDir };
|
||||
const outputTee = await createMatrixQaCommandOutputTee(checkedRunOptions.outputDir);
|
||||
let primaryError: unknown;
|
||||
let outputTeeError: unknown;
|
||||
try {
|
||||
process.stdout.write(`Matrix QA output: ${outputTee.outputPath}\n`);
|
||||
const result = await runMatrixQaLive(runOptions);
|
||||
const result = await runMatrixQaLive(checkedRunOptions);
|
||||
printLiveTransportQaArtifacts("Matrix QA", {
|
||||
report: result.reportPath,
|
||||
summary: result.summaryPath,
|
||||
|
||||
@@ -378,4 +378,57 @@ describe("Matrix QA CLI runtime", () => {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills ignored-stdio descendants after manual CLI session kill", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-session-kill-ignored-stdio-"),
|
||||
);
|
||||
const childPidPath = path.join(root, "child.pid");
|
||||
const grandchildPidPath = path.join(root, "grandchild.pid");
|
||||
let childPid: number | undefined;
|
||||
let grandchildPid: number | undefined;
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"import { spawn } from 'node:child_process';",
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
|
||||
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
|
||||
"grandchild.unref();",
|
||||
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
|
||||
"process.on('SIGTERM', () => process.exit(0));",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const session = startMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await waitForFile(grandchildPidPath, 2_000);
|
||||
await sleep(300);
|
||||
|
||||
session.kill();
|
||||
await sleep(500);
|
||||
|
||||
childPid = Number(await readFile(childPidPath, "utf8"));
|
||||
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
expect(isProcessRunning(grandchildPid)).toBe(false);
|
||||
} finally {
|
||||
for (const pid of [grandchildPid, childPid]) {
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
process.kill(pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,6 +148,7 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
let closed = false;
|
||||
let closeError: Error | undefined;
|
||||
let closeResult: MatrixQaCliRunResult | undefined;
|
||||
let killRequested = false;
|
||||
let timedOut = false;
|
||||
let forceKillTimeout: NodeJS.Timeout | undefined;
|
||||
let forceSettleTimeout: NodeJS.Timeout | undefined;
|
||||
@@ -187,6 +188,13 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
const finishTimeout = (result: MatrixQaCliRunResult) => {
|
||||
finish(result, new Error(formatMatrixQaCliTimeoutError(result, params.timeoutMs)));
|
||||
};
|
||||
const finishResult = (result: MatrixQaCliRunResult) => {
|
||||
if (result.exitCode !== 0 && params.allowNonZero !== true) {
|
||||
finish(result, new Error(formatMatrixQaCliExitError(result)));
|
||||
return;
|
||||
}
|
||||
finish(result);
|
||||
};
|
||||
const clearForcedTimeouts = () => {
|
||||
if (forceKillTimeout) {
|
||||
clearTimeout(forceKillTimeout);
|
||||
@@ -197,16 +205,23 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
forceSettleTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killMatrixQaCliChild(child, "SIGTERM");
|
||||
const finishForcedCleanup = (result: MatrixQaCliRunResult) => {
|
||||
if (timedOut) {
|
||||
finishTimeout(result);
|
||||
return;
|
||||
}
|
||||
finishResult(result);
|
||||
};
|
||||
const scheduleForcedCleanup = () => {
|
||||
if (forceKillTimeout || forceSettleTimeout) {
|
||||
return;
|
||||
}
|
||||
forceKillTimeout = setTimeout(() => {
|
||||
forceKillTimeout = undefined;
|
||||
killMatrixQaCliChild(child, "SIGKILL");
|
||||
forceSettleTimeout = setTimeout(() => {
|
||||
forceSettleTimeout = undefined;
|
||||
finishTimeout(
|
||||
finishForcedCleanup(
|
||||
buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: 1,
|
||||
@@ -215,6 +230,12 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
);
|
||||
}, MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS);
|
||||
}, MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killMatrixQaCliChild(child, "SIGTERM");
|
||||
scheduleForcedCleanup();
|
||||
}, params.timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
||||
@@ -241,20 +262,17 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
exitCode: exitCode ?? 1,
|
||||
output: readOutput(),
|
||||
});
|
||||
if (timedOut) {
|
||||
if (timedOut || killRequested) {
|
||||
// A closed parent is not proof that detached, ignored-stdio descendants are gone.
|
||||
if (isMatrixQaCliChildProcessGroupRunning(child)) {
|
||||
return;
|
||||
}
|
||||
clearForcedTimeouts();
|
||||
finishTimeout(result);
|
||||
finishForcedCleanup(result);
|
||||
return;
|
||||
}
|
||||
clearForcedTimeouts();
|
||||
if (result.exitCode !== 0 && params.allowNonZero !== true) {
|
||||
finish(result, new Error(formatMatrixQaCliExitError(result)));
|
||||
return;
|
||||
}
|
||||
finish(result);
|
||||
finishResult(result);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -310,7 +328,10 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
},
|
||||
kill: () => {
|
||||
if (!closed) {
|
||||
clearTimeout(timeout);
|
||||
killRequested = true;
|
||||
killMatrixQaCliChild(child, "SIGTERM");
|
||||
scheduleForcedCleanup();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
// Telegram tests cover topic name cache plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearTopicNameCache,
|
||||
getTopicEntry,
|
||||
getTopicName,
|
||||
resetTopicNameCacheForTest,
|
||||
setTelegramTopicNameStoreFactoryForTest,
|
||||
topicNameCacheSize,
|
||||
updateTopicName,
|
||||
} from "./topic-name-cache.js";
|
||||
|
||||
type TopicEntry = NonNullable<Awaited<ReturnType<typeof getTopicEntry>>>;
|
||||
type TopicEntry = {
|
||||
name: string;
|
||||
iconColor?: number;
|
||||
iconCustomEmojiId?: string;
|
||||
closed?: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function topicKey(chatId: number | string, threadId: number | string): string {
|
||||
return `${chatId}:${threadId}`;
|
||||
}
|
||||
|
||||
function getStoredTopicEntry(
|
||||
stores: Map<string, Map<string, TopicEntry>>,
|
||||
chatId: number | string,
|
||||
threadId: number | string,
|
||||
): TopicEntry | undefined {
|
||||
const key = topicKey(chatId, threadId);
|
||||
for (const entries of stores.values()) {
|
||||
const entry = entries.get(key);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function topicStoreSize(stores: Map<string, Map<string, TopicEntry>>): number {
|
||||
return Array.from(stores.values(), (entries) => entries.size).reduce(
|
||||
(total, size) => total + size,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function installMemoryStores() {
|
||||
const stores = new Map<string, Map<string, TopicEntry>>();
|
||||
@@ -36,10 +65,11 @@ function installMemoryStores() {
|
||||
}
|
||||
|
||||
describe("topic-name-cache", () => {
|
||||
let stores: Map<string, Map<string, TopicEntry>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
installMemoryStores();
|
||||
await clearTopicNameCache();
|
||||
stores = installMemoryStores();
|
||||
resetTopicNameCacheForTest();
|
||||
});
|
||||
|
||||
@@ -67,13 +97,13 @@ describe("topic-name-cache", () => {
|
||||
await updateTopicName(-100123, 42, { name: "Deployments" });
|
||||
await updateTopicName(-100123, 42, { closed: true });
|
||||
await expect(getTopicName(-100123, 42)).resolves.toBe("Deployments");
|
||||
expect((await getTopicEntry(-100123, 42))?.closed).toBe(true);
|
||||
expect(getStoredTopicEntry(stores, -100123, 42)?.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("marks topic as reopened", async () => {
|
||||
await updateTopicName(-100123, 42, { name: "Deployments", closed: true });
|
||||
await updateTopicName(-100123, 42, { closed: false });
|
||||
expect((await getTopicEntry(-100123, 42))?.closed).toBe(false);
|
||||
expect(getStoredTopicEntry(stores, -100123, 42)?.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("stores icon metadata", async () => {
|
||||
@@ -82,7 +112,7 @@ describe("topic-name-cache", () => {
|
||||
iconColor: 0x6fb9f0,
|
||||
iconCustomEmojiId: "emoji123",
|
||||
});
|
||||
const entry = await getTopicEntry(-100123, 42);
|
||||
const entry = getStoredTopicEntry(stores, -100123, 42);
|
||||
expect(entry?.iconColor).toBe(0x6fb9f0);
|
||||
expect(entry?.iconCustomEmojiId).toBe("emoji123");
|
||||
});
|
||||
@@ -90,16 +120,16 @@ describe("topic-name-cache", () => {
|
||||
it("does not store entries with empty name and no prior entry", async () => {
|
||||
await updateTopicName(-100123, 42, { closed: true });
|
||||
await expect(getTopicName(-100123, 42)).resolves.toBeUndefined();
|
||||
expect(topicNameCacheSize()).toBe(0);
|
||||
expect(topicStoreSize(stores)).toBe(0);
|
||||
});
|
||||
|
||||
it("updates timestamps on write", async () => {
|
||||
vi.useFakeTimers();
|
||||
await updateTopicName(-100123, 42, { name: "A" });
|
||||
const t1 = (await getTopicEntry(-100123, 42))?.updatedAt ?? 0;
|
||||
const t1 = getStoredTopicEntry(stores, -100123, 42)?.updatedAt ?? 0;
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await updateTopicName(-100123, 42, { name: "B" });
|
||||
const t2 = (await getTopicEntry(-100123, 42))?.updatedAt ?? 0;
|
||||
const t2 = getStoredTopicEntry(stores, -100123, 42)?.updatedAt ?? 0;
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
});
|
||||
|
||||
@@ -112,7 +142,7 @@ describe("topic-name-cache", () => {
|
||||
for (let i = 0; i < 2049; i++) {
|
||||
await updateTopicName(-100000, i, { name: `Topic ${i}` });
|
||||
}
|
||||
expect(topicNameCacheSize()).toBe(2048);
|
||||
expect(topicStoreSize(stores)).toBe(2048);
|
||||
await expect(getTopicName(-100000, 0)).resolves.toBeUndefined();
|
||||
await expect(getTopicName(-100000, 2048)).resolves.toBe("Topic 2048");
|
||||
});
|
||||
@@ -127,7 +157,7 @@ describe("topic-name-cache", () => {
|
||||
await getTopicName(-100000, 1);
|
||||
await updateTopicName(-100000, 9999, { name: "Newcomer" });
|
||||
await expect(getTopicName(-100000, 1)).resolves.toBe("Active");
|
||||
expect(topicNameCacheSize()).toBe(2048);
|
||||
expect(topicStoreSize(stores)).toBe(2048);
|
||||
});
|
||||
|
||||
it("reloads persisted entries from plugin state", async () => {
|
||||
|
||||
@@ -163,12 +163,6 @@ async function hydrateTopicStoreState(state: TopicNameStoreState): Promise<void>
|
||||
await state.hydratePromise;
|
||||
}
|
||||
|
||||
async function getTopicStore(scope?: string): Promise<TopicNameStore> {
|
||||
const state = getTopicStoreState(scope);
|
||||
await hydrateTopicStoreState(state);
|
||||
return state.store;
|
||||
}
|
||||
|
||||
function nextUpdatedAt(scope?: string): number {
|
||||
const state = getTopicStoreState(scope);
|
||||
const now = Date.now();
|
||||
@@ -223,14 +217,6 @@ export async function getTopicName(
|
||||
return entry?.name;
|
||||
}
|
||||
|
||||
export async function getTopicEntry(
|
||||
chatId: number | string,
|
||||
threadId: number | string,
|
||||
scope?: string,
|
||||
): Promise<TopicEntry | undefined> {
|
||||
return (await getTopicStore(scope)).get(cacheKey(chatId, threadId));
|
||||
}
|
||||
|
||||
export async function listTelegramLegacyTopicNameCacheEntries(params: {
|
||||
persistedPath: string;
|
||||
maxEntries?: number;
|
||||
@@ -246,18 +232,6 @@ export async function listTelegramLegacyTopicNameCacheEntries(params: {
|
||||
.map(([key, entry]) => ({ key, value: entry }));
|
||||
}
|
||||
|
||||
export async function clearTopicNameCache(): Promise<void> {
|
||||
const state = getTopicNameCacheState();
|
||||
await Promise.all(
|
||||
[...state.stores.values()].map((storeState) => storeState.persistentStore.clear()),
|
||||
);
|
||||
state.stores.clear();
|
||||
}
|
||||
|
||||
export function topicNameCacheSize(scope?: string): number {
|
||||
return getTopicStoreState(scope).store.size;
|
||||
}
|
||||
|
||||
export function resetTopicNameCacheForTest(): void {
|
||||
getTopicNameCacheState().stores.clear();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Zalouser tests cover monitor.account scope plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import "./monitor.send-mocks.js";
|
||||
import "./monitor.send.test-mocks.js";
|
||||
import { testing } from "./monitor.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
|
||||
import { sendMessageZalouserMock } from "./monitor.send.test-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import "./monitor.send-mocks.js";
|
||||
import "./monitor.send.test-mocks.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { resolveZalouserAccountSync } from "./accounts.js";
|
||||
import { testing, monitorZalouserProvider } from "./monitor.js";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
sendMessageZalouserMock,
|
||||
sendSeenZalouserMock,
|
||||
sendTypingZalouserMock,
|
||||
} from "./monitor.send-mocks.js";
|
||||
} from "./monitor.send.test-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
@@ -975,6 +975,10 @@
|
||||
"types": "./dist/plugin-sdk/session-store-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/session-store-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/session-transcript-runtime": {
|
||||
"types": "./dist/plugin-sdk/session-transcript-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/session-transcript-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/sqlite-runtime": {
|
||||
"types": "./dist/plugin-sdk/sqlite-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/sqlite-runtime.js"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// OpenClaw SDK tests cover package behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, type SpawnOptionsWithoutStdio } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mjs";
|
||||
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
|
||||
|
||||
type CommandResult = {
|
||||
@@ -36,24 +37,24 @@ type PackedPackage = {
|
||||
function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; timeoutMs?: number },
|
||||
options: { cwd: string; timeoutMs?: number } & Pick<
|
||||
SpawnOptionsWithoutStdio,
|
||||
"env" | "shell" | "windowsVerbatimArguments"
|
||||
>,
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: process.env.CI ?? "true",
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
env: options.env ?? createCommandEnv(),
|
||||
shell: options.shell,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsVerbatimArguments: options.windowsVerbatimArguments,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
signalCommandProcess(child, "SIGKILL");
|
||||
reject(
|
||||
new Error(
|
||||
`command timed out after ${options.timeoutMs ?? COMMAND_TIMEOUT_MS}ms: ${[
|
||||
@@ -88,6 +89,50 @@ function runCommand(
|
||||
});
|
||||
}
|
||||
|
||||
function signalCommandProcess(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function createCommandEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
CI: process.env.CI ?? "true",
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
|
||||
};
|
||||
}
|
||||
|
||||
function runPnpmCommand(
|
||||
args: string[],
|
||||
options: { cwd: string; timeoutMs?: number },
|
||||
): Promise<CommandResult> {
|
||||
const spec = createPnpmRunnerSpawnSpec({
|
||||
cwd: options.cwd,
|
||||
env: createCommandEnv(),
|
||||
pnpmArgs: args,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const cwd = typeof spec.options.cwd === "string" ? spec.options.cwd : options.cwd;
|
||||
return runCommand(spec.command, spec.args, {
|
||||
cwd,
|
||||
env: spec.options.env,
|
||||
shell: spec.options.shell,
|
||||
timeoutMs: options.timeoutMs,
|
||||
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkspaceDependencies(
|
||||
dependencies: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
@@ -226,7 +271,7 @@ describe("OpenClaw SDK package e2e", () => {
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
for (const packageName of WORKSPACE_PACKAGE_NAMES) {
|
||||
await runCommand("pnpm", ["--filter", packageName, "build"], {
|
||||
await runPnpmCommand(["--filter", packageName, "build"], {
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
|
||||
29
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
29
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
title: OpenAI-compatible chat tools HTTP API
|
||||
|
||||
scenario:
|
||||
id: openai-compatible-chat-tools
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- gateway.openai-compatible-apis
|
||||
secondary:
|
||||
- runtime.hosted-tool-use
|
||||
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
|
||||
successCriteria:
|
||||
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
|
||||
- The generated config preserves strict positive gateway port and timeout values.
|
||||
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
|
||||
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
|
||||
docsRefs:
|
||||
- docs/gateway/protocol.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-chat-tools/client.mjs
|
||||
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
|
||||
- scripts/e2e/openai-chat-tools-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
|
||||
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.
|
||||
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
title: OpenAI web_search minimal reasoning gate
|
||||
|
||||
scenario:
|
||||
id: openai-web-search-minimal
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.reasoning-and-cache-controls
|
||||
secondary:
|
||||
- web-search.openai-native-web-search
|
||||
- tools.web-search
|
||||
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
|
||||
successCriteria:
|
||||
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
|
||||
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
|
||||
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
|
||||
- Gateway ports are parsed strictly before connecting.
|
||||
docsRefs:
|
||||
- docs/tools/web.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
|
||||
- scripts/e2e/openai-web-search-minimal-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
|
||||
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.
|
||||
@@ -0,0 +1,30 @@
|
||||
title: OpenAI native web_search request assertions
|
||||
|
||||
scenario:
|
||||
id: openai-web-search-native-assertions
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- web-search.openai-native-web-search
|
||||
- plugins.web-search-and-fetch
|
||||
secondary:
|
||||
- web-search.model-and-filter-routing
|
||||
- tools.web-search
|
||||
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
|
||||
successCriteria:
|
||||
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
|
||||
- Large request logs are scanned without missing later success requests.
|
||||
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
|
||||
- Function-shaped `web_search` is rejected as native Responses proof.
|
||||
docsRefs:
|
||||
- docs/tools/web.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
|
||||
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
|
||||
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
|
||||
summary: Vitest coverage for native OpenAI web_search request-log assertions.
|
||||
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: OpenWebUI OpenAI-compatible API probe
|
||||
|
||||
scenario:
|
||||
id: openwebui-openai-compatible
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- gateway.openai-compatible-apis
|
||||
secondary:
|
||||
- runtime.hosted-provider-turns
|
||||
- runtime.provider-specific-model-options
|
||||
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
|
||||
successCriteria:
|
||||
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
|
||||
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
|
||||
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
|
||||
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- scripts/e2e/openwebui-probe.mjs
|
||||
- scripts/e2e/openwebui-docker.sh
|
||||
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
|
||||
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.
|
||||
@@ -61,6 +61,11 @@ const GATEWAY_TIMEOUT_MS = parseStrictIntegerOption({
|
||||
min: 1,
|
||||
raw: process.env.OPENCLAW_PROMPT_GATEWAY_TIMEOUT_MS,
|
||||
});
|
||||
const GATEWAY_PARENT_SIGNAL_EXIT_CODES = new Map<NodeJS.Signals, number>([
|
||||
["SIGHUP", 129],
|
||||
["SIGINT", 130],
|
||||
["SIGTERM", 143],
|
||||
]);
|
||||
const CAPTURE_PROXY_MAX_BODY_BYTES = parseStrictIntegerOption({
|
||||
fallback: 2 * 1024 * 1024,
|
||||
label: "OPENCLAW_PROMPT_CAPTURE_MAX_BODY_BYTES",
|
||||
@@ -121,6 +126,7 @@ type TokenSource = {
|
||||
|
||||
type StoppableGatewayChild = {
|
||||
exitCode: number | null;
|
||||
pid?: number;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
once(event: "exit", listener: () => void): unknown;
|
||||
@@ -468,21 +474,45 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_API_KEY_OLD: "",
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
|
||||
const exit = await withTimeout(
|
||||
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
TIMEOUT_MS,
|
||||
() => {
|
||||
child.kill("SIGKILL");
|
||||
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
|
||||
},
|
||||
);
|
||||
const stopDirectChild = async (signal: NodeJS.Signals = "SIGKILL") => {
|
||||
signalGatewayPromptChildTree(child, signal);
|
||||
await waitForGatewayPromptChildTreeExit(
|
||||
child,
|
||||
exitPromise.then(() => undefined),
|
||||
1_500,
|
||||
);
|
||||
};
|
||||
const removeParentSignalHandlers = installGatewayPromptParentSignalHandlers(
|
||||
child,
|
||||
stopDirectChild,
|
||||
);
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const exit = await Promise.race([
|
||||
exitPromise,
|
||||
new Promise<{ code: null; signal: NodeJS.Signals }>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
void stopDirectChild("SIGKILL").finally(() => {
|
||||
resolve({ code: null, signal: "SIGKILL" });
|
||||
});
|
||||
}, TIMEOUT_MS);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
removeParentSignalHandlers();
|
||||
});
|
||||
const joinedStdout = stdout.join("");
|
||||
const joinedStderr = stderr.join("");
|
||||
return {
|
||||
@@ -535,6 +565,7 @@ async function startGatewayProcess(params: {
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_API_KEY_OLD: "",
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
@@ -554,16 +585,25 @@ async function startGatewayProcess(params: {
|
||||
};
|
||||
child.stdout.on("data", trackLogWrite);
|
||||
child.stderr.on("data", trackLogWrite);
|
||||
let stopPromise: Promise<boolean> | undefined;
|
||||
let removeParentSignalHandlers = () => {};
|
||||
const stopOnce = async (): Promise<boolean> => {
|
||||
stopPromise ??= stopGatewayPromptChild(
|
||||
child,
|
||||
logFile,
|
||||
1_500,
|
||||
1_500,
|
||||
pendingLogWrites,
|
||||
logWriteErrors,
|
||||
).finally(() => {
|
||||
removeParentSignalHandlers();
|
||||
});
|
||||
return await stopPromise;
|
||||
};
|
||||
removeParentSignalHandlers = installGatewayPromptParentSignalHandlers(child, stopOnce);
|
||||
return {
|
||||
async stop(): Promise<boolean> {
|
||||
return await stopGatewayPromptChild(
|
||||
child,
|
||||
logFile,
|
||||
1_500,
|
||||
1_500,
|
||||
pendingLogWrites,
|
||||
logWriteErrors,
|
||||
);
|
||||
return await stopOnce();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -586,20 +626,16 @@ async function stopGatewayPromptChild(
|
||||
});
|
||||
});
|
||||
if (!exited) {
|
||||
child.kill("SIGINT");
|
||||
signalGatewayPromptChildTree(child, "SIGINT");
|
||||
}
|
||||
const exitedAfterSigint = await withTimeout(
|
||||
exitPromise.then(() => true),
|
||||
const exitedAfterSigint = await waitForGatewayPromptChildTreeExit(
|
||||
child,
|
||||
exitPromise,
|
||||
sigintTimeoutMs,
|
||||
() => false,
|
||||
);
|
||||
if (!exitedAfterSigint && !exited) {
|
||||
child.kill("SIGKILL");
|
||||
await withTimeout(
|
||||
exitPromise.then(() => true),
|
||||
sigkillTimeoutMs,
|
||||
() => false,
|
||||
);
|
||||
if (!exitedAfterSigint) {
|
||||
signalGatewayPromptChildTree(child, "SIGKILL");
|
||||
await waitForGatewayPromptChildTreeExit(child, exitPromise, sigkillTimeoutMs);
|
||||
}
|
||||
const failedLogWrite = (await Promise.allSettled(pendingLogWrites)).find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -612,6 +648,93 @@ async function stopGatewayPromptChild(
|
||||
return exited;
|
||||
}
|
||||
|
||||
function installGatewayPromptParentSignalHandlers(
|
||||
child: StoppableGatewayChild,
|
||||
stopGateway: () => Promise<unknown>,
|
||||
): () => void {
|
||||
let parentSignalShutdownStarted = false;
|
||||
const handlers = new Map<NodeJS.Signals, () => void>();
|
||||
const removeHandlers = () => {
|
||||
for (const [signal, handler] of handlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
handlers.clear();
|
||||
};
|
||||
for (const signal of GATEWAY_PARENT_SIGNAL_EXIT_CODES.keys()) {
|
||||
const handler = () => {
|
||||
if (parentSignalShutdownStarted) {
|
||||
signalGatewayPromptChildTree(child, "SIGKILL");
|
||||
return;
|
||||
}
|
||||
parentSignalShutdownStarted = true;
|
||||
void stopGateway()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
removeHandlers();
|
||||
process.exit(GATEWAY_PARENT_SIGNAL_EXIT_CODES.get(signal) ?? 1);
|
||||
});
|
||||
};
|
||||
handlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
return removeHandlers;
|
||||
}
|
||||
|
||||
async function waitForGatewayPromptChildTreeExit(
|
||||
child: StoppableGatewayChild,
|
||||
exitPromise: Promise<void>,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
let leaderExited = child.exitCode !== null || child.signalCode !== null;
|
||||
const trackedExit = exitPromise.then(() => {
|
||||
leaderExited = true;
|
||||
});
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (leaderExited && !gatewayPromptChildTreeIsAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
const waitMs = Math.min(50, Math.max(0, deadline - Date.now()));
|
||||
if (leaderExited) {
|
||||
await sleep(waitMs);
|
||||
} else {
|
||||
await Promise.race([trackedExit, sleep(waitMs)]);
|
||||
}
|
||||
}
|
||||
return leaderExited && !gatewayPromptChildTreeIsAlive(child);
|
||||
}
|
||||
|
||||
function signalGatewayPromptChildTree(
|
||||
child: StoppableGatewayChild,
|
||||
signal: NodeJS.Signals,
|
||||
): boolean {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return true;
|
||||
} catch {
|
||||
return child.kill(signal);
|
||||
}
|
||||
}
|
||||
return child.kill(signal);
|
||||
}
|
||||
|
||||
function gatewayPromptChildTreeIsAlive(child: StoppableGatewayChild): boolean {
|
||||
if (process.platform === "win32" || typeof child.pid !== "number") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return !isMissingProcessError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingProcessError(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
|
||||
}
|
||||
|
||||
async function waitForGatewayReady(url: string, token: string): Promise<void> {
|
||||
const deadline = Date.now() + 45_000;
|
||||
let lastError = "gateway start timeout";
|
||||
@@ -834,6 +957,7 @@ async function main() {
|
||||
|
||||
export const testing = {
|
||||
cleanupPromptProbeTmpDir,
|
||||
installGatewayPromptParentSignalHandlers,
|
||||
matchesExtraUsage400,
|
||||
promptProbeTmpResult,
|
||||
readLogTail,
|
||||
|
||||
@@ -105,6 +105,8 @@ type CliOptions = {
|
||||
const DEFAULT_RUNS = 5;
|
||||
const DEFAULT_WARMUP = 1;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const TIMEOUT_KILL_GRACE_MS = 1_000;
|
||||
const PROCESS_GROUP_EXIT_POLL_MS = 25;
|
||||
const DEFAULT_ENTRY = "openclaw.mjs";
|
||||
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
|
||||
const VALUE_FLAGS = new Set([
|
||||
@@ -708,12 +710,16 @@ async function runSample(params: {
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let forceKillAt: number | null = null;
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const maxOutputLength = 32 * 1024 * 1024;
|
||||
|
||||
try {
|
||||
return await new Promise<Sample>((resolve) => {
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
const proc = spawn(process.execPath, nodeArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: useProcessGroup,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: runRoot,
|
||||
@@ -733,6 +739,10 @@ async function runSample(params: {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
}
|
||||
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
resolve({
|
||||
ms,
|
||||
@@ -751,18 +761,11 @@ async function runSample(params: {
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch {
|
||||
// Best-effort timeout cleanup.
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// Best-effort timeout cleanup.
|
||||
}
|
||||
}, 1_000).unref?.();
|
||||
signalSampleProcess(proc, "SIGTERM", useProcessGroup);
|
||||
forceKillAt = Date.now() + TIMEOUT_KILL_GRACE_MS;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
signalSampleProcess(proc, "SIGKILL", useProcessGroup);
|
||||
}, TIMEOUT_KILL_GRACE_MS).unref?.();
|
||||
}, params.timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
@@ -790,16 +793,27 @@ async function runSample(params: {
|
||||
});
|
||||
proc.once("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
finish({
|
||||
exitCode: code,
|
||||
signal,
|
||||
...(code === 0 && signal == null
|
||||
? {}
|
||||
: {
|
||||
stdoutTail: tailLines(stdout, 20),
|
||||
stderrTail: tailLines(stderr, 20),
|
||||
}),
|
||||
});
|
||||
const complete = () =>
|
||||
finish({
|
||||
exitCode: code,
|
||||
signal,
|
||||
...(code === 0 && signal == null
|
||||
? {}
|
||||
: {
|
||||
stdoutTail: tailLines(stdout, 20),
|
||||
stderrTail: tailLines(stderr, 20),
|
||||
}),
|
||||
});
|
||||
if (timedOut && isSampleProcessGroupAlive(proc, useProcessGroup)) {
|
||||
void finishAfterTimeoutCleanup({
|
||||
complete,
|
||||
forceKillAt,
|
||||
proc,
|
||||
useProcessGroup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
complete();
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
@@ -807,6 +821,80 @@ async function runSample(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function finishAfterTimeoutCleanup(params: {
|
||||
complete: () => void;
|
||||
forceKillAt: number | null;
|
||||
proc: ReturnType<typeof spawn>;
|
||||
useProcessGroup: boolean;
|
||||
}): Promise<void> {
|
||||
const graceRemainingMs =
|
||||
params.forceKillAt === null
|
||||
? TIMEOUT_KILL_GRACE_MS
|
||||
: Math.max(0, params.forceKillAt - Date.now());
|
||||
if (graceRemainingMs > 0) {
|
||||
await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, graceRemainingMs);
|
||||
}
|
||||
if (isSampleProcessGroupAlive(params.proc, params.useProcessGroup)) {
|
||||
signalSampleProcess(params.proc, "SIGKILL", params.useProcessGroup);
|
||||
}
|
||||
await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, TIMEOUT_KILL_GRACE_MS);
|
||||
params.complete();
|
||||
}
|
||||
|
||||
function signalSampleProcess(
|
||||
proc: ReturnType<typeof spawn>,
|
||||
signal: NodeJS.Signals,
|
||||
useProcessGroup: boolean,
|
||||
): void {
|
||||
if (!proc.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (useProcessGroup) {
|
||||
process.kill(-proc.pid, signal);
|
||||
} else {
|
||||
proc.kill(signal);
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code !== "ESRCH" && code !== "EPERM") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSampleProcessGroupAlive(
|
||||
proc: ReturnType<typeof spawn>,
|
||||
useProcessGroup: boolean,
|
||||
): boolean {
|
||||
if (!useProcessGroup || !proc.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-proc.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSampleProcessGroupExit(
|
||||
proc: ReturnType<typeof spawn>,
|
||||
useProcessGroup: boolean,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!isSampleProcessGroupAlive(proc, useProcessGroup)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !isSampleProcessGroupAlive(proc, useProcessGroup);
|
||||
}
|
||||
|
||||
async function runCase(params: {
|
||||
entry: string;
|
||||
commandCase: CommandCase;
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../apps/macos"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_DIR="$ROOT_DIR/apps/macos"
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s\n' "$(basename "$0")"
|
||||
printf 'Build, stop, and relaunch the local debug OpenClaw macOS app.\n'
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--) ;;
|
||||
*) printf 'ERROR: Unknown build-and-run-mac option: %s\n' "$arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
BUILD_PATH=".build-local"
|
||||
PRODUCT="OpenClaw"
|
||||
|
||||
@@ -505,6 +505,9 @@ function toSnakeCase(value) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
||||
const explicitPaths = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
|
||||
const args = {
|
||||
base: "origin/main",
|
||||
head: "HEAD",
|
||||
@@ -515,8 +518,8 @@ function parseArgs(argv) {
|
||||
help: false,
|
||||
paths: [],
|
||||
};
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
const parsed = parseFlagArgs(
|
||||
flagArgv,
|
||||
args,
|
||||
[
|
||||
stringFlag("--base", "base"),
|
||||
@@ -530,14 +533,16 @@ function parseArgs(argv) {
|
||||
],
|
||||
{
|
||||
onUnhandledArg(arg, target) {
|
||||
if (arg === "--") {
|
||||
return "handled";
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
target.paths.push(arg);
|
||||
return "handled";
|
||||
},
|
||||
},
|
||||
);
|
||||
parsed.paths.push(...explicitPaths);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
@@ -586,7 +591,13 @@ function printHuman(result) {
|
||||
}
|
||||
|
||||
if (isDirectRun()) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
let args;
|
||||
try {
|
||||
args = parseArgs(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
|
||||
@@ -565,6 +565,10 @@ function printSummary(timings, options) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
||||
const explicitPaths =
|
||||
separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1).map(normalizeChangedPath);
|
||||
const args = {
|
||||
base: "origin/main",
|
||||
head: "HEAD",
|
||||
@@ -575,8 +579,8 @@ function parseArgs(argv) {
|
||||
help: false,
|
||||
paths: [],
|
||||
};
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
const parsed = parseFlagArgs(
|
||||
flagArgv,
|
||||
args,
|
||||
[
|
||||
stringFlag("--base", "base"),
|
||||
@@ -590,14 +594,16 @@ function parseArgs(argv) {
|
||||
],
|
||||
{
|
||||
onUnhandledArg(arg, target) {
|
||||
if (arg === "--") {
|
||||
return "handled";
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
target.paths.push(normalizeChangedPath(arg));
|
||||
return "handled";
|
||||
},
|
||||
},
|
||||
);
|
||||
parsed.paths.push(...explicitPaths);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
@@ -624,7 +630,13 @@ function isDirectRun() {
|
||||
|
||||
if (isDirectRun()) {
|
||||
const argv = process.argv.slice(2);
|
||||
const args = parseArgs(argv);
|
||||
let args;
|
||||
try {
|
||||
args = parseArgs(argv);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exitCode = 0;
|
||||
|
||||
@@ -228,6 +228,28 @@ export async function runKnipUnusedFiles(params = {}) {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const parentSignalHandlers = [];
|
||||
const cleanupParentSignalHandlers = () => {
|
||||
for (const { signal, handler } of parentSignalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
parentSignalHandlers.length = 0;
|
||||
};
|
||||
const relayParentSignal = (signal) => {
|
||||
const handler = () => {
|
||||
signalProcessTree(child, signal);
|
||||
signalProcessTree(child, "SIGKILL");
|
||||
cleanupParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
parentSignalHandlers.push({ signal, handler });
|
||||
process.once(signal, handler);
|
||||
};
|
||||
if (process.platform !== "win32") {
|
||||
relayParentSignal("SIGINT");
|
||||
relayParentSignal("SIGTERM");
|
||||
relayParentSignal("SIGHUP");
|
||||
}
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
writeStatus(
|
||||
@@ -255,6 +277,7 @@ export async function runKnipUnusedFiles(params = {}) {
|
||||
clearTimeout(timeoutTimer);
|
||||
clearInterval(heartbeatTimer);
|
||||
clearTimeout(killTimer);
|
||||
cleanupParentSignalHandlers();
|
||||
resolve({
|
||||
...result,
|
||||
output: output.join(""),
|
||||
|
||||
@@ -471,6 +471,7 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
|
||||
abortSignal?.addEventListener("abort", abortListener, { once: true });
|
||||
const teardownProcessCleanup = installVitestProcessGroupCleanup({
|
||||
child,
|
||||
forceSignal: "SIGKILL",
|
||||
onSignal: (signal) => {
|
||||
forwardedSignal ??= signal;
|
||||
},
|
||||
@@ -566,7 +567,10 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
|
||||
cleanup();
|
||||
settled = true;
|
||||
if (forwardedSignal) {
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
signalChild("SIGKILL");
|
||||
void waitAfterForceKill().finally(() => {
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (abortController?.signal.aborted) {
|
||||
|
||||
@@ -23,10 +23,37 @@ function fail(message) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tarball = process.argv[2];
|
||||
if (!tarball || process.argv.length > 3) {
|
||||
fail(usage());
|
||||
function parseArgs(argv) {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const tarball = args[0]?.trim() ?? "";
|
||||
if (tarball === "--help" || tarball === "-h") {
|
||||
return { help: true, tarball: "" };
|
||||
}
|
||||
if (!tarball) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (tarball.startsWith("-")) {
|
||||
throw new Error(`Unknown OpenClaw package tarball check option: ${tarball}`);
|
||||
}
|
||||
const extraArg = args[1]?.trim();
|
||||
if (extraArg) {
|
||||
throw new Error(`Unexpected OpenClaw package tarball check argument: ${extraArg}`);
|
||||
}
|
||||
return { help: false, tarball };
|
||||
}
|
||||
|
||||
let cliArgs;
|
||||
try {
|
||||
cliArgs = parseArgs(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
if (cliArgs.help) {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { tarball } = cliArgs;
|
||||
if (!fs.existsSync(tarball)) {
|
||||
fail(`OpenClaw package tarball does not exist: ${tarball}`);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,37 @@ function fail(message) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageRoot = path.resolve(process.argv[2] ?? process.cwd());
|
||||
if (process.argv.length > 3) {
|
||||
fail(usage());
|
||||
function parseArgs(argv) {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const packageRootArg = args[0]?.trim() ?? "";
|
||||
if (packageRootArg === "--help" || packageRootArg === "-h") {
|
||||
return { help: true, packageRoot: "" };
|
||||
}
|
||||
if (packageRootArg.startsWith("-")) {
|
||||
throw new Error(`Unknown package dist import check option: ${packageRootArg}`);
|
||||
}
|
||||
const extraArg = args[1]?.trim();
|
||||
if (extraArg) {
|
||||
throw new Error(`Unexpected package dist import check argument: ${extraArg}`);
|
||||
}
|
||||
return {
|
||||
help: false,
|
||||
packageRoot: path.resolve(packageRootArg || process.cwd()),
|
||||
};
|
||||
}
|
||||
|
||||
let cliArgs;
|
||||
try {
|
||||
cliArgs = parseArgs(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
if (cliArgs.help) {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { packageRoot } = cliArgs;
|
||||
const distRoot = path.join(packageRoot, "dist");
|
||||
if (!fs.existsSync(distRoot)) {
|
||||
fail(`missing dist directory: ${distRoot}`);
|
||||
|
||||
@@ -484,6 +484,7 @@ export function runMeasuredCommandLive(params) {
|
||||
let settled = false;
|
||||
let forceKillTimeout = null;
|
||||
let forceKillAt = 0;
|
||||
let parentTerminationSignal = null;
|
||||
const maxBufferBytes = params.maxBufferBytes ?? COMMAND_OUTPUT_MAX_BUFFER_BYTES;
|
||||
const maxRelayBytes = params.consoleOutputMaxBytes ?? maxBufferBytes;
|
||||
const timeoutKillGraceMs = params.timeoutKillGraceMs ?? 5_000;
|
||||
@@ -530,6 +531,14 @@ export function runMeasuredCommandLive(params) {
|
||||
}
|
||||
return !processGroupAlive();
|
||||
};
|
||||
const scheduleForceKill = () => {
|
||||
forceKillAt = Date.now() + timeoutKillGraceMs;
|
||||
forceKillTimeout ??= setTimeout(() => {
|
||||
forceKillTimeout = null;
|
||||
killMeasuredProcess("SIGKILL");
|
||||
}, timeoutKillGraceMs);
|
||||
forceKillTimeout.unref?.();
|
||||
};
|
||||
const parentSignalHandlers = new Map();
|
||||
const removeParentSignalHandlers = () => {
|
||||
for (const [signal, handler] of parentSignalHandlers) {
|
||||
@@ -541,9 +550,12 @@ export function runMeasuredCommandLive(params) {
|
||||
process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
for (const signal of parentSignals) {
|
||||
const handler = () => {
|
||||
if (parentTerminationSignal) {
|
||||
return;
|
||||
}
|
||||
parentTerminationSignal = signal;
|
||||
killMeasuredProcess(signal);
|
||||
removeParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
scheduleForceKill();
|
||||
};
|
||||
parentSignalHandlers.set(signal, handler);
|
||||
process.once(signal, handler);
|
||||
@@ -637,11 +649,7 @@ export function runMeasuredCommandLive(params) {
|
||||
message: `Command timed out after ${params.timeoutMs}ms`,
|
||||
};
|
||||
killMeasuredProcess();
|
||||
forceKillAt = Date.now() + timeoutKillGraceMs;
|
||||
forceKillTimeout = setTimeout(() => {
|
||||
killMeasuredProcess("SIGKILL");
|
||||
}, timeoutKillGraceMs);
|
||||
forceKillTimeout.unref?.();
|
||||
scheduleForceKill();
|
||||
}, params.timeoutMs)
|
||||
: null;
|
||||
timeout?.unref?.();
|
||||
@@ -695,7 +703,7 @@ export function runMeasuredCommandLive(params) {
|
||||
...parseTimedMetrics(finalStderr, wallMs, mode),
|
||||
});
|
||||
};
|
||||
const finishAfterTimeoutTeardown = async (status, signal) => {
|
||||
const waitForTerminationCleanup = async () => {
|
||||
const remainingGraceMs = Math.max(0, forceKillAt - Date.now());
|
||||
if (remainingGraceMs > 0) {
|
||||
await waitForProcessGroupExit(remainingGraceMs);
|
||||
@@ -704,8 +712,30 @@ export function runMeasuredCommandLive(params) {
|
||||
killMeasuredProcess("SIGKILL");
|
||||
await waitForProcessGroupExit(100);
|
||||
}
|
||||
};
|
||||
const rethrowParentTermination = () => {
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (forceKillTimeout) {
|
||||
clearTimeout(forceKillTimeout);
|
||||
}
|
||||
removeParentSignalHandlers();
|
||||
process.kill(process.pid, parentTerminationSignal);
|
||||
};
|
||||
const finishAfterTimeoutTeardown = async (status, signal) => {
|
||||
await waitForTerminationCleanup();
|
||||
if (parentTerminationSignal) {
|
||||
rethrowParentTermination();
|
||||
return;
|
||||
}
|
||||
finish(status, signal);
|
||||
};
|
||||
const finishAfterParentTermination = async () => {
|
||||
await waitForTerminationCleanup();
|
||||
rethrowParentTermination();
|
||||
};
|
||||
child.on("error", (error) => {
|
||||
spawnError = {
|
||||
code: typeof error.code === "string" ? error.code : null,
|
||||
@@ -714,6 +744,10 @@ export function runMeasuredCommandLive(params) {
|
||||
finish(null, null);
|
||||
});
|
||||
child.on("close", (status, signal) => {
|
||||
if (parentTerminationSignal) {
|
||||
void finishAfterParentTermination();
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
void finishAfterTimeoutTeardown(status, signal);
|
||||
return;
|
||||
|
||||
@@ -19,18 +19,24 @@ function readPackageArgValue(argv, index) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return "usage: node scripts/check-plugin-npm-runtime-builds.mjs [--package extensions/<id> ...]";
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
if (args[0] === "--help" || args[0] === "-h") {
|
||||
return { help: true, packageDirs: [] };
|
||||
}
|
||||
const packageDirs = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--package") {
|
||||
packageDirs.push(readPackageArgValue(argv, index));
|
||||
packageDirs.push(readPackageArgValue(args, index));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
"usage: node scripts/check-plugin-npm-runtime-builds.mjs [--package extensions/<id> ...]",
|
||||
);
|
||||
throw new Error(usage());
|
||||
}
|
||||
return { packageDirs };
|
||||
}
|
||||
@@ -75,6 +81,10 @@ export async function checkPluginNpmRuntimeBuilds(params = {}) {
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
const rows = await checkPluginNpmRuntimeBuilds(args);
|
||||
const builtCount = rows.filter((row) => row.status === "built").length;
|
||||
console.log(`checked ${rows.length} publishable plugins; built ${builtCount} npm runtimes`);
|
||||
|
||||
@@ -29,23 +29,28 @@ function readRefOptionValue(argv, index, optionName) {
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
||||
const explicitPaths =
|
||||
separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1).map(normalizePath);
|
||||
const args = { staged: false, base: "origin/main", head: "HEAD", paths: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
} else if (arg === "--staged") {
|
||||
for (let index = 0; index < flagArgv.length; index += 1) {
|
||||
const arg = flagArgv[index];
|
||||
if (arg === "--staged") {
|
||||
args.staged = true;
|
||||
} else if (arg === "--base") {
|
||||
args.base = readRefOptionValue(argv, index, arg);
|
||||
args.base = readRefOptionValue(flagArgv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg === "--head") {
|
||||
args.head = readRefOptionValue(argv, index, arg);
|
||||
args.head = readRefOptionValue(flagArgv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
} else {
|
||||
args.paths.push(normalizePath(arg));
|
||||
}
|
||||
}
|
||||
args.paths.push(...explicitPaths);
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -164,5 +169,10 @@ export function main(argv = process.argv.slice(2)) {
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
main();
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
||||
# shellcheck source=scripts/lib/host-timeout.sh
|
||||
source "$SCRIPT_DIR/lib/host-timeout.sh"
|
||||
|
||||
if [[ "$#" -ne 1 || -z "${1// }" ]]; then
|
||||
echo "usage: $0 <image>" >&2
|
||||
exit 2
|
||||
@@ -28,14 +32,15 @@ fi
|
||||
|
||||
last_status=1
|
||||
run_docker_pull() {
|
||||
if ! command -v timeout >/dev/null 2>&1; then
|
||||
echo "timeout command not found; cannot bound Docker pull after ${timeout_seconds}s" >&2
|
||||
local timeout_bin
|
||||
if ! timeout_bin="$(openclaw_host_timeout_bin)"; then
|
||||
echo "timeout or gtimeout command not found; cannot bound Docker pull after ${timeout_seconds}s" >&2
|
||||
return 127
|
||||
fi
|
||||
if timeout --kill-after=1s 1s true >/dev/null 2>&1; then
|
||||
timeout --kill-after=30s "${timeout_seconds}s" docker pull "$image"
|
||||
if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then
|
||||
"$timeout_bin" --kill-after=30s "${timeout_seconds}s" docker pull "$image"
|
||||
else
|
||||
timeout "${timeout_seconds}s" docker pull "$image"
|
||||
"$timeout_bin" "${timeout_seconds}s" docker pull "$image"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_BUNDLE="${1:-dist/OpenClaw.app}"
|
||||
APP_BUNDLE="dist/OpenClaw.app"
|
||||
IDENTITY="${SIGN_IDENTITY:-}"
|
||||
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
||||
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
|
||||
@@ -14,7 +14,7 @@ cleanup() {
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
cat <<'HELP'
|
||||
Usage: scripts/codesign-mac-app.sh [app-bundle]
|
||||
|
||||
@@ -28,6 +28,20 @@ HELP
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
case "$1" in
|
||||
-*) echo "ERROR: Unknown codesign option: $1" >&2; exit 1 ;;
|
||||
*) APP_BUNDLE="$1"; shift ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
echo "ERROR: Unexpected codesign argument: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_BUNDLE" ]; then
|
||||
echo "App bundle not found: $APP_BUNDLE" >&2
|
||||
exit 1
|
||||
|
||||
@@ -27,6 +27,11 @@ type GlossaryEntry = {
|
||||
target: string;
|
||||
};
|
||||
|
||||
type RunProcessParentSignalState = {
|
||||
done: boolean;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
type TranslationMemoryEntry = {
|
||||
cache_key: string;
|
||||
model: string;
|
||||
@@ -102,6 +107,7 @@ const DEFAULT_PROMPT_TIMEOUT_MS = 120_000;
|
||||
const RUN_PROCESS_OUTPUT_MAX_CHARS = 1024 * 1024;
|
||||
const RUN_PROCESS_TIMEOUT_MS = 120_000;
|
||||
const RUN_PROCESS_KILL_GRACE_MS = 5_000;
|
||||
const activeRunProcessParentSignals = new Set<RunProcessParentSignalState>();
|
||||
const PROGRESS_HEARTBEAT_MS = 30_000;
|
||||
const ENV_PROVIDER = "OPENCLAW_CONTROL_UI_I18N_PROVIDER";
|
||||
const ENV_MODEL = "OPENCLAW_CONTROL_UI_I18N_MODEL";
|
||||
@@ -991,6 +997,15 @@ function formatProcessOutput(capture: ProcessOutputCapture): string {
|
||||
return `[output truncated ${capture.truncatedChars} chars; showing tail]\n${capture.text}`;
|
||||
}
|
||||
|
||||
function maybeReraiseRunProcessParentSignal(signal: NodeJS.Signals): void {
|
||||
for (const state of activeRunProcessParentSignals) {
|
||||
if (state.signal === null || !state.done) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
process.kill(process.pid, signal);
|
||||
}
|
||||
|
||||
export async function runProcess(
|
||||
executable: string,
|
||||
args: string[],
|
||||
@@ -1015,6 +1030,9 @@ export async function runProcess(
|
||||
let waitingForKillGrace = false;
|
||||
let childClosedResult: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let parentSignalPending: NodeJS.Signals | null = null;
|
||||
const parentSignalState: RunProcessParentSignalState = { done: false, signal: null };
|
||||
activeRunProcessParentSignals.add(parentSignalState);
|
||||
const parentSignalHandlers: { handler: () => void; signal: NodeJS.Signals }[] = [];
|
||||
const cleanupParentSignalHandlers = () => {
|
||||
for (const { signal, handler } of parentSignalHandlers) {
|
||||
@@ -1058,9 +1076,28 @@ export async function runProcess(
|
||||
};
|
||||
const relayParentSignal = (signal: NodeJS.Signals) => {
|
||||
const handler = () => {
|
||||
parentSignalPending = signal;
|
||||
parentSignalState.signal = signal;
|
||||
signalChild(signal);
|
||||
cleanupParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
if (!processGroupIsAlive()) {
|
||||
parentSignalState.done = true;
|
||||
maybeReraiseRunProcessParentSignal(signal);
|
||||
return;
|
||||
}
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
waitingForKillGrace = true;
|
||||
// Keep this timer ref'ed so parent signal relay can force-kill stubborn
|
||||
// process groups before re-raising the original signal.
|
||||
killTimer = setTimeout(() => {
|
||||
waitingForKillGrace = false;
|
||||
killTimer = undefined;
|
||||
signalChild("SIGKILL");
|
||||
parentSignalState.done = true;
|
||||
maybeReraiseRunProcessParentSignal(signal);
|
||||
}, killGraceMs);
|
||||
};
|
||||
parentSignalHandlers.push({ handler, signal });
|
||||
process.once(signal, handler);
|
||||
@@ -1087,9 +1124,12 @@ export async function runProcess(
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (killTimer) {
|
||||
if (!parentSignalPending && killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
if (!parentSignalPending) {
|
||||
activeRunProcessParentSignals.delete(parentSignalState);
|
||||
}
|
||||
cleanupParentSignalHandlers();
|
||||
callback();
|
||||
};
|
||||
@@ -1158,6 +1198,19 @@ export async function runProcess(
|
||||
child.stdin.end();
|
||||
}
|
||||
child.once("close", (code, signal) => {
|
||||
if (parentSignalPending) {
|
||||
if (processGroupIsAlive()) {
|
||||
childClosedResult = { code, signal };
|
||||
return;
|
||||
}
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
killTimer = undefined;
|
||||
}
|
||||
parentSignalState.done = true;
|
||||
maybeReraiseRunProcessParentSignal(parentSignalPending);
|
||||
return;
|
||||
}
|
||||
if (waitingForKillGrace && processGroupIsAlive()) {
|
||||
childClosedResult = { code, signal };
|
||||
return;
|
||||
|
||||
@@ -167,6 +167,102 @@ const shellInlineCommandOptionsWithNextValue = new Set([
|
||||
"--init-file",
|
||||
"--rcfile",
|
||||
]);
|
||||
const nodeOptionsWithNextValueBeforeScript = new Set([
|
||||
"--allow-fs-read",
|
||||
"--allow-fs-write",
|
||||
"--conditions",
|
||||
"--cpu-prof-dir",
|
||||
"--cpu-prof-interval",
|
||||
"--cpu-prof-name",
|
||||
"--debug-port",
|
||||
"--diagnostic-dir",
|
||||
"--disable-proto",
|
||||
"--disable-warning",
|
||||
"--dns-result-order",
|
||||
"--env-file",
|
||||
"--env-file-if-exists",
|
||||
"--experimental-config-file",
|
||||
"--experimental-loader",
|
||||
"--experimental-test-isolation",
|
||||
"--heap-prof-dir",
|
||||
"--heap-prof-interval",
|
||||
"--heap-prof-name",
|
||||
"--heapsnapshot-near-heap-limit",
|
||||
"--heapsnapshot-signal",
|
||||
"--icu-data-dir",
|
||||
"--import",
|
||||
"--inspect-port",
|
||||
"--inspect-publish-uid",
|
||||
"--initial-old-space-size",
|
||||
"--localstorage-file",
|
||||
"--loader",
|
||||
"--max-http-header-size",
|
||||
"--max-old-space-size",
|
||||
"--max-old-space-size-percentage",
|
||||
"--max-semi-space-size",
|
||||
"--network-family-autoselection-attempt-timeout",
|
||||
"--openssl-config",
|
||||
"--redirect-warnings",
|
||||
"--report-dir",
|
||||
"--report-directory",
|
||||
"--report-filename",
|
||||
"--report-signal",
|
||||
"--require",
|
||||
"--secure-heap",
|
||||
"--secure-heap-min",
|
||||
"--snapshot-blob",
|
||||
"--test-concurrency",
|
||||
"--test-coverage-branches",
|
||||
"--test-coverage-exclude",
|
||||
"--test-coverage-functions",
|
||||
"--test-coverage-include",
|
||||
"--test-coverage-lines",
|
||||
"--test-global-setup",
|
||||
"--test-isolation",
|
||||
"--test-name-pattern",
|
||||
"--test-reporter",
|
||||
"--test-reporter-destination",
|
||||
"--test-rerun-failures",
|
||||
"--test-shard",
|
||||
"--test-skip-pattern",
|
||||
"--test-timeout",
|
||||
"--title",
|
||||
"--tls-cipher-list",
|
||||
"--tls-keylog",
|
||||
"--trace-event-categories",
|
||||
"--trace-event-file-pattern",
|
||||
"--trace-require-module",
|
||||
"--unhandled-rejections",
|
||||
"--use-largepages",
|
||||
"--v8-pool-size",
|
||||
"--watch-kill-signal",
|
||||
"--watch-path",
|
||||
"-C",
|
||||
"-r",
|
||||
]);
|
||||
const nodeOptionsWithoutScript = new Set([
|
||||
"--build-sea",
|
||||
"--build-snapshot",
|
||||
"--build-snapshot-config",
|
||||
"--check",
|
||||
"--completion-bash",
|
||||
"--eval",
|
||||
"--experimental-sea-config",
|
||||
"--help",
|
||||
"--input-type",
|
||||
"--interactive",
|
||||
"--print",
|
||||
"--prof-process",
|
||||
"--run",
|
||||
"--v8-options",
|
||||
"--version",
|
||||
"-c",
|
||||
"-e",
|
||||
"-h",
|
||||
"-i",
|
||||
"-p",
|
||||
"-v",
|
||||
]);
|
||||
|
||||
function escapeBatchCommand(command) {
|
||||
return `${command}`.replace(cmdMetaCharactersRe, "^$1");
|
||||
@@ -842,6 +938,12 @@ function commandWordsRuntimeEntrypoint(wordsInput) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function commandWordsShellEntrypoint(wordsInput) {
|
||||
const words = normalizeExecutableWords(wordsInput);
|
||||
const first = shellWordBasename(words[0]);
|
||||
return shellInlineCommandInterpreters.has(first) ? first : "";
|
||||
}
|
||||
|
||||
function commandNeedsAwsMacosPackageManager(commandArgs) {
|
||||
if (isChangedGateCommand(commandArgs)) {
|
||||
return true;
|
||||
@@ -913,10 +1015,56 @@ function isChangedGateWords(wordsInput) {
|
||||
return (
|
||||
(words[0] === "pnpm" && words[1] === "check:changed") ||
|
||||
(words[0] === "pnpm" && words[1] === "run" && words[2] === "check:changed") ||
|
||||
(words[0] === "node" && (words[1] ?? "").endsWith("scripts/check-changed.mjs"))
|
||||
nodeScriptWord(words)?.endsWith("scripts/check-changed.mjs")
|
||||
);
|
||||
}
|
||||
|
||||
function nodeScriptWord(words) {
|
||||
if (shellWordBasename(words[0]) !== "node") {
|
||||
return "";
|
||||
}
|
||||
for (let index = 1; index < words.length; index += 1) {
|
||||
const word = words[index] ?? "";
|
||||
if (!word) {
|
||||
return "";
|
||||
}
|
||||
if (word === "--") {
|
||||
return words[index + 1] ?? "";
|
||||
}
|
||||
if (nodeOptionsWithoutScript.has(word) || nodeOptionsWithoutScriptPrefix(word)) {
|
||||
return "";
|
||||
}
|
||||
const valueMode = nodeOptionValueModeBeforeScript(word);
|
||||
if (valueMode === "next") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (valueMode === "inline") {
|
||||
continue;
|
||||
}
|
||||
if (word.startsWith("-") && word !== "-") {
|
||||
continue;
|
||||
}
|
||||
return word;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function nodeOptionsWithoutScriptPrefix(word) {
|
||||
return word.startsWith("--eval=") || word.startsWith("--print=");
|
||||
}
|
||||
|
||||
function nodeOptionValueModeBeforeScript(word) {
|
||||
if (nodeOptionsWithNextValueBeforeScript.has(word)) {
|
||||
return "next";
|
||||
}
|
||||
const equalsIndex = word.indexOf("=");
|
||||
if (equalsIndex > 0 && nodeOptionsWithNextValueBeforeScript.has(word.slice(0, equalsIndex))) {
|
||||
return "inline";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function shellInlineCommand(words) {
|
||||
const command = shellWordBasename(words[0]);
|
||||
if (!shellInlineCommandInterpreters.has(command)) {
|
||||
@@ -1591,11 +1739,7 @@ function injectRemoteChangedGateEnvironment(commandArgs) {
|
||||
}
|
||||
|
||||
function markShellChangedGateAsRemoteChild(command) {
|
||||
const missingEnv = remoteChangedGateEnv.filter((assignment) => !command.includes(assignment));
|
||||
if (missingEnv.length === 0) {
|
||||
return command;
|
||||
}
|
||||
return `export ${missingEnv.join(" ")}; ${command}`;
|
||||
return `export ${remoteChangedGateEnv.join(" ")}; ${command}`;
|
||||
}
|
||||
|
||||
function markDirectChangedGateAsRemoteChild(commandArgs) {
|
||||
@@ -1784,8 +1928,8 @@ function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {})
|
||||
'tmp_dir="$(mktemp -d)" || { release_install_lock; return 1; };',
|
||||
'pkg="node-v${node_version}-darwin-${node_arch}.tar.gz";',
|
||||
'base_url="https://nodejs.org/dist/v${node_version}";',
|
||||
'curl -fsSLo "$tmp_dir/$pkg" "$base_url/$pkg" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
'curl -fsSLo "$tmp_dir/SHASUMS256.txt" "$base_url/SHASUMS256.txt" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
'curl -fsSL --connect-timeout 10 --max-time 300 --retry 2 --retry-delay 2 -o "$tmp_dir/$pkg" "$base_url/$pkg" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
'curl -fsSL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 -o "$tmp_dir/SHASUMS256.txt" "$base_url/SHASUMS256.txt" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
'(cd "$tmp_dir" && grep " $pkg$" SHASUMS256.txt | shasum -a 256 -c -) || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
'rm -rf "$node_dir" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
'tar -xzf "$tmp_dir/$pkg" -C "$tool_root" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
|
||||
@@ -1854,7 +1998,7 @@ function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {})
|
||||
'if [ ! -x "$bun_root/bin/bun" ] || [ ! -f "$bun_ready_marker" ]; then',
|
||||
'rm -rf "$bun_root" || { status=$?; release_bun_install_lock; return "$status"; };',
|
||||
'mkdir -p "$bun_root" || { status=$?; release_bun_install_lock; return "$status"; };',
|
||||
'npm install --global --prefix "$bun_root" "bun@${bun_version}" || { status=$?; release_bun_install_lock; return "$status"; };',
|
||||
'npm install --global --prefix "$bun_root" --fetch-timeout=120000 --fetch-retries=2 --fetch-retry-mintimeout=2000 --fetch-retry-maxtimeout=15000 "bun@${bun_version}" || { status=$?; release_bun_install_lock; return "$status"; };',
|
||||
'touch "$bun_ready_marker" || { status=$?; release_bun_install_lock; return "$status"; };',
|
||||
"fi;",
|
||||
"release_bun_install_lock;",
|
||||
@@ -2013,15 +2157,14 @@ function awsMacosScriptBootstrapRequirements(script) {
|
||||
const requirements = { packageManager: false, bun: false };
|
||||
const firstLine = script.match(/^[^\r\n]*/u)?.[0] ?? "";
|
||||
if (firstLine.startsWith("#!")) {
|
||||
let words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean);
|
||||
if ((words[0] ?? "").split("/").pop() === "env") {
|
||||
words = words.slice(1);
|
||||
while ((words[0] ?? "").startsWith("-")) {
|
||||
words = words.slice(1);
|
||||
}
|
||||
}
|
||||
const words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean);
|
||||
requirements.packageManager = commandWordsNeedEntrypoint(words, awsMacosCorepackEntrypoints);
|
||||
requirements.bun = commandWordsNeedEntrypoint(words, awsMacosBunEntrypoints);
|
||||
if (commandWordsShellEntrypoint(words)) {
|
||||
const body = script.slice(firstLine.length).replace(/^\r?\n/u, "");
|
||||
requirements.packageManager ||= commandNeedsAwsMacosPackageManager([body]);
|
||||
requirements.bun ||= commandNeedsAwsMacosBun([body]);
|
||||
}
|
||||
return requirements;
|
||||
}
|
||||
requirements.packageManager = commandNeedsAwsMacosPackageManager([script]);
|
||||
@@ -2560,23 +2703,23 @@ const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.pla
|
||||
const child = spawn(childInvocation.command, childInvocation.args, {
|
||||
cwd: childCwd,
|
||||
stdio: "inherit",
|
||||
detached: process.platform !== "win32",
|
||||
env: childEnv,
|
||||
windowsVerbatimArguments: childInvocation.windowsVerbatimArguments,
|
||||
});
|
||||
const childKillGraceMs = 5_000;
|
||||
let childForceKillTimer;
|
||||
let childTreeShutdownStarted = false;
|
||||
if (fullCheckout) {
|
||||
try {
|
||||
stopFullCheckoutKeepalive = startFullCheckoutKeepalive(fullCheckout, {
|
||||
intervalMs: fullCheckoutKeepaliveIntervalMsValue,
|
||||
onMissing: () => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
void exitAfterChildTreeTermination(child, "SIGTERM", 1);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
signalChildProcessTree(child, "SIGTERM");
|
||||
cleanupOnce();
|
||||
throw error;
|
||||
}
|
||||
@@ -2588,17 +2731,17 @@ const signalExitCodes = new Map([
|
||||
["SIGTERM", 143],
|
||||
]);
|
||||
for (const signal of signalExitCodes.keys()) {
|
||||
process.once(signal, () => {
|
||||
if (!child.killed) {
|
||||
child.kill(signal);
|
||||
}
|
||||
cleanupOnce();
|
||||
process.exit(signalExitCodes.get(signal) ?? 1);
|
||||
process.on(signal, () => {
|
||||
void exitAfterChildTreeTermination(child, signal, signalExitCodes.get(signal) ?? 1);
|
||||
});
|
||||
}
|
||||
process.once("exit", cleanupOnce);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearChildForceKillTimer();
|
||||
if (childTreeShutdownStarted) {
|
||||
return;
|
||||
}
|
||||
let fullCheckoutAvailable = true;
|
||||
if (fullCheckout) {
|
||||
fullCheckoutAvailable = assertFullCheckoutAvailableBeforeExit(fullCheckout.dir);
|
||||
@@ -2612,6 +2755,10 @@ child.on("exit", (code, signal) => {
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearChildForceKillTimer();
|
||||
if (childTreeShutdownStarted) {
|
||||
return;
|
||||
}
|
||||
if (fullCheckout) {
|
||||
assertFullCheckoutAvailableBeforeExit(fullCheckout.dir);
|
||||
}
|
||||
@@ -2619,3 +2766,81 @@ child.on("error", (error) => {
|
||||
console.error(`[crabbox] failed to execute ${displayBinary}: ${error.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
|
||||
async function exitAfterChildTreeTermination(childProcess, signal, exitCode) {
|
||||
if (childTreeShutdownStarted) {
|
||||
signalChildProcessTree(childProcess, "SIGKILL");
|
||||
return;
|
||||
}
|
||||
childTreeShutdownStarted = true;
|
||||
signalChildProcessTree(childProcess, signal);
|
||||
await waitForChildTreeExit(childProcess, childKillGraceMs);
|
||||
if (childProcessTreeIsAlive(childProcess)) {
|
||||
signalChildProcessTree(childProcess, "SIGKILL");
|
||||
}
|
||||
await waitForChildTreeExit(childProcess, childKillGraceMs);
|
||||
cleanupOnce();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function signalChildProcessTree(childProcess, signal) {
|
||||
if (
|
||||
process.platform === "win32" &&
|
||||
(childProcess.exitCode !== null || childProcess.signalCode !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (process.platform !== "win32" && typeof childProcess.pid === "number") {
|
||||
process.kill(-childProcess.pid, signal);
|
||||
} else {
|
||||
childProcess.kill(signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
try {
|
||||
childProcess.kill(signal);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (signal !== "SIGKILL" && !childForceKillTimer) {
|
||||
childForceKillTimer = setTimeout(() => {
|
||||
childForceKillTimer = undefined;
|
||||
signalChildProcessTree(childProcess, "SIGKILL");
|
||||
}, childKillGraceMs);
|
||||
childForceKillTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
function clearChildForceKillTimer() {
|
||||
if (childForceKillTimer) {
|
||||
clearTimeout(childForceKillTimer);
|
||||
childForceKillTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function childProcessTreeIsAlive(childProcess) {
|
||||
if (process.platform === "win32" || typeof childProcess.pid !== "number") {
|
||||
return childProcess.exitCode === null && childProcess.signalCode === null;
|
||||
}
|
||||
try {
|
||||
process.kill(-childProcess.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForChildTreeExit(childProcess, timeoutMs) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (!childProcessTreeIsAlive(childProcess)) {
|
||||
clearChildForceKillTimer();
|
||||
return true;
|
||||
}
|
||||
await new Promise((done) => {
|
||||
setTimeout(done, 50);
|
||||
});
|
||||
}
|
||||
return !childProcessTreeIsAlive(childProcess);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
|
||||
"extensions/diffs/src/viewer-client.ts",
|
||||
"extensions/diffs/src/viewer-payload.ts",
|
||||
"extensions/matrix/src/plugin-entry.runtime.js",
|
||||
"extensions/memory-core/src/memory-tool-manager-mock.ts",
|
||||
"ui/src/ui/browser-redact.ts",
|
||||
"src/agents/subagent-registry.runtime.ts",
|
||||
"src/auto-reply/reply/get-reply.test-loader.ts",
|
||||
|
||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
||||
source "$ROOT_DIR/scripts/lib/host-timeout.sh"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
|
||||
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||
@@ -52,15 +53,7 @@ run_docker_build() {
|
||||
|
||||
run_docker_pull() {
|
||||
local image="$1"
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
if timeout --kill-after=1s 1s true >/dev/null 2>&1; then
|
||||
timeout --kill-after=30s "$DOCKER_PULL_TIMEOUT" docker pull "$image"
|
||||
else
|
||||
timeout "$DOCKER_PULL_TIMEOUT" docker pull "$image"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
docker pull "$image"
|
||||
openclaw_host_timeout_cmd "$DOCKER_PULL_TIMEOUT" docker pull "$image"
|
||||
}
|
||||
|
||||
require_local_docker_image() {
|
||||
|
||||
@@ -31,6 +31,7 @@ const DEFAULT_MAX_COMMAND_RSS_MIB = 8192;
|
||||
const DEFAULT_OUTPUT_CAPTURE_CHARS = 1024 * 1024;
|
||||
const GATEWAY_TEARDOWN_GRACE_MS = 10000;
|
||||
const GATEWAY_TEARDOWN_KILL_GRACE_MS = 2000;
|
||||
const COMMAND_PARENT_SIGNAL_KILL_GRACE_MS = 2000;
|
||||
const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 50;
|
||||
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
|
||||
@@ -56,6 +57,40 @@ const ERROR_LOG_ALLOW_PATTERNS = [
|
||||
];
|
||||
|
||||
let callGatewayModulePromise;
|
||||
const activeCommandChildren = new Set();
|
||||
const commandParentSignals =
|
||||
process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
let commandShutdownPromise;
|
||||
let commandSignalHandlersInstalled = false;
|
||||
|
||||
function installCommandSignalHandlers() {
|
||||
if (commandSignalHandlersInstalled) {
|
||||
return;
|
||||
}
|
||||
commandSignalHandlersInstalled = true;
|
||||
for (const signal of commandParentSignals) {
|
||||
process.on(signal, commandSignalHandlers.get(signal));
|
||||
}
|
||||
}
|
||||
|
||||
function removeCommandSignalHandlers() {
|
||||
if (!commandSignalHandlersInstalled) {
|
||||
return;
|
||||
}
|
||||
commandSignalHandlersInstalled = false;
|
||||
for (const signal of commandParentSignals) {
|
||||
process.off(signal, commandSignalHandlers.get(signal));
|
||||
}
|
||||
}
|
||||
|
||||
const commandSignalHandlers = new Map(
|
||||
commandParentSignals.map((signal) => [
|
||||
signal,
|
||||
() => {
|
||||
void shutdownActiveCommands(signal);
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/e2e/kitchen-sink-rpc-walk.mjs
|
||||
@@ -301,6 +336,11 @@ function formatCapturedOutput(label, buffer) {
|
||||
}
|
||||
|
||||
export function runCommand(command, args, options = {}) {
|
||||
if (commandShutdownPromise) {
|
||||
return commandShutdownPromise.then(() => {
|
||||
throw new Error(`${command} ${args.join(" ")} skipped during parent signal shutdown`);
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const config = resolveKitchenSinkRpcConfig();
|
||||
const {
|
||||
@@ -320,6 +360,8 @@ export function runCommand(command, args, options = {}) {
|
||||
...spawnOptions,
|
||||
detached: spawnOptions.detached ?? process.platform !== "win32",
|
||||
});
|
||||
activeCommandChildren.add(child);
|
||||
installCommandSignalHandlers();
|
||||
const startedAt = Date.now();
|
||||
let stdout = { text: "", truncatedChars: 0 };
|
||||
let stderr = { text: "", truncatedChars: 0 };
|
||||
@@ -393,6 +435,7 @@ export function runCommand(command, args, options = {}) {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillAt = undefined;
|
||||
releaseCommandChild(child);
|
||||
void stopResourceSampling().finally(() =>
|
||||
reject(toLintErrorObject(error, "Command failed before exit")),
|
||||
);
|
||||
@@ -402,6 +445,7 @@ export function runCommand(command, args, options = {}) {
|
||||
const finish = () => {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillAt = undefined;
|
||||
releaseCommandChild(child);
|
||||
void stopResourceSampling().then((resourceSampleFailure) => {
|
||||
if (!timedOut && status === 0) {
|
||||
if (resourceSampleFailure) {
|
||||
@@ -470,6 +514,38 @@ async function finishTimedOutCommandProcessTree(child, { forceKillAt, timeoutKil
|
||||
await waitForCommandProcessTreeExit(child, timeoutKillGraceMs);
|
||||
}
|
||||
|
||||
function releaseCommandChild(child) {
|
||||
activeCommandChildren.delete(child);
|
||||
if (activeCommandChildren.size === 0 && !commandShutdownPromise) {
|
||||
removeCommandSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdownActiveCommands(signal) {
|
||||
if (commandShutdownPromise) {
|
||||
for (const child of activeCommandChildren) {
|
||||
signalProcessGroup(child, "SIGKILL");
|
||||
}
|
||||
return commandShutdownPromise;
|
||||
}
|
||||
const children = [...activeCommandChildren];
|
||||
for (const child of children) {
|
||||
signalProcessGroup(child, signal);
|
||||
}
|
||||
commandShutdownPromise = Promise.all(
|
||||
children.map((child) =>
|
||||
finishTimedOutCommandProcessTree(child, {
|
||||
forceKillAt: Date.now() + COMMAND_PARENT_SIGNAL_KILL_GRACE_MS,
|
||||
timeoutKillGraceMs: COMMAND_PARENT_SIGNAL_KILL_GRACE_MS,
|
||||
}),
|
||||
),
|
||||
).finally(() => {
|
||||
removeCommandSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
});
|
||||
return commandShutdownPromise;
|
||||
}
|
||||
|
||||
async function waitForCommandProcessTreeExit(child, timeoutMs) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
@@ -548,7 +624,7 @@ async function resolveOpenClawCommand(runner, args, env, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonOutput(stdout) {
|
||||
export function parseJsonOutput(stdout) {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("command produced no JSON output");
|
||||
@@ -681,6 +757,9 @@ function extractBalancedJsonObjects(text) {
|
||||
if (text[index] !== "{") {
|
||||
continue;
|
||||
}
|
||||
if (!isJsonObjectRecordStart(text, index)) {
|
||||
continue;
|
||||
}
|
||||
const end = findBalancedJsonObjectEnd(text, index);
|
||||
if (end > index) {
|
||||
candidates.push(text.slice(index, end + 1));
|
||||
@@ -690,6 +769,17 @@ function extractBalancedJsonObjects(text) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isJsonObjectRecordStart(text, index) {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
let cursor = index - 1;
|
||||
while (cursor >= 0 && (text[cursor] === " " || text[cursor] === "\t")) {
|
||||
cursor -= 1;
|
||||
}
|
||||
return cursor < 0 || text[cursor] === "\n" || text[cursor] === "\r";
|
||||
}
|
||||
|
||||
function findBalancedJsonObjectEnd(text, startIndex) {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
|
||||
@@ -67,6 +67,19 @@ function parseJson(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonObjectRecordStart(text, index) {
|
||||
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
||||
const char = text[cursor];
|
||||
if (char === "\n" || char === "\r") {
|
||||
return true;
|
||||
}
|
||||
if (char !== " " && char !== "\t") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseJsonObjectsFromText(text) {
|
||||
const payloads = [];
|
||||
let start = -1;
|
||||
@@ -77,7 +90,7 @@ function parseJsonObjectsFromText(text) {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (start === -1) {
|
||||
if (char === "{") {
|
||||
if (char === "{" && isJsonObjectRecordStart(text, index)) {
|
||||
start = index;
|
||||
depth = 1;
|
||||
inString = false;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 30_000;
|
||||
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
|
||||
const usage = () => {
|
||||
console.error("Usage: assertions.mjs <run-with-timeout|assert-image-providers> [...]");
|
||||
@@ -60,6 +61,17 @@ const waitForProcessGroupExit = async (child, timeout) => {
|
||||
return !processGroupAlive(child);
|
||||
};
|
||||
|
||||
const resolveSignalExitCode = (signal) => {
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGHUP":
|
||||
return 129;
|
||||
default:
|
||||
return 143;
|
||||
}
|
||||
};
|
||||
|
||||
const runWithTimeout = async (timeout, command, commandArgs) => {
|
||||
const killGrace = parsePositiveNumber(
|
||||
process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ??
|
||||
@@ -72,8 +84,14 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let timedOut = false;
|
||||
let parentSignal = null;
|
||||
let killTimer;
|
||||
let killDeadlineAt = 0;
|
||||
const scheduleForceKill = () => {
|
||||
killDeadlineAt = Date.now() + killGrace;
|
||||
killTimer ??= setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
|
||||
killTimer.unref();
|
||||
};
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
@@ -83,12 +101,29 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
signalChild(child, "SIGTERM");
|
||||
killDeadlineAt = Date.now() + killGrace;
|
||||
killTimer = setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
|
||||
killTimer.unref();
|
||||
scheduleForceKill();
|
||||
}, timeout);
|
||||
timeoutTimer.unref();
|
||||
|
||||
const parentSignalHandlers = new Map(
|
||||
PARENT_TERMINATION_SIGNALS.map((signal) => [
|
||||
signal,
|
||||
() => {
|
||||
parentSignal ??= signal;
|
||||
signalChild(child, signal);
|
||||
scheduleForceKill();
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const [signal, handler] of parentSignalHandlers) {
|
||||
process.on(signal, handler);
|
||||
}
|
||||
const cleanupParentSignalHandlers = () => {
|
||||
for (const [signal, handler] of parentSignalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
};
|
||||
|
||||
let spawnError;
|
||||
child.on("error", (error) => {
|
||||
spawnError = error;
|
||||
@@ -98,7 +133,8 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
|
||||
});
|
||||
|
||||
clearTimeout(timeoutTimer);
|
||||
if (timedOut) {
|
||||
cleanupParentSignalHandlers();
|
||||
if (timedOut || parentSignal) {
|
||||
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
|
||||
if (remainingGraceMs > 0) {
|
||||
await waitForProcessGroupExit(child, remainingGraceMs);
|
||||
@@ -108,6 +144,11 @@ const runWithTimeout = async (timeout, command, commandArgs) => {
|
||||
await waitForProcessGroupExit(child, 100);
|
||||
}
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
if (parentSignal) {
|
||||
process.exit(resolveSignalExitCode(parentSignal));
|
||||
}
|
||||
if (timedOut) {
|
||||
console.error(`command timed out after ${timeout}ms: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -102,10 +102,44 @@ function readPluginsList() {
|
||||
`Unable to list packaged bundled plugins: ${result.stderr || result.stdout || `exit ${result.status}`}`,
|
||||
);
|
||||
}
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const payload = parsePluginListOutput(result.stdout);
|
||||
return Array.isArray(payload.plugins) ? payload.plugins : [];
|
||||
}
|
||||
|
||||
function parsePluginListOutput(stdout) {
|
||||
const trimmed = stdout.trim();
|
||||
const parsed = parseJsonValue(trimmed);
|
||||
if (parsed.ok) {
|
||||
return parsed.value;
|
||||
}
|
||||
let lastParsed;
|
||||
for (const line of trimmed.split(/\r?\n/u).toReversed()) {
|
||||
if (!line.trimStart().startsWith("{")) {
|
||||
continue;
|
||||
}
|
||||
const candidate = parseJsonValue(line);
|
||||
if (!candidate.ok) {
|
||||
continue;
|
||||
}
|
||||
lastParsed ??= candidate.value;
|
||||
if (Array.isArray(candidate.value?.plugins)) {
|
||||
return candidate.value;
|
||||
}
|
||||
}
|
||||
if (lastParsed !== undefined) {
|
||||
return lastParsed;
|
||||
}
|
||||
throw new Error(`Unable to parse packaged bundled plugin list JSON: ${trimmed}`);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function pluginRequiresConfig(pluginDir) {
|
||||
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
|
||||
@@ -641,8 +641,16 @@ function processTreeIsAlive(child) {
|
||||
}
|
||||
}
|
||||
|
||||
function signalChildProcessTree(child, signal) {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
function defaultRunTaskkill(command, args, options) {
|
||||
return childProcess.spawnSync(command, args, options);
|
||||
}
|
||||
|
||||
export function signalChildProcessTree(
|
||||
child,
|
||||
signal,
|
||||
{ platform = process.platform, runTaskkill = defaultRunTaskkill } = {},
|
||||
) {
|
||||
if (platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
@@ -651,6 +659,16 @@ function signalChildProcessTree(child, signal) {
|
||||
// the legacy direct-child kill path as the fallback.
|
||||
}
|
||||
}
|
||||
if (platform === "win32" && typeof child.pid === "number") {
|
||||
const args = ["/PID", String(child.pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill("taskkill", args, { stdio: "ignore" });
|
||||
if (!result?.error && result?.status === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch (error) {
|
||||
@@ -912,26 +930,52 @@ function parseJsonOutput(stdout) {
|
||||
if (!trimmed) {
|
||||
throw new Error("gateway call produced no JSON output");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
const jsonStart = trimmed.indexOf("{");
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(jsonStart));
|
||||
} catch {
|
||||
// Fall through to the line-oriented fallback below.
|
||||
}
|
||||
}
|
||||
const jsonLine = trimmed
|
||||
.split(/\r?\n/u)
|
||||
.toReversed()
|
||||
.find((line) => line.trim().startsWith("{"));
|
||||
if (!jsonLine) {
|
||||
throw new Error(`gateway call JSON output was not parseable:\n${trimmed}`);
|
||||
}
|
||||
return JSON.parse(jsonLine);
|
||||
const parsed = parseJsonValue(trimmed);
|
||||
if (parsed.ok) {
|
||||
return parsed.value;
|
||||
}
|
||||
|
||||
let lastParsed;
|
||||
const lines = trimmed.split(/\r?\n/u);
|
||||
for (let start = lines.length - 1; start >= 0; start -= 1) {
|
||||
if (!lines[start].trimStart().startsWith("{")) {
|
||||
continue;
|
||||
}
|
||||
let candidate = "";
|
||||
for (let end = start; end < lines.length; end += 1) {
|
||||
candidate = candidate ? `${candidate}\n${lines[end]}` : lines[end];
|
||||
const candidateParsed = parseJsonValue(candidate);
|
||||
if (!candidateParsed.ok) {
|
||||
continue;
|
||||
}
|
||||
lastParsed ??= candidateParsed.value;
|
||||
if (isGatewayJsonOutput(candidateParsed.value)) {
|
||||
return candidateParsed.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastParsed !== undefined) {
|
||||
return lastParsed;
|
||||
}
|
||||
throw new Error(`gateway call JSON output was not parseable:\n${trimmed}`);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function isGatewayJsonOutput(raw) {
|
||||
return (
|
||||
raw?.ok === false ||
|
||||
hasOwnPayloadField(raw, "result") ||
|
||||
hasOwnPayloadField(raw, "payload") ||
|
||||
hasOwnPayloadField(raw, "data")
|
||||
);
|
||||
}
|
||||
|
||||
function hasOwnPayloadField(raw, field) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs an E2E command under a pseudo-terminal.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
import { spawn } from "@lydell/node-pty";
|
||||
import { spawn as spawnPty } from "@lydell/node-pty";
|
||||
import { readPositiveIntEnv } from "./env-limits.mjs";
|
||||
|
||||
const [logPath, command, ...args] = process.argv.slice(2);
|
||||
@@ -17,6 +17,9 @@ if (!logPath || !command) {
|
||||
let exiting = false;
|
||||
let forwardedSignal = null;
|
||||
let forceKillTimer = null;
|
||||
let terminationDrainTimer = null;
|
||||
let terminationPids = [];
|
||||
let pendingExitCode = null;
|
||||
let logFailed = false;
|
||||
const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
|
||||
const outputState = {
|
||||
@@ -25,7 +28,7 @@ const outputState = {
|
||||
};
|
||||
|
||||
const log = fs.createWriteStream(logPath, { flags: "w" });
|
||||
const pty = spawn(command, args, {
|
||||
const pty = spawnPty(command, args, {
|
||||
name: process.env.TERM || "xterm-256color",
|
||||
cols: readPositiveIntEnv("COLUMNS", 120),
|
||||
rows: readPositiveIntEnv("LINES", 40),
|
||||
@@ -43,11 +46,7 @@ log.on("error", (error) => {
|
||||
process.exit(1);
|
||||
}
|
||||
if (!exiting) {
|
||||
pty.kill("SIGTERM");
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
pty.kill("SIGKILL");
|
||||
}, FORCE_KILL_MS);
|
||||
forceKillTimer.unref?.();
|
||||
terminatePtyTree("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,18 +85,23 @@ pty.onData((data) => {
|
||||
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
exiting = true;
|
||||
clearTimeout(forceKillTimer);
|
||||
if (terminationPids.length === 0) {
|
||||
clearTerminationTimers();
|
||||
}
|
||||
if (logFailed) {
|
||||
process.exit(1);
|
||||
exitWhenTerminationDrains(1);
|
||||
return;
|
||||
}
|
||||
log.end(() => {
|
||||
if (forwardedSignal) {
|
||||
process.exit(signalExitCode(forwardedSignal));
|
||||
exitWhenTerminationDrains(signalExitCode(forwardedSignal));
|
||||
return;
|
||||
}
|
||||
if (typeof exitCode === "number") {
|
||||
process.exit(exitCode);
|
||||
exitWhenTerminationDrains(exitCode);
|
||||
return;
|
||||
}
|
||||
process.exit(signal ? 128 + signal : 1);
|
||||
exitWhenTerminationDrains(signal ? 128 + signal : 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,15 +113,108 @@ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (!exiting) {
|
||||
forwardedSignal ??= signal;
|
||||
pty.kill(signal);
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
pty.kill("SIGKILL");
|
||||
}, FORCE_KILL_MS);
|
||||
forceKillTimer.unref?.();
|
||||
terminatePtyTree(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function terminatePtyTree(signal) {
|
||||
// node-pty kill() targets only pty.pid on Unix; wrapper-owned shutdowns
|
||||
// keep the captured child tree alive until ignored descendants drain.
|
||||
if (terminationPids.length === 0) {
|
||||
terminationPids = collectPtyProcessTreePids();
|
||||
}
|
||||
signalPtyProcessTree(signal);
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
signalPtyProcessTree("SIGKILL");
|
||||
}, FORCE_KILL_MS);
|
||||
forceKillTimer.unref?.();
|
||||
}
|
||||
|
||||
function exitWhenTerminationDrains(exitCode) {
|
||||
pendingExitCode = exitCode;
|
||||
if (processTreeIsAlive(terminationPids)) {
|
||||
terminationDrainTimer ??= setInterval(finishIfTerminationDrained, 25);
|
||||
return;
|
||||
}
|
||||
finishIfTerminationDrained();
|
||||
}
|
||||
|
||||
function finishIfTerminationDrained() {
|
||||
if (processTreeIsAlive(terminationPids)) {
|
||||
return;
|
||||
}
|
||||
clearTerminationTimers();
|
||||
process.exit(pendingExitCode ?? 1);
|
||||
}
|
||||
|
||||
function clearTerminationTimers() {
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
}
|
||||
if (terminationDrainTimer) {
|
||||
clearInterval(terminationDrainTimer);
|
||||
terminationDrainTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectPtyProcessTreePids() {
|
||||
if (process.platform === "win32" || typeof pty.pid !== "number") {
|
||||
return typeof pty.pid === "number" ? [pty.pid] : [];
|
||||
}
|
||||
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (ps.status !== 0) {
|
||||
return [pty.pid];
|
||||
}
|
||||
const childrenByParent = new Map();
|
||||
for (const line of ps.stdout.split("\n")) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const ppid = Number(match[2]);
|
||||
const siblings = childrenByParent.get(ppid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(ppid, siblings);
|
||||
}
|
||||
const pids = [pty.pid];
|
||||
for (const parentPid of pids) {
|
||||
for (const pid of childrenByParent.get(parentPid) ?? []) {
|
||||
pids.push(pid);
|
||||
}
|
||||
}
|
||||
return [...new Set(pids)];
|
||||
}
|
||||
|
||||
function processTreeIsAlive(pids) {
|
||||
return pids.some((pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function signalPtyProcessTree(signal) {
|
||||
if (process.platform === "win32" || terminationPids.length === 0) {
|
||||
pty.kill(signal);
|
||||
return;
|
||||
}
|
||||
for (const pid of terminationPids.toReversed()) {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function signalExitCode(signal) {
|
||||
switch (signal) {
|
||||
case "SIGHUP":
|
||||
|
||||
@@ -147,7 +147,9 @@ function escapeRegExp(value) {
|
||||
}
|
||||
|
||||
function redactDiagnosticText(text, extraSecrets = []) {
|
||||
let redacted = text;
|
||||
let redacted = text
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer <redacted>")
|
||||
.replace(/openwebui-session=[^;"\s]+/giu, "openwebui-session=<redacted>");
|
||||
for (const secret of [email, password, ...extraSecrets]) {
|
||||
if (!secret) {
|
||||
continue;
|
||||
@@ -175,6 +177,13 @@ function cookieSecretValues(cookieHeader) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function authDiagnosticSecretValues(authHeaders) {
|
||||
const authorization = typeof authHeaders.authorization === "string" ? authHeaders.authorization : "";
|
||||
const bearerToken = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : "";
|
||||
const cookie = typeof authHeaders.cookie === "string" ? authHeaders.cookie : "";
|
||||
return [bearerToken, authorization, cookie, ...cookieSecretValues(cookie)].filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchSignin() {
|
||||
return await withRequestTimeout(
|
||||
"Open WebUI signin",
|
||||
@@ -325,7 +334,11 @@ const chatJson = await fetchChatCompletion(authHeaders, targetModel, diagnosticS
|
||||
const reply =
|
||||
chatJson?.choices?.[0]?.message?.content ?? chatJson?.message?.content ?? chatJson?.content ?? "";
|
||||
if (typeof reply !== "string" || !reply.includes(expectedNonce)) {
|
||||
throw new Error(`chat reply missing nonce: ${JSON.stringify(reply)}`);
|
||||
const diagnosticReply = redactDiagnosticText(JSON.stringify(reply), [
|
||||
...diagnosticSecrets,
|
||||
...authDiagnosticSecretValues(authHeaders),
|
||||
]);
|
||||
throw new Error(`chat reply missing nonce: ${diagnosticReply}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, model: targetModel, reply }, null, 2));
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./env-limits.ts";
|
||||
export * from "./host-command.ts";
|
||||
export * from "./host-server.ts";
|
||||
export * from "./lane-runner.ts";
|
||||
export * from "./macos-users.ts";
|
||||
export * from "./package-artifact.ts";
|
||||
export * from "./parallels-vm.ts";
|
||||
export * from "./plugin-isolation.ts";
|
||||
|
||||
@@ -94,6 +94,10 @@ export async function runWindowsBackgroundPowerShell(
|
||||
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
|
||||
const nonce = `${safeLabel}-${randomUUID()}`;
|
||||
const fileBase = `openclaw-parallels-${nonce}`;
|
||||
const logLengthPrefix = `__OPENCLAW_LOG_LENGTH__:${nonce}:`;
|
||||
const logOffsetPrefix = `__OPENCLAW_LOG_OFFSET__:${nonce}:`;
|
||||
const backgroundExitPrefix = `__OPENCLAW_BACKGROUND_EXIT__:${nonce}:`;
|
||||
const backgroundDoneMarker = `__OPENCLAW_BACKGROUND_DONE__:${nonce}`;
|
||||
const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)}
|
||||
$scriptPath = "$base.ps1"
|
||||
$logPath = "$base.log"
|
||||
@@ -187,10 +191,11 @@ Write-OpenClawUtf8File $pidPath ([string]$process.Id)
|
||||
}
|
||||
lastLaunchStatus = launch.status;
|
||||
if (launch.status === 0 || launch.status === 124) {
|
||||
const materialized = waitForWindowsBackgroundMaterialized({
|
||||
const materialized = await waitForWindowsBackgroundMaterialized({
|
||||
append,
|
||||
deadline,
|
||||
pathsScript,
|
||||
pollIntervalMs,
|
||||
runCommand,
|
||||
vmName: options.vmName,
|
||||
});
|
||||
@@ -237,7 +242,7 @@ if (Test-Path $logPath) {
|
||||
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$length = $stream.Length
|
||||
"__OPENCLAW_LOG_LENGTH__:$length"
|
||||
${psSingleQuote(logLengthPrefix)} + $length
|
||||
if ($length -gt $offset) {
|
||||
[void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
|
||||
$count = [int][Math]::Min($length - $offset, ${logChunkBytes})
|
||||
@@ -245,7 +250,7 @@ if (Test-Path $logPath) {
|
||||
$read = $stream.Read($buffer, 0, $count)
|
||||
if ($read -gt 0) {
|
||||
$nextOffset = $offset + $read
|
||||
"__OPENCLAW_LOG_OFFSET__:$nextOffset"
|
||||
${psSingleQuote(logOffsetPrefix)} + $nextOffset
|
||||
[System.Text.Encoding]::UTF8.GetString($buffer, 0, $read)
|
||||
}
|
||||
}
|
||||
@@ -255,8 +260,8 @@ if (Test-Path $logPath) {
|
||||
}
|
||||
if (Test-Path $donePath) {
|
||||
$backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' }
|
||||
"__OPENCLAW_BACKGROUND_EXIT__:$backgroundExit"
|
||||
'__OPENCLAW_BACKGROUND_DONE__'
|
||||
${psSingleQuote(backgroundExitPrefix)} + $backgroundExit
|
||||
${psSingleQuote(backgroundDoneMarker)}
|
||||
if ($backgroundExit -ne '0') { exit 23 }
|
||||
exit 0
|
||||
}`),
|
||||
@@ -264,21 +269,20 @@ if (Test-Path $donePath) {
|
||||
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
|
||||
);
|
||||
appendOutput(append, poll);
|
||||
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
|
||||
if (offsetMatch) {
|
||||
lastLogOffset = Number(offsetMatch[1]);
|
||||
const offsetRaw = findControlValue(poll.stdout, logOffsetPrefix);
|
||||
if (offsetRaw) {
|
||||
lastLogOffset = Number(offsetRaw);
|
||||
}
|
||||
const lengthMatch = poll.stdout.match(/__OPENCLAW_LOG_LENGTH__:(\d+)/);
|
||||
const logLength = lengthMatch ? Number(lengthMatch[1]) : lastLogOffset;
|
||||
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
|
||||
const lengthRaw = findControlValue(poll.stdout, logLengthPrefix);
|
||||
const logLength = lengthRaw ? Number(lengthRaw) : lastLogOffset;
|
||||
if (hasControlLine(poll.stdout, backgroundDoneMarker)) {
|
||||
doneSeen = true;
|
||||
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
|
||||
if (lastLogOffset < logLength) {
|
||||
await sleep(Math.min(pollIntervalMs, 100));
|
||||
continue;
|
||||
}
|
||||
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
|
||||
const backgroundExit = exitMatch?.[1] ?? "0";
|
||||
const backgroundExit = findControlValue(poll.stdout, backgroundExitPrefix) ?? "0";
|
||||
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
|
||||
throw new Error(`${options.label} failed`);
|
||||
}
|
||||
@@ -297,13 +301,23 @@ if (Test-Path $donePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForWindowsBackgroundMaterialized(params: {
|
||||
function findControlValue(output: string, prefix: string): string | undefined {
|
||||
const line = output.split(/\r?\n/u).find((entry) => entry.startsWith(prefix));
|
||||
return line?.slice(prefix.length).trim();
|
||||
}
|
||||
|
||||
function hasControlLine(output: string, marker: string): boolean {
|
||||
return output.split(/\r?\n/u).some((entry) => entry.trimEnd() === marker);
|
||||
}
|
||||
|
||||
async function waitForWindowsBackgroundMaterialized(params: {
|
||||
append?: (chunk: string | Uint8Array) => void;
|
||||
deadline: number;
|
||||
pathsScript: string;
|
||||
pollIntervalMs: number;
|
||||
runCommand: typeof run;
|
||||
vmName: string;
|
||||
}): boolean {
|
||||
}): Promise<boolean> {
|
||||
const materializeDeadline = Math.min(Date.now() + 45_000, params.deadline);
|
||||
while (Date.now() < materializeDeadline) {
|
||||
const result = params.runCommand(
|
||||
@@ -328,6 +342,7 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) {
|
||||
if (result.stdout.includes("materialized")) {
|
||||
return true;
|
||||
}
|
||||
await sleep(Math.min(params.pollIntervalMs, Math.max(1, materializeDeadline - Date.now())));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ let timedOut = false;
|
||||
let killTimer;
|
||||
let killDeadlineAt = 0;
|
||||
let outputExceeded = false;
|
||||
let forwardedSignal;
|
||||
let forwardedSignalKillTimer;
|
||||
let forwardedSignalPostForceTimer;
|
||||
let stderrBytes = 0;
|
||||
let stdoutBytes = 0;
|
||||
|
||||
@@ -189,6 +192,43 @@ function finishTimedOutAfterCleanup() {
|
||||
}, Math.max(0, killDeadlineAt - Date.now()));
|
||||
}
|
||||
|
||||
function finishForwardedSignal() {
|
||||
if (!forwardedSignal) {
|
||||
return;
|
||||
}
|
||||
if (forwardedSignalKillTimer) {
|
||||
clearTimeout(forwardedSignalKillTimer);
|
||||
}
|
||||
if (forwardedSignalPostForceTimer) {
|
||||
clearTimeout(forwardedSignalPostForceTimer);
|
||||
}
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
}
|
||||
|
||||
function finishForwardedSignalAfterCleanup() {
|
||||
if (!forwardedSignal) {
|
||||
return;
|
||||
}
|
||||
if (!groupAlive()) {
|
||||
finishForwardedSignal();
|
||||
return;
|
||||
}
|
||||
if (forwardedSignalKillTimer) {
|
||||
return;
|
||||
}
|
||||
forwardedSignalKillTimer = setTimeout(() => {
|
||||
if (groupAlive()) {
|
||||
signalGroup("SIGKILL");
|
||||
forwardedSignalPostForceTimer = setTimeout(
|
||||
finishForwardedSignal,
|
||||
Math.max(1, Math.min(25, payload.timeoutKillGraceMs)),
|
||||
);
|
||||
} else {
|
||||
finishForwardedSignal();
|
||||
}
|
||||
}, payload.timeoutKillGraceMs);
|
||||
}
|
||||
|
||||
function forwardBounded(stream, chunk) {
|
||||
const currentBytes = stream === "stdout" ? stdoutBytes : stderrBytes;
|
||||
const nextBytes = currentBytes + chunk.byteLength;
|
||||
@@ -219,8 +259,9 @@ function forwardBounded(stream, chunk) {
|
||||
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, () => {
|
||||
forwardedSignal ||= signal;
|
||||
signalGroup(signal);
|
||||
process.kill(process.pid, signal);
|
||||
finishForwardedSignalAfterCleanup();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,6 +298,10 @@ child.on("error", (error) => {
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
if (forwardedSignal) {
|
||||
finishForwardedSignalAfterCleanup();
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
finishTimedOutAfterCleanup();
|
||||
return;
|
||||
@@ -547,18 +592,63 @@ export async function runStreaming(
|
||||
signalStreamingChild("SIGTERM");
|
||||
});
|
||||
const parentSignalHandlers = new Map<NodeJS.Signals, () => void>();
|
||||
let forwardedParentSignal: NodeJS.Signals | undefined;
|
||||
let parentSignalKillTimer: NodeJS.Timeout | undefined;
|
||||
let parentSignalPostForceTimer: NodeJS.Timeout | undefined;
|
||||
const removeParentSignalHandlers = (): void => {
|
||||
for (const [signal, handler] of parentSignalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
parentSignalHandlers.clear();
|
||||
};
|
||||
const clearParentSignalTimers = (): void => {
|
||||
if (parentSignalKillTimer) {
|
||||
clearTimeout(parentSignalKillTimer);
|
||||
parentSignalKillTimer = undefined;
|
||||
}
|
||||
if (parentSignalPostForceTimer) {
|
||||
clearTimeout(parentSignalPostForceTimer);
|
||||
parentSignalPostForceTimer = undefined;
|
||||
}
|
||||
};
|
||||
const finishParentSignal = (): void => {
|
||||
if (!forwardedParentSignal) {
|
||||
return;
|
||||
}
|
||||
clearParentSignalTimers();
|
||||
removeParentSignalHandlers();
|
||||
process.kill(process.pid, forwardedParentSignal);
|
||||
};
|
||||
const finishParentSignalAfterCleanup = (): void => {
|
||||
if (!forwardedParentSignal) {
|
||||
return;
|
||||
}
|
||||
if (!streamingProcessGroupAlive()) {
|
||||
finishParentSignal();
|
||||
return;
|
||||
}
|
||||
if (parentSignalKillTimer) {
|
||||
return;
|
||||
}
|
||||
parentSignalKillTimer = setTimeout(() => {
|
||||
if (streamingProcessGroupAlive()) {
|
||||
signalHostCommandProcess(childPid, "SIGKILL");
|
||||
parentSignalPostForceTimer = setTimeout(
|
||||
finishParentSignal,
|
||||
HOST_COMMAND_POST_FORCE_KILL_WAIT_MS,
|
||||
);
|
||||
} else {
|
||||
finishParentSignal();
|
||||
}
|
||||
}, HOST_COMMAND_TIMEOUT_KILL_GRACE_MS);
|
||||
};
|
||||
if (process.platform !== "win32" && options.timeoutMs != null) {
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
|
||||
const handler = (): void => {
|
||||
forwardedParentSignal ??= signal;
|
||||
signalHostCommandProcess(childPid, signal);
|
||||
removeParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
finishParentSignalAfterCleanup();
|
||||
};
|
||||
parentSignalHandlers.set(signal, handler);
|
||||
process.once(signal, handler);
|
||||
@@ -637,6 +727,7 @@ export async function runStreaming(
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
clearParentSignalTimers();
|
||||
removeParentSignalHandlers();
|
||||
logStream?.destroy();
|
||||
reject(error);
|
||||
@@ -646,6 +737,10 @@ export async function runStreaming(
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (forwardedParentSignal) {
|
||||
finishParentSignalAfterCleanup();
|
||||
return;
|
||||
}
|
||||
removeParentSignalHandlers();
|
||||
if (timedOut) {
|
||||
await waitForStreamingTimeoutCleanup();
|
||||
@@ -653,6 +748,7 @@ export async function runStreaming(
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
clearParentSignalTimers();
|
||||
if (logStream) {
|
||||
logStream.end();
|
||||
await finished(logStream);
|
||||
|
||||
@@ -151,11 +151,11 @@ async function waitForHostServer(
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 10_000) {
|
||||
if (child.exitCode != null) {
|
||||
if (hasHostServerChildExited(child)) {
|
||||
if (!childClosed) {
|
||||
await Promise.race([childClose, delay(HOST_SERVER_STDERR_DRAIN_MS)]);
|
||||
}
|
||||
die(`host artifact server exited early: ${stderr.trim() || `exit ${child.exitCode}`}`);
|
||||
die(`host artifact server exited early: ${stderr.trim() || formatHostServerExit(child)}`);
|
||||
}
|
||||
if (await canConnect(port)) {
|
||||
return;
|
||||
@@ -176,6 +176,10 @@ function appendBoundedOutput(previous: string, chunk: Buffer, limitBytes: number
|
||||
return combined.subarray(combined.byteLength - limitBytes).toString("utf8");
|
||||
}
|
||||
|
||||
function formatHostServerExit(child: ChildProcessWithoutNullStreams): string {
|
||||
return child.signalCode ? `signal ${child.signalCode}` : `exit ${child.exitCode ?? "unknown"}`;
|
||||
}
|
||||
|
||||
async function canConnect(port: number): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = createConnection({ host: "127.0.0.1", port });
|
||||
|
||||
@@ -510,9 +510,13 @@ run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-cer
|
||||
this.guest.bash(`
|
||||
set -e
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL ${shellQuote(url)} -o ${shellQuote(outputPath)}
|
||||
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
|
||||
url,
|
||||
)} -o ${shellQuote(outputPath)}
|
||||
else
|
||||
wget -q -O ${shellQuote(outputPath)} ${shellQuote(url)}
|
||||
wget -q --timeout=10 --read-timeout=120 --tries=3 -O ${shellQuote(outputPath)} ${shellQuote(
|
||||
url,
|
||||
)}
|
||||
fi`);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
currentRunningSnapshotInfo,
|
||||
extractLastOpenClawVersionFromLog,
|
||||
makeTempDir,
|
||||
isLikelyMacosDesktopHome,
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
parseMacosDsclUserHomeLine,
|
||||
packOpenClaw,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
@@ -690,10 +692,11 @@ exec node "$entry" ${argv}`,
|
||||
},
|
||||
).stdout.replaceAll("\r", "");
|
||||
for (const line of users.split("\n")) {
|
||||
const [user, home] = line.trim().split(/\s+/);
|
||||
const parsed = parseMacosDsclUserHomeLine(line);
|
||||
const user = parsed?.user;
|
||||
if (
|
||||
user &&
|
||||
home?.startsWith("/Users/") &&
|
||||
isLikelyMacosDesktopHome(parsed?.home) &&
|
||||
!user.startsWith("_") &&
|
||||
user !== "Shared" &&
|
||||
user !== ".localized"
|
||||
@@ -806,7 +809,9 @@ rm -f /tmp/openclaw-parallels-macos-gateway.log`);
|
||||
private installLatestRelease(): void {
|
||||
this.guestSh(
|
||||
`export OPENCLAW_NO_ONBOARD=1
|
||||
curl -fsSL ${shellQuote(this.options.installUrl)} -o /tmp/openclaw-install.sh
|
||||
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
|
||||
this.options.installUrl,
|
||||
)} -o /tmp/openclaw-install.sh
|
||||
bash /tmp/openclaw-install.sh --version ${shellQuote(this.installVersion)}
|
||||
${guestOpenClaw} --version`,
|
||||
);
|
||||
@@ -834,7 +839,9 @@ ${guestOpenClaw} --version`);
|
||||
}
|
||||
const tgzUrl = this.server.urlFor(this.artifact.path);
|
||||
this.guestSh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)}
|
||||
curl -fsSL ${shellQuote(tgzUrl)} -o /tmp/${tempName}
|
||||
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
|
||||
tgzUrl,
|
||||
)} -o /tmp/${tempName}
|
||||
${guestNpm} install -g /tmp/${tempName}
|
||||
${guestOpenClaw} --version`);
|
||||
}
|
||||
|
||||
13
scripts/e2e/parallels/macos-users.ts
Normal file
13
scripts/e2e/parallels/macos-users.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// macOS user helpers support Parallels guest fallback discovery.
|
||||
export function parseMacosDsclUserHomeLine(line: string): { user: string; home: string } | null {
|
||||
const match = /^(\S+)\s+(.+?)\s*$/u.exec(line.replaceAll("\r", ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { user: match[1], home: match[2] };
|
||||
}
|
||||
|
||||
export function isLikelyMacosDesktopHome(home: string | undefined): boolean {
|
||||
const normalized = home?.trim();
|
||||
return Boolean(normalized) && /(?:^|\/)Users\/[^/]+$/u.test(normalized);
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
die,
|
||||
ensureValue,
|
||||
extractLastOpenClawVersionFromLog,
|
||||
isLikelyMacosDesktopHome,
|
||||
makeTempDir,
|
||||
packOpenClaw,
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
parseMacosDsclUserHomeLine,
|
||||
parsePlatformList,
|
||||
parseProvider,
|
||||
readPositiveIntEnv,
|
||||
@@ -823,10 +825,16 @@ export class NpmUpdateSmoke {
|
||||
}
|
||||
const output = run(
|
||||
"bash",
|
||||
["-lc", `curl -fsSL ${shellQuote(tarball)} | tar -xzOf - package/dist/build-info.json`],
|
||||
[
|
||||
"-lc",
|
||||
`curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
|
||||
tarball,
|
||||
)} | tar -xzOf - package/dist/build-info.json`,
|
||||
],
|
||||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: 150_000,
|
||||
},
|
||||
).stdout.trim();
|
||||
if (!output) {
|
||||
@@ -1096,10 +1104,11 @@ export class NpmUpdateSmoke {
|
||||
{ check: false, quiet: true, timeoutMs: 30_000 },
|
||||
).stdout.replaceAll("\r", "");
|
||||
for (const line of users.split("\n")) {
|
||||
const [user, home] = line.trim().split(/\s+/);
|
||||
const parsed = parseMacosDsclUserHomeLine(line);
|
||||
const user = parsed?.user;
|
||||
if (
|
||||
user &&
|
||||
home?.startsWith("/Users/") &&
|
||||
isLikelyMacosDesktopHome(parsed?.home) &&
|
||||
!user.startsWith("_") &&
|
||||
user !== "Shared" &&
|
||||
user !== ".localized"
|
||||
@@ -1116,8 +1125,8 @@ export class NpmUpdateSmoke {
|
||||
["exec", this.macosVm, "/usr/bin/dscl", ".", "-read", `/Users/${user}`, "NFSHomeDirectory"],
|
||||
{ check: false, quiet: true, timeoutMs: 30_000 },
|
||||
).stdout.replaceAll("\r", "");
|
||||
const match = /NFSHomeDirectory:\s*(\S+)/.exec(output);
|
||||
return match?.[1] ?? `/Users/${user}`;
|
||||
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
|
||||
return match?.[1]?.trim() || `/Users/${user}`;
|
||||
}
|
||||
|
||||
private async guestWindows(
|
||||
@@ -1226,6 +1235,8 @@ export class NpmUpdateSmoke {
|
||||
|
||||
let timedOut = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
let forceKillAt: number | undefined;
|
||||
const timeoutKillGraceMs = freshLaneTimeoutKillGraceMs;
|
||||
const signalChild = (signal: NodeJS.Signals): void => {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
@@ -1246,7 +1257,8 @@ export class NpmUpdateSmoke {
|
||||
}
|
||||
timedOut = true;
|
||||
signalChild("SIGTERM");
|
||||
killTimer = setTimeout(() => signalChild("SIGKILL"), 2_000);
|
||||
forceKillAt = Date.now() + timeoutKillGraceMs;
|
||||
killTimer = setTimeout(() => signalChild("SIGKILL"), timeoutKillGraceMs);
|
||||
killTimer.unref();
|
||||
};
|
||||
if (ctx.signal.aborted) {
|
||||
@@ -1273,8 +1285,10 @@ export class NpmUpdateSmoke {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
if (timedOut) {
|
||||
signalChild("SIGKILL");
|
||||
resolve(124);
|
||||
void finishTimedOutLoggedProcessTree(child, {
|
||||
forceKillAt,
|
||||
timeoutKillGraceMs,
|
||||
}).then(() => resolve(124), reject);
|
||||
return;
|
||||
}
|
||||
resolve(code ?? (signal ? 128 : 1));
|
||||
|
||||
@@ -194,18 +194,27 @@ async function withPackageLock<T>(lockDir: string, fn: () => Promise<T>): Promis
|
||||
}
|
||||
}
|
||||
|
||||
async function acquirePackageLock(lockDir: string, ownerToken: string): Promise<void> {
|
||||
async function acquirePackageLock(
|
||||
lockDir: string,
|
||||
ownerToken: string,
|
||||
params: { writeOwner?: (lockDir: string, ownerToken: string) => Promise<void> } = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_TIMEOUT_MS", 30 * 60_000);
|
||||
const staleMs = readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_STALE_MS", 2 * 60 * 60_000);
|
||||
const startedAt = Date.now();
|
||||
let waitAnnouncementBudget = 1;
|
||||
const consumeWaitAnnouncement = () => waitAnnouncementBudget-- > 0;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
let createdLockDir = false;
|
||||
try {
|
||||
await mkdir(lockDir);
|
||||
await writeLockOwner(lockDir, ownerToken);
|
||||
createdLockDir = true;
|
||||
await (params.writeOwner ?? writeLockOwner)(lockDir, ownerToken);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (createdLockDir) {
|
||||
await rm(lockDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
if (!isErrorCode(error, "EEXIST")) {
|
||||
throw error;
|
||||
}
|
||||
@@ -248,7 +257,7 @@ async function removeStalePackageLock(lockDir: string, staleMs: number): Promise
|
||||
return;
|
||||
}
|
||||
const ageMs = Date.now() - ((await stat(lockDir).catch(() => undefined))?.mtimeMs ?? Date.now());
|
||||
if (owner || ageMs >= staleMs) {
|
||||
if (owner?.pid !== undefined || staleMs <= 0 || ageMs >= staleMs) {
|
||||
await rm(lockDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -261,7 +270,10 @@ async function readLockOwner(lockDir: string): Promise<{ pid?: number; token?: s
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { pid?: unknown; token?: unknown };
|
||||
return {
|
||||
pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
|
||||
pid:
|
||||
typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: undefined,
|
||||
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
||||
};
|
||||
} catch {
|
||||
@@ -287,3 +299,9 @@ async function delay(ms: number): Promise<void> {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
acquirePackageLock,
|
||||
removeStalePackageLock,
|
||||
readLockOwner,
|
||||
};
|
||||
|
||||
@@ -125,7 +125,7 @@ if (Test-Path $portableGit) {
|
||||
Remove-Item $portableGit -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $portableGit | Out-Null
|
||||
curl.exe -fsSL ${psSingleQuote(minGitUrl)} -o $archive
|
||||
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(minGitUrl)} -o $archive
|
||||
tar.exe -xf $archive -C $portableGit
|
||||
Remove-Item $archive -Force -ErrorAction SilentlyContinue
|
||||
$env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH"
|
||||
|
||||
@@ -542,7 +542,7 @@ ${cleanScript}`,
|
||||
const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : "";
|
||||
this.guestPowerShell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)}
|
||||
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -TimeoutSec 120
|
||||
& ([scriptblock]::Create($script))${versionArg} -NoOnboard
|
||||
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
|
||||
Invoke-OpenClaw --version
|
||||
@@ -559,7 +559,7 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST
|
||||
this.guestPowerShell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)}
|
||||
curl.exe -fsSL ${psSingleQuote(tgzUrl)} -o $tgz
|
||||
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz
|
||||
npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
|
||||
Invoke-OpenClaw --version
|
||||
|
||||
@@ -190,12 +190,76 @@ function parseJsonOutput(stdout) {
|
||||
if (!text) {
|
||||
throw new Error("expected JSON output, got empty stdout");
|
||||
}
|
||||
const first = text.indexOf("{");
|
||||
const last = text.lastIndexOf("}");
|
||||
if (first < 0 || last < first) {
|
||||
const parsed = parseJsonObjectsFromMixedOutput(text).at(-1);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(`expected JSON object output, got: ${scrub(text.slice(0, 500))}`);
|
||||
}
|
||||
return JSON.parse(text.slice(first, last + 1));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isJsonRecordStart(text, index) {
|
||||
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
||||
const char = text[cursor];
|
||||
if (char === "\n" || char === "\r") {
|
||||
return true;
|
||||
}
|
||||
if (char !== " " && char !== "\t") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseJsonObjectsFromMixedOutput(text) {
|
||||
const objects = [];
|
||||
let start = -1;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (start === -1) {
|
||||
if (char === "{" && isJsonRecordStart(text, index)) {
|
||||
start = index;
|
||||
depth = 1;
|
||||
inString = false;
|
||||
escaped = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char !== "}") {
|
||||
continue;
|
||||
}
|
||||
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
try {
|
||||
objects.push(JSON.parse(text.slice(start, index + 1)));
|
||||
} catch {}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
function resolveOpenClawRunner() {
|
||||
@@ -297,6 +361,7 @@ function runCommand(command, args, options = {}) {
|
||||
const stderr = createOutputCapture("stderr");
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
let parentSignalPending = null;
|
||||
let killTimer;
|
||||
let forceKillAt;
|
||||
const armForceKill = () => {
|
||||
@@ -339,15 +404,33 @@ function runCommand(command, args, options = {}) {
|
||||
}
|
||||
parentSignalHandlers.clear();
|
||||
};
|
||||
const finishTerminatedTree = async () => {
|
||||
await finishTimedOutCommandProcessTree(child, {
|
||||
forceKillAt,
|
||||
timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS,
|
||||
});
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
forceKillAt = undefined;
|
||||
};
|
||||
if (process.platform !== "win32" && child.pid) {
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
const handler = () => {
|
||||
if (parentSignalPending) {
|
||||
terminateProcessTree(child, "SIGKILL");
|
||||
return;
|
||||
}
|
||||
parentSignalPending = signal;
|
||||
terminateProcessTree(child, signal);
|
||||
removeParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
armForceKill();
|
||||
void finishTerminatedTree().finally(() => {
|
||||
removeParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
});
|
||||
};
|
||||
parentSignalHandlers.set(signal, handler);
|
||||
process.once(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
@@ -363,24 +446,18 @@ function runCommand(command, args, options = {}) {
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
abortSignal?.removeEventListener("abort", abort);
|
||||
removeParentSignalHandlers();
|
||||
const result = { code, signal, stdout: stdout.text(), stderr: stderr.text() };
|
||||
const finishTerminatedTree = async () => {
|
||||
await finishTimedOutCommandProcessTree(child, {
|
||||
forceKillAt,
|
||||
timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS,
|
||||
});
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
forceKillAt = undefined;
|
||||
};
|
||||
if (aborted) {
|
||||
removeParentSignalHandlers();
|
||||
void finishTerminatedTree().finally(() =>
|
||||
reject(new Error(scrub(`command aborted: ${command} ${args.join(" ")}`))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parentSignalPending) {
|
||||
return;
|
||||
}
|
||||
removeParentSignalHandlers();
|
||||
if (timedOut) {
|
||||
void finishTerminatedTree().finally(() =>
|
||||
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`))),
|
||||
@@ -1687,7 +1764,7 @@ async function p12OpenAiLiveProof() {
|
||||
return "OpenAI model auth probe consumed API key through plugin-managed auth-profile SecretRef";
|
||||
}
|
||||
|
||||
async function runPtySecretsConfigurePreset(envCtx) {
|
||||
async function runPtySecretsConfigurePreset(envCtx, options = {}) {
|
||||
const { spawn } = await import("@lydell/node-pty");
|
||||
const command = await resolveOpenClawCommand(
|
||||
["secrets", "configure", "--providers-only", "--apply", "--yes", "--allow-exec", "--json"],
|
||||
@@ -1702,16 +1779,39 @@ async function runPtySecretsConfigurePreset(envCtx) {
|
||||
});
|
||||
const output = createOutputCapture("secrets configure stdout");
|
||||
let phase = "providers-menu";
|
||||
const keyTimers = new Set();
|
||||
const clearKeyTimers = () => {
|
||||
for (const keyTimer of keyTimers) {
|
||||
clearTimeout(keyTimer);
|
||||
}
|
||||
keyTimers.clear();
|
||||
};
|
||||
const sendKeys = (keys) => {
|
||||
keys.forEach((key, index) => {
|
||||
setTimeout(() => child.write(key), index * 80);
|
||||
const keyTimer = setTimeout(() => {
|
||||
keyTimers.delete(keyTimer);
|
||||
child.write(key);
|
||||
}, index * 80);
|
||||
keyTimers.add(keyTimer);
|
||||
});
|
||||
};
|
||||
return await new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
let forceKillAt;
|
||||
let forceKillTimer;
|
||||
const timeoutMs = options.timeoutMs ?? 60000;
|
||||
const timeoutKillGraceMs = options.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`secrets configure preset timed out: ${scrub(output.text())}`));
|
||||
}, 60000);
|
||||
timedOut = true;
|
||||
signalPtyProcessTree(child, "SIGHUP");
|
||||
forceKillAt = Date.now() + timeoutKillGraceMs;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
forceKillTimer = undefined;
|
||||
forceKillAt = undefined;
|
||||
signalPtyProcessTree(child, "SIGKILL");
|
||||
}, timeoutKillGraceMs);
|
||||
forceKillTimer.unref?.();
|
||||
}, timeoutMs);
|
||||
child.onData((data) => {
|
||||
output.append(data);
|
||||
const outputText = output.text();
|
||||
@@ -1733,6 +1833,20 @@ async function runPtySecretsConfigurePreset(envCtx) {
|
||||
});
|
||||
child.onExit(({ exitCode }) => {
|
||||
clearTimeout(timer);
|
||||
clearKeyTimers();
|
||||
if (timedOut) {
|
||||
void finishTimedOutPtyProcessTree(child, {
|
||||
forceKillAt,
|
||||
forceKillTimer,
|
||||
timeoutKillGraceMs,
|
||||
}).finally(() =>
|
||||
reject(new Error(`secrets configure preset timed out: ${scrub(output.text())}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
reject(new Error(`secrets configure preset failed (${exitCode}): ${scrub(output.text())}`));
|
||||
return;
|
||||
@@ -1742,6 +1856,57 @@ async function runPtySecretsConfigurePreset(envCtx) {
|
||||
});
|
||||
}
|
||||
|
||||
async function finishTimedOutPtyProcessTree(
|
||||
child,
|
||||
{ forceKillAt, forceKillTimer, timeoutKillGraceMs },
|
||||
) {
|
||||
const graceRemainingMs =
|
||||
forceKillAt === undefined ? timeoutKillGraceMs : Math.max(0, forceKillAt - Date.now());
|
||||
if (graceRemainingMs > 0) {
|
||||
await waitForPtyProcessTreeExit(child, graceRemainingMs);
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
}
|
||||
if (ptyProcessTreeIsAlive(child)) {
|
||||
signalPtyProcessTree(child, "SIGKILL");
|
||||
}
|
||||
await waitForPtyProcessTreeExit(child, timeoutKillGraceMs);
|
||||
}
|
||||
|
||||
function ptyProcessTreeIsAlive(child) {
|
||||
if (process.platform === "win32" || typeof child.pid !== "number") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPtyProcessTreeExit(child, timeoutMs) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (!ptyProcessTreeIsAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
return !ptyProcessTreeIsAlive(child);
|
||||
}
|
||||
|
||||
function signalPtyProcessTree(child, signal) {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
async function p13SecretsConfigurePreset() {
|
||||
await withProofEnv("p13", async (envCtx) => {
|
||||
const port = await allocatePort();
|
||||
@@ -1955,6 +2120,7 @@ export {
|
||||
cleanupEnv,
|
||||
expectGatewayStartupFails,
|
||||
gatewayCall,
|
||||
parseJsonOutput,
|
||||
runPtySecretsConfigurePreset,
|
||||
runWithProof,
|
||||
runCommand,
|
||||
|
||||
@@ -148,6 +148,7 @@ export const COMMAND_STDERR_TAIL_CHARS = 256 * 1024;
|
||||
export const COMMAND_FAILURE_STDOUT_TAIL_CHARS = 64 * 1024;
|
||||
export const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
export const COMMAND_TIMEOUT_KILL_GRACE_MS = 5_000;
|
||||
const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 25;
|
||||
export const REMOTE_SETUP_COMMAND_TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const REMOTE_ROOT = "/tmp/openclaw-telegram-user-crabbox";
|
||||
const CREDENTIAL_SCRIPT = fileURLToPath(new URL("./telegram-user-credential.ts", import.meta.url));
|
||||
@@ -608,6 +609,44 @@ function commandProcessTreeAlive(child: ChildProcess) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCommandProcessTreeExit(child: ChildProcess, timeoutMs: number) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!commandProcessTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, COMMAND_PROCESS_TREE_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !commandProcessTreeAlive(child);
|
||||
}
|
||||
|
||||
async function finishTimedOutCommandProcessTree(
|
||||
child: ChildProcess,
|
||||
options: {
|
||||
forceKillAt: number | undefined;
|
||||
timeoutKillGraceMs: number;
|
||||
},
|
||||
) {
|
||||
if (!commandProcessTreeAlive(child)) {
|
||||
activeCommandChildren.delete(child);
|
||||
return;
|
||||
}
|
||||
const graceRemainingMs =
|
||||
options.forceKillAt === undefined
|
||||
? options.timeoutKillGraceMs
|
||||
: Math.max(0, options.forceKillAt - Date.now());
|
||||
if (graceRemainingMs > 0) {
|
||||
await waitForCommandProcessTreeExit(child, graceRemainingMs);
|
||||
}
|
||||
if (commandProcessTreeAlive(child)) {
|
||||
signalCommandTree(child, "SIGKILL");
|
||||
await waitForCommandProcessTreeExit(child, options.timeoutKillGraceMs);
|
||||
}
|
||||
activeCommandChildren.delete(child);
|
||||
}
|
||||
|
||||
function untrackCommandChild(child: ChildProcess) {
|
||||
if (!commandProcessTreeAlive(child)) {
|
||||
activeCommandChildren.delete(child);
|
||||
@@ -664,6 +703,7 @@ export function runCommand(params: {
|
||||
let settled = false;
|
||||
let stdoutLimitError: string | null = null;
|
||||
let timeoutError: Error | null = null;
|
||||
let forceKillAt: number | undefined;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
const timeoutMs = params.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
||||
const timeoutKillGraceMs = params.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS;
|
||||
@@ -684,6 +724,7 @@ export function runCommand(params: {
|
||||
)}`,
|
||||
);
|
||||
signalCommandTree(child, "SIGTERM");
|
||||
forceKillAt = Date.now() + timeoutKillGraceMs;
|
||||
killTimer = setTimeout(() => {
|
||||
signalCommandTree(child, "SIGKILL");
|
||||
}, timeoutKillGraceMs);
|
||||
@@ -736,9 +777,16 @@ export function runCommand(params: {
|
||||
settled = true;
|
||||
untrackCommandChild(child);
|
||||
if (timeoutError) {
|
||||
signalCommandTree(child, "SIGKILL");
|
||||
const error = timeoutError;
|
||||
clearTimers();
|
||||
reject(timeoutError);
|
||||
void finishTimedOutCommandProcessTree(child, {
|
||||
forceKillAt,
|
||||
timeoutKillGraceMs,
|
||||
}).then(
|
||||
() => reject(error),
|
||||
(cleanupError: unknown) =>
|
||||
reject(cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
@@ -1882,6 +1930,40 @@ function writeSession(pathname: string, session: SessionFile) {
|
||||
fs.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
const FULL_ARTIFACT_JSON_NAMES = new Set([
|
||||
"probe.json",
|
||||
"status.json",
|
||||
"telegram-user-crabbox-proof-summary.json",
|
||||
"telegram-user-crabbox-session-summary.json",
|
||||
]);
|
||||
const FULL_ARTIFACT_FILE_EXTENSIONS = new Set([".gif", ".log", ".md", ".mp4", ".png"]);
|
||||
const TIMESTAMPED_PROBE_ARTIFACT_JSON = /^probe-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.json$/u;
|
||||
|
||||
function isFullArtifactJsonName(name: string) {
|
||||
return FULL_ARTIFACT_JSON_NAMES.has(name) || TIMESTAMPED_PROBE_ARTIFACT_JSON.test(name);
|
||||
}
|
||||
|
||||
export function stageFullSessionArtifacts(outputDir: string) {
|
||||
const publishDir = path.join(outputDir, "publish-full-artifacts");
|
||||
fs.rmSync(publishDir, { force: true, recursive: true });
|
||||
fs.mkdirSync(publishDir, { recursive: true });
|
||||
|
||||
for (const entry of fs.readdirSync(outputDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const extension = path.extname(entry.name);
|
||||
const isPublishableArtifact =
|
||||
FULL_ARTIFACT_FILE_EXTENSIONS.has(extension) || isFullArtifactJsonName(entry.name);
|
||||
if (!isPublishableArtifact) {
|
||||
continue;
|
||||
}
|
||||
fs.copyFileSync(path.join(outputDir, entry.name), path.join(publishDir, entry.name));
|
||||
}
|
||||
|
||||
return publishDir;
|
||||
}
|
||||
|
||||
function readSession(root: string, opts: Options, outputDir: string) {
|
||||
const pathname = sessionPath(root, opts, outputDir);
|
||||
if (!fs.existsSync(pathname)) {
|
||||
@@ -2414,7 +2496,7 @@ async function publishSessionArtifacts(root: string, opts: Options, outputDir: s
|
||||
);
|
||||
const publishGifPath = fs.existsSync(croppedMotionGifPath) ? croppedMotionGifPath : motionGifPath;
|
||||
const publishDir = opts.publishFullArtifacts
|
||||
? session.outputDir
|
||||
? stageFullSessionArtifacts(session.outputDir)
|
||||
: path.join(session.outputDir, "publish-gif-only");
|
||||
if (!opts.publishFullArtifacts) {
|
||||
if (!fs.existsSync(publishGifPath)) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { copyFile, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { normalizeCredentialPayloadForKind } from "../../qa/convex-credential-broker/convex/payload-validation.js";
|
||||
import { fetchJsonWithTimeout, runCommand } from "./telegram-user-credential-io.ts";
|
||||
import { expandHome, writePrivateJson } from "./telegram-user-credential-paths.ts";
|
||||
|
||||
@@ -18,6 +17,9 @@ const DEFAULT_BOT_CREDENTIALS_FILE =
|
||||
const DEFAULT_CONVEX_ENV_FILE = "~/.codex/skills/custom/telegram-e2e-bot-to-bot/convex.local.env";
|
||||
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
|
||||
const TELEGRAM_USER_QA_CREDENTIAL_KIND = "telegram-user";
|
||||
const SHA256_HEX_RE = /^[a-f0-9]{64}$/u;
|
||||
const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u;
|
||||
const TELEGRAM_USER_ID_RE = /^\d+$/u;
|
||||
const DEFAULT_CHUNKED_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const DEFAULT_CHUNKED_PAYLOAD_MAX_CHUNKS = 4096;
|
||||
const COMMAND_TIMEOUT_MS = optionalPositiveInteger(
|
||||
@@ -175,8 +177,83 @@ function optionalPositiveInteger(value: string | undefined, fallback: number, la
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function throwCredentialPayloadError(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function requireTelegramUserPayloadString(payload: Record<string, unknown>, key: string): string {
|
||||
const raw = payload[key];
|
||||
if (typeof raw !== "string") {
|
||||
throwCredentialPayloadError(
|
||||
`Credential payload for kind "${TELEGRAM_USER_QA_CREDENTIAL_KIND}" must include "${key}" as a string.`,
|
||||
);
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
throwCredentialPayloadError(
|
||||
`Credential payload for kind "${TELEGRAM_USER_QA_CREDENTIAL_KIND}" must include a non-empty "${key}" value.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTelegramUserQaCredentialPayload(payload: Record<string, unknown>): JsonObject {
|
||||
return normalizeCredentialPayloadForKind(TELEGRAM_USER_QA_CREDENTIAL_KIND, payload);
|
||||
const groupId = requireTelegramUserPayloadString(payload, "groupId");
|
||||
if (!TELEGRAM_CHAT_ID_RE.test(groupId)) {
|
||||
throwCredentialPayloadError(
|
||||
'Credential payload for kind "telegram-user" must include a numeric "groupId" string.',
|
||||
);
|
||||
}
|
||||
const testerUserId = requireTelegramUserPayloadString(payload, "testerUserId");
|
||||
if (!TELEGRAM_USER_ID_RE.test(testerUserId)) {
|
||||
throwCredentialPayloadError(
|
||||
'Credential payload for kind "telegram-user" must include a numeric "testerUserId" string.',
|
||||
);
|
||||
}
|
||||
const telegramApiId = requireTelegramUserPayloadString(payload, "telegramApiId");
|
||||
if (!TELEGRAM_USER_ID_RE.test(telegramApiId)) {
|
||||
throwCredentialPayloadError(
|
||||
'Credential payload for kind "telegram-user" must include a numeric "telegramApiId" string.',
|
||||
);
|
||||
}
|
||||
const tdlibArchiveSha256 = requireTelegramUserPayloadString(
|
||||
payload,
|
||||
"tdlibArchiveSha256",
|
||||
).toLowerCase();
|
||||
const desktopTdataArchiveSha256 = requireTelegramUserPayloadString(
|
||||
payload,
|
||||
"desktopTdataArchiveSha256",
|
||||
).toLowerCase();
|
||||
if (!SHA256_HEX_RE.test(tdlibArchiveSha256)) {
|
||||
throwCredentialPayloadError(
|
||||
'Credential payload for kind "telegram-user" must include "tdlibArchiveSha256" as a SHA-256 hex string.',
|
||||
);
|
||||
}
|
||||
if (!SHA256_HEX_RE.test(desktopTdataArchiveSha256)) {
|
||||
throwCredentialPayloadError(
|
||||
'Credential payload for kind "telegram-user" must include "desktopTdataArchiveSha256" as a SHA-256 hex string.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
groupId,
|
||||
sutToken: requireTelegramUserPayloadString(payload, "sutToken"),
|
||||
testerUserId,
|
||||
testerUsername: requireTelegramUserPayloadString(payload, "testerUsername"),
|
||||
telegramApiId,
|
||||
telegramApiHash: requireTelegramUserPayloadString(payload, "telegramApiHash"),
|
||||
tdlibDatabaseEncryptionKey: requireTelegramUserPayloadString(
|
||||
payload,
|
||||
"tdlibDatabaseEncryptionKey",
|
||||
),
|
||||
tdlibArchiveBase64: requireTelegramUserPayloadString(payload, "tdlibArchiveBase64"),
|
||||
tdlibArchiveSha256,
|
||||
desktopTdataArchiveBase64: requireTelegramUserPayloadString(
|
||||
payload,
|
||||
"desktopTdataArchiveBase64",
|
||||
),
|
||||
desktopTdataArchiveSha256,
|
||||
};
|
||||
}
|
||||
|
||||
async function fileSha256(pathValue: string) {
|
||||
|
||||
@@ -372,6 +372,7 @@ echo "Verifying config and state survived update..."
|
||||
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config
|
||||
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state
|
||||
|
||||
startup_summary="n/a"
|
||||
if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
|
||||
echo "Gateway restart was handled by openclaw update."
|
||||
else
|
||||
@@ -387,6 +388,7 @@ else
|
||||
openclaw_e2e_print_log "$GATEWAY_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
startup_summary="${start_seconds}s"
|
||||
fi
|
||||
|
||||
echo "Checking gateway HTTP probes..."
|
||||
@@ -428,5 +430,5 @@ if [ "$status_seconds" -gt "$STATUS_BUDGET" ]; then
|
||||
fi
|
||||
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-status-json /tmp/openclaw-upgrade-survivor-status.json
|
||||
|
||||
echo "Upgrade survivor Docker E2E passed scenario=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base} updateRestartMode=${UPDATE_RESTART_MODE} startup=${start_seconds}s status=${status_seconds}s."
|
||||
echo "Upgrade survivor Docker E2E passed scenario=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base} updateRestartMode=${UPDATE_RESTART_MODE} startup=${startup_summary} status=${status_seconds}s."
|
||||
'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gateway Bench Child script supports OpenClaw repository automation.
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
const TEARDOWN_GRACE_MS = 2_000;
|
||||
const TEARDOWN_KILL_GRACE_MS = 1_000;
|
||||
@@ -14,6 +14,13 @@ export type StopChildResult = ChildExit & {
|
||||
exitedBeforeTeardown: boolean;
|
||||
};
|
||||
|
||||
export type StopChildOptions = {
|
||||
killGraceMs?: number;
|
||||
platform?: NodeJS.Platform;
|
||||
runTaskkill?: typeof spawnSync;
|
||||
teardownGraceMs?: number;
|
||||
};
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
@@ -22,10 +29,14 @@ export function delay(ms: number): Promise<void> {
|
||||
|
||||
export async function stopChild(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
options: { killGraceMs?: number; teardownGraceMs?: number } = {},
|
||||
options: StopChildOptions = {},
|
||||
): Promise<StopChildResult> {
|
||||
const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS;
|
||||
const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS;
|
||||
const processTreeOptions = {
|
||||
platform: options.platform ?? process.platform,
|
||||
runTaskkill: options.runTaskkill ?? spawnSync,
|
||||
};
|
||||
let observedExit: ChildExit | null = null;
|
||||
const directExit = (): ChildExit | null =>
|
||||
observedExit ??
|
||||
@@ -34,7 +45,7 @@ export async function stopChild(
|
||||
: null);
|
||||
const currentExit = (): ChildExit | null => {
|
||||
const exit = directExit();
|
||||
if (exit == null || isProcessTreeAlive(child)) {
|
||||
if (exit == null || isProcessTreeAlive(child, processTreeOptions)) {
|
||||
return null;
|
||||
}
|
||||
return exit;
|
||||
@@ -42,26 +53,26 @@ export async function stopChild(
|
||||
const waitForProcessTreeExit = async (ms: number): Promise<boolean> => {
|
||||
const deadlineAt = Date.now() + ms;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!isProcessTreeAlive(child)) {
|
||||
if (!isProcessTreeAlive(child, processTreeOptions)) {
|
||||
return true;
|
||||
}
|
||||
await delay(Math.min(EXIT_POLL_MS, deadlineAt - Date.now()));
|
||||
}
|
||||
return !isProcessTreeAlive(child);
|
||||
return !isProcessTreeAlive(child, processTreeOptions);
|
||||
};
|
||||
const cleanupExitedProcessTree = async (
|
||||
exit: ChildExit,
|
||||
exitedBeforeTeardown: boolean,
|
||||
): Promise<StopChildResult> => {
|
||||
if (!isProcessTreeAlive(child)) {
|
||||
if (!isProcessTreeAlive(child, processTreeOptions)) {
|
||||
return { ...exit, exitedBeforeTeardown };
|
||||
}
|
||||
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
|
||||
const sentTeardownSignal = killProcessTree(child, "SIGTERM", processTreeOptions);
|
||||
if (sentTeardownSignal) {
|
||||
await waitForProcessTreeExit(teardownGraceMs);
|
||||
}
|
||||
if (sentTeardownSignal && isProcessTreeAlive(child)) {
|
||||
killProcessTree(child, "SIGKILL");
|
||||
if (sentTeardownSignal && isProcessTreeAlive(child, processTreeOptions)) {
|
||||
killProcessTree(child, "SIGKILL", processTreeOptions);
|
||||
await waitForProcessTreeExit(killGraceMs);
|
||||
}
|
||||
if (!sentTeardownSignal) {
|
||||
@@ -106,7 +117,7 @@ export async function stopChild(
|
||||
return await cleanupExitedProcessTree(queuedExit, true);
|
||||
}
|
||||
|
||||
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
|
||||
const sentTeardownSignal = killProcessTree(child, "SIGTERM", processTreeOptions);
|
||||
const gracefulExit = await waitForExit(teardownGraceMs);
|
||||
if (gracefulExit != null) {
|
||||
return { ...gracefulExit, exitedBeforeTeardown: !sentTeardownSignal };
|
||||
@@ -121,7 +132,7 @@ export async function stopChild(
|
||||
return { exitCode: null, exitedBeforeTeardown: true, signal: null };
|
||||
}
|
||||
|
||||
killProcessTree(child, "SIGKILL");
|
||||
killProcessTree(child, "SIGKILL", processTreeOptions);
|
||||
const killedExit = await waitForExit(killGraceMs);
|
||||
const finalExit = killedExit ?? currentExit();
|
||||
if (finalExit != null) {
|
||||
@@ -139,8 +150,11 @@ function releaseUnsettledChild(child: ChildProcessWithoutNullStreams): void {
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function isProcessTreeAlive(child: ChildProcessWithoutNullStreams): boolean {
|
||||
if (process.platform === "win32" || child.pid === undefined) {
|
||||
function isProcessTreeAlive(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
{ platform = process.platform }: Pick<StopChildOptions, "platform"> = {},
|
||||
): boolean {
|
||||
if (platform === "win32" || child.pid === undefined) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -156,8 +170,12 @@ function isProcessStillExistsError(error: unknown): boolean {
|
||||
return code === "EPERM";
|
||||
}
|
||||
|
||||
function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
|
||||
if (process.platform !== "win32" && child.pid !== undefined) {
|
||||
function killProcessTree(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
signal: NodeJS.Signals,
|
||||
{ platform = process.platform, runTaskkill = spawnSync }: StopChildOptions = {},
|
||||
): boolean {
|
||||
if (platform !== "win32" && child.pid !== undefined) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return true;
|
||||
@@ -165,5 +183,15 @@ function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.S
|
||||
// Fall back to the direct child below.
|
||||
}
|
||||
}
|
||||
if (platform === "win32" && child.pid !== undefined) {
|
||||
const args = ["/PID", String(child.pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill("taskkill", args, { stdio: "ignore" });
|
||||
if (!result.error && result.status === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return child.kill(signal);
|
||||
}
|
||||
|
||||
26
scripts/lib/host-timeout.sh
Normal file
26
scripts/lib/host-timeout.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
openclaw_host_timeout_bin() {
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
printf '%s\n' timeout
|
||||
elif command -v gtimeout >/dev/null 2>&1; then
|
||||
printf '%s\n' gtimeout
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
openclaw_host_timeout_cmd() {
|
||||
local timeout_value="$1"
|
||||
shift
|
||||
local timeout_bin
|
||||
if ! timeout_bin="$(openclaw_host_timeout_bin)"; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then
|
||||
"$timeout_bin" --kill-after=30s "$timeout_value" "$@"
|
||||
else
|
||||
"$timeout_bin" "$timeout_value" "$@"
|
||||
fi
|
||||
}
|
||||
@@ -103,6 +103,9 @@ export async function runManagedCommand({
|
||||
if (managedChild.forceKillTimer) {
|
||||
clearTimeout(managedChild.forceKillTimer);
|
||||
}
|
||||
if (managedChild.receivedSignal) {
|
||||
terminateManagedChild(child, "SIGKILL");
|
||||
}
|
||||
resolve(
|
||||
managedChild.receivedSignal
|
||||
? signalExitCode(managedChild.receivedSignal)
|
||||
|
||||
@@ -695,6 +695,9 @@ function readRunPackageDir(argv) {
|
||||
}
|
||||
|
||||
export function parseRunArgs(argv) {
|
||||
if (argv[0] === "--help" || argv[0] === "-h") {
|
||||
return { help: true, packageDir: "", command: "", args: [] };
|
||||
}
|
||||
if (argv[0] !== "--run") {
|
||||
throw new Error(RUN_USAGE);
|
||||
}
|
||||
@@ -703,6 +706,9 @@ export function parseRunArgs(argv) {
|
||||
if (!packageDir || separatorIndex === -1 || separatorIndex === argv.length - 1) {
|
||||
throw new Error(RUN_USAGE);
|
||||
}
|
||||
if (separatorIndex !== 2) {
|
||||
throw new Error(`unexpected plugin npm package manifest run argument: ${argv[2]}`);
|
||||
}
|
||||
return {
|
||||
packageDir,
|
||||
command: argv[separatorIndex + 1],
|
||||
@@ -711,7 +717,12 @@ export function parseRunArgs(argv) {
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const { packageDir, command, args } = parseRunArgs(argv);
|
||||
const parsedArgs = parseRunArgs(argv);
|
||||
if (parsedArgs.help) {
|
||||
console.log(RUN_USAGE);
|
||||
return 0;
|
||||
}
|
||||
const { packageDir, command, args } = parsedArgs;
|
||||
return withAugmentedPluginNpmManifestForPackage(
|
||||
{
|
||||
packageDir,
|
||||
|
||||
@@ -297,22 +297,38 @@ export async function buildPluginNpmRuntime(params) {
|
||||
};
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return "usage: node scripts/lib/plugin-npm-runtime-build.mjs <package-dir>";
|
||||
}
|
||||
|
||||
function readPackageDirArg(argv) {
|
||||
const packageDir = argv[0];
|
||||
if (!packageDir || packageDir.startsWith("--")) {
|
||||
throw new Error("usage: node scripts/lib/plugin-npm-runtime-build.mjs <package-dir>");
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const packageDir = args[0];
|
||||
if (packageDir === "--help" || packageDir === "-h") {
|
||||
return { help: true, packageDir: "" };
|
||||
}
|
||||
return packageDir;
|
||||
if (!packageDir || packageDir.startsWith("--")) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
const extraArg = args[1];
|
||||
if (extraArg) {
|
||||
throw new Error(`unexpected plugin npm runtime build argument: ${extraArg}`);
|
||||
}
|
||||
return { packageDir };
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const packageDir = readPackageDirArg(argv);
|
||||
return { packageDir };
|
||||
return readPackageDirArg(argv);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
const { packageDir } = parseArgs(process.argv.slice(2));
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
const { packageDir } = args;
|
||||
const result = await buildPluginNpmRuntime({ packageDir });
|
||||
if (result) {
|
||||
console.error(
|
||||
|
||||
@@ -114,6 +114,9 @@ export const pluginSdkDocMetadata = {
|
||||
"runtime-store": {
|
||||
category: "runtime",
|
||||
},
|
||||
"session-transcript-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
"sqlite-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"session-binding-runtime",
|
||||
"session-key-runtime",
|
||||
"session-store-runtime",
|
||||
"session-transcript-runtime",
|
||||
"sqlite-runtime",
|
||||
"sqlite-runtime-testing",
|
||||
"session-transcript-hit",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const STABLE_RELEASE_TAG_RE = /^v(?<version>\d{4}\.\d{1,2}\.\d{1,2})(?:-\d+)?$/u;
|
||||
const STABLE_RELEASE_TAG_RE = /^v(?<version>\d{4}\.\d{1,2}\.\d{1,2})(?:-[1-9]\d*)?$/u;
|
||||
const MAX_ROLLBACK_DRILL_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseStableReleaseTagDetails(tag) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawnPnpmRunner } from "../pnpm-runner.mjs";
|
||||
import {
|
||||
forceKillVitestProcessGroup,
|
||||
installVitestProcessGroupCleanup,
|
||||
shouldUseDetachedVitestProcessGroup,
|
||||
} from "../vitest-process-group.mjs";
|
||||
@@ -26,8 +27,10 @@ export async function runVitestBatch(params) {
|
||||
});
|
||||
const teardownChildCleanup = installVitestProcessGroupCleanup({
|
||||
child,
|
||||
forceSignal: "SIGKILL",
|
||||
forceSignalDelayMs: 100,
|
||||
onSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
forwardedSignal ??= signal;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,12 +40,13 @@ export async function runVitestBatch(params) {
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
teardownChildCleanup();
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
if (forwardedSignal) {
|
||||
forceKillVitestProcessGroup(child);
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
return;
|
||||
}
|
||||
if (forwardedSignal) {
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
resolve(code ?? 1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user