mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
264 Commits
v2026.5.30
...
fix-exec-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c642e1da24 | ||
|
|
6316648bab | ||
|
|
bf777b9af2 | ||
|
|
fba9eac7eb | ||
|
|
5965522af5 | ||
|
|
f18fd2094f | ||
|
|
770ee8eba6 | ||
|
|
b891d42f3a | ||
|
|
705bdcec70 | ||
|
|
db7aff8843 | ||
|
|
d30329fb0e | ||
|
|
c7f3d60722 | ||
|
|
0ffaeb1273 | ||
|
|
c43a571170 | ||
|
|
dd8b9bdcb8 | ||
|
|
399f55e511 | ||
|
|
7e654b40b8 | ||
|
|
7b119ec60d | ||
|
|
c1fffe1074 | ||
|
|
530f3aaab7 | ||
|
|
3ec1a25de4 | ||
|
|
5a6ec67eb0 | ||
|
|
0fdca6974d | ||
|
|
dc344a33fb | ||
|
|
e4a766f2f4 | ||
|
|
ad07ba141d | ||
|
|
bd78737f94 | ||
|
|
5f6e608c60 | ||
|
|
ddbd16a04a | ||
|
|
03151a2ebe | ||
|
|
1b69e7a005 | ||
|
|
227530f906 | ||
|
|
6df3fd5730 | ||
|
|
7c315252d6 | ||
|
|
0d7abcc94f | ||
|
|
344773ba09 | ||
|
|
ae4550f48b | ||
|
|
fdd02444b7 | ||
|
|
3491834d49 | ||
|
|
12cf34a8ea | ||
|
|
d328a0d7a0 | ||
|
|
421ad93203 | ||
|
|
dc05f598bb | ||
|
|
3171278372 | ||
|
|
01193dea26 | ||
|
|
cb9847968a | ||
|
|
54987715f3 | ||
|
|
0c74f18a1c | ||
|
|
59122812c0 | ||
|
|
bc95af1b7c | ||
|
|
144405e562 | ||
|
|
290b19275b | ||
|
|
72f74b33e1 | ||
|
|
bb673f47b2 | ||
|
|
16ef9c1435 | ||
|
|
2b30951b80 | ||
|
|
56b8030cd9 | ||
|
|
5706619068 | ||
|
|
edc0a22179 | ||
|
|
2682c02774 | ||
|
|
59683978e1 | ||
|
|
c8f8907f15 | ||
|
|
8eb1838dfa | ||
|
|
01f6ad6056 | ||
|
|
b7f657b3b0 | ||
|
|
22cb7fb6b7 | ||
|
|
48afba96a3 | ||
|
|
470a1ae8d1 | ||
|
|
a2acfc5049 | ||
|
|
fe8c781d67 | ||
|
|
ac2484f23e | ||
|
|
cabfbdfe0d | ||
|
|
5e2472567a | ||
|
|
79c4ac73d7 | ||
|
|
2a1882ebcc | ||
|
|
3bb04b67e9 | ||
|
|
cd0a7b10e2 | ||
|
|
bc45c36dbc | ||
|
|
7184522fae | ||
|
|
aa74d93aff | ||
|
|
be0d3489a6 | ||
|
|
f06b4b9aab | ||
|
|
0700f13d62 | ||
|
|
3c6c247e0a | ||
|
|
2e42b1372e | ||
|
|
f78bb34cb4 | ||
|
|
85c7490f72 | ||
|
|
63d93db867 | ||
|
|
2976db4b2c | ||
|
|
025bb01268 | ||
|
|
7a292bb16e | ||
|
|
a9e3eade5d | ||
|
|
3733cd8d63 | ||
|
|
190f935b53 | ||
|
|
c21e16c73d | ||
|
|
d52f1ea5ec | ||
|
|
13967e17e6 | ||
|
|
7ad2aa44dd | ||
|
|
874b3f921e | ||
|
|
c11d5d6d65 | ||
|
|
11631bf044 | ||
|
|
561e993282 | ||
|
|
23bf48e69e | ||
|
|
7d65ea3513 | ||
|
|
bfac12a184 | ||
|
|
cdcc151145 | ||
|
|
7681b95199 | ||
|
|
caa08a6dc0 | ||
|
|
4339d7c1d8 | ||
|
|
aa187c6496 | ||
|
|
34010894c1 | ||
|
|
c74bb4475a | ||
|
|
299a023bd1 | ||
|
|
0c852036c7 | ||
|
|
9cc759dd37 | ||
|
|
d1378650bb | ||
|
|
40f99e474a | ||
|
|
dc71b5867e | ||
|
|
fd2c65f59b | ||
|
|
575f74293e | ||
|
|
b27ae3f6e7 | ||
|
|
b388d3dc71 | ||
|
|
01b7ef9e88 | ||
|
|
4b89def277 | ||
|
|
fabd9469cd | ||
|
|
d3025b4007 | ||
|
|
c06096eabc | ||
|
|
9577e0be5a | ||
|
|
b12724b79b | ||
|
|
0de60cec12 | ||
|
|
c6232347dc | ||
|
|
b73e135f97 | ||
|
|
9b6c981260 | ||
|
|
02ac0ec48b | ||
|
|
d8329dedf6 | ||
|
|
b86e8bf359 | ||
|
|
3bb9224836 | ||
|
|
fdc10a64e9 | ||
|
|
87174c80b6 | ||
|
|
97c040f946 | ||
|
|
f833e96a31 | ||
|
|
9a32c0f85d | ||
|
|
d306f5bf2e | ||
|
|
65d5f7436c | ||
|
|
b78ce079a3 | ||
|
|
6c6cf41b14 | ||
|
|
0d79cbab4e | ||
|
|
b04c3e96d6 | ||
|
|
3854a61bea | ||
|
|
0d07e30725 | ||
|
|
bfc151e9d3 | ||
|
|
b653d94918 | ||
|
|
49e5091f18 | ||
|
|
cbdb59b255 | ||
|
|
2ac2a8d210 | ||
|
|
d042452d20 | ||
|
|
50f27ee91d | ||
|
|
84266cd30e | ||
|
|
61e9961abb | ||
|
|
7c04ce3a79 | ||
|
|
2ff9e27d4e | ||
|
|
5ee3e5d8c0 | ||
|
|
03dec8bb3a | ||
|
|
5bc80dbe27 | ||
|
|
8383e2e4d9 | ||
|
|
7f93755206 | ||
|
|
7dd1bd894b | ||
|
|
6ed6120977 | ||
|
|
0f396368a9 | ||
|
|
72679b16eb | ||
|
|
4a09fd43e2 | ||
|
|
026ab6b882 | ||
|
|
730492867f | ||
|
|
ceda284845 | ||
|
|
8da6b67607 | ||
|
|
e0d3c78042 | ||
|
|
af7749123b | ||
|
|
9d97e683d4 | ||
|
|
e2c745fc58 | ||
|
|
5df0ed3b9f | ||
|
|
e5acae4453 | ||
|
|
8076eead77 | ||
|
|
f6365d07c4 | ||
|
|
9a3e7d4f51 | ||
|
|
ce1165afda | ||
|
|
90712f6d5e | ||
|
|
7c15c2765e | ||
|
|
e681569536 | ||
|
|
b0679d1f13 | ||
|
|
80b7f56603 | ||
|
|
995a9bd702 | ||
|
|
92b9cd21ec | ||
|
|
d62bfab946 | ||
|
|
7aa309319f | ||
|
|
2df95c0b10 | ||
|
|
6f58a71582 | ||
|
|
55fc3c10b0 | ||
|
|
b4a6244ef4 | ||
|
|
6b2cb4db67 | ||
|
|
0715081990 | ||
|
|
462b52f62c | ||
|
|
118b9cacf6 | ||
|
|
8cfccca4de | ||
|
|
01603bbbf4 | ||
|
|
2e1ae531bd | ||
|
|
9c6f7553be | ||
|
|
ccb50f89da | ||
|
|
7c5a412b38 | ||
|
|
6653193fdb | ||
|
|
7a3a52cda9 | ||
|
|
fa2b2ffab4 | ||
|
|
a6f4de4a66 | ||
|
|
b02c448585 | ||
|
|
a93240e2c6 | ||
|
|
720071a6c6 | ||
|
|
2155450ed7 | ||
|
|
e74931778c | ||
|
|
9d54285b0d | ||
|
|
3ff86f3350 | ||
|
|
2f449285b9 | ||
|
|
465a5456fe | ||
|
|
ecbd97e968 | ||
|
|
ef04c72f08 | ||
|
|
e76df691fe | ||
|
|
f983111166 | ||
|
|
7e0d275f7a | ||
|
|
faae7529fd | ||
|
|
01ef169004 | ||
|
|
2fbddce881 | ||
|
|
a88e4fb7e0 | ||
|
|
90329e2848 | ||
|
|
454a69a048 | ||
|
|
78f2a89e95 | ||
|
|
3613981579 | ||
|
|
a129b912a4 | ||
|
|
2a30b937cb | ||
|
|
0ff5fe3a80 | ||
|
|
db0209ac5d | ||
|
|
3bac0bcbfb | ||
|
|
beb499b4d1 | ||
|
|
7617d062fd | ||
|
|
304e2c83c0 | ||
|
|
cb569f6ad9 | ||
|
|
b8f25e9648 | ||
|
|
6b0ad98d62 | ||
|
|
d88767e819 | ||
|
|
b988e2f92b | ||
|
|
e014145ac1 | ||
|
|
14dbf80c74 | ||
|
|
a9eefeea71 | ||
|
|
9f7eaf06e1 | ||
|
|
7d3fc6f924 | ||
|
|
b454677874 | ||
|
|
d729811224 | ||
|
|
1e14f4400f | ||
|
|
d641126c1d | ||
|
|
4b1d2faa99 | ||
|
|
058152cf69 | ||
|
|
56362524ed | ||
|
|
05b3f1c29d | ||
|
|
201bf125af | ||
|
|
db40fde88c | ||
|
|
cdff174ce6 | ||
|
|
1ba9af1693 |
@@ -187,11 +187,13 @@ gh pr view <number> --json additions,deletions,changedFiles \
|
||||
## Read beyond the diff
|
||||
|
||||
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
|
||||
- Before any verdict, read enough code to fill this map: changed surface, runtime entry point, owner boundary, one caller, one callee, sibling implementations sharing the invariant, adjacent tests, current `main` behavior, and shipped/dependency/Codex contracts when relevant.
|
||||
- For large-codebase PRs, sample enough related files to understand the runtime boundary before deciding. Default to more code reading when the change touches agents, gateway, plugins, auth, sessions, process, config, or provider/runtime seams.
|
||||
- Compare the PR against current `origin/main` behavior. Check whether recent main already changed the same surface.
|
||||
- Dependency-backed behavior: MUST read upstream docs/source/types before judging API use, defaults, output shapes, errors, timeouts, memory behavior, or compatibility. Do not assume dependency contracts from memory or PR text.
|
||||
- Judge solution quality, not only correctness. Ask whether the PR is the clean owner-boundary fix or a wart/workaround that should be replaced by a small refactor, moved seam, contract change, or deletion of duplicate logic.
|
||||
- Mention the main files read when the verdict depends on code-path evidence.
|
||||
- If the user challenges the verdict or asks whether the idea is really good, resume code reading first. Do not defend, soften, or reverse the verdict until the missing caller/callee/sibling/dependency path is checked.
|
||||
|
||||
## Best-fix review loop
|
||||
|
||||
@@ -206,6 +208,7 @@ Before verdict:
|
||||
5. Compare against current `origin/main` and shipped behavior when regression/compat matters.
|
||||
6. Inspect upstream dependency/Codex source or docs for dependency-backed behavior.
|
||||
7. Identify at least one alternative fix location or shape, then reject it with evidence.
|
||||
8. If any required path above is uninspected, keep reading or mark `Remaining uncertainty`; do not call the PR best, blocked, proof-sufficient, or merge-ready.
|
||||
|
||||
Review output must include:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Secret scanning alert handler for OpenClaw maintainers.
|
||||
// Usage: node secret-scanning.mjs <command> [options]
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -39,7 +39,9 @@ function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
stderr: proc.stderr,
|
||||
};
|
||||
}
|
||||
if (!json) return proc.stdout;
|
||||
if (!json) {
|
||||
return proc.stdout;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -70,7 +72,9 @@ export function loadBodyRedactionResult(locationType, resultFile) {
|
||||
if (!resultFile) {
|
||||
fail("Body notifications require a redaction result file from redact-body-if-needed");
|
||||
}
|
||||
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
|
||||
if (!fs.existsSync(resultFile)) {
|
||||
fail(`File not found: ${resultFile}`);
|
||||
}
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
|
||||
if (typeof result.notify_required !== "boolean") {
|
||||
@@ -182,10 +186,11 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
|
||||
failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);
|
||||
|
||||
const discussion = gql?.data?.repository?.discussion;
|
||||
if (!discussion)
|
||||
if (!discussion) {
|
||||
fail(
|
||||
`Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,
|
||||
);
|
||||
}
|
||||
|
||||
discussionId = discussion.id;
|
||||
|
||||
@@ -205,15 +210,18 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
|
||||
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
|
||||
);
|
||||
const replies = replyPage?.data?.node?.replies;
|
||||
if (!replies)
|
||||
if (!replies) {
|
||||
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
|
||||
}
|
||||
|
||||
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
|
||||
hasMoreReplies = replies.pageInfo.hasNextPage;
|
||||
replyCursor = replies.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
if (reply) return { discussionId, comment: reply };
|
||||
if (reply) {
|
||||
return { discussionId, comment: reply };
|
||||
}
|
||||
}
|
||||
|
||||
hasNextPage = discussion.comments.pageInfo.hasNextPage;
|
||||
@@ -241,7 +249,9 @@ function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
|
||||
* Fetch alert metadata + locations. Never exposes .secret.
|
||||
*/
|
||||
function cmdFetchAlert(alertNumber) {
|
||||
if (!alertNumber) fail("Usage: fetch-alert <number>");
|
||||
if (!alertNumber) {
|
||||
fail("Usage: fetch-alert <number>");
|
||||
}
|
||||
|
||||
const alert = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]);
|
||||
|
||||
@@ -280,17 +290,23 @@ function cmdFetchAlert(alertNumber) {
|
||||
* Saves full body to a temp file. Prints metadata + file path to stdout.
|
||||
*/
|
||||
function cmdFetchContent(locationJson) {
|
||||
if (!locationJson) fail("Usage: fetch-content '<location-json>'");
|
||||
if (!locationJson) {
|
||||
fail("Usage: fetch-content '<location-json>'");
|
||||
}
|
||||
const location = JSON.parse(locationJson);
|
||||
const type = location.type;
|
||||
const details = location.details;
|
||||
|
||||
if (type === "discussion_comment") {
|
||||
const commentUrl = details.discussion_comment_url;
|
||||
if (!commentUrl) fail("No discussion_comment_url in location details");
|
||||
if (!commentUrl) {
|
||||
fail("No discussion_comment_url in location details");
|
||||
}
|
||||
|
||||
const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/);
|
||||
if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);
|
||||
if (!urlMatch) {
|
||||
fail(`Cannot parse discussion comment URL: ${commentUrl}`);
|
||||
}
|
||||
const discussionNumber = urlMatch[1];
|
||||
const discussionCommentDbId = urlMatch[2];
|
||||
|
||||
@@ -298,10 +314,11 @@ function cmdFetchContent(locationJson) {
|
||||
discussionNumber,
|
||||
discussionCommentDbId,
|
||||
);
|
||||
if (!comment)
|
||||
if (!comment) {
|
||||
fail(
|
||||
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
const bodyFile = tmpFile("body.md");
|
||||
fs.writeFileSync(bodyFile, comment.body || "");
|
||||
@@ -334,7 +351,9 @@ function cmdFetchContent(locationJson) {
|
||||
details.issue_comment_url ||
|
||||
details.pull_request_comment_url ||
|
||||
details.pull_request_review_comment_url;
|
||||
if (!commentUrl) fail(`No comment URL in location details`);
|
||||
if (!commentUrl) {
|
||||
fail(`No comment URL in location details`);
|
||||
}
|
||||
|
||||
const comment = gh(["api", commentUrl]);
|
||||
const bodyFile = tmpFile("body.md");
|
||||
@@ -378,7 +397,9 @@ function cmdFetchContent(locationJson) {
|
||||
);
|
||||
} else if (type === "issue_body") {
|
||||
const issueUrl = details.issue_body_url || details.issue_url;
|
||||
if (!issueUrl) fail("No issue URL in location details");
|
||||
if (!issueUrl) {
|
||||
fail("No issue URL in location details");
|
||||
}
|
||||
|
||||
const issue = gh(["api", issueUrl]);
|
||||
const bodyFile = tmpFile("body.md");
|
||||
@@ -414,7 +435,9 @@ function cmdFetchContent(locationJson) {
|
||||
);
|
||||
} else if (type === "pull_request_body") {
|
||||
const prUrl = details.pull_request_body_url || details.pull_request_url;
|
||||
if (!prUrl) fail("No PR URL in location details");
|
||||
if (!prUrl) {
|
||||
fail("No PR URL in location details");
|
||||
}
|
||||
|
||||
const pr = gh(["api", prUrl]);
|
||||
const bodyFile = tmpFile("body.md");
|
||||
@@ -490,7 +513,9 @@ function cmdRedactBody(kind, number, bodyFile) {
|
||||
if (!kind || !number || !bodyFile) {
|
||||
fail("Usage: redact-body <issue|pr> <number> <redacted-body-file>");
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
|
||||
@@ -509,8 +534,12 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
|
||||
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
|
||||
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
|
||||
if (!fs.existsSync(currentBodyFile)) {
|
||||
fail(`File not found: ${currentBodyFile}`);
|
||||
}
|
||||
if (!fs.existsSync(redactedBodyFile)) {
|
||||
fail(`File not found: ${redactedBodyFile}`);
|
||||
}
|
||||
|
||||
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
|
||||
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
|
||||
@@ -541,7 +570,9 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
|
||||
* Delete a comment (and all its edit history).
|
||||
*/
|
||||
function cmdDeleteComment(commentId) {
|
||||
if (!commentId) fail("Usage: delete-comment <comment-id>");
|
||||
if (!commentId) {
|
||||
fail("Usage: delete-comment <comment-id>");
|
||||
}
|
||||
gh(["api", `repos/${REPO}/issues/comments/${commentId}`, "-X", "DELETE"], { json: false });
|
||||
console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));
|
||||
}
|
||||
@@ -551,7 +582,9 @@ function cmdDeleteComment(commentId) {
|
||||
* Delete a discussion comment via GraphQL (and all its edit history).
|
||||
*/
|
||||
function cmdDeleteDiscussionComment(nodeId) {
|
||||
if (!nodeId) fail("Usage: delete-discussion-comment <node-id>");
|
||||
if (!nodeId) {
|
||||
fail("Usage: delete-discussion-comment <node-id>");
|
||||
}
|
||||
const result = ghGraphQL(
|
||||
`mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`,
|
||||
);
|
||||
@@ -566,9 +599,12 @@ function cmdDeleteDiscussionComment(nodeId) {
|
||||
* Create a new discussion comment via GraphQL.
|
||||
*/
|
||||
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
|
||||
if (!discussionNodeId || !bodyFile)
|
||||
if (!discussionNodeId || !bodyFile) {
|
||||
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
|
||||
const body = fs.readFileSync(bodyFile, "utf8");
|
||||
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
|
||||
@@ -586,8 +622,12 @@ function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId)
|
||||
* Create a new comment from a file.
|
||||
*/
|
||||
function cmdRecreateComment(issueNumber, bodyFile) {
|
||||
if (!issueNumber || !bodyFile) fail("Usage: recreate-comment <issue-number> <body-file>");
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
if (!issueNumber || !bodyFile) {
|
||||
fail("Usage: recreate-comment <issue-number> <body-file>");
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
|
||||
const result = gh([
|
||||
"api",
|
||||
@@ -715,7 +755,9 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
|
||||
* Close a secret scanning alert.
|
||||
*/
|
||||
function cmdResolve(alertNumber, resolution, comment) {
|
||||
if (!alertNumber) fail("Usage: resolve <alert-number> [resolution] [comment]");
|
||||
if (!alertNumber) {
|
||||
fail("Usage: resolve <alert-number> [resolution] [comment]");
|
||||
}
|
||||
|
||||
const res = resolution || "revoked";
|
||||
const resComment = comment || "Content redacted and author notified to rotate credentials.";
|
||||
@@ -773,8 +815,12 @@ function cmdListOpen() {
|
||||
* Print a formatted summary table from a JSON results file.
|
||||
*/
|
||||
function cmdSummary(jsonFile) {
|
||||
if (!jsonFile) fail("Usage: summary <json-file>");
|
||||
if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`);
|
||||
if (!jsonFile) {
|
||||
fail("Usage: summary <json-file>");
|
||||
}
|
||||
if (!fs.existsSync(jsonFile)) {
|
||||
fail(`File not found: ${jsonFile}`);
|
||||
}
|
||||
|
||||
const results = JSON.parse(fs.readFileSync(jsonFile, "utf8"));
|
||||
const lines = [];
|
||||
|
||||
@@ -4,11 +4,11 @@ profile: openclaw-check
|
||||
provider: azure
|
||||
class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
market: on-demand
|
||||
strategy: most-available
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
# The Azure-backed billing account carries the OpenClaw runner credits; use
|
||||
# explicit on-demand capacity instead of low-priority spot, whose regional
|
||||
# quota is too small for broad maintainer proof or parallel Crabbox lanes.
|
||||
hints: true
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
@@ -48,6 +48,10 @@ aws:
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
azure:
|
||||
# The OpenClaw Azure subscription is reliable in eastus2; eastus rejects the
|
||||
# same SKUs and can stall provisioning.
|
||||
location: eastus2
|
||||
sync:
|
||||
delete: true
|
||||
checksum: false
|
||||
@@ -67,13 +71,16 @@ env:
|
||||
- OPENCLAW_*
|
||||
ssh:
|
||||
user: crabbox
|
||||
port: "2222"
|
||||
# Azure coordinator leases expose SSH on 22. The run wrapper can fall back
|
||||
# from 2222, but `crabbox job run` hydrates via the configured port directly.
|
||||
port: "22"
|
||||
jobs:
|
||||
prewarm:
|
||||
provider: azure
|
||||
target: linux
|
||||
class: standard
|
||||
market: spot
|
||||
type: Standard_D4ads_v6
|
||||
market: on-demand
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: true
|
||||
@@ -90,7 +97,8 @@ jobs:
|
||||
provider: azure
|
||||
target: linux
|
||||
class: standard
|
||||
market: spot
|
||||
type: Standard_D4ads_v6
|
||||
market: on-demand
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: true
|
||||
@@ -99,7 +107,18 @@ jobs:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
job: hydrate
|
||||
ref: main
|
||||
command: env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed
|
||||
shell: true
|
||||
command: |
|
||||
set -euo pipefail
|
||||
if ! git status --short >/dev/null 2>&1; then
|
||||
rm -rf .git
|
||||
git init -q
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-check-tree
|
||||
fi
|
||||
fi
|
||||
env CI=1 corepack pnpm check --timed
|
||||
stop: always
|
||||
testbox-changed:
|
||||
provider: blacksmith-testbox
|
||||
|
||||
136
.github/workflows/ci-check-testbox.yml
vendored
136
.github/workflows/ci-check-testbox.yml
vendored
@@ -139,3 +139,139 @@ jobs:
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -1202,6 +1202,9 @@ jobs:
|
||||
- check_name: check-guards
|
||||
task: guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-shrinkwrap
|
||||
task: shrinkwrap
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1277,7 +1280,6 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:shrinkwrap:check
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
@@ -1286,6 +1288,9 @@ jobs:
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
shrinkwrap)
|
||||
pnpm deps:shrinkwrap:check
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
;;
|
||||
|
||||
3
.github/workflows/openclaw-npm-release.yml
vendored
3
.github/workflows/openclaw-npm-release.yml
vendored
@@ -257,7 +257,8 @@ jobs:
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (let start = input.indexOf("["); start !== -1; start = input.indexOf("[", start + 1)) {
|
||||
for (const match of input.matchAll(/\[/g)) {
|
||||
const start = match.index;
|
||||
const end = arrayEndFrom(start);
|
||||
if (end === -1) {
|
||||
continue;
|
||||
|
||||
2
.github/workflows/update-migration.yml
vendored
2
.github/workflows/update-migration.yml
vendored
@@ -43,4 +43,4 @@ jobs:
|
||||
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
|
||||
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
|
||||
telegram_mode: none
|
||||
secrets: inherit
|
||||
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.
|
||||
|
||||
4
.github/workflows/windows-testbox-probe.yml
vendored
4
.github/workflows/windows-testbox-probe.yml
vendored
@@ -61,12 +61,14 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Probe native Windows
|
||||
env:
|
||||
TARGET_REF: ${{ inputs.target_ref || github.ref }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Write-Host "runner=$env:RUNNER_NAME"
|
||||
Write-Host "machine=$env:COMPUTERNAME"
|
||||
Write-Host "workspace=$env:GITHUB_WORKSPACE"
|
||||
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
|
||||
Write-Host "target_ref=$env:TARGET_REF"
|
||||
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
|
||||
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
|
||||
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())
|
||||
|
||||
68
.github/workflows/workflow-sanity.yml
vendored
68
.github/workflows/workflow-sanity.yml
vendored
@@ -84,6 +84,65 @@ jobs:
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Prepare trusted workflow audit configs
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
||||
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
|
||||
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
|
||||
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
|
||||
> "$trusted_config" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
|
||||
else
|
||||
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
|
||||
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
|
||||
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
|
||||
> "$trusted_zizmor_config" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
|
||||
else
|
||||
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
zizmor_config_path = sys.argv[2]
|
||||
text = config_path.read_text()
|
||||
if ".github/zizmor.yml" not in text:
|
||||
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
|
||||
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
|
||||
PY
|
||||
|
||||
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install pre-commit
|
||||
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -103,6 +162,15 @@ jobs:
|
||||
- name: Lint workflows
|
||||
run: actionlint
|
||||
|
||||
- name: Audit all workflows with zizmor
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t workflow_files < <(
|
||||
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
|
||||
)
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-param-reassign": "error",
|
||||
"eslint/no-proto": "error",
|
||||
"eslint/no-promise-executor-return": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-return-assign": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
"eslint/no-self-compare": "error",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-shadow": "error",
|
||||
"eslint/no-implicit-coercion": "error",
|
||||
"eslint/no-var": "error",
|
||||
"eslint/no-useless-call": "error",
|
||||
@@ -35,7 +36,8 @@
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-useless-rename": "error",
|
||||
"eslint/no-useless-return": "error",
|
||||
"eslint/no-unused-vars": "off",
|
||||
"eslint/no-useless-assignment": "error",
|
||||
"eslint/no-unused-vars": "error",
|
||||
"eslint/no-warning-comments": "error",
|
||||
"eslint/no-unmodified-loop-condition": "error",
|
||||
"eslint/no-new-wrappers": "error",
|
||||
@@ -78,6 +80,7 @@
|
||||
"typescript/no-extraneous-class": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/no-non-null-asserted-nullish-coalescing": "error",
|
||||
"typescript/no-unnecessary-qualifier": "error",
|
||||
@@ -229,15 +232,7 @@
|
||||
"**/*test-support.ts"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"eslint/no-unsafe-optional-chaining": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/agents/embedded-agent-runner/run/attempt.ts"],
|
||||
"rules": {
|
||||
"eslint/no-shadow": "error"
|
||||
"typescript/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -10,9 +10,10 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
|
||||
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
|
||||
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
|
||||
- Dependency-touching work: direct dependency inspection is mandatory when feasible; do not rely on assumptions, wrappers, or memory. Most dependencies are OSS, so read their source/docs/types. Codex-related work: before any verdict, comment, approval, merge recommendation, or `proof sufficient` claim, inspect sibling `../codex` source for the exact protocol/runtime behavior involved; if missing, clone `https://github.com/openai/codex.git` there first. Do not rely on PR text, OpenClaw wrappers, generated schemas, memory, or prior bot reviews as a substitute. Cite Codex files/lines checked in final/review/comment.
|
||||
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.
|
||||
- Dependency-touching work: direct dependency inspection is mandatory when feasible; do not rely on assumptions, wrappers, or memory. Most dependencies are OSS, so read their source/docs/types. Codex-related work has a hard gate: the acting agent must personally inspect sibling `../codex` source for the exact protocol/runtime behavior before any verdict, comment, approval, merge recommendation, code change, or `proof sufficient` claim. If missing, clone `https://github.com/openai/codex.git` there first. Subagent reports, PR text, OpenClaw wrappers, generated schemas, memory, and prior bot reviews do not satisfy this gate. No direct `../codex` check means no Codex verdict. Cite Codex files/lines checked in final/review/comment.
|
||||
- Dependency-backed behavior: read upstream docs/source/types first. No API/default/error/timing guesses.
|
||||
- External API work: Google/search for additional proof. Prefer official docs/source/types; cite current proof. No memory-only API claims.
|
||||
- External API work: live test required. Google/search for additional proof. Prefer official docs/source/types; cite current proof. No memory-only API claims.
|
||||
- Live-verify when feasible. Never print secrets.
|
||||
- Missing deps: `pnpm install`, retry once, then report first actionable error.
|
||||
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
|
||||
@@ -30,6 +31,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- For PRs that add, remove, or change config/default surfaces with possible compatibility, upgrade, provider/plugin, operator, setup, startup, or fallback impact, ClawSweeper review should emit a `reviewMetrics` entry when practical. The metric should name the count and direction of the changes, such as added, changed, or removed config/default surfaces, and explain why the metric matters before merge. When the metric indicates concrete merge risk, also surface the concern in `risks`, use `mergeRiskLabels` when the risk matches the label rubric, make `bestSolution` name the desired pre-merge state, and ensure `labelJustifications` explain the specific reason rather than restating the label.
|
||||
- Review whole decision surfaces, not only the touched runtime, provider, channel, harness, plugin seam, or context path. Check sibling Codex/Pi-style runtimes, provider/model routing, channel delivery, gateway/protocol, plugin SDK, and context-management paths when relevant.
|
||||
- Every PR review must explicitly ask whether the PR is the best fix, not merely a plausible fix. Verdicts need a best-fix judgment backed by enough code reading to compare owner boundaries, callers, siblings, tests, docs, current `main`, shipped behavior when relevant, and dependency/Codex contracts when involved.
|
||||
- Before a PR verdict, build a small evidence map: changed surface, entry point, owner boundary, at least one caller and callee, sibling surfaces that share the invariant, existing tests, and current `main` behavior. If any cell is missing, say the gap instead of concluding.
|
||||
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
|
||||
- Changelog findings: see Docs / Changelog.
|
||||
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
|
||||
@@ -61,6 +63,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
|
||||
- OpenAI Codex is folded into `openai`. No new/live `openai-codex` provider/plugin/auth/model routes; treat them as legacy input only. Runtime/setup/auth/catalog use `openai` + `openai/*`; doctor/migrations repair stale `openai-codex/*` profiles/metadata.
|
||||
- Config/env surface bar is high; `openclaw.json` and environment variables are already large. Before adding a config option or env var, first prove existing product behavior, provider selection, defaults, or doctor migration cannot solve it. Prefer removing or consolidating config/env options when touching these surfaces. Core supports only the latest config shape; `openclaw doctor --fix` migrates older shipped shapes into the current one.
|
||||
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
@@ -71,6 +74,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
|
||||
- State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime.
|
||||
- Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data.
|
||||
- SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
|
||||
- Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents/<agentId>/agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
|
||||
- Legacy state/cache files are migration debt. When touching code that reads/writes them, prefer moving the data into SQLite or calling out the refactor follow-up; do not add parallel file paths.
|
||||
- File storage must be a named product artifact: import/export, user attachment, log, backup, or external tool contract. If it is app state or cache, it belongs in SQLite.
|
||||
@@ -124,6 +128,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
@@ -145,6 +150,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
|
||||
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
|
||||
- Issue/PR work: search strong related issues/PRs before final; close proven dupes/fixed siblings. If none close, suggest one next related follow-up.
|
||||
- PR superseded by `main`: if code proof shows `main` already has same-or-better behavior, comment canonical commit/PR + focused proof, then close. Bar high: inspect PR diff, current code/tests, linked issue, caller/sibling path. If unsure, leave open.
|
||||
- Issue/PR numbers need a short summary every time; assume the reader has not opened or read them.
|
||||
- Before presenting a batch of issues/PRs, use smart subagents to verify live state and current `main`; omit closed/fixed items, and comment+close items already fixed on `main` when maintainer action is authorized.
|
||||
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
|
||||
@@ -259,6 +265,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Version bump surfaces live in `$release-openclaw-maintainer`.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- Before sharing WebVNC links, use Crabbox screenshot first; verify real app/path works and target UI is not broken.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.
|
||||
|
||||
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
|
||||
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
|
||||
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
|
||||
|
||||
@@ -35,8 +36,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
|
||||
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
|
||||
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
|
||||
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
|
||||
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
@@ -52,7 +57,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
# Build stages use full bookworm; the runtime image is always bookworm-slim.
|
||||
ARG OPENCLAW_EXTENSIONS=""
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
# Keep in sync with .github/actions/setup-node-env/action.yml bun-version.
|
||||
# To update: docker buildx imagetools inspect oven/bun:<version> and use the manifest-list digest.
|
||||
ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e"
|
||||
|
||||
@@ -5528,6 +5528,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
public let createdat: String
|
||||
public let updatedat: String
|
||||
public let createdby: AnyCodable
|
||||
public let origin: [String: AnyCodable]?
|
||||
public let proposedversion: String
|
||||
public let draftfile: String
|
||||
public let drafthash: String
|
||||
@@ -5552,6 +5553,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
createdat: String,
|
||||
updatedat: String,
|
||||
createdby: AnyCodable,
|
||||
origin: [String: AnyCodable]?,
|
||||
proposedversion: String,
|
||||
draftfile: String,
|
||||
drafthash: String,
|
||||
@@ -5575,6 +5577,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
self.createdat = createdat
|
||||
self.updatedat = updatedat
|
||||
self.createdby = createdby
|
||||
self.origin = origin
|
||||
self.proposedversion = proposedversion
|
||||
self.draftfile = draftfile
|
||||
self.drafthash = drafthash
|
||||
@@ -5600,6 +5603,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
case createdat = "createdAt"
|
||||
case updatedat = "updatedAt"
|
||||
case createdby = "createdBy"
|
||||
case origin
|
||||
case proposedversion = "proposedVersion"
|
||||
case draftfile = "draftFile"
|
||||
case drafthash = "draftHash"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f1da4b7930475e4be33cb05b8c239f728c7338eb1e8df9b7905bbae94d62da9e plugin-sdk-api-baseline.json
|
||||
6fd007eede80893680d65c6f245eafb9e6301a1e4306530b0134fd5b3da0cddb plugin-sdk-api-baseline.jsonl
|
||||
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
|
||||
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -248,7 +248,7 @@ iMessage catchup is now available as an opt-in feature on the bundled plugin. On
|
||||
|
||||
There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover.
|
||||
|
||||
The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate.
|
||||
The reply cache lives in SQLite plugin state. `openclaw doctor --fix` imports and archives the old `imessage/reply-cache.jsonl` sidecar when present.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Message IDs">
|
||||
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent in-memory reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
|
||||
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent SQLite-backed reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -714,7 +714,7 @@ Each replayed row is fed through the live dispatch path (`evaluateIMessageInboun
|
||||
|
||||
### Cursor and retry semantics
|
||||
|
||||
Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<account>__<hash>.json` (the OpenClaw state dir defaults to `~/.openclaw`, overridable with `OPENCLAW_STATE_DIR`):
|
||||
Catchup keeps a per-account cursor in SQLite plugin state:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -729,6 +729,7 @@ Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<acco
|
||||
- After the startup catchup query succeeds, later live-handled rows also advance the same cursor so a gateway restart does not replay messages that were already handled live. Live cursor writes do not jump past catchup failures that are still below `maxFailureRetries`.
|
||||
- After `maxFailureRetries` consecutive throws against the same `guid`, catchup logs a `warn` and force-advances the cursor past the wedged message so subsequent startups can make progress.
|
||||
- Already-given-up guids are skipped on sight (no dispatch attempt) on later runs and counted under `skippedGivenUp` in the run summary.
|
||||
- `openclaw doctor --fix` imports legacy `<openclawStateDir>/imessage/catchup/*.json` cursor files into SQLite plugin state and archives the old files.
|
||||
|
||||
### Operator-visible signals
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
|
||||
| Job | Purpose | When it runs |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
|
||||
| `security-fast` | Private key detection, workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
|
||||
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
|
||||
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
|
||||
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
|
||||
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
|
||||
@@ -80,6 +80,7 @@ apply to that PR.
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
|
||||
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
|
||||
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
|
||||
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
|
||||
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
|
||||
|
||||
@@ -45,6 +45,8 @@ openclaw doctor --deep
|
||||
openclaw doctor --fix
|
||||
openclaw doctor --fix --non-interactive
|
||||
openclaw doctor --generate-gateway-token
|
||||
openclaw doctor --post-upgrade
|
||||
openclaw doctor --post-upgrade --json
|
||||
```
|
||||
|
||||
For channel-specific permissions, use the channel probes instead of `doctor`:
|
||||
@@ -68,7 +70,8 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
|
||||
- `--allow-exec`: allow doctor to execute configured exec SecretRefs while verifying secrets
|
||||
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
|
||||
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output
|
||||
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
|
||||
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
|
||||
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
|
||||
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
|
||||
@@ -188,6 +191,16 @@ id is not registered, no check runs for that id; use the command's `checksRun`
|
||||
and `checksSkipped` fields to verify a focused gate is selecting the checks you
|
||||
expect.
|
||||
|
||||
## Post-upgrade mode
|
||||
|
||||
`openclaw doctor --post-upgrade` runs plugin compatibility probes intended to be
|
||||
chained after a build or upgrade. Findings are emitted to stdout; the command
|
||||
exits with code 1 if any finding has `level: "error"`. Add `--json` to receive a
|
||||
machine-readable envelope (`{ probesRun, findings }`) suitable for CI, the
|
||||
community `fork-upgrade` skill, and other post-upgrade smoke tooling. If the
|
||||
installed plugin index is missing or malformed, JSON mode still emits that
|
||||
envelope with a `plugin.index_unavailable` error finding.
|
||||
|
||||
Notes:
|
||||
|
||||
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
|
||||
|
||||
@@ -35,6 +35,7 @@ openclaw uninstall --dry-run
|
||||
Notes:
|
||||
|
||||
- Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces.
|
||||
- `--state` preserves configured workspace directories unless `--workspace` is also selected.
|
||||
- `--all` is shorthand for removing service, state, workspace, and app together.
|
||||
- `--non-interactive` requires `--yes`.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ For the bundled non-ACP Codex harness, OpenClaw applies the same lifecycle by pr
|
||||
OpenClaw calls two optional subagent lifecycle hooks:
|
||||
|
||||
<ParamField path="prepareSubagentSpawn" type="method">
|
||||
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds.
|
||||
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds. Native subagent spawns that request `lightContext` and resolve to `contextMode="isolated"` intentionally skip this hook so the child starts from the lightweight bootstrap context without context-engine-managed pre-spawn state.
|
||||
</ParamField>
|
||||
<ParamField path="onSubagentEnded" type="method">
|
||||
Clean up when a subagent session completes or is swept.
|
||||
|
||||
@@ -67,6 +67,27 @@ OpenClaw separates the selected provider/model from why it was selected. That so
|
||||
|
||||
The auto fallback primary-probe interval is five minutes and is not configurable. OpenClaw remembers recent probes per session and primary model so a failing primary is not retried on every turn. OpenClaw sends a visible notice when a session moves onto fallback and another notice when it returns to the selected primary; it does not repeat the notice on every sticky fallback turn.
|
||||
|
||||
## Auth failure skip cache
|
||||
|
||||
By default, every new turn keeps the existing fallback retry behavior: OpenClaw
|
||||
will try each configured fallback candidate again, including non-primary
|
||||
candidates that recently failed with `auth` or `auth_permanent`.
|
||||
|
||||
Operators who prefer to suppress those repeat auth failures can opt in with:
|
||||
|
||||
```bash
|
||||
OPENCLAW_FALLBACK_SKIP_TTL_MS=60000
|
||||
```
|
||||
|
||||
When enabled, OpenClaw records an in-memory, session-scoped skip marker for a
|
||||
non-primary fallback candidate after an auth-class failure. The marker is keyed
|
||||
by session id, provider, and model. Primary candidates are never skipped, so an
|
||||
explicit user model selection still surfaces the real auth error. The cache is
|
||||
process-local and clears on Gateway restart.
|
||||
|
||||
The value is a TTL in milliseconds. `0` or an unset value disables the cache.
|
||||
Positive values are clamped between 1 second and 10 minutes.
|
||||
|
||||
## User-visible fallback notices
|
||||
|
||||
When a session moves onto an auto-selected fallback, OpenClaw sends a status notice in the same reply surface:
|
||||
|
||||
@@ -120,6 +120,19 @@ sparse token/cache counters from the latest transcript usage entry, and
|
||||
the caller's current session; visible client labels such as `openclaw-tui` are
|
||||
not session keys.
|
||||
|
||||
When route metadata is available, `session_status` also includes a visible
|
||||
`Route context` JSON block and matching structured `details` fields. These
|
||||
fields disambiguate the session key from the route that is currently handling
|
||||
the live run:
|
||||
|
||||
- `origin` is where the session was created, or the provider inferred from a
|
||||
deliverable session-key prefix when older state lacks stored origin metadata.
|
||||
- `active` is the current live-run route. It is only reported for the live or
|
||||
current session being handled now.
|
||||
- `deliveryContext` is the persisted delivery route stored on the session,
|
||||
which OpenClaw can reuse for later delivery even when the active surface
|
||||
differs.
|
||||
|
||||
`sessions_yield` intentionally ends the current turn so the next message can be
|
||||
the follow-up event you are waiting for. Use it after spawning sub-agents when
|
||||
you want completion results to arrive as the next message instead of building
|
||||
|
||||
@@ -206,6 +206,17 @@ openclaw models auth login --provider openai --profile-id openai:lain
|
||||
This is the easiest way to keep multiple OAuth logins for the same provider
|
||||
separate inside one agent.
|
||||
|
||||
Use `--force` when a saved provider profile is stuck, expired, or tied to the
|
||||
wrong account and the normal login command keeps reusing it. `--force` deletes
|
||||
the saved auth profiles for that provider in the selected agent directory, then
|
||||
runs the same provider auth flow again. It does not revoke credentials at the
|
||||
provider; rotate or revoke them in the provider dashboard when you need
|
||||
provider-side invalidation.
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider anthropic --force
|
||||
```
|
||||
|
||||
### Per-session (chat command)
|
||||
|
||||
Use `/model <alias-or-id>@<profileId>` to pin a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`).
|
||||
|
||||
@@ -76,6 +76,37 @@ Server globs use the provider-safe MCP server prefix, not necessarily the raw `m
|
||||
|
||||
Without that sandbox-layer entry, the MCP server can still load successfully while its tools are filtered before the provider request. Use `openclaw doctor` to catch this shape for OpenClaw-managed servers in `mcp.servers`. MCP servers loaded from bundled plugin manifests or Claude `.mcp.json` use the same sandbox gate, but this diagnostic does not enumerate those sources yet; use the same allowlist entries if their tools disappear in sandboxed turns.
|
||||
|
||||
### `tools.codeMode`
|
||||
|
||||
`tools.codeMode` enables the generic OpenClaw code-mode surface. When enabled
|
||||
for a run with tools, the model sees only `exec` and `wait`; normal OpenClaw
|
||||
tools move behind the in-sandbox `tools.*` catalog bridge, and MCP tools are
|
||||
available through the generated `MCP` namespace.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
codeMode: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The shorthand is also accepted:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: { codeMode: true },
|
||||
}
|
||||
```
|
||||
|
||||
MCP declarations are exposed through the read-only virtual API file surface in
|
||||
code mode. Guest code can call `API.list("mcp")` and
|
||||
`API.read("mcp/<server>.d.ts")` to inspect TypeScript-style signatures before
|
||||
calling `MCP.<server>.<tool>()`. See [Code mode](/reference/code-mode) for the
|
||||
runtime contract, limits, and debugging steps.
|
||||
|
||||
### `tools.allow` / `tools.deny`
|
||||
|
||||
Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildcards. Applied even when Docker sandbox is off.
|
||||
|
||||
@@ -580,6 +580,11 @@ See [Inferred commitments](/concepts/commitments).
|
||||
value, so repeated failures from one localhost origin do not automatically
|
||||
lock out a different origin.
|
||||
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
|
||||
- `tailscale.serviceName`: optional Tailscale Service name for Serve mode, such
|
||||
as `svc:openclaw`. When set, OpenClaw passes it to `tailscale serve
|
||||
--service` so the Control UI can be exposed through a named Service instead
|
||||
of the device hostname. The value must use Tailscale's `svc:<dns-label>`
|
||||
Service name format; startup reports the derived Service URL.
|
||||
- `tailscale.preserveFunnel`: when `true` and `tailscale.mode = "serve"`, OpenClaw
|
||||
checks `tailscale funnel status` before re-applying Serve at startup and skips
|
||||
it if an externally configured Funnel route already covers the gateway port.
|
||||
|
||||
@@ -67,6 +67,25 @@ and use `gateway.auth.mode: "token"` or `"password"`.
|
||||
|
||||
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
To expose the Control UI through a named Tailscale Service instead of the
|
||||
device hostname, set `gateway.tailscale.serviceName` to the Service name:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
tailscale: { mode: "serve", serviceName: "svc:openclaw" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
With the example above, startup reports the Service URL as
|
||||
`https://openclaw.<tailnet-name>.ts.net/` instead of the device hostname.
|
||||
Tailscale Services require the host to be an approved tagged node in your
|
||||
tailnet. Configure the tag and approve the Service in Tailscale before enabling
|
||||
this option, otherwise `tailscale serve --service=...` will fail during gateway
|
||||
startup.
|
||||
|
||||
### Tailnet-only (bind to Tailnet IP)
|
||||
|
||||
Use this when you want the Gateway to listen directly on the Tailnet IP (no Serve/Funnel).
|
||||
@@ -114,6 +133,11 @@ openclaw gateway --tailscale funnel --auth password
|
||||
|
||||
- Tailscale Serve/Funnel requires the `tailscale` CLI to be installed and logged in.
|
||||
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure.
|
||||
- `gateway.tailscale.serviceName` applies only to Serve mode and is passed to
|
||||
`tailscale serve --service=<name>`. The value must use Tailscale's
|
||||
`svc:<dns-label>` Service name format, for example `svc:openclaw`.
|
||||
Tailscale requires Service hosts to be tagged nodes, and the Service may need
|
||||
approval in the admin console before Serve can publish it.
|
||||
- Set `gateway.tailscale.resetOnExit` if you want OpenClaw to undo `tailscale serve`
|
||||
or `tailscale funnel` configuration on shutdown.
|
||||
- Set `gateway.tailscale.preserveFunnel: true` to keep an externally configured
|
||||
|
||||
@@ -1775,7 +1775,7 @@ lives on the [Models FAQ](/help/faq-models).
|
||||
- The target channel supports outbound media and isn't blocked by allowlists.
|
||||
- The file is within the provider's size limits (images are resized to max 2048px).
|
||||
- `tools.fs.workspaceOnly=true` keeps local-path sends limited to workspace, temp/media-store, and sandbox-validated files.
|
||||
- `tools.fs.workspaceOnly=false` lets structured local media sends use host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, and Office docs). Plain text and secret-like files are still blocked.
|
||||
- `tools.fs.workspaceOnly=false` lets structured local media sends use host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, Office docs, and validated text documents such as Markdown/MD, TXT, JSON, YAML, and YML). This is not a secret scanner: an agent-readable `secret.txt` or `config.json` can be attached when the extension and content validation match. Keep sensitive files outside agent-readable paths, or keep `tools.fs.workspaceOnly=true` for stricter local-path sends.
|
||||
|
||||
See [Images](/nodes/images).
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ Recommended: use the built-in uninstaller:
|
||||
openclaw uninstall
|
||||
```
|
||||
|
||||
When using the CLI, state removal preserves configured workspace directories unless you also select `--workspace`.
|
||||
|
||||
Non-interactive (automation / npx):
|
||||
|
||||
```bash
|
||||
@@ -47,6 +49,7 @@ rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
|
||||
```
|
||||
|
||||
If you set `OPENCLAW_CONFIG_PATH` to a custom location outside the state dir, delete that file too.
|
||||
If you want to keep a workspace inside the state dir, such as `~/.openclaw/workspace`, move it aside before running `rm -rf` or delete state contents selectively.
|
||||
|
||||
4. Delete your workspace (optional, removes agent files):
|
||||
|
||||
|
||||
@@ -52,8 +52,14 @@ type MessagePresentationBlock =
|
||||
| { type: "buttons"; buttons: MessagePresentationButton[] }
|
||||
| { type: "select"; placeholder?: string; options: MessagePresentationOption[] };
|
||||
|
||||
type MessagePresentationAction =
|
||||
| { type: "command"; command: string }
|
||||
| { type: "callback"; value: string };
|
||||
|
||||
type MessagePresentationButton = {
|
||||
label: string;
|
||||
action?: MessagePresentationAction;
|
||||
/** Legacy callback value. Prefer action for new controls. */
|
||||
value?: string;
|
||||
url?: string;
|
||||
webApp?: { url: string };
|
||||
@@ -67,7 +73,9 @@ type MessagePresentationButton = {
|
||||
|
||||
type MessagePresentationOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
action?: MessagePresentationAction;
|
||||
/** Legacy callback value. Prefer action for new controls. */
|
||||
value?: string;
|
||||
};
|
||||
|
||||
type ReplyPayloadDelivery = {
|
||||
@@ -83,8 +91,13 @@ type ReplyPayloadDelivery = {
|
||||
|
||||
Button semantics:
|
||||
|
||||
- `value` is an application action value routed back through the channel's
|
||||
existing interaction path when the channel supports clickable controls.
|
||||
- `action.type: "command"` runs a native slash command through core's command
|
||||
path. Use this for built-in command buttons and menus.
|
||||
- `action.type: "callback"` carries opaque plugin data through the channel's
|
||||
interaction path. Channel plugins must not reinterpret callback data as slash
|
||||
commands.
|
||||
- `value` is the legacy opaque callback value. New controls should use `action`
|
||||
so channel plugins can map commands and callbacks without guessing from text.
|
||||
- `url` is a link button. It can exist without `value`.
|
||||
- `webApp` describes a channel-native web app button. Telegram renders this
|
||||
as `web_app` and only supports it in private chats. `web_app` is still
|
||||
@@ -106,7 +119,8 @@ Button semantics:
|
||||
|
||||
Select semantics:
|
||||
|
||||
- `options[].value` is the selected application value.
|
||||
- `options[].action` has the same command/callback meaning as button `action`.
|
||||
- `options[].value` is the legacy selected application value.
|
||||
- `placeholder` is advisory and may be ignored by channels without native
|
||||
select support.
|
||||
- If a channel does not support selects, fallback text lists the labels.
|
||||
|
||||
@@ -311,7 +311,8 @@ The branch already has a real shared SQLite base:
|
||||
`delivery_queue_entries`, `model_capability_cache`,
|
||||
`workspace_setup_state`, `native_hook_relay_bridges`,
|
||||
`current_conversation_bindings`, `plugin_binding_approvals`,
|
||||
`tui_last_sessions`, `task_runs`, `task_delivery_state`, `flow_runs`,
|
||||
`tui_last_sessions`, `acp_sessions`, `acp_replay_sessions`,
|
||||
`acp_replay_events`, `task_runs`, `task_delivery_state`, `flow_runs`,
|
||||
`subagent_runs`, `migration_runs`, and `backup_runs`.
|
||||
- Arbitrary plugin-owned state does not get host-owned typed tables. Installed
|
||||
plugins use `plugin_state_entries` for versioned JSON payloads and
|
||||
@@ -1669,6 +1670,8 @@ Move these into agent databases:
|
||||
- ACP replay ledger sessions. Done for runtime writes via
|
||||
`acp_replay_sessions` and `acp_replay_events`; legacy `acp/event-ledger.json`
|
||||
remains only as doctor input.
|
||||
- ACP session metadata. Done for runtime writes via `acp_sessions`; legacy
|
||||
`entry.acp` blocks in `sessions.json` are doctor migration input only.
|
||||
- Trajectory sidecars when they are not explicit export files. Done for runtime
|
||||
writes: trajectory capture writes agent-database `trajectory_runtime_events`
|
||||
rows and mirrors run-scoped artifacts into SQLite. Legacy sidecars are doctor
|
||||
|
||||
@@ -100,6 +100,11 @@ The shorthand is also accepted:
|
||||
Code mode remains off when `tools.codeMode` is omitted, `false`, or an object
|
||||
without `enabled: true`.
|
||||
|
||||
When you use sandboxed agents with configured MCP servers, also make sure the
|
||||
sandbox tool policy allows the bundled MCP plugin, for example with
|
||||
`tools.sandbox.tools.alsoAllow: ["bundle-mcp"]`. See
|
||||
[Configuration - tools and custom providers](/gateway/config-tools#mcp-and-plugin-tools-inside-sandbox-tool-policy).
|
||||
|
||||
Use explicit limits when you want tighter bounds:
|
||||
|
||||
```json5
|
||||
@@ -441,12 +446,13 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
|
||||
|
||||
MCP catalog entries are not callable through `tools.call(...)` or convenience
|
||||
functions in code mode. They are exposed only through the generated `MCP`
|
||||
namespace, which includes TypeScript-style API headers for discovery:
|
||||
namespace. TypeScript-style declaration files are available through the
|
||||
read-only `API` virtual file surface, so agents can inspect MCP signatures
|
||||
without adding MCP schemas to the prompt:
|
||||
|
||||
```typescript
|
||||
const servers = await MCP.$api();
|
||||
const githubApi = await MCP.github.$api();
|
||||
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
|
||||
const files = await API.list("mcp");
|
||||
const githubApi = await API.read("mcp/github.d.ts");
|
||||
|
||||
const issue = await MCP.github.createIssue({
|
||||
owner: "openclaw",
|
||||
@@ -462,7 +468,8 @@ const prompt = await MCP.docs.prompts.get({
|
||||
});
|
||||
```
|
||||
|
||||
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
|
||||
`API.read("mcp/<server>.d.ts")` returns compact declarations inferred from MCP
|
||||
tool metadata:
|
||||
|
||||
```typescript
|
||||
type McpToolResult = {
|
||||
@@ -491,6 +498,20 @@ declare namespace MCP.github {
|
||||
}
|
||||
```
|
||||
|
||||
The declaration files are virtual, not files written under the workspace or
|
||||
state directory. For each code-mode `exec` call, OpenClaw builds the run-scoped
|
||||
tool catalog, keeps the visible MCP entries, renders `mcp/index.d.ts` plus one
|
||||
`mcp/<server>.d.ts` declaration per visible server, and injects that small
|
||||
read-only table into the QuickJS worker. Guest code sees only the `API` object:
|
||||
`API.list(prefix?)` returns file metadata and `API.read(path)` returns the
|
||||
selected declaration content. Unknown paths and `.` / `..` segments are rejected.
|
||||
|
||||
This keeps large MCP schemas out of the model prompt. The agent learns that the
|
||||
virtual API exists from the `exec` tool description, reads only the needed
|
||||
declaration file, and then calls `MCP.<server>.<tool>()` with one object argument.
|
||||
`MCP.<server>.$api()` remains available as an inline fallback when the agent
|
||||
needs a single-tool schema response inside the program.
|
||||
|
||||
The guest runtime must not expose host objects directly. Inputs and outputs cross
|
||||
the bridge as JSON-compatible values with explicit size caps.
|
||||
|
||||
@@ -981,8 +1002,9 @@ Code mode coverage should prove:
|
||||
- all effective non-MCP tools appear in `ALL_TOOLS`
|
||||
- denied tools do not appear in `ALL_TOOLS`
|
||||
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
|
||||
- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP
|
||||
schemas
|
||||
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
|
||||
MCP declarations without a bridge/tool call
|
||||
- MCP namespace `$api()` remains available as an inline fallback for schemas
|
||||
- MCP namespace calls work for visible MCP tools with one object input, while
|
||||
direct MCP catalog entries are absent from `tools.*`
|
||||
- Tool Search control tools are hidden from both the model surface and the hidden
|
||||
@@ -1014,8 +1036,8 @@ Run these as integration or end-to-end tests when changing the runtime:
|
||||
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
|
||||
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
|
||||
`tools.describe`, and `tools.call`.
|
||||
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
|
||||
describe visible MCP tools.
|
||||
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
|
||||
assert the declaration files describe visible MCP tools.
|
||||
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
|
||||
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
|
||||
11. Assert denied tools are absent and cannot be called by guessed id.
|
||||
|
||||
@@ -212,9 +212,9 @@ Local-path behavior follows the same file-read trust model as the agent:
|
||||
- If `tools.fs.workspaceOnly` is `true`, outbound local media paths stay restricted to the OpenClaw temp root, the media cache, agent workspace paths, and sandbox-generated files.
|
||||
- If `tools.fs.workspaceOnly` is `false`, outbound local media can use host-local files the agent is already allowed to read.
|
||||
- Local paths can be absolute, workspace-relative, or home-relative with `~/`.
|
||||
- Host-local sends still only allow media and safe document types (images, audio, video, PDF, and Office documents). Plain text and secret-like files are not treated as sendable media.
|
||||
- Host-local sends still only allow media and safe document types (images, audio, video, PDF, Office documents, and validated text documents such as Markdown/MD, TXT, JSON, YAML, and YML). This is an extension of the existing host-read trust boundary, not a secret scanner: if the agent can read a host-local `secret.txt` or `config.json`, it can attach that file when the extension and content validation match.
|
||||
|
||||
That means generated images/files outside the workspace can now send when your fs policy already allows those reads, without reopening arbitrary host-text attachment exfiltration.
|
||||
That means generated images/files outside the workspace can now send when your fs policy already allows those reads, while arbitrary host-local text extensions remain blocked. Keep sensitive files outside the agent-readable filesystem, or keep `tools.fs.workspaceOnly=true` for stricter local-path sends.
|
||||
|
||||
## Operations checklist
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ async function startRealService(state: DeferredServiceState): Promise<AcpRuntime
|
||||
throw new Error("ACPX runtime service is not started");
|
||||
}
|
||||
state.startPromise ??= (async () => {
|
||||
const { createAcpxRuntimeService } = await loadServiceModule();
|
||||
const service = createAcpxRuntimeService(state.params);
|
||||
const { createAcpxRuntimeService: createAcpxRuntimeServiceLocal } = await loadServiceModule();
|
||||
const service = createAcpxRuntimeServiceLocal(state.params);
|
||||
state.realService = service;
|
||||
await service.start(state.ctx as OpenClawPluginServiceContext);
|
||||
const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
|
||||
@@ -262,7 +262,12 @@ async function terminatePids(
|
||||
deps: AcpxProcessCleanupDeps | undefined,
|
||||
): Promise<number[]> {
|
||||
const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||
const sleep = deps?.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const sleep =
|
||||
deps?.sleep ??
|
||||
((ms) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
}));
|
||||
const terminated: number[] = [];
|
||||
|
||||
for (const pid of pids) {
|
||||
@@ -302,7 +307,7 @@ export async function cleanupOpenClawOwnedAcpxProcessTree(params: {
|
||||
return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
|
||||
}
|
||||
|
||||
let processes: AcpxProcessInfo[] = [];
|
||||
let processes: AcpxProcessInfo[];
|
||||
try {
|
||||
processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
|
||||
} catch {
|
||||
|
||||
@@ -335,7 +335,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
for await (const eventValue of runtime.runTurn({
|
||||
for await (const ignoredEventValue of runtime.runTurn({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
backend: "acpx",
|
||||
@@ -346,6 +346,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
mode: "prompt",
|
||||
requestId: "turn-1",
|
||||
})) {
|
||||
void ignoredEventValue;
|
||||
// no-op
|
||||
}
|
||||
}).rejects.toMatchObject({
|
||||
@@ -568,7 +569,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
for await (const eventValue of runtime.runTurn({
|
||||
for await (const ignoredEventValue of runtime.runTurn({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
backend: "acpx",
|
||||
@@ -579,6 +580,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
mode: "prompt",
|
||||
requestId: "turn-1",
|
||||
})) {
|
||||
void ignoredEventValue;
|
||||
// no-op
|
||||
}
|
||||
|
||||
@@ -599,7 +601,8 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
mode: "prompt",
|
||||
requestId: "turn-2",
|
||||
});
|
||||
for await (const eventValue of turn.events) {
|
||||
for await (const ignoredEventValue of turn.events) {
|
||||
void ignoredEventValue;
|
||||
// no-op
|
||||
}
|
||||
await turn.result;
|
||||
@@ -947,16 +950,16 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(baseStore["load"]).toHaveBeenCalledTimes(1);
|
||||
|
||||
await runtime.prepareFreshSession({
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
});
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(baseStore["load"]).toHaveBeenCalledTimes(1);
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(baseStore["load"]).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrappedStore.save({
|
||||
acpxRecordId: "fresh-record",
|
||||
@@ -966,7 +969,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
||||
expect(baseStore["load"]).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("marks the session fresh after discardPersistentState close", async () => {
|
||||
@@ -998,7 +1001,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
discardPersistentState: true,
|
||||
});
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledOnce();
|
||||
expect(baseStore["load"]).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
|
||||
|
||||
@@ -1196,7 +1196,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
const record = await this.sessionStore.load(
|
||||
input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
);
|
||||
let closeSucceeded = false;
|
||||
let closeSucceeded;
|
||||
try {
|
||||
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
|
||||
handle: input.handle,
|
||||
|
||||
@@ -2958,7 +2958,9 @@ describe("active-memory plugin", () => {
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 5));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, (params.timeoutMs ?? 0) + 5);
|
||||
});
|
||||
return {
|
||||
payloads: [{ text: "late timeout payload that should never become memory context" }],
|
||||
meta: { aborted: true },
|
||||
@@ -3001,7 +3003,9 @@ describe("active-memory plugin", () => {
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedAgent.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5);
|
||||
});
|
||||
return { payloads: [{ text: "remember the ramen place" }] };
|
||||
});
|
||||
|
||||
@@ -3131,7 +3135,9 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 35);
|
||||
});
|
||||
return { payloads: [{ text: "User usually orders ramen." }] };
|
||||
});
|
||||
|
||||
@@ -3221,7 +3227,9 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 35);
|
||||
});
|
||||
return { payloads: [{ text: "User usually orders ramen after late flights." }] };
|
||||
});
|
||||
|
||||
|
||||
@@ -605,25 +605,25 @@ function resolveRecallRunChannelContext(params: {
|
||||
(!explicitProvider || explicitProvider === "webchat")
|
||||
? runnableExplicitChannel
|
||||
: undefined;
|
||||
const resolveReturnValue = (params: {
|
||||
const resolveReturnValue = (paramsLocal: {
|
||||
resolvedChannel?: string;
|
||||
resolvedChannelStrength?: "strong" | "weak";
|
||||
}) => {
|
||||
const trustedResolvedChannel =
|
||||
params.resolvedChannelStrength === "strong" ? params.resolvedChannel : undefined;
|
||||
paramsLocal.resolvedChannelStrength === "strong" ? paramsLocal.resolvedChannel : undefined;
|
||||
return {
|
||||
messageChannel:
|
||||
trustedExplicitChannel ??
|
||||
trustedResolvedChannel ??
|
||||
explicitProvider ??
|
||||
runnableExplicitChannel ??
|
||||
params.resolvedChannel,
|
||||
paramsLocal.resolvedChannel,
|
||||
messageProvider:
|
||||
trustedExplicitChannel ??
|
||||
trustedResolvedChannel ??
|
||||
explicitProvider ??
|
||||
runnableExplicitChannel ??
|
||||
params.resolvedChannel,
|
||||
paramsLocal.resolvedChannel,
|
||||
};
|
||||
};
|
||||
const resolvedSessionKey =
|
||||
@@ -1793,7 +1793,9 @@ function watchTerminalMemorySearchResult(params: {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(tick, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
|
||||
timeoutId = setTimeout(() => {
|
||||
void tick();
|
||||
}, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
|
||||
timeoutId.unref?.();
|
||||
};
|
||||
const tick = async () => {
|
||||
|
||||
11
extensions/amazon-bedrock-mantle/README.md
Normal file
11
extensions/amazon-bedrock-mantle/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Amazon Bedrock Mantle Provider
|
||||
|
||||
Official OpenClaw provider plugin for routing Amazon Bedrock Mantle models through OpenAI-compatible provider flows.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/amazon-bedrock-mantle-provider
|
||||
```
|
||||
|
||||
Use this plugin when your Bedrock deployment exposes Mantle-compatible model routing and you want OpenClaw agents to address those models through the Bedrock Mantle provider.
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Api, Model } from "openclaw/plugin-sdk/llm";
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMantleAnthropicStreamFn,
|
||||
|
||||
11
extensions/amazon-bedrock/README.md
Normal file
11
extensions/amazon-bedrock/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Amazon Bedrock Provider
|
||||
|
||||
Official OpenClaw provider plugin for Amazon Bedrock. It adds Bedrock model discovery, text generation, embeddings, and guardrail-aware provider routing for agents that use AWS-hosted models.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/amazon-bedrock-provider
|
||||
```
|
||||
|
||||
Configure AWS credentials and region through your normal OpenClaw credential/profile setup, then select Bedrock models with the `amazon-bedrock/...` provider prefix.
|
||||
@@ -365,29 +365,29 @@ export async function createBedrockEmbeddingProvider(
|
||||
|
||||
const embedQuery = async (
|
||||
text: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
optionsValue?: { signal?: AbortSignal },
|
||||
): Promise<number[]> => {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return (await embedCohere([text], "search_query", options?.signal))[0] ?? [];
|
||||
return (await embedCohere([text], "search_query", optionsValue?.signal))[0] ?? [];
|
||||
}
|
||||
return embedSingle(text, options?.signal);
|
||||
return embedSingle(text, optionsValue?.signal);
|
||||
};
|
||||
|
||||
const embedBatch = async (
|
||||
texts: string[],
|
||||
options?: { signal?: AbortSignal },
|
||||
optionsLocal?: { signal?: AbortSignal },
|
||||
): Promise<number[][]> => {
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return embedCohere(texts, "search_document", options?.signal);
|
||||
return embedCohere(texts, "search_document", optionsLocal?.signal);
|
||||
}
|
||||
return Promise.all(
|
||||
texts.map((t) => (t.trim() ? embedSingle(t, options?.signal) : Promise.resolve([]))),
|
||||
texts.map((t) => (t.trim() ? embedSingle(t, optionsLocal?.signal) : Promise.resolve([]))),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
setBedrockAppProfileControlPlaneForTest((region) => ({
|
||||
async getInferenceProfile(input) {
|
||||
class GetInferenceProfileCommand {
|
||||
constructor(readonly input: Record<string, unknown> = {}) {}
|
||||
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
|
||||
}
|
||||
bedrockClientConfigs.push(region ? { region } : {});
|
||||
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
|
||||
|
||||
11
extensions/anthropic-vertex/README.md
Normal file
11
extensions/anthropic-vertex/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Anthropic Vertex Provider
|
||||
|
||||
Official OpenClaw provider plugin for Claude models hosted through Google Vertex AI.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/anthropic-vertex-provider
|
||||
```
|
||||
|
||||
Configure Google Cloud credentials and the target Vertex project/region in OpenClaw, then select Claude models with the Anthropic Vertex provider.
|
||||
@@ -127,11 +127,11 @@ export default definePluginEntry({
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
|
||||
normalizeTransport: ({ api, baseUrl }) => {
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api,
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -495,7 +495,10 @@ export async function startGatewayBonjourAdvertiser(
|
||||
return { responder, services };
|
||||
}
|
||||
|
||||
async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) {
|
||||
async function stopCycle(
|
||||
cycle: BonjourCycle | null,
|
||||
optsValue?: { shutdownResponder?: boolean },
|
||||
) {
|
||||
if (!cycle) {
|
||||
return;
|
||||
}
|
||||
@@ -507,7 +510,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (opts?.shutdownResponder) {
|
||||
if (optsValue?.shutdownResponder) {
|
||||
await cycle.responder.shutdown();
|
||||
}
|
||||
} catch {
|
||||
@@ -615,7 +618,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
};
|
||||
|
||||
const recreateAdvertiser = async (reason: string, opts?: { stuckState?: boolean }) => {
|
||||
const recreateAdvertiser = async (reason: string, optsLocal?: { stuckState?: boolean }) => {
|
||||
if (stopped || disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -624,7 +627,9 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
recreatePromise = (async () => {
|
||||
consecutiveRestarts += 1;
|
||||
consecutiveStuckStateRestarts = opts?.stuckState ? consecutiveStuckStateRestarts + 1 : 0;
|
||||
consecutiveStuckStateRestarts = optsLocal?.stuckState
|
||||
? consecutiveStuckStateRestarts + 1
|
||||
: 0;
|
||||
const now = Date.now();
|
||||
while (
|
||||
restartTimestamps.length > 0 &&
|
||||
|
||||
@@ -530,7 +530,7 @@ export function createBrowserTool(opts?: {
|
||||
});
|
||||
|
||||
const proxyRequest = nodeTarget
|
||||
? async (opts: {
|
||||
? async (optsLocal: {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
@@ -540,12 +540,12 @@ export function createBrowserTool(opts?: {
|
||||
}) => {
|
||||
const proxy = await callBrowserProxy({
|
||||
nodeId: nodeTarget.nodeId,
|
||||
method: opts.method,
|
||||
path: opts.path,
|
||||
query: opts.query,
|
||||
body: opts.body,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
profile: opts.profile,
|
||||
method: optsLocal.method,
|
||||
path: optsLocal.path,
|
||||
query: optsLocal.query,
|
||||
body: optsLocal.body,
|
||||
timeoutMs: optsLocal.timeoutMs,
|
||||
profile: optsLocal.profile,
|
||||
});
|
||||
const mapping = await persistProxyFiles(proxy.files);
|
||||
applyProxyPaths(proxy.result, mapping);
|
||||
|
||||
@@ -232,12 +232,13 @@ describe("cdp-proxy-bypass", () => {
|
||||
describe("withNoProxyForLocalhost concurrency", () => {
|
||||
it("does not leak NO_PROXY when called concurrently", async () => {
|
||||
await withIsolatedNoProxyEnv(async () => {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostScoped } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
const releaseA = createDeferred();
|
||||
const enteredA = createDeferred();
|
||||
|
||||
const callA = withNoProxyForLocalhost(async () => {
|
||||
const callA = withNoProxyForLocalhostScoped(async () => {
|
||||
expect(process.env.NO_PROXY).toContain("localhost");
|
||||
expect(process.env.NO_PROXY).toContain("[::1]");
|
||||
enteredA.resolve();
|
||||
@@ -247,7 +248,7 @@ describe("withNoProxyForLocalhost concurrency", () => {
|
||||
|
||||
await enteredA.promise;
|
||||
|
||||
const callB = withNoProxyForLocalhost(async () => {
|
||||
const callB = withNoProxyForLocalhostScoped(async () => {
|
||||
return "b";
|
||||
});
|
||||
|
||||
@@ -264,21 +265,22 @@ describe("withNoProxyForLocalhost concurrency", () => {
|
||||
describe("withNoProxyForLocalhost reverse exit order", () => {
|
||||
it("restores NO_PROXY when first caller exits before second", async () => {
|
||||
await withIsolatedNoProxyEnv(async () => {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostItem } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
const enteredA = createDeferred();
|
||||
const enteredB = createDeferred();
|
||||
const releaseA = createDeferred();
|
||||
const releaseB = createDeferred();
|
||||
|
||||
const callA = withNoProxyForLocalhost(async () => {
|
||||
const callA = withNoProxyForLocalhostItem(async () => {
|
||||
enteredA.resolve();
|
||||
await releaseA.promise;
|
||||
return "a";
|
||||
});
|
||||
await enteredA.promise;
|
||||
|
||||
const callB = withNoProxyForLocalhost(async () => {
|
||||
const callB = withNoProxyForLocalhostItem(async () => {
|
||||
enteredB.resolve();
|
||||
await releaseB.promise;
|
||||
return "b";
|
||||
@@ -306,9 +308,10 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy:8080";
|
||||
|
||||
try {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostCandidate } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
await withNoProxyForLocalhost(async () => {
|
||||
await withNoProxyForLocalhostCandidate(async () => {
|
||||
// Should not modify since loopback is already covered
|
||||
expect(process.env.NO_PROXY).toBe(userNoProxy);
|
||||
return "ok";
|
||||
@@ -332,9 +335,10 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy:8080";
|
||||
|
||||
try {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostEntry } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
await withNoProxyForLocalhost(async () => {
|
||||
await withNoProxyForLocalhostEntry(async () => {
|
||||
expect(process.env.NO_PROXY).toBe(`${coveredNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
expect(process.env.no_proxy).toBe(`${staleLowerNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
});
|
||||
@@ -355,9 +359,10 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy:8080";
|
||||
|
||||
try {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostResult } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
await withNoProxyForLocalhost(async () => {
|
||||
await withNoProxyForLocalhostResult(async () => {
|
||||
expect(process.env.NO_PROXY).toBe(`${lowerNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
expect(process.env.no_proxy).toBe(`${lowerNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
});
|
||||
@@ -378,9 +383,10 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy:8080";
|
||||
|
||||
try {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostValue } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
await withNoProxyForLocalhost(async () => {
|
||||
await withNoProxyForLocalhostValue(async () => {
|
||||
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
delete process.env.no_proxy;
|
||||
@@ -402,9 +408,10 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy:8080";
|
||||
|
||||
try {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
const { withNoProxyForLocalhost: withNoProxyForLocalhostLocal } =
|
||||
await import("./cdp-proxy-bypass.js");
|
||||
|
||||
await withNoProxyForLocalhost(async () => {
|
||||
await withNoProxyForLocalhostLocal(async () => {
|
||||
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,9 @@ import { BrowserCdpEndpointBlockedError } from "./errors.js";
|
||||
|
||||
async function startWsServer() {
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` };
|
||||
}
|
||||
@@ -55,7 +57,9 @@ describe("cdp.helpers internal", () => {
|
||||
registerManagedProxyBrowserCdpBypassMock.mockReset();
|
||||
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss?.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.close(() => resolve());
|
||||
});
|
||||
wss = null;
|
||||
}
|
||||
});
|
||||
@@ -307,7 +311,9 @@ describe("cdp.helpers internal", () => {
|
||||
cb(true);
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
let callbackCount = 0;
|
||||
wss.on("connection", (socket) => {
|
||||
@@ -341,7 +347,9 @@ describe("cdp.helpers internal", () => {
|
||||
cb(false, 429, "too many requests");
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -397,7 +397,9 @@ type CdpSocketOptions = {
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRetryCount(value: number | undefined, fallback: number): number {
|
||||
|
||||
@@ -79,7 +79,9 @@ function replyToViewportCommandOrScreenshot(
|
||||
|
||||
async function startMockWsServer(handle: CdpReplyHandler) {
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
wss.on("connection", (socket) => {
|
||||
socket.on("message", (raw) => {
|
||||
@@ -113,7 +115,9 @@ describe("cdp internal", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss?.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.close(() => resolve());
|
||||
});
|
||||
wss = null;
|
||||
}
|
||||
});
|
||||
@@ -1072,7 +1076,9 @@ describe("cdp internal", () => {
|
||||
// in createTargetViaCdp — the bare-ws root triggers discovery.
|
||||
const http = await import("node:http");
|
||||
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wsServer.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wsServer.once("listening", () => resolve());
|
||||
});
|
||||
const wsPort = (wsServer.address() as { port: number }).port;
|
||||
wsServer.on("connection", (socket) => {
|
||||
socket.on("message", (raw) => {
|
||||
@@ -1110,7 +1116,9 @@ describe("cdp internal", () => {
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const httpPort = (httpServer.address() as { port: number }).port;
|
||||
try {
|
||||
const out = await createTargetViaCdp({
|
||||
@@ -1119,8 +1127,12 @@ describe("cdp internal", () => {
|
||||
});
|
||||
expect(out.targetId).toBe("T_BARE_WS");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wsServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wsServer.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ describe("cdp", () => {
|
||||
|
||||
const startWsServer = async () => {
|
||||
wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wsServer?.once("listening", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
wsServer?.once("listening", resolve);
|
||||
});
|
||||
return (wsServer.address() as { port: number }).port;
|
||||
};
|
||||
|
||||
@@ -77,7 +79,9 @@ describe("cdp", () => {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
return (httpServer.address() as { port: number }).port;
|
||||
};
|
||||
|
||||
@@ -85,14 +89,16 @@ describe("cdp", () => {
|
||||
vi.unstubAllEnvs();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!httpServer) {
|
||||
return resolve();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
httpServer.close(() => resolve());
|
||||
httpServer = null;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!wsServer) {
|
||||
return resolve();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
wsServer.close(() => resolve());
|
||||
wsServer = null;
|
||||
@@ -190,7 +196,9 @@ describe("cdp", () => {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const httpPort = (httpServer.address() as AddressInfo).port;
|
||||
|
||||
await expect(
|
||||
@@ -210,7 +218,9 @@ describe("cdp", () => {
|
||||
heldSockets.push(socket);
|
||||
// Hold the TCP connection open without completing the WebSocket handshake.
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (httpServer.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
@@ -507,7 +517,9 @@ describe("cdp", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const addr = server.address() as AddressInfo;
|
||||
const created = await createTargetViaCdp({
|
||||
@@ -516,8 +528,12 @@ describe("cdp", () => {
|
||||
});
|
||||
expect(created.targetId).toBe("ROOT_FALLBACK");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -490,11 +490,11 @@ describe("chrome MCP page parsing", () => {
|
||||
url: "about:blank",
|
||||
type: "page",
|
||||
});
|
||||
expect(session.client.callTool).toHaveBeenCalledWith({
|
||||
expect(session.client["callTool"]).toHaveBeenCalledWith({
|
||||
name: "new_page",
|
||||
arguments: { url: "about:blank", timeout: 5000 },
|
||||
});
|
||||
const callToolMock = session.client.callTool as unknown as ToolCallMock;
|
||||
const callToolMock = session.client["callTool"] as unknown as ToolCallMock;
|
||||
const callNames = callToolMock.mock.calls.map(([call]) => call.name);
|
||||
expect(callNames).not.toContain("navigate_page");
|
||||
});
|
||||
@@ -917,7 +917,7 @@ describe("chrome MCP page parsing", () => {
|
||||
// intentionally no timeoutMs
|
||||
});
|
||||
|
||||
const callToolMock = session.client.callTool as unknown as ToolCallMock;
|
||||
const callToolMock = session.client["callTool"] as unknown as ToolCallMock;
|
||||
const navigateCall = callToolMock.mock.calls.find(
|
||||
([call]) => call.name === "navigate_page",
|
||||
)?.[0];
|
||||
|
||||
@@ -137,7 +137,7 @@ async function diagnoseCdpHealthCommand(
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
let parsed: { id?: unknown; result?: unknown } | null = null;
|
||||
let parsed: { id?: unknown; result?: unknown } | null;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
|
||||
} catch {
|
||||
|
||||
@@ -194,8 +194,12 @@ async function withMockChromeCdpServer(params: {
|
||||
const addr = server.address() as AddressInfo;
|
||||
await params.run(`http://127.0.0.1:${addr.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,9 +956,13 @@ describe("chrome.ts internal", () => {
|
||||
it("resolves false when the direct-ws probe cannot connect", async () => {
|
||||
// Bind a ws server and then close it, so connecting to it fails.
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await expect(
|
||||
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/GONE`, 50),
|
||||
).resolves.toBe(false);
|
||||
@@ -962,7 +970,9 @@ describe("chrome.ts internal", () => {
|
||||
|
||||
it("resolves true when the direct-ws handshake succeeds", async () => {
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
try {
|
||||
// Direct /devtools/ WS URL — isChromeReachable goes through
|
||||
@@ -972,7 +982,9 @@ describe("chrome.ts internal", () => {
|
||||
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/OK`, 500),
|
||||
).resolves.toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -994,9 +1006,13 @@ describe("chrome.ts internal", () => {
|
||||
// accepting ws upgrades — the canRunCdpHealthCommand probe will
|
||||
// fire its 'error' handler during handshake.
|
||||
const dead = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => dead.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
dead.once("listening", () => resolve());
|
||||
});
|
||||
const deadPort = (dead.address() as { port: number }).port;
|
||||
await new Promise<void>((resolve) => dead.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
dead.close(() => resolve());
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -1009,14 +1025,18 @@ describe("chrome.ts internal", () => {
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
try {
|
||||
const addr = server.address() as AddressInfo;
|
||||
await expect(isChromeCdpReady(`http://127.0.0.1:${addr.port}`, 50, 10)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,14 +42,12 @@ async function startLoopbackCdpServer(): Promise<RunningServer> {
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
runningServers
|
||||
.splice(0)
|
||||
.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
),
|
||||
runningServers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -108,8 +108,12 @@ async function withMockChromeCdpServer(params: {
|
||||
const addr = server.address() as AddressInfo;
|
||||
await params.run(`http://127.0.0.1:${addr.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +553,9 @@ describe("browser chrome helpers", () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -559,7 +565,7 @@ describe("browser chrome helpers", () => {
|
||||
onConnection: (wss) => {
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
let message: { id?: unknown; method?: unknown } | null = null;
|
||||
let message: { id?: unknown; method?: unknown } | null;
|
||||
try {
|
||||
const text =
|
||||
typeof raw === "string"
|
||||
@@ -755,8 +761,12 @@ describe("browser chrome helpers", () => {
|
||||
expect(diagnostic.wsUrl).toBe(wsOnlyBase);
|
||||
expect(diagnostic.browser).toBe("Browserless/Mock");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -785,12 +795,16 @@ describe("browser chrome helpers", () => {
|
||||
);
|
||||
// A real WS server accepts the handshake.
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as AddressInfo).port;
|
||||
try {
|
||||
await expect(isChromeReachable(`ws://127.0.0.1:${port}`, 500)).resolves.toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -811,7 +825,9 @@ describe("browser chrome helpers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as AddressInfo).port;
|
||||
try {
|
||||
await expect(isChromeCdpReady(`ws://127.0.0.1:${port}`, 500, 500)).resolves.toBe(true);
|
||||
@@ -820,7 +836,9 @@ describe("browser chrome helpers", () => {
|
||||
);
|
||||
expect(diagnostic.wsUrl).toBe(`ws://127.0.0.1:${port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -519,7 +519,9 @@ export async function launchOpenClawChrome(
|
||||
if (exists(localStatePath) && exists(preferencesPath)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS);
|
||||
});
|
||||
}
|
||||
try {
|
||||
bootstrap.kill("SIGTERM");
|
||||
@@ -531,7 +533,9 @@ export async function launchOpenClawChrome(
|
||||
if (bootstrap.exitCode != null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +581,9 @@ export async function launchOpenClawChrome(
|
||||
launchHttpReachable = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_LAUNCH_READY_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
if (!launchHttpReachable) {
|
||||
@@ -682,7 +688,9 @@ export async function stopOpenClawChrome(
|
||||
return;
|
||||
}
|
||||
const remainingMs = timeoutMs - (Date.now() - start);
|
||||
await new Promise((r) => setTimeout(r, Math.max(1, Math.min(100, remainingMs))));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, Math.max(1, Math.min(100, remainingMs)));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,9 @@ describe("browser client fetch attachOnly diagnostics", () => {
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const configPath = path.join(tempHome.home, ".openclaw", "openclaw.json");
|
||||
await fs.writeFile(
|
||||
@@ -78,7 +80,9 @@ describe("browser client fetch attachOnly diagnostics", () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,7 +469,7 @@ export function resolveProfile(
|
||||
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
|
||||
let cdpHost = resolved.cdpHost;
|
||||
let cdpPort = profile.cdpPort ?? 0;
|
||||
let cdpUrl = "";
|
||||
let cdpUrl;
|
||||
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
|
||||
const headless = profile.headless ?? resolved.headless;
|
||||
const headlessSource =
|
||||
|
||||
@@ -212,7 +212,9 @@ describe("pw-session ensurePageState", () => {
|
||||
|
||||
try {
|
||||
handlers.get("download")?.[0]?.(download);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(unhandled).toStrictEqual([]);
|
||||
await expect(download.path?.()).rejects.toThrow("save failed");
|
||||
|
||||
@@ -947,7 +947,9 @@ async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise<
|
||||
break;
|
||||
}
|
||||
const delay = resolveCdpConnectRetryDelayMs(attempt);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lastErr instanceof Error) {
|
||||
@@ -1066,7 +1068,7 @@ async function findPageByTargetId(
|
||||
const pages = await getAllPages(browser);
|
||||
let resolvedViaCdp = false;
|
||||
for (const page of pages) {
|
||||
let tid: string | null = null;
|
||||
let tid: string | null;
|
||||
try {
|
||||
tid = await pageTargetId(page);
|
||||
resolvedViaCdp = true;
|
||||
@@ -1170,7 +1172,7 @@ export async function getPageForTargetId(opts: {
|
||||
}
|
||||
|
||||
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
let sameMainFrame = false;
|
||||
let sameMainFrame;
|
||||
try {
|
||||
sameMainFrame = request.frame() === page.mainFrame();
|
||||
} catch {
|
||||
@@ -1197,7 +1199,7 @@ function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
}
|
||||
|
||||
function isSubframeDocumentNavigationRequest(page: Page, request: Request): boolean {
|
||||
let sameMainFrame = false;
|
||||
let sameMainFrame;
|
||||
try {
|
||||
sameMainFrame = request.frame() === page.mainFrame();
|
||||
} catch {
|
||||
|
||||
@@ -581,7 +581,9 @@ export async function clickViaPlaywright(opts: {
|
||||
abortPromise,
|
||||
reconcileRemoteDialog,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
if (opts.doubleClick) {
|
||||
await awaitActionWithAbort(
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("pw-tools-core", () => {
|
||||
await fs.writeFile(uploadPath, "fixture", "utf8");
|
||||
const canonicalUploadPath = await fs.realpath(uploadPath);
|
||||
const fileChooser = { setFiles: vi.fn(async () => {}) };
|
||||
const waitForEvent = vi.fn(async (eventValue: string, _opts: unknown) => fileChooser);
|
||||
const waitForEvent = vi.fn(async (_eventValue: string, _opts: unknown) => fileChooser);
|
||||
setPwToolsCoreCurrentPage({
|
||||
waitForEvent,
|
||||
keyboard: { press: vi.fn(async () => {}) },
|
||||
|
||||
@@ -45,7 +45,9 @@ import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import { asyncBrowserRoute, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500] as const;
|
||||
|
||||
@@ -86,10 +86,10 @@ export function registerBrowserAgentDebugRoutes(
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "page errors",
|
||||
collect: async ({ cdpUrl, targetId, pw }) =>
|
||||
collect: async ({ cdpUrl, targetId: targetIdValue, pw }) =>
|
||||
await pw.getPageErrorsViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
targetId: targetIdValue,
|
||||
clear,
|
||||
}),
|
||||
});
|
||||
@@ -109,10 +109,10 @@ export function registerBrowserAgentDebugRoutes(
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "network requests",
|
||||
collect: async ({ cdpUrl, targetId, pw }) =>
|
||||
collect: async ({ cdpUrl, targetId: targetIdLocal, pw }) =>
|
||||
await pw.getNetworkRequestsViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
targetId: targetIdLocal,
|
||||
filter: normalizeOptionalString(filter),
|
||||
clear,
|
||||
}),
|
||||
|
||||
@@ -34,7 +34,9 @@ export async function resolveTargetIdAfterNavigate(opts: {
|
||||
const first = pickReplacement(await opts.listTabs());
|
||||
currentTargetId = first.targetId;
|
||||
if (first.shouldRetry) {
|
||||
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, opts.retryDelayMs ?? 800);
|
||||
});
|
||||
currentTargetId = pickReplacement(await opts.listTabs(), {
|
||||
allowSingleTabFallback: true,
|
||||
}).targetId;
|
||||
|
||||
@@ -25,14 +25,14 @@ const {
|
||||
stopKnownBrowserProfilesMock,
|
||||
trackedTabCleanupMock,
|
||||
} = vi.hoisted(() => {
|
||||
const trackedTabCleanupMock = vi.fn();
|
||||
const trackedTabCleanupMockLocal = vi.fn();
|
||||
return {
|
||||
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
|
||||
getPwAiModuleMock: vi.fn(),
|
||||
isPwAiLoadedMock: vi.fn(() => false),
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMock),
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
|
||||
stopKnownBrowserProfilesMock: vi.fn(async () => {}),
|
||||
trackedTabCleanupMock,
|
||||
trackedTabCleanupMock: trackedTabCleanupMockLocal,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -286,7 +286,9 @@ export function createProfileAvailability({
|
||||
if (await isReachable(attemptTimeoutMs)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start. ${await describeCdpFailure(
|
||||
@@ -306,7 +308,9 @@ export function createProfileAvailability({
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS);
|
||||
});
|
||||
}
|
||||
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
|
||||
};
|
||||
|
||||
@@ -350,7 +350,9 @@ export function createProfileTabOps({
|
||||
triggerManagedTabLimit(found.targetId);
|
||||
return assignTabAlias({ profileState, tab: found, label: opts?.label });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS);
|
||||
});
|
||||
}
|
||||
triggerManagedTabLimit(createdViaCdp);
|
||||
return assignTabAlias({
|
||||
|
||||
@@ -264,7 +264,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
isTransportAvailable: (timeoutMs) => getDefaultContext().isTransportAvailable(timeoutMs),
|
||||
isReachable: (timeoutMs, options) => getDefaultContext().isReachable(timeoutMs, options),
|
||||
listTabs: () => getDefaultContext().listTabs(),
|
||||
openTab: (url, opts) => getDefaultContext().openTab(url, opts),
|
||||
openTab: (url, optsLocal) => getDefaultContext().openTab(url, optsLocal),
|
||||
labelTab: (targetId, label) => getDefaultContext().labelTab(targetId, label),
|
||||
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
|
||||
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
|
||||
|
||||
@@ -21,7 +21,9 @@ function isTransientStartupFetchError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function postStartWithRetry(params: {
|
||||
|
||||
@@ -41,7 +41,9 @@ describe("browser control HTTP auth", () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => current.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
current.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
it("requires bearer auth for standalone browser HTTP routes", async () => {
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
type DescribeFn = ReturnType<typeof vi.fn>;
|
||||
|
||||
function makeDeps(
|
||||
describe: DescribeFn,
|
||||
describeCandidate: DescribeFn,
|
||||
overrides?: {
|
||||
normalizeBrowserScreenshot?: ReturnType<typeof vi.fn>;
|
||||
saveMediaBuffer?: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
describeImageFile: describe as never,
|
||||
describeImageFile: describeCandidate as never,
|
||||
normalizeBrowserScreenshot:
|
||||
(overrides?.normalizeBrowserScreenshot as never) ??
|
||||
(vi.fn(async (buffer: Buffer) => ({ buffer })) as never),
|
||||
@@ -41,7 +41,7 @@ async function withTempImage<T>(fn: (filePath: string) => Promise<T>): Promise<T
|
||||
|
||||
describe("describeBrowserScreenshot", () => {
|
||||
it("uses existing image understanding config with a browser screenshot prompt", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({
|
||||
const describeEntry = vi.fn().mockResolvedValue({
|
||||
text: "A login screen.",
|
||||
provider: "openai",
|
||||
model: "gpt-vision",
|
||||
@@ -62,7 +62,7 @@ describe("describeBrowserScreenshot", () => {
|
||||
activeModel: { provider: "anthropic", model: "claude-sonnet-4.6" },
|
||||
mediaScope: { sessionKey: "agent:main:telegram:dm:123", channel: "telegram" },
|
||||
},
|
||||
makeDeps(describe),
|
||||
makeDeps(describeEntry),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -71,7 +71,7 @@ describe("describeBrowserScreenshot", () => {
|
||||
model: "gpt-vision",
|
||||
decision: { outcome: "success" },
|
||||
});
|
||||
expect(describe).toHaveBeenCalledWith({
|
||||
expect(describeEntry).toHaveBeenCalledWith({
|
||||
filePath,
|
||||
cfg: {
|
||||
tools: {
|
||||
@@ -92,7 +92,7 @@ describe("describeBrowserScreenshot", () => {
|
||||
});
|
||||
|
||||
it("resizes screenshots before image understanding when image sanitization is configured", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({ text: "Small screenshot." });
|
||||
const describeResult = vi.fn().mockResolvedValue({ text: "Small screenshot." });
|
||||
const normalizeBrowserScreenshot = vi.fn(async () => ({
|
||||
buffer: Buffer.from("small"),
|
||||
contentType: "image/jpeg" as const,
|
||||
@@ -106,7 +106,7 @@ describe("describeBrowserScreenshot", () => {
|
||||
filePath,
|
||||
imageSanitization: { maxDimensionPx: 800 },
|
||||
},
|
||||
makeDeps(describe, { normalizeBrowserScreenshot, saveMediaBuffer }),
|
||||
makeDeps(describeResult, { normalizeBrowserScreenshot, saveMediaBuffer }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,11 +114,11 @@ describe("describeBrowserScreenshot", () => {
|
||||
maxSide: 800,
|
||||
});
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(Buffer.from("small"), "image/jpeg", "browser");
|
||||
expect(describe.mock.calls[0][0].filePath).toBe("/tmp/resized.jpg");
|
||||
expect(describeResult.mock.calls[0][0].filePath).toBe("/tmp/resized.jpg");
|
||||
});
|
||||
|
||||
it("returns null when image understanding is skipped or not configured", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({
|
||||
const describeValue = vi.fn().mockResolvedValue({
|
||||
text: undefined,
|
||||
decision: { outcome: "skipped" },
|
||||
});
|
||||
@@ -126,13 +126,13 @@ describe("describeBrowserScreenshot", () => {
|
||||
await expect(
|
||||
describeBrowserScreenshot(
|
||||
{ cfg: { browser: {} }, filePath: "/tmp/screenshot.png" },
|
||||
makeDeps(describe),
|
||||
makeDeps(describeValue),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not pass an incomplete active model to media understanding", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
const describeLocal = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
await describeBrowserScreenshot(
|
||||
{
|
||||
@@ -144,10 +144,10 @@ describe("describeBrowserScreenshot", () => {
|
||||
filePath: "/tmp/screenshot.png",
|
||||
activeModel: { model: "missing-provider" },
|
||||
},
|
||||
makeDeps(describe),
|
||||
makeDeps(describeLocal),
|
||||
);
|
||||
|
||||
expect(describe.mock.calls[0][0].activeModel).toBeUndefined();
|
||||
expect(describeLocal.mock.calls[0][0].activeModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
|
||||
|
||||
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
|
||||
const checks: BrowserDoctorCheck[] = [];
|
||||
let status: BrowserStatus | null = null;
|
||||
let status: BrowserStatus | null;
|
||||
|
||||
try {
|
||||
status = await fetchBrowserStatus(parent, profile);
|
||||
@@ -495,8 +495,10 @@ export function registerBrowserManageCommands(
|
||||
if (printJsonResult(parent, result)) {
|
||||
return;
|
||||
}
|
||||
const tab = (result as { tab?: BrowserTab }).tab;
|
||||
defaultRuntime.log(`labeled tab ${tab?.tabId ?? targetId} as ${tab?.label ?? label}`);
|
||||
const tabValue = (result as { tab?: BrowserTab }).tab;
|
||||
defaultRuntime.log(
|
||||
`labeled tab ${tabValue?.tabId ?? targetId} as ${tabValue?.label ?? label}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -555,7 +557,7 @@ export function registerBrowserManageCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const tab = await callBrowserRequest<BrowserTab>(
|
||||
const tabLocal = await callBrowserRequest<BrowserTab>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -565,11 +567,11 @@ export function registerBrowserManageCommands(
|
||||
},
|
||||
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
if (printJsonResult(parent, tab)) {
|
||||
if (printJsonResult(parent, tabLocal)) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
`opened: ${tab.url}\n${tab.tabId ? `tab: ${tab.tabId}\n` : ""}${tab.label ? `label: ${tab.label}\n` : ""}id: ${tab.targetId}`,
|
||||
`opened: ${tabLocal.url}\n${tabLocal.tabId ? `tab: ${tabLocal.tabId}\n` : ""}${tabLocal.label ? `label: ${tabLocal.label}\n` : ""}id: ${tabLocal.targetId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function handleBrowserGatewayRequest({
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
let nodeTarget: NodeSession | null = null;
|
||||
let nodeTarget: NodeSession | null;
|
||||
try {
|
||||
nodeTarget = resolveBrowserNodeTarget({
|
||||
cfg,
|
||||
|
||||
@@ -388,7 +388,6 @@ describe("canvas host", () => {
|
||||
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
|
||||
const linkPath = path.join(a2uiRoot, linkName);
|
||||
let createdBundle = false;
|
||||
let createdLink = false;
|
||||
|
||||
try {
|
||||
await fs.stat(bundlePath);
|
||||
@@ -398,7 +397,6 @@ describe("canvas host", () => {
|
||||
}
|
||||
|
||||
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
|
||||
createdLink = true;
|
||||
|
||||
try {
|
||||
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
|
||||
@@ -421,9 +419,7 @@ describe("canvas host", () => {
|
||||
expect(symlinkRes.status).toBe(404);
|
||||
expect(symlinkRes.body).toBe("not found");
|
||||
} finally {
|
||||
if (createdLink) {
|
||||
await fs.rm(linkPath, { force: true });
|
||||
}
|
||||
await fs.rm(linkPath, { force: true });
|
||||
if (createdBundle) {
|
||||
await fs.rm(bundlePath, { force: true });
|
||||
}
|
||||
|
||||
@@ -443,7 +443,9 @@ export async function createCanvasHostHandler(
|
||||
}
|
||||
}
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -528,9 +530,9 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
|
||||
if (ownsHandler) {
|
||||
await handler.close();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ describe("ClickClack gateway", () => {
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
|
||||
|
||||
socket.emit("message", Buffer.from("{not json"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(runError).toBeUndefined();
|
||||
expect(ctx.log?.warn).toHaveBeenCalledWith(
|
||||
"[default] skipped malformed ClickClack websocket event",
|
||||
|
||||
@@ -190,7 +190,9 @@ export async function startClickClackGatewayAccount(
|
||||
socket.on("error", reject);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
|
||||
@@ -790,7 +790,9 @@ async function waitForFile(filePath: string): Promise<string> {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new Error(`timed out waiting for ${filePath}`);
|
||||
@@ -838,10 +840,14 @@ describe("connectCodexAppServerEndpoint", () => {
|
||||
await expect(
|
||||
Promise.race([
|
||||
probe,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("probe timed out")), 500)),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("probe timed out")), 500);
|
||||
}),
|
||||
]),
|
||||
).resolves.toMatchObject([{ endpointId: "ws", ok: false }]);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed stdio frames instead of throwing out of band", async () => {
|
||||
@@ -930,7 +936,9 @@ describe("connectCodexAppServerEndpoint", () => {
|
||||
);
|
||||
|
||||
await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "exits", ok: true }]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
await expect(supervisor.probeEndpoints()).resolves.toMatchObject([
|
||||
{
|
||||
endpointId: "exits",
|
||||
|
||||
11
extensions/codex/README.md
Normal file
11
extensions/codex/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Codex
|
||||
|
||||
Official OpenClaw provider and harness plugin for OpenAI Codex app-server integration. It exposes the Codex-managed GPT model catalog and the Codex runtime surfaces used by OpenClaw agents.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/codex
|
||||
```
|
||||
|
||||
Use this plugin when you want OpenClaw to run Codex-backed model turns, media understanding, and prompt overlays through the Codex app-server harness.
|
||||
@@ -216,7 +216,7 @@ describe("codex plugin", () => {
|
||||
|
||||
it("enables the native hook relay for public Codex side questions", async () => {
|
||||
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
|
||||
const runSideQuestion = harness.runSideQuestion;
|
||||
const runSideQuestion = harness["runSideQuestion"];
|
||||
const result = { text: "ok" };
|
||||
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ describe("codex provider", () => {
|
||||
listModels: listTestCodexAppServerModels,
|
||||
});
|
||||
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
expect(client["close"]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not close an active shared app-server client during live discovery", async () => {
|
||||
@@ -293,8 +293,8 @@ describe("codex provider", () => {
|
||||
listModels: listTestCodexAppServerModels,
|
||||
});
|
||||
|
||||
expect(activeClient.close).not.toHaveBeenCalled();
|
||||
expect(discoveryClient.close).toHaveBeenCalledTimes(1);
|
||||
expect(activeClient["close"]).not.toHaveBeenCalled();
|
||||
expect(discoveryClient["close"]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves arbitrary Codex app-server model ids as text-only until discovered", () => {
|
||||
|
||||
@@ -337,7 +337,6 @@ export async function startCodexAttemptThread(params: {
|
||||
if (startupClientForAbandonedRequestCleanup === failedClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
attemptedClient = undefined;
|
||||
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server connection closed during startup; retries exhausted",
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
}, 5);
|
||||
});
|
||||
},
|
||||
operation: async () => new Promise<never>(() => undefined),
|
||||
operation: async () => new Promise<never>(() => {}),
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
const run = withCodexStartupTimeout({
|
||||
timeoutMs: 1_000,
|
||||
signal: controller.signal,
|
||||
operation: async () => new Promise<never>(() => undefined),
|
||||
operation: async () => new Promise<never>(() => {}),
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup aborted");
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ describe("CodexAppServerClient", () => {
|
||||
clients.push(harness.client);
|
||||
harness.client.addRequestHandler((request) => {
|
||||
if (request.method === "item/tool/call") {
|
||||
return new Promise<never>(() => undefined);
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
);
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
expect(fake.client.addNotificationHandler).not.toHaveBeenCalled();
|
||||
expect(fake.client["addNotificationHandler"]).not.toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.result?.tokensBefore).toBe(123);
|
||||
|
||||
@@ -179,6 +179,32 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
|
||||
});
|
||||
|
||||
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {
|
||||
get(target, property, receiver) {
|
||||
if (property === "0") {
|
||||
throw new Error("fuzzplugin tool entry getter exploded");
|
||||
}
|
||||
if (property === "1") {
|
||||
return messageTool;
|
||||
}
|
||||
if (property === "length") {
|
||||
return 2;
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
setOpenClawCodingToolsFactoryForTests(() => sourceTools);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
|
||||
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
|
||||
});
|
||||
|
||||
it("limits Codex memory flush runs to managed read and write tools", async () => {
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
embeddedAgentLog,
|
||||
filterProviderNormalizableTools,
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentRuntimeTools,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
resolveSandboxContext,
|
||||
supportsModelTools,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type RuntimeToolSchemaDiagnostic,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
|
||||
@@ -265,15 +267,19 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
},
|
||||
});
|
||||
toolBuildStages.mark("create-openclaw-coding-tools");
|
||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
||||
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
|
||||
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
||||
const readableAllTools = [...readableAllToolProjection.tools];
|
||||
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
|
||||
addSandboxShellDynamicToolsIfAvailable(
|
||||
isCodexMemoryFlushRun(params)
|
||||
? filterCodexMemoryFlushDynamicTools(allTools)
|
||||
: filterCodexDynamicTools(allTools, input.pluginConfig),
|
||||
allTools,
|
||||
? filterCodexMemoryFlushDynamicTools(readableAllTools)
|
||||
: filterCodexDynamicTools(readableAllTools, input.pluginConfig),
|
||||
readableAllTools,
|
||||
input,
|
||||
),
|
||||
allTools,
|
||||
readableAllTools,
|
||||
input,
|
||||
);
|
||||
toolBuildStages.mark("codex-filtering");
|
||||
@@ -295,8 +301,25 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
model: params.model,
|
||||
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
|
||||
preNormalizationDiagnostics.push(...diagnostics),
|
||||
});
|
||||
toolBuildStages.mark("runtime-normalization");
|
||||
if (preNormalizationDiagnostics.length > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
|
||||
{
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
diagnostics: preNormalizationDiagnostics.map((diagnostic) => ({
|
||||
index: diagnostic.toolIndex,
|
||||
tool: diagnostic.toolName,
|
||||
violations: diagnostic.violations.slice(0, 12),
|
||||
violationCount: diagnostic.violations.length,
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
const summary = toolBuildStages.snapshot();
|
||||
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
|
||||
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
|
||||
@@ -308,7 +331,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
phase,
|
||||
totalMs: summary.totalMs,
|
||||
stages: summary.stages,
|
||||
allToolCount: allTools.length,
|
||||
allToolCount: readableAllTools.length,
|
||||
codexFilteredToolCount: codexFilteredTools.length,
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("dynamic tool execution helpers", () => {
|
||||
toolBridge: {
|
||||
handleToolCall: vi.fn((_call, options) => {
|
||||
capturedSignal = options?.signal;
|
||||
return new Promise<never>(() => undefined);
|
||||
return new Promise<never>(() => {});
|
||||
}),
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
@@ -230,7 +230,7 @@ describe("dynamic tool execution helpers", () => {
|
||||
arguments: { action: "poll", sessionId: "process-session", timeout: 30_000 },
|
||||
},
|
||||
toolBridge: {
|
||||
handleToolCall: vi.fn(() => new Promise<never>(() => undefined)),
|
||||
handleToolCall: vi.fn(() => new Promise<never>(() => {})),
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
timeoutMs: 1,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
waitForDiagnosticEventsDrained,
|
||||
type DiagnosticEventPayload,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
|
||||
import {
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
embeddedAgentLog,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
waitForDiagnosticEventsDrained,
|
||||
type DiagnosticEventPayload,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
@@ -85,12 +85,6 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireArray(value: unknown, label: string): Array<unknown> {
|
||||
expect(Array.isArray(value), label).toBe(true);
|
||||
return value as Array<unknown>;
|
||||
}
|
||||
|
||||
function callArg(
|
||||
mock: { mock: { calls: Array<Array<unknown>> } },
|
||||
callIndex: number,
|
||||
@@ -346,9 +340,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
}),
|
||||
);
|
||||
const blockedEvents = diagnosticEvents.filter(
|
||||
(
|
||||
event,
|
||||
): event is Extract<DiagnosticEventPayload, { type: "tool.execution.blocked" }> =>
|
||||
(event): event is Extract<DiagnosticEventPayload, { type: "tool.execution.blocked" }> =>
|
||||
event.type === "tool.execution.blocked",
|
||||
);
|
||||
expect(blockedEvents).toContainEqual(
|
||||
@@ -889,7 +881,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
|
||||
it("passes raw tool failure state into agent tool result middleware", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn(async (eventValue: { isError?: boolean }) => undefined);
|
||||
const handler = vi.fn(async (_eventValue: { isError?: boolean }) => undefined);
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "tokenjuice",
|
||||
pluginName: "Tokenjuice",
|
||||
|
||||
@@ -35,7 +35,9 @@ const tinyPngBase64 =
|
||||
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
|
||||
|
||||
function flushDiagnosticEvents() {
|
||||
return new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function assistantMessage(text: string, timestamp: number) {
|
||||
@@ -316,6 +318,29 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.replayMetadata.replaySafe).toBe(true);
|
||||
});
|
||||
|
||||
it("streams final-answer assistant deltas into partial replies", async () => {
|
||||
const { onPartialReply, projector } = await createProjectorWithAssistantHooks();
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-final",
|
||||
phase: "final_answer",
|
||||
text: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(agentMessageDelta("hel", "msg-final"));
|
||||
await projector.handleNotification(agentMessageDelta("lo", "msg-final"));
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalledTimes(2);
|
||||
expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([
|
||||
{ text: "hel", delta: "hel" },
|
||||
{ text: "hello", delta: "lo" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses mirrored user prompt when the inbound message was already persisted", async () => {
|
||||
const params = await createParams();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -447,6 +447,11 @@ export class CodexAppServerEventProjector {
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
this.rememberAssistantPhase(readItem(params.item));
|
||||
const phase = readString(params, "phase");
|
||||
if (phase) {
|
||||
this.assistantPhaseByItem.set(itemId, phase);
|
||||
}
|
||||
if (!this.assistantStarted) {
|
||||
this.assistantStarted = true;
|
||||
await this.params.onAssistantMessageStart?.();
|
||||
@@ -456,10 +461,13 @@ export class CodexAppServerEventProjector {
|
||||
this.assistantTextByItem.set(itemId, text);
|
||||
if (this.isCommentaryAssistantItem(itemId)) {
|
||||
this.emitCommentaryProgress({ itemId, text });
|
||||
} else if (this.shouldStreamAssistantPartial(itemId)) {
|
||||
await this.params.onPartialReply?.({ text, delta });
|
||||
}
|
||||
// Codex app-server can emit multiple agentMessage items per turn, including
|
||||
// intermediate coordination/progress prose. Keep those deltas internal until
|
||||
// turn completion chooses the last assistant item as the user-visible reply.
|
||||
// their phase identifies terminal answer text or turn completion chooses the
|
||||
// last assistant item as the user-visible reply.
|
||||
}
|
||||
|
||||
private async handleReasoningDelta(
|
||||
@@ -970,6 +978,10 @@ export class CodexAppServerEventProjector {
|
||||
return this.assistantPhaseByItem.get(itemId) === "commentary";
|
||||
}
|
||||
|
||||
private shouldStreamAssistantPartial(itemId: string): boolean {
|
||||
return this.assistantPhaseByItem.get(itemId) === "final_answer";
|
||||
}
|
||||
|
||||
private emitCommentaryProgress(params: { itemId: string; text: string }): void {
|
||||
const progressText = params.text.replace(/\s+/g, " ").trim();
|
||||
if (
|
||||
|
||||
@@ -52,8 +52,30 @@ function createRuntime() {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
const createRunningTaskRun = vi.fn(
|
||||
(params): AgentHarnessTaskRecord => ({
|
||||
taskId: params.sourceId ?? params.runId,
|
||||
runtime: "subagent",
|
||||
sourceId: params.sourceId,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
agentId: params.agentId,
|
||||
runId: params.runId,
|
||||
label: params.label,
|
||||
task: params.task,
|
||||
status: "running",
|
||||
deliveryStatus: params.deliveryStatus ?? "not_applicable",
|
||||
notifyPolicy: params.notifyPolicy ?? "silent",
|
||||
createdAt: params.startedAt ?? Date.now(),
|
||||
startedAt: params.startedAt,
|
||||
lastEventAt: params.lastEventAt,
|
||||
progressSummary: params.progressSummary,
|
||||
}),
|
||||
);
|
||||
const taskRuntime = {
|
||||
createRunningTaskRun: vi.fn(),
|
||||
createRunningTaskRun,
|
||||
tryCreateRunningTaskRun: vi.fn((params) => createRunningTaskRun(params)),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
listTaskRecords: vi.fn((): AgentHarnessTaskRecord[] => []),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
createRunningTaskRun: vi.fn(),
|
||||
tryCreateRunningTaskRun: vi.fn((params) => ({ taskId: "task-native-subagent", ...params })),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
} as unknown as TaskLifecycleRuntime;
|
||||
@@ -49,7 +49,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "codex-thread:child-thread",
|
||||
agentId: "main",
|
||||
runId: "codex-thread:child-thread",
|
||||
@@ -62,7 +62,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 20_000,
|
||||
progressSummary: "Codex native subagent started.",
|
||||
});
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"childSessionKey",
|
||||
);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
@@ -99,7 +99,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).not.toHaveBeenCalled();
|
||||
expect(runtime.tryCreateRunningTaskRun).not.toHaveBeenCalled();
|
||||
expect(runtime.recordTaskRunProgressByRunId).not.toHaveBeenCalled();
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
mirror.handleNotification(notification);
|
||||
mirror.handleNotification(notification);
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps Codex thread status changes onto the mirrored task run", () => {
|
||||
@@ -228,7 +228,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "codex-thread:child-thread",
|
||||
runId: "codex-thread:child-thread",
|
||||
label: "Codex subagent",
|
||||
@@ -240,7 +240,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 40_000,
|
||||
progressSummary: "Codex native subagent spawned.",
|
||||
});
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"childSessionKey",
|
||||
);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
@@ -282,7 +282,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
task: "inspect one thing",
|
||||
@@ -319,7 +319,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
task: "inspect one thing",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isJsonObject } from "./protocol.js";
|
||||
|
||||
export type TaskLifecycleRuntime = Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"createRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
|
||||
export type CodexNativeSubagentTaskMirrorParams = {
|
||||
@@ -27,6 +27,7 @@ export type CodexNativeSubagentTaskMirrorParams = {
|
||||
|
||||
export class CodexNativeSubagentTaskMirror {
|
||||
private readonly mirroredThreadIds = new Set<string>();
|
||||
private readonly failedMirrorThreadIds = new Set<string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
@@ -81,7 +82,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
trimOptional(thread.preview) ??
|
||||
`Codex native subagent${label === "Codex subagent" ? "" : ` ${label}`}`;
|
||||
const createdAt = secondsToMillis(thread.createdAt) ?? this.now();
|
||||
this.runtime.createRunningTaskRun({
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: runId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
@@ -94,6 +95,13 @@ export class CodexNativeSubagentTaskMirror {
|
||||
lastEventAt: this.now(),
|
||||
progressSummary: "Codex native subagent started.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
this.mirroredThreadIds.delete(threadId);
|
||||
this.failedMirrorThreadIds.add(threadId);
|
||||
return;
|
||||
}
|
||||
this.failedMirrorThreadIds.delete(threadId);
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.applyStatus(threadId, thread.status);
|
||||
}
|
||||
|
||||
@@ -106,6 +114,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
|
||||
private applyStatus(threadId: string, status: CodexThreadStatus | null | undefined): void {
|
||||
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
|
||||
return;
|
||||
}
|
||||
const statusType = status?.type;
|
||||
if (!statusType) {
|
||||
return;
|
||||
@@ -219,7 +230,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
const prompt = trimOptional(readString(item, "prompt"));
|
||||
const runId = codexNativeSubagentRunId(normalizedThreadId);
|
||||
const createdAt = this.now();
|
||||
this.runtime.createRunningTaskRun({
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: runId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
@@ -232,6 +243,13 @@ export class CodexNativeSubagentTaskMirror {
|
||||
lastEventAt: createdAt,
|
||||
progressSummary: "Codex native subagent spawned.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
this.mirroredThreadIds.delete(normalizedThreadId);
|
||||
this.failedMirrorThreadIds.add(normalizedThreadId);
|
||||
return;
|
||||
}
|
||||
this.failedMirrorThreadIds.delete(normalizedThreadId);
|
||||
this.terminalRunIds.delete(runId);
|
||||
}
|
||||
|
||||
private applyCollabAgentStatus(
|
||||
@@ -239,6 +257,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
status: string | undefined,
|
||||
message: string | null | undefined,
|
||||
): void {
|
||||
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
|
||||
return;
|
||||
}
|
||||
const normalizedStatus = normalizeAgentStateStatus(status);
|
||||
if (!normalizedStatus) {
|
||||
return;
|
||||
|
||||
@@ -85,7 +85,9 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
}
|
||||
await Promise.race([
|
||||
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 5_000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -312,7 +314,7 @@ export function createAppServerHarness(
|
||||
return {
|
||||
request,
|
||||
requests,
|
||||
async waitForMethod(method: string, timeoutMs: number = appServerHarnessWait.timeout) {
|
||||
waitForMethod: async (method: string, timeoutMs: number = appServerHarnessWait.timeout) => {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!requests.some((entry) => entry.method === method)) {
|
||||
@@ -330,15 +332,15 @@ export function createAppServerHarness(
|
||||
{ interval: 1, timeout: timeoutMs },
|
||||
);
|
||||
},
|
||||
async notify(notification: CodexServerNotification) {
|
||||
notify: async (notification: CodexServerNotification) => {
|
||||
await sendNotification(notification);
|
||||
},
|
||||
waitForServerRequestHandler,
|
||||
async handleServerRequest(request: Parameters<AppServerRequestHandler>[0]) {
|
||||
handleServerRequest: async (requestLocal: Parameters<AppServerRequestHandler>[0]) => {
|
||||
const handler = await waitForServerRequestHandler();
|
||||
return handler(request);
|
||||
return handler(requestLocal);
|
||||
},
|
||||
async completeTurn(params: { threadId: string; turnId: string }) {
|
||||
completeTurn: async (params: { threadId: string; turnId: string }) => {
|
||||
await sendNotification({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
@@ -348,7 +350,7 @@ export function createAppServerHarness(
|
||||
},
|
||||
});
|
||||
},
|
||||
close() {
|
||||
close: () => {
|
||||
for (const handler of closeHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
@@ -334,16 +334,17 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
if (!contextEngine.bootstrap) {
|
||||
throw new Error("expected bootstrap hook");
|
||||
}
|
||||
expect(contextEngine.bootstrap).toHaveBeenCalledTimes(1);
|
||||
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
|
||||
NonNullable<ContextEngine["bootstrap"]>
|
||||
>[0];
|
||||
expect(contextEngine["bootstrap"]).toHaveBeenCalledTimes(1);
|
||||
const bootstrapParams = requireFirstCallArg(
|
||||
contextEngine["bootstrap"],
|
||||
"bootstrap",
|
||||
) as Parameters<NonNullable<ContextEngine["bootstrap"]>>[0];
|
||||
expect(bootstrapParams.sessionId).toBe("session-1");
|
||||
expect(bootstrapParams.sessionKey).toBe("agent:main:session-1");
|
||||
expect(bootstrapParams.sessionFile).toBe(sessionFile);
|
||||
|
||||
expect(contextEngine.assemble).toHaveBeenCalledTimes(1);
|
||||
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
|
||||
expect(contextEngine["assemble"]).toHaveBeenCalledTimes(1);
|
||||
const assembleParams = requireFirstCallArg(contextEngine["assemble"], "assemble") as Parameters<
|
||||
ContextEngine["assemble"]
|
||||
>[0];
|
||||
expect(assembleParams.sessionId).toBe("session-1");
|
||||
@@ -385,12 +386,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
if (!contextEngine.bootstrap) {
|
||||
throw new Error("expected bootstrap hook");
|
||||
}
|
||||
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
|
||||
NonNullable<ContextEngine["bootstrap"]>
|
||||
>[0];
|
||||
const bootstrapParams = requireFirstCallArg(
|
||||
contextEngine["bootstrap"],
|
||||
"bootstrap",
|
||||
) as Parameters<NonNullable<ContextEngine["bootstrap"]>>[0];
|
||||
expect(bootstrapParams.sessionKey).toBe("agent:main:main");
|
||||
|
||||
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
|
||||
const assembleParams = requireFirstCallArg(contextEngine["assemble"], "assemble") as Parameters<
|
||||
ContextEngine["assemble"]
|
||||
>[0];
|
||||
expect(assembleParams.sessionKey).toBe("agent:main:main");
|
||||
@@ -1640,7 +1642,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
|
||||
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
|
||||
const assembleParams = requireFirstCallArg(contextEngine["assemble"], "assemble") as Parameters<
|
||||
ContextEngine["assemble"]
|
||||
>[0];
|
||||
expect(assembleParams.messages.map((message) => message.role)).toEqual([
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user