mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 01:31:48 +08:00
Compare commits
346 Commits
codex/spli
...
fix/bug-op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed45202b70 | ||
|
|
586a6ce03b | ||
|
|
15c0dfa61b | ||
|
|
42f0822bfa | ||
|
|
2899560a6b | ||
|
|
44c1cc8285 | ||
|
|
2e3b4b58a1 | ||
|
|
5371b96af1 | ||
|
|
e71b6f7e57 | ||
|
|
2b9be22c0b | ||
|
|
78b2aeeae4 | ||
|
|
66a8262028 | ||
|
|
41fa603aa8 | ||
|
|
8246e91e92 | ||
|
|
59818226a9 | ||
|
|
bf1a5c3303 | ||
|
|
119d2359f3 | ||
|
|
6b68d05fdc | ||
|
|
d88681662b | ||
|
|
8fa4fad3a7 | ||
|
|
1c8a11265b | ||
|
|
608fa52c80 | ||
|
|
fca77dcb19 | ||
|
|
bbfcdea202 | ||
|
|
4b23b36f20 | ||
|
|
10056c9346 | ||
|
|
4980c32846 | ||
|
|
dd44a47ba3 | ||
|
|
2831d697ce | ||
|
|
2cc6871553 | ||
|
|
6d5c15a744 | ||
|
|
e72621e566 | ||
|
|
2814ab66fd | ||
|
|
eb8f9b46da | ||
|
|
05ff771010 | ||
|
|
de94217774 | ||
|
|
981ae137f5 | ||
|
|
371c4d621a | ||
|
|
340f480a7b | ||
|
|
d58f864e23 | ||
|
|
a4e0b6ef47 | ||
|
|
d2711c900d | ||
|
|
1ce363743a | ||
|
|
231a812276 | ||
|
|
989a369112 | ||
|
|
140892ce3d | ||
|
|
5297eebe88 | ||
|
|
49d605ece7 | ||
|
|
acbb06e266 | ||
|
|
96c576674d | ||
|
|
145b57c734 | ||
|
|
d606881807 | ||
|
|
26c0c19352 | ||
|
|
c8d20aeb48 | ||
|
|
c965b3a1ae | ||
|
|
5177180376 | ||
|
|
c1151ea899 | ||
|
|
f393ebe54e | ||
|
|
e7f644c7b1 | ||
|
|
ae52be9f32 | ||
|
|
982e88821c | ||
|
|
13cfb77c10 | ||
|
|
f89fcdd5b3 | ||
|
|
b4f69286fd | ||
|
|
b74cd69c6f | ||
|
|
0126aba57f | ||
|
|
0ee4ccf02c | ||
|
|
7014bd0ff1 | ||
|
|
a43cf2b5db | ||
|
|
1242931ba8 | ||
|
|
0cfccdb0c7 | ||
|
|
657f9d1422 | ||
|
|
41962ed369 | ||
|
|
9119492f15 | ||
|
|
f7a39f487c | ||
|
|
166097e564 | ||
|
|
53f36a8ee6 | ||
|
|
6842d72a9c | ||
|
|
f34a527f61 | ||
|
|
b3f8a0edf3 | ||
|
|
df6ec2822f | ||
|
|
698c40ef9d | ||
|
|
5aaad5f492 | ||
|
|
df659d124d | ||
|
|
cecb07655a | ||
|
|
cdfb1b4bf1 | ||
|
|
90653775a9 | ||
|
|
27ad3d7eeb | ||
|
|
8fa5ecb81d | ||
|
|
c8364b43de | ||
|
|
06047005ef | ||
|
|
42d6cf66d3 | ||
|
|
8d6b599737 | ||
|
|
d7d037b46f | ||
|
|
d0cb7ba55b | ||
|
|
716d719d4c | ||
|
|
81d22e8f53 | ||
|
|
97541170ca | ||
|
|
5304682593 | ||
|
|
b8ea6d2aee | ||
|
|
1baab3bef5 | ||
|
|
286964cd6a | ||
|
|
a67ee0f7a2 | ||
|
|
6290ed52ff | ||
|
|
b74984dd50 | ||
|
|
dfadc7b704 | ||
|
|
1d2bf82461 | ||
|
|
1954468efc | ||
|
|
10546e57dd | ||
|
|
223655dfc4 | ||
|
|
2e8f1d439d | ||
|
|
91cb04265b | ||
|
|
e4c42ae786 | ||
|
|
fafed256a6 | ||
|
|
b9c2590151 | ||
|
|
c0f8224109 | ||
|
|
2bd38da4b0 | ||
|
|
ffb8350478 | ||
|
|
00ab2f2cba | ||
|
|
9263e3887e | ||
|
|
c86214345f | ||
|
|
696fb41c5b | ||
|
|
848c38907d | ||
|
|
20d7bf7525 | ||
|
|
fe44ecd8f0 | ||
|
|
8bbd4baa9a | ||
|
|
d55fe4b6ae | ||
|
|
44bdc521f7 | ||
|
|
481f432e27 | ||
|
|
0fd8c507bf | ||
|
|
33b24d6f2e | ||
|
|
ce465d4422 | ||
|
|
1679b2f14c | ||
|
|
d3465756f6 | ||
|
|
1310c92be7 | ||
|
|
e9a2f10900 | ||
|
|
05001e102e | ||
|
|
e9d0ac2aba | ||
|
|
f3a43a90d3 | ||
|
|
8a1b7710d7 | ||
|
|
00e68b195e | ||
|
|
6510aecfb4 | ||
|
|
662e5b67d5 | ||
|
|
953fe4d6e1 | ||
|
|
48034a5cc7 | ||
|
|
51d3e363e3 | ||
|
|
8caed9d66d | ||
|
|
8f2200777a | ||
|
|
b1b533c627 | ||
|
|
d241a996de | ||
|
|
5d64ebe1de | ||
|
|
dc692aa6f6 | ||
|
|
a9e51732db | ||
|
|
209eadcd2d | ||
|
|
7d3eabdee8 | ||
|
|
10f4096f11 | ||
|
|
52b127b9fb | ||
|
|
0f5ce05753 | ||
|
|
cf265732c7 | ||
|
|
98c01585b7 | ||
|
|
956a967047 | ||
|
|
8ad308d3e9 | ||
|
|
1c35ec6cd7 | ||
|
|
ce5adbd2c2 | ||
|
|
e1ff653ade | ||
|
|
d9b5bdada1 | ||
|
|
1878662a91 | ||
|
|
bf3dad63aa | ||
|
|
38b0984d33 | ||
|
|
41ad8f00eb | ||
|
|
982c0aaa77 | ||
|
|
5268bf900e | ||
|
|
12adc30ac8 | ||
|
|
7b27c0495e | ||
|
|
840cea5d6e | ||
|
|
91aee9cd51 | ||
|
|
928a75a365 | ||
|
|
e5e65431fd | ||
|
|
833520b13a | ||
|
|
56e461b76a | ||
|
|
b9f6c96d18 | ||
|
|
5c69853cd6 | ||
|
|
cc4dca69eb | ||
|
|
4a4ef7be5e | ||
|
|
f65fec27a2 | ||
|
|
47f7ec7631 | ||
|
|
b9ade75fec | ||
|
|
0fe7479752 | ||
|
|
a7eab7467f | ||
|
|
42b8898e8e | ||
|
|
ffe1213bf8 | ||
|
|
a3e7473df2 | ||
|
|
e9823023f4 | ||
|
|
6509da7555 | ||
|
|
bba429831c | ||
|
|
2035f38ab2 | ||
|
|
f6599ede0d | ||
|
|
978cb6ac20 | ||
|
|
d5b5eaccc2 | ||
|
|
7c432d2bd8 | ||
|
|
d353dc128f | ||
|
|
2b5fba1519 | ||
|
|
049d6c9683 | ||
|
|
71d24f98a8 | ||
|
|
1dbd9a3154 | ||
|
|
bfddd45e25 | ||
|
|
c9ca7fc0d2 | ||
|
|
a43da0c8c5 | ||
|
|
80749b3bdf | ||
|
|
86ff2cf820 | ||
|
|
94cd364a00 | ||
|
|
84e62824f6 | ||
|
|
d8f6d65525 | ||
|
|
8b8e088620 | ||
|
|
0f18d52f16 | ||
|
|
a1934e9d0e | ||
|
|
e46b92cc58 | ||
|
|
ebfcddbaed | ||
|
|
ee655f4d94 | ||
|
|
eac918d69b | ||
|
|
b65411740e | ||
|
|
61fa2b285e | ||
|
|
9f7584c385 | ||
|
|
69d84d775b | ||
|
|
7e913c08f8 | ||
|
|
6ef0cbb94f | ||
|
|
030861e5d1 | ||
|
|
9cd1d27a89 | ||
|
|
d122839eb7 | ||
|
|
dc1e6fb02b | ||
|
|
75fc0bce0f | ||
|
|
bf8be79b88 | ||
|
|
532494b12a | ||
|
|
fa384d4de0 | ||
|
|
474b1e0386 | ||
|
|
8592352c24 | ||
|
|
3e701449ff | ||
|
|
693f06d811 | ||
|
|
678a0ee944 | ||
|
|
980d73dc5a | ||
|
|
322ceb36ce | ||
|
|
8f1fb675aa | ||
|
|
0028c2f793 | ||
|
|
068d88c142 | ||
|
|
0f608bc497 | ||
|
|
8ec2b2d09b | ||
|
|
1313e15241 | ||
|
|
130464e797 | ||
|
|
728b61a0a4 | ||
|
|
1600bcd44d | ||
|
|
40fa750b4f | ||
|
|
669bfdd9b0 | ||
|
|
771675e826 | ||
|
|
84a33c743e | ||
|
|
3f524a6423 | ||
|
|
126a3363a3 | ||
|
|
eb15c443fc | ||
|
|
1daef79f80 | ||
|
|
7d6b7f434c | ||
|
|
4f83cd6528 | ||
|
|
96307ca9b4 | ||
|
|
989d449404 | ||
|
|
2f7bfdbd10 | ||
|
|
1e1cf14da2 | ||
|
|
6158742f80 | ||
|
|
3736d7b60b | ||
|
|
6729dea36f | ||
|
|
5a684c4553 | ||
|
|
c4b9f54b46 | ||
|
|
d569e41c58 | ||
|
|
5a7d5c6def | ||
|
|
9fc71e9076 | ||
|
|
a818556dd9 | ||
|
|
be2213e46e | ||
|
|
bb48fcf36a | ||
|
|
acd3ce00ea | ||
|
|
538b537cc5 | ||
|
|
17051894d0 | ||
|
|
0a085bf15e | ||
|
|
950007dd9c | ||
|
|
1d972af69d | ||
|
|
ce4db4f9f3 | ||
|
|
f3e61580bd | ||
|
|
77505daa85 | ||
|
|
94fb547fe2 | ||
|
|
72bc429f60 | ||
|
|
b546998b9b | ||
|
|
8523d3268a | ||
|
|
b414020bef | ||
|
|
a6973ab9b4 | ||
|
|
acb942f634 | ||
|
|
cee8c8773b | ||
|
|
e6edccad3a | ||
|
|
a3325c9fb4 | ||
|
|
03ae999a1a | ||
|
|
16d06aa112 | ||
|
|
4f728f8321 | ||
|
|
4e84229e82 | ||
|
|
7d4d7512e4 | ||
|
|
50b98a1878 | ||
|
|
4e45b11983 | ||
|
|
3c16648ad7 | ||
|
|
80655fe955 | ||
|
|
daa7b1d06b | ||
|
|
d8a14e77c3 | ||
|
|
e09f89d37b | ||
|
|
38edae7df7 | ||
|
|
5e8f4981a5 | ||
|
|
ef86d8c95c | ||
|
|
60bec8c020 | ||
|
|
f7e2d9bb47 | ||
|
|
ad71c427fa | ||
|
|
4a85cd76f6 | ||
|
|
3127808473 | ||
|
|
8788ae1a8e | ||
|
|
e070519f43 | ||
|
|
c430fcde1c | ||
|
|
0f49bbbeb2 | ||
|
|
abb85ccc86 | ||
|
|
bf0228b5c2 | ||
|
|
3a64dc7623 | ||
|
|
f22c3a518e | ||
|
|
2fcf990cee | ||
|
|
639e7ff997 | ||
|
|
4d6593642e | ||
|
|
9b1b6d02fd | ||
|
|
983b33867e | ||
|
|
29a1dc2249 | ||
|
|
699c047c7d | ||
|
|
ed3ae0da43 | ||
|
|
21c25bbb9d | ||
|
|
7951cc0c8a | ||
|
|
c2b56ded61 | ||
|
|
0afccc62ab | ||
|
|
5c1ecda0ca | ||
|
|
e7500417c8 | ||
|
|
174cd49f78 | ||
|
|
39682889f9 | ||
|
|
71cb60706b | ||
|
|
0ea7871e53 | ||
|
|
b36fa1d8f1 | ||
|
|
c0641eb3ad | ||
|
|
e9dd1c43c4 | ||
|
|
aa117ec4de | ||
|
|
4007df7f60 | ||
|
|
23aeb58eaa |
@@ -27,10 +27,11 @@ Use when:
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
|
||||
- Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search.
|
||||
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing.
|
||||
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through.
|
||||
- Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish.
|
||||
- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior.
|
||||
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
|
||||
- For regression provenance, if no blamed PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
|
||||
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops.
|
||||
- Stop as soon as the helper exits 0 with no accepted/actionable findings. Do not run an extra review just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
@@ -168,11 +169,12 @@ The helper:
|
||||
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
|
||||
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
|
||||
- writes only to stdout unless `--output` or `--json-output` is set
|
||||
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
|
||||
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion
|
||||
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
|
||||
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
|
||||
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine
|
||||
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine, unless streamed output or compact Codex activity has been visible recently
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
- exits nonzero when accepted/actionable findings are present
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@ import concurrent.futures
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
ENGINES = ("codex", "claude", "droid", "copilot")
|
||||
@@ -100,7 +102,18 @@ def run_with_heartbeat(
|
||||
input_text: str | None = None,
|
||||
label: str,
|
||||
heartbeat_seconds: int = 60,
|
||||
stream_output: bool = False,
|
||||
stream_display: Callable[[str, str], str | None] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if stream_output:
|
||||
return run_with_stream(
|
||||
args,
|
||||
cwd,
|
||||
input_text=input_text,
|
||||
label=label,
|
||||
heartbeat_seconds=heartbeat_seconds,
|
||||
stream_display=stream_display,
|
||||
)
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
@@ -124,6 +137,82 @@ def run_with_heartbeat(
|
||||
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def run_with_stream(
|
||||
args: list[str],
|
||||
cwd: Path,
|
||||
*,
|
||||
input_text: str | None,
|
||||
label: str,
|
||||
heartbeat_seconds: int,
|
||||
stream_display: Callable[[str, str], str | None] | None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
cwd=cwd,
|
||||
stdin=subprocess.PIPE if input_text is not None else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
events: queue.Queue[tuple[str, str | None]] = queue.Queue()
|
||||
stdout_parts: list[str] = []
|
||||
stderr_parts: list[str] = []
|
||||
|
||||
def read_stream(name: str, stream: Any) -> None:
|
||||
try:
|
||||
for line in iter(stream.readline, ""):
|
||||
events.put((name, line))
|
||||
finally:
|
||||
events.put((name, None))
|
||||
|
||||
def write_stdin() -> None:
|
||||
if proc.stdin is None or input_text is None:
|
||||
return
|
||||
try:
|
||||
proc.stdin.write(input_text)
|
||||
proc.stdin.close()
|
||||
except BrokenPipeError:
|
||||
return
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=read_stream, args=("stdout", proc.stdout), daemon=True),
|
||||
threading.Thread(target=read_stream, args=("stderr", proc.stderr), daemon=True),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
stdin_thread = threading.Thread(target=write_stdin, daemon=True)
|
||||
stdin_thread.start()
|
||||
|
||||
open_streams = 2
|
||||
while open_streams:
|
||||
try:
|
||||
name, line = events.get(timeout=heartbeat_seconds)
|
||||
except queue.Empty:
|
||||
elapsed = int(time.monotonic() - started)
|
||||
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
if line is None:
|
||||
open_streams -= 1
|
||||
continue
|
||||
if name == "stdout":
|
||||
stdout_parts.append(line)
|
||||
else:
|
||||
stderr_parts.append(line)
|
||||
display = stream_display(name, line) if stream_display else line
|
||||
if display:
|
||||
target = sys.stdout if name == "stdout" else sys.stderr
|
||||
target.write(display)
|
||||
target.flush()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
stdin_thread.join(timeout=1)
|
||||
returncode = proc.wait()
|
||||
return subprocess.CompletedProcess(args, returncode, "".join(stdout_parts), "".join(stderr_parts))
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> str:
|
||||
return run(["git", *args], repo, check=check).stdout
|
||||
|
||||
@@ -336,9 +425,11 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd.extend(["--model", args.model])
|
||||
if args.thinking:
|
||||
cmd.extend(["-c", f'model_reasoning_effort="{args.thinking}"'])
|
||||
cmd.append("exec")
|
||||
if args.stream_engine_output:
|
||||
cmd.append("--json")
|
||||
cmd.extend(
|
||||
[
|
||||
"exec",
|
||||
"--ephemeral",
|
||||
"-C",
|
||||
str(repo),
|
||||
@@ -351,7 +442,14 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
"-",
|
||||
]
|
||||
)
|
||||
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="codex")
|
||||
result = run_with_heartbeat(
|
||||
cmd,
|
||||
repo,
|
||||
input_text=prompt,
|
||||
label="codex",
|
||||
stream_output=args.stream_engine_output,
|
||||
stream_display=CodexStreamDisplay() if args.stream_engine_output else None,
|
||||
)
|
||||
try:
|
||||
output = output_path.read_text()
|
||||
finally:
|
||||
@@ -368,7 +466,7 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
"--print",
|
||||
"--no-session-persistence",
|
||||
"--output-format",
|
||||
"json",
|
||||
"stream-json" if args.stream_engine_output else "json",
|
||||
"--json-schema",
|
||||
json.dumps(SCHEMA),
|
||||
]
|
||||
@@ -376,11 +474,20 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd.extend(["--allowedTools", claude_allowed_tools(args)])
|
||||
else:
|
||||
cmd.extend(["--tools", ""])
|
||||
if args.stream_engine_output:
|
||||
cmd.append("--verbose")
|
||||
if args.model:
|
||||
cmd.extend(["--model", args.model])
|
||||
if args.thinking:
|
||||
cmd.extend(["--effort", args.thinking])
|
||||
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="claude")
|
||||
result = run_with_heartbeat(
|
||||
cmd,
|
||||
repo,
|
||||
input_text=prompt,
|
||||
label="claude",
|
||||
stream_output=args.stream_engine_output,
|
||||
stream_display=ClaudeStreamDisplay() if args.stream_engine_output else None,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"claude engine failed ({result.returncode})\n{result.stderr or result.stdout}")
|
||||
return result.stdout
|
||||
@@ -405,7 +512,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd.extend(["--model", args.model])
|
||||
if not args.tools:
|
||||
cmd.extend(["--disabled-tools", "*"])
|
||||
result = run_with_heartbeat(cmd, repo, label="droid")
|
||||
result = run_with_heartbeat(cmd, repo, label="droid", stream_output=args.stream_engine_output)
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"droid engine failed ({result.returncode})\n{result.stderr or result.stdout}")
|
||||
@@ -430,7 +537,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
"--output-format",
|
||||
"json",
|
||||
"--stream",
|
||||
"off",
|
||||
"on" if args.stream_engine_output else "off",
|
||||
"--no-ask-user",
|
||||
"--disable-builtin-mcps",
|
||||
]
|
||||
@@ -447,12 +554,142 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
)
|
||||
if args.web_search:
|
||||
cmd.append("--allow-all-urls")
|
||||
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot")
|
||||
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot", stream_output=args.stream_engine_output)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"copilot engine failed ({result.returncode})\n{result.stderr or result.stdout}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
class CodexStreamDisplay:
|
||||
def __init__(self, *, activity_seconds: int = 20) -> None:
|
||||
self.activity_seconds = activity_seconds
|
||||
self.hidden_events = 0
|
||||
self.last_visible = time.monotonic()
|
||||
|
||||
def __call__(self, name: str, line: str) -> str | None:
|
||||
if name != "stdout":
|
||||
return line
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return self.visible(line)
|
||||
event_type = event.get("type")
|
||||
if event_type == "thread.started":
|
||||
return self.visible(f"codex thread: {event.get('thread_id', '<unknown>')}\n")
|
||||
if event_type == "turn.started":
|
||||
return self.visible("codex turn started\n")
|
||||
if event_type == "turn.completed":
|
||||
usage = event.get("usage")
|
||||
message = format_codex_usage(usage) + "\n" if isinstance(usage, dict) else "codex turn completed\n"
|
||||
return self.visible(self.flush_hidden() + message)
|
||||
item = event.get("item")
|
||||
if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
||||
return self.visible(self.flush_hidden() + item["text"].rstrip() + "\n")
|
||||
return self.hidden_activity()
|
||||
|
||||
def hidden_activity(self) -> str | None:
|
||||
self.hidden_events += 1
|
||||
if time.monotonic() - self.last_visible < self.activity_seconds:
|
||||
return None
|
||||
return self.visible(self.flush_hidden())
|
||||
|
||||
def flush_hidden(self) -> str:
|
||||
if not self.hidden_events:
|
||||
return ""
|
||||
count = self.hidden_events
|
||||
self.hidden_events = 0
|
||||
return f"codex activity: {count} hidden tool/status events\n"
|
||||
|
||||
def visible(self, text: str) -> str:
|
||||
self.last_visible = time.monotonic()
|
||||
return text
|
||||
|
||||
|
||||
class ClaudeStreamDisplay:
|
||||
def __init__(self, *, activity_seconds: int = 20) -> None:
|
||||
self.activity_seconds = activity_seconds
|
||||
self.hidden_events = 0
|
||||
self.last_visible = time.monotonic()
|
||||
self.started = False
|
||||
|
||||
def __call__(self, name: str, line: str) -> str | None:
|
||||
if name != "stdout":
|
||||
return line
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return self.visible(line)
|
||||
event_type = event.get("type")
|
||||
if event_type == "system" and not self.started:
|
||||
self.started = True
|
||||
return self.visible("claude turn started\n")
|
||||
if event_type == "assistant":
|
||||
return self.assistant_message(event)
|
||||
if event_type == "result":
|
||||
return self.visible(self.flush_hidden() + self.result_summary(event))
|
||||
return self.hidden_activity()
|
||||
|
||||
def assistant_message(self, event: dict[str, Any]) -> str | None:
|
||||
message = event.get("message")
|
||||
if not isinstance(message, dict):
|
||||
return self.hidden_activity()
|
||||
chunks: list[str] = []
|
||||
for item in message.get("content", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
||||
chunks.append(item["text"].rstrip())
|
||||
if chunks:
|
||||
return self.visible(self.flush_hidden() + "\n".join(chunks) + "\n")
|
||||
return self.hidden_activity()
|
||||
|
||||
def result_summary(self, event: dict[str, Any]) -> str:
|
||||
usage = event.get("usage")
|
||||
fields: list[str] = []
|
||||
if isinstance(usage, dict):
|
||||
for key in (
|
||||
"input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"output_tokens",
|
||||
):
|
||||
value = usage.get(key)
|
||||
if isinstance(value, int):
|
||||
fields.append(f"{key}={value}")
|
||||
cost = event.get("total_cost_usd")
|
||||
if isinstance(cost, (int, float)) and not isinstance(cost, bool):
|
||||
fields.append(f"cost_usd={cost:.6f}")
|
||||
return "claude usage: " + " ".join(fields) + "\n" if fields else "claude turn completed\n"
|
||||
|
||||
def hidden_activity(self) -> str | None:
|
||||
self.hidden_events += 1
|
||||
if time.monotonic() - self.last_visible < self.activity_seconds:
|
||||
return None
|
||||
return self.visible(self.flush_hidden())
|
||||
|
||||
def flush_hidden(self) -> str:
|
||||
if not self.hidden_events:
|
||||
return ""
|
||||
count = self.hidden_events
|
||||
self.hidden_events = 0
|
||||
return f"claude activity: {count} hidden tool/status events\n"
|
||||
|
||||
def visible(self, text: str) -> str:
|
||||
self.last_visible = time.monotonic()
|
||||
return text
|
||||
|
||||
|
||||
def format_codex_usage(usage: dict[str, Any]) -> str:
|
||||
fields = [
|
||||
"input_tokens",
|
||||
"cached_input_tokens",
|
||||
"output_tokens",
|
||||
"reasoning_output_tokens",
|
||||
]
|
||||
parts = [f"{field}={usage[field]}" for field in fields if isinstance(usage.get(field), int)]
|
||||
return "codex usage: " + " ".join(parts) if parts else "codex usage: unavailable"
|
||||
|
||||
|
||||
def claude_allowed_tools(args: argparse.Namespace) -> str:
|
||||
tools = [tool.strip() for tool in args.claude_allowed_tools.split(",") if tool.strip()]
|
||||
if not args.web_search:
|
||||
@@ -490,7 +727,7 @@ def extract_json(text: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
|
||||
candidates: list[str] = []
|
||||
candidates: list[str | dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -509,7 +746,13 @@ def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
|
||||
candidates.append(data["content"])
|
||||
if isinstance(event.get("result"), str):
|
||||
candidates.append(event["result"])
|
||||
if isinstance(event.get("structured_output"), dict):
|
||||
candidates.append(event["structured_output"])
|
||||
for candidate in reversed(candidates):
|
||||
if isinstance(candidate, dict):
|
||||
if "findings" in candidate:
|
||||
return candidate
|
||||
continue
|
||||
parsed = parse_json_candidate(candidate)
|
||||
if isinstance(parsed, dict) and "findings" in parsed:
|
||||
return parsed
|
||||
@@ -673,6 +916,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--dataset", action="append", help="Extra evidence file to include in the review bundle.")
|
||||
parser.add_argument("--output", help="Write human output to a file as well as stdout.")
|
||||
parser.add_argument("--json-output", help="Write validated structured review JSON.")
|
||||
parser.add_argument(
|
||||
"--stream-engine-output",
|
||||
action="store_true",
|
||||
default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1",
|
||||
help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
|
||||
)
|
||||
parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
|
||||
parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
|
||||
parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")
|
||||
|
||||
@@ -160,9 +160,14 @@ pnpm crabbox:run -- \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
--shell -- \
|
||||
"pnpm test"
|
||||
"pnpm verify"
|
||||
```
|
||||
|
||||
Use `pnpm verify` when you need check plus full Vitest proof. It emits
|
||||
`CRABBOX_PHASE:check` and `CRABBOX_PHASE:test`, making Crabbox summaries show
|
||||
which stage failed. Use plain `pnpm test` only when check proof is already
|
||||
covered or intentionally skipped.
|
||||
|
||||
Focused rerun:
|
||||
|
||||
```sh
|
||||
|
||||
87
.agents/skills/openclaw-changelog-update/SKILL.md
Normal file
87
.agents/skills/openclaw-changelog-update/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: openclaw-changelog-update
|
||||
description: Regenerate OpenClaw release changelog sections from git history before beta or stable releases.
|
||||
---
|
||||
|
||||
# OpenClaw Changelog Update
|
||||
|
||||
Use this for release changelog rewrites and GitHub release-note source text.
|
||||
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
|
||||
ordering, and audit discipline.
|
||||
|
||||
## Goal
|
||||
|
||||
Rewrite the target `CHANGELOG.md` version section from history, not from stale
|
||||
draft notes. Produce user-facing release notes sorted by user interest while
|
||||
preserving issue/PR refs and thanks.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Target base version: `YYYY.M.D`, without beta suffix.
|
||||
- Base tag: last reachable shipped release tag, usually the previous stable or
|
||||
the previous beta train requested by the operator.
|
||||
- Target ref: exact branch/SHA being released.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Start on `main` before branching when possible:
|
||||
- `git fetch --tags origin`
|
||||
- `git pull --ff-only`
|
||||
- confirm clean `git status -sb`
|
||||
2. Audit history, including direct commits:
|
||||
- `git log --first-parent --date=iso-strict --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
|
||||
- `git log --first-parent --grep='(#' --date=short --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
|
||||
- also inspect `--since='24 hours ago'` when main moved during the release.
|
||||
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
|
||||
infer notes from subject, body, touched files, tests, and nearby commits.
|
||||
4. Rewrite one stable-base section only:
|
||||
- use `## YYYY.M.D`
|
||||
- do not create beta-specific headings
|
||||
- do not leave a stale `## Unreleased` section above the target release
|
||||
- if `Unreleased` contains release-bound notes, fold them into the target
|
||||
section instead of deleting them
|
||||
5. Section shape:
|
||||
- `### Highlights`: 5-8 bullets, broad user wins first
|
||||
- `### Changes`: new capabilities and behavior changes
|
||||
- `### Fixes`: user-facing fixes first, grouped by impact and surface
|
||||
6. Preserve attribution:
|
||||
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
|
||||
- do not add GHSA references, advisory IDs, or security advisory slugs to
|
||||
changelog entries or GitHub release-note text unless explicitly requested
|
||||
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
|
||||
- if grouping multiple entries, carry all relevant refs and thanks into the
|
||||
grouped bullet
|
||||
7. Sorting preference:
|
||||
- security/data-loss and content-boundary fixes
|
||||
- transcript/replay/reply delivery correctness
|
||||
- channels and mobile integrations
|
||||
- providers/Codex/local model reliability
|
||||
- install/update/release path reliability
|
||||
- performance and observability
|
||||
- docs and contributor-only/internal details last or omitted
|
||||
8. Keep bullets single-line unless existing file style forces otherwise. Avoid
|
||||
internal release-process noise unless it changes user install/update safety.
|
||||
9. Check release-note side conditions:
|
||||
- inspect `src/plugins/compat/registry.ts`
|
||||
- inspect `src/commands/doctor/shared/deprecation-compat.ts`
|
||||
- if any compatibility `removeAfter` is on/before release date, resolve it
|
||||
or explicitly record the blocker before shipping
|
||||
10. Validate and ship:
|
||||
- `git diff --check`
|
||||
- for docs/changelog-only changes, no broad tests are required
|
||||
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
|
||||
- push, pull/rebase if needed, then branch/rebase release from latest `main`
|
||||
|
||||
## Quota / API Outage Rule
|
||||
|
||||
If GitHub API quota is exhausted, do not idle. Continue work that does not need
|
||||
GitHub API:
|
||||
|
||||
- local changelog rewrite and release-note extraction
|
||||
- local pretag checks and package/build sanity
|
||||
- git push/tag checks over git protocol
|
||||
- npm registry `npm view` checks
|
||||
- exact workflow-dispatch command preparation
|
||||
|
||||
Only GitHub Release creation, workflow dispatch, run polling, artifact download,
|
||||
and issue/PR mutation need API quota.
|
||||
@@ -168,13 +168,22 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
|
||||
|
||||
- Start every PR review with 1-3 plain sentences explaining what the change does and why it matters. Put this before `Findings`.
|
||||
- Then list findings first. If none, say `No blocking findings` or `No findings`.
|
||||
- Show size near the top as `LOC: +<additions>/-<deletions> (<changedFiles> files)`, using live PR stats or local diff stats.
|
||||
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, provenance for regressions when traceable, and best-fix verdict.
|
||||
- For bug/regression fixes, include a compact `Provenance:` line after cause/root-cause when a bounded history pass can identify it. Use `git log -S/-G`, `git blame`, linked PRs/issues, and tests.
|
||||
- Provenance must separate roles when they differ: blamed code author username, blamed PR merger/committer username, current PR author username, PR number, and date. Do not collapse them into one "introduced by" actor.
|
||||
- Provenance must separate roles when they differ: blamed code author username, blamed PR author username, blamed PR merger/committer username, automerge trigger when known, current PR author username, PR number, and date. Do not collapse them into one "introduced by" actor.
|
||||
- If the blamed PR was merged by `clawsweeper[bot]` or another automation, identify the human trigger when practical. Check live PR timeline/comments first; if rate-limited, use gitcrawl/cache or public PR HTML. Look for maintainer command comments such as `@clawsweeper automerge`, `/landpr`, labels/events that armed automerge, and ClawSweeper status comments. Report `automerge triggered by @login`; if not found, say trigger unknown rather than naming the bot as the human decision-maker.
|
||||
- For any confirmed bug, run `git blame` on the implicated line(s) after identifying the root cause. Report who broke it as the blamed PR merger/committer, and also name the blamed code author. Include the PR number. If no PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
|
||||
- Phrase provenance as `introduced by`, `made visible by`, or `carried forward by`, with confidence (`clear`, `likely`, `unknown`). If unclear, say what evidence is missing instead of guessing. For features, docs, and refactors, use `Provenance: N/A` or omit it when no broken behavior is being fixed.
|
||||
- Keep summaries compact, but include enough proof that the verdict is auditable without rereading the PR.
|
||||
|
||||
LOC proof:
|
||||
|
||||
```bash
|
||||
gh pr view <number> --json additions,deletions,changedFiles \
|
||||
--jq '"LOC: +\(.additions)/-\(.deletions) (\(.changedFiles) files)"'
|
||||
```
|
||||
|
||||
## Read beyond the diff
|
||||
|
||||
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
|
||||
@@ -194,7 +203,7 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
|
||||
- Before landing, require:
|
||||
1. symptom evidence such as a repro, logs, or a failing test
|
||||
2. a verified root cause in code with file/line
|
||||
3. blame-backed provenance for regressions when traceable, including blamed PR merger and date, or commit SHA/date when no PR is traceable
|
||||
3. blame-backed provenance for regressions when traceable, including blamed PR merger and automerge trigger when known, or commit SHA/date when no PR is traceable
|
||||
4. a fix that touches the implicated code path
|
||||
5. a regression test when feasible, or explicit manual verification plus a reason no test was added
|
||||
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
|
||||
|
||||
@@ -68,6 +68,7 @@ scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
|
||||
pnpm changed:lanes --json
|
||||
pnpm check:changed # changed typecheck/lint/guards; no Vitest
|
||||
pnpm test:changed # cheap smart changed Vitest targets
|
||||
pnpm verify # full check, then full Vitest
|
||||
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
|
||||
pnpm test <path-or-filter> -- --reporter=verbose
|
||||
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
|
||||
@@ -89,6 +90,8 @@ status checks or install reconciliation in a linked worktree.
|
||||
- `pnpm check` and `pnpm check:changed` do not run Vitest tests. They are for
|
||||
typecheck, lint, and guard proof.
|
||||
- `pnpm test` and `pnpm test:changed` run Vitest tests.
|
||||
- `pnpm verify` runs `pnpm check`, then `pnpm test`, with Crabbox phase markers
|
||||
so remote summaries show which half failed.
|
||||
- `pnpm test:changed` is intentionally cheap by default: direct test edits,
|
||||
sibling tests, explicit source mappings, and import-graph dependents.
|
||||
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` is the explicit broad
|
||||
|
||||
@@ -70,7 +70,8 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
release blocker unless the operator waives it or the data clearly proves
|
||||
infrastructure noise.
|
||||
- Generate the changelog before version/tag preparation so the top changelog
|
||||
section is deduped and ordered by user impact.
|
||||
section is deduped and ordered by user impact. Use
|
||||
`$openclaw-changelog-update` for the rewrite.
|
||||
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
|
||||
stable base version section, for example `v2026.4.20-beta.1` uses
|
||||
`## 2026.4.20` release notes.
|
||||
|
||||
2
.github/actions/setup-node-env/action.yml
vendored
2
.github/actions/setup-node-env/action.yml
vendored
@@ -55,7 +55,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g bun@1.3.13
|
||||
npm install -g bun@1.3.14
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
|
||||
@@ -62,6 +62,12 @@ runs:
|
||||
;;
|
||||
esac
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "$package_manager" --activate; then
|
||||
exit 0
|
||||
fi
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
corepack prepare "$package_manager" --activate
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
|
||||
@@ -8,7 +8,10 @@ openclaw_node_version_matches() {
|
||||
fi
|
||||
case "$requested" in
|
||||
*x)
|
||||
[[ "${actual%%.*}" == "${requested%%.*}" ]]
|
||||
[[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1
|
||||
if [[ "${requested%%.*}" == "22" ]]; then
|
||||
openclaw_node_version_at_least "$actual" "22.19.0"
|
||||
fi
|
||||
;;
|
||||
*.*.*)
|
||||
[[ "$actual" == "$requested" ]]
|
||||
@@ -22,6 +25,28 @@ openclaw_node_version_matches() {
|
||||
esac
|
||||
}
|
||||
|
||||
openclaw_node_version_at_least() {
|
||||
local actual="$1"
|
||||
local minimum="$2"
|
||||
local actual_major actual_minor actual_patch minimum_major minimum_minor minimum_patch
|
||||
IFS=. read -r actual_major actual_minor actual_patch <<< "$actual"
|
||||
IFS=. read -r minimum_major minimum_minor minimum_patch <<< "$minimum"
|
||||
actual_minor="${actual_minor:-0}"
|
||||
actual_patch="${actual_patch:-0}"
|
||||
minimum_minor="${minimum_minor:-0}"
|
||||
minimum_patch="${minimum_patch:-0}"
|
||||
|
||||
if (( actual_major != minimum_major )); then
|
||||
(( actual_major > minimum_major ))
|
||||
return
|
||||
fi
|
||||
if (( actual_minor != minimum_minor )); then
|
||||
(( actual_minor > minimum_minor ))
|
||||
return
|
||||
fi
|
||||
(( actual_patch >= minimum_patch ))
|
||||
}
|
||||
|
||||
openclaw_active_node_version() {
|
||||
node -p 'process.versions.node' 2>/dev/null || true
|
||||
}
|
||||
@@ -57,6 +82,9 @@ openclaw_find_toolcache_node() {
|
||||
"/Users/runner/hostedtoolcache" \
|
||||
"/c/hostedtoolcache/windows"
|
||||
do
|
||||
if [[ ! -d "$root" && "$root" == *\\* ]] && command -v cygpath >/dev/null 2>&1; then
|
||||
root="$(cygpath -u "$root" 2>/dev/null || printf '%s' "$root")"
|
||||
fi
|
||||
if [[ -d "$root/node" ]]; then
|
||||
roots+=("$root/node")
|
||||
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
|
||||
@@ -108,6 +136,9 @@ openclaw_node_download_platform() {
|
||||
Linux:aarch64 | Linux:arm64) printf 'linux-arm64\n' ;;
|
||||
Darwin:x86_64) printf 'darwin-x64\n' ;;
|
||||
Darwin:arm64) printf 'darwin-arm64\n' ;;
|
||||
MINGW*:x86_64 | MSYS*:x86_64 | CYGWIN*:x86_64 | MINGW*:AMD64 | MSYS*:AMD64 | CYGWIN*:AMD64)
|
||||
printf 'win-x64\n'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
@@ -120,8 +151,24 @@ openclaw_download_node() {
|
||||
version="$(openclaw_resolve_node_download_version "$requested_node")"
|
||||
platform="$(openclaw_node_download_platform)" || return 1
|
||||
install_root="${RUNNER_TEMP:-/tmp}/openclaw-node-${version}-${platform}"
|
||||
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
|
||||
mkdir -p "$install_root"
|
||||
if [[ "$platform" == win-* ]]; then
|
||||
local archive_path
|
||||
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.zip"
|
||||
archive_path="${RUNNER_TEMP:-/tmp}/node-${version}-${platform}.zip"
|
||||
echo "Downloading Node ${version} from ${archive_url}"
|
||||
curl -fsSL "$archive_url" -o "$archive_path"
|
||||
if command -v powershell.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2>&1; then
|
||||
powershell.exe -NoLogo -NoProfile -Command \
|
||||
"Expand-Archive -LiteralPath '$(cygpath -w "$archive_path")' -DestinationPath '$(cygpath -w "$install_root")' -Force"
|
||||
else
|
||||
unzip -q "$archive_path" -d "$install_root"
|
||||
fi
|
||||
openclaw_prepend_node_bin "$install_root/node-${version}-${platform}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
|
||||
echo "Downloading Node ${version} from ${archive_url}"
|
||||
curl -fsSL "$archive_url" | tar -xJ -C "$install_root" --strip-components=1
|
||||
openclaw_prepend_node_bin "$install_root/bin"
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -202,6 +202,7 @@ jobs:
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
|
||||
{ check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" },
|
||||
);
|
||||
} else {
|
||||
if (runNodeFastCiRouting) {
|
||||
@@ -683,7 +684,7 @@ jobs:
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }}
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
env:
|
||||
@@ -704,6 +705,9 @@ jobs:
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
bun-launcher)
|
||||
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported checks-fast task: $TASK" >&2
|
||||
exit 1
|
||||
@@ -1507,7 +1511,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
env:
|
||||
REQUESTED_NODE_VERSION: "24.x"
|
||||
REQUESTED_NODE_VERSION: "22.x"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
|
||||
@@ -1516,7 +1520,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: 24.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
|
||||
17
.github/workflows/crabbox-hydrate.yml
vendored
17
.github/workflows/crabbox-hydrate.yml
vendored
@@ -72,7 +72,24 @@ jobs:
|
||||
echo "PNPM_HOME=$PNPM_HOME"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
package_manager="$(node -e "const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); process.stdout.write(pkg.packageManager || '')")"
|
||||
case "$package_manager" in
|
||||
pnpm@*) ;;
|
||||
*)
|
||||
echo "::error::Expected packageManager to pin pnpm, got '${package_manager:-<empty>}'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
corepack enable --install-directory "$PNPM_HOME"
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "$package_manager" --activate; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" = 3 ]; then
|
||||
corepack prepare "$package_manager" --activate
|
||||
fi
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
|
||||
echo "$node_bin" >> "$GITHUB_PATH"
|
||||
|
||||
@@ -521,7 +521,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
echo "live-cache attempt ${attempt}/2"
|
||||
if timeout --kill-after=30s 8m pnpm test:live:cache; then
|
||||
if timeout --foreground --kill-after=30s 8m pnpm test:live:cache; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "2" ]]; then
|
||||
@@ -1434,7 +1434,7 @@ jobs:
|
||||
fi
|
||||
echo "Validating Docker E2E package tarball: $target"
|
||||
started_at="$(date +%s)"
|
||||
timeout --kill-after=30s 5m node scripts/check-openclaw-package-tarball.mjs "$target"
|
||||
timeout --foreground 5m node scripts/check-openclaw-package-tarball.mjs "$target"
|
||||
finished_at="$(date +%s)"
|
||||
echo "Docker E2E package tarball validation finished in $((finished_at - started_at))s."
|
||||
digest="$(sha256sum "$target" | awk '{print $1}')"
|
||||
@@ -1778,7 +1778,7 @@ jobs:
|
||||
|
||||
- name: Run Docker live model sweep
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
|
||||
validate_live_models_docker_targeted:
|
||||
name: Docker live models (selected providers)
|
||||
@@ -1953,7 +1953,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Run Docker live model sweep
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
|
||||
validate_live_provider_suites:
|
||||
needs: validate_selected_ref
|
||||
@@ -2289,32 +2289,32 @@ jobs:
|
||||
include:
|
||||
- suite_id: live-gateway-docker
|
||||
label: Docker live gateway OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
- suite_id: live-gateway-anthropic-docker
|
||||
label: Docker live gateway Anthropic
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-google-docker
|
||||
label: Docker live gateway Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-advisory-docker-deepseek-fireworks
|
||||
suite_group: live-gateway-advisory-docker
|
||||
label: Docker live gateway advisory DeepSeek/Fireworks
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2322,7 +2322,7 @@ jobs:
|
||||
- suite_id: live-gateway-advisory-docker-opencode-openrouter
|
||||
suite_group: live-gateway-advisory-docker
|
||||
label: Docker live gateway advisory OpenCode/OpenRouter
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2330,32 +2330,32 @@ jobs:
|
||||
- suite_id: live-gateway-advisory-docker-xai-zai
|
||||
suite_group: live-gateway-advisory-docker
|
||||
label: Docker live gateway advisory xAI/Z.ai
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
profiles: full
|
||||
- suite_id: live-cli-backend-docker
|
||||
label: Docker live CLI backend
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
|
||||
timeout_minutes: 50
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-acp-bind-docker
|
||||
label: Docker live ACP bind
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
|
||||
timeout_minutes: 50
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-codex-harness-docker
|
||||
label: Docker live Codex harness
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-subagent-announce-docker
|
||||
label: Docker live subagent announce
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
|
||||
timeout_minutes: 25
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
@@ -146,6 +146,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
|
||||
- External boundaries: prefer `zod` or existing schema helpers.
|
||||
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
|
||||
- Cross-function state: when valid combos matter, return a closed mode/result shape. Avoid parallel nullable fields or derived booleans that callers must keep in sync; make impossible states unrepresentable.
|
||||
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
|
||||
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
|
||||
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
|
||||
|
||||
202
CHANGELOG.md
202
CHANGELOG.md
@@ -2,10 +2,31 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
## 2026.5.26
|
||||
|
||||
### Highlights
|
||||
|
||||
- Faster Gateway and replies: startup avoids repeated plugin, channel, session, usage-cost, warning, scheduled-service, and filesystem scans; visible replies separate user-facing sends from slower follow-up work; Gateway runtime/session caches churn less under load.
|
||||
- Transcripts are core: transcript-backed meeting summaries, source-provider chunks, cleaned user turns, media provenance, Codex mirrors, WebChat replies, and CLI/TUI replay now use one more reliable transcript path.
|
||||
- More channels are production-ready: Telegram keeps typing/progress context and forum topics, iMessage handles attachment roots, remote media staging, and duplicate local Messages sources, WhatsApp restores group/media behavior, Discord improves voice playback and model picking, and Signal/iMessage/WhatsApp get reaction approvals.
|
||||
- Better voice and Talk: realtime Talk runs can be inspected, steered, cancelled, or followed up from Web UI and Discord voice; wake-name handling is more tolerant without letting ambient speech trigger agents.
|
||||
- Safer content boundaries: Browser snapshot reads honor SSRF policy, system-event text cannot spoof nested prompt markers, fetched file text is wrapped as external content, ClickClack inbound sender allowlists run before agent dispatch, stale device tokens are rejected, and serialized tool-call text is scrubbed from replies.
|
||||
- Providers, Codex, and local models are steadier: named auth profiles, OpenAI sampling params, Codex app-server resume/timeout/usage-limit recovery, dynamic tool-schema guards, xAI usage-limit surfacing, Ollama top-p normalization, and local approval resolution reduce provider-specific dead ends.
|
||||
- More reliable install/update/release paths: Alpine installs, trusted runtime fallback roots, stable update channels, Docker/package timeouts, Windows/macOS proof lanes, Testbox/Crabbox delegation, plugin publish checks, and macOS runner bootstraps all got hardened.
|
||||
- Better observability: Activity tab, gateway secret-prep traces, tool/model stream progress, explicit fast-mode status, systemd Gateway hygiene, OpenTelemetry LLM spans, release performance evidence, and richer telemetry signals make failures easier to inspect.
|
||||
|
||||
### Changes
|
||||
|
||||
- Transcripts: add core transcript capture and source-provider support for transcript-backed meeting summaries, including the renamed Transcripts docs, CLI surface, source-provider chunks, and cleaned user-turn persistence.
|
||||
- Auth: add named model login profiles and supported credential migration for Hermes, OpenCode, and Codex auth profiles, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
|
||||
- Diagnostics: trace gateway secret preparation, classify skill/tool usage, surface model stream progress, add OpenTelemetry LLM content spans, and expose alertable telemetry for blocked tools, failover, stale sessions, liveness, oversized payloads, and webhook ingress. (#83019, #80370, #86191)
|
||||
- Channels: add Signal reaction approvals, iMessage thumb approval reactions, and WhatsApp thumb approval reaction support so mobile approval flows work without textual `/approve` commands. (#85894, #85952, #85477)
|
||||
- Agents/API: forward OpenAI sampling params through the Gateway and expose estimated context-budget status for active agent runs. (#84094)
|
||||
- TUI/status: queue prompts submitted while an agent is busy and show explicit fast-mode state plus richer systemd Gateway hygiene in status output. (#86722, #87115, #86976)
|
||||
- Exec approvals: hide durable approval actions that are unavailable for the current prompt and keep approval runtime tokens local-only so stale prompts cannot offer misleading controls. (#86270, #86359)
|
||||
- Plugin SDK: add reaction approval helpers and keep diagnostic event root exports discoverable across function-name and alias-bound module graphs. (#86735, #87084)
|
||||
- Android/iOS: add the Android pair-new-gateway action and improve mobile Talk mode surfaces, including iOS realtime Talk mode and Android offline voice/gateway recovery. (#86798, #86355) Thanks @ngutman.
|
||||
- Performance: cache plugin metadata snapshots, package realpaths, stable gateway metadata, model cost indexes, channel resolution, usage-cost indexes, and session/auth hot-path facts so common Gateway and reply paths do less rediscovery. (#84649, #85843, #86517, #86678)
|
||||
- Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.
|
||||
- Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.
|
||||
- Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.
|
||||
@@ -13,15 +34,43 @@ Docs: https://docs.openclaw.ai
|
||||
- Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.
|
||||
- Cron: default `cron.maxConcurrentRuns` to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.
|
||||
- QA-Lab: add `qa coverage --match <query>` so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.
|
||||
- Discord/model picker: surface an alpha-bucket select (e.g. `A–G (12) · H–N (18) · O–Z (5)`) when the provider list or a provider's model list exceeds 25 items, so configs with `provider/*` wildcards stay one click from the right page instead of paginating through prev/next; falls back to numeric chunks when every item shares the same first letter.
|
||||
- Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.
|
||||
- Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)
|
||||
- Migrate: import supported Hermes, OpenCode, and Codex auth credentials into OpenClaw auth profiles when credential migration is selected, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
|
||||
- iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.
|
||||
- Media: replace the Sharp image backend with Photon for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
|
||||
- Media: replace the Sharp image backend with Rastermill for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
|
||||
- Codex: update the bundled Codex CLI to 0.134.0 and keep native compaction disabled for budget-triggered app-server turns so OpenClaw owns the recovery boundary. (#86772)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Memory/security: reject prompt-like text submitted through the explicit `memory_store` tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142)
|
||||
- Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack `allowFrom` sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps.
|
||||
- Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart.
|
||||
- TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976)
|
||||
- Plugin commands/SDK: preserve plugin LLM command auth, keep `onDiagnosticEvent` exports discoverable through `Function.name`, stabilize diagnostic event root aliases, correlate pathless read diagnostics, suppress transient runner failures in channel command paths, and repair local approval resolution. (#85936, #87084, #86977, #87069, #86771)
|
||||
- Codex/providers: keep WebChat delivery hints out of user prompts, avoid false queued-terminal idle timeouts, share the native hook relay registry, quarantine unsupported dynamic tool schemas, preserve Claude resumed-session system prompts, normalize greedy Ollama `top_p`, preserve per-agent thinking defaults for ingress runs, and avoid native compaction takeover on budget-triggered Codex turns. (#87096, #73950, #87049, #86689, #86772)
|
||||
- Gateway/perf/release: reuse startup-warning metadata and prepared auth stores, defer warning and scheduled-service fallback imports, trim Gateway session/startup/runtime CPU churn, skip duplicate turn session touches, stop chat timeout fallback cascades, drop stale subagent announce history, bound benchmark/watch/kitchen-sink teardown waits, bound macOS/package/onboarding/plugin smoke commands, bound install finalization probes, resolve Parallels npm-update commands from guest `PATH`, and bootstrap raw AWS macOS Node/pnpm commands through `/usr/bin/env`. (#86997)
|
||||
- Reply/perf: reduce visible reply delivery latency by preserving Telegram typing/progress context, lazy-loading slash-command startup metadata, avoiding hot-path model hydration, flag-gating Codex profiler timing, deferring context compaction maintenance, and tracking delivery timing. (#86989, #86990, #86991, #86992, #86993, #86994) Thanks @keshavbotagent.
|
||||
- Reply/source delivery: keep TUI, Control UI, media, TTS, transcript, and Codex source-reply finals live without duplicate terminal events or stale replay artifacts.
|
||||
- Agents/replay: repair legacy tool results before replay, preserve `sessions_spawn` transcript payloads, restore current guard checks, stage sandboxed workspace media, and keep duplicate transcripts tool display metadata from reappearing. (#82203, #86934, #87025) Thanks @martingarramon, @vincentkoc, and @joshavant.
|
||||
- Agents/hooks/subagents: enforce default hook agent allowlists, recover failed subagent lifecycle completions, and keep node task lifecycle cleanup from closing the Gateway listener. (#86101)
|
||||
- Codex: project newer OpenClaw chat history into resumed app-server threads and keep Codex turn timeouts inside the Codex runtime boundary so timeouts do not poison shared app-server clients or fall through to unrelated provider fallback. (#86677, #86476) Thanks @TurboTheTurtle and @pashpashpash.
|
||||
- Config/doctor/update: narrow profiled tool-section doctor repair, keep runtime-injected legacy web-search provider config out of user-authored config validation, and keep prerelease tags excluded from stable updater resolution. (#87030, #86818, #86559) Thanks @joshavant, @luoyanglang, and @stevenepalmer.
|
||||
- CLI/Windows: add a Windows-only stack-size respawn for stack-heavy startup paths, default CLI logs to local timestamps, and validate timeout/banner TTY state more strictly. (#87031, #85387) Thanks @giodl73-repo and @vincentkoc.
|
||||
- Locking/security: require owner identity proof before stale plugin lock removal, memoize session lock owner arguments, and avoid writing default exec approval stores unless policy state actually changed. (#86814, #86964) Thanks @Alix-007 and @vincentkoc.
|
||||
- Install/release: bound Docker package build, inventory, pack, and tarball preparation with process-group timeouts; pin shrinkwrap patch drift to the pnpm lock; harden macOS restart and dSYM packaging; and run release Docker/live timeout wrappers in the foreground so child processes cannot wedge gates.
|
||||
- Telegram/network: treat `ENETDOWN` as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.
|
||||
- Telegram: preserve inbound text entities, overlapping DM replies, account topic cache sidecars, outbound reply context, targeted bot-command mentions, durable group retry targets, forum topic names, and native progress callbacks. (#83873, #85361, #85555, #85656, #85709, #86299, #86553) Thanks @SebTardif, @luoyanglang, and @neeravmakwana.
|
||||
- iMessage: read image attachments from local Messages attachment roots, dedupe duplicate local Messages-source accounts, seed direct DM history, fix image/group media attachment commands, advance catchup cursors after live handling, and keep slash-command acknowledgements in the source conversation. (#82642, #85475, #86569, #86705, #86706, #86770) Thanks @homer-byte, @TurboTheTurtle, @swang430, and @OmarShahine.
|
||||
- WhatsApp/QQ/Twitch/IRC/Slack: restore WhatsApp ack identity and group-drop warnings, make QQ Bot media respect `OPENCLAW_HOME`, serialize Twitch auth disconnects, store IRC channel routes canonically, and keep Slack downloaded files out of reply media. (#83833, #85309, #85777, #85794, #85906, #86318, #86697) Thanks @sliverp, @neeravmakwana, and @Kailigithub.
|
||||
- Discord/voice: improve voice playback and wake replies, bucket large model picker menus, merge media captions into one message, route metadata through configured proxies, restore numeric channel sends, suppress self-reply echoes, and tighten wake matching without breaking fuzzy wake phrases. (#80227, #86238, #86487, #86571, #86595, #86601)
|
||||
- Codex: preserve native web-search metadata, keep oversized native thread reuse, bridge CLI API-key auth into the app server, preserve sandbox bootstrap path style, recover context-window prompt errors, honor yolo approval policy, disable native thread personality, and route compaction through Codex auth. (#85378, #85542, #85891, #85909, #86408)
|
||||
- Agents/runtime: enforce session lock max-hold reclaim, release embedded-attempt locks on all exits, treat aborted subagent runs as terminal, avoid runtime model hydration on hot paths, disclose scoped session list counts, derive overflow budgets from provider errors, and keep fallback errors scoped to the active model candidate. (#70473, #85764, #86014, #86134, #86427, #86944) Thanks @openperf, @fuller-stack-dev, @zhangguiping-xydt, and @ferminquant.
|
||||
- Config/update/doctor: retry config recovery after failed backup restore, skip shell env fallback on Windows, exclude prerelease tags from the stable git channel, support deep config edits, warn instead of aborting on unreadable cron stores, prune stale bundled plugin paths, and avoid duplicate restart prompts when the Gateway is already healthy. (#85739, #85787, #86060, #86260, #86384, #86533) Thanks @liaoyl830.
|
||||
- Install/release: support Alpine CLI installs and runtime floors, prefer trusted startup argv runtime fallback roots, reject stale CLI node runtimes, avoid npm `min-release-age` installer failures, bound npm/package/Docker install phases, restore config parent ownership in Docker, seed Docker lockfile package tarballs before prune, and make release/plugin prerelease checks fail closed instead of hanging or false-greening. (#85491)
|
||||
- Security: avoid printing Gateway tokens in Docker, validate plugin model-pattern regexes safely, escape transcript metadata field names, harden session allowlist glob matching, audit Claude permission overrides under YOLO, and require explicit allow for ACP auto approvals. (#85849, #85934, #86046, #86557)
|
||||
- Media/images: replace Sharp with Rastermill, keep EXIF normalization best-effort, normalize HEIC/HEIF before image descriptions, route Codex image API keys through OpenAI, preserve image compression metadata, and auto-scale live tool result caps. (#85776, #86037, #86437, #86857, #86923)
|
||||
- Memory: prevent semantic vector indexes from silently degrading when embeddings are unavailable, stop doctor OOMs on large session stores, preserve sidecar hooks/artifacts, write fallback dream diaries, use CJK-aware dreaming dedupe, and avoid per-file watcher FD fan-out. (#80613, #82928, #85060, #85704, #85967, #86701) Thanks @brokemac79, @openperf, and @yaaboo-gif.
|
||||
- Agents/sessions: include visibility metadata on restricted `sessions_list` results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.
|
||||
- Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid `discovery.wideArea.domain` and `dns setup --domain` values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.
|
||||
- Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.
|
||||
@@ -143,95 +192,33 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.
|
||||
|
||||
- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.
|
||||
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) thanks @zhangguiping-xydt.
|
||||
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) Thanks @zhangguiping-xydt.
|
||||
- iMessage: dedupe watcher startup when `channels.imessage.accounts` lists both `default` and a named account that point at the same local Messages source, so the gateway no longer spawns two `imsg rpc` processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and `openclaw doctor` flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.
|
||||
|
||||
## 2026.5.25
|
||||
|
||||
### Fixes
|
||||
|
||||
- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`.
|
||||
- Checks: prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
|
||||
- Plugins: allow linked local plugin paths to probe TypeScript source entries without requiring compiled package output, restoring source-checkout plugin development on native Windows.
|
||||
- CLI: route source-checkout build output to stderr before launching OpenClaw commands so stale local builds do not corrupt `--json` stdout.
|
||||
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
|
||||
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
|
||||
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
|
||||
- Windows: run direct Node package scripts with env overrides through a cross-platform launcher so gateway, TUI, Docker-all, generated-module formatting, and optional Discord native opus installer entrypoints work on native Windows.
|
||||
- Tests: run Vitest import timing entrypoints through a Node wrapper so native Windows package scripts can collect import diagnostics.
|
||||
- Control UI: split large build-time runtime dependencies into stable chunks so Linux/Docker install and package builds stay below the app chunk warning threshold.
|
||||
- Tests: run `test:max` and `test:changed:max` through a Node wrapper so high-worker Vitest entrypoints work on native Windows.
|
||||
- Tests: retry transient loopback HTTP resets in the kitchen-sink RPC walk so native Windows readiness probes do not fail after the gateway is already ready.
|
||||
- Tests: run `test:serial` through a Node wrapper so targeted serial Vitest commands work on native Windows.
|
||||
- Tests: normalize Vitest config path assertions so the infra config suite runs on native Windows paths.
|
||||
- Installer: avoid the incompatible generated `--before` install filter when raw npm `min-release-age` config is present. (#85491) Thanks @TurboTheTurtle.
|
||||
- Agents/MCP: bound bundled MCP `tools/list` catalog discovery so hung MCP servers do not block session tool materialization. (#85063) Thanks @nxmxbbd.
|
||||
- Channels/iMessage: recover malformed anchorless group watch payloads by GUID before debounce/routing, and drop unrecoverable payloads instead of replying to the sender DM. Fixes #84470. Refs #84503. Thanks @zhangguiping-xydt and @zqchris.
|
||||
- Channels/iMessage: advance the startup catchup cursor from live-handled rows after a completed catchup pass, including rows received while catchup is still running, so restarts do not replay them. (#85475) Thanks @TurboTheTurtle.
|
||||
- Tests: mount the shared Windows command helper into bare Docker E2E harness containers so published upgrade-survivor config walks can start on Linux.
|
||||
- Tests: keep the plugin binding command escape Docker smoke focused on its intended Vitest cases and skip source-only install lifecycle scripts.
|
||||
- Tests: let the generic plugin install E2E assertions use a configurable temp root and Windows home-relative install paths.
|
||||
- Tests: keep kitchen-sink plugin assertion fixtures on a configurable temp root so native Windows runs no longer skip full-surface diagnostic coverage.
|
||||
- Tests: fail Gateway startup benchmarks when a child startup never produces ready probes or process metrics instead of reporting all `n/a` samples as passing.
|
||||
- Config/secrets: allow exec SecretRef ids to include `#` selectors so AWS-style `secret#json_key` ids validate consistently. (#80731) Thanks @TurboTheTurtle.
|
||||
- Tests: keep the Telegram user credential helper on platform temp and path APIs so native Windows credential export and restore commands do not write through POSIX-only paths.
|
||||
- Installer: include the optional verify phase in the progress counter so `--verify` shows `[4/4] Verifying installation` instead of `[4/3]`.
|
||||
- Crabbox: let the wrapper find a sibling Crabbox checkout from linked Git worktrees so Codex worktrees can run remote gates without a PATH shim.
|
||||
- CI: tolerate the standard `--` option separator in shared helper flag parsing so perf and test commands accept package-manager argument forwarding.
|
||||
- Tests: preserve `--` passthrough arguments in live-media, live-shard, and extension batch harnesses so Vitest filters are not misread or silently ignored.
|
||||
- Crabbox: default AWS macOS runner requests to on-demand capacity so EC2 Mac proof commands do not fail on the unsupported Spot market default.
|
||||
- Tests: run upgrade-survivor config recipe commands through the Windows npm shim so native Windows package walks keep baseline config coverage.
|
||||
- Image tool: use bundled Anthropic media limits when resolving image compression policy without provider-runtime hooks.
|
||||
- Tests: fail the kitchen-sink RPC Docker walk when gateway RSS sampling is unavailable instead of silently disabling the per-process memory guard.
|
||||
- Tests: suppress the current Rolldown plugin timing warning format in the Vitest wrapper so tiny focused runs do not drown useful stderr in repeated build-timing noise.
|
||||
- Models/OpenRouter: use endpoint-specific OpenRouter context limits from `top_provider` metadata so provider-routed models no longer overstate available context. (#85949) Thanks @TurboTheTurtle.
|
||||
- Crabbox: sync clean sparse-checkout remote changed gates from a temporary full checkout with local-only commits overlaid as worktree changes so git-backed script checks can seed the runner repository.
|
||||
- Agents: avoid loading bundled channel plugins while resolving completion delivery policy and queue defaults on subagent handoff paths.
|
||||
- Tests: allow split Vitest config shards through the explicit-target preflight so CI shard jobs run their intended projects.
|
||||
- Tests: make startup memory and startup bench smoke scripts build CLI startup artifacts when run from a fresh source checkout.
|
||||
- iMessage: mark authorized slash-command turns as text-sourced commands so `/status`, `/new`, and `/restart` acknowledgements return to the source conversation. (#82642) thanks @homer-byte.
|
||||
- Crabbox: install Corepack shims into the writable hydration `PNPM_HOME` so local AWS runner hydration no longer tries to overwrite `/usr/local/bin/pnpm`.
|
||||
- Live tests: fail Gateway live model sweeps when selected coverage is lost to timeouts or stale high-signal filters instead of reporting false missing-profile coverage, and pin Docker OpenAI gateway coverage to the current `gpt-5.5` lane.
|
||||
- Tests: fail Docker resource-ceiling checks when stats samples or configured limits are invalid instead of silently reporting zero peaks.
|
||||
- Auth/Codex: emit a one-shot actionable `log.warn` from the embedded legacy Codex OAuth sidecar loader when the only available seed lives in the macOS Keychain, naming `openclaw doctor --fix` and macOS Keychain instead of letting the credential silently fall through to a downstream `No API key found for provider "openai-codex"`. Thanks @romneyda.
|
||||
- Agents: fail closed when provider-less session models match multiple provider-prefixed runtime policies so CLI runtime routing no longer depends on config order. (#85970) Thanks @potterdigital.
|
||||
- Control UI/agents: keep collapsed tool rows readable without early ellipses, preserve raw expanded tool details, and make post-compaction AGENTS.md reinjection opt-in to avoid duplicated project context. Fixes #45649 and #45488. Thanks @BunsDev.
|
||||
|
||||
## 2026.5.24
|
||||
## 2026.5.22
|
||||
|
||||
### Changes
|
||||
|
||||
- iMessage: support thumb-approval reactions — `👍` (Like tapback) resolves an approval as `allow-once` and `👎` resolves as `deny`, with the explicit-approver allowlist read from `channels.imessage.allowFrom`; `allow-always` stays on the manual `/approve <id> allow-always` text fallback. Mirrors the WhatsApp behavior from #85477.
|
||||
- Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.
|
||||
- Gateway/perf: cache stable install-record, channel-catalog, bundled-channel, and Telegram session-store metadata during process-local hot paths to reduce repeated JSON and manifest reads.
|
||||
- Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.
|
||||
- Talk/realtime: let WebUI and Discord voice callers ask for active OpenClaw run status, cancel, steer, or queue follow-up work while a consult is still running. (#84231) Thanks @Solvely-Colin.
|
||||
- Discord/voice: add realtime wake-name gating with agent-name defaults and raise profile bootstrap context budget for longer `USER.md`/`SOUL.md` files.
|
||||
- Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.
|
||||
- Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.
|
||||
- Image tool: add adaptive model-aware image compression with an `agents.defaults.imageQuality` preference for choosing token-efficient, balanced, or high-detail media handling.
|
||||
- Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only `openclaw meeting-notes` CLI access, and Discord voice as the first live source.
|
||||
- Meeting Notes/Discord: release channel account startup before meeting-notes auto-capture, wait for the Discord voice manager during gateway boot, and stop plugin services before channel shutdown so voice capture state remains available during startup and cleanup.
|
||||
- Transcripts: add the initial transcript capture and source-provider foundation, including auto-start capture config, manual transcript imports, read-only transcript access, and Discord voice as the first live source.
|
||||
- Docs/channels/config: add Signal `configPath`, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.
|
||||
- Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.
|
||||
- Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.
|
||||
- Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.
|
||||
- Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.
|
||||
- CLI/models: let `openclaw models auth login` store a single returned provider auth profile under a requested `--profile-id`, and document named Codex OAuth profile setup. (#49315) Thanks @DanielLSM.
|
||||
- Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.
|
||||
- Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.
|
||||
- Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.
|
||||
- Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.
|
||||
- Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.
|
||||
- Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.
|
||||
- Diagnostics: emit sanitized `secrets.prepare` timeline spans for Gateway secret preparation so operators can distinguish secret startup latency without exposing provider names, secret ids, or secret values. (#83019) Thanks @samzong.
|
||||
- Diagnostics: export bounded skill usage metrics/spans and tool source/owner labels for core, plugin, MCP, and channel tool execution without exposing raw paths or session identifiers. (#80370) Thanks @gauravprasadgp.
|
||||
- Agents/subagents: limit default sub-agent bootstrap context to `AGENTS.md` and `TOOLS.md`, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.
|
||||
- Maintainer skills: require clean autoreview before surfacing bug-sweep PR URLs and treat changelog-only conflicts as routine busy-main churn.
|
||||
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
|
||||
- QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.
|
||||
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
|
||||
- Plugin SDK/cron delivery: route cron delivery through the modern target resolver and outbound session-route APIs, deprecate parser-backed target helpers and `plugin-sdk/messaging-targets`, and move bundled callers to `plugin-sdk/channel-targets`.
|
||||
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
|
||||
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
|
||||
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.
|
||||
@@ -242,7 +229,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
|
||||
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
|
||||
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
|
||||
- ACPX: bump the bundled ACP backend to `acpx` 0.10.0 for session export/import support.
|
||||
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
|
||||
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
|
||||
- QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.
|
||||
@@ -262,75 +248,21 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/update: allow package-manager-managed hardlinked package roots during global update swaps while keeping generic plugin, hook, and dependency-free install moves fail-closed. (#85569) Thanks @ai-hpc.
|
||||
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
|
||||
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
|
||||
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
|
||||
- Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst.
|
||||
- Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen.
|
||||
- Discord/voice: serialize overlapping voice joins, retry aborted startup readiness within the configured timeout, upgrade meeting-notes-only sessions to realtime when the normal follow join arrives, detach promoted meeting-notes ownership without leaving voice, and include `OpenClaw` in default realtime wake names.
|
||||
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
|
||||
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
|
||||
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.
|
||||
- Discord/voice: recover stale realtime playback state when Discord stream-close/player-idle events do not arrive, and keep generated runtime plugin aliases available after postbuild rewrites.
|
||||
- Discord/voice: keep realtime playback running when meeting notes attaches to an existing voice session or a realtime consult starts, and route realtime user transcripts into meeting notes.
|
||||
- Config/secrets: preflight active runtime SecretRefs before root and include config writes persist, and roll back unchanged file/env state when post-write refresh fails. Fixes #46531. (#84454) Thanks @samzong.
|
||||
- CLI/models: preserve SecretRef-backed custom provider `apiKey` markers when `models status` regenerates `models.json`, avoiding resolved plaintext secrets on disk. Fixes #84632. (#84658) Thanks @NianJiuZst.
|
||||
- WhatsApp/auto-reply: deliver deferred media replies through the foreground reply fence so overlapping no-reply turns no longer hide already visible responses. (#85517) Thanks @cavit99.
|
||||
- Sessions/security: replace agent-to-agent wildcard allowlist regexes with a precompiled linear matcher so cross-agent access checks avoid backtracking-prone patterns. (#85849) Thanks @SebTardif.
|
||||
- WebChat: keep the run-complete indicator in progress until deferred history replay renders the assistant reply, so Done no longer appears before response text. (#85374) Thanks @neeravmakwana.
|
||||
- Agents/tools: give timed-out or cancelled process trees a bounded SIGTERM cleanup window before SIGKILL while preserving tree-aware cancellation. Fixes #66399. (#85865) Thanks @IWhatsskill.
|
||||
- Agents/subagents: treat aborted subagent stop reasons as killed terminal failures so parent sessions get error announcements instead of silent success. Fixes #72293. (#85860) Thanks @IWhatsskill.
|
||||
- Agents/providers: clamp proxy-like OpenAI Chat Completions output caps against the final request payload so strict local/API-compatible servers no longer reject prompts that already consume part of the context window. Fixes #83086. (#85889) Thanks @rendrag-git.
|
||||
- Agents/compaction: skip agent-harness preflight for provider-owned CLI runtime sessions so over-threshold Claude CLI sessions continue through normal compaction instead of failing on a missing harness. Fixes #84857. (#84878) Thanks @zhangguiping-xydt.
|
||||
- Codex/app-server: keep successful native hook relays available through a short post-turn grace window so late Codex hook subprocesses can finish policy enforcement without clearing a replacement relay. (#83987) Thanks @Kaspre.
|
||||
- Control UI/config: save form-mode edits from the source config snapshot so runtime-only provider defaults like empty `models.providers.<id>.baseUrl` are not written back and rejected. Fixes #85831. Thanks @garyd9.
|
||||
- Browser/existing-session: launch Chrome DevTools MCP with usage statistics disabled by default so its telemetry watchdog stays off unless an operator explicitly opts in. (#85886) Thanks @rohitjavvadi.
|
||||
- Telegram: normalize legacy durable group retry targets before retry sends, polls, and pins so group retries keep using the real chat id. (#85656) Thanks @luoyanglang.
|
||||
- Agents/PDF: route MiniMax PDF fallback policy through plugin metadata so MiniMax uses text extraction instead of VLM image fallback. (#85590, fixes #85575) Thanks @neeravmakwana.
|
||||
- CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable.
|
||||
- Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m.
|
||||
- Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman.
|
||||
- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828.
|
||||
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
|
||||
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
|
||||
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
|
||||
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
|
||||
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
|
||||
- Gateway: stop sending duplicate message-phase `sessions.changed` websocket events after displayable `session.message` transcript updates. (#84834)
|
||||
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
|
||||
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
|
||||
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
|
||||
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
|
||||
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
|
||||
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
|
||||
- Sandbox: keep workspace skill mounts read-only for remote container-cwd file operations and reject symlinked skill roots before creating protected overlays. (#85591) Thanks @jason-allen-oneal.
|
||||
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
|
||||
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
|
||||
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
|
||||
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
|
||||
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
|
||||
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
|
||||
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
|
||||
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
|
||||
- Scripts/Windows: run the Z.AI fallback repro through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
|
||||
- Codex/Windows: run app-server protocol formatting through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
|
||||
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
|
||||
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
|
||||
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
|
||||
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
|
||||
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
|
||||
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
|
||||
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
|
||||
- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
|
||||
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
|
||||
- Plugins/Gateway: treat non-empty return values from plugin gateway method handlers as successful responses so `openclaw gateway call` no longer times out after completed plugin work. Fixes #59470. Thanks @HTMG23.
|
||||
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
|
||||
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
|
||||
- Update: keep the detached gateway restart handoff best-effort when the restart script process cannot be spawned. (#83892) Thanks @davinci282828.
|
||||
- Windows/config: skip POSIX login-shell env fallback on native Windows so startup no longer warns about missing `/bin/sh`. Fixes #84795. Thanks @JIRBOY.
|
||||
- Telegram: persist the prompt-context message cache through plugin state and record bot-authored replies after sends and draft streaming so later turns can include prior assistant replies without relying on the JSON sidecar. (#85231) Thanks @keshavbotagent.
|
||||
- Agents/subagents: keep Codex persona and user workspace files turn-scoped so native Codex subagents inherit only shared tool guidance by default. (#85811) Thanks @lastguru-net.
|
||||
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
|
||||
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
|
||||
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
|
||||
@@ -339,7 +271,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
|
||||
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
|
||||
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
|
||||
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
|
||||
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
|
||||
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
|
||||
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
|
||||
@@ -351,13 +282,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
|
||||
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
|
||||
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
|
||||
- Control UI/chat: keep light-mode model, thinking, config, and agents select arrows visible without tiling background icons. Fixes #85713. Thanks @Linux2010.
|
||||
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
|
||||
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
|
||||
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
|
||||
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
|
||||
- Gateway: defer channel account startup work until HTTP readiness and remove startup model prewarm, avoiding startup event-loop stalls and timer-delay warnings.
|
||||
- Models/perf: reuse plugin metadata during models.json planning, keep bundled catalog augmentation manifest/static, and use static provider catalogs for metadata-only startup discovery so provider model normalization, auth discovery, and Gateway startup metadata do not reload broad plugin runtimes.
|
||||
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
|
||||
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
|
||||
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
|
||||
@@ -365,22 +293,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Windows installer: fail Git checkout installs when `pnpm install` or `pnpm build` fails instead of writing a wrapper to a missing CLI build.
|
||||
- Sessions: surface previous-transcript archive failures during `/new` rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.
|
||||
- TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in `openclaw tui`. Fixes #85538. Thanks @danpolasek.
|
||||
- Gateway/TUI: preserve source-reply metadata through reply normalization and emit message-tool-only agent replies over the live chat stream so `openclaw tui` renders Codex replies without waiting for a history refresh. Thanks @shakkernerd.
|
||||
- Codex/TUI: keep long source-reply runs alive after Codex reasoning completes so delayed visible `message` calls can still reach `openclaw tui`. Thanks @shakkernerd.
|
||||
- TUI: keep quiet active runs busy after the response watchdog notice instead of reopening the prompt and encouraging duplicate submissions while the backend turn is still running. Thanks @shakkernerd.
|
||||
- Agents: preserve the latest assistant thinking blocks while stripping invalid replay signatures from older turns, and retry Anthropic thinking failures without thinking replay. Fixes #85557. Thanks @bryanbaer.
|
||||
- Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.
|
||||
- Telegram: avoid false pairing prompts after transient pairing-store read failures while preserving configured `allowFrom` and per-DM pairing authorization. (#85555)
|
||||
- Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.
|
||||
- Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.
|
||||
- Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.
|
||||
- Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.
|
||||
- QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.
|
||||
- WhatsApp: persist inbound message delivery state through plugin state before dispatch and delay read receipts until handler completion, so retryable failures can redeliver without adding a plugin-local disk cache. Thanks @samzong.
|
||||
- Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.
|
||||
- Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.
|
||||
- Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.
|
||||
- Codex app-server: leave automatic compaction to native Codex, drop OpenClaw preflight/CLI/context-engine forced compaction for Codex runtime sessions, and still forward explicit `/compact` or plugin compaction requests into Codex while failing native compaction honestly. (#85500)
|
||||
- Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)
|
||||
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
|
||||
- OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.
|
||||
- OpenAI video: send uploaded video edit requests to the documented `/videos/edits` endpoint with a `video` file instead of posting MP4 references to `/videos`. Thanks @shakkernerd.
|
||||
@@ -389,11 +311,9 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.
|
||||
- Codex app-server: let authorized `/codex` control commands such as `/codex detach` escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.
|
||||
- Auto-reply/models: keep `/models` browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.
|
||||
- Browser/Doctor: read macOS Chrome app bundle versions from `Info.plist` before spawning Chrome and extend the fallback version probe timeout, avoiding false cold-cache warnings from Gatekeeper latency. Fixes #85418. Thanks @davidcittadini.
|
||||
- Codex app-server: disable native Code Mode when the effective exec host is `node` and keep OpenClaw `exec`/`process` available, so `/exec host=node` routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.
|
||||
- Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.
|
||||
- Gateway/agents: return phase-aware `agent.wait` timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.
|
||||
- Gateway/systemd: launch managed update handoff helpers in a transient user scope so systemd-supervised Update Now flows survive the gateway unit restart. Fixes #84068.
|
||||
- Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.
|
||||
- Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.
|
||||
- Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.
|
||||
@@ -408,7 +328,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.
|
||||
- Docker: pre-create the workspace and auth-profile config mount points with `node` ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.
|
||||
- Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.
|
||||
- Diagnostics/OTel: drop snake_case diagnostic id attributes alongside camelCase ids so exported telemetry cannot leak run, session, message, chat, trace, or tool-call identifiers. (#72645) Thanks @Lion0710.
|
||||
- CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)
|
||||
- Agents/providers: honor per-model `api` and `baseUrl` overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.
|
||||
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
|
||||
@@ -419,11 +338,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.
|
||||
- Gateway chat display: preserve OpenAI-compatible `prompt_tokens`, `completion_tokens`, and `total_tokens` usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.
|
||||
- Dashboard/CLI: allow macOS browser launching through `open` even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.
|
||||
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving available action query metadata in tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
|
||||
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
|
||||
- OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated `kimi-k2.6` turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.
|
||||
- Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.
|
||||
- Agents/tools: add bounded tool-policy audit log entries that identify which allow/deny rule removed tools or blocked a sandboxed tool call. Fixes #55801. Thanks @justinjkline.
|
||||
- CLI/logs: read implicit local Gateway logs through the passive backend client path so `openclaw logs --follow` does not register as a paired device, and use the active Linux systemd journal instead of stale configured-file fallbacks when live local RPC is unavailable. Fixes #83656 and #66841.
|
||||
- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.
|
||||
- Doctor/Codex: point native Codex asset warnings at the canonical `openclaw migrate plan codex` preview command. Fixes #84948. Thanks @markoa.
|
||||
- CLI/models: make `capability model auth logout --agent` remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.
|
||||
@@ -465,7 +382,7 @@ Docs: https://docs.openclaw.ai
|
||||
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
|
||||
- Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.
|
||||
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
|
||||
- Agents: estimate tool-heavy prompt pressure at the LLM boundary before provider submission for non-Codex embedded runtimes, so persistent PI-style sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
|
||||
- Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
|
||||
- Agents/hooks: wait for local one-shot CLI and Codex `agent_end` plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)
|
||||
- Providers/Google: preserve Gemini 3 cron `thinkingDefault: "low"` when stale catalog metadata says `reasoning:false`, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.
|
||||
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
|
||||
@@ -486,7 +403,6 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring `openclaw update --tag main` for one-off package updates. (#81296) Thanks @fuller-stack-dev.
|
||||
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
|
||||
- Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.
|
||||
- CLI/doctor: remove stale bundled plugin load paths from old versioned OpenClaw package roots after pnpm/npm upgrades. Fixes #58626. Thanks @solink7.
|
||||
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
|
||||
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
|
||||
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
|
||||
@@ -513,7 +429,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
|
||||
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
|
||||
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
|
||||
- Discord/voice-call: keep forced realtime voice consult diagnostics in debug logs instead of agent prompts, so callers do not hear OpenClaw policy text when the provider misses `openclaw_agent_consult`. (#84411) Thanks @fuller-stack-dev.
|
||||
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
|
||||
- Codex app-server: give visible `message` dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.
|
||||
- Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.
|
||||
@@ -522,7 +437,6 @@ Docs: https://docs.openclaw.ai
|
||||
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
|
||||
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
|
||||
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
|
||||
- Media-understanding: restore the 4096-token default for image descriptions so reasoning-capable vision models no longer truncate before returning text, while preserving smaller model caps. (#84932) Thanks @scotthuang.
|
||||
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
|
||||
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
|
||||
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
|
||||
|
||||
@@ -24,7 +24,7 @@ private final class StreamFailureBox: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkGatewayPermissionState: Equatable, Sendable {
|
||||
enum TalkGatewayPermissionState: Equatable {
|
||||
case unknown
|
||||
case ready
|
||||
case missingScope(String)
|
||||
|
||||
@@ -9,7 +9,7 @@ struct TalkRealtimeClientCreateParams: Encodable {
|
||||
var voice: String?
|
||||
}
|
||||
|
||||
struct TalkRealtimeClientSession: Decodable, Sendable {
|
||||
struct TalkRealtimeClientSession: Decodable {
|
||||
let provider: String
|
||||
let transport: String
|
||||
let clientSecret: String
|
||||
@@ -24,12 +24,12 @@ struct TalkRealtimeClientSession: Decodable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRealtimeToolCallResponse: Decodable, Sendable {
|
||||
struct TalkRealtimeToolCallResponse: Decodable {
|
||||
let runId: String?
|
||||
let idempotencyKey: String?
|
||||
}
|
||||
|
||||
struct TalkRealtimeServerEvent: Decodable, Sendable {
|
||||
struct TalkRealtimeServerEvent: Decodable {
|
||||
let type: String
|
||||
let itemId: String?
|
||||
let item: TalkRealtimeServerItem?
|
||||
@@ -69,7 +69,7 @@ struct TalkRealtimeServerEvent: Decodable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRealtimeServerItem: Decodable, Sendable {
|
||||
struct TalkRealtimeServerItem: Decodable {
|
||||
let id: String?
|
||||
let type: String?
|
||||
let callId: String?
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -84,7 +84,7 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalDecision: String, Codable {
|
||||
enum ExecApprovalDecision: String, Codable, Equatable {
|
||||
case allowOnce = "allow-once"
|
||||
case allowAlways = "allow-always"
|
||||
case deny
|
||||
|
||||
@@ -70,7 +70,9 @@ final class ExecApprovalsGatewayPrompter {
|
||||
timeoutMs: 10000)
|
||||
return
|
||||
}
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||
guard let decision = ExecApprovalsPromptPresenter.prompt(request.request) else {
|
||||
return
|
||||
}
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
params: [
|
||||
|
||||
@@ -14,6 +14,79 @@ struct ExecApprovalPromptRequest: Codable {
|
||||
var agentId: String?
|
||||
var resolvedPath: String?
|
||||
var sessionKey: String?
|
||||
var allowedDecisions: [ExecApprovalDecision]?
|
||||
|
||||
init(
|
||||
command: String,
|
||||
cwd: String? = nil,
|
||||
host: String? = nil,
|
||||
security: String? = nil,
|
||||
ask: String? = nil,
|
||||
agentId: String? = nil,
|
||||
resolvedPath: String? = nil,
|
||||
sessionKey: String? = nil,
|
||||
allowedDecisions: [ExecApprovalDecision]? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.host = host
|
||||
self.security = security
|
||||
self.ask = ask
|
||||
self.agentId = agentId
|
||||
self.resolvedPath = resolvedPath
|
||||
self.sessionKey = sessionKey
|
||||
self.allowedDecisions = allowedDecisions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case command
|
||||
case cwd
|
||||
case host
|
||||
case security
|
||||
case ask
|
||||
case agentId
|
||||
case resolvedPath
|
||||
case sessionKey
|
||||
case allowedDecisions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.command = try container.decode(String.self, forKey: .command)
|
||||
self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
|
||||
self.host = try container.decodeIfPresent(String.self, forKey: .host)
|
||||
self.security = try container.decodeIfPresent(String.self, forKey: .security)
|
||||
self.ask = try container.decodeIfPresent(String.self, forKey: .ask)
|
||||
self.agentId = try container.decodeIfPresent(String.self, forKey: .agentId)
|
||||
self.resolvedPath = try container.decodeIfPresent(String.self, forKey: .resolvedPath)
|
||||
self.sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)
|
||||
let decodedDecisions = (try? container.decodeIfPresent(
|
||||
[DecodedExecApprovalDecision].self,
|
||||
forKey: .allowedDecisions)) ?? []
|
||||
self.allowedDecisions = decodedDecisions.compactMap(\.decision)
|
||||
}
|
||||
|
||||
static func allowedDecisions(forAsk ask: String?) -> [ExecApprovalDecision] {
|
||||
// Older payloads did not carry ask/allowedDecisions. Preserve their durable
|
||||
// approval option; explicit ask=always and allowedDecisions payloads are the
|
||||
// policy-carrying shapes that remove it.
|
||||
ask == ExecAsk.always.rawValue
|
||||
? [.allowOnce, .deny]
|
||||
: [.allowOnce, .allowAlways, .deny]
|
||||
}
|
||||
}
|
||||
|
||||
private struct DecodedExecApprovalDecision: Decodable {
|
||||
var decision: ExecApprovalDecision?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
guard let raw = try? container.decode(String.self) else {
|
||||
self.decision = nil
|
||||
return
|
||||
}
|
||||
self.decision = ExecApprovalDecision(rawValue: raw)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExecApprovalSocketRequest: Codable {
|
||||
@@ -227,7 +300,7 @@ final class ExecApprovalsPromptServer {
|
||||
|
||||
enum ExecApprovalsPromptPresenter {
|
||||
@MainActor
|
||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision? {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
@@ -235,20 +308,47 @@ enum ExecApprovalsPromptPresenter {
|
||||
alert.informativeText = "Review the command details before allowing."
|
||||
alert.accessoryView = self.buildAccessoryView(request)
|
||||
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Always Allow")
|
||||
alert.addButton(withTitle: "Don't Allow")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
let decisions = self.allowedPromptDecisions(request)
|
||||
for decision in decisions {
|
||||
alert.addButton(withTitle: self.buttonTitle(for: decision))
|
||||
}
|
||||
if #available(macOS 11.0, *),
|
||||
let denyIndex = decisions.firstIndex(of: .deny),
|
||||
alert.buttons.indices.contains(denyIndex)
|
||||
{
|
||||
alert.buttons[denyIndex].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .allowOnce
|
||||
case .alertSecondButtonReturn:
|
||||
return .allowAlways
|
||||
default:
|
||||
return .deny
|
||||
return self.decision(forModalResponse: alert.runModal(), decisions: decisions)
|
||||
}
|
||||
|
||||
static func decision(
|
||||
forModalResponse response: NSApplication.ModalResponse,
|
||||
decisions: [ExecApprovalDecision]) -> ExecApprovalDecision?
|
||||
{
|
||||
let selectedIndex = response.rawValue
|
||||
- NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
|
||||
if decisions.indices.contains(selectedIndex) {
|
||||
return decisions[selectedIndex]
|
||||
}
|
||||
return decisions.contains(.deny) ? .deny : nil
|
||||
}
|
||||
|
||||
static func allowedPromptDecisions(_ request: ExecApprovalPromptRequest) -> [ExecApprovalDecision] {
|
||||
if let allowedDecisions = request.allowedDecisions, !allowedDecisions.isEmpty {
|
||||
return allowedDecisions
|
||||
}
|
||||
return ExecApprovalPromptRequest.allowedDecisions(forAsk: request.ask)
|
||||
}
|
||||
|
||||
private static func buttonTitle(for decision: ExecApprovalDecision) -> String {
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
"Allow Once"
|
||||
case .allowAlways:
|
||||
"Always Allow"
|
||||
case .deny:
|
||||
"Don't Allow"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +492,7 @@ private enum ExecHostExecutor {
|
||||
case .allow:
|
||||
break
|
||||
case .requiresPrompt:
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
guard let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: request.cwd,
|
||||
@@ -401,7 +501,15 @@ private enum ExecHostExecutor {
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: request.sessionKey))
|
||||
sessionKey: request.sessionKey,
|
||||
allowedDecisions: ExecApprovalPromptRequest.allowedDecisions(
|
||||
forAsk: context.ask.rawValue)))
|
||||
else {
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: approval prompt closed without decision",
|
||||
reason: "approval-cancelled")
|
||||
}
|
||||
|
||||
let followupDecision: ExecApprovalDecision
|
||||
switch decision {
|
||||
@@ -657,7 +765,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket")
|
||||
private let socketPath: String
|
||||
private let token: String
|
||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision?
|
||||
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
||||
private var socketFD: Int32 = -1
|
||||
private var acceptTask: Task<Void, Never>?
|
||||
@@ -666,7 +774,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
init(
|
||||
socketPath: String,
|
||||
token: String,
|
||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision?,
|
||||
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
||||
{
|
||||
self.socketPath = socketPath
|
||||
@@ -808,7 +916,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
||||
return
|
||||
}
|
||||
let decision = await self.onPrompt(request.request)
|
||||
guard let decision = await self.onPrompt(request.request) else { return }
|
||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -754,7 +754,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
if requiresAsk, !approvedByAsk {
|
||||
let decision = await MainActor.run {
|
||||
let promptDecision = await MainActor.run {
|
||||
ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
@@ -764,7 +764,26 @@ actor MacNodeRuntime {
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: context.sessionKey))
|
||||
sessionKey: context.sessionKey,
|
||||
allowedDecisions: ExecApprovalPromptRequest.allowedDecisions(
|
||||
forAsk: context.ask.rawValue)))
|
||||
}
|
||||
guard let decision = promptDecision else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "approval-cancelled"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval prompt closed without decision"))
|
||||
}
|
||||
switch decision {
|
||||
case .deny:
|
||||
|
||||
@@ -5,6 +5,123 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ExecApprovalPromptLayoutTests {
|
||||
@Test func `allowed decisions omit durable approval even when ask allows it`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1",
|
||||
allowedDecisions: [.allowOnce, .deny]))
|
||||
|
||||
#expect(decisions == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `ask always prompts omit durable approval when decisions are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `ask on miss prompts keep durable approval when decisions are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .allowAlways, .deny])
|
||||
}
|
||||
|
||||
@Test func `legacy prompts keep durable approval when policy fields are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .allowAlways, .deny])
|
||||
}
|
||||
|
||||
@Test func `unknown ask prompts keep legacy durable approval when decisions are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "unexpected",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .allowAlways, .deny])
|
||||
}
|
||||
|
||||
@Test func `approval request decodes valid allowed decisions only`() throws {
|
||||
let data = """
|
||||
{
|
||||
"command": "/bin/sh -lc pwd",
|
||||
"ask": "on-miss",
|
||||
"allowedDecisions": ["allow-once", "bad", "deny", 3]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let request = try JSONDecoder().decode(ExecApprovalPromptRequest.self, from: data)
|
||||
|
||||
#expect(request.allowedDecisions == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `approval request falls back when allowed decisions has wrong shape`() throws {
|
||||
let data = """
|
||||
{
|
||||
"command": "/bin/sh -lc pwd",
|
||||
"ask": "always",
|
||||
"allowedDecisions": "allow-once"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let request = try JSONDecoder().decode(ExecApprovalPromptRequest.self, from: data)
|
||||
|
||||
#expect(ExecApprovalsPromptPresenter.allowedPromptDecisions(request) == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `modal close does not synthesize deny when deny is unavailable`() {
|
||||
let closeResponse = NSApplication.ModalResponse(rawValue: 0)
|
||||
|
||||
let withoutDeny = ExecApprovalsPromptPresenter.decision(
|
||||
forModalResponse: closeResponse,
|
||||
decisions: [.allowOnce])
|
||||
let withDeny = ExecApprovalsPromptPresenter.decision(
|
||||
forModalResponse: closeResponse,
|
||||
decisions: [.allowOnce, .deny])
|
||||
|
||||
#expect(withoutDeny == nil)
|
||||
#expect(withDeny == .deny)
|
||||
}
|
||||
|
||||
@Test func `accessory view reserves nonzero alert layout space`() {
|
||||
let accessory = ExecApprovalsPromptPresenter.buildAccessoryView(
|
||||
ExecApprovalPromptRequest(
|
||||
|
||||
@@ -441,6 +441,47 @@
|
||||
"includeTools"
|
||||
]
|
||||
},
|
||||
"transcripts": {
|
||||
"emoji": "🎙️",
|
||||
"title": "Transcripts",
|
||||
"actions": {
|
||||
"start": {
|
||||
"label": "start",
|
||||
"detailKeys": [
|
||||
"providerId",
|
||||
"sessionId",
|
||||
"title",
|
||||
"meetingUrl",
|
||||
"guildId",
|
||||
"channelId"
|
||||
]
|
||||
},
|
||||
"stop": {
|
||||
"label": "stop",
|
||||
"detailKeys": [
|
||||
"sessionId"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"label": "status"
|
||||
},
|
||||
"import": {
|
||||
"label": "import",
|
||||
"detailKeys": [
|
||||
"providerId",
|
||||
"sessionId",
|
||||
"title",
|
||||
"meetingUrl"
|
||||
]
|
||||
},
|
||||
"summarize": {
|
||||
"label": "summarize",
|
||||
"detailKeys": [
|
||||
"sessionId"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessions_spawn": {
|
||||
"emoji": "🧑🔧",
|
||||
"title": "Sub-agent",
|
||||
@@ -499,45 +540,6 @@
|
||||
"lines"
|
||||
]
|
||||
},
|
||||
"transcripts": {
|
||||
"emoji": "📝",
|
||||
"title": "Transcripts",
|
||||
"actions": {
|
||||
"start": {
|
||||
"label": "start",
|
||||
"detailKeys": [
|
||||
"providerId",
|
||||
"accountId",
|
||||
"guildId",
|
||||
"channelId",
|
||||
"title"
|
||||
]
|
||||
},
|
||||
"stop": {
|
||||
"label": "stop",
|
||||
"detailKeys": [
|
||||
"sessionId"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"label": "status"
|
||||
},
|
||||
"import": {
|
||||
"label": "import",
|
||||
"detailKeys": [
|
||||
"providerId",
|
||||
"title",
|
||||
"speakerLabel"
|
||||
]
|
||||
},
|
||||
"summarize": {
|
||||
"label": "summarize",
|
||||
"detailKeys": [
|
||||
"sessionId"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"web_search": {
|
||||
"emoji": "🔎",
|
||||
"title": "Web Search",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
1d2c2fa07a2d3c4d046d2defe2eb48b27011be25e75db205b19e3da37e9fd0a0 config-baseline.json
|
||||
5b12e247f4375de2d454802d3af188a840851dd69e9d15a1a92a4815c7ef7d97 config-baseline.core.json
|
||||
c766614db5c416910fb6cdd454efb0738779af80ddd58a4fb06d8b1ca6484ce2 config-baseline.channel.json
|
||||
74441e331aabb3026784c148d4ee5ce3f489a15ed87ffd9b7ba0c5e2a7bc93be config-baseline.plugin.json
|
||||
53b7621e99d75b98ecc8f4389d38900f84cf213f95dbcc877f36125d763c660d config-baseline.json
|
||||
e92bbf45714e418383118098d4ff15d347fa8ffc7e7837b437b522d2b59ce9fe config-baseline.core.json
|
||||
b901fb766edfd9df630690281476fc4032c64772f69d1d8f7b2e0e913a90f229 config-baseline.channel.json
|
||||
5c214ab364011fd95735755f9fa4298aa4de8ad81144ae8dd08d969bb7ba318b config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
1f1824af61c8885360ff99dad3fe1d7b75565e540cdef57474e9f220f9876f78 plugin-sdk-api-baseline.json
|
||||
4f29099d81398cb76331b618c39d298b3c9398efd84291dfb93c2098cb4ae443 plugin-sdk-api-baseline.jsonl
|
||||
65cb96d0aa2888ddb7b014f810d7cd415f1f0ccdce7792fbf12b4aad11f146f8 plugin-sdk-api-baseline.json
|
||||
662b37da529f199ee9b56482f2f6897bdd010dfb72be778d208b289adaca1298 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -306,7 +306,7 @@ Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||
|
||||
- Use a dedicated hook token; do not reuse gateway auth tokens.
|
||||
- Keep `hooks.path` on a dedicated subpath; `/` is rejected.
|
||||
- Set `hooks.allowedAgentIds` to limit explicit `agentId` routing.
|
||||
- Set `hooks.allowedAgentIds` to limit which effective agent a hook can target, including the default agent when `agentId` is omitted.
|
||||
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
|
||||
- If you enable `hooks.allowRequestSessionKey`, also set `hooks.allowedSessionKeyPrefixes` to constrain allowed session key shapes.
|
||||
- Hook payloads are wrapped with safety boundaries by default.
|
||||
|
||||
@@ -51,7 +51,7 @@ If the message tool is unavailable under the active tool policy, OpenClaw falls
|
||||
back to automatic visible replies instead of silently suppressing the response.
|
||||
`openclaw doctor` warns about this mismatch.
|
||||
|
||||
For direct chats and any other source event, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Some harnesses, including Codex, also default direct/source chats to message-tool delivery when this is unset. Set `messages.visibleReplies: "automatic"` to force the old automatic final-reply path. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
|
||||
For direct chats and any other source event, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Internal WebChat direct turns default to automatic final-reply delivery so Pi and Codex receive the same visible-reply contract. Set `messages.visibleReplies: "message_tool"` to intentionally require `message(action=send)` for visible output. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
|
||||
|
||||
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.
|
||||
|
||||
|
||||
@@ -292,13 +292,17 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels).
|
||||
- Inbound messages normalize into the shared channel envelope with reply metadata, media placeholders, and persisted reply-chain context for Telegram replies the gateway has observed.
|
||||
- Group sessions are isolated by group ID. Forum topics append `:topic:<threadId>` to keep topics isolated.
|
||||
- DM messages can carry `message_thread_id`; OpenClaw preserves the thread ID for replies but keeps DMs on the flat session by default. Configure `channels.telegram.dm.threadReplies: "inbound"`, `channels.telegram.direct.<chatId>.threadReplies: "inbound"`, `requireTopic: true`, or a matching topic config when you intentionally want DM topic session isolation.
|
||||
- DM messages can carry `message_thread_id`; OpenClaw preserves it for replies. DM topic sessions split only when Telegram `getMe` reports `has_topics_enabled: true` for the bot; otherwise DMs stay on the flat session.
|
||||
- Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`.
|
||||
- Multi-account startup bounds concurrent Telegram `getMe` probes so large bot fleets do not fan out every account probe at once.
|
||||
- Long polling is guarded inside each gateway process so only one active poller can use a bot token at a time. If you still see `getUpdates` 409 conflicts, another OpenClaw gateway, script, or external poller is likely using the same token.
|
||||
- Long-polling watchdog restarts trigger after 120 seconds without completed `getUpdates` liveness by default. Increase `channels.telegram.pollingStallThresholdMs` only if your deployment still sees false polling-stall restarts during long-running work. The value is in milliseconds and is allowed from `30000` to `600000`; per-account overrides are supported.
|
||||
- Telegram Bot API has no read-receipt support (`sendReadReceipts` does not apply).
|
||||
|
||||
<Note>
|
||||
`channels.telegram.dm.threadReplies` and `channels.telegram.direct.<chatId>.threadReplies` were removed. Run `openclaw doctor --fix` after upgrading if your config still has those keys. DM topic routing now follows the bot capability from Telegram `getMe.has_topics_enabled`, which is controlled by BotFather threaded mode: topics-enabled bots use thread-scoped DM sessions when Telegram sends `message_thread_id`; other DMs stay on the flat session.
|
||||
</Note>
|
||||
|
||||
## Feature reference
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -663,7 +667,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
**Thread-bound ACP spawn from chat**: `/acp spawn <agent> --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` to remain enabled (default: `true`).
|
||||
|
||||
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing and reply metadata on flat sessions by default; they only use thread-aware session keys when configured with `threadReplies: "inbound"`, `threadReplies: "always"`, `requireTopic: true`, or a matching topic config. Use top-level `channels.telegram.dm.threadReplies` for the account default, or `direct.<chatId>.threadReplies` for one DM.
|
||||
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep reply metadata; they use thread-aware session keys only when Telegram `getMe` reports `has_topics_enabled: true` for the bot.
|
||||
The former `dm.threadReplies` and `direct.*.threadReplies` overrides are intentionally retired; use BotFather threaded mode as the single source of truth and run `openclaw doctor --fix` to remove stale config keys.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1078,7 +1083,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
|
||||
- topic defaults: `groups.<chatId>.topics."*"` applies to unmatched forum topics; exact topic IDs override it
|
||||
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`, `dm.threadReplies`, `direct.*.threadReplies`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
|
||||
|
||||
@@ -376,7 +376,7 @@ The separate `Install Smoke` workflow reuses the same scope script through its o
|
||||
|
||||
`main` pushes (including merge commits) do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation.
|
||||
|
||||
The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`. It runs on the nightly schedule and from the release checks workflow, and manual `Install Smoke` dispatches can opt into it, but pull requests and `main` pushes do not. QR and installer Docker tests keep their own install-focused Dockerfiles.
|
||||
The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`. It runs on the nightly schedule and from the release checks workflow, and manual `Install Smoke` dispatches can opt into it, but pull requests and `main` pushes do not. Normal PR CI still runs the fast Bun launcher regression lane for Node-relevant changes. QR and installer Docker tests keep their own install-focused Dockerfiles.
|
||||
|
||||
## Local Docker E2E
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
summary: "CLI reference for `openclaw docs` (search the live docs index)"
|
||||
read_when:
|
||||
- You want to search the live OpenClaw docs from the terminal
|
||||
- You need to know which helper binaries the docs CLI shells out to
|
||||
- You need to know which hosted search API the docs CLI calls
|
||||
title: "Docs"
|
||||
---
|
||||
|
||||
# `openclaw docs`
|
||||
|
||||
Search the live OpenClaw docs index from the terminal. The command shells out to the public Mintlify-hosted docs MCP search endpoint at `https://docs.openclaw.ai/mcp.search_open_claw` and renders the results in your terminal.
|
||||
Search the live OpenClaw docs index from the terminal. The command calls OpenClaw's Cloudflare-hosted docs search API and renders the results in your terminal.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -35,17 +35,7 @@ With no query, `openclaw docs` prints the docs entrypoint URL plus a sample sear
|
||||
|
||||
## How it works
|
||||
|
||||
`openclaw docs` invokes the `mcporter` CLI to call the docs search MCP tool, then parses the `Title: / Link: / Content:` blocks from the tool output into a list of results.
|
||||
|
||||
To resolve `mcporter`, OpenClaw checks in order:
|
||||
|
||||
1. `mcporter` on `PATH` (used directly if present).
|
||||
2. `pnpm dlx mcporter ...` if `pnpm` is installed.
|
||||
3. `npx -y mcporter ...` if `npx` is installed.
|
||||
|
||||
If none are available, the command fails with a hint to install `pnpm` (`npm install -g pnpm`).
|
||||
|
||||
The search call uses a fixed 30 second timeout. Result snippets are truncated to ~220 characters per entry.
|
||||
`openclaw docs` calls `https://docs.openclaw.ai/api/search` and renders the JSON results. The search call uses a fixed 30 second timeout.
|
||||
|
||||
## Output
|
||||
|
||||
@@ -62,10 +52,10 @@ In non-rich output (piped, `--no-color`, scripts), the same data renders as Mark
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
| ---- | --------------------------------------------------- |
|
||||
| `0` | Search succeeded (including zero-result responses). |
|
||||
| `1` | The MCP tool call failed; stderr is printed inline. |
|
||||
| Code | Meaning |
|
||||
| ---- | ----------------------------------------------------------------- |
|
||||
| `0` | Search succeeded (including zero-result responses). |
|
||||
| `1` | The hosted docs search API call failed; stderr is printed inline. |
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ Related:
|
||||
- `--json`: emit line-delimited JSON events
|
||||
- `--plain`: plain text output without styled formatting
|
||||
- `--no-color`: disable ANSI colors
|
||||
- `--local-time`: render timestamps in your local timezone
|
||||
- `--local-time`: render timestamps in your local timezone (default)
|
||||
- `--utc`: render timestamps in UTC
|
||||
|
||||
## Shared Gateway RPC options
|
||||
|
||||
@@ -49,13 +50,14 @@ openclaw logs --plain
|
||||
openclaw logs --no-color
|
||||
openclaw logs --limit 500
|
||||
openclaw logs --local-time
|
||||
openclaw logs --utc
|
||||
openclaw logs --follow --local-time
|
||||
openclaw logs --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `--local-time` to render timestamps in your local timezone.
|
||||
- Timestamps render in your local timezone by default. Use `--utc` for UTC output.
|
||||
- If the implicit local loopback Gateway asks for pairing, closes during connect, or times out before `logs.tail` answers, `openclaw logs` falls back to the configured Gateway file log automatically. Explicit `--url` targets do not use this fallback.
|
||||
- `openclaw logs --follow` does not follow configured-file fallbacks after implicit local Gateway RPC failures. On Linux, it uses the active user-systemd Gateway journal by PID when available and prints the selected log source; otherwise it keeps retrying the live Gateway instead of tailing a potentially stale side-by-side file.
|
||||
- When using `--follow`, transient gateway disconnects (WebSocket close, timeout, connection drop) trigger automatic reconnection with exponential backoff (up to 8 retries, capped at 30 s between attempts). A warning is printed to stderr on each retry, and a `[logs] gateway reconnected` notice is printed once a poll succeeds. In `--json` mode both the retry warning and the reconnect transition are emitted as `{"type":"notice"}` records on stderr. Non-recoverable errors (auth failure, bad configuration) still exit immediately.
|
||||
|
||||
@@ -103,7 +103,7 @@ rewriting files.
|
||||
|
||||
```bash
|
||||
openclaw plugins search "calendar" # search ClawHub plugins
|
||||
openclaw plugins install <package> # npm by default
|
||||
openclaw plugins install <package> # source auto-detection
|
||||
openclaw plugins install clawhub:<package> # ClawHub only
|
||||
openclaw plugins install npm:<package> # npm only
|
||||
openclaw plugins install npm-pack:<path.tgz> # local npm pack through npm install semantics
|
||||
@@ -123,7 +123,7 @@ sources with guarded environment variables. See
|
||||
[Plugin install overrides](/plugins/install-overrides).
|
||||
|
||||
<Warning>
|
||||
Bare package names install from npm by default during the launch cutover. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code. Prefer pinned versions.
|
||||
Bare package names install from npm by default during the launch cutover, unless they match an official plugin id. Raw `@openclaw/*` package specs that match bundled plugins use the bundled copy that shipped with the current OpenClaw build. Use `npm:<package>` when you deliberately want an external npm package instead. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code. Prefer pinned versions.
|
||||
</Warning>
|
||||
|
||||
`plugins search` queries ClawHub for installable plugin packages and prints
|
||||
@@ -171,7 +171,9 @@ is available, then fall back to `latest`.
|
||||
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
|
||||
|
||||
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover.
|
||||
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover unless they match an official plugin id.
|
||||
|
||||
Raw `@openclaw/*` package specs that match bundled plugins resolve to the image-owned bundled copy before npm fallback. For example, `openclaw plugins install @openclaw/discord@2026.5.20 --pin` uses the bundled Discord plugin from the current OpenClaw build instead of creating a managed npm override. To force the external npm package, use `openclaw plugins install npm:@openclaw/discord@2026.5.20 --pin`.
|
||||
|
||||
Bare specs and `@latest` stay on the stable track. OpenClaw date-stamped correction versions such as `2026.5.3-1` are stable releases for this check. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
|
||||
|
||||
@@ -207,7 +209,7 @@ openclaw plugins install clawhub:openclaw-codex-app-server
|
||||
openclaw plugins install clawhub:openclaw-codex-app-server@1.2.3
|
||||
```
|
||||
|
||||
Bare npm-safe plugin specs install from npm by default during the launch cutover:
|
||||
Bare npm-safe plugin specs install from npm by default during the launch cutover unless they match an official plugin id:
|
||||
|
||||
```bash
|
||||
openclaw plugins install openclaw-codex-app-server
|
||||
@@ -217,6 +219,7 @@ Use `npm:` to make npm-only resolution explicit:
|
||||
|
||||
```bash
|
||||
openclaw plugins install npm:openclaw-codex-app-server
|
||||
openclaw plugins install npm:@openclaw/discord@2026.5.20
|
||||
openclaw plugins install npm:@scope/plugin-name@1.0.1
|
||||
```
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
|
||||
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
|
||||
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedPiAgent` abort timer.
|
||||
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
|
||||
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; active work with no recent progress reports as `session.stalled`; `session.stuck` is reserved for stale session bookkeeping with no active work. Stale session bookkeeping releases the affected session lane immediately; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; active work with no recent progress reports as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered runs with no explicit model or agent timeout disable the idle watchdog and rely on the cron outer timeout.
|
||||
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ keys.
|
||||
- If commands seem stuck, enable verbose logs and look for "queued for ...ms" lines to confirm the queue is draining.
|
||||
- If you need queue depth, enable verbose logs and watch for queue timing lines.
|
||||
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
|
||||
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; active work with no recent progress logs as `session.stalled`; `session.stuck` is reserved for stale session bookkeeping with no active work, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; active work with no recent progress logs as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -50,6 +50,50 @@ Disable all flags:
|
||||
OPENCLAW_DIAGNOSTICS=0
|
||||
```
|
||||
|
||||
`OPENCLAW_DIAGNOSTICS=0` is a process-level disable override: it disables
|
||||
flags from both env and config for that process.
|
||||
|
||||
## Profiling flags
|
||||
|
||||
Profiler flags enable targeted timing spans without raising global logging
|
||||
levels. They are disabled by default.
|
||||
|
||||
Enable all profiler-gated spans for one gateway run:
|
||||
|
||||
```bash
|
||||
OPENCLAW_DIAGNOSTICS=profiler openclaw gateway run
|
||||
```
|
||||
|
||||
Enable only reply-dispatch profiler spans:
|
||||
|
||||
```bash
|
||||
OPENCLAW_DIAGNOSTICS=reply.profiler openclaw gateway run
|
||||
```
|
||||
|
||||
Enable only Codex app-server startup/tool/thread profiler spans:
|
||||
|
||||
```bash
|
||||
OPENCLAW_DIAGNOSTICS=codex.profiler openclaw gateway run
|
||||
```
|
||||
|
||||
Enable profiler flags from config:
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"flags": ["reply.profiler", "codex.profiler"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway after changing config flags. To disable a profiler flag,
|
||||
remove it from `diagnostics.flags` and restart. To temporarily disable every
|
||||
diagnostics flag even when config enables profiler flags, start the process with:
|
||||
|
||||
```bash
|
||||
OPENCLAW_DIAGNOSTICS=0 openclaw gateway run
|
||||
```
|
||||
|
||||
## Timeline artifacts
|
||||
|
||||
The `timeline` flag writes structured startup and runtime timing events for
|
||||
|
||||
@@ -399,6 +399,22 @@ minutes; set `0` to disable). One-shot embedded runs such as auth probes,
|
||||
slug generation, and active-memory recall request cleanup at run end so stdio
|
||||
children and Streamable HTTP/SSE streams do not outlive the run.
|
||||
|
||||
## Reseed history cap
|
||||
|
||||
When a fresh CLI session is seeded from a prior OpenClaw transcript (for
|
||||
example after a `session_expired` retry), the rendered
|
||||
`<conversation_history>` block is capped to keep reseed prompts from
|
||||
exploding. The default is `12288` characters (about 3000 tokens).
|
||||
|
||||
Claude CLI backends automatically use a larger cap derived from the resolved
|
||||
Claude context tier. Standard 200K-token Claude runs keep a larger transcript
|
||||
slice, and 1M-token Claude runs keep a larger slice again, while other CLI
|
||||
backends keep the conservative default.
|
||||
|
||||
- The cap only governs the reseed prompt's prior-history block. Live-session
|
||||
output limits are tuned separately under `reliability.outputLimits`
|
||||
(see [Sessions](#sessions)).
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into
|
||||
|
||||
@@ -788,7 +788,7 @@ See the full channel index: [Channels](/channels).
|
||||
|
||||
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
|
||||
Visible replies are controlled separately. Normal group and channel requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Some harnesses, including Codex, default direct/source chats to message-tool delivery so visible output only posts after the agent calls `message(action=send)`. If the model returns final text without calling the message tool, that final text stays private and the gateway verbose log records suppressed payload metadata.
|
||||
Visible replies are controlled separately. Normal group, channel, and internal WebChat direct requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Opt into `messages.visibleReplies: "message_tool"` or `messages.groupChat.visibleReplies: "message_tool"` when visible output should only post after the agent calls `message(action=send)`. If the model returns final text without calling the message tool in an opted-in tool-only mode, that final text stays private and the gateway verbose log records suppressed payload metadata.
|
||||
|
||||
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. If
|
||||
the session log shows assistant text with `didSendViaMessagingTool: false`, the
|
||||
@@ -834,7 +834,7 @@ Fix: either pick a stronger tool-calling model, remove the explicit `"message_to
|
||||
|
||||
`messages.groupChat.unmentionedInbound: "room_event"` submits unmentioned always-on group/channel messages as quiet room context on supported channels. Mentioned messages, commands, and direct messages remain user requests. See [Ambient room events](/channels/ambient-room-events) for complete Discord, Slack, and Telegram examples.
|
||||
|
||||
`messages.visibleReplies` is the global source-event default; `messages.groupChat.visibleReplies` overrides it for group/channel source events. When `messages.visibleReplies` is unset, direct/source chats use the selected runtime or harness default. The Codex harness defaults direct/source chats to message-tool delivery; set `messages.visibleReplies: "automatic"` to use automatic final delivery. Channel allowlists and mention gating still decide whether an event is processed.
|
||||
`messages.visibleReplies` is the global source-event default; `messages.groupChat.visibleReplies` overrides it for group/channel source events. When `messages.visibleReplies` is unset, direct/source chats use the selected runtime or harness default, but internal WebChat direct turns use automatic final delivery for Pi/Codex prompt parity. Set `messages.visibleReplies: "message_tool"` to intentionally require `message(action=send)` for visible output. Channel allowlists and mention gating still decide whether an event is processed.
|
||||
|
||||
#### DM history limits
|
||||
|
||||
|
||||
@@ -709,8 +709,8 @@ Validation and safety notes:
|
||||
- `transform` can point to a JS/TS module returning a hook action.
|
||||
- `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected).
|
||||
- Keep `hooks.transformsDir` under `~/.openclaw/hooks/transforms`; workspace skill directories are rejected. If `openclaw doctor` reports this path as invalid, move the transform module into the hooks transforms directory or remove `hooks.transformsDir`.
|
||||
- `agentId` routes to a specific agent; unknown IDs fall back to default.
|
||||
- `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
|
||||
- `agentId` routes to a specific agent; unknown IDs fall back to the default agent.
|
||||
- `allowedAgentIds`: restricts effective agent routing, including the default-agent path when `agentId` is omitted (`*` or omitted = allow all, `[]` = deny all).
|
||||
- `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
|
||||
- `allowRequestSessionKey`: allow `/hooks/agent` callers and template-driven mapping session keys to set `sessionKey` (default: `false`).
|
||||
- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`. It becomes required when any mapping or preset uses a templated `sessionKey`.
|
||||
|
||||
@@ -223,8 +223,8 @@ message bodies are also approved for export.
|
||||
- `openclaw.queue.depth` (histogram, attrs: `openclaw.lane` or `openclaw.channel=heartbeat`)
|
||||
- `openclaw.queue.wait_ms` (histogram, attrs: `openclaw.lane`)
|
||||
- `openclaw.session.state` (counter, attrs: `openclaw.state`, `openclaw.reason`)
|
||||
- `openclaw.session.stuck` (counter, attrs: `openclaw.state`; emitted only for stale session bookkeeping with no active work)
|
||||
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`; emitted only for stale session bookkeeping with no active work)
|
||||
- `openclaw.session.stuck` (counter, attrs: `openclaw.state`; emitted for recoverable stale session bookkeeping)
|
||||
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`; emitted for recoverable stale session bookkeeping)
|
||||
- `openclaw.session.turn.created` (counter, attrs: `openclaw.agent`, `openclaw.channel`, `openclaw.trigger`)
|
||||
- `openclaw.session.recovery.requested` (counter, attrs: `openclaw.state`, `openclaw.action`, `openclaw.active_work_kind`, `openclaw.reason`)
|
||||
- `openclaw.session.recovery.completed` (counter, attrs: `openclaw.state`, `openclaw.action`, `openclaw.status`, `openclaw.active_work_kind`, `openclaw.reason`)
|
||||
@@ -249,8 +249,9 @@ OpenClaw classifies sessions by the work it can still observe:
|
||||
turns behind the lane can resume. When unset, the abort threshold defaults to
|
||||
the safer extended window of at least 5 minutes and 3x
|
||||
`diagnostics.stuckSessionWarnMs`.
|
||||
- `session.stuck`: stale session bookkeeping with no active work. This releases
|
||||
the affected session lane immediately.
|
||||
- `session.stuck`: stale session bookkeeping with no active work, or an idle
|
||||
queued session with stale ownerless model/tool activity. This releases the
|
||||
affected session lane immediately after recovery gates pass.
|
||||
|
||||
Recovery emits structured `session.recovery.requested` and
|
||||
`session.recovery.completed` events. Diagnostic session state is marked idle
|
||||
|
||||
@@ -37,7 +37,10 @@ install method:
|
||||
- **`stable`** (package installs): updates via npm dist-tag `latest`.
|
||||
- **`beta`** (package installs): prefers npm dist-tag `beta`, but falls back to
|
||||
`latest` when `beta` is missing or older than the current stable tag.
|
||||
- **`stable`** (git installs): checks out the latest stable git tag.
|
||||
- **`stable`** (git installs): checks out the latest stable git tag, excluding
|
||||
semver prerelease tags such as `-alpha.N`, `-beta.N`, `-rc.N`, `-dev.N`,
|
||||
`-next.N`, `-preview.N`, `-canary.N`, `-nightly.N`, and other prerelease
|
||||
suffixes.
|
||||
- **`beta`** (git installs): prefers the latest beta git tag, but falls back to
|
||||
the latest stable git tag when beta is missing or older.
|
||||
- **`dev`**: ensures a git checkout (default `~/openclaw`, or
|
||||
@@ -121,9 +124,11 @@ source (config, git tag, git branch, or default).
|
||||
## Tagging best practices
|
||||
|
||||
- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable,
|
||||
`vYYYY.M.D-beta.N` for beta).
|
||||
`vYYYY.M.D-beta.N` for beta; named semver prerelease suffixes such as
|
||||
`-alpha.N`, `-rc.N`, and `-next.N` are not stable targets).
|
||||
- Legacy numeric stable tags such as `vYYYY.M.D-1` and `v1.0.1-1` are still
|
||||
recognized as stable git tags for compatibility.
|
||||
- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`.
|
||||
- Legacy `vYYYY.M.D-<patch>` tags are still recognized as stable (non-beta).
|
||||
- Keep tags immutable: never move or reuse a tag.
|
||||
- npm dist-tags remain the source of truth for npm installs:
|
||||
- `latest` -> stable
|
||||
|
||||
@@ -310,7 +310,7 @@ available timeout in this order:
|
||||
image-generation default.
|
||||
- For the media-understanding `image` tool, `tools.media.image.timeoutSeconds`
|
||||
converted to milliseconds, or the 60 second media default.
|
||||
- The 30 second dynamic-tool default.
|
||||
- The 90 second dynamic-tool default.
|
||||
|
||||
Dynamic tool budgets are capped at 600000 ms. On timeout, OpenClaw aborts the
|
||||
tool signal where supported and returns a failed dynamic-tool response to Codex
|
||||
@@ -353,7 +353,7 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
|
||||
- GPT-5.4 mini
|
||||
- GPT-5.2
|
||||
|
||||
The current bundled harness is `@openai/codex` `0.133.0`. A `model/list` probe
|
||||
The current bundled harness is `@openai/codex` `0.134.0`. A `model/list` probe
|
||||
against that bundled app-server returned:
|
||||
|
||||
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
|
||||
|
||||
@@ -49,11 +49,12 @@ newly selected model.
|
||||
## Visible replies and heartbeats
|
||||
|
||||
When a direct/source chat turn runs through the Codex harness, visible replies
|
||||
default to the message tool: final assistant text stays private unless the
|
||||
agent calls `message(action="send")`. This matches GPT models well because they
|
||||
can decide whether source-channel output is useful. Set
|
||||
`messages.visibleReplies: "automatic"` to restore the old mode where final
|
||||
assistant text posts automatically.
|
||||
default to automatic final assistant delivery for internal WebChat surfaces.
|
||||
This keeps Codex aligned with the Pi harness prompt contract: agents reply
|
||||
normally, and OpenClaw posts the final text to the source conversation. Set
|
||||
`messages.visibleReplies: "message_tool"` when a direct/source chat should
|
||||
intentionally keep final assistant text private unless the agent calls
|
||||
`message(action="send")`.
|
||||
|
||||
Codex heartbeat turns also get `heartbeat_respond` in the searchable OpenClaw
|
||||
tool catalog by default, so the agent can record whether the wake should stay
|
||||
|
||||
@@ -541,7 +541,7 @@ Supported `appServer` fields:
|
||||
| `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. |
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 30 second
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
|
||||
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends
|
||||
or shortens that specific tool budget. The `image_generate` tool uses
|
||||
`agents.defaults.imageGenerationModel.timeoutMs` when the tool call does not
|
||||
|
||||
@@ -198,7 +198,8 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
|
||||
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
|
||||
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
|
||||
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
|
||||
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers and local native exec prompt suppression |
|
||||
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
|
||||
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
|
||||
| `plugin-sdk/approval-runtime` | Exec/plugin approval payload helpers, native approval routing/runtime helpers, and structured approval display helpers such as `formatApprovalDisplayPath` |
|
||||
| `plugin-sdk/reply-dedupe` | Narrow inbound reply dedupe reset helpers |
|
||||
@@ -245,6 +246,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/runtime-config-snapshot` | Current process config snapshot helpers such as `getRuntimeConfig`, `getRuntimeConfigSnapshot`, and test snapshot setters |
|
||||
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
|
||||
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text barrel |
|
||||
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
|
||||
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers, and structured approval display path formatting |
|
||||
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
|
||||
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize and conversation-label helpers |
|
||||
|
||||
@@ -40,7 +40,9 @@ Before installing a plugin, make sure you have:
|
||||
```
|
||||
|
||||
ClawHub is the primary discovery surface for community plugins. During the
|
||||
launch cutover, ordinary bare package specs still install from npm. Use an
|
||||
launch cutover, ordinary bare package specs still install from npm unless
|
||||
they match an official plugin id. Raw `@openclaw/*` package specs that match
|
||||
bundled plugins use the bundled copy from the current OpenClaw build. Use an
|
||||
explicit prefix when you need one source.
|
||||
|
||||
</Step>
|
||||
@@ -126,10 +128,13 @@ Before installing a plugin, make sure you have:
|
||||
Bare package specs have special compatibility behavior. If the bare name matches
|
||||
a bundled plugin id, OpenClaw uses that bundled source. If it matches an
|
||||
official external plugin id, OpenClaw uses the official package catalog. Other
|
||||
ordinary bare package specs install through npm during the launch cutover. Use
|
||||
`clawhub:`, `npm:`, `git:`, or `npm-pack:` when you need deterministic source
|
||||
selection. See [`openclaw plugins`](/cli/plugins#install) for the full command
|
||||
contract.
|
||||
ordinary bare package specs install through npm during the launch cutover. Raw
|
||||
`@openclaw/*` package specs that match bundled plugins also resolve to the
|
||||
bundled copy before npm fallback. Use `npm:@openclaw/<plugin>@<version>` when
|
||||
you deliberately want the external npm package instead of the image-owned
|
||||
bundled copy. Use `clawhub:`, `npm:`, `git:`, or `npm-pack:` when you need
|
||||
deterministic source selection. See [`openclaw plugins`](/cli/plugins#install)
|
||||
for the full command contract.
|
||||
|
||||
### Configure plugin policy
|
||||
|
||||
|
||||
37
extensions/amazon-bedrock-mantle/npm-shrinkwrap.json
generated
37
extensions/amazon-bedrock-mantle/npm-shrinkwrap.json
generated
@@ -122,23 +122,6 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1053.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz",
|
||||
"integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.13",
|
||||
"@aws-sdk/nested-clients": "^3.997.11",
|
||||
"@aws-sdk/types": "^3.973.9",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-cognito-identity": {
|
||||
"version": "3.1051.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1051.0.tgz",
|
||||
@@ -457,9 +440,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1052.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
|
||||
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
|
||||
"version": "3.1053.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz",
|
||||
"integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.13",
|
||||
@@ -588,20 +571,6 @@
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai/node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
|
||||
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",
|
||||
|
||||
31
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
31
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
@@ -296,23 +296,6 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1052.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
|
||||
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.13",
|
||||
"@aws-sdk/nested-clients": "^3.997.11",
|
||||
"@aws-sdk/types": "^3.973.9",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.43",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz",
|
||||
@@ -514,20 +497,6 @@
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai/node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
|
||||
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",
|
||||
|
||||
25
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
25
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
@@ -282,23 +282,6 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1052.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
|
||||
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.13",
|
||||
"@aws-sdk/nested-clients": "^3.997.11",
|
||||
"@aws-sdk/types": "^3.973.9",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.43",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz",
|
||||
@@ -689,12 +672,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
|
||||
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz",
|
||||
"integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/core": "^3.24.4",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
|
||||
systemPromptFileArg: "--append-system-prompt-file",
|
||||
systemPromptMode: "append",
|
||||
systemPromptWhen: "first",
|
||||
systemPromptWhen: "always",
|
||||
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
|
||||
reliability: {
|
||||
watchdog: {
|
||||
|
||||
@@ -270,6 +270,13 @@ describe("normalizeClaudeBackendConfig", () => {
|
||||
expect(backend.config.resumeArgs).toContain("{sessionId}");
|
||||
});
|
||||
|
||||
it("passes system prompt on every turn (issue #80374 — systemPromptWhen must be 'always')", () => {
|
||||
// Before fix this was hardcoded to "first", which silently dropped
|
||||
// systemPromptOverride on every resumed / compacted claude-cli session.
|
||||
const backend = buildAnthropicCliBackend();
|
||||
expect(backend.config.systemPromptWhen).toBe("always");
|
||||
});
|
||||
|
||||
it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => {
|
||||
const backend = buildAnthropicCliBackend();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
|
||||
import {
|
||||
createExistingSessionAgentSharedModule,
|
||||
existingSessionRouteState,
|
||||
@@ -215,13 +216,72 @@ describe("existing-session browser routes", () => {
|
||||
it("checks existing-session snapshot URL when SSRF policy is configured", async () => {
|
||||
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationAllowed).not.toHaveBeenCalled();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
expect(chromeMcpMocks.takeChromeMcpSnapshot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows existing-session snapshots under the default SSRF policy object", async () => {
|
||||
const handler = getSnapshotGetHandler({});
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationAllowed).not.toHaveBeenCalled();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
ssrfPolicy: {},
|
||||
});
|
||||
expect(chromeMcpMocks.takeChromeMcpSnapshot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks existing-session snapshots when the current URL violates browser navigation policy", async () => {
|
||||
routeState.profileCtx.ensureTabAvailable.mockResolvedValueOnce({
|
||||
targetId: "7",
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
});
|
||||
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
|
||||
new Error("browser navigation blocked by policy"),
|
||||
);
|
||||
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
expect(chromeMcpMocks.takeChromeMcpSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects existing-session snapshot selectors before checking the current URL", async () => {
|
||||
routeState.profileCtx.ensureTabAvailable.mockResolvedValueOnce({
|
||||
targetId: "7",
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
});
|
||||
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: { format: "ai", selector: "#admin" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: EXISTING_SESSION_LIMITS.snapshot.snapshotSelector,
|
||||
});
|
||||
expect(navigationGuardMocks.assertBrowserNavigationAllowed).not.toHaveBeenCalled();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
|
||||
expect(chromeMcpMocks.takeChromeMcpSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks existing-session screenshot URL when SSRF policy is configured", async () => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
||||
import type { BrowserRequest } from "./types.js";
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
profileCtx: {
|
||||
profile: {
|
||||
driver: "openclaw" as const,
|
||||
name: "openclaw",
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
cdpIsLoopback: true,
|
||||
},
|
||||
ensureTabAvailable: vi.fn(async () => ({
|
||||
targetId: "7",
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
wsUrl: "ws://127.0.0.1/devtools/page/7",
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "private", depth: 0 }],
|
||||
})),
|
||||
snapshotRoleViaCdp: vi.fn(async () => ({
|
||||
snapshot: '- link "private" [ref=e1]',
|
||||
refs: { e1: { role: "link", name: "private" } },
|
||||
stats: { lines: 1, chars: 25, refs: 1, interactive: 1 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const navigationGuardMocks = vi.hoisted(() => ({
|
||||
assertBrowserNavigationAllowed: vi.fn(async () => {}),
|
||||
assertBrowserNavigationResultAllowed: vi.fn(async () => {
|
||||
throw new Error("browser navigation blocked by policy");
|
||||
}),
|
||||
withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
|
||||
}));
|
||||
|
||||
vi.mock("../cdp.js", () => ({
|
||||
captureScreenshot: vi.fn(),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
snapshotRoleViaCdp: cdpMocks.snapshotRoleViaCdp,
|
||||
}));
|
||||
|
||||
vi.mock("../chrome-mcp.js", () => ({
|
||||
evaluateChromeMcpScript: vi.fn(),
|
||||
navigateChromeMcpPage: vi.fn(),
|
||||
takeChromeMcpScreenshot: vi.fn(),
|
||||
takeChromeMcpSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation-guard.js", () => ({
|
||||
assertBrowserNavigationAllowed: navigationGuardMocks.assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed: navigationGuardMocks.assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy: navigationGuardMocks.withBrowserNavigationPolicy,
|
||||
}));
|
||||
|
||||
vi.mock("../screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({
|
||||
buffer,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./agent.shared.js", () => ({
|
||||
getPwAiModule: vi.fn(async () => null),
|
||||
handleRouteError: vi.fn(
|
||||
(
|
||||
_ctx: unknown,
|
||||
res: { status: (code: number) => unknown; json: (body: unknown) => void },
|
||||
err: unknown,
|
||||
) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
res.status(400);
|
||||
res.json({ error: message });
|
||||
},
|
||||
),
|
||||
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
|
||||
requirePwAi: vi.fn(async () => null),
|
||||
resolveProfileContext: vi.fn(() => routeState.profileCtx),
|
||||
withPlaywrightRouteContext: vi.fn(),
|
||||
withRouteTabContext: vi.fn(),
|
||||
}));
|
||||
|
||||
const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js");
|
||||
|
||||
function getSnapshotGetHandler() {
|
||||
const { app, getHandlers } = createBrowserRouteApp();
|
||||
registerBrowserAgentSnapshotRoutes(app, {
|
||||
state: () => ({
|
||||
resolved: {
|
||||
extraArgs: [],
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
},
|
||||
}),
|
||||
} as never);
|
||||
const handler = getHandlers.get("/snapshot");
|
||||
expect(handler).toBeTypeOf("function");
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("local-managed browser snapshot routes", () => {
|
||||
beforeEach(() => {
|
||||
routeState.profileCtx.ensureTabAvailable.mockClear();
|
||||
cdpMocks.snapshotAria.mockClear();
|
||||
cdpMocks.snapshotRoleViaCdp.mockClear();
|
||||
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear();
|
||||
navigationGuardMocks.withBrowserNavigationPolicy.mockClear();
|
||||
});
|
||||
|
||||
it("blocks ARIA CDP snapshots when the current tab violates browser navigation policy", async () => {
|
||||
const handler = getSnapshotGetHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: { format: "aria" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
});
|
||||
expect(cdpMocks.snapshotAria).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks AI CDP role snapshots when the current tab violates browser navigation policy", async () => {
|
||||
const handler = getSnapshotGetHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: { format: "ai", interactive: "true" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
});
|
||||
expect(cdpMocks.snapshotRoleViaCdp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -546,12 +546,20 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
const usesChromeMcp = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
|
||||
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
|
||||
let observedBrowserState: unknown;
|
||||
if (!usesChromeMcp && pwModule) {
|
||||
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
||||
}
|
||||
if (usesChromeMcp && (plan.selectorValue || plan.frameSelectorValue)) {
|
||||
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
|
||||
}
|
||||
if (ssrfPolicyOpts.ssrfPolicy) {
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: tab.url,
|
||||
...ssrfPolicyOpts,
|
||||
});
|
||||
}
|
||||
let observedBrowserState: unknown;
|
||||
if (!usesChromeMcp && pwModule) {
|
||||
observedBrowserState = await pwModule
|
||||
.getObservedBrowserStateViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
@@ -560,19 +568,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
||||
}
|
||||
if (usesChromeMcp) {
|
||||
if (plan.selectorValue || plan.frameSelectorValue) {
|
||||
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
|
||||
}
|
||||
if (ssrfPolicyOpts.ssrfPolicy) {
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: tab.url,
|
||||
...ssrfPolicyOpts,
|
||||
});
|
||||
}
|
||||
const snapshot = await takeChromeMcpSnapshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
withBrowserNavigationPolicy,
|
||||
} from "../navigation-guard.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import type { BrowserRequest } from "./types.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
|
||||
export const existingSessionRouteState = {
|
||||
profileCtx: {
|
||||
@@ -32,7 +32,11 @@ export const existingSessionRouteState = {
|
||||
export function createExistingSessionAgentSharedModule() {
|
||||
return {
|
||||
getPwAiModule: vi.fn(async () => null),
|
||||
handleRouteError: vi.fn(),
|
||||
handleRouteError: vi.fn((_ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
res.status(400);
|
||||
res.json({ error: message });
|
||||
}),
|
||||
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
|
||||
requirePwAi: vi.fn(async () => {
|
||||
throw new Error("Playwright should not be used for existing-session tests");
|
||||
|
||||
@@ -5,22 +5,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { normalizeBrowserScreenshot } from "./screenshot.js";
|
||||
|
||||
describe("browser screenshot normalization", () => {
|
||||
const unavailableImageBackend = process.platform === "win32" ? "sips" : "windows-native";
|
||||
|
||||
async function withUnavailableImageBackend<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const previousBackend = process.env.OPENCLAW_IMAGE_BACKEND;
|
||||
process.env.OPENCLAW_IMAGE_BACKEND = unavailableImageBackend;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (previousBackend === undefined) {
|
||||
delete process.env.OPENCLAW_IMAGE_BACKEND;
|
||||
} else {
|
||||
process.env.OPENCLAW_IMAGE_BACKEND = previousBackend;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {
|
||||
const bigPng = createSolidPngBuffer(2100, 2100, { r: 12, g: 34, b: 56 });
|
||||
|
||||
@@ -47,18 +31,4 @@ describe("browser screenshot normalization", () => {
|
||||
|
||||
expect(normalized.buffer.equals(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects screenshots above max side when no image processor is available", async () => {
|
||||
const png = createSolidPngBuffer(420, 120, { r: 12, g: 34, b: 56 });
|
||||
expect(png.byteLength).toBeLessThan(5 * 1024 * 1024);
|
||||
|
||||
await withUnavailableImageBackend(async () => {
|
||||
await expect(
|
||||
normalizeBrowserScreenshot(png, {
|
||||
maxSide: 120,
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow(/image processor unavailable/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
75
extensions/clickclack/src/access.ts
Normal file
75
extensions/clickclack/src/access.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
resolveStableChannelMessageIngress,
|
||||
type StableChannelIngressIdentityParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { getClickClackRuntime } from "./runtime.js";
|
||||
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "clickclack" as const;
|
||||
|
||||
function normalizeClickClackUserId(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const withoutProvider = trimmed.replace(/^(clickclack|cc):/i, "").trim();
|
||||
const directTarget = withoutProvider.match(/^dm:(.+)$/i);
|
||||
return directTarget?.[1]?.trim() || withoutProvider || null;
|
||||
}
|
||||
|
||||
const clickClackIngressIdentity = {
|
||||
key: "user-id",
|
||||
normalizeEntry: normalizeClickClackUserId,
|
||||
normalizeSubject: normalizeClickClackUserId,
|
||||
isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
|
||||
entryIdPrefix: "clickclack-user",
|
||||
} satisfies StableChannelIngressIdentityParams;
|
||||
|
||||
export type ClickClackInboundAccess = {
|
||||
shouldDispatch: boolean;
|
||||
commandAuthorized: boolean;
|
||||
};
|
||||
|
||||
export async function resolveClickClackInboundAccess(params: {
|
||||
account: ResolvedClickClackAccount;
|
||||
config: CoreConfig;
|
||||
message: ClickClackMessage;
|
||||
}): Promise<ClickClackInboundAccess> {
|
||||
const runtime = getClickClackRuntime();
|
||||
const isDirect = Boolean(params.message.direct_conversation_id);
|
||||
const cfg = params.config as OpenClawConfig;
|
||||
const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized(
|
||||
params.message.body,
|
||||
cfg,
|
||||
);
|
||||
const resolved = await resolveStableChannelMessageIngress({
|
||||
channelId: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
identity: clickClackIngressIdentity,
|
||||
cfg,
|
||||
subject: { stableId: params.message.author_id },
|
||||
conversation: {
|
||||
kind: isDirect ? "direct" : "group",
|
||||
id: isDirect
|
||||
? (params.message.direct_conversation_id ?? params.message.author_id)
|
||||
: (params.message.channel_id ?? params.message.thread_root_id),
|
||||
},
|
||||
allowFrom: params.account.allowFrom,
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
command: shouldCheckCommand
|
||||
? {
|
||||
cfg,
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
}
|
||||
: false,
|
||||
});
|
||||
|
||||
return {
|
||||
shouldDispatch: resolved.ingress.admission === "dispatch",
|
||||
commandAuthorized: resolved.commandAccess.requested
|
||||
? resolved.commandAccess.authorized
|
||||
: resolved.senderAccess.allowed,
|
||||
};
|
||||
}
|
||||
@@ -19,9 +19,14 @@ const mocks = vi.hoisted(() => ({
|
||||
thread: vi.fn(),
|
||||
},
|
||||
handleClickClackInbound: vi.fn(),
|
||||
resolveClickClackInboundAccess: vi.fn(),
|
||||
resolveWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./access.js", () => ({
|
||||
resolveClickClackInboundAccess: mocks.resolveClickClackInboundAccess,
|
||||
}));
|
||||
|
||||
vi.mock("./http-client.js", () => ({
|
||||
createClickClackClient: vi.fn(() => mocks.client),
|
||||
}));
|
||||
@@ -76,6 +81,10 @@ describe("ClickClack gateway", () => {
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
mocks.client.events.mockResolvedValue([]);
|
||||
mocks.resolveClickClackInboundAccess.mockResolvedValue({
|
||||
shouldDispatch: true,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
mocks.resolveWorkspaceId.mockResolvedValue("workspace-1");
|
||||
mocks.client.channelMessages.mockResolvedValue([
|
||||
{
|
||||
@@ -135,8 +144,47 @@ describe("ClickClack gateway", () => {
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.handleClickClackInbound).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.handleClickClackInbound.mock.calls[0]?.[0].access).toEqual({
|
||||
shouldDispatch: true,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
abort.abort();
|
||||
await run;
|
||||
expect(runError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops messages denied by ClickClack sender access before inbound handling", async () => {
|
||||
const socket = new FakeSocket();
|
||||
mocks.client.websocket.mockReturnValue(socket);
|
||||
mocks.resolveClickClackInboundAccess.mockResolvedValue({
|
||||
shouldDispatch: false,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
const run = startClickClackGatewayAccount(ctx);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
|
||||
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "evt-1",
|
||||
cursor: "cursor-1",
|
||||
type: "message.created",
|
||||
workspace_id: "workspace-1",
|
||||
channel_id: "chan-1",
|
||||
seq: 2,
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
payload: { message_id: "msg-1", author_id: "human-1" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.handleClickClackInbound).not.toHaveBeenCalled();
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { RawData } from "ws";
|
||||
import { resolveClickClackInboundAccess } from "./access.js";
|
||||
import { resolveClickClackAccount } from "./accounts.js";
|
||||
import { createClickClackClient } from "./http-client.js";
|
||||
import { handleClickClackInbound } from "./inbound.js";
|
||||
@@ -93,7 +94,20 @@ async function processEvent(params: {
|
||||
if (message.author?.kind === "bot") {
|
||||
return;
|
||||
}
|
||||
await handleClickClackInbound({ account: params.account, config: params.config, message });
|
||||
const access = await resolveClickClackInboundAccess({
|
||||
account: params.account,
|
||||
config: params.config,
|
||||
message,
|
||||
});
|
||||
if (!access.shouldDispatch) {
|
||||
return;
|
||||
}
|
||||
await handleClickClackInbound({
|
||||
account: params.account,
|
||||
config: params.config,
|
||||
message,
|
||||
access,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startClickClackGatewayAccount(
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleClickClackInbound } from "./inbound.js";
|
||||
import { setClickClackRuntime } from "./runtime.js";
|
||||
import type { CoreConfig, ResolvedClickClackAccount } from "./types.js";
|
||||
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
|
||||
|
||||
const sendClickClackTextMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -72,6 +72,58 @@ function createRuntime(): PluginRuntime {
|
||||
} as unknown as PluginRuntime);
|
||||
}
|
||||
|
||||
function createAgentAccount(
|
||||
overrides: Partial<ResolvedClickClackAccount> = {},
|
||||
): ResolvedClickClackAccount {
|
||||
const base = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
token: "ccb_default",
|
||||
workspace: "wsp_1",
|
||||
replyMode: "agent",
|
||||
toolsAllow: [],
|
||||
defaultTo: "channel:general",
|
||||
allowFrom: ["*"],
|
||||
reconnectMs: 1_500,
|
||||
config: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
} satisfies ResolvedClickClackAccount;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
config: {
|
||||
...base.config,
|
||||
...overrides.config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<ClickClackMessage> = {}): ClickClackMessage {
|
||||
return {
|
||||
id: "msg_1",
|
||||
workspace_id: "wsp_1",
|
||||
channel_id: "chn_1",
|
||||
author_id: "usr_owner",
|
||||
thread_root_id: "msg_1",
|
||||
body: "/fast on",
|
||||
body_format: "markdown",
|
||||
created_at: "2026-05-09T12:00:00.000Z",
|
||||
author: {
|
||||
id: "usr_owner",
|
||||
kind: "human",
|
||||
display_name: "Peter",
|
||||
handle: "steipete",
|
||||
avatar_url: "",
|
||||
created_at: "2026-05-09T12:00:00.000Z",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleClickClackInbound", () => {
|
||||
it("runs model-mode bot accounts without tools and posts the bot reply", async () => {
|
||||
sendClickClackTextMock.mockReset();
|
||||
@@ -139,4 +191,95 @@ describe("handleClickClackInbound", () => {
|
||||
expect(sendRequest?.text).toBe("service bot online");
|
||||
expect(sendRequest?.replyToId).toBe("msg_1");
|
||||
});
|
||||
|
||||
it("marks agent turns command-authorized for allowlisted senders", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
|
||||
setClickClackRuntime(runtime);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
} satisfies CoreConfig;
|
||||
|
||||
await handleClickClackInbound({
|
||||
account: createAgentAccount({
|
||||
allowFrom: ["usr_owner"],
|
||||
config: { allowFrom: ["usr_owner"] },
|
||||
}),
|
||||
config: cfg,
|
||||
message: createMessage(),
|
||||
});
|
||||
|
||||
const runPrepared = vi.mocked(runtime.channel.turn.runPrepared);
|
||||
expect(runPrepared).toHaveBeenCalledTimes(1);
|
||||
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts ClickClack DM target syntax in allowFrom", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
|
||||
setClickClackRuntime(runtime);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
} satisfies CoreConfig;
|
||||
|
||||
await handleClickClackInbound({
|
||||
account: createAgentAccount({
|
||||
allowFrom: ["dm:usr_owner"],
|
||||
config: { allowFrom: ["dm:usr_owner"] },
|
||||
}),
|
||||
config: cfg,
|
||||
message: createMessage({
|
||||
channel_id: undefined,
|
||||
direct_conversation_id: "dcn_1",
|
||||
}),
|
||||
});
|
||||
|
||||
const runPrepared = vi.mocked(runtime.channel.turn.runPrepared);
|
||||
expect(runPrepared).toHaveBeenCalledTimes(1);
|
||||
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
|
||||
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("does not dispatch agent turns from senders outside allowFrom", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
|
||||
setClickClackRuntime(runtime);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
} satisfies CoreConfig;
|
||||
|
||||
await handleClickClackInbound({
|
||||
account: createAgentAccount({
|
||||
allowFrom: ["usr_owner"],
|
||||
config: { allowFrom: ["usr_owner"] },
|
||||
}),
|
||||
config: cfg,
|
||||
message: createMessage({
|
||||
author_id: "usr_attacker",
|
||||
author: {
|
||||
id: "usr_attacker",
|
||||
kind: "human",
|
||||
display_name: "Attacker",
|
||||
handle: "attacker",
|
||||
avatar_url: "",
|
||||
created_at: "2026-05-09T12:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(runtime.channel.turn.runPrepared).not.toHaveBeenCalled();
|
||||
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveClickClackInboundAccess, type ClickClackInboundAccess } from "./access.js";
|
||||
import { sendClickClackText } from "./outbound.js";
|
||||
import { getClickClackRuntime } from "./runtime.js";
|
||||
import { buildClickClackTarget } from "./target.js";
|
||||
@@ -81,9 +82,20 @@ export async function handleClickClackInbound(params: {
|
||||
account: ResolvedClickClackAccount;
|
||||
config: CoreConfig;
|
||||
message: ClickClackMessage;
|
||||
access?: ClickClackInboundAccess;
|
||||
}) {
|
||||
const runtime = getClickClackRuntime();
|
||||
const message = params.message;
|
||||
const access =
|
||||
params.access ??
|
||||
(await resolveClickClackInboundAccess({
|
||||
account: params.account,
|
||||
config: params.config,
|
||||
message,
|
||||
}));
|
||||
if (!access.shouldDispatch) {
|
||||
return;
|
||||
}
|
||||
const isDirect = Boolean(message.direct_conversation_id);
|
||||
const target = buildClickClackTarget(
|
||||
isDirect
|
||||
@@ -150,7 +162,7 @@ export async function handleClickClackInbound(params: {
|
||||
Timestamp: message.created_at,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: target,
|
||||
CommandAuthorized: true,
|
||||
CommandAuthorized: access.commandAuthorized,
|
||||
});
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
|
||||
81
extensions/codex/npm-shrinkwrap.json
generated
81
extensions/codex/npm-shrinkwrap.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "2026.5.26",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.75.5",
|
||||
"@openai/codex": "0.133.0",
|
||||
"@openai/codex": "0.134.0",
|
||||
"typebox": "1.1.38",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
@@ -274,23 +274,6 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1052.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
|
||||
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.13",
|
||||
"@aws-sdk/nested-clients": "^3.997.11",
|
||||
"@aws-sdk/types": "^3.973.9",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.43",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz",
|
||||
@@ -781,9 +764,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@openai/codex": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0.tgz",
|
||||
"integrity": "sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ==",
|
||||
"version": "0.134.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0.tgz",
|
||||
"integrity": "sha512-N0vmdTXl/rglZjgd3PaMe9oRrqjO6zZ//uAvUhCDRnJNAUT3LrpYvCK3y9B/ev7QcChfXR43IGUh3ssqWRvMmA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
@@ -792,19 +775,19 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.133.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.133.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.133.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.133.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.133.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.133.0-win32-x64"
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.134.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.134.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.134.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.134.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.134.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.134.0-win32-x64"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.133.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg==",
|
||||
"version": "0.134.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-pOxwQjb1HHtY6KG66+g/rX7uP4yBvchfCrQw22ddYy64s7fJqnD6UV/Ur60j6MWXt71jcaWLEkV1pJthQy9CFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -819,9 +802,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.133.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg==",
|
||||
"version": "0.134.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-XjRtq8PB9dtpxQ5QU6TrzR/z8EVlLLk55oonsyRp3VkMOsKjJWXvLxAnmzUm1MuVZqz90Ua7CJbr+8BG+ZUWpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -836,9 +819,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-linux-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.133.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ==",
|
||||
"version": "0.134.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-fqI8iClQGvrANFx/dJwZK8KNQlqlQKo7A/UB5G7IaeTAAJ+y/CG2R33Bbd9GboH/8ormY39ureNk27eqt++51g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -853,9 +836,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-linux-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.133.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-linux-x64.tgz",
|
||||
"integrity": "sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA==",
|
||||
"version": "0.134.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-linux-x64.tgz",
|
||||
"integrity": "sha512-d/o1AVAniQU2oSEq7ZV0hVwzmk6Dj2IWeNPLnX/KXyv1DfIMJbY+qEg/xhfRmuVW4VPhrhQLBITwrkAYviy1MA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -870,9 +853,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-win32-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.133.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g==",
|
||||
"version": "0.134.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-8OdRmbCcyLLMF3Bg6945PW6INZ7bZVygYo2lusnC0Q2KZ3MRYrMnXRJ6mfvkDc8kPpFE+djMFnQ70gt/jBLVCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -887,9 +870,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-win32-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.133.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-win32-x64.tgz",
|
||||
"integrity": "sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ==",
|
||||
"version": "0.134.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-win32-x64.tgz",
|
||||
"integrity": "sha512-hW1omBcN1jKeVUVnTqWlpc42nF2qAwCEN6l1IFeKFJegYoZ39YrE7pdh56gAmaZTyT5Eexx7cgNpaEK3JElxvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -963,12 +946,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
|
||||
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz",
|
||||
"integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/core": "^3.24.4",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.75.5",
|
||||
"@openai/codex": "0.133.0",
|
||||
"@openai/codex": "0.134.0",
|
||||
"typebox": "1.1.38",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
@@ -311,6 +311,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -321,6 +322,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
expect(mockInvokeNativeHookRelay).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
event: "pre_tool_use",
|
||||
rawPayload: {
|
||||
hook_event_name: "PreToolUse",
|
||||
@@ -342,6 +344,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
cmd: "cat /tmp/private_key",
|
||||
},
|
||||
},
|
||||
requireGeneration: true,
|
||||
});
|
||||
findApprovalEvent(params, {
|
||||
status: "denied",
|
||||
@@ -374,6 +377,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -416,6 +420,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -455,6 +460,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -498,6 +504,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -534,6 +541,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -565,6 +573,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-missing",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
@@ -589,6 +598,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
.mockResolvedValueOnce({ id: "plugin:permission-approval", decision: "deny" });
|
||||
const nativeHookRelay = {
|
||||
relayId: "relay-1",
|
||||
generation: "generation-1",
|
||||
allowedEvents: ["pre_tool_use" as const],
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: Pick<NativeHookRelayRegistrationHandle, "allowedEvents" | "relayId">;
|
||||
nativeHookRelay?: Pick<
|
||||
NativeHookRelayRegistrationHandle,
|
||||
"allowedEvents" | "generation" | "relayId"
|
||||
>;
|
||||
autoApprove?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<JsonValue | undefined> {
|
||||
@@ -316,7 +319,10 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
context: ApprovalContext;
|
||||
nativeHookRelay?: Pick<NativeHookRelayRegistrationHandle, "allowedEvents" | "relayId">;
|
||||
nativeHookRelay?: Pick<
|
||||
NativeHookRelayRegistrationHandle,
|
||||
"allowedEvents" | "generation" | "relayId"
|
||||
>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ApprovalPolicyOutcome | undefined> {
|
||||
const policyRequest = buildOpenClawToolPolicyRequest(params.method, params.requestParams);
|
||||
@@ -379,7 +385,10 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
|
||||
requestParams: JsonObject | undefined;
|
||||
context: ApprovalContext;
|
||||
policyRequest: { toolName: string; params: JsonObject };
|
||||
nativeHookRelay?: Pick<NativeHookRelayRegistrationHandle, "allowedEvents" | "relayId">;
|
||||
nativeHookRelay?: Pick<
|
||||
NativeHookRelayRegistrationHandle,
|
||||
"allowedEvents" | "generation" | "relayId"
|
||||
>;
|
||||
cwd?: string;
|
||||
}): Promise<
|
||||
| {
|
||||
@@ -423,8 +432,10 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
|
||||
const response = await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: params.nativeHookRelay.relayId,
|
||||
generation: params.nativeHookRelay.generation,
|
||||
event: "pre_tool_use",
|
||||
rawPayload: payload,
|
||||
requireGeneration: true,
|
||||
});
|
||||
const decision = readNativeRelayPreToolUseDecision(response);
|
||||
if (decision.blocked) {
|
||||
|
||||
@@ -131,6 +131,16 @@ function resolveCodexAppServerAuthProfileStore(params: {
|
||||
authProfileStore?: AuthProfileStore;
|
||||
config?: AuthProfileOrderConfig;
|
||||
}): AuthProfileStore {
|
||||
if (params.authProfileStore) {
|
||||
const providedProfileId = resolveCodexAppServerAuthProfileId({
|
||||
authProfileId: params.authProfileId,
|
||||
store: params.authProfileStore,
|
||||
config: params.config,
|
||||
});
|
||||
if (providedProfileId && params.authProfileStore.profiles[providedProfileId]) {
|
||||
return params.authProfileStore;
|
||||
}
|
||||
}
|
||||
const overlaidStore = ensureCodexAppServerAuthProfileStore({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
|
||||
@@ -22,3 +22,13 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
) =>
|
||||
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ function startCompaction(sessionFile: string, options: { currentTokenCount?: num
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -64,6 +65,7 @@ function startSandboxedCompaction(sessionFile: string) {
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
});
|
||||
}
|
||||
@@ -74,6 +76,7 @@ function startNodeExecCompaction(sessionFile: string) {
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: { tools: { exec: { host: "node", node: "worker-1" } } },
|
||||
});
|
||||
}
|
||||
@@ -123,6 +126,63 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
expect(details.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("skips native app-server compaction for automatic budget triggers", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding();
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fake.request).not.toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toBe("codex app-server owns automatic compaction");
|
||||
expect(result.result?.tokensBefore).toBe(456);
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "non_manual_trigger",
|
||||
trigger: "budget",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips native app-server compaction when trigger is omitted", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding();
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
currentTokenCount: 789,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fake.request).not.toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toBe("codex app-server owns automatic compaction");
|
||||
expect(result.result?.tokensBefore).toBe(789);
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "non_manual_trigger",
|
||||
trigger: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks native app-server compaction when the current OpenClaw session is sandboxed", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
@@ -278,6 +338,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -313,6 +374,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
sessionKey: "agent:sara:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
@@ -354,6 +416,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
sessionKey: "agent:nik:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -402,6 +465,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
sessionKey: "agent:lossless:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
contextEngine,
|
||||
config: {
|
||||
plugins: {
|
||||
@@ -455,6 +519,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
sessionKey: "agent:lossless-child:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
contextEngine,
|
||||
config: {
|
||||
plugins: {
|
||||
@@ -511,6 +576,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
authProfileId: "openai-codex:runtime",
|
||||
});
|
||||
|
||||
|
||||
@@ -122,6 +122,29 @@ async function compactCodexNativeThread(
|
||||
params: CompactEmbeddedPiSessionParams,
|
||||
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
|
||||
): Promise<EmbeddedPiCompactResult | undefined> {
|
||||
if (params.trigger !== "manual") {
|
||||
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
trigger: params.trigger,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "codex app-server owns automatic compaction",
|
||||
result: {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: {
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "non_manual_trigger",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: params.config,
|
||||
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
||||
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
|
||||
import {
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
embeddedAgentLog,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
@@ -257,6 +258,90 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
expect(heartbeatExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps available and registered schemas paired with their tools", () => {
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({
|
||||
name: "message",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { current: { type: "string" } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
registeredTools: [
|
||||
createTool({
|
||||
name: "message",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { durable: { type: "string" } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(bridge.availableSpecs[0]?.inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: { current: { type: "string" } },
|
||||
});
|
||||
expect(bridge.specs[0]?.inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: { durable: { type: "string" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("quarantines dynamic tools with unsupported input schemas", async () => {
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const badExecute = vi.fn();
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({ name: "message" }),
|
||||
createTool({
|
||||
name: "dofbot_move_angles",
|
||||
parameters: { type: "array", items: { type: "number" } },
|
||||
execute: badExecute,
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(bridge.telemetry.quarantinedTools).toEqual([
|
||||
{
|
||||
tool: "dofbot_move_angles",
|
||||
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
|
||||
},
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("dofbot_move_angles"),
|
||||
expect.objectContaining({
|
||||
tools: [
|
||||
{
|
||||
tool: "dofbot_move_angles",
|
||||
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "dofbot_move_angles",
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: dofbot_move_angles" }],
|
||||
});
|
||||
expect(badExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can expose all dynamic tools directly for compatibility", () => {
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "web_search" }), createTool({ name: "message" })],
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
extractToolResultMediaArtifact,
|
||||
filterToolResultMediaUrls,
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
embeddedAgentLog,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isMessagingTool,
|
||||
isMessagingToolSendAction,
|
||||
normalizeHeartbeatToolResponse,
|
||||
projectRuntimeToolInputSchema,
|
||||
runAgentHarnessAfterToolCallHook,
|
||||
setBeforeToolCallDiagnosticsEnabled,
|
||||
type AnyAgentTool,
|
||||
@@ -46,6 +48,16 @@ type CodexDynamicToolHookContext = {
|
||||
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
inputSchema: JsonValue;
|
||||
};
|
||||
|
||||
type CodexDynamicToolSchemaQuarantine = {
|
||||
tool: string;
|
||||
violations: readonly string[];
|
||||
};
|
||||
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
specs: CodexDynamicToolSpec[];
|
||||
@@ -63,6 +75,7 @@ export type CodexDynamicToolBridge = {
|
||||
toolMediaUrls: string[];
|
||||
toolAudioAsVoice: boolean;
|
||||
successfulCronAdds?: number;
|
||||
quarantinedTools: CodexDynamicToolSchemaQuarantine[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -83,16 +96,28 @@ export function createCodexDynamicToolBridge(params: {
|
||||
}): CodexDynamicToolBridge {
|
||||
const toolResultHookContext = toToolResultHookContext(params.hookContext);
|
||||
const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars(params.hookContext);
|
||||
const tools = params.tools.map((tool) => {
|
||||
const availableProjection = projectCodexDynamicTools(params.tools);
|
||||
const registeredProjection = params.registeredTools
|
||||
? projectCodexDynamicTools(params.registeredTools)
|
||||
: availableProjection;
|
||||
const availableTools = availableProjection.tools.map(({ tool, inputSchema }) => {
|
||||
if (isToolWrappedWithBeforeToolCallHook(tool)) {
|
||||
setBeforeToolCallDiagnosticsEnabled(tool, false);
|
||||
return tool;
|
||||
return { tool, inputSchema };
|
||||
}
|
||||
return wrapToolWithBeforeToolCallHook(tool, params.hookContext, { emitDiagnostics: false });
|
||||
return {
|
||||
tool: wrapToolWithBeforeToolCallHook(tool, params.hookContext, { emitDiagnostics: false }),
|
||||
inputSchema,
|
||||
};
|
||||
});
|
||||
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
const registeredTools = params.registeredTools ?? tools;
|
||||
const toolMap = new Map(availableTools.map(({ tool }) => [tool.name, tool]));
|
||||
const registeredTools = registeredProjection.tools.map(({ tool }) => tool);
|
||||
const registeredToolNames = new Set(registeredTools.map((tool) => tool.name));
|
||||
const quarantinedTools = dedupeQuarantinedDynamicTools([
|
||||
...availableProjection.quarantinedTools,
|
||||
...registeredProjection.quarantinedTools,
|
||||
]);
|
||||
warnQuarantinedDynamicTools(quarantinedTools);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
@@ -101,6 +126,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
toolMediaUrls: [],
|
||||
toolAudioAsVoice: false,
|
||||
quarantinedTools,
|
||||
};
|
||||
const middlewareRunner = createAgentToolResultMiddlewareRunner({
|
||||
runtime: "codex",
|
||||
@@ -114,16 +140,18 @@ export function createCodexDynamicToolBridge(params: {
|
||||
]);
|
||||
|
||||
return {
|
||||
availableSpecs: tools.map((tool) =>
|
||||
availableSpecs: availableTools.map(({ tool, inputSchema }) =>
|
||||
createCodexDynamicToolSpec({
|
||||
tool,
|
||||
inputSchema,
|
||||
loading: params.loading ?? "searchable",
|
||||
directToolNames,
|
||||
}),
|
||||
),
|
||||
specs: registeredTools.map((tool) =>
|
||||
specs: registeredProjection.tools.map(({ tool, inputSchema }) =>
|
||||
createCodexDynamicToolSpec({
|
||||
tool,
|
||||
inputSchema,
|
||||
loading: params.loading ?? "searchable",
|
||||
directToolNames,
|
||||
}),
|
||||
@@ -257,13 +285,14 @@ export function createCodexDynamicToolBridge(params: {
|
||||
|
||||
function createCodexDynamicToolSpec(params: {
|
||||
tool: AnyAgentTool;
|
||||
inputSchema: JsonValue;
|
||||
loading: CodexDynamicToolsLoading;
|
||||
directToolNames: ReadonlySet<string>;
|
||||
}): CodexDynamicToolSpec {
|
||||
const base = {
|
||||
name: params.tool.name,
|
||||
description: params.tool.description,
|
||||
inputSchema: toJsonValue(params.tool.parameters),
|
||||
inputSchema: params.inputSchema,
|
||||
};
|
||||
if (params.loading === "direct" || params.directToolNames.has(params.tool.name)) {
|
||||
return base;
|
||||
@@ -274,6 +303,55 @@ function createCodexDynamicToolSpec(params: {
|
||||
deferLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): {
|
||||
tools: ProjectedCodexDynamicTool[];
|
||||
quarantinedTools: CodexDynamicToolSchemaQuarantine[];
|
||||
} {
|
||||
const projectedTools: ProjectedCodexDynamicTool[] = [];
|
||||
const quarantinedTools: CodexDynamicToolSchemaQuarantine[] = [];
|
||||
for (const tool of tools) {
|
||||
const projection = projectRuntimeToolInputSchema(tool.parameters, `${tool.name}.inputSchema`);
|
||||
if (projection.violations.length > 0) {
|
||||
quarantinedTools.push({ tool: tool.name, violations: projection.violations });
|
||||
continue;
|
||||
}
|
||||
projectedTools.push({ tool, inputSchema: projection.schema as JsonValue });
|
||||
}
|
||||
return { tools: projectedTools, quarantinedTools };
|
||||
}
|
||||
|
||||
function warnQuarantinedDynamicTools(tools: readonly CodexDynamicToolSchemaQuarantine[]): void {
|
||||
if (tools.length === 0) {
|
||||
return;
|
||||
}
|
||||
const unique = new Map<string, readonly string[]>();
|
||||
for (const tool of tools) {
|
||||
unique.set(tool.tool, tool.violations);
|
||||
}
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server quarantined ${unique.size} dynamic ${unique.size === 1 ? "tool" : "tools"} with unsupported input schemas: ${[...unique.keys()].join(", ")}`,
|
||||
{
|
||||
tools: [...unique.entries()].map(([tool, violations]) => ({ tool, violations })),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeQuarantinedDynamicTools(
|
||||
tools: readonly CodexDynamicToolSchemaQuarantine[],
|
||||
): CodexDynamicToolSchemaQuarantine[] {
|
||||
return [
|
||||
...new Map(
|
||||
tools.map((tool) => [
|
||||
tool.tool,
|
||||
{
|
||||
tool: tool.tool,
|
||||
violations: tool.violations,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
}
|
||||
function toToolResultHookContext(
|
||||
ctx: CodexDynamicToolHookContext | undefined,
|
||||
): CodexToolResultHookContext {
|
||||
@@ -634,18 +712,6 @@ function convertToolContent(
|
||||
];
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): JsonValue {
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(text) as JsonValue;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function jsonObjectToRecord(value: JsonValue | undefined): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => {
|
||||
const authBridge = {
|
||||
applyAuthProfile: vi.fn(async () => undefined),
|
||||
authProfileId: vi.fn((params?: { authProfileId?: string }) => params?.authProfileId),
|
||||
fallbackApiKeyCacheKey: vi.fn(() => undefined),
|
||||
startOptions: vi.fn(async ({ startOptions }) => startOptions),
|
||||
};
|
||||
const managedBinary = {
|
||||
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => {
|
||||
vi.mock("./auth-bridge.js", () => ({
|
||||
applyCodexAppServerAuthProfile: mocks.authBridge.applyAuthProfile,
|
||||
bridgeCodexAppServerStartOptions: mocks.authBridge.startOptions,
|
||||
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.authBridge.fallbackApiKeyCacheKey,
|
||||
resolveCodexAppServerAuthProfileIdForAgent: mocks.authBridge.authProfileId,
|
||||
}));
|
||||
|
||||
@@ -50,6 +52,8 @@ describe("listCodexAppServerModels", () => {
|
||||
mocks.authBridge.authProfileId.mockImplementation(
|
||||
(params?: { authProfileId?: string }) => params?.authProfileId,
|
||||
);
|
||||
mocks.authBridge.fallbackApiKeyCacheKey.mockClear();
|
||||
mocks.authBridge.fallbackApiKeyCacheKey.mockReturnValue(undefined);
|
||||
mocks.authBridge.startOptions.mockClear();
|
||||
mocks.managedBinary.startOptions.mockClear();
|
||||
mocks.managedBinary.startOptions.mockImplementation(async (startOptions) => startOptions);
|
||||
|
||||
@@ -74,10 +74,13 @@ async function withCodexAppServerModelClient<T>(
|
||||
): Promise<T> {
|
||||
const timeoutMs = options.timeoutMs ?? 2500;
|
||||
const useSharedClient = options.sharedClient !== false;
|
||||
const { createIsolatedCodexAppServerClient, getSharedCodexAppServerClient } =
|
||||
await import("./shared-client.js");
|
||||
const {
|
||||
createIsolatedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} = await import("./shared-client.js");
|
||||
const client = useSharedClient
|
||||
? await getSharedCodexAppServerClient({
|
||||
? await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
@@ -94,7 +97,9 @@ async function withCodexAppServerModelClient<T>(
|
||||
try {
|
||||
return await run({ client, timeoutMs });
|
||||
} finally {
|
||||
if (!useSharedClient) {
|
||||
if (useSharedClient) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
} else {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -34,7 +34,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event post_tool_use",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event post_tool_use",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -48,7 +48,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -62,7 +62,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event before_agent_finalize",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event before_agent_finalize",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -125,7 +125,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -160,7 +160,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -200,7 +200,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -260,6 +260,7 @@ function createRelay(options?: {
|
||||
return {
|
||||
relayId: "relay-1",
|
||||
provider: "codex",
|
||||
generation: "generation-1",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
@@ -267,7 +268,7 @@ function createRelay(options?: {
|
||||
expiresAtMs: Date.now() + 1000,
|
||||
shouldRelayEvent: (event) => !inactiveEvents.has(event),
|
||||
commandForEvent: (event) =>
|
||||
`openclaw hooks relay --provider codex --relay-id relay-1 --event ${event}`,
|
||||
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}`,
|
||||
renew: () => undefined,
|
||||
unregister: () => undefined,
|
||||
};
|
||||
|
||||
30
extensions/codex/src/app-server/profiler-flag.test.ts
Normal file
30
extensions/codex/src/app-server/profiler-flag.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
|
||||
|
||||
describe("isCodexAppServerProfilerEnabled", () => {
|
||||
it("is disabled by default", () => {
|
||||
expect(isCodexAppServerProfilerEnabled(undefined, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches global and Codex profiler flags", () => {
|
||||
expect(
|
||||
isCodexAppServerProfilerEnabled(
|
||||
{ diagnostics: { flags: ["codex.profiler"] } },
|
||||
{} as NodeJS.ProcessEnv,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCodexAppServerProfilerEnabled(undefined, {
|
||||
OPENCLAW_DIAGNOSTICS: "profiler",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the documented diagnostics env disable override", () => {
|
||||
expect(
|
||||
isCodexAppServerProfilerEnabled({ diagnostics: { flags: ["codex.profiler"] } }, {
|
||||
OPENCLAW_DIAGNOSTICS: "0",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
11
extensions/codex/src/app-server/profiler-flag.ts
Normal file
11
extensions/codex/src/app-server/profiler-flag.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
|
||||
const PROFILER_FLAGS = ["profiler", "codex.profiler"] as const;
|
||||
|
||||
export function isCodexAppServerProfilerEnabled(
|
||||
config?: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return PROFILER_FLAGS.some((flag) => isDiagnosticFlagEnabled(flag, config, env));
|
||||
}
|
||||
@@ -52,18 +52,6 @@
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissionProfile": {
|
||||
"description": "Full active permissions for this thread. `activePermissionProfile` carries display/provenance metadata for this runtime profile.",
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PermissionProfile"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -83,7 +71,7 @@
|
||||
}
|
||||
},
|
||||
"sandbox": {
|
||||
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions.",
|
||||
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SandboxPolicy"
|
||||
@@ -570,202 +558,6 @@
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"FileSystemAccessMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"read",
|
||||
"write",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"FileSystemPath": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"path"
|
||||
],
|
||||
"title": "PathFileSystemPathType"
|
||||
}
|
||||
},
|
||||
"title": "PathFileSystemPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pattern",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"glob_pattern"
|
||||
],
|
||||
"title": "GlobPatternFileSystemPathType"
|
||||
}
|
||||
},
|
||||
"title": "GlobPatternFileSystemPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"special"
|
||||
],
|
||||
"title": "SpecialFileSystemPathType"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/definitions/FileSystemSpecialPath"
|
||||
}
|
||||
},
|
||||
"title": "SpecialFileSystemPath"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileSystemSandboxEntry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"access",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"access": {
|
||||
"$ref": "#/definitions/FileSystemAccessMode"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/definitions/FileSystemPath"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileSystemSpecialPath": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"root"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "RootFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"minimal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "MinimalFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"project_roots"
|
||||
]
|
||||
},
|
||||
"subpath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "KindFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tmpdir"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "TmpdirFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"slash_tmp"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "SlashTmpFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileUpdateChange": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -823,6 +615,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageDetail": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"high",
|
||||
"original"
|
||||
]
|
||||
},
|
||||
"McpToolCallError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1004,135 +803,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionProfile": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Codex owns sandbox construction for this profile.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"fileSystem",
|
||||
"network",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"fileSystem": {
|
||||
"$ref": "#/definitions/PermissionProfileFileSystemPermissions"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"managed"
|
||||
],
|
||||
"title": "ManagedPermissionProfileType"
|
||||
}
|
||||
},
|
||||
"title": "ManagedPermissionProfile"
|
||||
},
|
||||
{
|
||||
"description": "Do not apply an outer sandbox.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled"
|
||||
],
|
||||
"title": "DisabledPermissionProfileType"
|
||||
}
|
||||
},
|
||||
"title": "DisabledPermissionProfile"
|
||||
},
|
||||
{
|
||||
"description": "Filesystem isolation is enforced by an external caller.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"network",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"network": {
|
||||
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"external"
|
||||
],
|
||||
"title": "ExternalPermissionProfileType"
|
||||
}
|
||||
},
|
||||
"title": "ExternalPermissionProfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionProfileFileSystemPermissions": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"entries",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/FileSystemSandboxEntry"
|
||||
}
|
||||
},
|
||||
"globScanMaxDepth": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint",
|
||||
"minimum": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"restricted"
|
||||
],
|
||||
"title": "RestrictedPermissionProfileFileSystemPermissionsType"
|
||||
}
|
||||
},
|
||||
"title": "RestrictedPermissionProfileFileSystemPermissions"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unrestricted"
|
||||
],
|
||||
"title": "UnrestrictedPermissionProfileFileSystemPermissionsType"
|
||||
}
|
||||
},
|
||||
"title": "UnrestrictedPermissionProfileFileSystemPermissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionProfileNetworkPermissions": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"type": "string",
|
||||
@@ -2442,6 +2112,17 @@
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2462,6 +2143,17 @@
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -52,18 +52,6 @@
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissionProfile": {
|
||||
"description": "Full active permissions for this thread. `activePermissionProfile` carries display/provenance metadata for this runtime profile.",
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PermissionProfile"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -83,7 +71,7 @@
|
||||
}
|
||||
},
|
||||
"sandbox": {
|
||||
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions.",
|
||||
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SandboxPolicy"
|
||||
@@ -570,202 +558,6 @@
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"FileSystemAccessMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"read",
|
||||
"write",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"FileSystemPath": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"path"
|
||||
],
|
||||
"title": "PathFileSystemPathType"
|
||||
}
|
||||
},
|
||||
"title": "PathFileSystemPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pattern",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"glob_pattern"
|
||||
],
|
||||
"title": "GlobPatternFileSystemPathType"
|
||||
}
|
||||
},
|
||||
"title": "GlobPatternFileSystemPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"special"
|
||||
],
|
||||
"title": "SpecialFileSystemPathType"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/definitions/FileSystemSpecialPath"
|
||||
}
|
||||
},
|
||||
"title": "SpecialFileSystemPath"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileSystemSandboxEntry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"access",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"access": {
|
||||
"$ref": "#/definitions/FileSystemAccessMode"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/definitions/FileSystemPath"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileSystemSpecialPath": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"root"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "RootFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"minimal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "MinimalFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"project_roots"
|
||||
]
|
||||
},
|
||||
"subpath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "KindFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tmpdir"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "TmpdirFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"slash_tmp"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "SlashTmpFileSystemSpecialPath"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileUpdateChange": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -823,6 +615,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageDetail": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"high",
|
||||
"original"
|
||||
]
|
||||
},
|
||||
"McpToolCallError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1004,135 +803,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionProfile": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Codex owns sandbox construction for this profile.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"fileSystem",
|
||||
"network",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"fileSystem": {
|
||||
"$ref": "#/definitions/PermissionProfileFileSystemPermissions"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"managed"
|
||||
],
|
||||
"title": "ManagedPermissionProfileType"
|
||||
}
|
||||
},
|
||||
"title": "ManagedPermissionProfile"
|
||||
},
|
||||
{
|
||||
"description": "Do not apply an outer sandbox.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled"
|
||||
],
|
||||
"title": "DisabledPermissionProfileType"
|
||||
}
|
||||
},
|
||||
"title": "DisabledPermissionProfile"
|
||||
},
|
||||
{
|
||||
"description": "Filesystem isolation is enforced by an external caller.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"network",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"network": {
|
||||
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"external"
|
||||
],
|
||||
"title": "ExternalPermissionProfileType"
|
||||
}
|
||||
},
|
||||
"title": "ExternalPermissionProfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionProfileFileSystemPermissions": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"entries",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/FileSystemSandboxEntry"
|
||||
}
|
||||
},
|
||||
"globScanMaxDepth": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint",
|
||||
"minimum": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"restricted"
|
||||
],
|
||||
"title": "RestrictedPermissionProfileFileSystemPermissionsType"
|
||||
}
|
||||
},
|
||||
"title": "RestrictedPermissionProfileFileSystemPermissions"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unrestricted"
|
||||
],
|
||||
"title": "UnrestrictedPermissionProfileFileSystemPermissionsType"
|
||||
}
|
||||
},
|
||||
"title": "UnrestrictedPermissionProfileFileSystemPermissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionProfileNetworkPermissions": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"type": "string",
|
||||
@@ -2442,6 +2112,17 @@
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2462,6 +2143,17 @@
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -436,6 +436,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageDetail": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"high",
|
||||
"original"
|
||||
]
|
||||
},
|
||||
"McpToolCallError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1471,6 +1478,17 @@
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1491,6 +1509,17 @@
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -432,6 +432,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageDetail": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"high",
|
||||
"original"
|
||||
]
|
||||
},
|
||||
"McpToolCallError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1467,6 +1474,17 @@
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1487,6 +1505,17 @@
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"default": null,
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,11 @@ const sharedClientMocks = vi.hoisted(() => ({
|
||||
getSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./shared-client.js", () => sharedClientMocks);
|
||||
vi.mock("./shared-client.js", () => ({
|
||||
...sharedClientMocks,
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { requestCodexAppServerJson } = await import("./request.js");
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
|
||||
import {
|
||||
createIsolatedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { withTimeout } from "./timeout.js";
|
||||
|
||||
@@ -63,7 +64,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const client = await (
|
||||
params.isolated ? createIsolatedCodexAppServerClient : getSharedCodexAppServerClient
|
||||
params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient
|
||||
)({
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
@@ -81,6 +82,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
// underlying codex binary, so the unref'd close() path can leave
|
||||
// the child running and keep the parent's event loop alive.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
} else {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -887,6 +887,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await closeCodexSandboxExecServersForTests();
|
||||
resetCodexAppServerClientFactoryForTest();
|
||||
testing.resetOpenClawCodingToolsFactoryForTests();
|
||||
testing.resetEnsuredCodexWorkspaceDirsForTests();
|
||||
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
nativeHookRelayTesting.clearNativeHookRelaysForTests();
|
||||
@@ -903,6 +904,16 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("recreates cached Codex workspace directories after cleanup removes them", async () => {
|
||||
const workspaceDir = path.join(tempDir, "cached-workspace");
|
||||
|
||||
await testing.ensureCodexWorkspaceDirOnceForTests(workspaceDir);
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
await testing.ensureCodexWorkspaceDirOnceForTests(workspaceDir);
|
||||
|
||||
expect((await fs.stat(workspaceDir)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("filters Codex-native dynamic tools from app-server tool exposure", () => {
|
||||
const tools = [
|
||||
"read",
|
||||
@@ -2334,18 +2345,21 @@ describe("runCodexAppServerAttempt", () => {
|
||||
testing.buildDeveloperInstructions(params, {
|
||||
dynamicTools: [createMessageDynamicTool("Message test tool")],
|
||||
}),
|
||||
).toContain("To send a visible message, use the `message` tool.");
|
||||
).toContain("Visible source replies are not automatically delivered for this run.");
|
||||
|
||||
const withoutMessageToolInstructions = testing.buildDeveloperInstructions(params, {
|
||||
dynamicTools: [],
|
||||
});
|
||||
expect(withoutMessageToolInstructions).toContain("active Codex delivery path");
|
||||
expect(withoutMessageToolInstructions).not.toContain("use the `message` tool");
|
||||
expect(withoutMessageToolInstructions).toContain(
|
||||
"reply normally in your final assistant message",
|
||||
);
|
||||
expect(withoutMessageToolInstructions).not.toContain("message(action=send)");
|
||||
expect(withoutMessageToolInstructions).not.toContain("Use `message`");
|
||||
|
||||
params.sourceReplyDeliveryMode = "automatic";
|
||||
const automaticInstructions = testing.buildDeveloperInstructions(params);
|
||||
expect(automaticInstructions).toContain("active Codex delivery path");
|
||||
expect(automaticInstructions).not.toContain("use the `message` tool");
|
||||
expect(automaticInstructions).toContain("reply normally in your final assistant message");
|
||||
expect(automaticInstructions).not.toContain("message(action=send)");
|
||||
});
|
||||
|
||||
it("includes Codex app-server scoped plugin command guidance in developer instructions", () => {
|
||||
@@ -2433,12 +2447,44 @@ describe("runCodexAppServerAttempt", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps leading delivery hints out of the Codex current user request", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-delivery-hint.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-delivery-hint");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "Delivery: to send a message, use the `message` tool.\n\nhello";
|
||||
params.skillsSnapshot = {
|
||||
prompt: "<available_skills><skill><name>demo</name></skill></available_skills>",
|
||||
skills: [],
|
||||
};
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
};
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toContain("OpenClaw delivery metadata:");
|
||||
expect(inputText).toContain(
|
||||
"This delivery metadata is runtime routing guidance, not the user's request.",
|
||||
);
|
||||
expect(inputText).toContain("Delivery: to send a message, use the `message` tool.");
|
||||
expect(inputText).toContain("Current user request:\nhello");
|
||||
expect(inputText).not.toContain("Current user request:\nDelivery:");
|
||||
});
|
||||
|
||||
it("mirrors the Codex prompt into the transcript when the turn starts", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-early-prompt.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-early-prompt");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "external channel prompt";
|
||||
const onUserMessagePersisted = vi.fn();
|
||||
params.onUserMessagePersisted = onUserMessagePersisted;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
@@ -2448,6 +2494,15 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(raw).toContain('"content":"external channel prompt"');
|
||||
expect(raw).toContain('"idempotencyKey":"codex-app-server:thread-1:turn-1:prompt"');
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onUserMessagePersisted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "external channel prompt",
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const rawBeforeCompletion = await fs.readFile(sessionFile, "utf8");
|
||||
expect(rawBeforeCompletion).not.toContain('"role":"assistant"');
|
||||
@@ -2457,6 +2512,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const rawAfterCompletion = await fs.readFile(sessionFile, "utf8");
|
||||
expect(rawAfterCompletion.match(/"role":"user"/gu)).toHaveLength(1);
|
||||
expect(onUserMessagePersisted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not mirror the Codex prompt early when user message persistence is suppressed", async () => {
|
||||
@@ -3078,6 +3134,22 @@ describe("runCodexAppServerAttempt", () => {
|
||||
).toBe(testing.CODEX_DYNAMIC_MESSAGE_TOOL_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("uses a 90 second default for generic Codex dynamic tool calls", () => {
|
||||
expect(
|
||||
testing.resolveDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-session-status",
|
||||
namespace: null,
|
||||
tool: "session_status",
|
||||
arguments: { sessionKey: "current" },
|
||||
},
|
||||
config: undefined,
|
||||
}),
|
||||
).toBe(90_000);
|
||||
});
|
||||
|
||||
it("caps dynamic tool timeouts at the bridge maximum", () => {
|
||||
expect(
|
||||
testing.resolveDynamicToolCallTimeoutMs({
|
||||
@@ -4149,7 +4221,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
|
||||
it("unsubscribes and closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
|
||||
const close = vi.fn();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
@@ -4193,6 +4265,13 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{
|
||||
threadId: "thread-1",
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
@@ -5508,6 +5587,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("does not treat global rate-limit notifications as turn progress", async () => {
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
@@ -5550,6 +5630,14 @@ describe("runCodexAppServerAttempt", () => {
|
||||
),
|
||||
{ interval: 1 },
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"codex app-server client retired after timed-out turn",
|
||||
expect.objectContaining({
|
||||
reason: "turn_completion_idle_timeout",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("yields a macrotask before processing queued app-server notifications", async () => {
|
||||
@@ -5575,6 +5663,70 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false });
|
||||
});
|
||||
|
||||
it("does not idle-timeout when terminal completion queues behind projection", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 120;
|
||||
const turnStartProgressEvents: DiagnosticEventPayload[] = [];
|
||||
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
|
||||
if (event.type === "run.progress" && event.reason === "codex_app_server:turn:start") {
|
||||
turnStartProgressEvents.push(event);
|
||||
}
|
||||
});
|
||||
let resolveReasoningStarted!: () => void;
|
||||
const reasoningStarted = new Promise<void>((resolve) => {
|
||||
resolveReasoningStarted = resolve;
|
||||
});
|
||||
let releaseProjection!: () => void;
|
||||
const projectionGate = new Promise<void>((resolve) => {
|
||||
releaseProjection = resolve;
|
||||
});
|
||||
params.onReasoningStream = async () => {
|
||||
resolveReasoningStarted();
|
||||
await projectionGate;
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
turnTerminalIdleTimeoutMs: 5,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
await vi.waitFor(() => expect(turnStartProgressEvents).toHaveLength(2), { interval: 1 });
|
||||
stopDiagnostics();
|
||||
|
||||
const blockedProjection = harness.notify({
|
||||
method: "item/reasoning/textDelta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "reasoning-1",
|
||||
delta: "thinking",
|
||||
},
|
||||
});
|
||||
void blockedProjection.catch(() => undefined);
|
||||
await reasoningStarted;
|
||||
|
||||
const queuedTerminal = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
void queuedTerminal.catch(() => undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
releaseProjection();
|
||||
await queuedTerminal;
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("releases the session when a completed agent message item goes quiet", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
@@ -6298,6 +6450,104 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(inputText).toContain("make the default webpage openclaw");
|
||||
});
|
||||
|
||||
it("projects newer mirrored history when resuming an existing Codex thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? "");
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage("David Ondrej was mentioned in that prior thread", bindingUpdatedAt + 2_000),
|
||||
);
|
||||
const harness = createResumeHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "is the previous message trustworthy?";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toContain("thread/resume");
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const inputText =
|
||||
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
|
||||
"";
|
||||
|
||||
expect(inputText).toContain("OpenClaw assembled context for this turn:");
|
||||
expect(inputText).toContain("we were discussing the Sonnet leak screenshots");
|
||||
expect(inputText).toContain("David Ondrej was mentioned in that prior thread");
|
||||
expect(inputText).toContain("Current user request:");
|
||||
expect(inputText).toContain("is the previous message trustworthy?");
|
||||
});
|
||||
|
||||
it("does not reproject Codex-owned mirrored messages on consecutive resumes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const oldBindingUpdatedAt = Date.now() - 60_000;
|
||||
const bindingPath = `${sessionFile}.codex-app-server.json`;
|
||||
const bindingPayload = JSON.parse(await fs.readFile(bindingPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
bindingPayload.updatedAt = new Date(oldBindingUpdatedAt).toISOString();
|
||||
await fs.writeFile(bindingPath, `${JSON.stringify(bindingPayload, null, 2)}\n`);
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage(
|
||||
"David Ondrej was mentioned in that prior thread",
|
||||
oldBindingUpdatedAt + 2_000,
|
||||
),
|
||||
);
|
||||
|
||||
const firstHarness = createResumeHarness();
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
firstParams.prompt = "is the previous message trustworthy?";
|
||||
const firstRun = runCodexAppServerAttempt(firstParams);
|
||||
await firstHarness.waitForMethod("turn/start");
|
||||
await firstHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await firstRun;
|
||||
|
||||
const firstTurnStart = firstHarness.requests.find((request) => request.method === "turn/start");
|
||||
const firstInputText =
|
||||
(firstTurnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]
|
||||
?.text ?? "";
|
||||
expect(firstInputText).toContain("OpenClaw assembled context for this turn:");
|
||||
expect(firstInputText).toContain("we were discussing the Sonnet leak screenshots");
|
||||
expect(firstInputText).toContain("is the previous message trustworthy?");
|
||||
|
||||
const secondHarness = createResumeHarness();
|
||||
const secondParams = createParams(sessionFile, workspaceDir);
|
||||
secondParams.prompt = "continue from there";
|
||||
const secondRun = runCodexAppServerAttempt(secondParams);
|
||||
await secondHarness.waitForMethod("turn/start");
|
||||
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await secondRun;
|
||||
|
||||
const secondTurnStart = secondHarness.requests.find(
|
||||
(request) => request.method === "turn/start",
|
||||
);
|
||||
const secondInputText =
|
||||
(secondTurnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]
|
||||
?.text ?? "";
|
||||
expect(secondInputText).not.toContain("OpenClaw assembled context for this turn:");
|
||||
expect(secondInputText).not.toContain("we were discussing the Sonnet leak screenshots");
|
||||
expect(secondInputText).not.toContain("is the previous message trustworthy?");
|
||||
expect(secondInputText).toContain("continue from there");
|
||||
});
|
||||
|
||||
it("passes stable workspace files as Codex developer instructions and keeps MEMORY.md as turn context", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -8241,8 +8491,12 @@ describe("runCodexAppServerAttempt", () => {
|
||||
);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
expect(queueActiveRunMessageForTest("session-1", "first", { steeringMode: "all" })).toBe(true);
|
||||
expect(queueActiveRunMessageForTest("session-1", "second", { steeringMode: "all" })).toBe(true);
|
||||
expect(
|
||||
queueActiveRunMessageForTest("session-1", "first", { debounceMs: 5, steeringMode: "all" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
queueActiveRunMessageForTest("session-1", "second", { debounceMs: 5, steeringMode: "all" }),
|
||||
).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,12 @@ let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSh
|
||||
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
|
||||
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
|
||||
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
|
||||
let detachSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").detachSharedCodexAppServerClientIfCurrent;
|
||||
let getLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").getLeasedSharedCodexAppServerClient;
|
||||
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
|
||||
let retainSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retainSharedCodexAppServerClientIfCurrent;
|
||||
let releaseLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").releaseLeasedSharedCodexAppServerClient;
|
||||
let retireSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retireSharedCodexAppServerClientIfCurrent;
|
||||
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
|
||||
|
||||
async function sendInitializeResult(
|
||||
@@ -116,7 +121,12 @@ describe("shared Codex app-server client", () => {
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait,
|
||||
createIsolatedCodexAppServerClient,
|
||||
detachSharedCodexAppServerClientIfCurrent,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
retainSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} = await import("./shared-client.js"));
|
||||
});
|
||||
@@ -316,6 +326,39 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves keyed shared-client state when adding lease metadata", async () => {
|
||||
const legacy = createClientHarness();
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39176",
|
||||
authToken: "tok-keyed",
|
||||
headers: {},
|
||||
};
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: unknown;
|
||||
};
|
||||
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
|
||||
clients: new Map([[key, { client: legacy.client, promise: Promise.resolve(legacy.client) }]]),
|
||||
};
|
||||
|
||||
await expect(getLeasedSharedCodexAppServerClient({ startOptions })).resolves.toBe(
|
||||
legacy.client,
|
||||
);
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(legacy.client)).toEqual({
|
||||
activeLeases: 1,
|
||||
closed: false,
|
||||
});
|
||||
expect(legacy.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(legacy.client)).toBe(true);
|
||||
expect(legacy.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -508,6 +551,101 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("can detach the current shared client without closing it", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
|
||||
first.client.close();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
|
||||
second.client.close();
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("closes a retired shared app-server after all active leases release", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const releaseFirst = retainSharedCodexAppServerClientIfCurrent(first.client);
|
||||
const releaseSecond = retainSharedCodexAppServerClientIfCurrent(first.client);
|
||||
expect(releaseFirst).toBeTypeOf("function");
|
||||
expect(releaseSecond).toBeTypeOf("function");
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
releaseFirst?.();
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
releaseSecond?.();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(second.client)).toEqual({
|
||||
activeLeases: 0,
|
||||
closed: true,
|
||||
});
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("leases shared app-server clients before returning concurrent acquirers", async () => {
|
||||
const first = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
|
||||
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await expect(firstLease).resolves.toBe(first.client);
|
||||
await expect(secondLease).resolves.toBe(first.client);
|
||||
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(false);
|
||||
});
|
||||
|
||||
it("waits only for the shared client that is still current", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
|
||||
@@ -17,10 +17,13 @@ import { withTimeout } from "./timeout.js";
|
||||
type SharedCodexAppServerClientEntry = {
|
||||
client?: CodexAppServerClient;
|
||||
promise?: Promise<CodexAppServerClient>;
|
||||
activeLeases: number;
|
||||
closeWhenIdle: boolean;
|
||||
};
|
||||
|
||||
type SharedCodexAppServerClientState = {
|
||||
clients: Map<string, SharedCodexAppServerClientEntry>;
|
||||
leasedReleases: WeakMap<CodexAppServerClient, Array<() => void>>;
|
||||
};
|
||||
|
||||
type LegacySharedCodexAppServerClientState = Partial<SharedCodexAppServerClientEntry> & {
|
||||
@@ -28,6 +31,11 @@ type LegacySharedCodexAppServerClientState = Partial<SharedCodexAppServerClientE
|
||||
clients?: unknown;
|
||||
};
|
||||
|
||||
type KeyedSharedCodexAppServerClientState = {
|
||||
clients: Map<string, Partial<SharedCodexAppServerClientEntry>>;
|
||||
leasedReleases?: unknown;
|
||||
};
|
||||
|
||||
const SHARED_CODEX_APP_SERVER_CLIENT_STATE = Symbol.for("openclaw.codexAppServerClientState");
|
||||
|
||||
function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
@@ -35,31 +43,48 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
[SHARED_CODEX_APP_SERVER_CLIENT_STATE]?: unknown;
|
||||
};
|
||||
const state = globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE];
|
||||
if (isSharedCodexAppServerClientState(state)) {
|
||||
return state;
|
||||
const keyedState = readKeyedSharedCodexAppServerClientState(state);
|
||||
if (keyedState) {
|
||||
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
|
||||
for (const entry of clients.values()) {
|
||||
entry.activeLeases ??= 0;
|
||||
entry.closeWhenIdle ??= false;
|
||||
}
|
||||
const nextState: SharedCodexAppServerClientState = {
|
||||
clients,
|
||||
leasedReleases:
|
||||
keyedState.leasedReleases instanceof WeakMap ? keyedState.leasedReleases : new WeakMap(),
|
||||
};
|
||||
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] = nextState;
|
||||
return nextState;
|
||||
}
|
||||
const legacyState = readLegacySharedCodexAppServerClientState(state);
|
||||
const clients = new Map<string, SharedCodexAppServerClientEntry>();
|
||||
if (legacyState?.key && (legacyState.client || legacyState.promise)) {
|
||||
const legacyKey = legacyState.key;
|
||||
clients.set(legacyKey, { client: legacyState.client, promise: legacyState.promise });
|
||||
clients.set(legacyKey, {
|
||||
client: legacyState.client,
|
||||
promise: legacyState.promise,
|
||||
activeLeases: 0,
|
||||
closeWhenIdle: false,
|
||||
});
|
||||
legacyState.client?.addCloseHandler((closedClient) =>
|
||||
clearSharedClientEntryIfCurrent(legacyKey, closedClient),
|
||||
);
|
||||
}
|
||||
const nextState: SharedCodexAppServerClientState = { clients };
|
||||
const nextState: SharedCodexAppServerClientState = { clients, leasedReleases: new WeakMap() };
|
||||
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] = nextState;
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function isSharedCodexAppServerClientState(
|
||||
function readKeyedSharedCodexAppServerClientState(
|
||||
value: unknown,
|
||||
): value is SharedCodexAppServerClientState {
|
||||
return (
|
||||
value !== null &&
|
||||
): KeyedSharedCodexAppServerClientState | undefined {
|
||||
return value !== null &&
|
||||
typeof value === "object" &&
|
||||
(value as { clients?: unknown }).clients instanceof Map
|
||||
);
|
||||
? (value as KeyedSharedCodexAppServerClientState)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readLegacySharedCodexAppServerClientState(
|
||||
@@ -71,13 +96,59 @@ function readLegacySharedCodexAppServerClientState(
|
||||
return value as LegacySharedCodexAppServerClientState;
|
||||
}
|
||||
|
||||
export async function getSharedCodexAppServerClient(options?: {
|
||||
type SharedCodexAppServerClientOptions = {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
};
|
||||
|
||||
export async function getSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
): Promise<CodexAppServerClient> {
|
||||
return (await acquireSharedCodexAppServerClient(options)).client;
|
||||
}
|
||||
|
||||
export async function getLeasedSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
): Promise<CodexAppServerClient> {
|
||||
const acquired = await acquireSharedCodexAppServerClient(options, { leased: true });
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const releases = state.leasedReleases.get(acquired.client) ?? [];
|
||||
releases.push(acquired.release);
|
||||
state.leasedReleases.set(acquired.client, releases);
|
||||
return acquired.client;
|
||||
}
|
||||
|
||||
export function releaseLeasedSharedCodexAppServerClient(client: CodexAppServerClient): boolean {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const releases = state.leasedReleases.get(client);
|
||||
if (!releases) {
|
||||
return false;
|
||||
}
|
||||
const release = releases.pop();
|
||||
if (!release) {
|
||||
return false;
|
||||
}
|
||||
if (releases.length === 0) {
|
||||
state.leasedReleases.delete(client);
|
||||
}
|
||||
release();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function acquireSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
): Promise<{ client: CodexAppServerClient }>;
|
||||
async function acquireSharedCodexAppServerClient(
|
||||
options: SharedCodexAppServerClientOptions | undefined,
|
||||
leaseOptions: { leased: true },
|
||||
): Promise<{ client: CodexAppServerClient; release: () => void }>;
|
||||
async function acquireSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
leaseOptions?: { leased: true },
|
||||
): Promise<{ client: CodexAppServerClient; release?: () => void }> {
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
@@ -132,11 +203,13 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
}
|
||||
})());
|
||||
try {
|
||||
return await withTimeout(
|
||||
const client = await withTimeout(
|
||||
sharedPromise,
|
||||
options?.timeoutMs ?? 0,
|
||||
"codex app-server initialize timed out",
|
||||
);
|
||||
const release = leaseOptions?.leased ? retainSharedClientEntry(entry) : undefined;
|
||||
return release ? { client, release } : { client };
|
||||
} catch (error) {
|
||||
const currentEntry = state.clients.get(key);
|
||||
if (currentEntry?.promise === sharedPromise) {
|
||||
@@ -223,6 +296,59 @@ export function clearSharedCodexAppServerClientIfCurrent(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function detachSharedCodexAppServerClientIfCurrent(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): boolean {
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
state.clients.delete(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function retainSharedCodexAppServerClientIfCurrent(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): (() => void) | undefined {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const entry of state.clients.values()) {
|
||||
if (entry.client === client) {
|
||||
return retainSharedClientEntry(entry);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function retireSharedCodexAppServerClientIfCurrent(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): { activeLeases: number; closed: boolean } | undefined {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
state.clients.delete(key);
|
||||
entry.closeWhenIdle = true;
|
||||
const closed = closeRetiredSharedClientEntryIfIdle(entry);
|
||||
return { activeLeases: entry.activeLeases, closed };
|
||||
}
|
||||
}
|
||||
const activeLeases = state.leasedReleases.get(client)?.length ?? 0;
|
||||
if (activeLeases > 0) {
|
||||
return { activeLeases, closed: false };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function clearSharedCodexAppServerClientIfCurrentAndWait(
|
||||
client: CodexAppServerClient | undefined,
|
||||
options?: {
|
||||
@@ -260,7 +386,7 @@ function getOrCreateSharedClientEntry(
|
||||
): SharedCodexAppServerClientEntry {
|
||||
let entry = state.clients.get(key);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
entry = { activeLeases: 0, closeWhenIdle: false };
|
||||
state.clients.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
@@ -283,6 +409,30 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
|
||||
}
|
||||
}
|
||||
|
||||
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.activeLeases += 1;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
entry.activeLeases = Math.max(0, entry.activeLeases - 1);
|
||||
closeRetiredSharedClientEntryIfIdle(entry);
|
||||
};
|
||||
}
|
||||
|
||||
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
|
||||
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
|
||||
return false;
|
||||
}
|
||||
const client = entry.client;
|
||||
entry.closeWhenIdle = false;
|
||||
entry.client = undefined;
|
||||
client.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
|
||||
return [
|
||||
...new Set(
|
||||
|
||||
@@ -30,6 +30,9 @@ vi.mock("./session-binding.js", () => ({
|
||||
|
||||
vi.mock("./shared-client.js", () => ({
|
||||
getSharedCodexAppServerClient: (...args: unknown[]) => getSharedCodexAppServerClientMock(...args),
|
||||
getLeasedSharedCodexAppServerClient: (...args: unknown[]) =>
|
||||
getSharedCodexAppServerClientMock(...args),
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-bridge.js", () => ({
|
||||
@@ -712,7 +715,13 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(sideParams(), { nativeHookRelay: { enabled: true } }),
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
cfg: { tools: { loopDetection: { enabled: true } } } as never,
|
||||
sessionKey: "agent:main:session-1",
|
||||
}),
|
||||
{ nativeHookRelay: { enabled: true } },
|
||||
),
|
||||
).rejects.toThrow("fork failed");
|
||||
|
||||
expect(relayIdDuringFork).toBeDefined();
|
||||
@@ -738,7 +747,13 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(sideParams(), { nativeHookRelay: { enabled: true } }),
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
cfg: { tools: { loopDetection: { enabled: true } } } as never,
|
||||
sessionKey: "agent:main:session-1",
|
||||
}),
|
||||
{ nativeHookRelay: { enabled: true } },
|
||||
),
|
||||
).resolves.toEqual({ text: "Side answer." });
|
||||
|
||||
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
|
||||
@@ -855,15 +870,21 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
|
||||
startedAtMs = Date.now();
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(sideParams(), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
requestTimeoutMs,
|
||||
turnCompletionIdleTimeoutMs: completionTimeoutMs,
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
cfg: { tools: { loopDetection: { enabled: true } } } as never,
|
||||
sessionKey: "agent:main:session-1",
|
||||
}),
|
||||
{
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
requestTimeoutMs,
|
||||
turnCompletionIdleTimeoutMs: completionTimeoutMs,
|
||||
},
|
||||
},
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
nativeHookRelay: { enabled: true },
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ text: "Side answer." });
|
||||
|
||||
expect(relayIdDuringFork).toBeDefined();
|
||||
@@ -1157,6 +1178,21 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(timeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("uses a 90 second default for generic side-thread dynamic tool calls", () => {
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "session_status",
|
||||
arguments: { sessionKey: "current" },
|
||||
},
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
expect(timeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("cleans up notification handlers when side tool setup fails", async () => {
|
||||
const client = createFakeClient();
|
||||
createOpenClawCodingToolsMock.mockImplementation(() => {
|
||||
|
||||
@@ -66,7 +66,10 @@ import { rememberCodexRateLimits, readRecentCodexRateLimits } from "./rate-limit
|
||||
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
import { getSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import {
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import {
|
||||
buildCodexRuntimeThreadConfig,
|
||||
CODEX_NATIVE_PERSONALITY_NONE,
|
||||
@@ -75,7 +78,7 @@ import {
|
||||
} from "./thread-lifecycle.js";
|
||||
import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
|
||||
const CODEX_SIDE_DYNAMIC_TOOL_TIMEOUT_MS = 30_000;
|
||||
const CODEX_SIDE_DYNAMIC_TOOL_TIMEOUT_MS = 90_000;
|
||||
const CODEX_SIDE_DYNAMIC_TOOL_MAX_TIMEOUT_MS = 600_000;
|
||||
const CODEX_SIDE_DYNAMIC_IMAGE_GENERATION_TOOL_TIMEOUT_MS = 120_000;
|
||||
const CODEX_SIDE_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS = 60_000;
|
||||
@@ -145,7 +148,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const authProfileId = params.authProfileId ?? binding.authProfileId;
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
authProfileId,
|
||||
@@ -403,6 +406,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
});
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
nativeHookRelay?.unregister();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
mergeCodexThreadConfigs,
|
||||
type CodexPluginThreadConfig,
|
||||
} from "./plugin-thread-config.js";
|
||||
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
|
||||
import {
|
||||
assertCodexThreadResumeResponse,
|
||||
assertCodexThreadStartResponse,
|
||||
@@ -84,6 +85,113 @@ const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
|
||||
project_doc_max_bytes: 0,
|
||||
};
|
||||
|
||||
type CodexThreadLifecycleTimingSpan = {
|
||||
name: string;
|
||||
durationMs: number;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
type CodexThreadLifecycleTimingSummary = {
|
||||
totalMs: number;
|
||||
spans: CodexThreadLifecycleTimingSpan[];
|
||||
};
|
||||
|
||||
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS = 1_000;
|
||||
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS = 500;
|
||||
|
||||
function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean } = {}): {
|
||||
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
|
||||
measureSync: <T>(name: string, run: () => T) => T;
|
||||
logIfSlow: (params: {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
action: "started" | "resumed" | "rotated";
|
||||
threadId?: string;
|
||||
}) => void;
|
||||
} {
|
||||
if (!options.enabled) {
|
||||
return {
|
||||
async measure(_name, run) {
|
||||
return await run();
|
||||
},
|
||||
measureSync(_name, run) {
|
||||
return run();
|
||||
},
|
||||
logIfSlow() {},
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let didLog = false;
|
||||
const spans: CodexThreadLifecycleTimingSpan[] = [];
|
||||
const toMs = (value: number) => Math.max(0, Math.round(value));
|
||||
const record = (name: string, spanStartedAt: number) => {
|
||||
spans.push({
|
||||
name,
|
||||
durationMs: toMs(Date.now() - spanStartedAt),
|
||||
elapsedMs: toMs(Date.now() - startedAt),
|
||||
});
|
||||
};
|
||||
const snapshot = (): CodexThreadLifecycleTimingSummary => ({
|
||||
totalMs: toMs(Date.now() - startedAt),
|
||||
spans: spans.slice(),
|
||||
});
|
||||
const shouldLog = (summary: CodexThreadLifecycleTimingSummary) =>
|
||||
summary.totalMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS ||
|
||||
summary.spans.some((span) => span.durationMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS);
|
||||
const formatSpans = (summary: CodexThreadLifecycleTimingSummary) =>
|
||||
summary.spans.length > 0
|
||||
? summary.spans
|
||||
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
|
||||
.join(",")
|
||||
: "none";
|
||||
return {
|
||||
async measure(name, run) {
|
||||
const spanStartedAt = Date.now();
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
record(name, spanStartedAt);
|
||||
}
|
||||
},
|
||||
measureSync(name, run) {
|
||||
const spanStartedAt = Date.now();
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
record(name, spanStartedAt);
|
||||
}
|
||||
},
|
||||
logIfSlow(params) {
|
||||
if (didLog) {
|
||||
return;
|
||||
}
|
||||
const summary = snapshot();
|
||||
if (!shouldLog(summary)) {
|
||||
return;
|
||||
}
|
||||
didLog = true;
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server thread lifecycle timings runId=${params.runId} sessionId=${
|
||||
params.sessionId
|
||||
} sessionKey=${params.sessionKey ?? "unknown"} action=${params.action} totalMs=${
|
||||
summary.totalMs
|
||||
} stages=${formatSpans(summary)}`,
|
||||
{
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
action: params.action,
|
||||
threadId: params.threadId,
|
||||
totalMs: summary.totalMs,
|
||||
spans: summary.spans,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startOrResumeThread(params: {
|
||||
client: CodexAppServerClient;
|
||||
params: EmbeddedRunAttemptParams;
|
||||
@@ -103,10 +211,16 @@ export async function startOrResumeThread(params: {
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
|
||||
}): Promise<CodexAppServerThreadLifecycleBinding> {
|
||||
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
|
||||
const contextEngineBinding = buildContextEngineBinding(
|
||||
params.params,
|
||||
params.contextEngineProjection,
|
||||
// Thread lifecycle spans are useful when profiling startup churn, but normal
|
||||
// turns should not pay Date.now/span-array overhead while resuming threads.
|
||||
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
|
||||
enabled: isCodexAppServerProfilerEnabled(params.params.config),
|
||||
});
|
||||
const dynamicToolsFingerprint = lifecycleTiming.measureSync("fingerprint_dynamic_tools", () =>
|
||||
fingerprintDynamicTools(params.dynamicTools),
|
||||
);
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context_engine_binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
const userMcpServersConfigPatch =
|
||||
params.userMcpServersEnabled === false
|
||||
@@ -118,11 +232,13 @@ export async function startOrResumeThread(params: {
|
||||
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
|
||||
params.environmentSelection,
|
||||
);
|
||||
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
let binding = await lifecycleTiming.measure("read_binding", () =>
|
||||
readCodexAppServerBinding(params.params.sessionFile, {
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
}),
|
||||
);
|
||||
let preserveExistingBinding = false;
|
||||
let rotatedContextEngineBinding = false;
|
||||
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
|
||||
@@ -207,7 +323,9 @@ export async function startOrResumeThread(params: {
|
||||
})
|
||||
) {
|
||||
try {
|
||||
prebuiltPluginThreadConfig = await params.pluginThreadConfig?.build();
|
||||
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin_config_recovery", () =>
|
||||
params.pluginThreadConfig?.build(),
|
||||
);
|
||||
pluginBindingStale =
|
||||
prebuiltPluginThreadConfig?.fingerprint !== binding.pluginAppsFingerprint;
|
||||
} catch (error) {
|
||||
@@ -274,19 +392,21 @@ export async function startOrResumeThread(params: {
|
||||
userMcpServersConfigPatch,
|
||||
params.finalConfigPatch,
|
||||
);
|
||||
const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () =>
|
||||
buildThreadResumeParams(params.params, {
|
||||
threadId: binding.threadId,
|
||||
authProfileId,
|
||||
appServer: params.appServer,
|
||||
dynamicTools: params.dynamicTools,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: resumeConfig,
|
||||
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
|
||||
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
|
||||
}),
|
||||
);
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await params.client.request(
|
||||
"thread/resume",
|
||||
buildThreadResumeParams(params.params, {
|
||||
threadId: binding.threadId,
|
||||
authProfileId,
|
||||
appServer: params.appServer,
|
||||
dynamicTools: params.dynamicTools,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: resumeConfig,
|
||||
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
|
||||
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
|
||||
}),
|
||||
await lifecycleTiming.measure("thread_resume_request", () =>
|
||||
params.client.request("thread/resume", resumeParams),
|
||||
),
|
||||
);
|
||||
const boundAuthProfileId = authProfileId;
|
||||
@@ -301,29 +421,31 @@ export async function startOrResumeThread(params: {
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
? params.mcpServersFingerprint
|
||||
: binding.mcpServersFingerprint;
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: boundAuthProfileId,
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
environmentSelectionFingerprint,
|
||||
createdAt: binding.createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
},
|
||||
await lifecycleTiming.measure("thread_resume_write_binding", () =>
|
||||
writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: boundAuthProfileId,
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
environmentSelectionFingerprint,
|
||||
createdAt: binding.createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (contextEngineBinding) {
|
||||
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
|
||||
@@ -336,6 +458,13 @@ export async function startOrResumeThread(params: {
|
||||
action: "resumed",
|
||||
});
|
||||
}
|
||||
lifecycleTiming.logIfSlow({
|
||||
runId: params.params.runId,
|
||||
sessionId: params.params.sessionId,
|
||||
sessionKey: params.params.sessionKey,
|
||||
threadId: response.thread.id,
|
||||
action: "resumed",
|
||||
});
|
||||
return {
|
||||
...binding,
|
||||
threadId: response.thread.id,
|
||||
@@ -366,27 +495,34 @@ export async function startOrResumeThread(params: {
|
||||
}
|
||||
|
||||
const pluginThreadConfig = params.pluginThreadConfig?.enabled
|
||||
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
|
||||
? (prebuiltPluginThreadConfig ??
|
||||
(await lifecycleTiming.measure("plugin_config_build", () =>
|
||||
params.pluginThreadConfig?.build(),
|
||||
)))
|
||||
: undefined;
|
||||
const config = mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
params.finalConfigPatch,
|
||||
const config = lifecycleTiming.measureSync("merge_thread_config", () =>
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
params.finalConfigPatch,
|
||||
),
|
||||
);
|
||||
const startParams = lifecycleTiming.measureSync("thread_start_params", () =>
|
||||
buildThreadStartParams(params.params, {
|
||||
cwd: params.cwd,
|
||||
dynamicTools: params.dynamicTools,
|
||||
appServer: params.appServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config,
|
||||
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
|
||||
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
|
||||
environmentSelection: params.environmentSelection,
|
||||
}),
|
||||
);
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await params.client.request(
|
||||
"thread/start",
|
||||
buildThreadStartParams(params.params, {
|
||||
cwd: params.cwd,
|
||||
dynamicTools: params.dynamicTools,
|
||||
appServer: params.appServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config,
|
||||
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
|
||||
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
|
||||
environmentSelection: params.environmentSelection,
|
||||
}),
|
||||
await lifecycleTiming.measure("thread_start_request", () =>
|
||||
params.client.request("thread/start", startParams),
|
||||
),
|
||||
);
|
||||
const modelProvider = resolveCodexAppServerModelProvider({
|
||||
@@ -400,29 +536,31 @@ export async function startOrResumeThread(params: {
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
|
||||
if (!preserveExistingBinding) {
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: params.params.authProfileId,
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
environmentSelectionFingerprint,
|
||||
createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
},
|
||||
await lifecycleTiming.measure("thread_start_write_binding", () =>
|
||||
writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: params.params.authProfileId,
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
environmentSelectionFingerprint,
|
||||
createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (contextEngineBinding) {
|
||||
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
|
||||
@@ -436,6 +574,13 @@ export async function startOrResumeThread(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
lifecycleTiming.logIfSlow({
|
||||
runId: params.params.runId,
|
||||
sessionId: params.params.sessionId,
|
||||
sessionKey: params.params.sessionKey,
|
||||
threadId: response.thread.id,
|
||||
action: rotatedContextEngineBinding ? "rotated" : "started",
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
threadId: response.thread.id,
|
||||
@@ -973,9 +1118,12 @@ function buildVisibleReplyInstruction(
|
||||
? dynamicTools.some((tool) => tool.name.trim() === "message")
|
||||
: params.disableMessageTool !== true;
|
||||
if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) {
|
||||
return "To send a visible message, use the `message` tool.";
|
||||
return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer.";
|
||||
}
|
||||
return "To send a visible reply, use the active Codex delivery path.";
|
||||
if (messageToolAvailable) {
|
||||
return "For the current source conversation, reply normally in your final assistant message; OpenClaw will deliver it through the active source conversation. Use `message` only for explicit out-of-band sends, media/file sends, or sends to a different target.";
|
||||
}
|
||||
return "For the current source conversation, reply normally in your final assistant message; OpenClaw will deliver it through the active source conversation.";
|
||||
}
|
||||
|
||||
function buildUserInput(
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
makeAgentUserMessage,
|
||||
} from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { attachCodexMirrorIdentity, mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
|
||||
import {
|
||||
attachCodexMirrorIdentity,
|
||||
buildCodexUserPromptMessage,
|
||||
mirrorCodexAppServerTranscript,
|
||||
} from "./transcript-mirror.js";
|
||||
|
||||
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -57,6 +61,37 @@ async function makeRoot(prefix: string): Promise<string> {
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("buildCodexUserPromptMessage", () => {
|
||||
it("uses the prepared user transcript message for app-server prompt mirrors", () => {
|
||||
const message = buildCodexUserPromptMessage({
|
||||
prompt: "[Mon 2026-05-25 19:14 GMT+1] What is in this image?",
|
||||
messageChannel: "webchat",
|
||||
userTurnTranscriptRecorder: {
|
||||
message: {
|
||||
role: "user",
|
||||
content: "What is in this image?",
|
||||
timestamp: 1779732875151,
|
||||
MediaPath: "/tmp/image.png",
|
||||
MediaPaths: ["/tmp/image.png"],
|
||||
MediaType: "image/png",
|
||||
MediaTypes: ["image/png"],
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof buildCodexUserPromptMessage>[0]);
|
||||
|
||||
expect(message).toMatchObject({
|
||||
role: "user",
|
||||
content: "What is in this image?",
|
||||
timestamp: 1779732875151,
|
||||
sourceChannel: "webchat",
|
||||
MediaPath: "/tmp/image.png",
|
||||
MediaPaths: ["/tmp/image.png"],
|
||||
MediaType: "image/png",
|
||||
MediaTypes: ["image/png"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function parseJsonLines<T>(raw: string): T[] {
|
||||
const records: T[] = [];
|
||||
for (const line of raw.trim().split("\n")) {
|
||||
@@ -126,13 +161,13 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
"turn-1:prompt",
|
||||
);
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
const firstMirror = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [userMessage],
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
await mirrorCodexAppServerTranscript({
|
||||
const secondMirror = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [userMessage],
|
||||
@@ -152,6 +187,18 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
||||
});
|
||||
expect(updates[0]?.messageSeq).toBe(1);
|
||||
expect(firstMirror.userMessagesPresent).toHaveLength(1);
|
||||
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "show me live" }],
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
||||
});
|
||||
expect(secondMirror.userMessagesPresent).toHaveLength(1);
|
||||
expect(secondMirror.userMessagesPresent[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "show me live" }],
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits stable sequence numbers for multi-message mirror batches", async () => {
|
||||
@@ -278,6 +325,52 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the persisted user message for duplicate mirror hits", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_message_write",
|
||||
handler: (event) => ({
|
||||
message: castAgentMessage({
|
||||
...((event as { message: unknown }).message as Record<string, unknown>),
|
||||
content: [{ type: "text", text: "[redacted by hook]" }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const sourceMessage = makeAgentUserMessage({
|
||||
content: [{ type: "text", text: "secret prompt" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const first = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
});
|
||||
const second = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
});
|
||||
|
||||
expect(first.userMessagesPresent[0]?.content).toEqual([
|
||||
{ type: "text", text: "[redacted by hook]" },
|
||||
]);
|
||||
expect(second.userMessagesPresent[0]?.content).toEqual([
|
||||
{ type: "text", text: "[redacted by hook]" },
|
||||
]);
|
||||
expect(JSON.stringify(second.userMessagesPresent)).not.toContain("secret prompt");
|
||||
const records = parseJsonLines<{ type?: string; message?: { role?: string } }>(
|
||||
await fs.readFile(sessionFile, "utf8"),
|
||||
);
|
||||
expect(records.filter((record) => record.message?.role === "user")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves the computed idempotency key when hooks rewrite message keys", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
type MirroredUserMessage = Extract<AgentMessage, { role: "user" }>;
|
||||
|
||||
export type CodexAppServerTranscriptMirrorResult = {
|
||||
userMessagesPresent: MirroredUserMessage[];
|
||||
};
|
||||
|
||||
const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const;
|
||||
|
||||
@@ -32,7 +37,10 @@ function buildSenderLabel(params: {
|
||||
return `${label} (${params.senderId})`;
|
||||
}
|
||||
|
||||
export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): AgentMessage {
|
||||
function buildCodexUserPromptMessageFromPrepared(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
preparedUserMessage: MirroredUserMessage | undefined,
|
||||
): AgentMessage {
|
||||
const senderId = normalizeOptionalString(params.senderId);
|
||||
const senderName = normalizeOptionalString(params.senderName);
|
||||
const senderUsername = normalizeOptionalString(params.senderUsername);
|
||||
@@ -41,6 +49,20 @@ export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): A
|
||||
const sourceChannel = normalizeOptionalString(
|
||||
params.inputProvenance?.sourceChannel ?? params.messageChannel ?? params.messageProvider,
|
||||
);
|
||||
if (preparedUserMessage) {
|
||||
return {
|
||||
role: "user",
|
||||
timestamp: Date.now(),
|
||||
...(params.inputProvenance ? { provenance: params.inputProvenance } : {}),
|
||||
...(sourceChannel ? { sourceChannel } : {}),
|
||||
...(senderId ? { senderId } : {}),
|
||||
...(senderName ? { senderName } : {}),
|
||||
...(senderUsername ? { senderUsername } : {}),
|
||||
...(senderE164 ? { senderE164 } : {}),
|
||||
...(senderLabel ? { senderLabel } : {}),
|
||||
...(preparedUserMessage as unknown as Record<string, unknown>),
|
||||
} as AgentMessage;
|
||||
}
|
||||
return {
|
||||
role: "user",
|
||||
content: params.prompt,
|
||||
@@ -55,6 +77,23 @@ export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): A
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): AgentMessage {
|
||||
return buildCodexUserPromptMessageFromPrepared(
|
||||
params,
|
||||
params.userTurnTranscriptRecorder?.message,
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildResolvedCodexUserPromptMessage(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
): Promise<AgentMessage> {
|
||||
const resolvedMessage = await params.userTurnTranscriptRecorder?.resolveMessage();
|
||||
return buildCodexUserPromptMessageFromPrepared(
|
||||
params,
|
||||
resolvedMessage ?? params.userTurnTranscriptRecorder?.message,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a message with a stable logical identity for mirror dedupe. Callers
|
||||
* should use a value that is invariant for the same logical message across
|
||||
@@ -113,13 +152,13 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
messages: AgentMessage[];
|
||||
idempotencyScope?: string;
|
||||
config?: SessionWriteLockAcquireTimeoutConfig;
|
||||
}): Promise<void> {
|
||||
}): Promise<CodexAppServerTranscriptMirrorResult> {
|
||||
const messages = params.messages.filter(
|
||||
(message): message is MirroredAgentMessage =>
|
||||
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
);
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
return { userMessagesPresent: [] };
|
||||
}
|
||||
|
||||
const lock = await acquireSessionWriteLock({
|
||||
@@ -128,6 +167,7 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
});
|
||||
const appendedUpdates: Array<{ messageId: string; message: AgentMessage; messageSeq: number }> =
|
||||
[];
|
||||
const userMessagesPresent: MirroredUserMessage[] = [];
|
||||
try {
|
||||
const mirrorState = await readTranscriptMirrorState(params.sessionFile);
|
||||
let nextMessageSeq = mirrorState.messageCount;
|
||||
@@ -136,13 +176,17 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
|
||||
continue;
|
||||
}
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
|
||||
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
|
||||
if (persistedUserMessage) {
|
||||
userMessagesPresent.push(persistedUserMessage);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
@@ -162,8 +206,15 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
|
||||
transcriptPath: params.sessionFile,
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
config: params.config,
|
||||
});
|
||||
if (appendedMessage.role === "user") {
|
||||
userMessagesPresent.push(appendedMessage);
|
||||
if (idempotencyKey) {
|
||||
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
|
||||
}
|
||||
}
|
||||
nextMessageSeq += 1;
|
||||
appendedUpdates.push({ messageId, message: appendedMessage, messageSeq: nextMessageSeq });
|
||||
if (idempotencyKey) {
|
||||
@@ -183,12 +234,17 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
messageSeq: update.messageSeq,
|
||||
});
|
||||
}
|
||||
|
||||
return { userMessagesPresent };
|
||||
}
|
||||
|
||||
async function readTranscriptMirrorState(
|
||||
sessionFile: string,
|
||||
): Promise<{ idempotencyKeys: Set<string>; messageCount: number }> {
|
||||
async function readTranscriptMirrorState(sessionFile: string): Promise<{
|
||||
idempotencyKeys: Set<string>;
|
||||
messageCount: number;
|
||||
userMessagesByIdempotencyKey: Map<string, MirroredUserMessage>;
|
||||
}> {
|
||||
const idempotencyKeys = new Set<string>();
|
||||
const userMessagesByIdempotencyKey = new Map<string, MirroredUserMessage>();
|
||||
let messageCount = 0;
|
||||
let raw: string;
|
||||
try {
|
||||
@@ -197,23 +253,26 @@ async function readTranscriptMirrorState(
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
return { idempotencyKeys, messageCount };
|
||||
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
|
||||
const parsed = JSON.parse(line) as { message?: AgentMessage & { idempotencyKey?: unknown } };
|
||||
if ((parsed as { type?: unknown }).type === "message") {
|
||||
messageCount += 1;
|
||||
}
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
idempotencyKeys.add(parsed.message.idempotencyKey);
|
||||
if (parsed.message.role === "user") {
|
||||
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { idempotencyKeys, messageCount };
|
||||
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
|
||||
export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
|
||||
// Keep this in sync with the Codex CLI live-test package pin.
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.133.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.134.0";
|
||||
|
||||
@@ -18,7 +18,11 @@ const agentRuntimeMocks = vi.hoisted(() => ({
|
||||
saveAuthProfileStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
|
||||
vi.mock("./app-server/shared-client.js", () => ({
|
||||
...sharedClientMocks,
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks);
|
||||
|
||||
import {
|
||||
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
writeCodexAppServerBinding,
|
||||
type CodexAppServerAuthProfileLookup,
|
||||
} from "./app-server/session-binding.js";
|
||||
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
|
||||
import {
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./app-server/shared-client.js";
|
||||
import { CODEX_NATIVE_PERSONALITY_NONE } from "./app-server/thread-lifecycle.js";
|
||||
import { formatCodexDisplayText } from "./command-formatters.js";
|
||||
import {
|
||||
@@ -270,52 +273,56 @@ async function attachExistingThread(params: {
|
||||
modelProvider: params.modelProvider,
|
||||
...agentLookup,
|
||||
});
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
...agentLookup,
|
||||
});
|
||||
const response: CodexThreadResumeResponse = await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
const thread = response.thread;
|
||||
const runtimeApprovalPolicy =
|
||||
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
|
||||
await writeCodexAppServerBinding(
|
||||
params.sessionFile,
|
||||
{
|
||||
threadId: thread.id,
|
||||
cwd: thread.cwd ?? params.workspaceDir,
|
||||
authProfileId: params.authProfileId,
|
||||
model: response.model ?? params.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
try {
|
||||
const response: CodexThreadResumeResponse = await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
const thread = response.thread;
|
||||
const runtimeApprovalPolicy =
|
||||
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
|
||||
await writeCodexAppServerBinding(
|
||||
params.sessionFile,
|
||||
{
|
||||
threadId: thread.id,
|
||||
cwd: thread.cwd ?? params.workspaceDir,
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
model: response.model ?? params.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
...agentLookup,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
async function createThread(params: {
|
||||
@@ -340,54 +347,58 @@ async function createThread(params: {
|
||||
modelProvider: params.modelProvider,
|
||||
...agentLookup,
|
||||
});
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
...agentLookup,
|
||||
});
|
||||
const response: CodexThreadStartResponse = await client.request(
|
||||
"thread/start",
|
||||
{
|
||||
cwd: params.workspaceDir,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
developerInstructions:
|
||||
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
const runtimeApprovalPolicy =
|
||||
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
|
||||
await writeCodexAppServerBinding(
|
||||
params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: response.thread.cwd ?? params.workspaceDir,
|
||||
authProfileId: params.authProfileId,
|
||||
model: response.model ?? params.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
try {
|
||||
const response: CodexThreadStartResponse = await client.request(
|
||||
"thread/start",
|
||||
{
|
||||
cwd: params.workspaceDir,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
developerInstructions:
|
||||
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
const runtimeApprovalPolicy =
|
||||
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
|
||||
await writeCodexAppServerBinding(
|
||||
params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: response.thread.cwd ?? params.workspaceDir,
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
model: response.model ?? params.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
...agentLookup,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBoundTurn(params: {
|
||||
@@ -407,7 +418,7 @@ async function runBoundTurn(params: {
|
||||
throw new Error("bound Codex conversation has no thread binding");
|
||||
}
|
||||
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: binding.authProfileId,
|
||||
@@ -498,6 +509,7 @@ async function runBoundTurn(params: {
|
||||
} finally {
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ const sharedClientMocks = vi.hoisted(() => ({
|
||||
getSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
|
||||
vi.mock("./app-server/shared-client.js", () => ({
|
||||
...sharedClientMocks,
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("codex conversation controls", () => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.js";
|
||||
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
|
||||
import {
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./app-server/shared-client.js";
|
||||
import { formatCodexDisplayText } from "./command-formatters.js";
|
||||
|
||||
type ActiveTurn = {
|
||||
@@ -61,20 +64,24 @@ export async function stopCodexConversationTurn(params: {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const lookup = buildBindingLookup(params);
|
||||
const binding = await readCodexAppServerBinding(params.sessionFile, lookup);
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: binding?.authProfileId,
|
||||
...lookup,
|
||||
});
|
||||
await client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: active.threadId,
|
||||
turnId: active.turnId,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
try {
|
||||
await client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: active.threadId,
|
||||
turnId: active.turnId,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
return { stopped: true, message: "Codex stop requested." };
|
||||
}
|
||||
|
||||
@@ -96,21 +103,25 @@ export async function steerCodexConversationTurn(params: {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const lookup = buildBindingLookup(params);
|
||||
const binding = await readCodexAppServerBinding(params.sessionFile, lookup);
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: binding?.authProfileId,
|
||||
...lookup,
|
||||
});
|
||||
await client.request(
|
||||
"turn/steer",
|
||||
{
|
||||
threadId: active.threadId,
|
||||
expectedTurnId: active.turnId,
|
||||
input: [{ type: "text", text, text_elements: [] }],
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
try {
|
||||
await client.request(
|
||||
"turn/steer",
|
||||
{
|
||||
threadId: active.threadId,
|
||||
expectedTurnId: active.turnId,
|
||||
input: [{ type: "text", text, text_elements: [] }],
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
return { steered: true, message: "Sent steer message to Codex." };
|
||||
}
|
||||
|
||||
@@ -261,25 +272,29 @@ async function resumeThreadWithOverrides(params: {
|
||||
serviceTier?: CodexServiceTier;
|
||||
}): Promise<CodexThreadResumeResponse> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
const client = await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
...buildBindingLookup(params),
|
||||
});
|
||||
return await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
...(params.serviceTier ? { serviceTier: params.serviceTier } : {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
try {
|
||||
return await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
...(params.serviceTier ? { serviceTier: params.serviceTier } : {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBindingLookup(params: {
|
||||
|
||||
@@ -42,7 +42,8 @@ import type { v2 } from "../app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait,
|
||||
getSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "../app-server/shared-client.js";
|
||||
import { applyCodexAuthItem, buildCodexAuthConfigPatchItems } from "./auth.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
@@ -86,8 +87,8 @@ export function prepareTargetCodexAppServer(
|
||||
): CodexMigrationTargetAppServerPreparation {
|
||||
const appServer = resolveTargetCodexAppServer(ctx);
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
let warmedClient: Awaited<ReturnType<typeof getSharedCodexAppServerClient>> | undefined;
|
||||
const ready = getSharedCodexAppServerClient({
|
||||
let warmedClient: Awaited<ReturnType<typeof getLeasedSharedCodexAppServerClient>> | undefined;
|
||||
const ready = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
timeoutMs: 60_000,
|
||||
agentDir: targets.agentDir,
|
||||
@@ -101,6 +102,9 @@ export function prepareTargetCodexAppServer(
|
||||
return {
|
||||
async dispose() {
|
||||
await ready;
|
||||
if (warmedClient) {
|
||||
releaseLeasedSharedCodexAppServerClient(warmedClient);
|
||||
}
|
||||
await clearSharedCodexAppServerClientIfCurrentAndWait(warmedClient, {
|
||||
exitTimeoutMs: 2_000,
|
||||
forceKillDelayMs: 250,
|
||||
|
||||
@@ -53,6 +53,7 @@ const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const traceExporterCtor = vi.hoisted(() => vi.fn());
|
||||
const metricExporterCtor = vi.hoisted(() => vi.fn());
|
||||
const logExporterCtor = vi.hoisted(() => vi.fn());
|
||||
const spanProcessorCtor = vi.hoisted(() => vi.fn());
|
||||
const unhandledRejectionHandlerState = vi.hoisted(() => {
|
||||
let handlers: Array<(reason: unknown) => boolean> = [];
|
||||
return {
|
||||
@@ -136,6 +137,9 @@ vi.mock("@opentelemetry/sdk-metrics", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@opentelemetry/sdk-trace-base", () => ({
|
||||
BatchSpanProcessor: function BatchSpanProcessor(exporter?: unknown, options?: unknown) {
|
||||
spanProcessorCtor(exporter, options);
|
||||
},
|
||||
ParentBasedSampler: function ParentBasedSampler() {},
|
||||
TraceIdRatioBasedSampler: function TraceIdRatioBasedSampler() {},
|
||||
}));
|
||||
@@ -261,6 +265,10 @@ function firstExporterOptions(mock: { mock: { calls: unknown[][] } }): { url?: s
|
||||
return mockCallArg(mock, 0) as { url?: string };
|
||||
}
|
||||
|
||||
function firstSpanProcessorOptions(): { scheduledDelayMillis?: number } {
|
||||
return mockCallArg(spanProcessorCtor, 1) as { scheduledDelayMillis?: number };
|
||||
}
|
||||
|
||||
function firstSetSpanContext(): Record<string, unknown> {
|
||||
return mockCallArg(telemetryState.tracer.setSpanContext, 1) as Record<string, unknown>;
|
||||
}
|
||||
@@ -390,6 +398,7 @@ describe("diagnostics-otel service", () => {
|
||||
traceExporterCtor.mockClear();
|
||||
metricExporterCtor.mockClear();
|
||||
logExporterCtor.mockClear();
|
||||
spanProcessorCtor.mockClear();
|
||||
unhandledRejectionHandlerState.reset();
|
||||
unhandledRejectionHandlerState.register.mockClear();
|
||||
delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
|
||||
@@ -1195,6 +1204,18 @@ describe("diagnostics-otel service", () => {
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("applies flush interval to trace batching", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createTraceOnlyContext(OTEL_TEST_ENDPOINT);
|
||||
ctx.config.diagnostics!.otel!.flushIntervalMs = 250;
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(spanProcessorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(firstSpanProcessorOptions().scheduledDelayMillis).toBe(1000);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("uses signal-specific OTLP endpoints ahead of the shared endpoint", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
|
||||
|
||||
@@ -14,7 +14,11 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||
import {
|
||||
BatchSpanProcessor,
|
||||
ParentBasedSampler,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
import {
|
||||
ATTR_GEN_AI_INPUT_MESSAGES,
|
||||
@@ -1167,6 +1171,14 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
...(headers ? { headers } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const spanProcessors =
|
||||
traceExporter && typeof otel.flushIntervalMs === "number"
|
||||
? [
|
||||
new BatchSpanProcessor(traceExporter, {
|
||||
scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs),
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const metricExporter = metricsEnabled
|
||||
? new OTLPMetricExporter({
|
||||
@@ -1186,7 +1198,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
|
||||
sdk = new NodeSDK({
|
||||
resource,
|
||||
...(traceExporter ? { traceExporter } : {}),
|
||||
...(spanProcessors ? { spanProcessors } : traceExporter ? { traceExporter } : {}),
|
||||
...(metricReader ? { metricReader } : {}),
|
||||
...(sampleRate !== undefined
|
||||
? {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user