mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 06:22:28 +08:00
Compare commits
126 Commits
v2026.6.20
...
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 |
@@ -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 |
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
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";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
@@ -21,7 +21,6 @@ const messageReplyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
|
||||
const emptyConfig: ClawdbotConfig = {};
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
@@ -101,24 +100,20 @@ function callData<T>(
|
||||
|
||||
async function withIsolatedHome<T>(run: () => Promise<T>): Promise<T> {
|
||||
const originalHome = process.env.HOME;
|
||||
const tempHome = tempDirs.make("openclaw-feishu-media-");
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
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;
|
||||
}
|
||||
}
|
||||
tempDirs.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
tempDirs.cleanup();
|
||||
});
|
||||
|
||||
describe("sendMediaFeishu msg_type routing", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
@@ -1780,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"; };',
|
||||
@@ -1850,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;",
|
||||
@@ -2009,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]);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -624,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");
|
||||
@@ -757,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));
|
||||
@@ -766,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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -930,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
@@ -2056,6 +2120,7 @@ export {
|
||||
cleanupEnv,
|
||||
expectGatewayStartupFails,
|
||||
gatewayCall,
|
||||
parseJsonOutput,
|
||||
runPtySecretsConfigurePreset,
|
||||
runWithProof,
|
||||
runCommand,
|
||||
|
||||
@@ -1930,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)) {
|
||||
@@ -2462,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."
|
||||
'
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -39,9 +39,10 @@ if [[ -z "$VERSION" ]]; then
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
NOTES_HTML=""
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
if [[ "${KEEP_SPARKLE_NOTES:-0}" != "1" ]]; then
|
||||
if [[ -n "$NOTES_HTML" && "${KEEP_SPARKLE_NOTES:-0}" != "1" ]]; then
|
||||
rm -f "$NOTES_HTML"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -12,11 +12,42 @@ set -euo pipefail
|
||||
# NOTARYTOOL_KEY_ID API key ID
|
||||
# NOTARYTOOL_ISSUER API issuer ID
|
||||
|
||||
ARTIFACT="${1:-}"
|
||||
ARTIFACT=""
|
||||
STAPLE_APP_PATH="${STAPLE_APP_PATH:-}"
|
||||
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: scripts/notarize-mac-artifact.sh <artifact>
|
||||
|
||||
Env:
|
||||
STAPLE_APP_PATH=dist/OpenClaw.app
|
||||
NOTARYTOOL_PROFILE=<keychain-profile>
|
||||
NOTARYTOOL_KEY=<api-key.p8>
|
||||
NOTARYTOOL_KEY_ID=<api-key-id>
|
||||
NOTARYTOOL_ISSUER=<issuer-id>
|
||||
HELP
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
case "$1" in
|
||||
-*) echo "Error: unknown notarization option: $1" >&2; exit 1 ;;
|
||||
*) ARTIFACT="$1"; shift ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
echo "Error: unexpected notarization argument: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$ARTIFACT" ]]; then
|
||||
echo "Usage: $0 <artifact>" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -e "$ARTIFACT" ]]; then
|
||||
|
||||
@@ -99,6 +99,41 @@ export type PublishedInstallScenario = {
|
||||
expectedVersion: string;
|
||||
};
|
||||
|
||||
export type OpenClawNpmPostpublishVerifyArgs =
|
||||
| {
|
||||
help: false;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
help: true;
|
||||
version: "";
|
||||
};
|
||||
|
||||
export function openClawNpmPostpublishVerifyUsage(): string {
|
||||
return "Usage: node --import tsx scripts/openclaw-npm-postpublish-verify.ts <version>";
|
||||
}
|
||||
|
||||
export function parseOpenClawNpmPostpublishVerifyArgs(
|
||||
argv: readonly string[],
|
||||
): OpenClawNpmPostpublishVerifyArgs {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const version = args[0]?.trim() ?? "";
|
||||
if (version === "--help" || version === "-h") {
|
||||
return { help: true, version: "" };
|
||||
}
|
||||
if (!version) {
|
||||
throw new Error(openClawNpmPostpublishVerifyUsage());
|
||||
}
|
||||
if (version.startsWith("-")) {
|
||||
throw new Error(`Unknown openclaw npm postpublish verifier option: ${version}`);
|
||||
}
|
||||
const extraArg = args[1]?.trim();
|
||||
if (extraArg) {
|
||||
throw new Error(`Unexpected openclaw npm postpublish verifier argument: ${extraArg}`);
|
||||
}
|
||||
return { help: false, version };
|
||||
}
|
||||
|
||||
export function buildPublishedInstallScenarios(version: string): PublishedInstallScenario[] {
|
||||
const parsed = parseReleaseVersion(version);
|
||||
if (parsed === null) {
|
||||
@@ -1147,14 +1182,14 @@ function verifyScenario(version: string, scenario: PublishedInstallScenario): vo
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const version = process.argv[2]?.trim();
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
"Usage: node --import tsx scripts/openclaw-npm-postpublish-verify.ts <version>",
|
||||
);
|
||||
async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const args = parseOpenClawNpmPostpublishVerifyArgs(argv);
|
||||
if (args.help) {
|
||||
console.log(openClawNpmPostpublishVerifyUsage());
|
||||
return;
|
||||
}
|
||||
|
||||
const { version } = args;
|
||||
const scenarios = buildPublishedInstallScenarios(version);
|
||||
await verifyPublishedRegistryProvenance(version);
|
||||
for (const scenario of scenarios) {
|
||||
|
||||
@@ -19,6 +19,51 @@ type InstalledPackageJson = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type OpenClawNpmPrepublishVerifyArgs =
|
||||
| {
|
||||
expectedVersion?: string;
|
||||
help: false;
|
||||
tarballPath: string;
|
||||
}
|
||||
| {
|
||||
expectedVersion?: undefined;
|
||||
help: true;
|
||||
tarballPath: "";
|
||||
};
|
||||
|
||||
export function openClawNpmPrepublishVerifyUsage(): string {
|
||||
return "Usage: node --import tsx scripts/openclaw-npm-prepublish-verify.ts <tarball.tgz> [expected-version]";
|
||||
}
|
||||
|
||||
export function parseOpenClawNpmPrepublishVerifyArgs(
|
||||
argv: readonly string[],
|
||||
): OpenClawNpmPrepublishVerifyArgs {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const tarballPath = args[0]?.trim() ?? "";
|
||||
if (tarballPath === "--help" || tarballPath === "-h") {
|
||||
return { help: true, tarballPath: "" };
|
||||
}
|
||||
if (!tarballPath) {
|
||||
throw new Error(openClawNpmPrepublishVerifyUsage());
|
||||
}
|
||||
if (tarballPath.startsWith("-")) {
|
||||
throw new Error(`Unknown openclaw npm prepublish verifier option: ${tarballPath}`);
|
||||
}
|
||||
|
||||
const expectedVersion = args[1]?.trim();
|
||||
if (expectedVersion?.startsWith("-")) {
|
||||
throw new Error(`Unknown openclaw npm prepublish verifier option: ${expectedVersion}`);
|
||||
}
|
||||
const extraArg = args[2]?.trim();
|
||||
if (extraArg) {
|
||||
throw new Error(`Unexpected openclaw npm prepublish verifier argument: ${extraArg}`);
|
||||
}
|
||||
|
||||
return expectedVersion
|
||||
? { expectedVersion, help: false, tarballPath }
|
||||
: { help: false, tarballPath };
|
||||
}
|
||||
|
||||
function npmExec(args: string[], cwd: string): string {
|
||||
const invocation = resolveNpmCommandInvocation({
|
||||
npmArgs: args,
|
||||
@@ -30,13 +75,11 @@ function npmExec(args: string[], cwd: string): string {
|
||||
return runNpmVerifyCommand(invocation, cwd);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const tarballPath = process.argv[2]?.trim();
|
||||
const expectedVersion = process.argv[3]?.trim();
|
||||
if (!tarballPath) {
|
||||
throw new Error(
|
||||
"Usage: node --import tsx scripts/openclaw-npm-prepublish-verify.ts <tarball.tgz> [expected-version]",
|
||||
);
|
||||
function main(argv = process.argv.slice(2)): void {
|
||||
const args = parseOpenClawNpmPrepublishVerifyArgs(argv);
|
||||
if (args.help) {
|
||||
console.log(openClawNpmPrepublishVerifyUsage());
|
||||
return;
|
||||
}
|
||||
|
||||
const workingDir = mkdtempSync(join(tmpdir(), "openclaw-prepublish-"));
|
||||
@@ -48,7 +91,7 @@ function main(): void {
|
||||
"-g",
|
||||
"--prefix",
|
||||
prefixDir,
|
||||
realpathSync(tarballPath),
|
||||
realpathSync(args.tarballPath),
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
],
|
||||
@@ -59,7 +102,7 @@ function main(): void {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(packageRoot, "package.json"), "utf8"),
|
||||
) as InstalledPackageJson;
|
||||
const resolvedExpectedVersion = expectedVersion || pkg.version?.trim() || "";
|
||||
const resolvedExpectedVersion = args.expectedVersion || pkg.version?.trim() || "";
|
||||
const errors = collectInstalledPackageErrors({
|
||||
expectedVersion: resolvedExpectedVersion,
|
||||
installedVersion: pkg.version?.trim() ?? "",
|
||||
|
||||
@@ -2,11 +2,33 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-}"
|
||||
publish_target="${2:-}"
|
||||
usage() {
|
||||
echo "usage: bash scripts/openclaw-npm-publish.sh --publish [package.tgz]"
|
||||
}
|
||||
|
||||
if [[ "${mode}" != "--publish" ]]; then
|
||||
echo "usage: bash scripts/openclaw-npm-publish.sh --publish [package.tgz]" >&2
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" != "--publish" ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
publish_target=""
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
case "$1" in
|
||||
-*) echo "error: unexpected npm publish target option: $1" >&2; exit 2 ;;
|
||||
*) publish_target="$1"; shift ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
echo "error: unexpected npm publish argument: $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
||||
@@ -154,6 +154,13 @@ SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
|
||||
NOTARIZE=1
|
||||
SKIP_DSYM="${SKIP_DSYM:-0}"
|
||||
SKIP_DMG="${SKIP_DMG:-0}"
|
||||
NOTARY_ZIP_PENDING_CLEANUP=0
|
||||
|
||||
cleanup_notary_zip() {
|
||||
if [[ "$NOTARY_ZIP_PENDING_CLEANUP" == "1" ]]; then
|
||||
rm -f "$NOTARY_ZIP"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
|
||||
NOTARIZE=0
|
||||
@@ -185,9 +192,13 @@ fi
|
||||
if [[ "$NOTARIZE" == "1" ]]; then
|
||||
echo "📦 Notary zip: $NOTARY_ZIP"
|
||||
rm -f "$NOTARY_ZIP"
|
||||
NOTARY_ZIP_PENDING_CLEANUP=1
|
||||
trap cleanup_notary_zip EXIT
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP" "$NOTARY_ZIP"
|
||||
STAPLE_APP_PATH="$APP" "$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$NOTARY_ZIP"
|
||||
rm -f "$NOTARY_ZIP"
|
||||
NOTARY_ZIP_PENDING_CLEANUP=0
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
echo "📦 Zip: $ZIP"
|
||||
@@ -256,7 +267,10 @@ if [[ "$SKIP_DSYM" != "1" ]]; then
|
||||
fi
|
||||
echo "🧩 dSYM: $DSYM_ZIP"
|
||||
rm -f "$DSYM_ZIP"
|
||||
ditto -c -k --keepParent "$TMP_DSYM" "$DSYM_ZIP"
|
||||
if ! ditto -c -k --keepParent "$TMP_DSYM" "$DSYM_ZIP"; then
|
||||
rm -rf "$TMP_DSYM"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$TMP_DSYM"
|
||||
else
|
||||
echo "Error: dSYM not found (set SKIP_DSYM=1 to skip symbols)" >&2
|
||||
|
||||
@@ -2,21 +2,44 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] <package-dir>"
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mode="${1:-}"
|
||||
package_dir="${2:-}"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
invocation_root="$(pwd)"
|
||||
|
||||
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" ]]; then
|
||||
echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] <package-dir>" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
package_dir=""
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
case "$1" in
|
||||
-*) echo "unexpected plugin ClawHub package-dir option: $1" >&2; exit 2 ;;
|
||||
*) package_dir="$1"; shift ;;
|
||||
esac
|
||||
fi
|
||||
if [[ -z "${package_dir}" ]]; then
|
||||
echo "missing package dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
echo "unexpected plugin ClawHub publish argument: $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! "${package_dir}" =~ ^extensions/[a-z0-9][a-z0-9._-]*$ ]]; then
|
||||
echo "invalid package dir: ${package_dir}" >&2
|
||||
|
||||
@@ -2,18 +2,40 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-}"
|
||||
package_dir="${2:-}"
|
||||
usage() {
|
||||
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] <package-dir>"
|
||||
}
|
||||
|
||||
if [[ "${mode}" != "--dry-run" && "${mode}" != "--pack-dry-run" && "${mode}" != "--publish" ]]; then
|
||||
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] <package-dir>" >&2
|
||||
exit 2
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mode="${1:-}"
|
||||
if [[ "${mode}" != "--dry-run" && "${mode}" != "--pack-dry-run" && "${mode}" != "--publish" ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
package_dir=""
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
case "$1" in
|
||||
-*) echo "unexpected plugin npm package-dir option: $1" >&2; exit 2 ;;
|
||||
*) package_dir="$1"; shift ;;
|
||||
esac
|
||||
fi
|
||||
if [[ -z "${package_dir}" ]]; then
|
||||
echo "missing package dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
echo "unexpected plugin npm publish argument: $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")"
|
||||
package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")"
|
||||
|
||||
@@ -64,6 +64,7 @@ function readEntrypointBudgetEnv(name, fallback) {
|
||||
|
||||
const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
|
||||
core: 2,
|
||||
health: 1,
|
||||
lmstudio: 1,
|
||||
"provider-setup": 1,
|
||||
"self-hosted-provider-setup": 14,
|
||||
@@ -124,6 +125,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
|
||||
"channel-policy": 8,
|
||||
"channel-route": 5,
|
||||
"session-store-runtime": 1,
|
||||
"session-transcript-runtime": 1,
|
||||
"group-access": 13,
|
||||
"media-generation-runtime-shared": 3,
|
||||
"music-generation-core": 20,
|
||||
@@ -160,12 +162,12 @@ let budgets;
|
||||
let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 320),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10301),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5171),
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10331),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5183),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3244,
|
||||
3245,
|
||||
),
|
||||
publicWildcardReexports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
source "$REPO_PATH/scripts/lib/host-timeout.sh"
|
||||
RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh"
|
||||
QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in"
|
||||
OPENCLAW_USER="$(id -un)"
|
||||
@@ -47,27 +48,11 @@ fail() {
|
||||
|
||||
run_podman_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 "$PODMAN_PULL_TIMEOUT" podman pull "$image"
|
||||
else
|
||||
timeout "$PODMAN_PULL_TIMEOUT" podman pull "$image"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
podman pull "$image"
|
||||
openclaw_host_timeout_cmd "$PODMAN_PULL_TIMEOUT" podman pull "$image"
|
||||
}
|
||||
|
||||
run_podman_build() {
|
||||
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 "$PODMAN_BUILD_TIMEOUT" podman build "$@"
|
||||
else
|
||||
timeout "$PODMAN_BUILD_TIMEOUT" podman build "$@"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
podman build "$@"
|
||||
openclaw_host_timeout_cmd "$PODMAN_BUILD_TIMEOUT" podman build "$@"
|
||||
}
|
||||
|
||||
validate_single_line_value() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
||||
import path, { resolve } from "node:path";
|
||||
import { isLocalCheckEnabled } from "./lib/local-heavy-check-runtime.mjs";
|
||||
import { parsePositiveInt } from "./lib/numeric-options.mjs";
|
||||
import { pluginSdkEntrypoints, publicPluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "..");
|
||||
const runTsgoScript = path.join(repoRoot, "scripts/run-tsgo.mjs");
|
||||
@@ -241,6 +242,22 @@ const ENTRY_SHIMS_INPUTS = [
|
||||
"scripts/lib/plugin-sdk-entrypoints.json",
|
||||
"scripts/lib/plugin-sdk-entries.mjs",
|
||||
];
|
||||
const ENTRY_SHIM_RUNTIME_OUTPUTS = ["dist/plugin-sdk/webhook-path.js"];
|
||||
|
||||
/**
|
||||
* Lists entry-shim artifacts written by scripts/write-plugin-sdk-entry-dts.ts.
|
||||
*/
|
||||
export function resolveBoundaryEntryShimRequiredOutputs(env = process.env) {
|
||||
const entries =
|
||||
env.OPENCLAW_BUILD_PRIVATE_QA === "1" ? pluginSdkEntrypoints : publicPluginSdkEntrypoints;
|
||||
return [
|
||||
...entries.flatMap((entry) => [
|
||||
`dist/plugin-sdk/${entry}.d.ts`,
|
||||
`packages/plugin-sdk/dist/src/plugin-sdk/${entry}.d.ts`,
|
||||
]),
|
||||
...ENTRY_SHIM_RUNTIME_OUTPUTS,
|
||||
].toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function isRelevantTypeInput(filePath) {
|
||||
const basename = path.basename(filePath);
|
||||
@@ -621,7 +638,10 @@ async function main(argv = process.argv.slice(2)) {
|
||||
"dist/plugin-sdk/.tsbuildinfo",
|
||||
"packages/plugin-sdk/dist/.tsbuildinfo",
|
||||
],
|
||||
outputPaths: ["dist/plugin-sdk/.boundary-entry-shims.stamp"],
|
||||
outputPaths: [
|
||||
"dist/plugin-sdk/.boundary-entry-shims.stamp",
|
||||
...resolveBoundaryEntryShimRequiredOutputs(),
|
||||
],
|
||||
});
|
||||
const qaChannelDtsFresh =
|
||||
isArtifactSetFresh({
|
||||
|
||||
@@ -12,6 +12,11 @@ type QaE2eDeps = {
|
||||
writeStdout?: (text: string) => void;
|
||||
};
|
||||
|
||||
type QaE2eArgs = {
|
||||
help: boolean;
|
||||
outputPath: string;
|
||||
};
|
||||
|
||||
async function loadQaE2eRuntime(): Promise<QaE2eRuntime> {
|
||||
return await import("../extensions/qa-lab/api.js");
|
||||
}
|
||||
@@ -23,18 +28,80 @@ export function enablePrivateQaScriptEnv(env: NodeJS.ProcessEnv = process.env) {
|
||||
}
|
||||
|
||||
export function resolveQaE2eOutputPath(argv: readonly string[] = process.argv.slice(2)) {
|
||||
return argv[0]?.trim() || ".artifacts/qa-e2e/self-check.md";
|
||||
return parseQaE2eArgs(argv).outputPath;
|
||||
}
|
||||
|
||||
export function usage(): string {
|
||||
return `Usage: pnpm qa:e2e [--output <path>]
|
||||
|
||||
Options:
|
||||
--output <path> Markdown report output path
|
||||
-h, --help Display help
|
||||
`;
|
||||
}
|
||||
|
||||
export function parseQaE2eArgs(argv: readonly string[]): QaE2eArgs {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
let outputPath = "";
|
||||
let positionalMode = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (positionalMode) {
|
||||
if (!outputPath && arg.trim()) {
|
||||
outputPath = arg.trim();
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected qa:e2e argument: ${arg}`);
|
||||
}
|
||||
if (arg === "--") {
|
||||
positionalMode = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { help: true, outputPath: ".artifacts/qa-e2e/self-check.md" };
|
||||
}
|
||||
const inlineOutput = arg.startsWith("--output=") ? arg.slice("--output=".length).trim() : null;
|
||||
if (inlineOutput !== null) {
|
||||
if (!inlineOutput) {
|
||||
throw new Error("--output requires a value");
|
||||
}
|
||||
outputPath = inlineOutput;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--output") {
|
||||
const value = args[index + 1]?.trim();
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error("--output requires a value");
|
||||
}
|
||||
outputPath = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown qa:e2e option: ${arg}`);
|
||||
}
|
||||
if (outputPath) {
|
||||
throw new Error(`Unexpected qa:e2e argument: ${arg}`);
|
||||
}
|
||||
outputPath = arg.trim();
|
||||
}
|
||||
return { help: false, outputPath: outputPath || ".artifacts/qa-e2e/self-check.md" };
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
deps: QaE2eDeps = {},
|
||||
): Promise<number> {
|
||||
const args = parseQaE2eArgs(argv);
|
||||
if (args.help) {
|
||||
(deps.writeStdout ?? ((text: string) => process.stdout.write(text)))(usage());
|
||||
return 0;
|
||||
}
|
||||
enablePrivateQaScriptEnv(deps.env ?? process.env);
|
||||
const { isQaSelfCheckSuccessful, runQaE2eSelfCheck } = await (
|
||||
deps.loadRuntime ?? loadQaE2eRuntime
|
||||
)();
|
||||
const result = await runQaE2eSelfCheck({ outputPath: resolveQaE2eOutputPath(argv) });
|
||||
const result = await runQaE2eSelfCheck({ outputPath: args.outputPath });
|
||||
(deps.writeStdout ?? ((text: string) => process.stdout.write(text)))(
|
||||
`QA self-check report: ${result.outputPath}\n`,
|
||||
);
|
||||
@@ -47,5 +114,10 @@ function isMainModule() {
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
process.exitCode = await main();
|
||||
try {
|
||||
process.exitCode = await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async function runQaLabUp(argv: readonly string[], deps: QaLabUpDeps = {}): Prom
|
||||
}
|
||||
|
||||
const parsePort = (value: string | undefined, flag: string) => {
|
||||
if (!value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseStrictPositiveInteger(value);
|
||||
|
||||
@@ -376,7 +376,7 @@ async function sha256(file) {
|
||||
}
|
||||
|
||||
function assertSha256(value) {
|
||||
if (!/^[a-f0-9]{64}$/u.test(value)) {
|
||||
if (!/^[a-f0-9]{64}$/iu.test(value)) {
|
||||
throw new Error(`package_sha256 must be a lowercase or uppercase 64-character SHA-256 digest`);
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,8 @@ async function assertExpectedSha256(file, expected) {
|
||||
return actual;
|
||||
}
|
||||
|
||||
export const assertExpectedSha256ForTest = assertExpectedSha256;
|
||||
|
||||
async function findSingleTarball(dir) {
|
||||
const root = path.resolve(ROOT_DIR, dir);
|
||||
const pending = [root];
|
||||
@@ -421,8 +423,11 @@ async function findSingleTarball(dir) {
|
||||
if (entry.isFile() && /\.t(?:ar\.)?gz$/u.test(entry.name)) {
|
||||
tarballs.push(absolute);
|
||||
if (tarballs.length > 1) {
|
||||
const relativeTarballs = tarballs
|
||||
.map((tarball) => path.relative(root, tarball))
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
throw new Error(
|
||||
`source=artifact requires exactly one .tgz under ${dir}; found at least 2: ${tarballs.toSorted((a, b) => a.localeCompare(b)).join(", ")}`,
|
||||
`source=artifact requires exactly one .tgz under ${dir}; found at least 2: ${relativeTarballs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -960,10 +965,13 @@ function validateTrustedPackageDownloadUrl(parsed, trustedSource, options = {})
|
||||
}
|
||||
}
|
||||
|
||||
function createTrustedPackageAuthHeaders(trustedSource) {
|
||||
function createTrustedPackageAuthHeaders(trustedSource, parsed, initialOrigin) {
|
||||
if (!trustedSource?.auth) {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.origin !== initialOrigin) {
|
||||
return undefined;
|
||||
}
|
||||
const token = process.env[TRUSTED_PACKAGE_SOURCE_TOKEN_ENV];
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
@@ -1190,8 +1198,8 @@ async function openPackageDownloadResponse(url, options) {
|
||||
const timeoutMs = options.timeoutMs ?? PACKAGE_URL_DOWNLOAD_TIMEOUT_MS;
|
||||
const maxRedirects = options.maxRedirects ?? PACKAGE_URL_MAX_REDIRECTS;
|
||||
const trustedSource = options.trustedSource;
|
||||
const headers = createTrustedPackageAuthHeaders(trustedSource);
|
||||
let parsed = new URL(url);
|
||||
const initialOrigin = parsed.origin;
|
||||
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
|
||||
if (trustedSource) {
|
||||
validateTrustedPackageDownloadUrl(parsed, trustedSource, { isRedirect: redirectCount > 0 });
|
||||
@@ -1199,6 +1207,7 @@ async function openPackageDownloadResponse(url, options) {
|
||||
validatePackageDownloadUrl(parsed);
|
||||
}
|
||||
const addresses = await resolvePackageDownloadAddresses(parsed, lookupHost, trustedSource);
|
||||
const headers = createTrustedPackageAuthHeaders(trustedSource, parsed, initialOrigin);
|
||||
const opened = options.fetchImpl
|
||||
? await openFetchPackageDownloadResponse(parsed, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
|
||||
@@ -116,7 +116,8 @@ for arg in "$@"; do
|
||||
log "Default behavior: Auto-detect signing keys, fallback to --no-sign if none found"
|
||||
exit 0
|
||||
;;
|
||||
*) ;;
|
||||
--) ;;
|
||||
*) fail "Unknown restart option: ${arg}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export function runNodeMain(params?: {
|
||||
fs?: unknown;
|
||||
stderr?: { write: (value: string) => void };
|
||||
process?: NodeJS.Process;
|
||||
signalProcess?: (pid: number, signal?: NodeJS.Signals | number) => boolean | void;
|
||||
execPath?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/lib/host-timeout.sh
|
||||
source "$SCRIPT_DIR/lib/host-timeout.sh"
|
||||
PLATFORM_NAME="$(uname -s 2>/dev/null || echo unknown)"
|
||||
|
||||
resolve_user_home() {
|
||||
@@ -37,15 +40,7 @@ fail() {
|
||||
}
|
||||
|
||||
run_podman_detached() {
|
||||
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 "$PODMAN_RUN_TIMEOUT" podman run "$@"
|
||||
else
|
||||
timeout "$PODMAN_RUN_TIMEOUT" podman run "$@"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
podman run "$@"
|
||||
openclaw_host_timeout_cmd "$PODMAN_RUN_TIMEOUT" podman run "$@"
|
||||
}
|
||||
|
||||
validate_single_line_value() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveLocalVitestEnv } from "./lib/vitest-local-scheduling.mjs";
|
||||
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
|
||||
import {
|
||||
forceKillVitestProcessGroup,
|
||||
forwardSignalToVitestProcessGroup,
|
||||
installVitestProcessGroupCleanup,
|
||||
shouldUseDetachedVitestProcessGroup,
|
||||
} from "./vitest-process-group.mjs";
|
||||
|
||||
@@ -702,6 +702,22 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/lib/managed-child-process.mjs", ["test/scripts/managed-child-process.test.ts"]],
|
||||
["scripts/lib/npm-verify-exec.ts", ["test/scripts/npm-verify-exec.test.ts"]],
|
||||
["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]],
|
||||
[
|
||||
"scripts/lib/plistbuddy.sh",
|
||||
[
|
||||
"test/scripts/create-dmg.test.ts",
|
||||
"test/scripts/package-mac-app.test.ts",
|
||||
"test/scripts/package-mac-dist.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/lib/plugin-npm-runtime-build.mjs",
|
||||
["test/scripts/plugin-npm-runtime-build-args.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/lib/plugin-npm-package-manifest.mjs",
|
||||
["test/scripts/plugin-npm-package-manifest-args.test.ts"],
|
||||
],
|
||||
["scripts/lib/source-file-scan-cache.mjs", ["test/scripts/source-file-scan-cache.test.ts"]],
|
||||
["scripts/lib/test-group-report.mjs", ["test/scripts/test-group-report.test.ts"]],
|
||||
["scripts/lib/ts-guard-utils.mjs", ["test/scripts/ts-guard-utils.test.ts"]],
|
||||
@@ -715,7 +731,14 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts"],
|
||||
],
|
||||
["scripts/mantis/publish-pr-evidence.mjs", ["test/scripts/mantis-publish-pr-evidence.test.ts"]],
|
||||
["scripts/qa-e2e.ts", ["test/scripts/qa-e2e.test.ts"]],
|
||||
["scripts/qa-lab-up.ts", ["test/scripts/qa-lab-up.test.ts"]],
|
||||
["scripts/qa-coverage-report.ts", ["test/scripts/qa-report-cli.test.ts"]],
|
||||
["scripts/qa-parity-report.ts", ["test/scripts/qa-report-cli.test.ts"]],
|
||||
[
|
||||
"scripts/qa/ux-matrix-evidence-producer.ts",
|
||||
["test/scripts/qa-ux-matrix-evidence-producer.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/run-vitest.mjs",
|
||||
[
|
||||
@@ -734,15 +757,54 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/docker-e2e-rerun.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
|
||||
["scripts/docker-e2e-timings.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
|
||||
["scripts/generate-npm-shrinkwrap.mjs", ["test/scripts/generate-npm-shrinkwrap.test.ts"]],
|
||||
[
|
||||
"scripts/install.sh",
|
||||
[
|
||||
"test/scripts/install-sh.test.ts",
|
||||
"test/scripts/test-install-sh-docker.test.ts",
|
||||
"test/scripts/website-installer-sync-workflow.test.ts",
|
||||
"test/scripts/openclaw-cross-os-release-checks.test.ts",
|
||||
"src/scripts/ci-changed-scope.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/install.ps1",
|
||||
[
|
||||
"test/scripts/install-ps1.test.ts",
|
||||
"test/scripts/website-installer-sync-workflow.test.ts",
|
||||
"test/scripts/openclaw-cross-os-release-checks.test.ts",
|
||||
"src/scripts/ci-changed-scope.test.ts",
|
||||
],
|
||||
],
|
||||
["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]],
|
||||
["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]],
|
||||
["scripts/kova-ci-summary.mjs", ["test/scripts/kova-ci-summary.test.ts"]],
|
||||
["scripts/make_appcast.sh", ["test/scripts/make-appcast.test.ts"]],
|
||||
["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]],
|
||||
["scripts/openclaw-npm-postpublish-verify.ts", ["test/openclaw-npm-postpublish-verify.test.ts"]],
|
||||
["scripts/openclaw-npm-release-check.ts", ["test/openclaw-npm-release-check.test.ts"]],
|
||||
["scripts/openclaw-prepack.ts", ["test/openclaw-prepack.test.ts"]],
|
||||
[
|
||||
"scripts/check-openclaw-package-tarball.mjs",
|
||||
["test/scripts/check-openclaw-package-tarball.test.ts"],
|
||||
],
|
||||
["scripts/check-package-dist-imports.mjs", ["test/scripts/check-package-dist-imports.test.ts"]],
|
||||
[
|
||||
"scripts/check-plugin-npm-runtime-builds.mjs",
|
||||
["test/scripts/plugin-npm-runtime-build-args.test.ts"],
|
||||
],
|
||||
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
|
||||
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
|
||||
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
|
||||
[
|
||||
"scripts/sparkle-build.ts",
|
||||
[
|
||||
"test/appcast.test.ts",
|
||||
"test/release-check.test.ts",
|
||||
"test/scripts/package-mac-app.test.ts",
|
||||
"test/scripts/package-mac-dist.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/package-openclaw-for-docker.mjs",
|
||||
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
|
||||
@@ -797,6 +859,17 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
"test/scripts/plugin-prerelease-test-plan.test.ts",
|
||||
],
|
||||
],
|
||||
["scripts/measure-rpc-rtt.mjs", ["test/scripts/measure-rpc-rtt.test.ts"]],
|
||||
[
|
||||
"scripts/e2e/telegram-user-crabbox-proof.ts",
|
||||
["test/scripts/telegram-user-crabbox-proof.test.ts"],
|
||||
],
|
||||
["scripts/e2e/telegram-user-credential.ts", ["test/scripts/telegram-user-credential.test.ts"]],
|
||||
["scripts/e2e/telegram-user-credential-io.ts", ["test/scripts/telegram-user-credential.test.ts"]],
|
||||
[
|
||||
"scripts/e2e/telegram-user-credential-paths.ts",
|
||||
["test/scripts/telegram-user-credential.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/onboard-docker.sh",
|
||||
["test/scripts/docker-build-helper.test.ts", "test/scripts/openclaw-test-state.test.ts"],
|
||||
@@ -1072,28 +1145,70 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
"src/image-generation/openai-compatible-image-provider.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-chat-tools/client.mjs",
|
||||
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-chat-tools/scenario.sh",
|
||||
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-chat-tools/write-config.mjs",
|
||||
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/openai-chat-tools-docker.sh",
|
||||
["test/scripts/openai-chat-tools-client.test.ts", "test/scripts/docker-e2e-plan.test.ts"],
|
||||
[
|
||||
"test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts",
|
||||
"test/scripts/docker-e2e-plan.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-web-search-minimal/assertions.mjs",
|
||||
["test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-web-search-minimal/client.mjs",
|
||||
["test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs",
|
||||
[
|
||||
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
|
||||
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh",
|
||||
[
|
||||
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
|
||||
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/openai-web-search-minimal-docker.sh",
|
||||
[
|
||||
"test/scripts/docker-build-helper.test.ts",
|
||||
"test/scripts/docker-e2e-plan.test.ts",
|
||||
"test/scripts/openai-web-search-minimal-client.test.ts",
|
||||
"test/scripts/openai-web-search-minimal-assertions.test.ts",
|
||||
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
|
||||
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/openwebui/http-probe.mjs",
|
||||
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/openwebui-docker.sh",
|
||||
[
|
||||
"test/scripts/docker-build-helper.test.ts",
|
||||
"test/scripts/docker-e2e-plan.test.ts",
|
||||
"test/scripts/openwebui-probe.test.ts",
|
||||
"test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts",
|
||||
"test/scripts/fixture-config.test.ts",
|
||||
],
|
||||
],
|
||||
["scripts/e2e/openwebui-probe.mjs", ["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"]],
|
||||
[
|
||||
"scripts/e2e/plugin-binding-command-escape-docker.sh",
|
||||
[
|
||||
@@ -1117,6 +1232,18 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
|
||||
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
|
||||
["scripts/dev/gateway-smoke.ts", ["test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts"]],
|
||||
["scripts/dev/test-device-pair-telegram.ts", ["test/scripts/test-device-pair-telegram.test.ts"]],
|
||||
["scripts/test-live-media.ts", ["test/scripts/test-live-media.test.ts"]],
|
||||
["scripts/profile-extension-memory.mjs", ["test/scripts/profile-extension-memory.test.ts"]],
|
||||
[
|
||||
"scripts/openclaw-performance-source-summary.mjs",
|
||||
["test/scripts/openclaw-performance-source-summary.test.ts"],
|
||||
],
|
||||
["scripts/check-gateway-cpu-scenarios.mjs", ["test/scripts/check-gateway-cpu-scenarios.test.ts"]],
|
||||
[
|
||||
"scripts/check-gateway-watch-regression.mjs",
|
||||
["test/scripts/check-gateway-watch-regression.test.ts"],
|
||||
],
|
||||
["scripts/e2e/cron-mcp-cleanup-seed.ts", ["test/scripts/docker-e2e-seeds.test.ts"]],
|
||||
["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
|
||||
["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
|
||||
|
||||
@@ -358,6 +358,28 @@ function readPackedPackageReadme(packageDir, files) {
|
||||
return fs.readFileSync(path.join(packageDir, readmePath), "utf8").trim();
|
||||
}
|
||||
|
||||
export function usage() {
|
||||
return "Usage: node scripts/verify-plugin-npm-published-runtime.mjs <package-spec>";
|
||||
}
|
||||
|
||||
export function parseVerifyPublishedPluginRuntimeArgs(argv) {
|
||||
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const first = args[0]?.trim();
|
||||
if (first === "--help" || first === "-h") {
|
||||
return { help: true, spec: "" };
|
||||
}
|
||||
if (!first) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (first.startsWith("-")) {
|
||||
throw new Error(`Unknown plugin npm verifier option: ${first}`);
|
||||
}
|
||||
if (args.length > 1) {
|
||||
throw new Error(`Unexpected plugin npm verifier argument: ${args[1]}`);
|
||||
}
|
||||
return { help: false, spec: first };
|
||||
}
|
||||
|
||||
export async function verifyPublishedPluginRuntime(spec) {
|
||||
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-npm-runtime."));
|
||||
try {
|
||||
@@ -396,11 +418,12 @@ export async function verifyPublishedPluginRuntime(spec) {
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const spec = argv[0]?.trim();
|
||||
if (!spec) {
|
||||
throw new Error("Usage: node scripts/verify-plugin-npm-published-runtime.mjs <package-spec>");
|
||||
const args = parseVerifyPublishedPluginRuntimeArgs(argv);
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
const result = await verifyPublishedPluginRuntime(spec);
|
||||
const result = await verifyPublishedPluginRuntime(args.spec);
|
||||
console.log(
|
||||
`plugin-npm-published-runtime-check: ${result.packageName}@${result.version} OK (${result.fileCount} files, ${result.readmeLength} readme chars)`,
|
||||
);
|
||||
|
||||
@@ -98,20 +98,6 @@ export type ToolOutcomeObservation = {
|
||||
|
||||
export type ToolOutcomeObserver = (observation: ToolOutcomeObservation) => void;
|
||||
|
||||
/** Detect abort-related errors produced by the supplied signal. */
|
||||
export function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
|
||||
if (!signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
if (err === signal.reason) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
err instanceof Error &&
|
||||
(err.name === "AbortError" || ("cause" in err && err.cause === signal.reason))
|
||||
);
|
||||
}
|
||||
|
||||
export type HookContext = {
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -791,7 +777,13 @@ async function requestPluginToolApproval(params: {
|
||||
};
|
||||
} catch (err) {
|
||||
notifyPluginApprovalResolution(approval, PluginApprovalResolutions.CANCELLED);
|
||||
if (isAbortSignalCancellation(err, params.signal)) {
|
||||
const signal = params.signal;
|
||||
const abortCancelled =
|
||||
signal?.aborted === true &&
|
||||
(err === signal.reason ||
|
||||
(err instanceof Error &&
|
||||
(err.name === "AbortError" || ("cause" in err && err.cause === signal.reason))));
|
||||
if (abortCancelled) {
|
||||
log.warn(`plugin approval wait cancelled by run abort: ${String(err)}`);
|
||||
return {
|
||||
blocked: true,
|
||||
|
||||
@@ -4,21 +4,42 @@
|
||||
* unsafe or redundant for the active channel.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterToolNamesByMessageProvider } from "./agent-tools.message-provider-policy.js";
|
||||
import { filterToolsByMessageProvider } from "./agent-tools.message-provider-policy.js";
|
||||
|
||||
const DEFAULT_TOOL_NAMES = ["read", "write", "tts", "web_search"];
|
||||
const DEFAULT_TOOLS = [
|
||||
{ name: "read" },
|
||||
{ name: "write" },
|
||||
{ name: "tts" },
|
||||
{ name: "web_search" },
|
||||
];
|
||||
|
||||
function toolNames(tools: readonly { name: string }[]): Set<string> {
|
||||
return new Set(tools.map((tool) => tool.name));
|
||||
}
|
||||
|
||||
describe("createOpenClawCodingTools message provider policy", () => {
|
||||
it.each(["voice", "VOICE", " Voice ", "discord-voice", "DISCORD-VOICE", " Discord-Voice "])(
|
||||
"does not expose tts tool for normalized voice provider: %s",
|
||||
(messageProvider) => {
|
||||
const names = new Set(filterToolNamesByMessageProvider(DEFAULT_TOOL_NAMES, messageProvider));
|
||||
const names = toolNames(filterToolsByMessageProvider(DEFAULT_TOOLS, messageProvider));
|
||||
expect(names.has("tts")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps tts tool for non-voice providers", () => {
|
||||
const names = new Set(filterToolNamesByMessageProvider(DEFAULT_TOOL_NAMES, "guildchat"));
|
||||
const names = toolNames(filterToolsByMessageProvider(DEFAULT_TOOLS, "guildchat"));
|
||||
expect(names.has("tts")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves duplicate tool entries while filtering", () => {
|
||||
const tools = [
|
||||
{ name: "read", id: 1 },
|
||||
{ name: "tts", id: 2 },
|
||||
{ name: "read", id: 3 },
|
||||
];
|
||||
expect(filterToolsByMessageProvider(tools, "voice")).toStrictEqual([
|
||||
{ name: "read", id: 1 },
|
||||
{ name: "read", id: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,49 +14,24 @@ const TOOL_ALLOW_BY_MESSAGE_PROVIDER: Readonly<Record<string, readonly string[]>
|
||||
node: ["canvas", "image", "pdf", "tts", "web_fetch", "web_search"],
|
||||
};
|
||||
|
||||
/** Filters tool names by the active message-provider allow/deny policy. */
|
||||
export function filterToolNamesByMessageProvider(
|
||||
toolNames: readonly string[],
|
||||
messageProvider?: string,
|
||||
): string[] {
|
||||
const normalizedProvider = normalizeOptionalLowercaseString(messageProvider);
|
||||
if (!normalizedProvider) {
|
||||
return [...toolNames];
|
||||
}
|
||||
const allowedTools = TOOL_ALLOW_BY_MESSAGE_PROVIDER[normalizedProvider];
|
||||
if (allowedTools && allowedTools.length > 0) {
|
||||
const allowedSet = new Set(allowedTools);
|
||||
return toolNames.filter((toolName) => allowedSet.has(toolName));
|
||||
}
|
||||
const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider];
|
||||
if (!deniedTools || deniedTools.length === 0) {
|
||||
return [...toolNames];
|
||||
}
|
||||
const deniedSet = new Set(deniedTools);
|
||||
return toolNames.filter((toolName) => !deniedSet.has(toolName));
|
||||
}
|
||||
|
||||
/** Applies message-provider filtering while preserving duplicate tool entries. */
|
||||
export function filterToolsByMessageProvider<TTool extends { name: string }>(
|
||||
tools: readonly TTool[],
|
||||
messageProvider?: string,
|
||||
): TTool[] {
|
||||
const filteredToolNames = filterToolNamesByMessageProvider(
|
||||
tools.map((tool) => tool.name),
|
||||
messageProvider,
|
||||
);
|
||||
const remainingCounts = new Map<string, number>();
|
||||
for (const toolName of filteredToolNames) {
|
||||
remainingCounts.set(toolName, (remainingCounts.get(toolName) ?? 0) + 1);
|
||||
const normalizedProvider = normalizeOptionalLowercaseString(messageProvider);
|
||||
if (!normalizedProvider) {
|
||||
return [...tools];
|
||||
}
|
||||
return tools.filter((tool) => {
|
||||
// Counted matching preserves the original order and duplicate instances
|
||||
// after name-level policy filtering.
|
||||
const remaining = remainingCounts.get(tool.name) ?? 0;
|
||||
if (remaining <= 0) {
|
||||
return false;
|
||||
}
|
||||
remainingCounts.set(tool.name, remaining - 1);
|
||||
return true;
|
||||
});
|
||||
const allowedTools = TOOL_ALLOW_BY_MESSAGE_PROVIDER[normalizedProvider];
|
||||
if (allowedTools && allowedTools.length > 0) {
|
||||
const allowedSet = new Set(allowedTools);
|
||||
return tools.filter((tool) => allowedSet.has(tool.name));
|
||||
}
|
||||
const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider];
|
||||
if (!deniedTools || deniedTools.length === 0) {
|
||||
return [...tools];
|
||||
}
|
||||
const deniedSet = new Set(deniedTools);
|
||||
return tools.filter((tool) => !deniedSet.has(tool.name));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "./agent-tools.before-tool-call.js";
|
||||
import {
|
||||
cleanToolSchemaForGemini,
|
||||
normalizeToolParameterSchema,
|
||||
normalizeToolParameters,
|
||||
} from "./agent-tools.schema.js";
|
||||
@@ -136,15 +135,18 @@ describe("normalizeToolParameterSchema", () => {
|
||||
});
|
||||
|
||||
it("inlines local $ref before removing unsupported keywords", () => {
|
||||
const cleaned = cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
foo: { $ref: "#/$defs/Foo" },
|
||||
const cleaned = normalizeToolParameterSchema(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
foo: { $ref: "#/$defs/Foo" },
|
||||
},
|
||||
$defs: {
|
||||
Foo: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
},
|
||||
$defs: {
|
||||
Foo: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
}) as {
|
||||
{ modelProvider: "gemini" },
|
||||
) as {
|
||||
$defs?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
@@ -600,18 +602,21 @@ describe("normalizeToolParameterSchema", () => {
|
||||
});
|
||||
|
||||
it("cleans tuple items schemas", () => {
|
||||
const cleaned = cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
const cleaned = normalizeToolParameterSchema(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
{ modelProvider: "gemini" },
|
||||
) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -625,13 +630,16 @@ describe("normalizeToolParameterSchema", () => {
|
||||
});
|
||||
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
const cleaned = normalizeToolParameterSchema(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
{ modelProvider: "gemini" },
|
||||
) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -92,11 +92,3 @@ export function normalizeToolParameters(
|
||||
parameters,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use normalizeToolParameters with modelProvider instead.
|
||||
* This function should only be used for Gemini providers.
|
||||
*/
|
||||
export function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
|
||||
return normalizeToolParameterSchema(schema, { modelProvider: "gemini" });
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
wrapToolWorkspaceRootGuardWithOptions,
|
||||
wrapToolParamValidation,
|
||||
} from "./agent-tools.read.js";
|
||||
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./agent-tools.schema.js";
|
||||
import { normalizeToolParameters } from "./agent-tools.schema.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { createApplyPatchTool } from "./apply-patch.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
@@ -409,7 +409,6 @@ export { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js"
|
||||
|
||||
/** Test-only access to internal tool assembly helpers. */
|
||||
export const testing = {
|
||||
cleanToolSchemaForGemini,
|
||||
getToolParamsRecord,
|
||||
wrapToolParamValidation,
|
||||
assertRequiredParams,
|
||||
|
||||
@@ -158,13 +158,19 @@ export async function registerExecApprovalRequest(
|
||||
return { id, expiresAtMs };
|
||||
}
|
||||
|
||||
/** Waits for a registered approval decision, returning null when it expires. */
|
||||
export async function waitForExecApprovalDecision(id: string): Promise<string | null> {
|
||||
/** Uses a pre-resolved decision or waits for the registered approval id. */
|
||||
export async function resolveRegisteredExecApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
preResolvedDecision: string | null | undefined;
|
||||
}): Promise<string | null> {
|
||||
if (params.preResolvedDecision !== undefined) {
|
||||
return params.preResolvedDecision ?? null;
|
||||
}
|
||||
try {
|
||||
const decisionResult = await callGatewayTool<{ decision: string }>(
|
||||
"exec.approval.waitDecision",
|
||||
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
|
||||
{ id },
|
||||
{ id: params.approvalId },
|
||||
);
|
||||
return parseDecision(decisionResult).value;
|
||||
} catch (err) {
|
||||
@@ -177,17 +183,6 @@ export async function waitForExecApprovalDecision(id: string): Promise<string |
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses a pre-resolved decision or waits for the registered approval id. */
|
||||
export async function resolveRegisteredExecApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
preResolvedDecision: string | null | undefined;
|
||||
}): Promise<string | null> {
|
||||
if (params.preResolvedDecision !== undefined) {
|
||||
return params.preResolvedDecision ?? null;
|
||||
}
|
||||
return await waitForExecApprovalDecision(params.approvalId);
|
||||
}
|
||||
|
||||
type HostExecApprovalParams = {
|
||||
approvalId: string;
|
||||
command?: string;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js";
|
||||
import { isOpenRouterAnthropicModelRef } from "../../llm/providers/stream-wrappers/anthropic-family-cache-semantics.js";
|
||||
import { testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js";
|
||||
import { resolveCacheRetention } from "./prompt-cache-retention.js";
|
||||
|
||||
@@ -324,11 +323,3 @@ describe("cacheRetention default behavior", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("anthropic-family cache semantics", () => {
|
||||
it("classifies OpenRouter Anthropic model refs centrally", () => {
|
||||
expect(isOpenRouterAnthropicModelRef("openrouter", "anthropic/claude-opus-4-6")).toBe(true);
|
||||
expect(isOpenRouterAnthropicModelRef("openrouter", "google/gemini-2.5-pro")).toBe(false);
|
||||
expect(isOpenRouterAnthropicModelRef("OpenRouter", "Anthropic/Claude-Sonnet-4")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,23 +33,6 @@ type AttemptWorkspaceBootstrapRoutingInput = Omit<
|
||||
bootstrapFiles?: readonly WorkspaceBootstrapFile[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a resolved bootstrap mode to concrete prompt destinations. Today only
|
||||
* full bootstrap enters system context; limited/none intentionally avoid
|
||||
* runtime-context injection until that path has a separate contract.
|
||||
*/
|
||||
export function resolveBootstrapContextTargets(params: {
|
||||
bootstrapMode: BootstrapMode;
|
||||
}): Pick<
|
||||
AttemptBootstrapRouting,
|
||||
"includeBootstrapInSystemContext" | "includeBootstrapInRuntimeContext"
|
||||
> {
|
||||
return {
|
||||
includeBootstrapInSystemContext: params.bootstrapMode === "full",
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAttemptBootstrapRouting(
|
||||
params: AttemptBootstrapRoutingInput,
|
||||
): AttemptBootstrapRouting {
|
||||
@@ -66,22 +49,11 @@ function resolveAttemptBootstrapRouting(
|
||||
|
||||
return {
|
||||
bootstrapMode,
|
||||
...resolveBootstrapContextTargets({ bootstrapMode }),
|
||||
includeBootstrapInSystemContext: bootstrapMode === "full",
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasBootstrapFileContent(files?: readonly WorkspaceBootstrapFile[]): boolean {
|
||||
return (
|
||||
files?.some(
|
||||
(file) =>
|
||||
file.name === DEFAULT_BOOTSTRAP_FILENAME &&
|
||||
!file.missing &&
|
||||
typeof file.content === "string" &&
|
||||
file.content.trim().length > 0,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves workspace bootstrap routing after checking pending state and
|
||||
* hook-provided bootstrap files. Hook content counts as both pending bootstrap
|
||||
@@ -94,7 +66,14 @@ export async function resolveAttemptWorkspaceBootstrapRouting(
|
||||
const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending(
|
||||
params.resolvedWorkspace,
|
||||
);
|
||||
const hasHookBootstrapContent = hasBootstrapFileContent(params.bootstrapFiles);
|
||||
const hasHookBootstrapContent =
|
||||
params.bootstrapFiles?.some(
|
||||
(file) =>
|
||||
file.name === DEFAULT_BOOTSTRAP_FILENAME &&
|
||||
!file.missing &&
|
||||
typeof file.content === "string" &&
|
||||
file.content.trim().length > 0,
|
||||
) ?? false;
|
||||
return resolveAttemptBootstrapRouting({
|
||||
...params,
|
||||
workspaceBootstrapPending: workspaceBootstrapPending || hasHookBootstrapContent,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Coverage for bootstrap routing across canonical and effective workspaces.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hasBootstrapFileContent,
|
||||
resolveBootstrapContextTargets,
|
||||
resolveAttemptWorkspaceBootstrapRouting,
|
||||
} from "./attempt-bootstrap-routing.js";
|
||||
import { resolveAttemptWorkspaceBootstrapRouting } from "./attempt-bootstrap-routing.js";
|
||||
|
||||
describe("runEmbeddedAttempt bootstrap routing", () => {
|
||||
it("resolves bootstrap pending from the canonical workspace instead of a copied sandbox", async () => {
|
||||
@@ -100,34 +96,27 @@ describe("runEmbeddedAttempt bootstrap routing", () => {
|
||||
expect(routing.includeBootstrapInRuntimeContext).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat empty hook-provided BOOTSTRAP.md as pending bootstrap context", () => {
|
||||
expect(
|
||||
hasBootstrapFileContent([
|
||||
it("does not treat empty hook-provided BOOTSTRAP.md as pending bootstrap context", async () => {
|
||||
const routing = await resolveAttemptWorkspaceBootstrapRouting({
|
||||
isWorkspaceBootstrapPending: vi.fn(async () => false),
|
||||
bootstrapFiles: [
|
||||
{
|
||||
name: "BOOTSTRAP.md",
|
||||
path: "/tmp/openclaw-workspace/BOOTSTRAP.md",
|
||||
content: " ",
|
||||
missing: false,
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
],
|
||||
trigger: "user",
|
||||
isPrimaryRun: true,
|
||||
isCanonicalWorkspace: true,
|
||||
effectiveWorkspace: "/tmp/openclaw-workspace",
|
||||
resolvedWorkspace: "/tmp/openclaw-workspace",
|
||||
hasBootstrapFileAccess: true,
|
||||
});
|
||||
|
||||
it("keeps BOOTSTRAP.md in Project Context for full bootstrap turns", () => {
|
||||
expect(resolveBootstrapContextTargets({ bootstrapMode: "full" })).toEqual({
|
||||
includeBootstrapInSystemContext: true,
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes BOOTSTRAP.md from every context outside full bootstrap turns", () => {
|
||||
expect(resolveBootstrapContextTargets({ bootstrapMode: "limited" })).toEqual({
|
||||
includeBootstrapInSystemContext: false,
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
});
|
||||
expect(resolveBootstrapContextTargets({ bootstrapMode: "none" })).toEqual({
|
||||
includeBootstrapInSystemContext: false,
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
});
|
||||
expect(routing.bootstrapMode).toBe("none");
|
||||
expect(routing.includeBootstrapInSystemContext).toBe(false);
|
||||
expect(routing.includeBootstrapInRuntimeContext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "../stream-resolution.js";
|
||||
import { resolveBootstrapContextTargets } from "./attempt-bootstrap-routing.js";
|
||||
import { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
|
||||
import {
|
||||
buildAfterTurnRuntimeContext,
|
||||
@@ -332,23 +331,6 @@ describe("resolvePromptModeForSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBootstrapContextTargets", () => {
|
||||
it("keeps BOOTSTRAP.md in system Project Context only for full bootstrap turns", () => {
|
||||
expect(resolveBootstrapContextTargets({ bootstrapMode: "full" })).toEqual({
|
||||
includeBootstrapInSystemContext: true,
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
});
|
||||
expect(resolveBootstrapContextTargets({ bootstrapMode: "limited" })).toEqual({
|
||||
includeBootstrapInSystemContext: false,
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
});
|
||||
expect(resolveBootstrapContextTargets({ bootstrapMode: "none" })).toEqual({
|
||||
includeBootstrapInSystemContext: false,
|
||||
includeBootstrapInRuntimeContext: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnOnOrphanedUserRepair", () => {
|
||||
it("warns for user and manual runs", () => {
|
||||
expect(shouldWarnOnOrphanedUserRepair("user")).toBe(true);
|
||||
|
||||
@@ -1,65 +1,7 @@
|
||||
// Coverage for Tool Search control planning and allowlist accounting.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setPluginToolMeta } from "../../../plugins/tools.js";
|
||||
import {
|
||||
buildAutoAddedToolSearchControlNamesForAllowlistCheck,
|
||||
buildCallableToolNamesForEmptyAllowlistCheck,
|
||||
buildToolSearchRunPlan,
|
||||
} from "./attempt.tool-search-run-plan.js";
|
||||
|
||||
describe("buildCallableToolNamesForEmptyAllowlistCheck", () => {
|
||||
it("ignores auto-added Tool Search controls so bad allowlists still fail", () => {
|
||||
// Auto-added controls are not real callable tools when the backing catalog
|
||||
// is empty.
|
||||
expect(
|
||||
buildCallableToolNamesForEmptyAllowlistCheck({
|
||||
effectiveToolNames: ["tool_search_code"],
|
||||
autoAddedToolSearchControlNames: new Set(["tool_search_code"]),
|
||||
toolSearchCatalogToolCount: 0,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts cataloged tools hidden behind auto-added Tool Search controls", () => {
|
||||
expect(
|
||||
buildCallableToolNamesForEmptyAllowlistCheck({
|
||||
effectiveToolNames: ["tool_search_code"],
|
||||
autoAddedToolSearchControlNames: new Set(["tool_search_code"]),
|
||||
toolSearchCatalogToolCount: 1,
|
||||
}),
|
||||
).toEqual(["tool-search:0"]);
|
||||
});
|
||||
|
||||
it("keeps explicitly requested Tool Search controls callable", () => {
|
||||
expect(
|
||||
buildCallableToolNamesForEmptyAllowlistCheck({
|
||||
effectiveToolNames: ["tool_search_code"],
|
||||
autoAddedToolSearchControlNames: new Set(),
|
||||
toolSearchCatalogToolCount: 0,
|
||||
}),
|
||||
).toEqual(["tool_search_code"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAutoAddedToolSearchControlNamesForAllowlistCheck", () => {
|
||||
it("treats controls as auto-added unless any explicit allowlist requested them", () => {
|
||||
expect(
|
||||
buildAutoAddedToolSearchControlNamesForAllowlistCheck({
|
||||
toolSearchControlsEnabled: true,
|
||||
explicitAllowlistSources: [{ entries: ["missing_tool"] }],
|
||||
controlNames: ["tool_search_code", "tool_search"],
|
||||
}),
|
||||
).toEqual(new Set(["tool_search_code", "tool_search"]));
|
||||
|
||||
expect(
|
||||
buildAutoAddedToolSearchControlNamesForAllowlistCheck({
|
||||
toolSearchControlsEnabled: true,
|
||||
explicitAllowlistSources: [{ entries: ["tool_search_code"] }],
|
||||
controlNames: ["tool_search_code", "tool_search"],
|
||||
}),
|
||||
).toEqual(new Set(["tool_search"]));
|
||||
});
|
||||
});
|
||||
import { buildToolSearchRunPlan } from "./attempt.tool-search-run-plan.js";
|
||||
|
||||
describe("buildToolSearchRunPlan", () => {
|
||||
it("keeps compact visible names separate from replay-safe names", () => {
|
||||
@@ -161,6 +103,19 @@ describe("buildToolSearchRunPlan", () => {
|
||||
expect(plan.emptyAllowlistCallableNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicitly requested Tool Search controls callable", () => {
|
||||
const plan = buildToolSearchRunPlan({
|
||||
visibleTools: [{ name: "tool_search_code" }] as never,
|
||||
uncompactedTools: [{ name: "tool_search_code" }] as never,
|
||||
clientToolsCataloged: true,
|
||||
catalogToolCount: 0,
|
||||
controlsEnabled: true,
|
||||
explicitAllowlistSources: [{ entries: ["tool_search_code"] }],
|
||||
});
|
||||
|
||||
expect(plan.emptyAllowlistCallableNames).toEqual(["tool_search_code"]);
|
||||
});
|
||||
|
||||
it("keeps uncataloged directory-mode client tools visible", () => {
|
||||
const plan = buildToolSearchRunPlan({
|
||||
visibleTools: [
|
||||
|
||||
@@ -29,56 +29,9 @@ type ToolSearchRunPlan = {
|
||||
replayAllowedToolNames: Set<string>;
|
||||
liveAllowedToolNames: Set<string>;
|
||||
capabilityToolNames: Set<string>;
|
||||
autoAddedControlNames?: Set<string>;
|
||||
emptyAllowlistCallableNames: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the callable-name list used to decide whether an allowlist is empty.
|
||||
* Auto-added tool-search controls are excluded so they do not make an otherwise
|
||||
* empty user/tool allowlist look populated.
|
||||
*/
|
||||
export function buildCallableToolNamesForEmptyAllowlistCheck(params: {
|
||||
effectiveToolNames: string[];
|
||||
autoAddedToolSearchControlNames?: Set<string>;
|
||||
toolSearchCatalogToolCount: number;
|
||||
}): string[] {
|
||||
return [
|
||||
...params.effectiveToolNames.filter(
|
||||
(toolName) => !params.autoAddedToolSearchControlNames?.has(toolName),
|
||||
),
|
||||
...Array.from(
|
||||
{ length: params.toolSearchCatalogToolCount },
|
||||
(_, index) => `tool-search:${index}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies tool-search control names that were added by policy rather than
|
||||
* explicitly allowed by the user. Explicit controls stay visible to empty
|
||||
* allowlist checks because the user selected them.
|
||||
*/
|
||||
export function buildAutoAddedToolSearchControlNamesForAllowlistCheck(params: {
|
||||
toolSearchControlsEnabled: boolean;
|
||||
explicitAllowlistSources: Array<{ entries: string[] }>;
|
||||
controlNames?: readonly string[];
|
||||
}): Set<string> | undefined {
|
||||
if (!params.toolSearchControlsEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
const explicitlyAllowed = new Set(
|
||||
params.explicitAllowlistSources.flatMap((source) =>
|
||||
source.entries.map((entry) => normalizeToolName(entry)),
|
||||
),
|
||||
);
|
||||
return new Set(
|
||||
(params.controlNames ?? TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES).filter(
|
||||
(controlName) => !explicitlyAllowed.has(normalizeToolName(controlName)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function collectExplicitlyAllowedClientToolNames(params: {
|
||||
clientTools?: CollectAllowedToolNamesParams["clientTools"];
|
||||
explicitAllowlistSources: Array<{ entries: string[] }>;
|
||||
@@ -153,11 +106,17 @@ export function buildToolSearchRunPlan(params: {
|
||||
liveAllowedToolNames.add(visibleName);
|
||||
}
|
||||
}
|
||||
const autoAddedControlNames = buildAutoAddedToolSearchControlNamesForAllowlistCheck({
|
||||
toolSearchControlsEnabled: params.controlsEnabled,
|
||||
explicitAllowlistSources: params.explicitAllowlistSources,
|
||||
controlNames: params.controlNames,
|
||||
});
|
||||
const explicitControlAllowlistNames = new Set(
|
||||
params.explicitAllowlistSources.flatMap((source) =>
|
||||
source.entries.map((entry) => normalizeToolName(entry)),
|
||||
),
|
||||
);
|
||||
const autoAddedControlNames = new Set(
|
||||
(params.controlsEnabled
|
||||
? (params.controlNames ?? TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES)
|
||||
: []
|
||||
).filter((controlName) => !explicitControlAllowlistNames.has(normalizeToolName(controlName))),
|
||||
);
|
||||
const explicitlyAllowedClientToolNames = collectExplicitlyAllowedClientToolNames({
|
||||
clientTools: params.clientTools,
|
||||
explicitAllowlistSources: params.explicitAllowlistSources,
|
||||
@@ -175,13 +134,11 @@ export function buildToolSearchRunPlan(params: {
|
||||
replayAllowedToolNames,
|
||||
liveAllowedToolNames,
|
||||
capabilityToolNames,
|
||||
autoAddedControlNames,
|
||||
emptyAllowlistCallableNames: [
|
||||
...buildCallableToolNamesForEmptyAllowlistCheck({
|
||||
effectiveToolNames: [...emptyAllowlistVisibleToolNames],
|
||||
autoAddedToolSearchControlNames: autoAddedControlNames,
|
||||
toolSearchCatalogToolCount: params.catalogToolCount,
|
||||
}),
|
||||
...[...emptyAllowlistVisibleToolNames].filter(
|
||||
(toolName) => !autoAddedControlNames.has(toolName),
|
||||
),
|
||||
...Array.from({ length: params.catalogToolCount }, (_, index) => `tool-search:${index}`),
|
||||
...explicitClientCallableNames,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resolveFinalAssistantRawText,
|
||||
resolveFinalAssistantVisibleText,
|
||||
resolveNextSameModelRateLimitRetryCount,
|
||||
resolveSameModelRateLimitBackoffMs,
|
||||
resolveSameModelRateLimitRetryDelayMs,
|
||||
} from "./helpers.js";
|
||||
|
||||
@@ -88,23 +87,23 @@ describe("resolveFinalAssistantVisibleText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSameModelRateLimitBackoffMs", () => {
|
||||
describe("resolveSameModelRateLimitRetryDelayMs", () => {
|
||||
it("waits 10s/20s/30s linearly before the 1st/2nd/3rd same-model retry", () => {
|
||||
expect(resolveSameModelRateLimitBackoffMs(0)).toBe(10_000);
|
||||
expect(resolveSameModelRateLimitBackoffMs(1)).toBe(20_000);
|
||||
expect(resolveSameModelRateLimitBackoffMs(2)).toBe(30_000);
|
||||
expect(resolveSameModelRateLimitRetryDelayMs({ retriesSoFar: 0 })).toBe(10_000);
|
||||
expect(resolveSameModelRateLimitRetryDelayMs({ retriesSoFar: 1 })).toBe(20_000);
|
||||
expect(resolveSameModelRateLimitRetryDelayMs({ retriesSoFar: 2 })).toBe(30_000);
|
||||
});
|
||||
|
||||
it("caps at 60s if the retry count is ever raised further", () => {
|
||||
expect(resolveSameModelRateLimitBackoffMs(10)).toBe(60_000);
|
||||
expect(resolveSameModelRateLimitRetryDelayMs({ retriesSoFar: 10 })).toBe(60_000);
|
||||
});
|
||||
|
||||
it("is deterministic so RPM windows clear predictably", () => {
|
||||
expect(resolveSameModelRateLimitBackoffMs(2)).toBe(resolveSameModelRateLimitBackoffMs(2));
|
||||
expect(resolveSameModelRateLimitRetryDelayMs({ retriesSoFar: 2 })).toBe(
|
||||
resolveSameModelRateLimitRetryDelayMs({ retriesSoFar: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSameModelRateLimitRetryDelayMs", () => {
|
||||
it("honors a short provider Retry-After when it is longer than the fixed backoff", () => {
|
||||
expect(
|
||||
resolveSameModelRateLimitRetryDelayMs({
|
||||
|
||||
@@ -65,16 +65,13 @@ export function resolveRateLimitProfileRotationLimit(cfg?: OpenClawConfig): numb
|
||||
* retries already happened. Linear and deterministic (no jitter) so RPM
|
||||
* windows clear predictably and tests can assert exact values.
|
||||
*/
|
||||
export function resolveSameModelRateLimitBackoffMs(retriesSoFar: number): number {
|
||||
const delay = SAME_MODEL_RATE_LIMIT_BACKOFF_STEP_MS * (Math.max(0, retriesSoFar) + 1);
|
||||
return Math.min(SAME_MODEL_RATE_LIMIT_MAX_BACKOFF_MS, delay);
|
||||
}
|
||||
|
||||
export function resolveSameModelRateLimitRetryDelayMs(params: {
|
||||
retriesSoFar: number;
|
||||
retryAfterSeconds?: number;
|
||||
}): number {
|
||||
const backoffMs = resolveSameModelRateLimitBackoffMs(params.retriesSoFar);
|
||||
const backoffDelayMs =
|
||||
SAME_MODEL_RATE_LIMIT_BACKOFF_STEP_MS * (Math.max(0, params.retriesSoFar) + 1);
|
||||
const backoffMs = Math.min(SAME_MODEL_RATE_LIMIT_MAX_BACKOFF_MS, backoffDelayMs);
|
||||
const retryAfterMs = Number.isFinite(params.retryAfterSeconds)
|
||||
? Math.ceil(Math.max(0, params.retryAfterSeconds ?? 0) * 1000)
|
||||
: 0;
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCurrentInboundPrompt,
|
||||
buildCurrentInboundPromptContextPrefix,
|
||||
buildRuntimeContextCustomMessage,
|
||||
buildRuntimeContextSystemContext,
|
||||
resolveRuntimeContextPromptParts,
|
||||
} from "./runtime-context-prompt.js";
|
||||
|
||||
@@ -283,31 +281,6 @@ describe("runtime context prompt submission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current-turn context as prompt-local text", () => {
|
||||
expect(
|
||||
buildCurrentInboundPromptContextPrefix({
|
||||
text: "Conversation info (untrusted metadata):\n```json\n{}\n```",
|
||||
}),
|
||||
).toBe("Conversation info (untrusted metadata):\n```json\n{}\n```");
|
||||
});
|
||||
|
||||
it("can use compact current-turn context for resumable backends", () => {
|
||||
expect(
|
||||
buildCurrentInboundPromptContextPrefix(
|
||||
{
|
||||
text: "Room context:\nAlice: lunch?\n\nCurrent event:\nBob: yes",
|
||||
resumableText: "Current event:\nBob: yes",
|
||||
},
|
||||
{ preferResumableText: true },
|
||||
),
|
||||
).toBe("Current event:\nBob: yes");
|
||||
});
|
||||
|
||||
it("omits empty current-turn context", () => {
|
||||
expect(buildCurrentInboundPromptContextPrefix(undefined)).toBe("");
|
||||
expect(buildCurrentInboundPromptContextPrefix({ text: " " })).toBe("");
|
||||
});
|
||||
|
||||
it("joins current-turn context and prompt with the requested separator", () => {
|
||||
expect(
|
||||
buildCurrentInboundPrompt({
|
||||
@@ -333,6 +306,13 @@ describe("runtime context prompt submission", () => {
|
||||
preferResumableText: true,
|
||||
}),
|
||||
).toBe("Current event:\nBob: yes\n\n[OpenClaw room event]");
|
||||
|
||||
expect(
|
||||
buildCurrentInboundPrompt({
|
||||
context: { text: " " },
|
||||
prompt: "visible ask",
|
||||
}),
|
||||
).toBe("visible ask");
|
||||
});
|
||||
|
||||
it("builds runtime context as prompt-local custom context before the current user prompt", () => {
|
||||
@@ -352,20 +332,14 @@ describe("runtime context prompt submission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("labels next-turn runtime context only when used as prompt-local system context", () => {
|
||||
const systemContext = buildRuntimeContextSystemContext("secret runtime context");
|
||||
it("labels runtime-only events as system context", () => {
|
||||
const parts = resolveRuntimeContextPromptParts({
|
||||
effectivePrompt: "internal event",
|
||||
transcriptPrompt: "",
|
||||
});
|
||||
|
||||
expect(systemContext).toContain(
|
||||
"OpenClaw runtime context for the immediately preceding user message.",
|
||||
);
|
||||
expect(systemContext).toContain("not user-authored");
|
||||
expect(systemContext).toContain("secret runtime context");
|
||||
});
|
||||
|
||||
it("labels runtime-only events as system context", async () => {
|
||||
const { buildRuntimeEventSystemContext } = await import("./runtime-context-prompt.js");
|
||||
|
||||
expect(buildRuntimeEventSystemContext("internal event")).toContain("OpenClaw runtime event.");
|
||||
expect(buildRuntimeEventSystemContext("internal event")).toContain("not user-authored");
|
||||
expect(parts.runtimeSystemContext).toContain("OpenClaw runtime event.");
|
||||
expect(parts.runtimeSystemContext).toContain("not user-authored");
|
||||
expect(parts.runtimeSystemContext).toContain("internal event");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,27 +34,17 @@ export type RuntimeContextCustomMessage = {
|
||||
|
||||
type EmptyTranscriptMode = "model-prompt" | "runtime-event";
|
||||
|
||||
/** Returns the visible or resumable inbound prompt prefix used before the user prompt. */
|
||||
export function buildCurrentInboundPromptContextPrefix(
|
||||
context: CurrentInboundPromptContext | undefined,
|
||||
options?: { preferResumableText?: boolean },
|
||||
): string {
|
||||
const text =
|
||||
options?.preferResumableText === true
|
||||
? (context?.resumableText ?? context?.text)
|
||||
: context?.text;
|
||||
return text?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Combines inbound context and the current prompt using the channel-provided joiner. */
|
||||
export function buildCurrentInboundPrompt(params: {
|
||||
context: CurrentInboundPromptContext | undefined;
|
||||
prompt: string;
|
||||
preferResumableText?: boolean;
|
||||
}): string {
|
||||
const prefix = buildCurrentInboundPromptContextPrefix(params.context, {
|
||||
preferResumableText: params.preferResumableText,
|
||||
});
|
||||
const contextText =
|
||||
params.preferResumableText === true
|
||||
? (params.context?.resumableText ?? params.context?.text)
|
||||
: params.context?.text;
|
||||
const prefix = contextText?.trim() ?? "";
|
||||
if (!prefix) {
|
||||
return params.prompt;
|
||||
}
|
||||
@@ -133,7 +123,10 @@ export function resolveRuntimeContextPromptParts(params: {
|
||||
: {}),
|
||||
runtimeContext,
|
||||
runtimeOnly: true,
|
||||
runtimeSystemContext: buildRuntimeEventSystemContext(runtimeContext),
|
||||
runtimeSystemContext: buildRuntimeContextMessageContent({
|
||||
runtimeContext,
|
||||
kind: "runtime-event",
|
||||
}),
|
||||
}
|
||||
: {
|
||||
prompt: "",
|
||||
@@ -169,16 +162,6 @@ function buildRuntimeContextMessageContent(params: {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Builds the hidden next-turn system context payload for model conversion. */
|
||||
export function buildRuntimeContextSystemContext(runtimeContext: string): string {
|
||||
return buildRuntimeContextMessageContent({ runtimeContext, kind: "next-turn" });
|
||||
}
|
||||
|
||||
/** Builds the hidden runtime-event system context payload for empty runtime-only turns. */
|
||||
export function buildRuntimeEventSystemContext(runtimeContext: string): string {
|
||||
return buildRuntimeContextMessageContent({ runtimeContext, kind: "runtime-event" });
|
||||
}
|
||||
|
||||
/** Creates a non-displayed custom transcript message for runtime context, if any exists. */
|
||||
export function buildRuntimeContextCustomMessage(
|
||||
runtimeContext: string | undefined,
|
||||
@@ -190,7 +173,10 @@ export function buildRuntimeContextCustomMessage(
|
||||
return {
|
||||
role: "custom",
|
||||
customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE,
|
||||
content: buildRuntimeContextSystemContext(trimmedRuntimeContext),
|
||||
content: buildRuntimeContextMessageContent({
|
||||
runtimeContext: trimmedRuntimeContext,
|
||||
kind: "next-turn",
|
||||
}),
|
||||
display: false,
|
||||
details: { source: "openclaw-runtime-context" },
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -80,6 +80,7 @@ import { parseExecApprovalResultText } from "./exec-approval-result.js";
|
||||
import type { AgentEvent } from "./runtime/index.js";
|
||||
import { buildToolMutationState, isSameToolMutationAction } from "./tool-mutation.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
import { readToolResultDetails } from "./tool-result-error.js";
|
||||
|
||||
type ExecApprovalReplyModule = typeof import("../infra/exec-approval-reply.js");
|
||||
type HookRunnerGlobalModule = typeof import("../plugins/hook-runner-global.js");
|
||||
@@ -336,10 +337,6 @@ function emitAgentEventCallbackBestEffort(
|
||||
}
|
||||
}
|
||||
|
||||
function readToolResultDetailsRecord(result: unknown): Record<string, unknown> | undefined {
|
||||
return readRecordField(asOptionalObjectRecord(result)?.details);
|
||||
}
|
||||
|
||||
function applyCurrentMessageProvider(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
@@ -357,21 +354,21 @@ function applyCurrentMessageProvider(
|
||||
}
|
||||
|
||||
function applyToolSendReceiptForExtraction(result: unknown, receiptResult: unknown): unknown {
|
||||
const toolSend = readToolResultDetailsRecord(receiptResult)?.toolSend;
|
||||
const toolSend = readToolResultDetails(receiptResult)?.toolSend;
|
||||
if (toolSend === undefined) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...readRecordField(result),
|
||||
details: {
|
||||
...readToolResultDetailsRecord(result),
|
||||
...readToolResultDetails(result),
|
||||
toolSend,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isAsyncStartedToolResult(result: unknown): boolean {
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
const details = readToolResultDetails(result);
|
||||
return details?.async === true && details.status === "started";
|
||||
}
|
||||
|
||||
@@ -379,7 +376,7 @@ function readAsyncStartedTaskIds(result: unknown): {
|
||||
asyncTaskRunId?: string;
|
||||
asyncTaskId?: string;
|
||||
} {
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
const details = readToolResultDetails(result);
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
@@ -393,7 +390,7 @@ function readAsyncStartedTaskIds(result: unknown): {
|
||||
}
|
||||
|
||||
function readExecToolDetails(result: unknown): ExecToolDetails | null {
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
const details = readToolResultDetails(result);
|
||||
if (!details || typeof details.status !== "string") {
|
||||
return null;
|
||||
}
|
||||
@@ -423,7 +420,7 @@ function capLiveExecResult(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
const details = readToolResultDetails(result);
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
details: {
|
||||
@@ -474,7 +471,7 @@ function shouldEmitLiveExecUpdate(ctx: ToolHandlerContext, toolCallId: string):
|
||||
}
|
||||
|
||||
function readApplyPatchSummary(result: unknown): ApplyPatchSummary | null {
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
const details = readToolResultDetails(result);
|
||||
const summary =
|
||||
details?.summary && typeof details.summary === "object" && !Array.isArray(details.summary)
|
||||
? (details.summary as Record<string, unknown>)
|
||||
|
||||
@@ -7,7 +7,6 @@ const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
let isResolvedSessionVisibleToRequester: typeof import("./sessions-resolution.js").isResolvedSessionVisibleToRequester;
|
||||
let looksLikeSessionId: typeof import("./sessions-resolution.js").looksLikeSessionId;
|
||||
let looksLikeSessionKey: typeof import("./sessions-resolution.js").looksLikeSessionKey;
|
||||
let resolveCurrentSessionClientAlias: typeof import("./sessions-resolution.js").resolveCurrentSessionClientAlias;
|
||||
@@ -15,12 +14,11 @@ let resolveDisplaySessionKey: typeof import("./sessions-resolution.js").resolveD
|
||||
let resolveInternalSessionKey: typeof import("./sessions-resolution.js").resolveInternalSessionKey;
|
||||
let resolveMainSessionAlias: typeof import("./sessions-resolution.js").resolveMainSessionAlias;
|
||||
let resolveSessionReference: typeof import("./sessions-resolution.js").resolveSessionReference;
|
||||
let shouldVerifyRequesterSpawnedSessionVisibility: typeof import("./sessions-resolution.js").shouldVerifyRequesterSpawnedSessionVisibility;
|
||||
let resolveVisibleSessionReference: typeof import("./sessions-resolution.js").resolveVisibleSessionReference;
|
||||
let shouldResolveSessionIdInput: typeof import("./sessions-resolution.js").shouldResolveSessionIdInput;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
isResolvedSessionVisibleToRequester,
|
||||
looksLikeSessionId,
|
||||
looksLikeSessionKey,
|
||||
resolveCurrentSessionClientAlias,
|
||||
@@ -28,7 +26,7 @@ beforeAll(async () => {
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
resolveSessionReference,
|
||||
shouldVerifyRequesterSpawnedSessionVisibility,
|
||||
resolveVisibleSessionReference,
|
||||
shouldResolveSessionIdInput,
|
||||
} = await import("./sessions-resolution.js"));
|
||||
});
|
||||
@@ -166,58 +164,60 @@ describe("session reference shape detection", () => {
|
||||
});
|
||||
|
||||
describe("resolved session visibility checks", () => {
|
||||
it("requires spawned-session verification only for sandboxed key-based cross-session access", () => {
|
||||
expect(
|
||||
shouldVerifyRequesterSpawnedSessionVisibility({
|
||||
it("requires spawned-session verification only for sandboxed key-based cross-session access", async () => {
|
||||
const cases = [
|
||||
{
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:worker",
|
||||
restrictToSpawned: true,
|
||||
resolvedViaSessionId: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldVerifyRequesterSpawnedSessionVisibility({
|
||||
expectsGateway: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:worker",
|
||||
restrictToSpawned: false,
|
||||
resolvedViaSessionId: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldVerifyRequesterSpawnedSessionVisibility({
|
||||
expectsGateway: false,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:worker",
|
||||
restrictToSpawned: true,
|
||||
resolvedViaSessionId: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldVerifyRequesterSpawnedSessionVisibility({
|
||||
expectsGateway: false,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:main",
|
||||
restrictToSpawned: true,
|
||||
resolvedViaSessionId: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
expectsGateway: false,
|
||||
},
|
||||
];
|
||||
|
||||
it("returns true immediately when spawned-session verification is not required", async () => {
|
||||
await expect(
|
||||
isResolvedSessionVisibleToRequester({
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:main",
|
||||
restrictToSpawned: true,
|
||||
resolvedViaSessionId: false,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
isResolvedSessionVisibleToRequester({
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:other",
|
||||
restrictToSpawned: false,
|
||||
resolvedViaSessionId: false,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
for (const testCase of cases) {
|
||||
callGatewayMock.mockResolvedValueOnce({ key: testCase.targetSessionKey });
|
||||
const result = resolveVisibleSessionReference({
|
||||
resolvedSession: {
|
||||
ok: true,
|
||||
key: testCase.targetSessionKey,
|
||||
displayKey: testCase.targetSessionKey,
|
||||
resolvedViaSessionId: testCase.resolvedViaSessionId,
|
||||
},
|
||||
requesterSessionKey: testCase.requesterSessionKey,
|
||||
restrictToSpawned: testCase.restrictToSpawned,
|
||||
visibilitySessionKey: testCase.targetSessionKey,
|
||||
});
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
ok: true,
|
||||
key: testCase.targetSessionKey,
|
||||
displayKey: testCase.targetSessionKey,
|
||||
});
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(testCase.expectsGateway ? 1 : 0);
|
||||
callGatewayMock.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hide an exact spawned target behind the sessions.list visibility cap", async () => {
|
||||
@@ -240,13 +240,22 @@ describe("resolved session visibility checks", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
isResolvedSessionVisibleToRequester({
|
||||
resolveVisibleSessionReference({
|
||||
resolvedSession: {
|
||||
ok: true,
|
||||
key: "agent:main:subagent:worker-999",
|
||||
displayKey: "agent:main:subagent:worker-999",
|
||||
resolvedViaSessionId: false,
|
||||
},
|
||||
requesterSessionKey: "agent:main:main",
|
||||
targetSessionKey: "agent:main:subagent:worker-999",
|
||||
restrictToSpawned: true,
|
||||
resolvedViaSessionId: false,
|
||||
visibilitySessionKey: "agent:main:subagent:worker-999",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:subagent:worker-999",
|
||||
displayKey: "agent:main:subagent:worker-999",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export function resolveCurrentSessionClientAlias(params: {
|
||||
return requesterKey;
|
||||
}
|
||||
|
||||
export async function isRequesterSpawnedSessionVisible(params: {
|
||||
async function isRequesterSpawnedSessionVisible(params: {
|
||||
requesterSessionKey: string;
|
||||
targetSessionKey: string;
|
||||
limit?: number;
|
||||
@@ -117,43 +117,6 @@ export async function isRequesterSpawnedSessionVisible(params: {
|
||||
return keys.has(params.targetSessionKey);
|
||||
}
|
||||
|
||||
export function shouldVerifyRequesterSpawnedSessionVisibility(params: {
|
||||
requesterSessionKey: string;
|
||||
targetSessionKey: string;
|
||||
restrictToSpawned: boolean;
|
||||
resolvedViaSessionId: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
params.restrictToSpawned &&
|
||||
!params.resolvedViaSessionId &&
|
||||
params.requesterSessionKey !== params.targetSessionKey
|
||||
);
|
||||
}
|
||||
|
||||
export async function isResolvedSessionVisibleToRequester(params: {
|
||||
requesterSessionKey: string;
|
||||
targetSessionKey: string;
|
||||
restrictToSpawned: boolean;
|
||||
resolvedViaSessionId: boolean;
|
||||
limit?: number;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
!shouldVerifyRequesterSpawnedSessionVisibility({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
restrictToSpawned: params.restrictToSpawned,
|
||||
resolvedViaSessionId: params.resolvedViaSessionId,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return await isRequesterSpawnedSessionVisible({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
limit: params.limit,
|
||||
});
|
||||
}
|
||||
|
||||
export { looksLikeSessionId };
|
||||
|
||||
export function looksLikeSessionKey(value: string): boolean {
|
||||
@@ -489,12 +452,16 @@ export async function resolveVisibleSessionReference(params: {
|
||||
}): Promise<VisibleSessionReferenceResolution> {
|
||||
const resolvedKey = params.resolvedSession.key;
|
||||
const displayKey = params.resolvedSession.displayKey;
|
||||
const visible = await isResolvedSessionVisibleToRequester({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
targetSessionKey: resolvedKey,
|
||||
restrictToSpawned: params.restrictToSpawned,
|
||||
resolvedViaSessionId: params.resolvedSession.resolvedViaSessionId,
|
||||
});
|
||||
const shouldVerifySpawnedVisibility =
|
||||
params.restrictToSpawned &&
|
||||
!params.resolvedSession.resolvedViaSessionId &&
|
||||
params.requesterSessionKey !== resolvedKey;
|
||||
const visible =
|
||||
!shouldVerifySpawnedVisibility ||
|
||||
(await isRequesterSpawnedSessionVisible({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
targetSessionKey: resolvedKey,
|
||||
}));
|
||||
if (!visible) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user