mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 19:48:59 +08:00
Compare commits
397 Commits
script-to-
...
fix/skip-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd09d2e7d0 | ||
|
|
3aecc4ee9d | ||
|
|
02330f372c | ||
|
|
5645dd4d22 | ||
|
|
5a7857dc18 | ||
|
|
25bd8a7191 | ||
|
|
df87b40bec | ||
|
|
5d9c010628 | ||
|
|
03ca096e84 | ||
|
|
22ddf87d2c | ||
|
|
2147312aa2 | ||
|
|
9698070939 | ||
|
|
1c0b38f960 | ||
|
|
0842cb71eb | ||
|
|
392bd16a1d | ||
|
|
f3050ab614 | ||
|
|
6e798c02d8 | ||
|
|
911cd683d5 | ||
|
|
4637b65470 | ||
|
|
e2b6753b87 | ||
|
|
366ef93641 | ||
|
|
dc881a6a31 | ||
|
|
ea72a3382d | ||
|
|
19677bd4ef | ||
|
|
9c9c884526 | ||
|
|
120fd2f702 | ||
|
|
582c2d41b9 | ||
|
|
30955d3660 | ||
|
|
5370e73ee9 | ||
|
|
cf7850040e | ||
|
|
1380a9e094 | ||
|
|
5055f32ee3 | ||
|
|
1075f3819c | ||
|
|
c09ed1954f | ||
|
|
5372c7146b | ||
|
|
529150868c | ||
|
|
08e0b8cf6b | ||
|
|
5c34695491 | ||
|
|
f3ae525211 | ||
|
|
d47371d9c4 | ||
|
|
b962c53e78 | ||
|
|
4ae94d1d46 | ||
|
|
102c1f4ec7 | ||
|
|
71645bb8a3 | ||
|
|
db4bcd7d09 | ||
|
|
745b011632 | ||
|
|
a18cbcb7c6 | ||
|
|
2ca375fc1a | ||
|
|
22356395a2 | ||
|
|
759b7902ee | ||
|
|
f1c44e2d6d | ||
|
|
ffeccce5f9 | ||
|
|
d4fb49f3c4 | ||
|
|
f7178a74ef | ||
|
|
da67802baf | ||
|
|
5b46a11d2d | ||
|
|
5ee0f13a54 | ||
|
|
3f18ee4567 | ||
|
|
5b1ba437ba | ||
|
|
4132ce155e | ||
|
|
91bcc4cf2a | ||
|
|
a079d98eb4 | ||
|
|
85d5d94519 | ||
|
|
cb1e4356aa | ||
|
|
93e3bcef7a | ||
|
|
8decb546f7 | ||
|
|
e99a6d4c19 | ||
|
|
19c7731292 | ||
|
|
3c64a575dd | ||
|
|
81df1b239b | ||
|
|
80c47ecb99 | ||
|
|
41a0b8df36 | ||
|
|
122f29e5ea | ||
|
|
b6714bf109 | ||
|
|
df86f36a57 | ||
|
|
7279f43bbb | ||
|
|
f51b52ceca | ||
|
|
10da9ae248 | ||
|
|
49e95c5308 | ||
|
|
7de5bdca19 | ||
|
|
7a880bcf29 | ||
|
|
b86b891326 | ||
|
|
481fd10988 | ||
|
|
299d31c56e | ||
|
|
d6774e46e0 | ||
|
|
d491018a45 | ||
|
|
f3a1d1fcb0 | ||
|
|
6456d03868 | ||
|
|
e68db3a1b8 | ||
|
|
57b66b2ec8 | ||
|
|
90e72a67a3 | ||
|
|
6810c67f0c | ||
|
|
ba91eb7acf | ||
|
|
c12d921291 | ||
|
|
884a6a113c | ||
|
|
bed5bf339e | ||
|
|
d1923085e3 | ||
|
|
3b4808100d | ||
|
|
0455028a3c | ||
|
|
e0b1cb76e0 | ||
|
|
be4c541176 | ||
|
|
cbf6f0001b | ||
|
|
bda7581126 | ||
|
|
e349bdb949 | ||
|
|
768704e906 | ||
|
|
ba1403604d | ||
|
|
e939963784 | ||
|
|
5ff7242391 | ||
|
|
c25a4e6d0b | ||
|
|
4ea1b4fc4a | ||
|
|
3881cb3426 | ||
|
|
bfd11ee29f | ||
|
|
664948e7bf | ||
|
|
8142c12db2 | ||
|
|
963783e3be | ||
|
|
283e8cf793 | ||
|
|
78f7ef88eb | ||
|
|
abee98feaa | ||
|
|
53ff3085f9 | ||
|
|
e2fa4f396b | ||
|
|
2d91aaa9ed | ||
|
|
e15dadec64 | ||
|
|
18aa327655 | ||
|
|
94e79a052c | ||
|
|
0804901c11 | ||
|
|
7b03f11084 | ||
|
|
812dcc5d4d | ||
|
|
0b698709d8 | ||
|
|
58ec07c598 | ||
|
|
7aac97c1a9 | ||
|
|
d9c4f9a964 | ||
|
|
f06539d8ba | ||
|
|
9e3db6bedd | ||
|
|
580bba0637 | ||
|
|
68eb5031bd | ||
|
|
aca48b55ad | ||
|
|
69abb2c090 | ||
|
|
6ee989a235 | ||
|
|
d4e67ebc9a | ||
|
|
05bbe75212 | ||
|
|
a48a9bbd7d | ||
|
|
e655357197 | ||
|
|
c3c4d44f6e | ||
|
|
0a314c61b1 | ||
|
|
c1ac18e481 | ||
|
|
956856ae07 | ||
|
|
c624ae49db | ||
|
|
074a4ef7e6 | ||
|
|
31b69a1256 | ||
|
|
44a4b21d9c | ||
|
|
1474f4af2b | ||
|
|
eebb5d73f4 | ||
|
|
c784f649b1 | ||
|
|
cea318bcc6 | ||
|
|
7186f0d654 | ||
|
|
0479b9ed5d | ||
|
|
2c3c4b0122 | ||
|
|
834c7c2e47 | ||
|
|
f0488dd6aa | ||
|
|
47059e4ebc | ||
|
|
8ea5342c99 | ||
|
|
cda11ced07 | ||
|
|
20ef410d64 | ||
|
|
3d3d8a5bef | ||
|
|
0baaa63def | ||
|
|
c0c1a92967 | ||
|
|
f9439715e9 | ||
|
|
8ad356403e | ||
|
|
d9d4da0608 | ||
|
|
7758f5e224 | ||
|
|
54bcdea342 | ||
|
|
dbb62bba85 | ||
|
|
0c6ebcd6c0 | ||
|
|
22405223c2 | ||
|
|
f8fc316b0c | ||
|
|
e098eb735f | ||
|
|
a0a0e5e4cb | ||
|
|
ada70ece6f | ||
|
|
d7d4852e5e | ||
|
|
cc451f98cb | ||
|
|
04255b247c | ||
|
|
97b9bd1d81 | ||
|
|
71ce525c69 | ||
|
|
f559b75918 | ||
|
|
5c3a29a1c2 | ||
|
|
62fad3da86 | ||
|
|
f33cf5c866 | ||
|
|
4559a8d736 | ||
|
|
087e3f56dc | ||
|
|
3f25c578c1 | ||
|
|
a73f026c2d | ||
|
|
508ce22468 | ||
|
|
9911a682f4 | ||
|
|
0c909ea97f | ||
|
|
9b8102d774 | ||
|
|
922aea7d28 | ||
|
|
8c6139006a | ||
|
|
ed88457f3b | ||
|
|
c6d4c5299a | ||
|
|
5baca82072 | ||
|
|
d667dcfb90 | ||
|
|
ad81cb44ba | ||
|
|
f465ae08e2 | ||
|
|
5af0ccfd5f | ||
|
|
8a4d92d362 | ||
|
|
c4e0d27ade | ||
|
|
8c8866c921 | ||
|
|
ca2fbece8b | ||
|
|
4c8ac47dbe | ||
|
|
d32e241ca0 | ||
|
|
c83c37b4d2 | ||
|
|
95dafc824e | ||
|
|
ee2d4e1f79 | ||
|
|
d2bf67f4b7 | ||
|
|
12eeb5cb63 | ||
|
|
83ad2cddee | ||
|
|
66a8d0a7ec | ||
|
|
c48657b920 | ||
|
|
2a02746bd7 | ||
|
|
16f66e367c | ||
|
|
e84d68e794 | ||
|
|
d320e69326 | ||
|
|
f50812dd56 | ||
|
|
8da30037b3 | ||
|
|
09bd5d5d19 | ||
|
|
4f1e2efaa1 | ||
|
|
d3cf3d70f8 | ||
|
|
d79d5487aa | ||
|
|
d03ef9d717 | ||
|
|
490ef68864 | ||
|
|
f0acd91478 | ||
|
|
975d2e9b2b | ||
|
|
27c8fae1ce | ||
|
|
d0ae6ead8b | ||
|
|
bdbc8c6592 | ||
|
|
80238595ed | ||
|
|
0da5861e74 | ||
|
|
46b6aa9044 | ||
|
|
3312c7f467 | ||
|
|
4681a559c0 | ||
|
|
6855cbc3df | ||
|
|
bfc5e49291 | ||
|
|
20ea7a055e | ||
|
|
71fbddd2bb | ||
|
|
16142bebd8 | ||
|
|
788eb2e3bf | ||
|
|
32d0b9c872 | ||
|
|
e074f36168 | ||
|
|
589d3b12dd | ||
|
|
c6b5ef9b20 | ||
|
|
b2f7d9ebc8 | ||
|
|
c6d7d85763 | ||
|
|
81c9bd3997 | ||
|
|
9824061241 | ||
|
|
0bc384fc03 | ||
|
|
045a7148d4 | ||
|
|
c6478defba | ||
|
|
17ba3bc65d | ||
|
|
c38c4e9212 | ||
|
|
2bde35d29c | ||
|
|
0adfca3189 | ||
|
|
b9d676ce45 | ||
|
|
c3bd9250c0 | ||
|
|
e33113426a | ||
|
|
f68b06da46 | ||
|
|
fe61b62c2b | ||
|
|
ce007fbb1e | ||
|
|
cef3293d31 | ||
|
|
5d69ce6aa4 | ||
|
|
6c9fa4ac8c | ||
|
|
90160b52df | ||
|
|
4377bd189d | ||
|
|
91f0767257 | ||
|
|
d53e559ae7 | ||
|
|
625085187e | ||
|
|
5d0f5473da | ||
|
|
8d3929e86f | ||
|
|
eb92a0bf76 | ||
|
|
defaffbb93 | ||
|
|
e834249db3 | ||
|
|
7c276f4ba1 | ||
|
|
71c112219f | ||
|
|
8f66a9028c | ||
|
|
6afb08f9ab | ||
|
|
94a4a3fbc4 | ||
|
|
c1f706d370 | ||
|
|
ab1e5832d2 | ||
|
|
70664e6083 | ||
|
|
25a7e34e11 | ||
|
|
b23022d3af | ||
|
|
bd6dc4bdc3 | ||
|
|
8bef7a214e | ||
|
|
a588a33ffa | ||
|
|
e209a56d0b | ||
|
|
4d3e355a52 | ||
|
|
599abac902 | ||
|
|
25ba8e3d35 | ||
|
|
70de1047b8 | ||
|
|
9dc92156d1 | ||
|
|
cf64a9c517 | ||
|
|
8b06d80655 | ||
|
|
2f222cdc1c | ||
|
|
e5ff835c01 | ||
|
|
fbfaba09fd | ||
|
|
0c651fd082 | ||
|
|
66fde5a467 | ||
|
|
e188350c74 | ||
|
|
ce763e6ec9 | ||
|
|
db02036f8d | ||
|
|
34dbb11e3e | ||
|
|
2333137d83 | ||
|
|
256f224d67 | ||
|
|
f8e7d66ae6 | ||
|
|
4cd83d26be | ||
|
|
5f90f08957 | ||
|
|
8e77d5e144 | ||
|
|
ad0af79ddf | ||
|
|
b3d37f4609 | ||
|
|
4900881747 | ||
|
|
1abf68f12e | ||
|
|
4039f06f70 | ||
|
|
c7549f5040 | ||
|
|
65b1638381 | ||
|
|
6567f99625 | ||
|
|
e896ca9634 | ||
|
|
8ae4580df6 | ||
|
|
06e2614cf4 | ||
|
|
43400f8d5b | ||
|
|
29a647e816 | ||
|
|
5c62ed8db1 | ||
|
|
5b077d549e | ||
|
|
b338a68e57 | ||
|
|
4df8237a01 | ||
|
|
a7ebcfded3 | ||
|
|
6dec15b4ff | ||
|
|
fafcdb5a74 | ||
|
|
af26d005c9 | ||
|
|
53655f39f1 | ||
|
|
93216e1ca1 | ||
|
|
461f0cfc5b | ||
|
|
b832dd27e1 | ||
|
|
88334627fe | ||
|
|
9fd9aa5fcd | ||
|
|
891dd037b5 | ||
|
|
b3a1472875 | ||
|
|
70023a1183 | ||
|
|
dd7376fdcb | ||
|
|
53accb122d | ||
|
|
8686f04699 | ||
|
|
3001ec4381 | ||
|
|
68ef80116f | ||
|
|
dcbea62351 | ||
|
|
479e9d94b8 | ||
|
|
4fef350f8e | ||
|
|
c91cbce77c | ||
|
|
9bc7dced98 | ||
|
|
acb24937e7 | ||
|
|
2800e8ecb6 | ||
|
|
c40e904c1b | ||
|
|
10f3e52be0 | ||
|
|
e7e686db2d | ||
|
|
d2279591bf | ||
|
|
5c74fde912 | ||
|
|
dc384393fc | ||
|
|
4e78776a5c | ||
|
|
e62b0122e7 | ||
|
|
d1ea170c9b | ||
|
|
916616502f | ||
|
|
76cd61a903 | ||
|
|
8432d7d624 | ||
|
|
2ee4b523b4 | ||
|
|
b67775f7fe | ||
|
|
5b9a3d05b6 | ||
|
|
06ddc85857 | ||
|
|
f3f8ca3d92 | ||
|
|
f684527085 | ||
|
|
e17297f7dc | ||
|
|
7c97c6da9b | ||
|
|
f7d96c9301 | ||
|
|
00d2452fac | ||
|
|
cfb27e6437 | ||
|
|
6774e7f259 | ||
|
|
f94a2506d2 | ||
|
|
8db66b416b | ||
|
|
2b92fbc2ee | ||
|
|
7b659543e1 | ||
|
|
4f860bfab0 | ||
|
|
411e79d558 | ||
|
|
7d4001c855 | ||
|
|
2caf92a5b7 | ||
|
|
4747e949c7 | ||
|
|
5a251bc54c | ||
|
|
6ede75dbeb | ||
|
|
3576d1e967 | ||
|
|
003d3100c3 | ||
|
|
94e6255666 | ||
|
|
5af44a7616 |
@@ -91,6 +91,32 @@ attribution.
|
||||
- if any compatibility `removeAfter` is on/before release date, resolve it
|
||||
or explicitly record the blocker before shipping
|
||||
10. Validate and ship:
|
||||
- generate and verify the complete contribution ledger before committing:
|
||||
```bash
|
||||
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
|
||||
--base <base-tag> \
|
||||
--target <target-ref> \
|
||||
--version <YYYY.M.PATCH> \
|
||||
--write-ledger
|
||||
```
|
||||
- the command fails when any `#NNN` reference in release history or the
|
||||
rendered release section is absent from the ledger, when reverted work is
|
||||
presented as shipped, or when an eligible PR author, issue reporter, or
|
||||
known co-author is missing from that entry's `Thanks @...` credit
|
||||
- after the GitHub release or prerelease is published, verify every matching
|
||||
release page against the same source section:
|
||||
```bash
|
||||
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
|
||||
--base <base-tag> \
|
||||
--target <target-ref> \
|
||||
--version <YYYY.M.PATCH> \
|
||||
--release-tag v<YYYY.M.PATCH> \
|
||||
--check-github
|
||||
```
|
||||
- add one `--release-tag` for every beta and stable page in the train; a
|
||||
`### Release verification` tail is permitted, but any other body drift
|
||||
fails the check; the GitHub body must begin with the complete
|
||||
`## YYYY.M.PATCH` changelog section, including its heading
|
||||
- `git diff --check`
|
||||
- for docs/changelog-only changes, no broad tests are required
|
||||
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const repo = "openclaw/openclaw";
|
||||
const excludedHandles = new Set(["openclaw", "clawsweeper", "codex", "steipete"]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
releaseTags: [],
|
||||
checkGithub: false,
|
||||
json: false,
|
||||
writeLedger: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--check-github" || arg === "--json" || arg === "--write-ledger") {
|
||||
options[
|
||||
arg === "--check-github"
|
||||
? "checkGithub"
|
||||
: arg === "--write-ledger"
|
||||
? "writeLedger"
|
||||
: "json"
|
||||
] = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base" || arg === "--target" || arg === "--version" || arg === "--release-tag") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
fail(`missing value for ${arg}`);
|
||||
}
|
||||
if (arg === "--release-tag") {
|
||||
options.releaseTags.push(value);
|
||||
} else {
|
||||
options[arg.slice(2)] = value;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
fail(`unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
for (const name of ["base", "target", "version"]) {
|
||||
if (!options[name]) {
|
||||
fail(`--${name} is required`);
|
||||
}
|
||||
}
|
||||
if (options.checkGithub && options.releaseTags.length === 0) {
|
||||
fail("--check-github requires at least one --release-tag");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
return execFileSync(command, args, {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, NO_COLOR: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return run("git", args).trimEnd();
|
||||
}
|
||||
|
||||
function githubApi(args) {
|
||||
try {
|
||||
return JSON.parse(run("ghx", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
|
||||
} catch (error) {
|
||||
if (typeof error.stdout === "string" && error.stdout.trim() !== "") {
|
||||
return JSON.parse(error.stdout.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function isEligibleHandle(handle) {
|
||||
return Boolean(handle) && !handle.endsWith("[bot]") && !excludedHandles.has(handle.toLowerCase());
|
||||
}
|
||||
|
||||
function sectionFor(changelog, version) {
|
||||
const heading = new RegExp(`^## ${escapeRegExp(version)}\\r?$`, "m").exec(changelog);
|
||||
if (!heading || heading.index === undefined) {
|
||||
fail(`CHANGELOG.md does not contain ## ${version}`);
|
||||
}
|
||||
const start = heading.index;
|
||||
const bodyStart = changelog.indexOf("\n", start) + 1;
|
||||
const next = /^## /gm;
|
||||
next.lastIndex = bodyStart;
|
||||
const nextHeading = next.exec(changelog);
|
||||
const end = nextHeading?.index ?? changelog.length;
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
source: changelog.slice(start, end).trimEnd(),
|
||||
body: changelog.slice(bodyStart, end).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function referencesIn(text) {
|
||||
return [...text.matchAll(/#(\d+)/g)].map((match) => Number(match[1]));
|
||||
}
|
||||
|
||||
function appendReferences(references, additions) {
|
||||
const seen = new Set(references);
|
||||
for (const number of additions) {
|
||||
if (!seen.has(number)) {
|
||||
references.push(number);
|
||||
seen.add(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCommits(base, target) {
|
||||
const mergeBase = git(["merge-base", base, target]);
|
||||
const output = git([
|
||||
"log",
|
||||
"--first-parent",
|
||||
"--reverse",
|
||||
"--format=%H%x1f%s%x1f%B%x1e",
|
||||
`${mergeBase}..${target}`,
|
||||
]);
|
||||
const commits = new Map();
|
||||
const revertsByTarget = new Map();
|
||||
for (const record of output.split("\x1e")) {
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
const [rawHash, subject, ...bodyParts] = record.split("\x1f");
|
||||
const hash = rawHash.trim();
|
||||
const body = bodyParts.join("\x1f");
|
||||
const revertedHash = body.match(/This reverts commit ([0-9a-f]{7,40})\./i)?.[1];
|
||||
const isRevert = subject.startsWith('Revert "') || Boolean(revertedHash);
|
||||
commits.set(hash, { body, hash, isRevert, revertedHash, subject });
|
||||
}
|
||||
for (const commit of commits.values()) {
|
||||
if (!commit.revertedHash) {
|
||||
continue;
|
||||
}
|
||||
const targetHash = [...commits.keys()].find((candidate) => candidate.startsWith(commit.revertedHash));
|
||||
if (targetHash) {
|
||||
const reverts = revertsByTarget.get(targetHash) ?? [];
|
||||
reverts.push(commit.hash);
|
||||
revertsByTarget.set(targetHash, reverts);
|
||||
}
|
||||
}
|
||||
const active = new Map();
|
||||
function isActive(hash) {
|
||||
if (active.has(hash)) {
|
||||
return active.get(hash);
|
||||
}
|
||||
const cancellingReverts = revertsByTarget.get(hash) ?? [];
|
||||
const value = !cancellingReverts.some((revertHash) => isActive(revertHash));
|
||||
active.set(hash, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
const references = [];
|
||||
const revertedReferences = new Set();
|
||||
const coauthorsByReference = new Map();
|
||||
for (const commit of commits.values()) {
|
||||
if (commit.isRevert) {
|
||||
continue;
|
||||
}
|
||||
const uniqueReferences = [...new Set(referencesIn(`${commit.subject}\n${commit.body}`))];
|
||||
if (!isActive(commit.hash)) {
|
||||
for (const number of uniqueReferences) {
|
||||
revertedReferences.add(number);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
appendReferences(references, uniqueReferences);
|
||||
const coauthors = [...commit.body.matchAll(/<(?:(?:\d+)\+)?([^@<>\s]+)@users\.noreply\.github\.com>/gi)]
|
||||
.map((match) => match[1])
|
||||
.filter(isEligibleHandle);
|
||||
for (const number of uniqueReferences) {
|
||||
if (coauthors.length > 0) {
|
||||
const handles = coauthorsByReference.get(number) ?? new Set();
|
||||
for (const handle of coauthors) {
|
||||
handles.add(handle);
|
||||
}
|
||||
coauthorsByReference.set(number, handles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { mergeBase, references, revertedReferences, coauthorsByReference };
|
||||
}
|
||||
|
||||
function graphql(query) {
|
||||
return githubApi(["graphql", "-f", `query=${query}`]).data;
|
||||
}
|
||||
|
||||
function resolveReferences(numbers) {
|
||||
const nodes = new Map();
|
||||
for (let index = 0; index < numbers.length; index += 40) {
|
||||
const chunk = numbers.slice(index, index + 40);
|
||||
const fields = chunk
|
||||
.map(
|
||||
(number) => `n${number}: repository(owner: "openclaw", name: "openclaw") {
|
||||
issueOrPullRequest(number: ${number}) {
|
||||
__typename
|
||||
... on Issue { number title author { __typename login } }
|
||||
... on PullRequest { number title author { __typename login } }
|
||||
}
|
||||
}`,
|
||||
)
|
||||
.join("\n");
|
||||
const data = graphql(`query { ${fields} }`);
|
||||
for (const number of chunk) {
|
||||
const node = data[`n${number}`]?.issueOrPullRequest;
|
||||
if (node) {
|
||||
nodes.set(number, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function resolveCoauthors(handles) {
|
||||
const resolved = new Map();
|
||||
const uniqueHandles = [...new Set(handles)];
|
||||
for (let index = 0; index < uniqueHandles.length; index += 80) {
|
||||
const chunk = uniqueHandles.slice(index, index + 80);
|
||||
const fields = chunk
|
||||
.map(
|
||||
(handle, offset) =>
|
||||
`u${index + offset}: user(login: ${JSON.stringify(handle)}) { __typename login }`,
|
||||
)
|
||||
.join("\n");
|
||||
const data = graphql(`query { ${fields} }`);
|
||||
for (let offset = 0; offset < chunk.length; offset += 1) {
|
||||
const user = data[`u${index + offset}`];
|
||||
if (user?.__typename === "User" && isEligibleHandle(user.login)) {
|
||||
resolved.set(chunk[offset].toLowerCase(), user.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function thanksFor(node, coauthorHandles) {
|
||||
const handles = [];
|
||||
if (node.author?.__typename === "User" && isEligibleHandle(node.author.login)) {
|
||||
handles.push(node.author.login);
|
||||
}
|
||||
for (const handle of coauthorHandles) {
|
||||
if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) {
|
||||
handles.push(handle);
|
||||
}
|
||||
}
|
||||
return handles;
|
||||
}
|
||||
|
||||
function ledgerFor(base, target, references, nodes, coauthorsByReference, resolvedCoauthors) {
|
||||
const missing = references.filter((number) => !nodes.has(number));
|
||||
if (missing.length > 0) {
|
||||
fail(`GitHub could not resolve source references: ${missing.map((number) => `#${number}`).join(", ")}`);
|
||||
}
|
||||
|
||||
const entries = references.map((number) => {
|
||||
const node = nodes.get(number);
|
||||
const rawCoauthors = coauthorsByReference.get(number) ?? new Set();
|
||||
const coauthors = [...rawCoauthors]
|
||||
.map((handle) => resolvedCoauthors.get(handle.toLowerCase()))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
number,
|
||||
title: node.title.replace(/#(\d+)/g, "issue $1").replace(/\s+/g, " ").trim(),
|
||||
type: node.__typename,
|
||||
thanks: thanksFor(node, coauthors),
|
||||
};
|
||||
});
|
||||
|
||||
const pullRequests = entries.filter((entry) => entry.type === "PullRequest");
|
||||
const issues = entries.filter((entry) => entry.type === "Issue");
|
||||
const renderEntry = (entry, issue = false) => {
|
||||
const attribution = entry.thanks.length > 0 ? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.` : "";
|
||||
return `- ${issue ? "Reported: " : ""}${entry.title} (#${entry.number}).${attribution}`;
|
||||
};
|
||||
const ledger = [
|
||||
"### Complete contribution ledger",
|
||||
"",
|
||||
`This audited record covers the complete ${base}..${target} history: ${pullRequests.length} PRs and ${issues.length} linked issues. The grouped notes above prioritize user impact; this ledger preserves every contribution reference and eligible human credit.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
...pullRequests.map((entry) => renderEntry(entry)),
|
||||
"",
|
||||
"#### Linked issues",
|
||||
"",
|
||||
...issues.map((entry) => renderEntry(entry, true)),
|
||||
].join("\n");
|
||||
return { entries, issues, ledger, pullRequests };
|
||||
}
|
||||
|
||||
function replaceLedger(changelog, section, ledger) {
|
||||
const beforeLedger = section.source.replace(/\n+### Complete contribution ledger[\s\S]*$/m, "").trimEnd();
|
||||
const replacement = `${beforeLedger}\n\n${ledger}\n`;
|
||||
return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`;
|
||||
}
|
||||
|
||||
function ledgerChecks(section, entries) {
|
||||
const errors = [];
|
||||
if (!section.source.includes("### Highlights")) {
|
||||
errors.push("missing ### Highlights");
|
||||
}
|
||||
if (!section.source.includes("### Changes")) {
|
||||
errors.push("missing ### Changes");
|
||||
}
|
||||
if (!section.source.includes("### Fixes")) {
|
||||
errors.push("missing ### Fixes");
|
||||
}
|
||||
const ledgerStart = section.source.indexOf("### Complete contribution ledger");
|
||||
if (ledgerStart < 0) {
|
||||
errors.push("missing ### Complete contribution ledger");
|
||||
return errors;
|
||||
}
|
||||
const ledger = section.source.slice(ledgerStart);
|
||||
const entryNumbers = new Set(entries.map((entry) => entry.number));
|
||||
for (const number of new Set(referencesIn(section.source))) {
|
||||
if (!entryNumbers.has(number)) {
|
||||
errors.push(`missing ledger entry for #${number}`);
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const prefix = entry.type === "Issue" ? "- Reported: " : "- ";
|
||||
const line = ledger
|
||||
.split("\n")
|
||||
.find((candidate) => candidate.startsWith(prefix) && candidate.includes(`(#${entry.number})`));
|
||||
if (!line) {
|
||||
errors.push(`missing ledger entry for #${entry.number}`);
|
||||
continue;
|
||||
}
|
||||
for (const handle of entry.thanks) {
|
||||
if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) {
|
||||
errors.push(`missing Thanks @${handle} for #${entry.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function releaseChecks(section, releaseTags) {
|
||||
const expected = section.source;
|
||||
const checks = [];
|
||||
for (const tag of releaseTags) {
|
||||
const release = githubApi([`repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]);
|
||||
const suffix = release.body.slice(expected.length).trimStart();
|
||||
const matches =
|
||||
release.body === expected ||
|
||||
(release.body.startsWith(expected) && (suffix === "" || suffix.startsWith("### Release verification")));
|
||||
checks.push({
|
||||
tag,
|
||||
releaseId: release.id,
|
||||
matches,
|
||||
bodyLength: release.body.length,
|
||||
});
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
let changelog = readFileSync("CHANGELOG.md", "utf8");
|
||||
let section = sectionFor(changelog, options.version);
|
||||
const source = sourceCommits(options.base, options.target);
|
||||
const preexistingNotes = section.source.replace(/\n+### Complete contribution ledger[\s\S]*$/m, "");
|
||||
const noteReferences = referencesIn(preexistingNotes);
|
||||
const revertedNoteReferences = noteReferences.filter((number) => source.revertedReferences.has(number));
|
||||
if (revertedNoteReferences.length > 0) {
|
||||
fail(
|
||||
`release notes reference reverted work: ${[
|
||||
...new Set(revertedNoteReferences),
|
||||
]
|
||||
.map((number) => `#${number}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const references = [...source.references];
|
||||
appendReferences(references, noteReferences);
|
||||
const nodes = resolveReferences(references);
|
||||
const coauthorHandles = [...source.coauthorsByReference.values()].flatMap((handles) => [...handles]);
|
||||
const resolvedCoauthors = resolveCoauthors(coauthorHandles);
|
||||
const ledger = ledgerFor(
|
||||
options.base,
|
||||
options.target,
|
||||
references,
|
||||
nodes,
|
||||
source.coauthorsByReference,
|
||||
resolvedCoauthors,
|
||||
);
|
||||
|
||||
if (options.writeLedger) {
|
||||
changelog = replaceLedger(changelog, section, ledger.ledger);
|
||||
writeFileSync("CHANGELOG.md", changelog);
|
||||
section = sectionFor(changelog, options.version);
|
||||
}
|
||||
|
||||
const errors = ledgerChecks(section, ledger.entries);
|
||||
const github = options.checkGithub ? releaseChecks(section, options.releaseTags) : [];
|
||||
for (const check of github) {
|
||||
if (!check.matches) {
|
||||
errors.push(`GitHub release ${check.tag} does not match the ${options.version} CHANGELOG section`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
base: options.base,
|
||||
target: options.target,
|
||||
mergeBase: source.mergeBase,
|
||||
version: options.version,
|
||||
source: {
|
||||
references: references.length,
|
||||
pullRequests: ledger.pullRequests.length,
|
||||
issues: ledger.issues.length,
|
||||
},
|
||||
github,
|
||||
errors,
|
||||
};
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}\n`,
|
||||
);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -100,6 +100,26 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- `dev`: moving head on `main`
|
||||
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||
|
||||
## Close stable releases on main
|
||||
|
||||
Stable publication is not complete until `main` carries the actual shipped release state.
|
||||
|
||||
1. Start from fresh latest `main`. Audit `release/YYYY.M.PATCH` against it and
|
||||
forward-port real fixes that are absent from `main`. Do not blindly merge
|
||||
release-only compatibility, test, or validation adapters into newer `main`.
|
||||
2. Set `main` to the shipped stable version, not a speculative next train. Run
|
||||
`pnpm release:prep` after the root version change, then
|
||||
`pnpm deps:shrinkwrap:generate`.
|
||||
3. Make `CHANGELOG.md`'s `## YYYY.M.PATCH` section on `main` exactly match the
|
||||
tagged release branch. Include the stable `appcast.xml` update when the mac
|
||||
release published one.
|
||||
4. Do not add `YYYY.M.PATCH+1`, a beta version, or an empty future changelog
|
||||
section to `main` until the operator explicitly starts that release train.
|
||||
5. Run `pnpm release:generated:check`, `pnpm deps:shrinkwrap:check`, and
|
||||
`OPENCLAW_TESTBOX=1 pnpm check:changed`. Push, then verify `origin/main`
|
||||
contains the shipped version and changelog before calling the stable release
|
||||
done.
|
||||
|
||||
## Handle versions and release files consistently
|
||||
|
||||
- Version locations include:
|
||||
@@ -205,6 +225,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
|
||||
next level-2 heading and use that complete block as the release notes.
|
||||
- Before publishing or closing a release, run
|
||||
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable
|
||||
and beta release tag in the train. Do not publish or leave a page live when
|
||||
it is missing a source-history reference, eligible human credit, or the
|
||||
complete matching changelog body.
|
||||
- To update an existing GitHub Release body, resolve the numeric release id and
|
||||
patch that resource with the notes file as the `body` field:
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
|
||||
@@ -773,13 +798,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
and `.dSYM.zip` artifacts to the existing GitHub release in
|
||||
`openclaw/openclaw`.
|
||||
32. For stable releases, download `macos-appcast-<tag>` from the successful
|
||||
private mac run, update `appcast.xml` on `main`, and verify the feed. Merge
|
||||
or cherry-pick release branch changes back to `main` after stable succeeds.
|
||||
private mac run, update `appcast.xml` on `main`, verify the feed, then
|
||||
complete the **Close stable releases on main** gate.
|
||||
33. For beta releases, publish the mac assets only when intentionally requested;
|
||||
expect no shared production
|
||||
`appcast.xml` artifact and do not update the shared production feed unless a
|
||||
separate beta feed exists.
|
||||
34. After publish, verify npm and the attached release artifacts.
|
||||
34. After stable main closeout, verify npm and the attached release artifacts.
|
||||
|
||||
## GHSA advisory work
|
||||
|
||||
|
||||
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -12,9 +12,14 @@
|
||||
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/security-sensitive-guard.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/security-sensitive-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/security-sensitive-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
|
||||
/scripts/github/security-sensitive-guard.mjs @openclaw/openclaw-secops
|
||||
/.gitignore @openclaw/openclaw-secops
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
|
||||
6
.github/workflows/ci-check-testbox.yml
vendored
6
.github/workflows/ci-check-testbox.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
timeout_minutes:
|
||||
type: number
|
||||
description: "Maximum GitHub job runtime for long Testbox commands"
|
||||
default: 120
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
@@ -25,7 +29,7 @@ jobs:
|
||||
contents: read
|
||||
name: "check"
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
|
||||
@@ -407,12 +407,28 @@ jobs:
|
||||
const path = require("node:path");
|
||||
|
||||
const packageDir = process.env.PACKAGE_DIR;
|
||||
function resolveTarballFileName(value, label) {
|
||||
const fileName = typeof value === "string" ? value.trim() : "";
|
||||
if (
|
||||
!fileName.endsWith(".tgz") ||
|
||||
fileName.includes("\0") ||
|
||||
fileName !== path.basename(fileName) ||
|
||||
fileName !== path.win32.basename(fileName)
|
||||
) {
|
||||
throw new Error(`${label} must be a local .tgz filename.`);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim();
|
||||
const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz"));
|
||||
const candidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
|
||||
if (!candidateFileName) {
|
||||
const selectedCandidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
|
||||
if (!selectedCandidateFileName) {
|
||||
throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`);
|
||||
}
|
||||
const candidateFileName = resolveTarballFileName(
|
||||
selectedCandidateFileName,
|
||||
"candidate_file_name",
|
||||
);
|
||||
if (!fs.existsSync(path.join(packageDir, candidateFileName))) {
|
||||
throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`);
|
||||
}
|
||||
@@ -474,12 +490,23 @@ jobs:
|
||||
run: |
|
||||
node <<'NODE' >>"$GITHUB_OUTPUT"
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
function resolveTarballFileName(value, label) {
|
||||
const fileName = typeof value === "string" ? value.trim() : "";
|
||||
if (
|
||||
!fileName.endsWith(".tgz") ||
|
||||
fileName.includes("\0") ||
|
||||
fileName !== path.basename(fileName) ||
|
||||
fileName !== path.win32.basename(fileName)
|
||||
) {
|
||||
throw new Error(`${label} must be a local .tgz filename.`);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(process.env.BASELINE_PACK_JSON, "utf8"));
|
||||
const entry = Array.isArray(payload) ? payload.at(-1) : null;
|
||||
if (!entry?.filename) {
|
||||
throw new Error("Baseline npm pack did not produce a filename.");
|
||||
}
|
||||
process.stdout.write(`file_name=${entry.filename}\n`);
|
||||
const fileName = resolveTarballFileName(entry?.filename, "Baseline npm pack filename");
|
||||
process.stdout.write(`file_name=${fileName}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload candidate artifact
|
||||
|
||||
24
.github/workflows/openclaw-npm-release.yml
vendored
24
.github/workflows/openclaw-npm-release.yml
vendored
@@ -223,10 +223,25 @@ jobs:
|
||||
set -euo pipefail
|
||||
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
|
||||
npm pack --json 2>&1 | tee "$PACK_OUTPUT"
|
||||
PACK_PATH="$(node - "$PACK_OUTPUT" <<'NODE'
|
||||
PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const input = fs.readFileSync(process.argv[2], "utf8");
|
||||
|
||||
function resolveTarballFileName(value) {
|
||||
const fileName = typeof value === "string" ? value.trim() : "";
|
||||
if (
|
||||
!fileName.endsWith(".tgz") ||
|
||||
fileName.includes("\0") ||
|
||||
fileName !== path.basename(fileName) ||
|
||||
fileName !== path.win32.basename(fileName)
|
||||
) {
|
||||
console.error(`npm pack reported unsafe tarball filename ${JSON.stringify(fileName)}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function arrayEndFrom(start) {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
@@ -266,8 +281,8 @@ jobs:
|
||||
try {
|
||||
const parsed = JSON.parse(input.slice(start, end));
|
||||
const first = Array.isArray(parsed) ? parsed[0] : null;
|
||||
if (first && typeof first.filename === "string" && first.filename) {
|
||||
process.stdout.write(first.filename);
|
||||
if (first && Object.prototype.hasOwnProperty.call(first, "filename")) {
|
||||
process.stdout.write(resolveTarballFileName(first.filename));
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
@@ -279,6 +294,7 @@ jobs:
|
||||
process.exit(1);
|
||||
NODE
|
||||
)"
|
||||
PACK_PATH="$PWD/$PACK_NAME"
|
||||
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
|
||||
echo "npm pack did not produce a tarball file." >&2
|
||||
exit 1
|
||||
@@ -290,7 +306,7 @@ jobs:
|
||||
else
|
||||
RELEASE_TAG="${RELEASE_REF}"
|
||||
fi
|
||||
TARBALL_NAME="$(basename "$PACK_PATH")"
|
||||
TARBALL_NAME="$PACK_NAME"
|
||||
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
|
||||
55
.github/workflows/security-sensitive-guard.yml
vendored
Normal file
55
.github/workflows/security-sensitive-guard.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Security Sensitive Guard
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: security-sensitive-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
security-sensitive-guard-detect:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect security-sensitive changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: detect
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/security-sensitive-guard.mjs
|
||||
|
||||
security-sensitive-guard:
|
||||
if: ${{ !github.event.pull_request.draft && always() }}
|
||||
needs:
|
||||
- security-sensitive-guard-detect
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce security-sensitive guard
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: enforce
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/security-sensitive-guard.mjs
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -77,6 +77,7 @@ extensions/canvas/src/host/a2ui/*.map
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
apps/android/fastlane/README.md
|
||||
apps/ios/fastlane/report.xml
|
||||
apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
|
||||
1340
CHANGELOG.md
1340
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
11
apps/android/CHANGELOG.md
Normal file
11
apps/android/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Android Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
OpenClaw is now available on Android.
|
||||
|
||||
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.
|
||||
14
apps/android/Config/ReleaseSigning.json
Normal file
14
apps/android/Config/ReleaseSigning.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"signingRepo": "git@github.com:openclaw/apps-signing.git",
|
||||
"signingBranch": "main",
|
||||
"assetPath": "android/openclaw",
|
||||
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
|
||||
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
|
||||
"materializedRoot": "apps/android/build/release-signing",
|
||||
"gradlePropertyNames": [
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD",
|
||||
"OPENCLAW_ANDROID_KEY_ALIAS",
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD"
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,16 @@ pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
```
|
||||
|
||||
Release-owner signing sync:
|
||||
|
||||
```bash
|
||||
pnpm android:release:signing:plan
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
|
||||
```
|
||||
|
||||
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
|
||||
|
||||
Generate raw Google Play screenshots:
|
||||
|
||||
```bash
|
||||
@@ -64,7 +74,7 @@ pnpm android:screenshots
|
||||
- Play build: `openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `openclaw-<version>-third-party-release.apk`
|
||||
|
||||
`pnpm android:bundle:release` is an alias for the same archive helper.
|
||||
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
|
||||
|
||||
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ Android release builds use pinned app metadata instead of auto-bumping `build.gr
|
||||
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
|
||||
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
|
||||
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
|
||||
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -23,16 +25,41 @@ pnpm android:version:check
|
||||
pnpm android:version:sync
|
||||
pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
pnpm android:release:signing:plan
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
|
||||
pnpm android:release:preflight
|
||||
```
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
|
||||
|
||||
1. exact pinned version, for example `## 2026.6.2`
|
||||
2. `## Unreleased`
|
||||
|
||||
Recommended workflow:
|
||||
|
||||
- while iterating on a Play internal testing train, keep pending notes under `## Unreleased`
|
||||
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
|
||||
|
||||
## Release Workflow
|
||||
|
||||
1. Pin Android to the intended release version.
|
||||
2. Run `pnpm android:version:sync`.
|
||||
3. Update `apps/android/fastlane/metadata/android/en-US/release_notes.txt`.
|
||||
4. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
|
||||
5. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
|
||||
6. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
|
||||
7. Promote to production manually in Google Play Console.
|
||||
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
|
||||
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
|
||||
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
|
||||
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
|
||||
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
|
||||
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
|
||||
9. Promote to production manually in Google Play Console.
|
||||
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
|
||||
|
||||
## Signing model
|
||||
|
||||
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
|
||||
|
||||
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
|
||||
|
||||
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.
|
||||
|
||||
@@ -111,6 +111,8 @@ class MainViewModel(
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> =
|
||||
runtimeState(initial = GatewayNodeApprovalState.Loading) { it.nodeCapabilityApprovalState }
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
|
||||
@@ -69,6 +69,7 @@ import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -301,6 +302,8 @@ class NodeRuntime(
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
private val _nodeConnected = MutableStateFlow(false)
|
||||
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
|
||||
private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
|
||||
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
@@ -395,6 +398,7 @@ class NodeRuntime(
|
||||
val nodesDevicesRefreshing: StateFlow<Boolean> = _nodesDevicesRefreshing.asStateFlow()
|
||||
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
|
||||
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
|
||||
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
|
||||
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
|
||||
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
|
||||
private val _channelsRefreshing = MutableStateFlow(false)
|
||||
@@ -452,6 +456,7 @@ class NodeRuntime(
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
invalidateNodeCapabilityApprovalState()
|
||||
operatorStatusText = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
@@ -512,12 +517,15 @@ class NodeRuntime(
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
|
||||
val endpoint = connectedEndpoint
|
||||
val auth = activeGatewayAuth
|
||||
if (endpoint != null && auth != null) {
|
||||
if (operatorConnected) {
|
||||
scope.launch { refreshNodesDevicesFromGateway() }
|
||||
} else if (endpoint != null && auth != null) {
|
||||
maybeStartOperatorSessionAfterNodeConnect(endpoint, auth)
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
invalidateNodeCapabilityApprovalState()
|
||||
nodeStatusText = message
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
@@ -2009,21 +2017,42 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
private suspend fun refreshNodesDevicesFromGateway() {
|
||||
_nodesDevicesRefreshing.value = true
|
||||
_nodesDevicesErrorText.value = null
|
||||
val refreshGeneration = nodeApprovalRefreshGuard.begin()
|
||||
val refreshStarted =
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodesDevicesRefreshing.value = true
|
||||
_nodesDevicesErrorText.value = null
|
||||
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
|
||||
}
|
||||
if (!refreshStarted) return
|
||||
if (!operatorConnected) {
|
||||
_nodesDevicesSummary.value =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = emptyList(),
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
)
|
||||
_nodesDevicesRefreshing.value = false
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodesDevicesSummary.value =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = emptyList(),
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
)
|
||||
_nodesDevicesRefreshing.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
val nodesRes = operatorSession.request("node.list", "{}")
|
||||
val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
|
||||
val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray)
|
||||
val approvalState =
|
||||
currentNodeCapabilityApprovalState(
|
||||
nodes = nodes,
|
||||
selfNodeId = identityStore.loadOrCreate().deviceId,
|
||||
)
|
||||
val publishedApproval =
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodeCapabilityApprovalState.value = approvalState
|
||||
}
|
||||
if (!publishedApproval) {
|
||||
return
|
||||
}
|
||||
val devicesRoot =
|
||||
try {
|
||||
val devicesRes = operatorSession.request("device.pair.list", "{}")
|
||||
@@ -2031,16 +2060,30 @@ class NodeRuntime(
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
_nodesDevicesSummary.value =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray),
|
||||
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
|
||||
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
|
||||
devicePairingAvailable = devicesRoot != null,
|
||||
)
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodesDevicesSummary.value =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = nodes,
|
||||
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
|
||||
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
|
||||
devicePairingAvailable = devicesRoot != null,
|
||||
)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
_nodesDevicesErrorText.value = "Could not load nodes and devices."
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodesDevicesErrorText.value = "Could not load nodes and devices."
|
||||
}
|
||||
} finally {
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodesDevicesRefreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateNodeCapabilityApprovalState() {
|
||||
val refreshGeneration = nodeApprovalRefreshGuard.begin()
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
|
||||
_nodesDevicesRefreshing.value = false
|
||||
}
|
||||
}
|
||||
@@ -2289,22 +2332,8 @@ class NodeRuntime(
|
||||
|
||||
private fun parseGatewayNodes(nodes: JsonArray?): List<GatewayNodeSummary> =
|
||||
nodes
|
||||
?.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return@mapNotNull null
|
||||
GatewayNodeSummary(
|
||||
id = id,
|
||||
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
paired = obj.boolean("paired"),
|
||||
connected = obj.boolean("connected"),
|
||||
capabilities = parseStringArray(obj["caps"] as? JsonArray),
|
||||
commands = parseStringArray(obj["commands"] as? JsonArray),
|
||||
)
|
||||
}.orEmpty()
|
||||
?.mapNotNull(::parseGatewayNodeSummary)
|
||||
.orEmpty()
|
||||
|
||||
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
|
||||
devices
|
||||
@@ -2832,6 +2861,81 @@ data class GatewayNodesDevicesSummary(
|
||||
val devicePairingAvailable: Boolean = true,
|
||||
)
|
||||
|
||||
enum class GatewayNodeApprovalState {
|
||||
Loading,
|
||||
Unsupported,
|
||||
Approved,
|
||||
PendingApproval,
|
||||
PendingReapproval,
|
||||
Unapproved,
|
||||
}
|
||||
|
||||
/** Prevents older node.list responses from overwriting newer approval state. */
|
||||
internal class GatewayNodeApprovalRefreshGuard {
|
||||
private val lock = Any()
|
||||
private var generation = 0L
|
||||
|
||||
fun begin(): Long =
|
||||
synchronized(lock) {
|
||||
generation += 1
|
||||
generation
|
||||
}
|
||||
|
||||
fun publishIfCurrent(
|
||||
refreshGeneration: Long,
|
||||
publish: () -> Unit,
|
||||
): Boolean =
|
||||
synchronized(lock) {
|
||||
if (refreshGeneration != generation) return@synchronized false
|
||||
publish()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalState =
|
||||
when (raw?.trim()?.lowercase()) {
|
||||
null, "" -> GatewayNodeApprovalState.Loading
|
||||
"approved" -> GatewayNodeApprovalState.Approved
|
||||
"pending-approval" -> GatewayNodeApprovalState.PendingApproval
|
||||
"pending-reapproval" -> GatewayNodeApprovalState.PendingReapproval
|
||||
"unapproved" -> GatewayNodeApprovalState.Unapproved
|
||||
else -> GatewayNodeApprovalState.Loading
|
||||
}
|
||||
|
||||
internal fun currentNodeCapabilityApprovalState(
|
||||
nodes: List<GatewayNodeSummary>,
|
||||
selfNodeId: String,
|
||||
): GatewayNodeApprovalState =
|
||||
nodes
|
||||
.firstOrNull { it.id == selfNodeId }
|
||||
?.approvalState
|
||||
?: GatewayNodeApprovalState.Loading
|
||||
|
||||
internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
|
||||
val obj = item.asObjectOrNull() ?: return null
|
||||
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return null
|
||||
return GatewayNodeSummary(
|
||||
id = id,
|
||||
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
paired = obj.boolean("paired"),
|
||||
connected = obj.boolean("connected"),
|
||||
// Only an omitted field identifies a legacy gateway; malformed and future values stay fail-closed.
|
||||
approvalState =
|
||||
if (obj.containsKey("approvalState")) {
|
||||
parseGatewayNodeApprovalState(obj["approvalState"].asStringOrNull())
|
||||
} else {
|
||||
GatewayNodeApprovalState.Unsupported
|
||||
},
|
||||
pendingRequestId = obj["pendingRequestId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray),
|
||||
commands = parseGatewayStringArray(obj["commands"] as? JsonArray),
|
||||
)
|
||||
}
|
||||
|
||||
data class GatewayNodeSummary(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
@@ -2840,6 +2944,8 @@ data class GatewayNodeSummary(
|
||||
val deviceFamily: String?,
|
||||
val paired: Boolean,
|
||||
val connected: Boolean,
|
||||
val approvalState: GatewayNodeApprovalState,
|
||||
val pendingRequestId: String?,
|
||||
val capabilities: List<String>,
|
||||
val commands: List<String>,
|
||||
)
|
||||
@@ -2962,6 +3068,11 @@ private fun JsonObject?.cronStatus(key: String): String? =
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseGatewayStringArray(items: JsonArray?): List<String> =
|
||||
items
|
||||
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } }
|
||||
.orEmpty()
|
||||
|
||||
fun providerDisplayName(provider: String): String =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> "OpenAI"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDeviceTokenSummary
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPairedDeviceSummary
|
||||
@@ -155,8 +156,8 @@ private fun NodeRow(node: GatewayNodeSummary) {
|
||||
badge = nodeBadge(node.displayName ?: node.id),
|
||||
title = node.displayName ?: node.id,
|
||||
subtitle = nodeSubtitle(node),
|
||||
statusText = if (node.connected) "Online" else "Offline",
|
||||
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
|
||||
statusText = nodeStatusText(node),
|
||||
status = nodeStatus(node),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,14 +206,46 @@ private fun nodeSubtitle(node: GatewayNodeSummary): String {
|
||||
val kind = node.deviceFamily ?: "Node host"
|
||||
val version = node.version?.let { "OpenClaw $it" }
|
||||
val status = if (node.paired) "Paired" else "Unpaired"
|
||||
val approval = nodeApprovalSubtitle(node.approvalState)
|
||||
val commands =
|
||||
node.commands
|
||||
.take(2)
|
||||
.joinToString(", ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
|
||||
return listOfNotNull(kind, version, status, approval, commands).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun nodeStatusText(node: GatewayNodeSummary): String =
|
||||
when (node.approvalState) {
|
||||
GatewayNodeApprovalState.PendingApproval -> "Needs approval"
|
||||
GatewayNodeApprovalState.PendingReapproval -> "Needs reapproval"
|
||||
GatewayNodeApprovalState.Unapproved -> "Unapproved"
|
||||
else -> if (node.connected) "Online" else "Offline"
|
||||
}
|
||||
|
||||
private fun nodeStatus(node: GatewayNodeSummary): ClawStatus =
|
||||
when (node.approvalState) {
|
||||
GatewayNodeApprovalState.Approved -> if (node.connected) ClawStatus.Success else ClawStatus.Warning
|
||||
GatewayNodeApprovalState.PendingApproval,
|
||||
GatewayNodeApprovalState.PendingReapproval,
|
||||
GatewayNodeApprovalState.Unapproved,
|
||||
-> ClawStatus.Warning
|
||||
GatewayNodeApprovalState.Loading,
|
||||
GatewayNodeApprovalState.Unsupported,
|
||||
-> if (node.connected) ClawStatus.Neutral else ClawStatus.Warning
|
||||
}
|
||||
|
||||
private fun nodeApprovalSubtitle(approvalState: GatewayNodeApprovalState): String? =
|
||||
when (approvalState) {
|
||||
GatewayNodeApprovalState.Approved -> "Approved"
|
||||
GatewayNodeApprovalState.PendingApproval -> "Capability approval pending"
|
||||
GatewayNodeApprovalState.PendingReapproval -> "Capability reapproval pending"
|
||||
GatewayNodeApprovalState.Unapproved -> "Capability unapproved"
|
||||
GatewayNodeApprovalState.Loading,
|
||||
GatewayNodeApprovalState.Unsupported,
|
||||
-> null
|
||||
}
|
||||
|
||||
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
@@ -139,6 +140,7 @@ fun OnboardingFlow(
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val nodeCapabilityApprovalState by viewModel.nodeCapabilityApprovalState.collectAsState()
|
||||
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@@ -147,7 +149,12 @@ fun OnboardingFlow(
|
||||
val savedToken by viewModel.gatewayToken.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
|
||||
val ready = canFinishOnboarding(isConnected = isConnected, isNodeConnected = isNodeConnected)
|
||||
val ready =
|
||||
canFinishOnboarding(
|
||||
isConnected = isConnected,
|
||||
isNodeConnected = isNodeConnected,
|
||||
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
|
||||
)
|
||||
|
||||
var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) }
|
||||
var setupCode by rememberSaveable { mutableStateOf("") }
|
||||
@@ -327,6 +334,7 @@ fun OnboardingFlow(
|
||||
attemptedGatewayName = attemptedGatewayName,
|
||||
remoteAddress = remoteAddress,
|
||||
ready = ready,
|
||||
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
|
||||
gatewayConnectionProblem = gatewayConnectionProblem,
|
||||
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
|
||||
onBack = { step = OnboardingStep.Gateway },
|
||||
@@ -609,6 +617,7 @@ private fun GatewayRecoveryScreen(
|
||||
attemptedGatewayName: String?,
|
||||
remoteAddress: String?,
|
||||
ready: Boolean,
|
||||
nodeCapabilityApprovalState: GatewayNodeApprovalState,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
connectSettling: Boolean,
|
||||
onBack: () -> Unit,
|
||||
@@ -617,7 +626,14 @@ private fun GatewayRecoveryScreen(
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem)
|
||||
val recoveryState =
|
||||
gatewayRecoveryUiState(
|
||||
ready = ready,
|
||||
statusText = statusText,
|
||||
connectSettling = connectSettling,
|
||||
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
|
||||
gatewayConnectionProblem = gatewayConnectionProblem,
|
||||
)
|
||||
val context = LocalContext.current
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
@@ -629,6 +645,7 @@ private fun GatewayRecoveryScreen(
|
||||
imageVector =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
|
||||
@@ -639,6 +656,7 @@ private fun GatewayRecoveryScreen(
|
||||
tint =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
|
||||
@@ -658,7 +676,18 @@ private fun GatewayRecoveryScreen(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(
|
||||
text =
|
||||
recoveryGatewayDetail(
|
||||
ready = ready,
|
||||
remoteAddress = remoteAddress,
|
||||
statusText = statusText,
|
||||
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
|
||||
gatewayConnectionProblem = gatewayConnectionProblem,
|
||||
),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
|
||||
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
|
||||
}
|
||||
@@ -666,6 +695,7 @@ private fun GatewayRecoveryScreen(
|
||||
text =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> "Healthy"
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> "Node approval"
|
||||
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
|
||||
GatewayRecoveryUiState.Pairing -> "Pairing"
|
||||
GatewayRecoveryUiState.Finishing -> "Connecting"
|
||||
@@ -674,6 +704,7 @@ private fun GatewayRecoveryScreen(
|
||||
status =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> ClawStatus.Success
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawStatus.Warning
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
|
||||
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
|
||||
@@ -1022,6 +1053,10 @@ internal enum class GatewayRecoveryUiState(
|
||||
title = "Pairing Gateway",
|
||||
message = "Approve this phone on the gateway.\nThen retry the connection.",
|
||||
),
|
||||
NodeCapabilityApprovalPending(
|
||||
title = "Node Approval Pending",
|
||||
message = "Gateway pairing worked.\nApprove this phone's node capabilities from an operator UI.",
|
||||
),
|
||||
Pairing(
|
||||
title = "Pairing Gateway",
|
||||
message = "Approval is in progress.\nOpenClaw will reconnect automatically.",
|
||||
@@ -1079,14 +1114,19 @@ internal fun gatewayRecoveryUiState(
|
||||
ready: Boolean,
|
||||
statusText: String,
|
||||
connectSettling: Boolean,
|
||||
nodeCapabilityApprovalState: GatewayNodeApprovalState = GatewayNodeApprovalState.Loading,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem? = null,
|
||||
): GatewayRecoveryUiState =
|
||||
when {
|
||||
ready -> GatewayRecoveryUiState.Connected
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved -> GatewayRecoveryUiState.NodeCapabilityApprovalPending
|
||||
gatewayConnectionProblem?.isPairingRequired == true &&
|
||||
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
|
||||
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
|
||||
gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading -> GatewayRecoveryUiState.Finishing
|
||||
connectSettling -> GatewayRecoveryUiState.Finishing
|
||||
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
|
||||
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
|
||||
@@ -1170,12 +1210,21 @@ private fun recoveryGatewayDetail(
|
||||
ready: Boolean,
|
||||
remoteAddress: String?,
|
||||
statusText: String,
|
||||
nodeCapabilityApprovalState: GatewayNodeApprovalState,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
): String =
|
||||
remoteAddress
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: if (ready) {
|
||||
"Ready for chat and voice"
|
||||
} else if (
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
|
||||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved
|
||||
) {
|
||||
"Gateway paired. Waiting for node capability approval."
|
||||
} else if (nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading) {
|
||||
"Gateway paired. Checking node capability approval."
|
||||
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
|
||||
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
|
||||
?.let { "Gateway approval is pending. Run this on the gateway host:" }
|
||||
@@ -1248,11 +1297,24 @@ private class PermissionState(
|
||||
val applyToViewModel: () -> Unit,
|
||||
)
|
||||
|
||||
/** Onboarding can finish only after gateway and node channels are both ready. */
|
||||
/** Onboarding finishes only after the gateway resolves node capability approval. */
|
||||
internal fun canFinishOnboarding(
|
||||
isConnected: Boolean,
|
||||
isNodeConnected: Boolean,
|
||||
): Boolean = isConnected && isNodeConnected
|
||||
nodeCapabilityApprovalState: GatewayNodeApprovalState,
|
||||
): Boolean =
|
||||
isConnected &&
|
||||
isNodeConnected &&
|
||||
when (nodeCapabilityApprovalState) {
|
||||
GatewayNodeApprovalState.PendingApproval,
|
||||
GatewayNodeApprovalState.PendingReapproval,
|
||||
GatewayNodeApprovalState.Unapproved,
|
||||
GatewayNodeApprovalState.Loading,
|
||||
-> false
|
||||
GatewayNodeApprovalState.Approved,
|
||||
GatewayNodeApprovalState.Unsupported,
|
||||
-> true
|
||||
}
|
||||
|
||||
/** Builds permission rows and applies granted feature toggles after onboarding. */
|
||||
@Composable
|
||||
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app.ui
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewaySkillSummary
|
||||
import ai.openclaw.app.HomeDestination
|
||||
@@ -566,7 +567,7 @@ internal fun homeAttentionRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
|
||||
if (nodesDevicesSummary.pendingDevices.isNotEmpty() || nodesDevicesSummary.hasNodeCapabilityApprovalPending()) {
|
||||
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
|
||||
} else {
|
||||
null
|
||||
@@ -997,6 +998,7 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
|
||||
val devices = summary.pairedDevices.size
|
||||
return when {
|
||||
summary.pendingDevices.isNotEmpty() -> "${summary.pendingDevices.size} pending"
|
||||
summary.hasNodeCapabilityApprovalPending() -> "Node approval pending"
|
||||
summary.nodes.isNotEmpty() -> "$online/${summary.nodes.size} online"
|
||||
devices > 0 -> "$devices paired"
|
||||
else -> "No devices"
|
||||
@@ -1007,11 +1009,19 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
|
||||
private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
|
||||
when {
|
||||
summary.pendingDevices.isNotEmpty() -> false
|
||||
summary.hasNodeCapabilityApprovalPending() -> false
|
||||
summary.nodes.any { it.connected } -> true
|
||||
summary.pairedDevices.isNotEmpty() -> true
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun GatewayNodesDevicesSummary.hasNodeCapabilityApprovalPending(): Boolean =
|
||||
nodes.any { node ->
|
||||
node.approvalState == GatewayNodeApprovalState.PendingApproval ||
|
||||
node.approvalState == GatewayNodeApprovalState.PendingReapproval ||
|
||||
node.approvalState == GatewayNodeApprovalState.Unapproved
|
||||
}
|
||||
|
||||
/** Summarizes channel connection state, surfacing errors before connected counts. */
|
||||
private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
|
||||
val connected = summary.channels.count { it.connected }
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayNodeApprovalStateTest {
|
||||
@Test
|
||||
fun parsesGatewayNodeApprovalState() {
|
||||
assertEquals(GatewayNodeApprovalState.Approved, parseGatewayNodeApprovalState("approved"))
|
||||
assertEquals(GatewayNodeApprovalState.PendingApproval, parseGatewayNodeApprovalState("pending-approval"))
|
||||
assertEquals(GatewayNodeApprovalState.PendingReapproval, parseGatewayNodeApprovalState("pending-reapproval"))
|
||||
assertEquals(GatewayNodeApprovalState.Unapproved, parseGatewayNodeApprovalState("unapproved"))
|
||||
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState(null))
|
||||
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState("future-state"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesNodeListApprovalFields() {
|
||||
val node =
|
||||
parseGatewayNodeSummary(
|
||||
Json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"nodeId": "android-node",
|
||||
"paired": true,
|
||||
"connected": true,
|
||||
"approvalState": "pending-approval",
|
||||
"pendingRequestId": "request-1",
|
||||
"caps": ["device"],
|
||||
"commands": ["device.status"]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
requireNotNull(node)
|
||||
assertEquals(GatewayNodeApprovalState.PendingApproval, node.approvalState)
|
||||
assertEquals("request-1", node.pendingRequestId)
|
||||
assertEquals(listOf("device"), node.capabilities)
|
||||
assertEquals(listOf("device.status"), node.commands)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun treatsMissingNodeApprovalStateAsUnsupported() {
|
||||
val node =
|
||||
parseGatewayNodeSummary(
|
||||
Json.parseToJsonElement("""{"nodeId":"android-node","paired":true,"connected":true}"""),
|
||||
)
|
||||
|
||||
requireNotNull(node)
|
||||
assertEquals(GatewayNodeApprovalState.Unsupported, node.approvalState)
|
||||
assertEquals(
|
||||
GatewayNodeApprovalState.Unsupported,
|
||||
currentNodeCapabilityApprovalState(nodes = listOf(node), selfNodeId = "android-node"),
|
||||
)
|
||||
assertNull(node.pendingRequestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesCurrentPhoneNodeApprovalState() {
|
||||
val nodes =
|
||||
listOf(
|
||||
GatewayNodeSummary(
|
||||
id = "other",
|
||||
displayName = null,
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = null,
|
||||
paired = true,
|
||||
connected = false,
|
||||
approvalState = GatewayNodeApprovalState.Approved,
|
||||
pendingRequestId = null,
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
),
|
||||
GatewayNodeSummary(
|
||||
id = "self",
|
||||
displayName = null,
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = null,
|
||||
paired = true,
|
||||
connected = true,
|
||||
approvalState = GatewayNodeApprovalState.PendingApproval,
|
||||
pendingRequestId = null,
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
GatewayNodeApprovalState.PendingApproval,
|
||||
currentNodeCapabilityApprovalState(nodes = nodes, selfNodeId = "self"),
|
||||
)
|
||||
assertEquals(
|
||||
GatewayNodeApprovalState.Loading,
|
||||
currentNodeCapabilityApprovalState(nodes = nodes, selfNodeId = "missing"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresStaleNodeApprovalRefreshResults() {
|
||||
val guard = GatewayNodeApprovalRefreshGuard()
|
||||
var approvalState = GatewayNodeApprovalState.Loading
|
||||
val staleRefresh = guard.begin()
|
||||
val currentRefresh = guard.begin()
|
||||
|
||||
assertFalse(guard.publishIfCurrent(staleRefresh) { approvalState = GatewayNodeApprovalState.Approved })
|
||||
assertTrue(
|
||||
guard.publishIfCurrent(currentRefresh) { approvalState = GatewayNodeApprovalState.PendingReapproval },
|
||||
)
|
||||
assertEquals(GatewayNodeApprovalState.PendingReapproval, approvalState)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -9,22 +13,48 @@ import org.junit.Test
|
||||
class OnboardingFlowLogicTest {
|
||||
@Test
|
||||
fun blocksFinishWhenOnlyOperatorIsConnected() {
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false))
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blocksFinishWhenDisconnected() {
|
||||
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false))
|
||||
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blocksFinishWhenOnlyNodeIsConnected() {
|
||||
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true))
|
||||
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowsFinishOnlyWhenOperatorAndNodeAreConnected() {
|
||||
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
|
||||
fun blocksFinishWhenNodeCapabilityApprovalIsPending() {
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingApproval))
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingReapproval))
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unapproved))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowsFinishWhenOperatorNodeAndCapabilityApprovalAreReady() {
|
||||
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blocksFinishWhileDelayedNodeListResolvesPendingApproval() =
|
||||
runTest {
|
||||
val delayedNodeList = CompletableDeferred<GatewayNodeApprovalState>()
|
||||
var approvalState = GatewayNodeApprovalState.Loading
|
||||
val refresh = launch { approvalState = delayedNodeList.await() }
|
||||
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = approvalState))
|
||||
|
||||
delayedNodeList.complete(GatewayNodeApprovalState.PendingApproval)
|
||||
refresh.join()
|
||||
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = approvalState))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowsFinishWhenSuccessfulLegacyNodeListOmitsApprovalState() {
|
||||
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unsupported))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,6 +128,32 @@ class OnboardingFlowLogicTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsNodeApprovalStateWhenCapabilityApprovalIsPending() {
|
||||
assertEquals(
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending,
|
||||
gatewayRecoveryUiState(
|
||||
ready = false,
|
||||
statusText = "Connected",
|
||||
connectSettling = false,
|
||||
nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingApproval,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsFinishingStateWhileNodeApprovalLoads() {
|
||||
assertEquals(
|
||||
GatewayRecoveryUiState.Finishing,
|
||||
gatewayRecoveryUiState(
|
||||
ready = false,
|
||||
statusText = "Connected",
|
||||
connectSettling = false,
|
||||
nodeCapabilityApprovalState = GatewayNodeApprovalState.Loading,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsApprovalRequiredForPausedPairingProblem() {
|
||||
assertEquals(
|
||||
|
||||
@@ -3,6 +3,8 @@ package ai.openclaw.app.ui
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -118,6 +120,41 @@ class ShellScreenLogicTest {
|
||||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes =
|
||||
listOf(
|
||||
GatewayNodeSummary(
|
||||
id = "android-node",
|
||||
displayName = "Android",
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = "Android",
|
||||
paired = true,
|
||||
connected = true,
|
||||
approvalState = GatewayNodeApprovalState.PendingApproval,
|
||||
pendingRequestId = null,
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
),
|
||||
),
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
readyProviderCount = 1,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Nodes & Devices"), rows.map { it.title })
|
||||
assertEquals("Node approval pending", rows.single().subtitle)
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
|
||||
@@ -9,6 +9,12 @@ default_platform(:android)
|
||||
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
|
||||
DEFAULT_PLAY_TRACK = "internal"
|
||||
DEFAULT_PLAY_RELEASE_STATUS = "completed"
|
||||
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD",
|
||||
"OPENCLAW_ANDROID_KEY_ALIAS",
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD"
|
||||
].freeze
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -36,6 +42,14 @@ def repo_root
|
||||
File.expand_path("../..", android_root)
|
||||
end
|
||||
|
||||
def android_release_signing_script
|
||||
File.join(repo_root, "scripts", "android-release-signing.mjs")
|
||||
end
|
||||
|
||||
def android_release_signing_materialized_properties_path
|
||||
File.join(android_root, "build", "release-signing", "gradle.properties")
|
||||
end
|
||||
|
||||
def shell_join(args)
|
||||
args.shelljoin
|
||||
end
|
||||
@@ -136,17 +150,22 @@ def android_release_notes_path
|
||||
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
|
||||
end
|
||||
|
||||
def validate_android_release_notes!
|
||||
release_notes_path = android_release_notes_path
|
||||
UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path)
|
||||
UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path))
|
||||
end
|
||||
|
||||
def android_changelog_path(version_code)
|
||||
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
|
||||
end
|
||||
|
||||
def sync_android_changelog!(version_code)
|
||||
release_notes_path = android_release_notes_path
|
||||
UI.user_error!("Missing Android release notes at #{release_notes_path}.") unless File.exist?(release_notes_path)
|
||||
validate_android_release_notes!
|
||||
|
||||
changelog_path = android_changelog_path(version_code)
|
||||
FileUtils.mkdir_p(File.dirname(changelog_path))
|
||||
File.write(changelog_path, File.read(release_notes_path))
|
||||
File.write(changelog_path, File.read(android_release_notes_path))
|
||||
changelog_path
|
||||
end
|
||||
|
||||
@@ -178,6 +197,69 @@ def capture_android_screenshots!
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
|
||||
end
|
||||
|
||||
def read_android_release_signing_properties!(path)
|
||||
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
|
||||
|
||||
properties = {}
|
||||
File.foreach(path) do |line|
|
||||
stripped = line.strip
|
||||
next if stripped.empty? || stripped.start_with?("#")
|
||||
|
||||
key, value = stripped.split("=", 2)
|
||||
next if key.nil? || key.empty? || value.nil?
|
||||
|
||||
properties[key] = value.strip
|
||||
end
|
||||
|
||||
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
|
||||
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
|
||||
|
||||
properties
|
||||
end
|
||||
|
||||
def export_android_release_signing_properties!(path)
|
||||
read_android_release_signing_properties!(path).each do |key, value|
|
||||
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
|
||||
end
|
||||
end
|
||||
|
||||
def sync_android_release_signing!
|
||||
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
|
||||
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
|
||||
end
|
||||
|
||||
def prepare_android_release_signing!
|
||||
if env_present?(ENV["MATCH_PASSWORD"])
|
||||
sync_android_release_signing!
|
||||
elsif File.exist?(android_release_signing_materialized_properties_path)
|
||||
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_android_release_signing!
|
||||
Dir.chdir(android_root) do
|
||||
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
|
||||
end
|
||||
end
|
||||
|
||||
def print_android_release_plan!(version_metadata)
|
||||
UI.message("Android Play release plan:")
|
||||
UI.message(" package: #{play_package_name}")
|
||||
UI.message(" track: #{play_track}")
|
||||
UI.message(" release_status: #{play_release_status}")
|
||||
UI.message(" validate_only: #{play_validate_only?}")
|
||||
UI.message(" versionName: #{version_metadata.fetch(:version)}")
|
||||
UI.message(" versionCode: #{version_metadata.fetch(:version_code)}")
|
||||
end
|
||||
|
||||
def validate_android_release_preflight!(version_metadata)
|
||||
validate_play_auth!
|
||||
prepare_android_release_signing!
|
||||
validate_android_release_signing!
|
||||
validate_android_release_notes!
|
||||
print_android_release_plan!(version_metadata)
|
||||
end
|
||||
|
||||
def upload_play_store_metadata!(version_metadata)
|
||||
validate_android_screenshots!
|
||||
sync_android_changelog!(version_metadata.fetch(:version_code))
|
||||
@@ -230,6 +312,38 @@ platform :android do
|
||||
UI.success("Google Play API credentials are valid.")
|
||||
end
|
||||
|
||||
desc "Print the Android release signing plan"
|
||||
lane :signing_plan do
|
||||
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
|
||||
end
|
||||
|
||||
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
|
||||
lane :signing_check do
|
||||
sync_android_release_signing!
|
||||
validate_android_release_signing!
|
||||
UI.success("Android release signing assets are available locally.")
|
||||
end
|
||||
|
||||
desc "Pull encrypted Android release signing assets from the shared signing repo"
|
||||
lane :signing_sync_pull do
|
||||
sync_android_release_signing!
|
||||
UI.success("Pulled Android release signing assets.")
|
||||
end
|
||||
|
||||
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
|
||||
lane :signing_sync_push do
|
||||
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
|
||||
UI.success("Pushed Android release signing assets.")
|
||||
end
|
||||
|
||||
desc "Validate Android Play release auth, signing, versioning, and release notes"
|
||||
lane :release_preflight do
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
validate_android_release_preflight!(version_metadata)
|
||||
UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
|
||||
end
|
||||
|
||||
desc "Upload Google Play metadata, changelog, and optional screenshots"
|
||||
lane :metadata do
|
||||
sync_android_versioning!
|
||||
@@ -242,6 +356,7 @@ platform :android do
|
||||
desc "Build signed Android release artifacts locally without uploading"
|
||||
lane :play_store_archive do
|
||||
sync_android_versioning!
|
||||
prepare_android_release_signing!
|
||||
build_release_artifacts!
|
||||
end
|
||||
|
||||
@@ -260,9 +375,9 @@ platform :android do
|
||||
|
||||
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
|
||||
lane :release_upload do
|
||||
auth_check
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
validate_android_release_preflight!(version_metadata)
|
||||
screenshots
|
||||
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
|
||||
|
||||
@@ -20,6 +20,35 @@ Optional app targeting:
|
||||
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
|
||||
```
|
||||
|
||||
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
|
||||
|
||||
```bash
|
||||
pnpm android:release:signing:plan
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
|
||||
```
|
||||
|
||||
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
|
||||
|
||||
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
|
||||
|
||||
```bash
|
||||
MATCH_PASSWORD=<signing repo password> \
|
||||
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
|
||||
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
|
||||
pnpm android:release:signing:sync:push
|
||||
```
|
||||
|
||||
The source signing properties file must contain:
|
||||
|
||||
```properties
|
||||
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
|
||||
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
|
||||
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
|
||||
```
|
||||
|
||||
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
|
||||
|
||||
Validate auth:
|
||||
|
||||
```bash
|
||||
@@ -56,12 +85,19 @@ Release rules:
|
||||
|
||||
- `apps/android/version.json` is the pinned Android release version source.
|
||||
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
|
||||
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
|
||||
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
|
||||
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
|
||||
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
|
||||
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
|
||||
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
|
||||
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
|
||||
- `pnpm android:version:sync` updates generated version artifacts.
|
||||
- `pnpm android:version:check` validates checked-in Android version artifacts.
|
||||
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
|
||||
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
|
||||
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
|
||||
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
|
||||
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
|
||||
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
|
||||
|
||||
@@ -368,7 +368,7 @@ enum ExecApprovalsStore {
|
||||
tempURL.path,
|
||||
targetURL.path,
|
||||
nil,
|
||||
copyfile_flags_t(COPYFILE_EXCL))
|
||||
copyfile_flags_t(COPYFILE_DATA | COPYFILE_EXCL))
|
||||
if copied == -1 {
|
||||
if errno == EEXIST {
|
||||
try? FileManager().removeItem(at: tempURL)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
99a18e1e8e3af265e233504b6cf1ff8a227a6466dd0d515c56f823503f0b7bc7 plugin-sdk-api-baseline.json
|
||||
930a414cf783baa2bedb21a85af6fcaa02a12073d9e06cc49c827e7379f85646 plugin-sdk-api-baseline.jsonl
|
||||
b810f3b17d1eb746a6fbc4c45095a3b2bb3e08c5cd62a5928f9add2c59bb95b9 plugin-sdk-api-baseline.json
|
||||
36174a54f2a9e11b822f499b5659d0b1351198ce98112946d95283b0ee1032dd plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1175,8 +1175,24 @@
|
||||
"source": "Control UI",
|
||||
"target": "Control UI"
|
||||
},
|
||||
{
|
||||
"source": "Models CLI",
|
||||
"target": "模型 CLI"
|
||||
},
|
||||
{
|
||||
"source": "Z.AI (GLM)",
|
||||
"target": "Z.AI (GLM)"
|
||||
},
|
||||
{
|
||||
"source": "Cohere",
|
||||
"target": "Cohere"
|
||||
},
|
||||
{
|
||||
"source": "Cohere plugin",
|
||||
"target": "Cohere 插件"
|
||||
},
|
||||
{
|
||||
"source": "cohere",
|
||||
"target": "cohere"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -422,7 +422,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich message formatting">
|
||||
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
|
||||
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients. This compatibility mode supports normal bold, italic, links, code, spoilers, and quotes, but not Bot API 10.1 rich-only blocks such as native tables, details, rich media, and formulas.
|
||||
|
||||
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
|
||||
|
||||
@@ -436,13 +436,16 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
}
|
||||
```
|
||||
|
||||
When enabled:
|
||||
|
||||
- The agent is told that Telegram rich messages are available for this bot/account.
|
||||
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
|
||||
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
|
||||
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
|
||||
|
||||
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
|
||||
|
||||
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
|
||||
Default: off for client compatibility. Rich messages require compatible Telegram clients; some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported. Keep this option disabled unless every client used with the bot can render them. `/status` shows whether the current Telegram session has rich messages on or off.
|
||||
|
||||
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
|
||||
|
||||
|
||||
@@ -11,13 +11,17 @@ sidebarTitle: "MCP"
|
||||
`openclaw mcp` has two jobs:
|
||||
|
||||
- run OpenClaw as an MCP server with `openclaw mcp serve`
|
||||
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
|
||||
- manage OpenClaw-managed outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
|
||||
|
||||
In other words:
|
||||
|
||||
- `serve` is OpenClaw acting as an MCP server
|
||||
- the other subcommands are OpenClaw acting as an MCP client-side registry for MCP servers its runtimes may consume later
|
||||
|
||||
<Note>
|
||||
`list`, `show`, `set`, and `unset` only read and write OpenClaw-managed `mcp.servers` entries in OpenClaw config. They do not include mcporter servers from `config/mcporter.json`; use `mcporter list` for that registry.
|
||||
</Note>
|
||||
|
||||
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
|
||||
|
||||
## Choose the right MCP path
|
||||
@@ -368,7 +372,7 @@ For broader testing context, see [Testing](/help/testing).
|
||||
This is the `openclaw mcp list`, `show`, `status`, `doctor`, `probe`, `add`, `set`,
|
||||
`configure`, `tools`, `login`, `logout`, `reload`, and `unset` path.
|
||||
|
||||
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
|
||||
These commands do not expose OpenClaw over MCP. They manage OpenClaw-managed MCP server definitions under `mcp.servers` in OpenClaw config. They do not read mcporter servers from `config/mcporter.json`.
|
||||
|
||||
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded OpenClaw and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ Notes:
|
||||
in the shared managed skills directory when combined with `--global`.
|
||||
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
|
||||
default. There is no `--json` flag because JSON is already the default.
|
||||
- When ClawHub returns server-resolved source provenance, verify JSON also
|
||||
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
|
||||
self-declared source URLs stay only in the raw provenance envelope and are not
|
||||
promoted.
|
||||
- `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it
|
||||
verifies the installed version against the registry it came from. `--version`
|
||||
and `--tag` override the version selector but keep that installed registry
|
||||
|
||||
@@ -224,6 +224,29 @@ Optional members:
|
||||
| `onSubagentEnded(params)` | Method | Clean up after a subagent ends. |
|
||||
| `dispose()` | Method | Release resources. Called during gateway shutdown or plugin reload - not per-session. |
|
||||
|
||||
### Runtime settings
|
||||
|
||||
Lifecycle hooks that run inside OpenClaw receive an optional
|
||||
`runtimeSettings` object. It is a versioned, read-only internal
|
||||
producer/consumer API surface: OpenClaw produces it for the selected context
|
||||
engine, and the context engine consumes it inside lifecycle hooks. It is not
|
||||
rendered directly to users and does not create a dedicated reporting surface.
|
||||
|
||||
- `schemaVersion`: currently `1`
|
||||
- `runtime`: OpenClaw host, runtime mode (`normal`, `fallback`, or
|
||||
`degraded`), and optional harness/runtime ids
|
||||
- `contextEngineSelection`: selected context engine id and selection source
|
||||
- `executionHost`: host id and label for the surface invoking the hook
|
||||
- `model`: requested model, resolved model, provider, and optional model family
|
||||
- `limits`: prompt token budget and max output tokens when known
|
||||
- `diagnostics`: closed fallback and degraded reason codes when known
|
||||
|
||||
Fields that can be unknown are represented as `null`; discriminator fields such
|
||||
as runtime mode and selection source remain non-nullable. Older engines remain
|
||||
compatible: if a strict legacy engine rejects `runtimeSettings` as an unknown
|
||||
property, OpenClaw retries the lifecycle call without it instead of quarantining
|
||||
the engine.
|
||||
|
||||
### Host requirements
|
||||
|
||||
Context engines can declare host capability requirements on `info.hostRequirements`.
|
||||
|
||||
@@ -258,7 +258,9 @@ Gemini CLI OAuth is shipped as part of the bundled `google` plugin.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`, with `stats.cached` normalized into OpenClaw `cacheRead`.
|
||||
Gemini CLI uses `stream-json` by default. OpenClaw reads assistant stream
|
||||
messages and normalizes `stats.cached` into `cacheRead`; legacy
|
||||
`--output-format json` overrides still read reply text from `response`.
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
@@ -294,6 +296,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
| --------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
|
||||
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` | `cerebras/zai-glm-4.7` |
|
||||
| Cohere | `cohere` | `COHERE_API_KEY` | `cohere/command-a-03-2025` |
|
||||
| Cloudflare AI Gateway | `cloudflare-ai-gateway` | `CLOUDFLARE_AI_GATEWAY_API_KEY` | - |
|
||||
| DeepInfra | `deepinfra` | `DEEPINFRA_API_KEY` | `deepinfra/deepseek-ai/DeepSeek-V4-Flash` |
|
||||
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek/deepseek-v4-flash` |
|
||||
|
||||
@@ -1386,7 +1386,11 @@
|
||||
"clawhub/api",
|
||||
"clawhub/http-api",
|
||||
"clawhub/acceptable-usage",
|
||||
"clawhub/content-rights"
|
||||
"clawhub/moderation",
|
||||
"clawhub/security",
|
||||
"clawhub/security-audits",
|
||||
"clawhub/content-rights",
|
||||
"clawhub/plugin-validation-fixes"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1413,6 +1417,7 @@
|
||||
"providers/azure-speech",
|
||||
"providers/cerebras",
|
||||
"providers/chutes",
|
||||
"providers/cohere",
|
||||
"providers/claude-max-api-proxy",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
"providers/comfy",
|
||||
|
||||
@@ -287,8 +287,10 @@ load local files from plain paths.
|
||||
## Inputs / outputs
|
||||
|
||||
- `output: "json"` (default) tries to parse JSON and extract text + session id.
|
||||
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and
|
||||
usage from `stats` when `usage` is missing or empty.
|
||||
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage
|
||||
from `stats` when `usage` is missing or empty. The bundled Gemini CLI default
|
||||
uses `stream-json`, but old `--output-format json` overrides still use the
|
||||
JSON parser.
|
||||
- `output: "jsonl"` parses JSONL streams and extracts the final agent message plus session
|
||||
identifiers when present.
|
||||
- `output: "text"` treats stdout as the final response.
|
||||
@@ -318,8 +320,11 @@ The bundled Anthropic plugin registers a default for `claude-cli`:
|
||||
The bundled Google plugin also registers a default for `google-gemini-cli`:
|
||||
|
||||
- `command: "gemini"`
|
||||
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
|
||||
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
|
||||
- `args: ["--skip-trust", "--approval-mode", "auto_edit", "--output-format", "stream-json", "--prompt", "{prompt}"]`
|
||||
- `resumeArgs: ["--skip-trust", "--approval-mode", "auto_edit", "--resume", "{sessionId}", "--output-format", "stream-json", "--prompt", "{prompt}"]`
|
||||
- `output: "jsonl"`
|
||||
- `resumeOutput: "jsonl"`
|
||||
- `jsonlDialect: "gemini-stream-json"`
|
||||
- `imageArg: "@"`
|
||||
- `imagePathScope: "workspace"`
|
||||
- `modelArg: "--model"`
|
||||
@@ -330,9 +335,13 @@ Prerequisite: the local Gemini CLI must be installed and available as
|
||||
`gemini` on `PATH` (`brew install gemini-cli` or
|
||||
`npm install -g @google/gemini-cli`).
|
||||
|
||||
Gemini CLI JSON notes:
|
||||
Gemini CLI output notes:
|
||||
|
||||
- Reply text is read from the JSON `response` field.
|
||||
- The default `stream-json` parser reads assistant `message` events, tool events,
|
||||
final `result` usage, and fatal Gemini error events.
|
||||
- If you override Gemini args to `--output-format json`, OpenClaw normalizes that
|
||||
backend back to `output: "json"` and reads reply text from the JSON `response`
|
||||
field.
|
||||
- Usage falls back to `stats` when `usage` is absent or empty.
|
||||
- `stats.cached` is normalized into OpenClaw `cacheRead`.
|
||||
- If `stats.input` is missing, OpenClaw derives input tokens from
|
||||
@@ -372,8 +381,10 @@ api.registerTextTransforms({
|
||||
rewrites streamed assistant deltas and parsed final text before OpenClaw handles
|
||||
its own control markers and channel delivery.
|
||||
|
||||
For CLIs that emit Claude Code stream-json compatible JSONL, set
|
||||
`jsonlDialect: "claude-stream-json"` on that backend's config.
|
||||
For CLIs that emit provider-specific JSONL events, set `jsonlDialect` on that
|
||||
backend's config. Supported dialects are `claude-stream-json` for Claude
|
||||
Code-compatible streams and `gemini-stream-json` for Gemini CLI `stream-json`
|
||||
events.
|
||||
|
||||
## Native compaction ownership
|
||||
|
||||
|
||||
@@ -103,8 +103,46 @@ Supported `appServer` fields:
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
|
||||
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
|
||||
`default_permissions` in the Codex thread config so the generated permission
|
||||
profile can start Codex managed networking. By default, OpenClaw generates a
|
||||
collision-resistant `openclaw-network-<fingerprint>` profile name from the
|
||||
profile body; use `profileName` only when a stable local name is required.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
|
||||
The plugin blocks older or unversioned app-server handshakes. Codex app-server
|
||||
must report stable version `0.125.0` or newer.
|
||||
|
||||
|
||||
@@ -561,8 +561,52 @@ Supported `appServer` fields:
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
|
||||
`default_permissions` in the Codex thread config so the generated permission
|
||||
profile can start Codex managed networking. By default, OpenClaw generates a
|
||||
collision-resistant `openclaw-network-<fingerprint>` profile name from the
|
||||
profile body; use `profileName` only when a stable local name is required.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unixSockets: {
|
||||
"/tmp/proxy.sock": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
Domain entries use `allow` or `deny`; Unix socket entries use Codex's
|
||||
`allow` or `none` values.
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
|
||||
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends
|
||||
|
||||
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Core npm package
|
||||
|
||||
90 plugins
|
||||
91 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
@@ -81,6 +81,8 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw.
|
||||
|
||||
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw. Adds Cohere model provider support to OpenClaw.
|
||||
|
||||
- **[comfy](/plugins/reference/comfy)** (`@openclaw/comfy-provider`) - included in OpenClaw. Adds ComfyUI model provider support to OpenClaw.
|
||||
|
||||
- **[copilot-proxy](/plugins/reference/copilot-proxy)** (`@openclaw/copilot-proxy`) - included in OpenClaw. Adds Copilot Proxy model provider support to OpenClaw.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
23
docs/plugins/reference/cohere.md
Normal file
23
docs/plugins/reference/cohere.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "Adds Cohere model provider support to OpenClaw."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the cohere plugin
|
||||
title: "Cohere plugin"
|
||||
---
|
||||
|
||||
# Cohere plugin
|
||||
|
||||
Adds Cohere model provider support to OpenClaw.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/cohere-provider`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
providers: cohere
|
||||
|
||||
## Related docs
|
||||
|
||||
- [cohere](/providers/cohere)
|
||||
@@ -388,13 +388,13 @@ For an end-to-end authoring guide, see
|
||||
|
||||
### Exclusive slots
|
||||
|
||||
| Method | What it registers |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time). The `assemble()` callback receives `availableTools` and `citationsMode` so the engine can tailor prompt additions. |
|
||||
| `api.registerMemoryCapability(capability)` | Unified memory capability |
|
||||
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
|
||||
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
|
||||
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
|
||||
| Method | What it registers |
|
||||
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time). Lifecycle callbacks receive `runtimeSettings` when the host can provide model/provider/mode diagnostics; older strict engines are retried without that key. |
|
||||
| `api.registerMemoryCapability(capability)` | Unified memory capability |
|
||||
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
|
||||
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
|
||||
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
|
||||
|
||||
### Deprecated memory embedding adapters
|
||||
|
||||
|
||||
63
docs/providers/cohere.md
Normal file
63
docs/providers/cohere.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
summary: "Cohere setup (auth + model selection)"
|
||||
title: "Cohere"
|
||||
read_when:
|
||||
- You want to use Cohere with OpenClaw
|
||||
- You need the Cohere API key env var or CLI auth choice
|
||||
---
|
||||
|
||||
[Cohere](https://cohere.com) provides OpenAI-compatible inference through its Compatibility API. OpenClaw includes a bundled Cohere provider plugin with the Command A model catalog.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Provider id | `cohere` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `COHERE_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice cohere-api-key` |
|
||||
| Direct CLI flag | `--cohere-api-key <key>` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.cohere.ai/compatibility/v1` |
|
||||
| Default model | `cohere/command-a-03-2025` |
|
||||
|
||||
## Get started
|
||||
|
||||
1. Create a Cohere API key.
|
||||
2. Run onboarding:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice cohere-api-key \
|
||||
--cohere-api-key "$COHERE_API_KEY"
|
||||
```
|
||||
|
||||
3. Confirm the catalog is available:
|
||||
|
||||
```bash
|
||||
openclaw models list --provider cohere
|
||||
```
|
||||
|
||||
The default model is set only when no primary model is already configured.
|
||||
|
||||
## Environment-only setup
|
||||
|
||||
Make `COHERE_API_KEY` available to the Gateway process, then select the bundled model:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "cohere/command-a-03-2025" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
If the Gateway runs as a daemon or in Docker, configure `COHERE_API_KEY` for that service. Exporting it only in an interactive shell does not make it available to an already-running Gateway.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
- [Model providers](/concepts/model-providers)
|
||||
- [Models CLI](/cli/models)
|
||||
- [Provider directory](/providers)
|
||||
@@ -435,11 +435,14 @@ WebSocket endpoint, sends the initial setup payload, and waits for
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gemini CLI JSON usage notes">
|
||||
When using the `google-gemini-cli` OAuth provider, OpenClaw normalizes
|
||||
the CLI JSON output as follows:
|
||||
<Accordion title="Gemini CLI usage notes">
|
||||
When using the `google-gemini-cli` OAuth provider, OpenClaw uses Gemini
|
||||
CLI `stream-json` output by default and normalizes usage from the final
|
||||
`stats` payload. Legacy `--output-format json` overrides still use the
|
||||
JSON parser.
|
||||
|
||||
- Reply text comes from the CLI JSON `response` field.
|
||||
- Streamed reply text comes from assistant `message` events.
|
||||
- For legacy JSON output, reply text comes from the CLI JSON `response` field.
|
||||
- Usage falls back to `stats` when the CLI leaves `usage` empty.
|
||||
- `stats.cached` is normalized into OpenClaw `cacheRead`.
|
||||
- If `stats.input` is missing, OpenClaw derives input tokens from
|
||||
|
||||
@@ -33,6 +33,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
|
||||
- [Cerebras](/providers/cerebras)
|
||||
- [Chutes](/providers/chutes)
|
||||
- [Cohere](/providers/cohere)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [ComfyUI](/providers/comfy)
|
||||
- [DeepSeek](/providers/deepseek)
|
||||
|
||||
@@ -27,6 +27,7 @@ model as `provider/model`.
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
|
||||
- [Chutes](/providers/chutes)
|
||||
- [Cohere](/providers/cohere)
|
||||
- [ComfyUI](/providers/comfy)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [DeepInfra](/providers/deepinfra)
|
||||
|
||||
@@ -155,7 +155,29 @@ the maintainer-only release runbook.
|
||||
11. After publish, run the npm post-publish verifier, optional standalone
|
||||
published-npm Telegram E2E when you need post-publish channel proof,
|
||||
dist-tag promotion when needed, verify the generated GitHub release page,
|
||||
and run the release announcement steps.
|
||||
run the release announcement steps, then complete [Stable main
|
||||
closeout](#stable-main-closeout) before calling a stable release finished.
|
||||
|
||||
## Stable main closeout
|
||||
|
||||
Stable publication is not complete until `main` carries the actual shipped
|
||||
release state.
|
||||
|
||||
1. Start from fresh latest `main`. Audit `release/YYYY.M.PATCH` against it and
|
||||
forward-port real fixes that are absent from `main`. Do not blindly merge
|
||||
release-only compatibility, test, or validation adapters into newer `main`.
|
||||
2. Set `main` to the shipped stable version, not a speculative next train. Run
|
||||
`pnpm release:prep` after the root version change, then
|
||||
`pnpm deps:shrinkwrap:generate`.
|
||||
3. Make `CHANGELOG.md`'s `## YYYY.M.PATCH` section on `main` exactly match the
|
||||
tagged release branch. Include the stable `appcast.xml` update when the mac
|
||||
release published one.
|
||||
4. Do not add `YYYY.M.PATCH+1`, a beta version, or an empty future changelog
|
||||
section to `main` until the operator explicitly starts that release train.
|
||||
5. Run `pnpm release:generated:check`, `pnpm deps:shrinkwrap:check`, and
|
||||
`OPENCLAW_TESTBOX=1 pnpm check:changed`. Push, then verify `origin/main`
|
||||
contains the shipped version and changelog before calling the stable release
|
||||
done.
|
||||
|
||||
## Release preflight
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ OpenClaw features that can generate provider usage or paid API calls.
|
||||
- `/usage tokens` shows tokens only; subscription-style OAuth/token and CLI flows
|
||||
still show tokens only unless that runtime supplies compatible usage metadata
|
||||
and an explicit local price is configured.
|
||||
- Gemini CLI note: when the CLI returns JSON output, OpenClaw reads usage from
|
||||
`stats`, normalizes `stats.cached` into `cacheRead`, and derives input tokens
|
||||
from `stats.input_tokens - stats.cached` when needed.
|
||||
- Gemini CLI note: the default `stream-json` output and legacy JSON overrides
|
||||
both read usage from `stats`, normalize `stats.cached` into `cacheRead`, and
|
||||
derive input tokens from `stats.input_tokens - stats.cached` when needed.
|
||||
|
||||
Anthropic note: Anthropic staff told us OpenClaw-style Claude CLI usage is
|
||||
allowed again, so OpenClaw treats Claude CLI reuse and `claude -p` usage as
|
||||
|
||||
@@ -675,9 +675,10 @@ is disabled, uninstalled, or rolled back:
|
||||
clearCodeModeNamespacesForPlugin(pluginId);
|
||||
```
|
||||
|
||||
Use `unregisterCodeModeNamespace(namespaceId)` only when removing one known
|
||||
namespace. Tests can call `clearCodeModeNamespacesForTest()` to avoid leaking
|
||||
registrations across cases.
|
||||
Code-mode cleanup is plugin-owned; clear the plugin's namespace registrations
|
||||
when its lifecycle ends instead of keeping per-namespace teardown handles. Tests
|
||||
can call `clearCodeModeNamespacesForTest()` to avoid leaking registrations
|
||||
across cases.
|
||||
|
||||
### Test checklist
|
||||
|
||||
|
||||
@@ -163,10 +163,11 @@ If the provider does not support this cache mode, `cacheRetention` has no effect
|
||||
OpenClaw manages a provider-native `cachedContents` resource rather than
|
||||
injecting cache markers into the request.
|
||||
|
||||
### Gemini CLI JSON usage
|
||||
### Gemini CLI usage
|
||||
|
||||
- Gemini CLI JSON output can also surface cache hits through `stats.cached`;
|
||||
OpenClaw maps that to `cacheRead`.
|
||||
- Gemini CLI `stream-json` output can surface cache hits through `stats.cached`;
|
||||
OpenClaw maps that to `cacheRead`. Legacy `--output-format json` overrides use
|
||||
the same usage normalization.
|
||||
- If the CLI omits a direct `stats.input` value, OpenClaw derives input tokens
|
||||
from `stats.input_tokens - stats.cached`.
|
||||
- This is usage normalization only. It does not mean OpenClaw is creating
|
||||
|
||||
@@ -362,8 +362,8 @@ OpenClaw also enforces a safety floor for embedded runs:
|
||||
|
||||
Why: leave enough headroom for multi-turn "housekeeping" (like memory writes) before compaction becomes unavoidable.
|
||||
|
||||
Implementation: `ensureAgentCompactionReserveTokens()` in `src/agents/agent-settings.ts`
|
||||
(called from `src/agents/embedded-agent-runner.ts`).
|
||||
Implementation: `applyAgentCompactionSettingsFromConfig()` in `src/agents/agent-settings.ts`
|
||||
(called from embedded-runner turn and compaction setup paths).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -92,9 +92,11 @@ Usage surfaces normalize common provider-native field aliases before display.
|
||||
For OpenAI-family Responses traffic, that includes both `input_tokens` /
|
||||
`output_tokens` and `prompt_tokens` / `completion_tokens`, so transport-specific
|
||||
field names do not change `/status`, `/usage`, or session summaries.
|
||||
Gemini CLI JSON usage is normalized too: reply text comes from `response`, and
|
||||
`stats.cached` maps to `cacheRead` with `stats.input_tokens - stats.cached`
|
||||
used when the CLI omits an explicit `stats.input` field.
|
||||
Gemini CLI usage is normalized too: the default `stream-json` parser reads
|
||||
assistant `message` events, and `stats.cached` maps to `cacheRead` with
|
||||
`stats.input_tokens - stats.cached` used when the CLI omits an explicit
|
||||
`stats.input` field. Legacy JSON overrides still read reply text from
|
||||
`response`.
|
||||
For native OpenAI-family Responses traffic, WebSocket/SSE usage aliases are
|
||||
normalized the same way, and totals fall back to normalized input + output when
|
||||
`total_tokens` is missing or `0`.
|
||||
|
||||
4
extensions/acpx/npm-shrinkwrap.json
generated
4
extensions/acpx/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.100.1",
|
||||
"@aws/bedrock-token-generator": "1.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -24,10 +24,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1056.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1056.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -28,10 +28,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,10 +23,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
4
extensions/brave/npm-shrinkwrap.json
generated
4
extensions/brave/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9"
|
||||
"openclawVersion": "2026.6.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -27,9 +27,9 @@ export const CHROME_STOP_TIMEOUT_MS = 2500;
|
||||
export const CHROME_STOP_PROBE_TIMEOUT_MS = 200;
|
||||
export const CHROME_STDERR_HINT_MAX_CHARS = 2000;
|
||||
|
||||
export const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
|
||||
export const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
|
||||
export const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
|
||||
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
|
||||
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
|
||||
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
|
||||
export const PROFILE_ATTACH_RETRY_TIMEOUT_MS = 1200;
|
||||
export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600;
|
||||
export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000;
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
|
||||
import {
|
||||
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
|
||||
PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS,
|
||||
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
|
||||
resolveCdpReachabilityTimeouts,
|
||||
} from "./cdp-timeouts.js";
|
||||
import { resolveCdpReachabilityTimeouts } from "./cdp-timeouts.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
||||
|
||||
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
|
||||
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
|
||||
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
|
||||
@@ -41,18 +41,21 @@ function profileContext(tabs: Array<{ targetId: string; url: string }>) {
|
||||
};
|
||||
}
|
||||
|
||||
function routeContextForTab(url: string): BrowserRouteContext {
|
||||
function routeContextForTab(
|
||||
url: string,
|
||||
ensureTabAvailable = vi.fn(async () => ({
|
||||
targetId: "tab-1",
|
||||
title: "Tab",
|
||||
url,
|
||||
type: "page",
|
||||
})),
|
||||
): BrowserRouteContext {
|
||||
const profileCtx = {
|
||||
profile: {
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
name: "default",
|
||||
},
|
||||
ensureTabAvailable: vi.fn(async () => ({
|
||||
targetId: "tab-1",
|
||||
title: "Tab",
|
||||
url,
|
||||
type: "page",
|
||||
})),
|
||||
ensureTabAvailable,
|
||||
} as unknown as ProfileContext;
|
||||
|
||||
return {
|
||||
@@ -132,6 +135,27 @@ describe("browser route shared helpers", () => {
|
||||
});
|
||||
|
||||
describe("withRouteTabContext", () => {
|
||||
it("opts agent routes into Playwright target-id fallback", async () => {
|
||||
const response = createBrowserRouteResponse();
|
||||
const ensureTabAvailable = vi.fn(async () => ({
|
||||
targetId: "tab-1",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
type: "page",
|
||||
}));
|
||||
|
||||
await withRouteTabContext({
|
||||
req: requestWithBody({}),
|
||||
res: response.res,
|
||||
ctx: routeContextForTab("https://example.com", ensureTabAvailable),
|
||||
run: async () => {},
|
||||
});
|
||||
|
||||
expect(ensureTabAvailable).toHaveBeenCalledWith(undefined, {
|
||||
allowPlaywrightFallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enforce current-tab URL policy unless requested", async () => {
|
||||
const response = createBrowserRouteResponse();
|
||||
const run = vi.fn(async () => {
|
||||
|
||||
@@ -147,7 +147,10 @@ export async function withRouteTabContext<T>(
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(params.targetId);
|
||||
// Agent routes can address local-managed tabs through Playwright when per-tab WS discovery lags.
|
||||
const tab = await profileCtx.ensureTabAvailable(params.targetId, {
|
||||
allowPlaywrightFallback: true,
|
||||
});
|
||||
if (params.enforceCurrentUrlAllowed) {
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: tab.url,
|
||||
|
||||
@@ -128,6 +128,9 @@ describe("local-managed browser snapshot routes", () => {
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
|
||||
expect(routeState.profileCtx.ensureTabAvailable).toHaveBeenCalledWith(undefined, {
|
||||
allowPlaywrightFallback: false,
|
||||
});
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
|
||||
@@ -594,7 +594,9 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
});
|
||||
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined, {
|
||||
allowPlaywrightFallback: hasPlaywright,
|
||||
});
|
||||
const usesChromeMcp = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
|
||||
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
|
||||
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||
|
||||
@@ -3,15 +3,14 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import {
|
||||
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
|
||||
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
|
||||
} from "./cdp-timeouts.js";
|
||||
import { PROFILE_ATTACH_RETRY_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { BrowserProfileUnavailableError } from "./errors.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import { makeBrowserServerState, mockLaunchedChrome } from "./server-context.test-harness.js";
|
||||
|
||||
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
|
||||
|
||||
function setupEnsureBrowserAvailableHarness() {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
202
extensions/browser/src/browser/server-context.selection.test.ts
Normal file
202
extensions/browser/src/browser/server-context.selection.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import {
|
||||
OPEN_TAB_DISCOVERY_POLL_MS,
|
||||
OPEN_TAB_DISCOVERY_WINDOW_MS,
|
||||
} from "./server-context.constants.js";
|
||||
import { createProfileSelectionOps } from "./server-context.selection.js";
|
||||
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
||||
|
||||
const LOCAL_PROFILE: ResolvedBrowserProfile = {
|
||||
name: "openclaw",
|
||||
cdpPort: 18800,
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
color: "#FF4500",
|
||||
driver: "openclaw",
|
||||
headless: true,
|
||||
headlessSource: "config",
|
||||
attachOnly: false,
|
||||
};
|
||||
|
||||
function tab(targetId: string, wsUrl?: string): BrowserTab {
|
||||
return {
|
||||
targetId,
|
||||
title: targetId,
|
||||
url: `https://${targetId.toLowerCase()}.example`,
|
||||
type: "page",
|
||||
...(wsUrl ? { wsUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createSelectionHarness(params: {
|
||||
snapshots: Array<BrowserTab[] | Error>;
|
||||
openedTab?: BrowserTab;
|
||||
}) {
|
||||
const snapshots = [...params.snapshots];
|
||||
let lastSnapshot: BrowserTab[] = [];
|
||||
const listTabs = vi.fn(async () => {
|
||||
const next = snapshots.shift();
|
||||
if (next instanceof Error) {
|
||||
throw next;
|
||||
}
|
||||
if (next) {
|
||||
lastSnapshot = next;
|
||||
}
|
||||
return lastSnapshot;
|
||||
});
|
||||
const profileState: ProfileRuntimeState = {
|
||||
profile: LOCAL_PROFILE,
|
||||
running: null,
|
||||
lastTargetId: null,
|
||||
reconcile: null,
|
||||
};
|
||||
const openTab = vi.fn(async () => {
|
||||
const openedTab = params.openedTab ?? tab("OPENED");
|
||||
profileState.lastTargetId = openedTab.targetId;
|
||||
return openedTab;
|
||||
});
|
||||
const selection = createProfileSelectionOps({
|
||||
profile: LOCAL_PROFILE,
|
||||
getProfileState: () => profileState,
|
||||
getCdpControlPolicy: () => undefined,
|
||||
ensureBrowserAvailable: async () => {},
|
||||
listTabs,
|
||||
openTab,
|
||||
});
|
||||
return { selection, listTabs, openTab, profileState };
|
||||
}
|
||||
|
||||
async function advancePastDiscoveryWindow(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(OPEN_TAB_DISCOVERY_WINDOW_MS + OPEN_TAB_DISCOVERY_POLL_MS);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("browser profile tab selection", () => {
|
||||
it("preserves the opened tab when the immediate relist omits it", async () => {
|
||||
const openedTab = tab("OPENED", "ws://127.0.0.1/devtools/page/OPENED");
|
||||
const { selection, listTabs, openTab } = createSelectionHarness({
|
||||
snapshots: [[], []],
|
||||
openedTab,
|
||||
});
|
||||
|
||||
await expect(selection.ensureTabAvailable()).resolves.toEqual(openedTab);
|
||||
expect(openTab).toHaveBeenCalledOnce();
|
||||
expect(listTabs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves a target-id-only opened tab for a Playwright-backed caller", async () => {
|
||||
vi.useFakeTimers();
|
||||
const openedTab = tab("OPENED");
|
||||
const otherWithWs = tab("OTHER", "ws://127.0.0.1/devtools/page/OTHER");
|
||||
const { selection } = createSelectionHarness({
|
||||
snapshots: [[], [otherWithWs]],
|
||||
openedTab,
|
||||
});
|
||||
|
||||
const selected = selection.ensureTabAvailable(undefined, {
|
||||
allowPlaywrightFallback: true,
|
||||
});
|
||||
await advancePastDiscoveryWindow();
|
||||
|
||||
await expect(selected).resolves.toEqual(openedTab);
|
||||
});
|
||||
|
||||
it("polls until delayed wsUrl discovery makes an existing tab selectable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const withoutWs = tab("LAGGING");
|
||||
const withWs = tab("LAGGING", "ws://127.0.0.1/devtools/page/LAGGING");
|
||||
const { selection, listTabs, openTab } = createSelectionHarness({
|
||||
snapshots: [[withoutWs], [withoutWs], [withWs]],
|
||||
});
|
||||
|
||||
const selected = selection.ensureTabAvailable();
|
||||
await vi.advanceTimersByTimeAsync(OPEN_TAB_DISCOVERY_POLL_MS);
|
||||
|
||||
await expect(selected).resolves.toEqual(withWs);
|
||||
expect(listTabs).toHaveBeenCalledTimes(3);
|
||||
expect(openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows an existing target-id-only tab only for Playwright-backed callers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const withoutWs = tab("PLAYWRIGHT_TARGET");
|
||||
const otherWithWs = tab("OTHER", "ws://127.0.0.1/devtools/page/OTHER");
|
||||
const { selection } = createSelectionHarness({
|
||||
snapshots: [[withoutWs, otherWithWs]],
|
||||
});
|
||||
|
||||
const selected = selection.ensureTabAvailable("PLAYWRIGHT_TARGET", {
|
||||
allowPlaywrightFallback: true,
|
||||
});
|
||||
await advancePastDiscoveryWindow();
|
||||
|
||||
await expect(selected).resolves.toEqual(withoutWs);
|
||||
});
|
||||
|
||||
it("preserves a sticky target-id-only tab instead of switching to another tab", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stickyWithoutWs = tab("STICKY");
|
||||
const otherWithWs = tab("OTHER", "ws://127.0.0.1/devtools/page/OTHER");
|
||||
const { selection, profileState } = createSelectionHarness({
|
||||
snapshots: [[stickyWithoutWs, otherWithWs]],
|
||||
});
|
||||
profileState.lastTargetId = stickyWithoutWs.targetId;
|
||||
|
||||
const selected = selection.ensureTabAvailable(undefined, {
|
||||
allowPlaywrightFallback: true,
|
||||
});
|
||||
await advancePastDiscoveryWindow();
|
||||
|
||||
await expect(selected).resolves.toEqual(stickyWithoutWs);
|
||||
});
|
||||
|
||||
it("keeps polling after a transient tab-list rejection", async () => {
|
||||
vi.useFakeTimers();
|
||||
const withoutWs = tab("RECOVERED");
|
||||
const withWs = tab("RECOVERED", "ws://127.0.0.1/devtools/page/RECOVERED");
|
||||
const { selection, listTabs } = createSelectionHarness({
|
||||
snapshots: [[withoutWs], new Error("transient list failure"), [withWs]],
|
||||
});
|
||||
|
||||
const selected = selection.ensureTabAvailable();
|
||||
await vi.advanceTimersByTimeAsync(OPEN_TAB_DISCOVERY_POLL_MS);
|
||||
|
||||
await expect(selected).resolves.toEqual(withWs);
|
||||
expect(listTabs).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("falls back to the last nonempty unfiltered snapshot after empty relists", async () => {
|
||||
vi.useFakeTimers();
|
||||
const withoutWs = tab("LAST_NONEMPTY");
|
||||
const { selection, openTab } = createSelectionHarness({
|
||||
snapshots: [[withoutWs], [], new Error("transient list failure")],
|
||||
});
|
||||
|
||||
const selected = selection.ensureTabAvailable(undefined, {
|
||||
allowPlaywrightFallback: true,
|
||||
});
|
||||
await advancePastDiscoveryWindow();
|
||||
|
||||
await expect(selected).resolves.toEqual(withoutWs);
|
||||
expect(openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a target-id-only local tab when the caller cannot use Playwright", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { selection } = createSelectionHarness({
|
||||
snapshots: [[tab("NO_PLAYWRIGHT")]],
|
||||
});
|
||||
|
||||
const selected = expect(selection.ensureTabAvailable("NO_PLAYWRIGHT")).rejects.toThrow(
|
||||
/tab not found/i,
|
||||
);
|
||||
await advancePastDiscoveryWindow();
|
||||
|
||||
await selected;
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
* Browser tab selection operations for default tab choice, focus, and close.
|
||||
*/
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath } from "./cdp.js";
|
||||
@@ -11,7 +12,15 @@ import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.j
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
||||
import {
|
||||
OPEN_TAB_DISCOVERY_POLL_MS,
|
||||
OPEN_TAB_DISCOVERY_WINDOW_MS,
|
||||
} from "./server-context.constants.js";
|
||||
import type {
|
||||
BrowserTab,
|
||||
EnsureTabAvailableOptions,
|
||||
ProfileRuntimeState,
|
||||
} from "./server-context.types.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
|
||||
type SelectionDeps = {
|
||||
@@ -24,11 +33,40 @@ type SelectionDeps = {
|
||||
};
|
||||
|
||||
type SelectionOps = {
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
ensureTabAvailable: (
|
||||
targetId?: string,
|
||||
options?: EnsureTabAvailableOptions,
|
||||
) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function mergeOpenedTabSnapshot(
|
||||
tabs: BrowserTab[],
|
||||
openedTab: BrowserTab | undefined,
|
||||
): BrowserTab[] {
|
||||
if (!openedTab) {
|
||||
return tabs;
|
||||
}
|
||||
const index = tabs.findIndex((tab) => tab.targetId === openedTab.targetId);
|
||||
if (index < 0) {
|
||||
return [...tabs, openedTab];
|
||||
}
|
||||
const listedTab = tabs[index];
|
||||
if (!listedTab || listedTab.wsUrl || !openedTab.wsUrl) {
|
||||
return tabs;
|
||||
}
|
||||
const merged = tabs.slice();
|
||||
merged[index] = { ...listedTab, wsUrl: openedTab.wsUrl };
|
||||
return merged;
|
||||
}
|
||||
|
||||
function waitForTabDiscoveryPoll(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, OPEN_TAB_DISCOVERY_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds tab selection/focus/close operations for one resolved browser profile. */
|
||||
export function createProfileSelectionOps({
|
||||
profile,
|
||||
@@ -41,16 +79,99 @@ export function createProfileSelectionOps({
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
const ensureTabAvailable = async (
|
||||
targetId?: string,
|
||||
options?: EnsureTabAvailableOptions,
|
||||
): Promise<BrowserTab> => {
|
||||
await ensureBrowserAvailable();
|
||||
const profileState = getProfileState();
|
||||
const tabs1 = await listTabs();
|
||||
if (tabs1.length === 0) {
|
||||
await openTab("about:blank");
|
||||
let lastNonEmptyTabs: BrowserTab[] = [];
|
||||
let lastListError: unknown;
|
||||
let sawSuccessfulList = false;
|
||||
let openedTab: BrowserTab | undefined;
|
||||
|
||||
const readTabs = async (): Promise<BrowserTab[]> => {
|
||||
try {
|
||||
const tabs = await listTabs();
|
||||
sawSuccessfulList = true;
|
||||
if (tabs.length > 0) {
|
||||
lastNonEmptyTabs = tabs;
|
||||
}
|
||||
return tabs;
|
||||
} catch (err) {
|
||||
lastListError = err;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const openWhenConfirmedEmpty = async (tabs: BrowserTab[]): Promise<void> => {
|
||||
if (!openedTab && sawSuccessfulList && lastNonEmptyTabs.length === 0 && tabs.length === 0) {
|
||||
openedTab = await openTab("about:blank");
|
||||
}
|
||||
};
|
||||
|
||||
const candidateTabs = (tabs: BrowserTab[]) =>
|
||||
capabilities.supportsPerTabWs ? tabs.filter((tab) => Boolean(tab.wsUrl)) : tabs;
|
||||
const canResolveSelection = (tabs: BrowserTab[]) => {
|
||||
const desiredTargetId =
|
||||
targetId ??
|
||||
openedTab?.targetId ??
|
||||
normalizeOptionalString(profileState.lastTargetId) ??
|
||||
undefined;
|
||||
if (!desiredTargetId) {
|
||||
return tabs.length > 0;
|
||||
}
|
||||
const resolved = resolveTargetIdFromTabs(desiredTargetId, tabs);
|
||||
return resolved.ok || resolved.reason === "ambiguous";
|
||||
};
|
||||
|
||||
const tabs1 = await readTabs();
|
||||
await openWhenConfirmedEmpty(tabs1);
|
||||
|
||||
let listedTabs = await readTabs();
|
||||
await openWhenConfirmedEmpty(listedTabs);
|
||||
let unfilteredTabs = mergeOpenedTabSnapshot(listedTabs, openedTab);
|
||||
let candidates = candidateTabs(unfilteredTabs);
|
||||
const preservedCanResolveSelection = () =>
|
||||
canResolveSelection(mergeOpenedTabSnapshot(lastNonEmptyTabs, openedTab));
|
||||
|
||||
if (
|
||||
capabilities.supportsPerTabWs &&
|
||||
!canResolveSelection(candidates) &&
|
||||
(candidates.length === 0 ||
|
||||
canResolveSelection(unfilteredTabs) ||
|
||||
preservedCanResolveSelection())
|
||||
) {
|
||||
const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await waitForTabDiscoveryPoll();
|
||||
listedTabs = await readTabs();
|
||||
await openWhenConfirmedEmpty(listedTabs);
|
||||
unfilteredTabs = mergeOpenedTabSnapshot(listedTabs, openedTab);
|
||||
candidates = candidateTabs(unfilteredTabs);
|
||||
if (canResolveSelection(candidates)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = await listTabs();
|
||||
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
||||
if (!canResolveSelection(candidates)) {
|
||||
// Keep the last useful discovery snapshot across empty or failed relists.
|
||||
// Target-id-only fallback is opt-in because only Playwright-backed callers can use it safely.
|
||||
const preservedTabs = mergeOpenedTabSnapshot(lastNonEmptyTabs, openedTab);
|
||||
const preservedCandidates = candidateTabs(preservedTabs);
|
||||
if (canResolveSelection(preservedCandidates)) {
|
||||
candidates = preservedCandidates;
|
||||
} else if (options?.allowPlaywrightFallback && canResolveSelection(preservedTabs)) {
|
||||
candidates = preservedTabs;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0 && !sawSuccessfulList && lastListError) {
|
||||
throw lastListError instanceof Error
|
||||
? lastListError
|
||||
: new Error(formatErrorMessage(lastListError));
|
||||
}
|
||||
|
||||
const resolveById = (raw: string) => {
|
||||
const resolved = resolveTargetIdFromTabs(raw, candidates);
|
||||
|
||||
@@ -265,7 +265,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
listProfiles,
|
||||
// Legacy methods delegate to default profile
|
||||
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
|
||||
ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId),
|
||||
ensureTabAvailable: (targetId, options) =>
|
||||
getDefaultContext().ensureTabAvailable(targetId, options),
|
||||
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
|
||||
isTransportAvailable: (timeoutMs) => getDefaultContext().isTransportAvailable(timeoutMs),
|
||||
isReachable: (timeoutMs, options) => getDefaultContext().isReachable(timeoutMs, options),
|
||||
|
||||
@@ -43,9 +43,17 @@ export type BrowserServerState = {
|
||||
stopUnhandledRejectionHandler?: () => void;
|
||||
};
|
||||
|
||||
export type EnsureTabAvailableOptions = {
|
||||
/** Allow a target-id-only tab when the caller can continue through Playwright. */
|
||||
allowPlaywrightFallback?: boolean;
|
||||
};
|
||||
|
||||
type BrowserProfileActions = {
|
||||
ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
ensureTabAvailable: (
|
||||
targetId?: string,
|
||||
options?: EnsureTabAvailableOptions,
|
||||
) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isTransportAvailable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cerebras provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,7 @@
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.9"
|
||||
"openclaw": ">=2026.6.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex-supervisor",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Codex app-server fleet supervision plugin.",
|
||||
"type": "module",
|
||||
|
||||
4
extensions/codex/npm-shrinkwrap.json
generated
4
extensions/codex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
|
||||
@@ -193,6 +193,47 @@
|
||||
"enum": ["user", "auto_review", "guardian_subagent"]
|
||||
},
|
||||
"serviceTier": { "type": ["string", "null"] },
|
||||
"networkProxy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"profileName": { "type": "string" },
|
||||
"baseProfile": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["limited", "full"]
|
||||
},
|
||||
"domains": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
}
|
||||
},
|
||||
"unixSockets": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "none"]
|
||||
}
|
||||
},
|
||||
"proxyUrl": { "type": "string" },
|
||||
"socksUrl": { "type": "string" },
|
||||
"enableSocks5": { "type": "boolean" },
|
||||
"enableSocks5Udp": { "type": "boolean" },
|
||||
"allowUpstreamProxy": { "type": "boolean" },
|
||||
"allowLocalBinding": { "type": "boolean" },
|
||||
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
|
||||
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"defaultWorkspaceDir": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -385,6 +426,81 @@
|
||||
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy": {
|
||||
"label": "Network Proxy",
|
||||
"help": "Enable Codex permissions-profile networking for app-server commands.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enabled": {
|
||||
"label": "Network Proxy Enabled",
|
||||
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it with default_permissions instead of sandbox fields.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.profileName": {
|
||||
"label": "Network Proxy Profile",
|
||||
"help": "Optional stable Codex permissions profile name. Leave unset to use a generated openclaw-network fingerprint name.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.baseProfile": {
|
||||
"label": "Network Proxy Base",
|
||||
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.domains": {
|
||||
"label": "Network Domains",
|
||||
"help": "Domain allow and deny rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.unixSockets": {
|
||||
"label": "Unix Sockets",
|
||||
"help": "Unix socket allow and none rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.proxyUrl": {
|
||||
"label": "HTTP Proxy URL",
|
||||
"help": "HTTP listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.socksUrl": {
|
||||
"label": "SOCKS Proxy URL",
|
||||
"help": "SOCKS listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5": {
|
||||
"label": "Enable SOCKS5",
|
||||
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5Udp": {
|
||||
"label": "Enable SOCKS5 UDP",
|
||||
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowUpstreamProxy": {
|
||||
"label": "Allow Upstream Proxy",
|
||||
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowLocalBinding": {
|
||||
"label": "Allow Local Binding",
|
||||
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.mode": {
|
||||
"label": "Network Mode",
|
||||
"help": "Codex sandboxed networking mode for subprocess traffic.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
|
||||
"label": "Allow Non-Loopback Proxy",
|
||||
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
|
||||
"label": "Allow All Unix Sockets",
|
||||
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.defaultWorkspaceDir": {
|
||||
"label": "Default Workspace",
|
||||
"help": "Workspace used by /codex bind when --cwd is omitted.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,10 +34,10 @@
|
||||
]
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9"
|
||||
"openclawVersion": "2026.6.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("Codex app-server attempt context", () => {
|
||||
it("returns a run context report without deferred Codex dynamic tool schemas", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
name: "message",
|
||||
description: "Send a message.",
|
||||
inputSchema: {
|
||||
@@ -28,15 +29,23 @@ describe("Codex app-server attempt context", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
type: "namespace",
|
||||
name: "openclaw",
|
||||
description: "",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "web_search",
|
||||
description: "Search the web.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
deferLoading: true,
|
||||
},
|
||||
},
|
||||
deferLoading: true,
|
||||
],
|
||||
},
|
||||
] as CodexDynamicToolSpec[];
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
|
||||
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
|
||||
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
|
||||
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
|
||||
import { isJsonObject } from "./protocol.js";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
@@ -280,7 +281,7 @@ export function buildCodexSystemPromptReport(params: {
|
||||
skillsPrompt: string;
|
||||
tools: CodexDynamicToolSpec[];
|
||||
}): CodexSystemPromptReport {
|
||||
const toolEntries = params.tools.map(buildCodexToolReportEntry);
|
||||
const toolEntries = flattenCodexDynamicToolFunctions(params.tools).map(buildCodexToolReportEntry);
|
||||
const schemaChars = toolEntries.reduce((sum, tool) => sum + tool.schemaChars, 0);
|
||||
const skillsPrompt = params.skillsPrompt.trim();
|
||||
const bootstrapMaxChars = readPositiveNumber(
|
||||
@@ -344,7 +345,7 @@ function buildCodexSkillReportEntries(
|
||||
.filter((entry) => entry.blockChars > 0);
|
||||
}
|
||||
|
||||
function buildCodexToolReportEntry(tool: CodexDynamicToolSpec): CodexToolReportEntry {
|
||||
function buildCodexToolReportEntry(tool: CodexDynamicToolFunctionSpec): CodexToolReportEntry {
|
||||
const summary = tool.description.trim();
|
||||
if (tool.deferLoading === true) {
|
||||
return {
|
||||
@@ -854,13 +855,15 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
|
||||
}
|
||||
|
||||
/** Returns whether the current dynamic tool list can serve workspace memory. */
|
||||
export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean {
|
||||
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
|
||||
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
|
||||
}
|
||||
|
||||
/** Lists available memory tool names understood by Codex workspace memory routing. */
|
||||
export function getCodexWorkspaceMemoryToolNames(tools: readonly { name: string }[]): string[] {
|
||||
const availableToolNames = new Set(tools.map((tool) => normalizeCodexDynamicToolName(tool.name)));
|
||||
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
|
||||
const availableToolNames = new Set(
|
||||
flattenCodexDynamicToolFunctions(tools).map((tool) => normalizeCodexDynamicToolName(tool.name)),
|
||||
);
|
||||
return Array.from(CODEX_MEMORY_TOOL_NAMES).filter((name) => availableToolNames.has(name));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
@@ -58,6 +61,25 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
|
||||
} as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
function threadStartResult(threadId = "thread-auth-contract") {
|
||||
return {
|
||||
thread: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CODEX_PLUGINS_CONFIG_KEYS,
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
codexAppServerStartOptionsKey,
|
||||
fingerprintCodexAppServerNetworkProxyConfigPatch,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
resolveCodexComputerUseConfig,
|
||||
@@ -83,6 +84,21 @@ describe("Codex app-server config", () => {
|
||||
sandbox: "danger-full-access",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
networkProxy: {
|
||||
profileName: "openclaw-network",
|
||||
configFingerprint: "network-proxy-v1",
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "openclaw-network",
|
||||
permissions: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses typed plugin config before falling back to environment knobs", () => {
|
||||
@@ -125,6 +141,102 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex permissions-profile config for app-server network proxy", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
profileName: "mock-proxy",
|
||||
mode: "limited",
|
||||
domains: {
|
||||
" api.openai.com ": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unixSockets: {
|
||||
" /tmp/mock-proxy.sock ": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
socksUrl: "socks5h://127.0.0.1:8081",
|
||||
enableSocks5: true,
|
||||
enableSocks5Udp: false,
|
||||
allowUpstreamProxy: true,
|
||||
allowLocalBinding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const networkProxy = runtime.networkProxy;
|
||||
if (!networkProxy) {
|
||||
throw new Error("Expected network proxy runtime config");
|
||||
}
|
||||
expect(networkProxy).toEqual({
|
||||
profileName: "mock-proxy",
|
||||
configFingerprint: expect.any(String),
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "mock-proxy",
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
mode: "limited",
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unix_sockets: {
|
||||
"/tmp/mock-proxy.sock": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
socks_url: "socks5h://127.0.0.1:8081",
|
||||
enable_socks5: true,
|
||||
enable_socks5_udp: false,
|
||||
allow_upstream_proxy: true,
|
||||
allow_local_binding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(networkProxy.configFingerprint).toBe(
|
||||
fingerprintCodexAppServerNetworkProxyConfigPatch(networkProxy.configPatch),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "read-only",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "example.com": "allow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const profileName = runtime.networkProxy?.profileName;
|
||||
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
|
||||
string,
|
||||
{ filesystem: { ":project_roots": { ".": string } } }
|
||||
>;
|
||||
|
||||
expect(profileName).toMatch(/^openclaw-network-[a-f0-9]{16}$/u);
|
||||
expect(runtime.networkProxy?.configPatch.default_permissions).toBe(profileName);
|
||||
expect(permissions[profileName ?? ""]?.filesystem[":project_roots"]["."]).toBe("read");
|
||||
});
|
||||
|
||||
it("clamps oversized app-server timer config", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Codex helper module supports config behavior.
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { hostname as readHostName } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
@@ -111,6 +111,34 @@ export type CodexAppServerExperimentalConfig = {
|
||||
sandboxExecServer?: boolean;
|
||||
};
|
||||
|
||||
export type CodexAppServerNetworkProxyDomainPermission = "allow" | "deny";
|
||||
export type CodexAppServerNetworkProxyUnixSocketPermission = "allow" | "none";
|
||||
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
|
||||
export type CodexAppServerNetworkProxyMode = "limited" | "full";
|
||||
|
||||
export type CodexAppServerNetworkProxyConfig = {
|
||||
enabled?: boolean;
|
||||
profileName?: string;
|
||||
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
|
||||
mode?: CodexAppServerNetworkProxyMode;
|
||||
domains?: Record<string, CodexAppServerNetworkProxyDomainPermission>;
|
||||
unixSockets?: Record<string, CodexAppServerNetworkProxyUnixSocketPermission>;
|
||||
proxyUrl?: string;
|
||||
socksUrl?: string;
|
||||
enableSocks5?: boolean;
|
||||
enableSocks5Udp?: boolean;
|
||||
allowUpstreamProxy?: boolean;
|
||||
allowLocalBinding?: boolean;
|
||||
dangerouslyAllowNonLoopbackProxy?: boolean;
|
||||
dangerouslyAllowAllUnixSockets?: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedCodexAppServerNetworkProxyConfig = {
|
||||
profileName: string;
|
||||
configFingerprint: string;
|
||||
configPatch: JsonObject;
|
||||
};
|
||||
|
||||
export type ResolvedCodexPluginPolicy = {
|
||||
configKey: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
@@ -151,6 +179,7 @@ export type CodexAppServerRuntimeOptions = {
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
approvalsReviewer: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
@@ -188,15 +217,20 @@ export type CodexPluginConfig = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
networkProxy?: CodexAppServerNetworkProxyConfig;
|
||||
defaultWorkspaceDir?: string;
|
||||
experimental?: CodexAppServerExperimentalConfig;
|
||||
};
|
||||
};
|
||||
|
||||
export function shouldAutoApproveCodexAppServerApprovals(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy" | "sandbox">,
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy" | "networkProxy" | "sandbox">,
|
||||
): boolean {
|
||||
return appServer.approvalPolicy === "never" && appServer.sandbox === "danger-full-access";
|
||||
return (
|
||||
appServer.networkProxy === undefined &&
|
||||
appServer.approvalPolicy === "never" &&
|
||||
appServer.sandbox === "danger-full-access"
|
||||
);
|
||||
}
|
||||
|
||||
export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
@@ -216,6 +250,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
"sandbox",
|
||||
"approvalsReviewer",
|
||||
"serviceTier",
|
||||
"networkProxy",
|
||||
"defaultWorkspaceDir",
|
||||
"experimental",
|
||||
] as const;
|
||||
@@ -249,6 +284,7 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
|
||||
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network";
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
@@ -273,6 +309,26 @@ const codexAppServerExperimentalSchema = z
|
||||
sandboxExecServer: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const codexAppServerNetworkProxyDomainPermissionSchema = z.enum(["allow", "deny"]);
|
||||
const codexAppServerNetworkProxyUnixSocketPermissionSchema = z.enum(["allow", "none"]);
|
||||
const codexAppServerNetworkProxySchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
profileName: z.string().trim().min(1).optional(),
|
||||
baseProfile: z.enum(["read-only", "workspace"]).optional(),
|
||||
mode: z.enum(["limited", "full"]).optional(),
|
||||
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
|
||||
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
|
||||
proxyUrl: z.string().trim().min(1).optional(),
|
||||
socksUrl: z.string().trim().min(1).optional(),
|
||||
enableSocks5: z.boolean().optional(),
|
||||
enableSocks5Udp: z.boolean().optional(),
|
||||
allowUpstreamProxy: z.boolean().optional(),
|
||||
allowLocalBinding: z.boolean().optional(),
|
||||
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
|
||||
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const codexPluginEntryConfigSchema = z
|
||||
.object({
|
||||
@@ -334,6 +390,7 @@ const codexPluginConfigSchema = z
|
||||
sandbox: codexAppServerSandboxSchema.optional(),
|
||||
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
|
||||
serviceTier: codexAppServerServiceTierSchema,
|
||||
networkProxy: codexAppServerNetworkProxySchema.optional(),
|
||||
defaultWorkspaceDir: z.string().optional(),
|
||||
experimental: codexAppServerExperimentalSchema.optional(),
|
||||
})
|
||||
@@ -549,6 +606,11 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
? normalizedPolicyMode
|
||||
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
|
||||
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
|
||||
const resolvedSandbox =
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
|
||||
if (transport === "websocket" && !url) {
|
||||
throw new Error(
|
||||
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
|
||||
@@ -597,17 +659,14 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
sandbox: resolvedSandbox,
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -821,6 +880,104 @@ export function codexSandboxPolicyForTurn(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAppServerNetworkProxy(
|
||||
config: CodexAppServerNetworkProxyConfig | undefined,
|
||||
sandbox: CodexAppServerSandboxMode,
|
||||
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
|
||||
if (config?.enabled !== true) {
|
||||
return {};
|
||||
}
|
||||
const fileSystemMode =
|
||||
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
|
||||
? "read"
|
||||
: "write";
|
||||
const networkConfig = removeUndefinedJsonFields({
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
domains: normalizeNetworkProxyPermissionMap(config.domains),
|
||||
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
|
||||
proxy_url: readNonEmptyString(config.proxyUrl),
|
||||
socks_url: readNonEmptyString(config.socksUrl),
|
||||
enable_socks5: config.enableSocks5,
|
||||
enable_socks5_udp: config.enableSocks5Udp,
|
||||
allow_upstream_proxy: config.allowUpstreamProxy,
|
||||
allow_local_binding: config.allowLocalBinding,
|
||||
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
|
||||
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
|
||||
});
|
||||
const profile = {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": {
|
||||
".": fileSystemMode,
|
||||
},
|
||||
},
|
||||
network: networkConfig,
|
||||
};
|
||||
const profileName = resolveNetworkProxyPermissionProfileName(config, profile);
|
||||
const configPatch: JsonObject = {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: profileName,
|
||||
permissions: {
|
||||
[profileName]: profile,
|
||||
},
|
||||
};
|
||||
return {
|
||||
networkProxy: {
|
||||
profileName,
|
||||
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
|
||||
configPatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNetworkProxyPermissionProfileName(
|
||||
config: CodexAppServerNetworkProxyConfig,
|
||||
profile: JsonObject,
|
||||
): string {
|
||||
const explicitProfileName = readNonEmptyString(config.profileName);
|
||||
if (explicitProfileName) {
|
||||
return explicitProfileName;
|
||||
}
|
||||
const suffix = createHash("sha256")
|
||||
.update(stableStringifyJson({ version: 1, profile }))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
return `${DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX}-${suffix}`;
|
||||
}
|
||||
|
||||
export function fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch: JsonObject): string {
|
||||
return createHash("sha256").update(stableStringifyJson(configPatch)).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeNetworkProxyPermissionMap<TPermission extends string>(
|
||||
value: Record<string, TPermission> | undefined,
|
||||
): Record<string, TPermission> | undefined {
|
||||
const entries = Object.entries(value ?? {})
|
||||
.map(([key, permission]) => [key.trim(), permission] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function stableStringifyJson(value: JsonValue): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringifyJson(item)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyJson(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function withMcpElicitationsApprovalPolicy(
|
||||
policy: CodexAppServerEffectiveApprovalPolicy,
|
||||
): CodexAppServerEffectiveApprovalPolicy {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
shouldUseDirectCodexDynamicToolsForModel,
|
||||
} from "./dynamic-tool-profile.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
@@ -401,7 +402,9 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
|
||||
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
|
||||
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
|
||||
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
|
||||
const webSearch = flattenCodexDynamicToolFunctions(toolBridge.specs).find(
|
||||
(tool) => tool.name === "web_search",
|
||||
);
|
||||
expect(webSearch).not.toHaveProperty("deferLoading");
|
||||
expect(webSearch).not.toHaveProperty("namespace");
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
createCodexDynamicToolBridge,
|
||||
} from "./dynamic-tools.js";
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
|
||||
|
||||
function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
|
||||
return {
|
||||
@@ -115,6 +115,20 @@ function expectDynamicSpec(
|
||||
}
|
||||
}
|
||||
|
||||
function flattenSpecsWithNamespace(
|
||||
specs: readonly CodexDynamicToolSpec[],
|
||||
): Array<CodexDynamicToolFunctionSpec & { namespace?: string }> {
|
||||
return specs.flatMap((spec) =>
|
||||
spec.type === "namespace"
|
||||
? spec.tools.map((tool) => ({ ...tool, namespace: spec.name }))
|
||||
: [spec],
|
||||
);
|
||||
}
|
||||
|
||||
function specNames(specs: readonly CodexDynamicToolSpec[]): string[] {
|
||||
return flattenSpecsWithNamespace(specs).map((tool) => tool.name);
|
||||
}
|
||||
|
||||
function expectNoNamespace(spec: unknown) {
|
||||
const record = requireRecord(spec, "tool spec");
|
||||
expect(record).not.toHaveProperty("namespace");
|
||||
@@ -176,11 +190,12 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const webSearch = bridge.specs.find((tool) => tool.name === "web_search");
|
||||
const message = bridge.specs.find((tool) => tool.name === "message");
|
||||
const heartbeat = bridge.specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
|
||||
const sessionsSpawn = bridge.specs.find((tool) => tool.name === "sessions_spawn");
|
||||
const sessionsYield = bridge.specs.find((tool) => tool.name === "sessions_yield");
|
||||
const specs = flattenSpecsWithNamespace(bridge.specs);
|
||||
const webSearch = specs.find((tool) => tool.name === "web_search");
|
||||
const message = specs.find((tool) => tool.name === "message");
|
||||
const heartbeat = specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
|
||||
const sessionsSpawn = specs.find((tool) => tool.name === "sessions_spawn");
|
||||
const sessionsYield = specs.find((tool) => tool.name === "sessions_yield");
|
||||
|
||||
expectDynamicSpec(webSearch, {
|
||||
name: "web_search",
|
||||
@@ -212,14 +227,21 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
directToolNames: ["message"],
|
||||
});
|
||||
|
||||
const specs = flattenSpecsWithNamespace(bridge.specs);
|
||||
expect(bridge.specs).toHaveLength(2);
|
||||
expectDynamicSpec(bridge.specs[0], { name: "message" });
|
||||
expectDynamicSpec(bridge.specs[1], {
|
||||
name: "web_search",
|
||||
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
deferLoading: true,
|
||||
});
|
||||
expectNoNamespace(bridge.specs[0]);
|
||||
expectDynamicSpec(
|
||||
specs.find((tool) => tool.name === "message"),
|
||||
{ name: "message" },
|
||||
);
|
||||
expectDynamicSpec(
|
||||
specs.find((tool) => tool.name === "web_search"),
|
||||
{
|
||||
name: "web_search",
|
||||
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
deferLoading: true,
|
||||
},
|
||||
);
|
||||
expectNoNamespace(specs.find((tool) => tool.name === "message"));
|
||||
});
|
||||
|
||||
it("can register a durable tool schema while denying execution for the current turn", async () => {
|
||||
@@ -236,11 +258,8 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
hookContext: { runId: "run-unavailable", onToolOutcome },
|
||||
});
|
||||
|
||||
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(bridge.specs.map((tool) => tool.name)).toEqual([
|
||||
"message",
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
]);
|
||||
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
|
||||
expect(specNames(bridge.specs)).toEqual(["message", HEARTBEAT_RESPONSE_TOOL_NAME]);
|
||||
|
||||
const result = await bridge.handleToolCall(
|
||||
{
|
||||
@@ -312,11 +331,11 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(bridge.availableSpecs[0]?.inputSchema).toEqual({
|
||||
expect(flattenSpecsWithNamespace(bridge.availableSpecs)[0]?.inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: { current: { type: "string" } },
|
||||
});
|
||||
expect(bridge.specs[0]?.inputSchema).toEqual({
|
||||
expect(flattenSpecsWithNamespace(bridge.specs)[0]?.inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: { durable: { type: "string" } },
|
||||
});
|
||||
@@ -352,8 +371,8 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
unsubscribeDiagnostics();
|
||||
}
|
||||
|
||||
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
|
||||
expect(specNames(bridge.specs)).toEqual(["message"]);
|
||||
expect(bridge.telemetry.quarantinedTools).toEqual([
|
||||
{
|
||||
tool: "fuzzplugin_move_angles",
|
||||
@@ -450,8 +469,8 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
|
||||
expect(specNames(bridge.specs)).toEqual(["message"]);
|
||||
expect(bridge.telemetry.quarantinedTools).toEqual([
|
||||
{
|
||||
tool: "tool[0]",
|
||||
@@ -509,8 +528,8 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(registeredBridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(registeredBridge.specs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(specNames(registeredBridge.availableSpecs)).toEqual(["message"]);
|
||||
expect(specNames(registeredBridge.specs)).toEqual(["message"]);
|
||||
});
|
||||
|
||||
it("can expose all dynamic tools directly for compatibility", () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
CodexDynamicToolCallParams,
|
||||
CodexDynamicToolCallResponse,
|
||||
CodexDynamicToolDiagnosticTerminalType,
|
||||
CodexDynamicToolFunctionSpec,
|
||||
CodexDynamicToolSpec,
|
||||
JsonValue,
|
||||
} from "./protocol.js";
|
||||
@@ -201,20 +202,16 @@ export function createCodexDynamicToolBridge(params: {
|
||||
...(params.directToolNames ?? []),
|
||||
]);
|
||||
return {
|
||||
availableSpecs: availableTools.map((entry) =>
|
||||
createCodexDynamicToolSpec({
|
||||
entry,
|
||||
loading: params.loading ?? "searchable",
|
||||
directToolNames,
|
||||
}),
|
||||
),
|
||||
specs: registeredSpecTools.map((entry) =>
|
||||
createCodexDynamicToolSpec({
|
||||
entry,
|
||||
loading: params.loading ?? "searchable",
|
||||
directToolNames,
|
||||
}),
|
||||
),
|
||||
availableSpecs: createCodexDynamicToolSpecs({
|
||||
entries: availableTools,
|
||||
loading: params.loading ?? "searchable",
|
||||
directToolNames,
|
||||
}),
|
||||
specs: createCodexDynamicToolSpecs({
|
||||
entries: registeredSpecTools,
|
||||
loading: params.loading ?? "searchable",
|
||||
directToolNames,
|
||||
}),
|
||||
telemetry,
|
||||
handleToolCall: async (call, options) => {
|
||||
const toolEntry = toolMap.get(call.tool);
|
||||
@@ -502,24 +499,41 @@ function wrapProjectedCodexDynamicTools(
|
||||
return { tools: wrappedTools, quarantinedTools };
|
||||
}
|
||||
|
||||
function createCodexDynamicToolSpec(params: {
|
||||
entry: ProjectedCodexDynamicTool;
|
||||
function createCodexDynamicToolSpecs(params: {
|
||||
entries: readonly ProjectedCodexDynamicTool[];
|
||||
loading: CodexDynamicToolsLoading;
|
||||
directToolNames: ReadonlySet<string>;
|
||||
}): CodexDynamicToolSpec {
|
||||
const base = {
|
||||
}): CodexDynamicToolSpec[] {
|
||||
const specs: CodexDynamicToolSpec[] = [];
|
||||
const namespaceTools: CodexDynamicToolFunctionSpec[] = [];
|
||||
for (const entry of params.entries) {
|
||||
const functionSpec = createCodexDynamicToolFunctionSpec({ entry });
|
||||
if (params.loading === "direct" || params.directToolNames.has(entry.name)) {
|
||||
specs.push(functionSpec);
|
||||
continue;
|
||||
}
|
||||
namespaceTools.push({ ...functionSpec, deferLoading: true });
|
||||
}
|
||||
if (namespaceTools.length > 0) {
|
||||
specs.push({
|
||||
type: "namespace",
|
||||
name: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
description: "",
|
||||
tools: namespaceTools,
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function createCodexDynamicToolFunctionSpec(params: {
|
||||
entry: ProjectedCodexDynamicTool;
|
||||
}): CodexDynamicToolFunctionSpec {
|
||||
return {
|
||||
type: "function",
|
||||
name: params.entry.name,
|
||||
description: params.entry.description,
|
||||
inputSchema: params.entry.inputSchema,
|
||||
};
|
||||
if (params.loading === "direct" || params.directToolNames.has(params.entry.name)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
deferLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): {
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", ()
|
||||
expectRecordFields(eventRecord, {
|
||||
toolName: "exec",
|
||||
toolCallId: "call-middleware",
|
||||
args: { command: "status" },
|
||||
args: mergedParams,
|
||||
});
|
||||
expectRecordFields(requireRecord(eventRecord.result, "tool_result middleware result"), {
|
||||
content: [{ type: "text", text: "raw output" }],
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"credentialSource": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AmazonBedrockCredentialSource"
|
||||
}
|
||||
],
|
||||
"default": "awsManaged"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"amazonBedrock"
|
||||
@@ -61,6 +69,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AmazonBedrockCredentialSource": {
|
||||
"enum": [
|
||||
"codexManaged",
|
||||
"awsManaged"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PlanType": {
|
||||
"enum": [
|
||||
"free",
|
||||
|
||||
@@ -861,6 +861,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SubAgentActivityKind": {
|
||||
"enum": [
|
||||
"started",
|
||||
"interacted",
|
||||
"interrupted"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -1617,6 +1625,38 @@
|
||||
"title": "CollabAgentToolCallThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"agentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"agentThreadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/SubAgentActivityKind"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"subAgentActivity"
|
||||
],
|
||||
"title": "SubAgentActivityThreadItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agentPath",
|
||||
"agentThreadId",
|
||||
"id",
|
||||
"kind",
|
||||
"type"
|
||||
],
|
||||
"title": "SubAgentActivityThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -1675,6 +1715,32 @@
|
||||
"title": "ImageViewThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"durationMs": {
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"sleep"
|
||||
],
|
||||
"title": "SleepThreadItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"durationMs",
|
||||
"id",
|
||||
"type"
|
||||
],
|
||||
"title": "SleepThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -1790,11 +1856,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -861,6 +861,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SubAgentActivityKind": {
|
||||
"enum": [
|
||||
"started",
|
||||
"interacted",
|
||||
"interrupted"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -1617,6 +1625,38 @@
|
||||
"title": "CollabAgentToolCallThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"agentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"agentThreadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/SubAgentActivityKind"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"subAgentActivity"
|
||||
],
|
||||
"title": "SubAgentActivityThreadItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agentPath",
|
||||
"agentThreadId",
|
||||
"id",
|
||||
"kind",
|
||||
"type"
|
||||
],
|
||||
"title": "SubAgentActivityThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -1675,6 +1715,32 @@
|
||||
"title": "ImageViewThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"durationMs": {
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"sleep"
|
||||
],
|
||||
"title": "SleepThreadItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"durationMs",
|
||||
"id",
|
||||
"type"
|
||||
],
|
||||
"title": "SleepThreadItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -1790,11 +1856,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user