Compare commits

...

126 Commits

Author SHA1 Message Date
Dallin Romney
963830c46a test: refresh mirrored QA routing expectation 2026-06-20 14:13:36 -07:00
Dallin Romney
a404915620 test: pin folded QA coverage ids 2026-06-20 14:12:15 -07:00
Dallin Romney
c6d19d86c9 test: avoid overclaiming gateway tool API coverage 2026-06-20 14:12:15 -07:00
Dallin Romney
fecc451f41 test: preserve chat tools profile build guard 2026-06-20 14:12:15 -07:00
Dallin Romney
75169f8349 test: update mirrored QA routing expectation 2026-06-20 14:12:15 -07:00
Dallin Romney
1cf089ef20 test: keep native QA evidence out of parity tiers 2026-06-20 14:11:45 -07:00
Dallin Romney
c4b2dd3fe0 test: align folded QA coverage ids 2026-06-20 14:11:45 -07:00
Dallin Romney
30889ef336 test: trim folded QA Lab script cruft 2026-06-20 14:11:45 -07:00
Dallin Romney
563d640d6c test: relax QA native scenario catalog inventory 2026-06-20 14:11:45 -07:00
Dallin Romney
b988d762e8 test: remove folded HTTP API script tests 2026-06-20 14:11:45 -07:00
Dallin Romney
c0d659f0d7 test: fold HTTP API script proof into QA Lab 2026-06-20 14:11:24 -07:00
Vincent Koc
15a2d74320 test(scripts): focus installer routing changes 2026-06-20 23:05:21 +02:00
Shakker
77f07a11e7 fix: share operator approval env snapshots 2026-06-20 22:02:27 +01:00
Josh Lehman
7a0d36f3d0 refactor: add SDK transcript identity target API (#95030) 2026-06-20 14:01:07 -07:00
Vincent Koc
0a707afb9a chore(deadcode): inline exec approval wait helper 2026-06-21 04:58:14 +08:00
Shakker
bdeda6553b test: finish gateway token env routing 2026-06-20 21:50:55 +01:00
Shakker
3499b277e3 fix: route gateway env setup through helpers 2026-06-20 21:50:55 +01:00
Vincent Koc
8c8857c3ef fix(qa): keep telegram credential tests sparse safe 2026-06-20 22:45:25 +02:00
Vincent Koc
d75613e794 chore(deadcode): reuse tool result details reader 2026-06-21 04:42:48 +08:00
Shakker
beb8897f49 test: keep Claude seed HOME fallback covered 2026-06-20 21:36:15 +01:00
Shakker
add5f76a1e fix: isolate Claude history HOME setup 2026-06-20 21:34:58 +01:00
Vincent Koc
9a9f4dbefe test(rpc): map rtt measurement script changes 2026-06-20 22:32:17 +02:00
Vincent Koc
5beaaf343c test(qa): map qa e2e script changes 2026-06-20 22:29:33 +02:00
Vincent Koc
1db811282c fix(release): validate plugin manifest runner args 2026-06-20 22:23:30 +02:00
Vincent Koc
aa23d9f34e chore(deadcode): inline approval abort classification 2026-06-21 04:22:12 +08:00
Vincent Koc
2962c95010 fix(release): validate plugin runtime build args 2026-06-20 22:19:50 +02:00
Vincent Koc
80d3b132a5 fix(release): validate package dist check args 2026-06-20 22:16:26 +02:00
Shakker
1a5d84d3fe test: reuse discovery env snapshot 2026-06-20 21:09:10 +01:00
Vincent Koc
71a75b9b28 fix(release): validate package tarball check args 2026-06-20 22:08:25 +02:00
Vincent Koc
b1f562570a fix(release): validate openclaw npm verifier args 2026-06-20 22:03:38 +02:00
Vincent Koc
bdcc691745 chore(deadcode): inline message provider tool filtering 2026-06-21 04:00:09 +08:00
Shakker
4461e257e3 fix: restore env warning flags with helper 2026-06-20 20:58:13 +01:00
Vincent Koc
76014cfe95 fix(release): validate plugin npm verifier args 2026-06-20 21:57:13 +02:00
Vincent Koc
498ff1fb5a fix(release): validate plugin clawhub publish args 2026-06-20 21:53:59 +02:00
Shakker
ae81aa018d test: reuse update method env wrapper 2026-06-20 20:52:09 +01:00
Vincent Koc
1706bfda2c fix(release): validate plugin npm publish args 2026-06-20 21:51:32 +02:00
Vincent Koc
a1201e99fc fix(release): validate npm publish wrapper args 2026-06-20 21:48:01 +02:00
Shakker
90d2f161c9 fix: scope config open path env 2026-06-20 20:46:29 +01:00
Vincent Koc
bff7134a69 fix(mac): validate notarization wrapper args 2026-06-20 21:44:09 +02:00
Vincent Koc
e59d0b540e fix(mac): reject invalid codesign args 2026-06-20 21:41:34 +02:00
Shakker
aa5fcf70f7 test: share gateway credential env guard 2026-06-20 20:40:57 +01:00
Vincent Koc
63ac2e2ce0 fix(mac): reject build-and-run wrapper args 2026-06-20 21:36:42 +02:00
Shakker
803064c6e0 fix: localize session transcript env 2026-06-20 20:35:32 +01:00
Vincent Koc
577e5a4692 fix(mac): reject unknown restart options 2026-06-20 21:33:48 +02:00
Vincent Koc
a49f3f9362 fix(qa): parse qa e2e wrapper flags 2026-06-20 21:29:18 +02:00
Vincent Koc
7b9ddbda99 chore(deadcode): inline inbound prompt prefix 2026-06-21 03:27:50 +08:00
Shakker
0f83051353 test: share release journey env wrapper 2026-06-20 20:22:18 +01:00
Vincent Koc
4341cf24cc fix(crabbox): detect node-wrapped changed gates 2026-06-20 21:19:03 +02:00
Shakker
6a3f990140 fix: isolate plugin index loader env 2026-06-20 20:13:24 +01:00
scotthuang
81abc2b21b fix: preserve cron delivery awareness for target sessions (#93580)
Merged via squash.

Prepared head SHA: 460562ceff
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-20 12:13:10 -07:00
Shakker
09fcafffbc test: scope package root fallback env 2026-06-20 20:11:46 +01:00
Vincent Koc
2a93d7b9c5 chore(deadcode): inline runtime context builders 2026-06-21 03:09:43 +08:00
Shakker
0eaefc9050 fix: share npm verifier env guard 2026-06-20 20:02:45 +01:00
Shakker
52e01676be test: reuse memory fd env helper 2026-06-20 19:58:05 +01:00
Shakker
df68b81006 fix: isolate bundled probe env 2026-06-20 19:57:16 +01:00
Vincent Koc
a5417b5c6c chore(deadcode): inline bootstrap routing helpers 2026-06-21 02:55:16 +08:00
Shakker
da2c7e2d2b test: reuse startup bench env helper 2026-06-20 19:45:59 +01:00
Shakker
3a14f247ad fix: scope bundled skills env 2026-06-20 19:44:37 +01:00
Vincent Koc
5c36001fcb chore(deadcode): inline tool-search allowlist helpers 2026-06-21 02:40:32 +08:00
Shakker
05bed72a8d test: restore plugin trust env 2026-06-20 19:34:22 +01:00
Vincent Koc
c2433d41a7 fix(ci): reject release metadata option typos 2026-06-20 20:32:50 +02:00
Shakker
d368fd620c fix: restore clawhub home env 2026-06-20 19:31:26 +01:00
Vincent Koc
7dc7deaa13 fix(ci): reject mistyped changed gate options 2026-06-20 20:28:15 +02:00
Vincent Koc
a2ff59fdb2 chore(deadcode): inline same-model retry backoff 2026-06-21 02:24:56 +08:00
Vincent Koc
b12223a79f fix(qa): reject empty qa lab port flags 2026-06-20 20:17:52 +02:00
Vincent Koc
f519ceab9c fix(ci): allow gtimeout for docker pull retry 2026-06-20 20:12:30 +02:00
Vincent Koc
1f1b1aee6b chore(deadcode): remove duplicate Gemini schema helper 2026-06-21 02:09:19 +08:00
Vincent Koc
62b2e9ef14 fix(scripts): honor gtimeout in host setup wrappers 2026-06-20 20:07:50 +02:00
Vincent Koc
0f67474251 fix(docker): keep upgrade survivor auto-auth summary safe 2026-06-20 20:02:14 +02:00
Gio Della-Libera
e56fd1dc04 Keep core doctor health in contribution order (#86627)
Merged via squash.

Prepared head SHA: e0955797c1
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-20 10:59:31 -07:00
Vincent Koc
b3968f69c9 fix(package): accept uppercase artifact digests 2026-06-20 19:52:59 +02:00
Vincent Koc
b0df6dc10e fix(package): scope trusted URL auth to original origin 2026-06-20 19:50:09 +02:00
Vincent Koc
141fb2b119 fix(crabbox): bootstrap macOS stdin shell scripts 2026-06-20 19:44:40 +02:00
Vincent Koc
64b6488f6c fix(crabbox): bootstrap env-option macOS stdin scripts 2026-06-20 19:39:05 +02:00
Vincent Koc
e1fc4683bb chore(deadcode): remove unused cron run log reader 2026-06-21 01:32:51 +08:00
Vincent Koc
85ab952956 fix(release): reject zero correction tags 2026-06-20 19:30:26 +02:00
Vincent Koc
abd5fb4494 fix(release): guard appcast cleanup before notes path 2026-06-20 19:28:42 +02:00
Vincent Koc
aea050b43e fix(mac): clean failed notary zip staging 2026-06-20 19:25:38 +02:00
Vincent Koc
85f552bf37 fix(qa): clean failed Parallels package locks 2026-06-20 19:20:40 +02:00
Vincent Koc
dafd98dd98 chore(deadcode): drop unused llm provider helpers 2026-06-21 01:17:06 +08:00
Vincent Koc
3632c62f85 fix(qa): isolate OTEL smoke exporter env 2026-06-20 19:14:06 +02:00
Vincent Koc
ad5d2cbc1b fix(mac): clean dSYM staging on zip failure 2026-06-20 19:07:04 +02:00
Vincent Koc
7cda58c109 fix(package): keep artifact duplicate diagnostics relative 2026-06-20 19:02:54 +02:00
Vincent Koc
5c0b99ae2b chore(deadcode): remove unused task flow retry path 2026-06-21 01:00:42 +08:00
Vincent Koc
979925c194 fix(openwebui): redact failed chat diagnostics 2026-06-20 18:58:30 +02:00
Vincent Koc
2f9f45f734 fix(telegram): include session probe artifacts 2026-06-20 18:51:20 +02:00
Vincent Koc
32cbaecd09 fix(telegram): stage full proof artifacts safely 2026-06-20 18:47:12 +02:00
Vincent Koc
1989726eb6 chore(deadcode): remove unused cron failure target wrapper 2026-06-21 00:40:26 +08:00
Vincent Koc
2454acc287 fix(crabbox): bound macos bun bootstrap fetches 2026-06-20 18:38:00 +02:00
Vincent Koc
fce5db415b fix(crabbox): bound macos node bootstrap downloads 2026-06-20 18:33:48 +02:00
Vincent Koc
2166652eb3 fix(parallels): bound update tarball probe 2026-06-20 18:28:13 +02:00
Vincent Koc
7a9c269541 chore(deadcode): drop unused cron summary guard 2026-06-21 00:27:23 +08:00
Vincent Koc
aa893b9228 fix(parallels): bound linux smoke downloads 2026-06-20 18:25:57 +02:00
Vincent Koc
98a7741468 fix(parallels): bound windows smoke downloads 2026-06-20 18:24:13 +02:00
Vincent Koc
3df4341e5a fix(parallels): bound macos smoke downloads 2026-06-20 18:20:55 +02:00
Vincent Koc
ecac665bf3 fix(parallels): pace background launch probes 2026-06-20 18:14:08 +02:00
Vincent Koc
021fd5de2b chore(deadcode): remove unused channel sender validator 2026-06-21 00:11:51 +08:00
Vincent Koc
60159b9f00 fix(parallels): keep fresh malformed package locks 2026-06-20 18:10:32 +02:00
Vincent Koc
165440117e fix(canvas): ignore stale pnpm execpath 2026-06-20 18:05:23 +02:00
Vincent Koc
fddfcbe10e fix(canvas): use corepack for a2ui pnpm fallback 2026-06-20 18:02:17 +02:00
Vincent Koc
7c850bdf38 fix(test): kill SDK package command trees 2026-06-20 17:54:16 +02:00
Vincent Koc
2bc20f2ec5 fix(test): use pnpm runner for SDK package build 2026-06-20 17:51:21 +02:00
Vincent Koc
ed500dda25 fix(qa): use corepack for lab docker build fallback 2026-06-20 17:45:09 +02:00
Vincent Koc
bc754b3160 fix(ci): restore Vitest watchdog cleanup 2026-06-20 23:42:22 +08:00
Vincent Koc
b972956173 test(ci): use public Feishu temp-dir helper 2026-06-20 23:42:22 +08:00
Vincent Koc
29444b26f2 chore(deadcode): dedupe plugin JSON logger 2026-06-20 23:37:00 +08:00
Vincent Koc
7fc5a72433 fix(qa): cap chunked credential lease payloads 2026-06-20 17:34:38 +02:00
Vincent Koc
a590f7f690 fix(qa): require boundary entry shim outputs 2026-06-20 17:25:11 +02:00
Vincent Koc
2252674168 fix(qa): reject matrix output symlink escapes 2026-06-20 17:15:45 +02:00
Vincent Koc
60612ff492 chore(deadcode): inline auto-reply display wrappers 2026-06-20 23:14:23 +08:00
Vincent Koc
c5623e72f3 fix(qa): quote generated compose paths 2026-06-20 17:08:40 +02:00
Vincent Koc
947c21ee5a refactor(qa): reuse qa shell quote helper 2026-06-20 17:05:10 +02:00
Vincent Koc
99f58ae6d6 fix(qa): quote qa docker stop command 2026-06-20 16:59:14 +02:00
Vincent Koc
3f0e740f83 chore(deadcode): inline session visibility wrappers 2026-06-20 22:56:40 +08:00
Vincent Koc
106961b513 fix(e2e): resolve mounted macOS desktop homes 2026-06-20 16:51:20 +02:00
Vincent Koc
d0001f96f0 fix(e2e): ignore bundled plugin list diagnostics 2026-06-20 16:44:11 +02:00
Vincent Koc
527bd807b9 fix(e2e): ignore runtime smoke rpc log records 2026-06-20 16:40:14 +02:00
Vincent Koc
7546231762 fix(run-node): type signal process injection 2026-06-20 22:37:26 +08:00
Vincent Koc
a977dc843d chore(deadcode): delete unused route wrappers 2026-06-20 22:37:26 +08:00
Vincent Koc
6ad7f66af2 fix(e2e): ignore inline kitchen sink json diagnostics 2026-06-20 16:34:52 +02:00
Vincent Koc
1b4fb6291d fix(e2e): parse secret proof json records 2026-06-20 16:31:09 +02:00
Vincent Koc
ee69465fe9 fix(e2e): ignore embedded diagnostic reply json 2026-06-20 16:26:00 +02:00
Vincent Koc
7b329ade32 fix(e2e): reject malformed package lock pids 2026-06-20 16:21:27 +02:00
Vincent Koc
44422b2151 fix(e2e): isolate Windows background control markers 2026-06-20 16:17:04 +02:00
Vincent Koc
48b338a5a9 fix(e2e): report signaled host server startups 2026-06-20 16:14:16 +02:00
Vincent Koc
d4f68475fd fix(e2e): preserve spaced macOS desktop homes 2026-06-20 16:11:03 +02:00
211 changed files with 6925 additions and 2375 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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">

View File

@@ -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 |

View File

@@ -1,5 +1,7 @@
export interface PnpmRunnerParams {
comSpec?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
nodeArgs?: string[];
nodeExecPath?: string;
npmExecPath?: string;

View File

@@ -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");
}

View File

@@ -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 });
}
});
});

View File

@@ -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 () => {
({

View File

@@ -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",
);
});
});

View File

@@ -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

View File

@@ -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-"));

View File

@@ -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`,
};
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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"

View File

@@ -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,
});

View 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.

View 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.

View File

@@ -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.

View 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.

View File

@@ -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"

View File

@@ -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);

View File

@@ -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;

View File

@@ -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}`);
}

View File

@@ -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}`);

View File

@@ -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`);

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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]);

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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) {

View File

@@ -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));

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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 });

View File

@@ -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`);
}

View File

@@ -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`);
}

View 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);
}

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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)) {

View File

@@ -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) {

View File

@@ -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."
'

View 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
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -114,6 +114,9 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
"session-transcript-runtime": {
category: "runtime",
},
"sqlite-runtime": {
category: "runtime",
},

View File

@@ -217,6 +217,7 @@
"session-binding-runtime",
"session-key-runtime",
"session-store-runtime",
"session-transcript-runtime",
"sqlite-runtime",
"sqlite-runtime-testing",
"session-transcript-hit",

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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() ?? "",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}")"

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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({

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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

View File

@@ -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[];

View File

@@ -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() {

View File

@@ -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";

View File

@@ -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"]],

View File

@@ -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)`,
);

View File

@@ -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,

View File

@@ -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 },
]);
});
});

View File

@@ -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));
}

View File

@@ -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>;
};

View File

@@ -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" });
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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: [

View File

@@ -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,
],
};

View File

@@ -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({

View File

@@ -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;

View File

@@ -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");
});
});

View File

@@ -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(),

View File

@@ -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>)

View File

@@ -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",
});
});
});

View File

@@ -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