mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 18:31:39 +08:00
Compare commits
142 Commits
codex/red-
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
807659492b | ||
|
|
d1b917120a | ||
|
|
78f7de01c1 | ||
|
|
3630d502eb | ||
|
|
7bbd09047b | ||
|
|
c53dbcaf4d | ||
|
|
461e551e85 | ||
|
|
dc575d148a | ||
|
|
12685ee6b7 | ||
|
|
36722014ef | ||
|
|
1b8b8500ce | ||
|
|
c29e1fe764 | ||
|
|
c52adf7505 | ||
|
|
199700de26 | ||
|
|
b14a95b3fd | ||
|
|
ebf1ba70d5 | ||
|
|
78d70230b6 | ||
|
|
98ed83f848 | ||
|
|
1bdde66950 | ||
|
|
2720ac06b7 | ||
|
|
ce15f348bb | ||
|
|
e5c3c59c67 | ||
|
|
2e881ab1c6 | ||
|
|
90c20d15c2 | ||
|
|
cb8bc71ff8 | ||
|
|
b5c662f4f5 | ||
|
|
d693ed4af3 | ||
|
|
6c5a9fde9f | ||
|
|
b8e3de1160 | ||
|
|
b9c64142e2 | ||
|
|
84bcd500c9 | ||
|
|
f857e8d66e | ||
|
|
a048aeae16 | ||
|
|
4b9e01813e | ||
|
|
7830faa5fe | ||
|
|
ddedf13190 | ||
|
|
cb4244fe15 | ||
|
|
361869e434 | ||
|
|
4010b81a77 | ||
|
|
8fa24325b5 | ||
|
|
f4fa10c2c5 | ||
|
|
2100ee7cc8 | ||
|
|
6e8f30c0e2 | ||
|
|
9d800b71c0 | ||
|
|
5ccfc97b31 | ||
|
|
a7bfc06f45 | ||
|
|
c5d34c8376 | ||
|
|
fbfadbd806 | ||
|
|
6f1076351c | ||
|
|
898ca9741c | ||
|
|
67118d5ab9 | ||
|
|
bf2a8ecfdb | ||
|
|
cee2aca409 | ||
|
|
56259606d1 | ||
|
|
552ec2b49d | ||
|
|
4d0f19a968 | ||
|
|
072d3ed7b5 | ||
|
|
1bccd29304 | ||
|
|
498567190d | ||
|
|
5880e0afc4 | ||
|
|
65fec9d787 | ||
|
|
4d9cd7d227 | ||
|
|
12ea61a08d | ||
|
|
4932366b92 | ||
|
|
4f3d81b918 | ||
|
|
e09b9dfc1b | ||
|
|
ff35f3bb2c | ||
|
|
ff18374293 | ||
|
|
fa78cfbfb7 | ||
|
|
8252fc009f | ||
|
|
c691872b9e | ||
|
|
e0932e0bc4 | ||
|
|
808c227edb | ||
|
|
deb0ffdcdf | ||
|
|
a846b879ec | ||
|
|
43dd34262e | ||
|
|
6883c6c070 | ||
|
|
91726e9624 | ||
|
|
289865b392 | ||
|
|
6db4624f43 | ||
|
|
b7a9d3005c | ||
|
|
bdd365a348 | ||
|
|
a82902c725 | ||
|
|
aca905cce5 | ||
|
|
ab966c214b | ||
|
|
41c00a65d6 | ||
|
|
eba1ca683f | ||
|
|
b3eee03740 | ||
|
|
6109420e5c | ||
|
|
44e522cf6b | ||
|
|
ab8cd3dac9 | ||
|
|
816c2cf1f8 | ||
|
|
9cc10a8382 | ||
|
|
c6757d7a75 | ||
|
|
a70e7ce24b | ||
|
|
6b98d179b6 | ||
|
|
cb4e9e4118 | ||
|
|
0023cc816a | ||
|
|
6d62dae215 | ||
|
|
8d61631b40 | ||
|
|
68bed5e902 | ||
|
|
7b549a26e8 | ||
|
|
57f62a5fd9 | ||
|
|
ba70d365ac | ||
|
|
ce88d65779 | ||
|
|
689baa5c1e | ||
|
|
4c4396c4c2 | ||
|
|
c1336b6b41 | ||
|
|
d4a01e48bc | ||
|
|
a0e9ca1e95 | ||
|
|
1b6557dfa2 | ||
|
|
2968004680 | ||
|
|
9636bea901 | ||
|
|
1089253ca9 | ||
|
|
e5123e44b0 | ||
|
|
1cd6f81a46 | ||
|
|
80c754ddf4 | ||
|
|
512f0f1bf7 | ||
|
|
338e119533 | ||
|
|
e4f63577d0 | ||
|
|
94d93d4c85 | ||
|
|
7718e25b2a | ||
|
|
8079aa62a2 | ||
|
|
6f162f321a | ||
|
|
527f8f0cbb | ||
|
|
c05d0d5bbf | ||
|
|
535af4452b | ||
|
|
ec737ee74d | ||
|
|
9a735bea03 | ||
|
|
81e53202f2 | ||
|
|
e9f9a68d68 | ||
|
|
db255b1154 | ||
|
|
4fc504d321 | ||
|
|
751a6c23f0 | ||
|
|
899f65097b | ||
|
|
a6a4652c70 | ||
|
|
3b292ba9d4 | ||
|
|
0fdfc9f65f | ||
|
|
448b7c75b6 | ||
|
|
6830aa39ea | ||
|
|
a0b397748f | ||
|
|
dd0e4f6e61 |
@@ -29,7 +29,7 @@ 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. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through.
|
||||
- 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, Claude, and cursor-agent 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.
|
||||
@@ -226,15 +226,16 @@ The helper:
|
||||
- accepts `--mode uncommitted` as an alias for `--mode local`
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
|
||||
- supports `--engine codex`, `claude`, `droid`, `copilot`, and `cursor-agent`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
|
||||
- resolves bare `git`, `gh`, reviewer, and PowerShell shell commands from absolute `PATH` entries only, never from the reviewed checkout; explicit relative `--*-bin` paths are resolved from the reviewed repository root
|
||||
- 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`, `--json-output`, or live streamed engine stderr is set
|
||||
- supports `--dry-run`, `--parallel-tests`, `--parallel-tests-shell`, `--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 `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex, Claude, and cursor-agent 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
|
||||
- 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; cursor-agent is run through headless `--print` in ask mode with sandboxing enabled from a helper-owned temporary workspace
|
||||
- rejects `--no-web-search` for cursor-agent because the Cursor CLI does not expose a CLI-level web-search disable switch
|
||||
- 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
|
||||
|
||||
@@ -17,12 +17,13 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
ENGINES = ("codex", "claude", "droid", "copilot")
|
||||
ENGINES = ("codex", "claude", "droid", "copilot", "cursor-agent")
|
||||
THINKING_LEVELS_BY_ENGINE = {
|
||||
"codex": {"low", "medium", "high", "xhigh"},
|
||||
"claude": {"low", "medium", "high", "xhigh", "max"},
|
||||
"droid": set(),
|
||||
"copilot": set(),
|
||||
"cursor-agent": set(),
|
||||
}
|
||||
|
||||
|
||||
@@ -480,7 +481,7 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
|
||||
{json.dumps(SCHEMA, indent=2)}
|
||||
- Do not modify files.
|
||||
- Do not invoke nested reviewers or review tools.
|
||||
- Forbidden nested review commands include: codex review, autoreview, claude review, oracle review.
|
||||
- Forbidden nested review commands include: codex review, autoreview, claude review, cursor-agent, oracle review.
|
||||
- You may use read-only tools and web search to inspect files, dependency contracts, upstream docs, current behavior, and security implications.
|
||||
- Shell commands, if available, must be read-only inspection commands. Do not run tests, formatters, package installs, generators, network mutation commands, git mutation commands, or commands that write files.
|
||||
- Report only actionable defects introduced or exposed by this change.
|
||||
@@ -660,6 +661,44 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
return result.stdout
|
||||
|
||||
|
||||
def run_cursor_agent(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
if args.thinking:
|
||||
raise SystemExit("--thinking is not supported by the cursor-agent engine")
|
||||
if not args.tools:
|
||||
raise SystemExit("--no-tools is not supported by the cursor-agent engine; use --engine claude --no-tools for a no-tools run")
|
||||
if not args.web_search:
|
||||
raise SystemExit("--no-web-search is not supported by the cursor-agent engine; use an engine with a CLI-level web-search disable switch")
|
||||
with tempfile.TemporaryDirectory(prefix="autoreview-cursor-agent.") as tempdir:
|
||||
# Trust only the helper-owned empty workspace, never the reviewed repo.
|
||||
# Cursor may load trusted project hooks/config before model instructions apply.
|
||||
cmd = [
|
||||
resolve_command(args.cursor_agent_bin, repo),
|
||||
"--print",
|
||||
"--output-format",
|
||||
"stream-json" if args.stream_engine_output else "json",
|
||||
"--trust",
|
||||
"--workspace",
|
||||
tempdir,
|
||||
"--mode",
|
||||
"ask",
|
||||
"--sandbox",
|
||||
"enabled",
|
||||
]
|
||||
if args.model:
|
||||
cmd.extend(["--model", args.model])
|
||||
result = run_with_heartbeat(
|
||||
cmd,
|
||||
Path(tempdir),
|
||||
input_text=prompt,
|
||||
label="cursor-agent",
|
||||
stream_output=args.stream_engine_output,
|
||||
stream_display=CursorAgentStreamDisplay() if args.stream_engine_output else None,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"cursor-agent 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
|
||||
@@ -779,6 +818,41 @@ class ClaudeStreamDisplay:
|
||||
return text
|
||||
|
||||
|
||||
class CursorAgentStreamDisplay(ClaudeStreamDisplay):
|
||||
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":
|
||||
return self.visible(f"cursor-agent session: {event.get('session_id', '<unknown>')}\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 result_summary(self, event: dict[str, Any]) -> str:
|
||||
usage = event.get("usage")
|
||||
fields: list[str] = []
|
||||
if isinstance(usage, dict):
|
||||
for key in ("inputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens"):
|
||||
value = usage.get(key)
|
||||
if isinstance(value, int):
|
||||
fields.append(f"{key}={value}")
|
||||
return "cursor-agent usage: " + " ".join(fields) + "\n" if fields else "cursor-agent turn completed\n"
|
||||
|
||||
def flush_hidden(self) -> str:
|
||||
if not self.hidden_events:
|
||||
return ""
|
||||
count = self.hidden_events
|
||||
self.hidden_events = 0
|
||||
return f"cursor-agent activity: {count} hidden tool/status events\n"
|
||||
|
||||
|
||||
def format_codex_usage(usage: dict[str, Any]) -> str:
|
||||
fields = [
|
||||
"input_tokens",
|
||||
@@ -868,13 +942,35 @@ def parse_json_candidate(text: str) -> Any | None:
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return parse_embedded_json_object(stripped)
|
||||
if isinstance(parsed, str) and parsed != text:
|
||||
nested = parse_json_candidate(parsed)
|
||||
return nested if nested is not None else parsed
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_embedded_json_object(text: str) -> Any | None:
|
||||
decoder = json.JSONDecoder()
|
||||
candidates: list[Any] = []
|
||||
for index, char in enumerate(text):
|
||||
if char not in "[{":
|
||||
continue
|
||||
try:
|
||||
parsed, _end = decoder.raw_decode(text[index:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, str):
|
||||
nested = parse_json_candidate(parsed)
|
||||
if nested is not None:
|
||||
candidates.append(nested)
|
||||
else:
|
||||
candidates.append(parsed)
|
||||
for candidate in reversed(candidates):
|
||||
if isinstance(candidate, dict) and "findings" in candidate:
|
||||
return candidate
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
def validate_report(report: dict[str, Any], repo: Path, changed_paths: set[str], required: list[str]) -> None:
|
||||
allowed_top = {"findings", "overall_correctness", "overall_explanation", "overall_confidence"}
|
||||
extra_top = set(report) - allowed_top
|
||||
@@ -1016,7 +1112,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude"))
|
||||
parser.add_argument("--droid-bin", default=os.environ.get("DROID_BIN", "droid"))
|
||||
parser.add_argument("--copilot-bin", default=os.environ.get("COPILOT_BIN", "copilot"))
|
||||
parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex and copilot reject no-tools review.")
|
||||
parser.add_argument("--cursor-agent-bin", default=os.environ.get("CURSOR_AGENT_BIN", "cursor-agent"))
|
||||
parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex, copilot, and cursor-agent reject no-tools review.")
|
||||
parser.add_argument("--no-web-search", dest="web_search", action="store_false", default=True)
|
||||
parser.add_argument(
|
||||
"--claude-allowed-tools",
|
||||
@@ -1034,7 +1131,7 @@ def parse_args() -> argparse.Namespace:
|
||||
"--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.",
|
||||
help="Stream review engine output while preserving buffered output for validation. Codex, Claude, and cursor-agent 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(
|
||||
@@ -1061,6 +1158,8 @@ def run_engine(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
return run_droid(args, repo, prompt)
|
||||
if args.engine == "copilot":
|
||||
return run_copilot(args, repo, prompt)
|
||||
if args.engine == "cursor-agent":
|
||||
return run_cursor_agent(args, repo, prompt)
|
||||
raise SystemExit(f"unsupported engine: {args.engine}")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ param(
|
||||
[ValidateSet('malicious', 'benign')]
|
||||
[string] $Fixture,
|
||||
|
||||
[ValidateSet('codex', 'claude', 'droid', 'copilot')]
|
||||
[ValidateSet('codex', 'claude', 'droid', 'copilot', 'cursor-agent')]
|
||||
[string[]] $Engine,
|
||||
|
||||
[Alias('h')]
|
||||
|
||||
@@ -13,7 +13,7 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ENGINES = ("codex", "claude", "droid", "copilot")
|
||||
ENGINES = ("codex", "claude", "droid", "copilot", "cursor-agent")
|
||||
DEFAULT_ENGINES = ("codex", "claude")
|
||||
|
||||
MALICIOUS_INITIAL = """export function uploadPath(name) {
|
||||
|
||||
@@ -13,12 +13,13 @@ registration edge limit.
|
||||
|
||||
- The scarce resource is Blacksmith runner registrations, not Blacksmith vCPU
|
||||
capacity.
|
||||
- GitHub runner registrations are capped at 1,500 per 5 minutes per repository,
|
||||
organization, or enterprise. The `openclaw` organization shares one bucket.
|
||||
- GitHub runner registrations for `openclaw` are currently capped at 3,000 per
|
||||
5 minutes per repository, organization, or enterprise. The `openclaw`
|
||||
organization shares one bucket.
|
||||
- Core REST quota does not draw down this bucket. Check
|
||||
`actions_runner_registration` separately; core quota can be healthy while
|
||||
runner registration is throttled.
|
||||
- Use 1,000 registrations per 5 minutes as the operating target. Leave the last
|
||||
- Use 2,000 registrations per 5 minutes as the operating target. Leave the last
|
||||
third for other repos, retries, and burst overlap.
|
||||
- Jobs that route, notify, summarize, choose shards, or run short CodeQL quality
|
||||
scans should stay on GitHub-hosted runners unless measured evidence says
|
||||
@@ -87,7 +88,7 @@ admission. The debounce only suppresses pushes that arrive while
|
||||
registrations are spent even if a later push cancels the run. If timing is
|
||||
uncertain, count every sequential push in the window.
|
||||
|
||||
Reject a change unless the org-level worst case stays below 1,000 registrations
|
||||
Reject a change unless the org-level worst case stays below 2,000 registrations
|
||||
per 5 minutes with headroom for ClawSweeper, ClawHub, Clownfish, OpenClaw RTT,
|
||||
and Clawbench.
|
||||
|
||||
@@ -127,8 +128,8 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
|
||||
- `runner-admission` on `ubuntu-24.04` with
|
||||
`OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS=90`.
|
||||
- `preflight` and `security-fast` needing `runner-admission`.
|
||||
- CI matrix caps: fast/check lanes at 8, compact Node PR plan at current caps,
|
||||
Windows and Android at 2.
|
||||
- CI matrix caps: fast/check lanes at 12, Node test shards at 24, Windows and
|
||||
Android at 2.
|
||||
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
|
||||
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
|
||||
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca
|
||||
|
||||
# OpenClaw Mac Release
|
||||
|
||||
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -23,7 +23,7 @@ Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`
|
||||
|
||||
## GitHub Secrets
|
||||
|
||||
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
|
||||
Target release-ops repo environment: `openclaw/releases`, env `mac-release`.
|
||||
|
||||
Set only after local notary auth validation:
|
||||
|
||||
@@ -35,12 +35,24 @@ Do not update these from mixed sources. All three ASC fields must come from the
|
||||
|
||||
## Workflow Shape
|
||||
|
||||
- `openclaw/openclaw` is the public product repo. Its GitHub Releases page is
|
||||
where macOS assets are ultimately attached.
|
||||
- `openclaw/openclaw` `macos-release.yml` is public handoff validation only.
|
||||
It never signs, notarizes, or uploads macOS assets, regardless of
|
||||
`preflight_only`.
|
||||
- `openclaw/releases` is the restricted release-ops repo. Its macOS workflows
|
||||
sign, notarize, validate, and promote assets onto the
|
||||
`openclaw/openclaw` GitHub release.
|
||||
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
|
||||
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
|
||||
- Use `source_ref=release/YYYY.M.PATCH` for release-ops mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
|
||||
- Real mac publish must reuse:
|
||||
- a successful private mac preflight run for the same tag/source SHA
|
||||
- a successful private mac validation run for the same tag/source SHA
|
||||
- a successful release-ops mac preflight run for the same tag/source SHA
|
||||
- a successful release-ops mac validation run for the same tag/source SHA
|
||||
- Release-ops preflight and real publish enter the protected `mac-release`
|
||||
environment in the `build_sign_and_package` job. Operators may be able to
|
||||
trigger the workflow while Vincent or another environment reviewer approves
|
||||
the paused deployment before signing/notarization/promotion proceeds.
|
||||
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
|
||||
|
||||
## Notarization
|
||||
@@ -52,10 +64,25 @@ Do not update these from mixed sources. All three ASC fields must come from the
|
||||
|
||||
## Dispatch
|
||||
|
||||
Private preflight:
|
||||
Public handoff validation:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
gh workflow run macos-release.yml --repo openclaw/openclaw \
|
||||
--ref release/YYYY.M.PATCH \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f preflight_only=true \
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
- Use the public release branch as the workflow ref so the Actions list displays
|
||||
`release/YYYY.M.PATCH`, matching prior stable macOS handoff runs.
|
||||
- Do not use `--ref main` or `--ref vYYYY.M.PATCH` for this public handoff
|
||||
validation. The workflow checks out the tag from the `tag` input internally.
|
||||
|
||||
Release-ops preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f source_ref=release/YYYY.M.PATCH \
|
||||
-f preflight_only=true \
|
||||
@@ -64,18 +91,24 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --re
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
Private validation for a branch-variation preflight:
|
||||
Wait for the run to reach the `mac-release` environment approval if GitHub
|
||||
pauses it, then get approval from Vincent or another configured environment
|
||||
reviewer. Record the successful preflight run id.
|
||||
|
||||
Release-ops validation for a branch-variation preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases --ref main \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f source_ref=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
Record the successful validation run id.
|
||||
|
||||
Real publish:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f preflight_only=false \
|
||||
-f smoke_test_only=false \
|
||||
@@ -85,6 +118,14 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --re
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
Wait for the `mac-release` environment approval again if GitHub pauses the real
|
||||
publish run before it promotes assets.
|
||||
|
||||
- Release-ops `openclaw/releases` publish/validate workflows run from their own
|
||||
trusted `main` workflow ref. Real publish has a guard that rejects any other
|
||||
workflow ref. That displayed `main` ref is expected; the public OpenClaw
|
||||
source is selected by `tag` and optional `source_ref`.
|
||||
|
||||
## Verify
|
||||
|
||||
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
|
||||
@@ -203,8 +203,9 @@ Stable publication is not complete until `main` carries the actual shipped relea
|
||||
validation-only release machinery. If mac packaging needs release-branch-only
|
||||
fixes after the stable npm package or GitHub tag is already published, do not
|
||||
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
|
||||
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
|
||||
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
|
||||
Dispatch the release-ops mac workflows for the original `tag=vYYYY.M.PATCH`
|
||||
with `source_ref=release/YYYY.M.PATCH` and
|
||||
`public_release_branch=release/YYYY.M.PATCH`;
|
||||
provenance checks must prove the source SHA descends from the tag and
|
||||
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
|
||||
tags for emergency hotfixes that must publish a new npm package/release
|
||||
@@ -579,8 +580,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
|
||||
- Serialize host build/package mutations ahead of VM lanes. Finish `pnpm build`, `pnpm ui:build`, `pnpm release:check`, install smoke, and any Docker/package-prep lanes before starting Parallels `npm pack` lanes; otherwise `dist` can disappear during VM pack prep and produce false failures.
|
||||
- Include mac release readiness in preflight by running the public validation
|
||||
workflow in `openclaw/openclaw` and the real mac preflight in
|
||||
`openclaw/releases-private` for every release.
|
||||
workflow in `openclaw/openclaw` and the release-ops mac preflight in
|
||||
`openclaw/releases` for every release.
|
||||
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
|
||||
- The workflows remain tag-based. The agent is responsible for making sure
|
||||
preflight runs complete successfully before any publish run starts.
|
||||
@@ -608,16 +609,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
## Use the right auth flow
|
||||
|
||||
- OpenClaw publish uses GitHub trusted publishing.
|
||||
- Stable npm promotion from `beta` to `latest` uses the private
|
||||
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
|
||||
workflow because `npm dist-tag` management needs `NPM_TOKEN`, while the
|
||||
public npm release workflow stays OIDC-only.
|
||||
- Prefer fixing the private workflow token path over any local 1Password
|
||||
fallback. The desired setup is a granular npm token stored as the private
|
||||
- Stable npm promotion from `beta` to `latest` uses the restricted release-ops
|
||||
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
|
||||
because `npm dist-tag` management needs `NPM_TOKEN`, while the public npm
|
||||
release workflow stays OIDC-only.
|
||||
- Prefer fixing the release-ops workflow token path over any local 1Password
|
||||
fallback. The desired setup is a granular npm token stored as the release-ops
|
||||
repo's `NPM_TOKEN` secret, scoped to the `openclaw` package with read/write
|
||||
and 2FA bypass for automation.
|
||||
- If the private dist-tag workflow cannot promote because `NPM_TOKEN` is absent
|
||||
or stale, use the local tmux + 1Password fallback:
|
||||
- If the release-ops dist-tag workflow cannot promote because `NPM_TOKEN` is
|
||||
absent or stale, use the local tmux + 1Password fallback:
|
||||
- Start or reuse a tmux session so interactive `npm login` and OTP prompts
|
||||
are observable and recoverable.
|
||||
- Hard rule: never run `op` directly in the main agent shell during release
|
||||
@@ -635,21 +636,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- Verify with a cache-bypassed registry read, for example:
|
||||
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
|
||||
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
|
||||
- Direct stable publishes can also use that private dist-tag workflow to point
|
||||
`beta` at the already-published `latest` version when the operator wants both
|
||||
tags aligned immediately.
|
||||
- Direct stable publishes can also use that release-ops dist-tag workflow to
|
||||
point `beta` at the already-published `latest` version when the operator wants
|
||||
both tags aligned immediately.
|
||||
- The publish run must be started manually with `workflow_dispatch`.
|
||||
- The npm workflow and the private mac publish workflow accept
|
||||
- The npm workflow and the release-ops mac publish workflow accept
|
||||
`preflight_only=true` to run validation/build/package steps without uploading
|
||||
public release assets.
|
||||
- Real npm publish requires a prior successful npm preflight run id and the
|
||||
successful Full Release Validation run id for the same tag/SHA so the publish
|
||||
job promotes the prepared tarball instead of rebuilding it and attaches the
|
||||
correct release evidence.
|
||||
- Real private mac publish requires a prior successful private mac preflight
|
||||
run id so the publish job promotes the prepared artifacts instead of
|
||||
- Real release-ops mac publish requires a prior successful release-ops mac
|
||||
preflight run id so the publish job promotes the prepared artifacts instead of
|
||||
rebuilding or renotarizing them again.
|
||||
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
|
||||
- The release-ops mac workflow also accepts `smoke_test_only=true` for branch-safe
|
||||
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
|
||||
appcast generation, and do not prove release readiness.
|
||||
- `preflight_only=true` on the npm workflow is also the right way to validate an
|
||||
@@ -670,27 +671,27 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
use only `main` or `release/YYYY.M.PATCH`.
|
||||
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
|
||||
public validation-only handoff. It validates the tag/release state and points
|
||||
operators to the private repo. It still rebuilds the JS outputs needed for
|
||||
operators to the release-ops repo. It still rebuilds the JS outputs needed for
|
||||
release validation, but it does not sign, notarize, or publish macOS
|
||||
artifacts.
|
||||
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
|
||||
is the required private mac validation lane for `swift test`; keep it green
|
||||
- `openclaw/releases/.github/workflows/openclaw-macos-validate.yml` is the
|
||||
required release-ops mac validation lane for `swift test`; keep it green
|
||||
before any real stable mac publish run starts.
|
||||
- Real mac preflight and real mac publish both use
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
|
||||
- The private mac validation lane runs on GitHub's standard macOS runner.
|
||||
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
|
||||
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml`.
|
||||
- The release-ops mac validation lane runs on GitHub's standard macOS runner.
|
||||
- The release-ops mac preflight path runs on GitHub's xlarge macOS runner and uses
|
||||
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
|
||||
- Private mac preflight uploads notarized build artifacts as workflow artifacts
|
||||
instead of uploading public GitHub release assets.
|
||||
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
|
||||
- Release-ops mac preflight uploads notarized build artifacts as workflow
|
||||
artifacts instead of uploading public GitHub release assets.
|
||||
- Release-ops smoke-test runs upload ad-hoc, non-notarized build artifacts as
|
||||
workflow artifacts and intentionally skip stable `appcast.xml` generation.
|
||||
- For stable releases, npm preflight, Full Release Validation, public mac
|
||||
validation, private mac validation, and private mac preflight must all pass
|
||||
before any real publish run starts. For beta releases, npm preflight and Full
|
||||
Release Validation must pass before npm publish unless the operator explicitly
|
||||
waives the full gate; mac beta validation is still only required when
|
||||
requested.
|
||||
validation, release-ops mac validation, and release-ops mac preflight must all
|
||||
pass before any real publish run starts. For beta releases, npm preflight and
|
||||
Full Release Validation must pass before npm publish unless the operator
|
||||
explicitly waives the full gate; mac beta validation is still only required
|
||||
when requested.
|
||||
- Real publish runs may be dispatched from `main` or from a
|
||||
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
|
||||
in that release branch, and the real publish must reuse a successful preflight
|
||||
@@ -699,21 +700,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
rather than workflow-level SHA pinning.
|
||||
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
|
||||
- Mac publish uses
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
|
||||
private mac preflight artifact preparation and real publish artifact
|
||||
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for
|
||||
release-ops mac preflight artifact preparation and real publish artifact
|
||||
promotion.
|
||||
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
|
||||
- Real release-ops mac publish uploads the packaged `.zip`, `.dmg`, and
|
||||
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
|
||||
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
|
||||
private repo `mac-release` environment.
|
||||
release-ops repo `mac-release` environment.
|
||||
- For stable releases, the agent must also download the signed
|
||||
`macos-appcast-<tag>` artifact from the successful private mac workflow and
|
||||
then update `appcast.xml` on `main`.
|
||||
`macos-appcast-<tag>` artifact from the successful release-ops mac workflow
|
||||
and then update `appcast.xml` on `main`.
|
||||
- For beta mac releases, do not update the shared production `appcast.xml`
|
||||
unless a separate beta Sparkle feed exists.
|
||||
- The private repo targets a dedicated `mac-release` environment. If the GitHub
|
||||
plan does not yet support required reviewers there, do not assume the
|
||||
environment alone is the approval boundary; rely on private repo access and
|
||||
- The release-ops repo targets a dedicated `mac-release` environment. If the
|
||||
GitHub plan does not yet support required reviewers there, do not assume the
|
||||
environment alone is the approval boundary; rely on restricted repo access and
|
||||
CODEOWNERS until those settings can be enabled.
|
||||
- Do not use `NPM_TOKEN` or the plugin OTP flow for the OpenClaw package
|
||||
publish path; package publishing uses trusted publishing.
|
||||
@@ -800,12 +801,12 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
18. For stable releases, start `.github/workflows/macos-release.yml` in
|
||||
`openclaw/openclaw` and wait for the public validation-only run to pass.
|
||||
19. For stable releases, start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
|
||||
with the same tag and wait for the private mac validation lane to pass.
|
||||
`openclaw/releases/.github/workflows/openclaw-macos-validate.yml` with the
|
||||
same tag and wait for the release-ops mac validation lane to pass.
|
||||
20. For stable releases, start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
with `preflight_only=true` and wait for it to pass. Save that run id because
|
||||
the real publish requires it to reuse the notarized mac artifacts.
|
||||
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` with
|
||||
`preflight_only=true` and wait for it to pass. Save that run id because the
|
||||
real publish requires it to reuse the notarized mac artifacts.
|
||||
21. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
delete the tag and any accidental draft/incomplete GitHub release, recreate
|
||||
the tag from the fixed commit, and rerun all relevant preflights from
|
||||
@@ -861,22 +862,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
promotion roster when the matching beta already carried the full confidence
|
||||
pass: published npm postpublish verify, Docker install/update smoke,
|
||||
macOS-only Parallels install/update smoke, and required QA signal.
|
||||
Then start the private
|
||||
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
|
||||
workflow to promote that stable version from `beta` to `latest`, then
|
||||
verify `latest` now points at that version.
|
||||
Then start the restricted release-ops
|
||||
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
|
||||
to promote that stable version from `beta` to `latest`, then verify
|
||||
`latest` now points at that version.
|
||||
29. If the stable release was published directly to `latest` and `beta` should
|
||||
follow it, start that same private dist-tag workflow to point `beta` at the
|
||||
stable version, then verify both `latest` and `beta` point at that version.
|
||||
follow it, start that same release-ops dist-tag workflow to point `beta` at
|
||||
the stable version, then verify both `latest` and `beta` point at that
|
||||
version.
|
||||
30. For stable releases, start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
for the real publish with the successful private mac `preflight_run_id` and
|
||||
wait for success.
|
||||
31. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
|
||||
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for the
|
||||
real publish with the successful release-ops mac `preflight_run_id` and wait
|
||||
for success.
|
||||
31. Verify the successful real release-ops mac run uploaded the `.zip`, `.dmg`,
|
||||
and `.dSYM.zip` artifacts to the existing GitHub release in
|
||||
`openclaw/openclaw`.
|
||||
32. For stable releases, download `macos-appcast-<tag>` from the successful
|
||||
private mac run, update `appcast.xml` on `main`, verify the feed, then
|
||||
release-ops mac run, update `appcast.xml` on `main`, verify the feed, then
|
||||
complete the **Close stable releases on main** gate.
|
||||
33. For beta releases, publish the mac assets only when intentionally requested;
|
||||
expect no shared production
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -858,7 +858,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 12
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -977,7 +977,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 12
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1058,7 +1058,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 12
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1212,8 +1212,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# The canonical main path waits for the admission debounce above, so
|
||||
# modestly widen this large matrix without recreating registration bursts.
|
||||
max-parallel: 16
|
||||
# widen this large matrix within the current runner-registration budget.
|
||||
max-parallel: 24
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1351,7 +1351,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-guards
|
||||
@@ -1493,7 +1493,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-additional-boundaries-a
|
||||
@@ -2419,7 +2419,8 @@ jobs:
|
||||
- macos-swift
|
||||
- ios-build
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
# Re-enable this job when we want to collect CI timing data for timing optimization.
|
||||
if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
51
.github/workflows/plugin-init-scaffold-validation.yml
vendored
Normal file
51
.github/workflows/plugin-init-scaffold-validation.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Plugin Init Scaffold Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/plugin-init-scaffold-validation.yml"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/validate-plugin-init-provider-scaffold.ts"
|
||||
- "src/cli/plugins-authoring-command.ts"
|
||||
- "src/cli/plugins-authoring-command.test.ts"
|
||||
- "src/cli/plugins-cli.ts"
|
||||
- "src/plugin-sdk/**"
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- ".github/workflows/plugin-init-scaffold-validation.yml"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/validate-plugin-init-provider-scaffold.ts"
|
||||
- "src/cli/plugins-authoring-command.ts"
|
||||
- "src/cli/plugins-authoring-command.test.ts"
|
||||
- "src/cli/plugins-cli.ts"
|
||||
- "src/plugin-sdk/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate-provider-scaffold:
|
||||
name: Validate provider scaffold
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Generate and validate provider scaffold
|
||||
run: pnpm test:plugins:init-provider-scaffold
|
||||
@@ -143,6 +143,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## GitHub / PRs
|
||||
|
||||
- Fresh GitHub items: read `CONTRIBUTING.md`, the issue chooser/form, PR template, and `.github/CODEOWNERS`; blank issues are disabled; preserve templates and evidence requirements.
|
||||
- Agent-authored/non-trivial work: create or reuse the issue first; tiny fixes may go direct. PRs use the template, link context, and keep durable problem/impact/evidence sections.
|
||||
- Route support to Discord and security through `SECURITY.md`. Use listed maintainer areas/`CODEOWNERS`; never guess mentions.
|
||||
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
|
||||
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
|
||||
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
|
||||
|
||||
117
CHANGELOG.md
117
CHANGELOG.md
@@ -2,45 +2,100 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.10
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
|
||||
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
|
||||
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
|
||||
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
|
||||
|
||||
### Changes
|
||||
|
||||
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
|
||||
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
|
||||
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
|
||||
- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.
|
||||
- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt.
|
||||
|
||||
### Complete contribution record
|
||||
## 2026.6.10
|
||||
|
||||
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
Automatic fast mode starts short conversations quickly, then returns longer or fallback work to normal mode without losing visible state. Provider routing, channel progress, session identity, and trusted tool policies are more reliable, with smaller improvements spanning provider setup, diagnostics, and transcript tooling.
|
||||
|
||||
#### Pull requests
|
||||
### Highlights
|
||||
|
||||
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
|
||||
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
|
||||
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
|
||||
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
|
||||
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
|
||||
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
|
||||
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
|
||||
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
|
||||
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
|
||||
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
|
||||
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
|
||||
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
|
||||
#### Automatic fast mode
|
||||
|
||||
- Adds [`/fast auto`](https://docs.openclaw.ai/tools/thinking) so short conversational calls can start quickly, while longer or fallback work returns to normal mode with the effective state still visible. [PR #85104](https://github.com/openclaw/openclaw/pull/85104), [Issue #85087](https://github.com/openclaw/openclaw/issues/85087). Thanks @alexph-dev and @vincentkoc.
|
||||
- Shows the effective automatic fast-mode state in status instead of reducing it to on/off, and avoids carrying a cleared Codex service-tier choice into later runs. [8845f2f](https://github.com/openclaw/openclaw/commit/8845f2fd6143becc37110ab5021dd5e1517f0cdc). Thanks @vincentkoc.
|
||||
- Keeps automatic fast-mode timing consistent when a turn switches to a fallback model. [075091d](https://github.com/openclaw/openclaw/commit/075091d0cab94053ff094268efc0acb225d514f4). Thanks @vincentkoc.
|
||||
- Keeps the original fast-mode timing and progress behavior when a live model switch retries a turn. [d1e190f](https://github.com/openclaw/openclaw/commit/d1e190fbe822ad6ae4e660ce376b60ec9fdb0fba). Thanks @vincentkoc.
|
||||
- Keeps automatic fast-mode progress and reset behavior distinct from explicit fast mode after a run switches modes. [20aec98](https://github.com/openclaw/openclaw/commit/20aec985545db7a24ea066e5bff1c47b789cbded). Thanks @vincentkoc.
|
||||
- Shows the effective fast-mode value in connected-agent sessions instead of the configured value, so status reflects what the session is actually using. [9509aa0](https://github.com/openclaw/openclaw/commit/9509aa063c0ef3e32be1516fcb0c23606b6d5c7b). Thanks @vincentkoc.
|
||||
- Keeps the effective automatic fast-mode setting visible through fallback transitions in connected-agent sessions. [7f5423c](https://github.com/openclaw/openclaw/commit/7f5423ca97174a3f16c211db54a6c96e5b3a6089). Thanks @vincentkoc.
|
||||
- Keeps automatic fast-mode timing and progress consistent when reply and [scheduled-agent runs](https://docs.openclaw.ai/automation/cron-jobs) retry or switch models. [6c29f88](https://github.com/openclaw/openclaw/commit/6c29f88913796bfe05696556cd82246670b126f0). Thanks @vincentkoc.
|
||||
- Keeps fast-mode cleanup and status consistent when a run switches between fallback models. [c4694f8](https://github.com/openclaw/openclaw/commit/c4694f84ffd52064f89609098cc4f8570fb72e1b). Thanks @vincentkoc.
|
||||
- Shows the automatic fast-mode reset only when fallback work is finished, so status messages match the end of the transition. [f4d93c8](https://github.com/openclaw/openclaw/commit/f4d93c855bff6930f5e5d739b95e0c2612ec4899). Thanks @vincentkoc.
|
||||
- Shows reset and delivery progress at the right time when auto-reply or other follow-up runs retry or leave automatic fast mode. [684e440](https://github.com/openclaw/openclaw/commit/684e44013778bd47d159e64b2595e4d09a92ebea). Thanks @vincentkoc.
|
||||
|
||||
### Channels and Messaging
|
||||
|
||||
#### Channel delivery and progress updates
|
||||
|
||||
- Prevents the next turn after a [scheduled message](https://docs.openclaw.ai/automation/cron-jobs) from losing what was delivered or whether delivery failed, so replies can use that context without exposing cron details in the channel. [PR #93580](https://github.com/openclaw/openclaw/pull/93580). Thanks @jalehman and @scotthuang.
|
||||
- Prevents streamed channel progress from dropping a repeated status that represents a separate step, so each meaningful step remains visible in the draft. [2d42e52](https://github.com/openclaw/openclaw/commit/2d42e52ac5513e0bd824b8a0e069db83e04bc056). Thanks @vincentkoc.
|
||||
- Prevents keyed streamed progress from staying on an older status, so viewers see the latest state instead of stale text. [8bb6472](https://github.com/openclaw/openclaw/commit/8bb6472c4de2eea06f1ba31d6ed679e2ac4581b0). Thanks @vincentkoc.
|
||||
|
||||
### Providers and Models
|
||||
|
||||
#### Provider model catalogs and reasoning controls
|
||||
|
||||
- Treats Zhipu/GLM overload responses as overloads, so a configured fallback is selected for the right reason instead of following the wrong failover path. [PR #93241](https://github.com/openclaw/openclaw/pull/93241), [Issue #93211](https://github.com/openclaw/openclaw/issues/93211). Thanks @0xghost42 and @zhengli0922.
|
||||
- Prevents Telegram, Slack, and Discord `/think` menus for live Ollama models from hiding supported levels, so users can choose valid reasoning settings without guessing. [PR #94067](https://github.com/openclaw/openclaw/pull/94067), [Issue #93835](https://github.com/openclaw/openclaw/issues/93835). Thanks @civiltox and @openperf.
|
||||
- Expands [`zai/glm-5.2` thinking choices](https://docs.openclaw.ai/tools/thinking) beyond binary on/off and sends high or max requests as the intended Z.AI reasoning effort. [PR #94136](https://github.com/openclaw/openclaw/pull/94136). Thanks @borclaw.
|
||||
- Prevents bundled [Z.ai GLM-5 models](https://docs.openclaw.ai/providers/zai) from falling through to OpenAI and producing misleading API-key errors, so they use Z.AI by default. [PR #94461](https://github.com/openclaw/openclaw/pull/94461), [Issue #94269](https://github.com/openclaw/openclaw/issues/94269). Thanks @chrysb and @pandah97.
|
||||
- Adds GLM-5.2 and Kimi K2.7 Code to the [OpenCode Go catalog](https://docs.openclaw.ai/providers/opencode-go) with current limits, so users can select the models from OpenClaw. [66f84a9](https://github.com/openclaw/openclaw/commit/66f84a9bf1082de26f92b2b3741cc2f34aba34fa). Thanks @samson1357924.
|
||||
- Corrects `kimi-k2.7-code` capability listings so OpenCode Go users are not offered unsupported video prompts when the model accepts text and images. [715dc71](https://github.com/openclaw/openclaw/commit/715dc718fc5a2a5d6f7e9ec16e0269382b726e83).
|
||||
|
||||
#### Provider plugin onboarding
|
||||
|
||||
- Prevents first-run setup from skipping the selected provider's credential prompt after plugin installation, so onboarding continues with that provider instead of falling back to OpenAI. [PR #95792](https://github.com/openclaw/openclaw/pull/95792), [Issue #95765](https://github.com/openclaw/openclaw/issues/95765). Thanks @snowzlmbot.
|
||||
|
||||
### Memory, Sessions, and State
|
||||
|
||||
#### Session transcript SDK helpers
|
||||
|
||||
- Adds a durable [session-transcript SDK contract](https://docs.openclaw.ai/plugins/sdk-runtime) so plugins can read, append, publish, and lock the intended transcript without treating [legacy file paths](https://docs.openclaw.ai/plugins/sdk-subpaths) as identity. [PR #95030](https://github.com/openclaw/openclaw/pull/95030). Thanks @jalehman.
|
||||
|
||||
#### Cross-channel session identity
|
||||
|
||||
- Prevents a shared direct-message [session](https://docs.openclaw.ai/concepts/session) from carrying the previous [channel's identity](https://docs.openclaw.ai/channels/channel-routing) after a switch, so status, reactions, threads, and message references target the current channel. [PR #95328](https://github.com/openclaw/openclaw/pull/95328), [Issue #95325](https://github.com/openclaw/openclaw/issues/95325). Thanks @gorkem2020, @jalehman, and @zengwen-dt.
|
||||
|
||||
### Gateway, Security, and Trust
|
||||
|
||||
#### Prompt context boundaries
|
||||
|
||||
- Keeps empty prompts separate from hook-added context during compaction or session reuse in [Copilot and Codex sessions](https://docs.openclaw.ai/plugins/copilot), so prompt boundaries remain consistent. [PR #94838](https://github.com/openclaw/openclaw/pull/94838). Thanks @vincentkoc.
|
||||
|
||||
#### Trusted tool policy enforcement
|
||||
|
||||
- Keeps [approval-sensitive Gateway and plugin tools](https://docs.openclaw.ai/plugins/hooks) protected when connected extensions change, so configured safeguards continue to apply. [PR #94545](https://github.com/openclaw/openclaw/pull/94545). Thanks @jesse-merhi.
|
||||
|
||||
#### Trusted package redirects
|
||||
|
||||
- Prevents authenticated package-source tokens from being sent to an allowed redirect on another origin, while the valid redirected download still completes. [b0df6dc](https://github.com/openclaw/openclaw/commit/b0df6dc10eb5b9e9fdca93063a16316f8589954e).
|
||||
|
||||
### Clients and Interfaces
|
||||
|
||||
#### Docker and Podman setup timeouts
|
||||
|
||||
- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695).
|
||||
|
||||
### Plugins and Packaging
|
||||
|
||||
#### Codex service-tier clearing
|
||||
|
||||
- Prevents cleared [Codex service tiers](https://docs.openclaw.ai/tools/thinking) from being persisted as explicit stale state, so resumed or switched conversations use the normal default instead. [cd32d9f](https://github.com/openclaw/openclaw/commit/cd32d9ff91caf84c0ead38796ef096cdc5bea06e). Thanks @vincentkoc.
|
||||
|
||||
#### StepFun provider installation
|
||||
|
||||
- Restores [ClawHub discovery](https://docs.openclaw.ai/plugins/reference/stepfun) for the [StepFun provider](https://docs.openclaw.ai/providers/stepfun) plugin, so operators can install it through either ClawHub or npm. [ecb82f1](https://github.com/openclaw/openclaw/commit/ecb82f1be93024be23c1b191ebea92c63230b6c0). Thanks @vincentkoc.
|
||||
|
||||
### Docs and Operator Workflows
|
||||
|
||||
#### Doctor check ordering
|
||||
|
||||
- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo.
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -97,6 +97,23 @@ Welcome to the lobster tank! 🦞
|
||||
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## Issue, PR, and Contact Routing
|
||||
|
||||
Start from this routing map before creating GitHub items:
|
||||
|
||||
| Situation | Use | Required evidence |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| Product bug, regression, crash, or behavior defect | [Bug report](https://github.com/openclaw/openclaw/issues/new?template=bug_report.yml) | Repro steps, expected vs actual behavior, version, OS, model/provider route when relevant, logs/screenshots, impact |
|
||||
| Documentation bug or missing/contradictory docs | [Docs bug report](https://github.com/openclaw/openclaw/issues/new?template=docs_bug_report.yml) | Affected docs path or URL, verification steps, expected docs content, actual docs content, impact, evidence |
|
||||
| New feature, architecture change, or product improvement | [Feature request](https://github.com/openclaw/openclaw/issues/new?template=feature_request.yml) or Discord first | Problem, proposed solution, alternatives, impact, examples or prior art |
|
||||
| Onboarding, setup help, or general support question | Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) | Do not open a GitHub issue unless there is a concrete product defect or docs gap |
|
||||
| Security vulnerability | See [Report a Vulnerability](#report-a-vulnerability) below | Do not file public issues for private security reports |
|
||||
| PR for an existing or newly filed issue | Use the [PR template](.github/pull_request_template.md) | Visible `Closes #<issue>` or `Related: #<issue>`, problem, shipped solution, user impact, validation evidence |
|
||||
|
||||
For agent-authored or otherwise non-trivial work, create or reuse the issue first, then open the PR against it. Bugs and very small fixes may go straight to PR, but still link existing context when it exists and fill out the PR template.
|
||||
|
||||
Do not guess who to tag. Let issue forms, labels/automation, `.github/CODEOWNERS`, and the maintainer areas above route the work. Mention a maintainer only when their listed area or owned path is directly relevant and you need a decision; otherwise rely on normal review. For coordinated change sets, ask in **#clawtributors** before opening more than the PR limit.
|
||||
|
||||
## PR Limits
|
||||
|
||||
We cap at **20 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.
|
||||
|
||||
@@ -304,6 +304,9 @@ by Peter Steinberger and the community.
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
Use the [issue chooser](https://github.com/openclaw/openclaw/issues/new/choose) for bugs, docs bugs, and feature requests;
|
||||
ask setup/support questions in [Discord](https://discord.gg/clawd); and report vulnerabilities through [SECURITY.md](SECURITY.md).
|
||||
PRs should link the relevant issue when possible and follow the [PR template](.github/pull_request_template.md) with problem, impact, and evidence.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
|
||||
174
appcast.xml
174
appcast.xml
@@ -2,6 +2,53 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.10</title>
|
||||
<pubDate>Fri, 26 Jun 2026 23:37:36 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606001090</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.10</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.10</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li><strong>Automatic fast mode for talks:</strong> OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.</li>
|
||||
<li><strong>More reliable model routing:</strong> Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
|
||||
<li><strong>Safer session and channel state:</strong> channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.</li>
|
||||
<li><strong>Trusted policies survive hook composition:</strong> composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li><strong>Agent and channel runtime:</strong> fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
|
||||
<li><strong>Provider behavior:</strong> model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li><strong>Fast-mode and policy correctness:</strong> fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.</li>
|
||||
<li><strong>Model and delivery edge cases:</strong> Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
|
||||
<li><strong>Provider plugin onboarding:</strong> setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.</li>
|
||||
</ul>
|
||||
<h3>Complete contribution record</h3>
|
||||
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
<h4>Pull requests</h4>
|
||||
<ul>
|
||||
<li><strong>PR #86627</strong> Keep core doctor health in contribution order. Thanks @giodl73-repo.</li>
|
||||
<li><strong>PR #93580</strong> fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.</li>
|
||||
<li><strong>PR #95030</strong> refactor: add SDK transcript identity target API. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94838</strong> refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #95328</strong> fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.</li>
|
||||
<li><strong>PR #94461</strong> fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.</li>
|
||||
<li><strong>PR #93241</strong> fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.</li>
|
||||
<li><strong>PR #94067</strong> fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.</li>
|
||||
<li><strong>PR #94136</strong> fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.</li>
|
||||
<li><strong>PR #85104</strong> feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.</li>
|
||||
<li><strong>PR #94545</strong> fix: keep trusted policies with hook registry. Thanks @jesse-merhi.</li>
|
||||
<li><strong>PR #95792</strong> fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.10/OpenClaw-2026.6.10.zip" length="56115790" type="application/octet-stream" sparkle:edSignature="MEeGG8+WePhUg9uDShznmdhhAgy/WWe7bAwr4XRTauNdrM441iziQYIlwhfNrtHDHX+uE1/tkRtIMcELfuekAg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.8</title>
|
||||
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
|
||||
@@ -124,132 +171,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026060190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
|
||||
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
|
||||
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
|
||||
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
|
||||
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
|
||||
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
|
||||
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
|
||||
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
|
||||
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
|
||||
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
|
||||
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
|
||||
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
|
||||
<li>iOS: support native iPad display layouts.</li>
|
||||
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
|
||||
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
|
||||
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
|
||||
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
|
||||
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
|
||||
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
|
||||
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
|
||||
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
|
||||
<li>Providers: add MiniMax M3 model support. (#88860)</li>
|
||||
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
|
||||
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
|
||||
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
|
||||
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
|
||||
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
|
||||
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
|
||||
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
|
||||
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
|
||||
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
|
||||
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
|
||||
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
|
||||
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
|
||||
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
|
||||
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
|
||||
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
|
||||
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
|
||||
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
|
||||
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
|
||||
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
|
||||
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
|
||||
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
|
||||
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
|
||||
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
|
||||
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
|
||||
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
|
||||
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
|
||||
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
|
||||
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
|
||||
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
|
||||
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
|
||||
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
|
||||
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
|
||||
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
|
||||
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
|
||||
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
|
||||
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
|
||||
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
|
||||
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
|
||||
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
|
||||
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
|
||||
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
|
||||
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
|
||||
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
|
||||
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
|
||||
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
|
||||
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
|
||||
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
|
||||
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
|
||||
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
|
||||
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
|
||||
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
|
||||
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
|
||||
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
|
||||
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
|
||||
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
|
||||
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
|
||||
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
|
||||
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
|
||||
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
|
||||
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
|
||||
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
|
||||
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
|
||||
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
|
||||
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
|
||||
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
|
||||
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
|
||||
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
|
||||
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
|
||||
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
|
||||
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
|
||||
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -56,6 +56,38 @@ Recommended workflow:
|
||||
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
|
||||
|
||||
## Release SHA tracking
|
||||
|
||||
Successful Play build uploads create a non-tag Git ref that records the source
|
||||
commit for the uploaded store build:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/android/<versionName>-<versionCode>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/android/2026.6.10-2026061008
|
||||
```
|
||||
|
||||
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
|
||||
not appear on GitHub release or tag pages, and they do not participate in the
|
||||
core OpenClaw release machinery.
|
||||
|
||||
`pnpm android:release:upload` checks the ref before uploading the Play build and
|
||||
records it only after `upload_to_play_store` succeeds. Existing refs are
|
||||
immutable: the same ref at the same SHA is accepted, while the same ref at a
|
||||
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
|
||||
not record it because no Play build is published.
|
||||
|
||||
Useful direct commands:
|
||||
|
||||
```bash
|
||||
pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008
|
||||
pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008
|
||||
```
|
||||
|
||||
## Signing model
|
||||
|
||||
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
|
||||
|
||||
@@ -198,6 +198,58 @@ def capture_android_screenshots!
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
|
||||
end
|
||||
|
||||
def mobile_release_ref_script
|
||||
File.join(repo_root, "scripts", "mobile-release-ref.ts")
|
||||
end
|
||||
|
||||
def release_git_sha
|
||||
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
|
||||
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
|
||||
stdout.strip
|
||||
end
|
||||
|
||||
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
args = [
|
||||
"node",
|
||||
"--import",
|
||||
"tsx",
|
||||
mobile_release_ref_script,
|
||||
command,
|
||||
"--platform",
|
||||
platform,
|
||||
"--version",
|
||||
version,
|
||||
"--root",
|
||||
repo_root,
|
||||
]
|
||||
args.push("--build", build.to_s) if build
|
||||
args.push("--version-code", version_code.to_s) if version_code
|
||||
args.push("--sha", sha.to_s) if sha
|
||||
sh(shell_join(args))
|
||||
end
|
||||
|
||||
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"preflight",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"record",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def read_android_release_signing_properties!(path)
|
||||
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
|
||||
|
||||
@@ -282,6 +334,13 @@ def upload_play_store_metadata!(version_metadata)
|
||||
end
|
||||
|
||||
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
|
||||
release_sha = release_git_sha
|
||||
ensure_mobile_release_ref_available!(
|
||||
platform: "android",
|
||||
version: version_metadata.fetch(:version),
|
||||
version_code: version_metadata.fetch(:version_code),
|
||||
sha: release_sha
|
||||
)
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
|
||||
validate_android_screenshots!
|
||||
sync_android_changelog!(version_metadata.fetch(:version_code))
|
||||
@@ -302,6 +361,15 @@ def upload_play_store_build!(version_metadata, upload_metadata: false, upload_im
|
||||
skip_upload_screenshots: !upload_screenshots,
|
||||
validate_only: play_validate_only?
|
||||
)
|
||||
|
||||
unless play_validate_only?
|
||||
record_mobile_release_ref!(
|
||||
platform: "android",
|
||||
version: version_metadata.fetch(:version),
|
||||
version_code: version_metadata.fetch(:version_code),
|
||||
sha: release_sha
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env"))
|
||||
|
||||
@@ -129,6 +129,37 @@ pnpm ios:version:pin -- --version 2026.4.10
|
||||
|
||||
This keeps the TestFlight version stable while review is in flight.
|
||||
|
||||
## Release SHA tracking
|
||||
|
||||
Successful App Store Connect uploads create a non-tag Git ref that records the
|
||||
source commit for the uploaded store build:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/ios/<CFBundleShortVersionString>-<CFBundleVersion>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/ios/2026.6.10-8
|
||||
```
|
||||
|
||||
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
|
||||
not appear on GitHub release or tag pages, and they do not participate in the
|
||||
core OpenClaw release machinery.
|
||||
|
||||
`pnpm ios:release:upload` checks the ref before archive/upload work and records
|
||||
it only after `upload_to_testflight` succeeds. Existing refs are immutable: the
|
||||
same ref at the same SHA is accepted, while the same ref at a different SHA
|
||||
fails.
|
||||
|
||||
Useful direct commands:
|
||||
|
||||
```bash
|
||||
pnpm mobile:release:preflight -- --platform ios --version 2026.6.10 --build 8
|
||||
pnpm mobile:release:resolve -- --platform ios --version 2026.6.10 --build 8
|
||||
```
|
||||
|
||||
## New release promotion workflow
|
||||
|
||||
When you want the next production iOS release to align with the current gateway release:
|
||||
|
||||
@@ -1128,6 +1128,58 @@ def prepare_app_store_release!(version:, build_number:)
|
||||
release_xcconfig
|
||||
end
|
||||
|
||||
def mobile_release_ref_script
|
||||
File.join(repo_root, "scripts", "mobile-release-ref.ts")
|
||||
end
|
||||
|
||||
def release_git_sha
|
||||
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
|
||||
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
|
||||
stdout.strip
|
||||
end
|
||||
|
||||
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
args = [
|
||||
"node",
|
||||
"--import",
|
||||
"tsx",
|
||||
mobile_release_ref_script,
|
||||
command,
|
||||
"--platform",
|
||||
platform,
|
||||
"--version",
|
||||
version,
|
||||
"--root",
|
||||
repo_root,
|
||||
]
|
||||
args.push("--build", build.to_s) if build
|
||||
args.push("--version-code", version_code.to_s) if version_code
|
||||
args.push("--sha", sha.to_s) if sha
|
||||
sh(shell_join(args))
|
||||
end
|
||||
|
||||
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"preflight",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"record",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def validate_app_store_ipa!(ipa_path)
|
||||
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
|
||||
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
|
||||
@@ -1309,15 +1361,22 @@ platform :ios do
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
|
||||
end
|
||||
|
||||
release_sha = release_git_sha
|
||||
release_signing_check!
|
||||
preserve_local_signing do
|
||||
screenshots
|
||||
end
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
ensure_mobile_release_ref_available!(
|
||||
platform: "ios",
|
||||
version: context[:short_version],
|
||||
build: context[:build_number],
|
||||
sha: release_sha
|
||||
)
|
||||
ENV["DELIVER_SCREENSHOTS"] = "1"
|
||||
ENV["DELIVER_RELEASE_NOTES"] = "1"
|
||||
metadata
|
||||
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
@@ -1326,6 +1385,12 @@ platform :ios do
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
record_mobile_release_ref!(
|
||||
platform: "ios",
|
||||
version: build[:short_version],
|
||||
build: build[:build_number],
|
||||
sha: release_sha
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
UI.important("App Review submission remains manual in App Store Connect.")
|
||||
|
||||
@@ -7187,17 +7187,20 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let limit: Int?
|
||||
public let offset: Int?
|
||||
public let maxchars: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
limit: Int?,
|
||||
offset: Int? = nil,
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
self.maxchars = maxchars
|
||||
}
|
||||
|
||||
@@ -7205,6 +7208,7 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case limit
|
||||
case offset
|
||||
case maxchars = "maxChars"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
|
||||
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl
|
||||
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
|
||||
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -11,7 +11,7 @@ Generated locale trees and live translation memory now live in the publish repo:
|
||||
|
||||
- English docs are authored in `openclaw/openclaw`.
|
||||
- The source docs tree lives under `docs/`.
|
||||
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/zh-TW/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/vi/**`, `docs/nl/**`, `docs/fa/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, `docs/pl/**`, or `docs/th/**`.
|
||||
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/zh-TW/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/hi/**`, `docs/ar/**`, `docs/it/**`, `docs/vi/**`, `docs/nl/**`, `docs/fa/**`, `docs/ru/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, `docs/pl/**`, or `docs/th/**`.
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
@@ -32,10 +32,10 @@ Generated locale trees and live translation memory now live in the publish repo:
|
||||
|
||||
## Locale visibility
|
||||
|
||||
- Control UI supports `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `ar`, `it`, `tr`, `uk`, `id`, `pl`, `th`, `vi`, `nl`, and `fa`.
|
||||
- Control UI supports `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `hi`, `ar`, `it`, `vi`, `nl`, `fa`, `ru`, `tr`, `uk`, `id`, `pl`, and `th`.
|
||||
- Docs translation workflows generate the same non-English locale set in `openclaw/docs`.
|
||||
- The Mintlify docs language picker can expose only the locales accepted by Mintlify `navigation.languages`; today that includes Vietnamese (`vi`) and Dutch (`nl`), but not Thai (`th`) or Persian (`fa`).
|
||||
- Do not treat missing `th` or `fa` entries in generated `docs/docs.json` as a pipeline failure. Verify their generated folders in `openclaw/docs` instead.
|
||||
- The Mintlify docs language picker can expose only the locales accepted by Mintlify `navigation.languages`; Russian (`ru`) and Hindi (`hi`) are now included in the publish configuration.
|
||||
- Do not treat locale visibility in generated `docs/docs.json` as proof that translation artifacts exist. Verify each generated locale folder and its translation memory in `openclaw/docs`.
|
||||
|
||||
## Files in this folder
|
||||
|
||||
|
||||
82
docs/.i18n/glossary.hi.json
Normal file
82
docs/.i18n/glossary.hi.json
Normal file
@@ -0,0 +1,82 @@
|
||||
[
|
||||
{
|
||||
"source": "ACP",
|
||||
"target": "ACP"
|
||||
},
|
||||
{
|
||||
"source": "Active Memory",
|
||||
"target": "Active Memory"
|
||||
},
|
||||
{
|
||||
"source": "ClawHub",
|
||||
"target": "ClawHub"
|
||||
},
|
||||
{
|
||||
"source": "CLI",
|
||||
"target": "CLI"
|
||||
},
|
||||
{
|
||||
"source": "Compaction",
|
||||
"target": "Compaction"
|
||||
},
|
||||
{
|
||||
"source": "Cron",
|
||||
"target": "Cron"
|
||||
},
|
||||
{
|
||||
"source": "Dreaming",
|
||||
"target": "Dreaming"
|
||||
},
|
||||
{
|
||||
"source": "Gateway",
|
||||
"target": "Gateway"
|
||||
},
|
||||
{
|
||||
"source": "Heartbeat",
|
||||
"target": "Heartbeat"
|
||||
},
|
||||
{
|
||||
"source": "LINE",
|
||||
"target": "LINE"
|
||||
},
|
||||
{
|
||||
"source": "Mintlify",
|
||||
"target": "Mintlify"
|
||||
},
|
||||
{
|
||||
"source": "Node",
|
||||
"target": "Node"
|
||||
},
|
||||
{
|
||||
"source": "OpenClaw",
|
||||
"target": "OpenClaw"
|
||||
},
|
||||
{
|
||||
"source": "Pi",
|
||||
"target": "Pi"
|
||||
},
|
||||
{
|
||||
"source": "Plugin",
|
||||
"target": "Plugin"
|
||||
},
|
||||
{
|
||||
"source": "Skills",
|
||||
"target": "Skills"
|
||||
},
|
||||
{
|
||||
"source": "Tailscale",
|
||||
"target": "Tailscale"
|
||||
},
|
||||
{
|
||||
"source": "TaskFlow",
|
||||
"target": "TaskFlow"
|
||||
},
|
||||
{
|
||||
"source": "TUI",
|
||||
"target": "TUI"
|
||||
},
|
||||
{
|
||||
"source": "Webhook",
|
||||
"target": "Webhook"
|
||||
}
|
||||
]
|
||||
82
docs/.i18n/glossary.ru.json
Normal file
82
docs/.i18n/glossary.ru.json
Normal file
@@ -0,0 +1,82 @@
|
||||
[
|
||||
{
|
||||
"source": "ACP",
|
||||
"target": "ACP"
|
||||
},
|
||||
{
|
||||
"source": "Active Memory",
|
||||
"target": "Active Memory"
|
||||
},
|
||||
{
|
||||
"source": "ClawHub",
|
||||
"target": "ClawHub"
|
||||
},
|
||||
{
|
||||
"source": "CLI",
|
||||
"target": "CLI"
|
||||
},
|
||||
{
|
||||
"source": "Compaction",
|
||||
"target": "Compaction"
|
||||
},
|
||||
{
|
||||
"source": "Cron",
|
||||
"target": "Cron"
|
||||
},
|
||||
{
|
||||
"source": "Dreaming",
|
||||
"target": "Dreaming"
|
||||
},
|
||||
{
|
||||
"source": "Gateway",
|
||||
"target": "Gateway"
|
||||
},
|
||||
{
|
||||
"source": "Heartbeat",
|
||||
"target": "Heartbeat"
|
||||
},
|
||||
{
|
||||
"source": "LINE",
|
||||
"target": "LINE"
|
||||
},
|
||||
{
|
||||
"source": "Mintlify",
|
||||
"target": "Mintlify"
|
||||
},
|
||||
{
|
||||
"source": "Node",
|
||||
"target": "Node"
|
||||
},
|
||||
{
|
||||
"source": "OpenClaw",
|
||||
"target": "OpenClaw"
|
||||
},
|
||||
{
|
||||
"source": "Pi",
|
||||
"target": "Pi"
|
||||
},
|
||||
{
|
||||
"source": "Plugin",
|
||||
"target": "Plugin"
|
||||
},
|
||||
{
|
||||
"source": "Skills",
|
||||
"target": "Skills"
|
||||
},
|
||||
{
|
||||
"source": "Tailscale",
|
||||
"target": "Tailscale"
|
||||
},
|
||||
{
|
||||
"source": "TaskFlow",
|
||||
"target": "TaskFlow"
|
||||
},
|
||||
{
|
||||
"source": "TUI",
|
||||
"target": "TUI"
|
||||
},
|
||||
{
|
||||
"source": "Webhook",
|
||||
"target": "Webhook"
|
||||
}
|
||||
]
|
||||
27
docs/ci.md
27
docs/ci.md
@@ -90,9 +90,9 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
|
||||
|
||||
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Normal CI then packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites; heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. Pull requests on the canonical repository use an additional compact admission plan: the same per-config groups run in isolated subprocesses inside the current 34-job Linux Node plan, so a single PR does not register the full 70-plus-job Node matrix. `main` pushes, manual dispatches, and release gates retain the full matrix. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
|
||||
|
||||
Once admitted, canonical Linux CI permits up to 12 concurrent Node jobs and 8 for
|
||||
the smaller fast/check lanes; Windows and Android stay at two because those
|
||||
runner pools are narrower.
|
||||
Once admitted, canonical Linux CI permits up to 24 concurrent Node test jobs and
|
||||
12 for the smaller fast/check lanes; Windows and Android stay at two because
|
||||
those runner pools are narrower.
|
||||
|
||||
The compact PR plan emits 18 Node jobs for the current suite: whole-config
|
||||
groups are batched in isolated subprocesses with a 120-minute batch timeout,
|
||||
@@ -145,17 +145,17 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
|
||||
## Runner registration budget
|
||||
|
||||
GitHub caps self-hosted runner registrations at 1,500 runners per 5 minutes per
|
||||
repository, organization, or enterprise. The limit is shared by all Blacksmith
|
||||
runner registrations in the `openclaw` organization, so adding another
|
||||
Blacksmith installation does not add a new bucket.
|
||||
OpenClaw's current GitHub runner-registration bucket allows 3,000 self-hosted
|
||||
runner registrations per 5 minutes. The limit is shared by all Blacksmith runner
|
||||
registrations in the `openclaw` organization, so adding another Blacksmith
|
||||
installation does not add a new bucket.
|
||||
|
||||
Treat Blacksmith labels as the scarce resource for burst control. Jobs that
|
||||
only route, notify, summarize, select shards, or run short CodeQL scans should
|
||||
stay on GitHub-hosted runners unless they have measured Blacksmith-specific
|
||||
needs. Any new Blacksmith matrix, larger `max-parallel`, or high-frequency
|
||||
workflow must show its worst-case registration count and keep the org-level
|
||||
target below 1,000 registrations per 5 minutes, leaving headroom for concurrent
|
||||
target below 2,000 registrations per 5 minutes, leaving headroom for concurrent
|
||||
repositories and retried jobs.
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
|
||||
@@ -651,7 +651,16 @@ pnpm crabbox:run -- --provider blacksmith-testbox \
|
||||
"corepack pnpm test"
|
||||
```
|
||||
|
||||
Read the final JSON summary. The useful fields are `provider`, `leaseId`, `syncDelegated`, `exitCode`, `commandMs`, and `totalMs`. One-shot Blacksmith-backed Crabbox runs should stop the Testbox automatically; if a run is interrupted or cleanup is unclear, inspect live boxes and stop only the boxes you created:
|
||||
Read the final JSON summary. The useful fields are `provider`, `leaseId`,
|
||||
`syncDelegated`, `exitCode`, `commandMs`, and `totalMs`. For delegated
|
||||
Blacksmith Testbox runs, the Crabbox wrapper exit code and JSON summary are the
|
||||
command result. The linked GitHub Actions run owns hydration and keepalive; it
|
||||
can finish as `cancelled` when the Testbox is stopped externally after the SSH
|
||||
command has already returned. Treat that as a cleanup/status artifact unless
|
||||
the wrapper `exitCode` is non-zero or the command output shows a failed test.
|
||||
One-shot Blacksmith-backed Crabbox runs should stop the Testbox automatically;
|
||||
if a run is interrupted or cleanup is unclear, inspect live boxes and stop only
|
||||
the boxes you created:
|
||||
|
||||
```bash
|
||||
blacksmith testbox list --all
|
||||
|
||||
@@ -24,17 +24,31 @@ OpenClaw agent or Gateway.
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
|
||||
openclaw skills verify @owner/<slug>
|
||||
|
||||
openclaw plugins search "calendar"
|
||||
openclaw plugins install clawhub:<package>
|
||||
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
|
||||
openclaw plugins update <id-or-npm-spec>
|
||||
```
|
||||
|
||||
Skill installs target the active workspace `skills/` directory by default. Add
|
||||
`--global` to install into the shared managed skills directory.
|
||||
|
||||
OpenClaw checks the selected community ClawHub skill or plugin trust state
|
||||
before downloading it. Versioned community skill and plugin releases use
|
||||
exact-release trust metadata; resolver-backed GitHub skills rely on ClawHub's
|
||||
install resolver to enforce scan and force-install policy before it returns a
|
||||
pinned commit. Malicious or blocked community releases are refused. Risky
|
||||
community releases require review and `--acknowledge-clawhub-risk` when a
|
||||
non-interactive command should continue after that review.
|
||||
|
||||
Official ClawHub publishers/packages and bundled OpenClaw sources bypass this
|
||||
release-trust prompt and security-verdict fetch during install and update.
|
||||
|
||||
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
|
||||
instead of npm or another install source.
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ openclaw doctor
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --all
|
||||
openclaw doctor --lint --allow-exec
|
||||
openclaw doctor --deep
|
||||
openclaw doctor --fix
|
||||
@@ -73,6 +74,7 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
|
||||
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
|
||||
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
|
||||
- `--all`: with `--lint`, run all registered checks, including opt-in checks excluded from the default automation set
|
||||
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
|
||||
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
|
||||
|
||||
@@ -82,13 +84,14 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
|
||||
It uses the structured health-check path, does not prompt, and does not repair
|
||||
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
|
||||
when you want machine-readable findings instead of guided repair prompts.
|
||||
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
|
||||
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and `--skip`
|
||||
are only accepted with `--lint`.
|
||||
|
||||
```bash
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --all
|
||||
openclaw doctor --lint --allow-exec
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
```
|
||||
@@ -130,6 +133,13 @@ Exit behavior:
|
||||
example, `openclaw doctor --lint --severity-min error` can print no findings and
|
||||
exit `0` even when lower-severity `info` or `warning` findings exist.
|
||||
|
||||
`--all` controls which checks are selected before severity filtering. The
|
||||
default lint run is the stable automation gate and excludes checks that are
|
||||
intentionally opt-in because they are deep, historical, or more likely to
|
||||
surface repairable legacy residue. Use `--all` when you want the complete lint
|
||||
inventory without listing each check id. `--only <id>` remains the most precise
|
||||
selector and can run any registered check by id.
|
||||
|
||||
## Structured Health Checks
|
||||
|
||||
Modern doctor checks use a small structured contract:
|
||||
@@ -186,6 +196,7 @@ Use `--only` and `--skip` when a workflow wants a focused gate:
|
||||
```bash
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
openclaw doctor --lint --skip core/doctor/skills-readiness
|
||||
openclaw doctor --lint --all --skip core/doctor/session-locks
|
||||
```
|
||||
|
||||
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`
|
||||
|
||||
@@ -54,8 +54,9 @@ openclaw plugins update <id-or-npm-spec>
|
||||
openclaw plugins update --all
|
||||
openclaw plugins marketplace list <marketplace>
|
||||
openclaw plugins marketplace list <marketplace> --json
|
||||
openclaw plugins init <id>
|
||||
openclaw plugins init <id> --directory ./my-plugin --name "My Plugin"
|
||||
openclaw plugins init my-tool --name "My Tool"
|
||||
openclaw plugins init my-provider --name "My Provider" --type provider
|
||||
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
|
||||
openclaw plugins build --entry ./dist/index.js
|
||||
openclaw plugins build --entry ./dist/index.js --check
|
||||
openclaw plugins validate --entry ./dist/index.js
|
||||
@@ -86,12 +87,15 @@ npm run plugin:build
|
||||
npm run plugin:validate
|
||||
```
|
||||
|
||||
`plugins init` creates a minimal TypeScript tool plugin that uses
|
||||
`defineToolPlugin`. `plugins build` imports that entry, reads its static tool
|
||||
metadata, writes `openclaw.plugin.json`, and keeps `package.json`
|
||||
`openclaw.extensions` aligned. `plugins validate` checks that the generated
|
||||
manifest, package metadata, and current entry export still agree. See
|
||||
[Tool Plugins](/plugins/tool-plugins) for the full authoring workflow.
|
||||
`plugins init` creates a minimal TypeScript tool plugin by default. The first
|
||||
argument is the plugin id; pass `--name` for the display name. OpenClaw uses the
|
||||
id for the default output directory and package naming. Tool scaffolds use
|
||||
`defineToolPlugin`.
|
||||
`plugins build` imports the built entry, reads its static tool metadata, writes
|
||||
`openclaw.plugin.json`, and keeps `package.json` `openclaw.extensions` aligned.
|
||||
`plugins validate` checks that the generated manifest, package metadata, and
|
||||
current entry export still agree. See [Tool Plugins](/plugins/tool-plugins) for
|
||||
the full tool-authoring workflow.
|
||||
|
||||
The scaffold writes TypeScript source but generates metadata from the built
|
||||
`./dist/index.js` entry so the workflow also works with the published CLI. Use
|
||||
@@ -99,6 +103,29 @@ The scaffold writes TypeScript source but generates metadata from the built
|
||||
`plugins build --check` in CI to fail when generated metadata is stale without
|
||||
rewriting files.
|
||||
|
||||
### Provider Scaffold
|
||||
|
||||
```bash
|
||||
openclaw plugins init acme-models --name "Acme Models" --type provider
|
||||
cd acme-models
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
npm run validate
|
||||
```
|
||||
|
||||
Provider scaffolds create a generic text/model provider plugin with OpenAI-compatible
|
||||
API-key plumbing, a built-in `npm run validate` script for `clawhub package
|
||||
validate`, ClawHub package metadata, and a manually dispatched GitHub workflow
|
||||
for future trusted publishing through GitHub Actions OIDC. Provider scaffolds do
|
||||
not generate skills and do not use `openclaw plugins build` or
|
||||
`openclaw plugins validate`; those commands are for the tool scaffold's
|
||||
generated metadata path.
|
||||
|
||||
Before publishing, replace the placeholder API base URL, model catalog, docs
|
||||
route, credential text, and README copy with real provider details. Use the
|
||||
generated README for first-time ClawHub publishing and trusted publisher setup.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
@@ -111,6 +138,7 @@ openclaw plugins install git:github.com/<owner>/<repo> # git repo
|
||||
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
|
||||
openclaw plugins install <package> --force # overwrite existing install
|
||||
openclaw plugins install <package> --pin # pin version
|
||||
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
|
||||
openclaw plugins install <package> --dangerously-force-unsafe-install
|
||||
openclaw plugins install <path> # local path
|
||||
openclaw plugins install <plugin>@<marketplace> # marketplace
|
||||
@@ -163,6 +191,12 @@ is available, then fall back to `latest`.
|
||||
|
||||
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="--acknowledge-clawhub-risk">
|
||||
Community ClawHub installs check the selected release trust record before downloading the package. If ClawHub disables download for the release, reports malicious scan findings, or puts the release in a blocking moderation state such as quarantine, OpenClaw refuses the release. For non-blocking risky scan statuses, risky moderation states, or registry reasons, OpenClaw shows the trust details and asks for confirmation before continuing.
|
||||
|
||||
Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Hook packs and npm specs">
|
||||
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
|
||||
@@ -390,6 +424,7 @@ openclaw plugins update <id-or-npm-spec>
|
||||
openclaw plugins update --all
|
||||
openclaw plugins update <id-or-npm-spec> --dry-run
|
||||
openclaw plugins update @openclaw/voice-call
|
||||
openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk
|
||||
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
|
||||
```
|
||||
|
||||
@@ -421,6 +456,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
|
||||
<Accordion title="--dangerously-force-unsafe-install on update">
|
||||
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded.
|
||||
</Accordion>
|
||||
<Accordion title="--acknowledge-clawhub-risk on update">
|
||||
Community ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Inspect
|
||||
|
||||
@@ -31,9 +31,11 @@ openclaw skills install git:owner/repo
|
||||
openclaw skills install git:owner/repo@main
|
||||
openclaw skills install ./path/to/skill --as custom-name
|
||||
openclaw skills install @owner/<slug> --force
|
||||
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
|
||||
openclaw skills install @owner/<slug> --agent <id>
|
||||
openclaw skills install @owner/<slug> --global
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
|
||||
openclaw skills update @owner/<slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --agent <id>
|
||||
@@ -97,6 +99,14 @@ Notes:
|
||||
- `install --version <version>` applies only to ClawHub skill refs.
|
||||
- `install --force` overwrites an existing workspace skill folder for the same
|
||||
slug.
|
||||
- Community ClawHub skill installs and updates check trust before downloading.
|
||||
Versioned community archive releases use exact-release trust metadata.
|
||||
Resolver-backed GitHub skills rely on ClawHub's install resolver to enforce
|
||||
scan and force-install policy before it returns a pinned commit. Malicious or
|
||||
blocked community releases are refused. Risky community releases require
|
||||
review and `--acknowledge-clawhub-risk` when a non-interactive command should
|
||||
continue after that review. Official ClawHub skill publishers and bundled
|
||||
OpenClaw skill sources bypass this release-trust prompt.
|
||||
- `--global` targets the shared managed skills directory and cannot be combined
|
||||
with `--agent <id>`.
|
||||
- `--agent <id>` targets one configured agent workspace and overrides current
|
||||
|
||||
@@ -28,6 +28,7 @@ openclaw update --tag main
|
||||
openclaw update --dry-run
|
||||
openclaw update --no-restart
|
||||
openclaw update --yes
|
||||
openclaw update --acknowledge-clawhub-risk
|
||||
openclaw update --json
|
||||
openclaw --update
|
||||
```
|
||||
@@ -45,6 +46,11 @@ openclaw --update
|
||||
when npm plugin artifact drift is detected during post-update plugin sync.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1800s).
|
||||
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
|
||||
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
|
||||
warnings, allow post-update plugin sync to continue without an interactive
|
||||
prompt. Without this, risky community ClawHub plugin releases are skipped and
|
||||
left unchanged when OpenClaw cannot prompt. Official ClawHub packages and
|
||||
bundled OpenClaw plugin sources bypass this release-trust prompt.
|
||||
|
||||
`openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview
|
||||
the planned channel/tag/install/restart actions, `--json` for machine-readable
|
||||
@@ -88,6 +94,7 @@ converge.
|
||||
```bash
|
||||
openclaw update repair
|
||||
openclaw update repair --channel beta
|
||||
openclaw update repair --acknowledge-clawhub-risk
|
||||
openclaw update repair --json
|
||||
```
|
||||
|
||||
@@ -98,6 +105,10 @@ Options:
|
||||
- `--json`: print machine-readable finalization JSON.
|
||||
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
|
||||
- `--yes`: skip confirmation prompts.
|
||||
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
|
||||
warnings, allow repair-time plugin convergence to continue without an
|
||||
interactive prompt. Official ClawHub packages and bundled OpenClaw plugin
|
||||
sources bypass this release-trust prompt.
|
||||
- `--no-restart`: accepted for update command parity; repair never restarts the
|
||||
Gateway.
|
||||
|
||||
|
||||
@@ -297,7 +297,8 @@ tool-call XML payloads (including `<tool_call>...</tool_call>`,
|
||||
downgraded tool-call scaffolding / leaked ASCII/full-width model control
|
||||
tokens / malformed MiniMax tool-call XML from assistant recall, and can
|
||||
replace oversized rows with `[sessions_history omitted: message too large]`
|
||||
instead of returning a raw transcript dump.
|
||||
instead of returning a raw transcript dump. Use `nextOffset` when present to
|
||||
page backward through older transcript windows.
|
||||
|
||||
## Scaling pattern
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ results may be scope-limited.
|
||||
|
||||
`sessions_history` fetches the conversation transcript for a specific session.
|
||||
By default, tool results are excluded -- pass `includeTools: true` to see them.
|
||||
Use `limit` for the newest bounded tail. Pass `offset: 0` when you need
|
||||
pagination metadata, then pass returned `nextOffset` values to page backward
|
||||
through older OpenClaw transcript windows without reading raw transcript files.
|
||||
Explicit offset pages do not merge external CLI fallback imports; use the
|
||||
default newest-tail view when you need that merged display history.
|
||||
The returned view is intentionally bounded and safety-filtered:
|
||||
|
||||
- assistant text is normalized before recall:
|
||||
@@ -78,7 +83,7 @@ The returned view is intentionally bounded and safety-filtered:
|
||||
- very large histories can drop older rows or replace an oversized row with
|
||||
`[sessions_history omitted: message too large]`
|
||||
- the tool reports summary flags such as `truncated`, `droppedMessages`,
|
||||
`contentTruncated`, `contentRedacted`, and `bytes`
|
||||
`contentTruncated`, `contentRedacted`, `bytes`, and pagination metadata
|
||||
|
||||
Both tools accept either a **session key** (like `"main"`) or a **session ID**
|
||||
from a previous list call.
|
||||
|
||||
@@ -316,6 +316,11 @@ conversation bindings, or any non-Codex harness.
|
||||
plugin/app support for the Codex harness. Default: `false`.
|
||||
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
|
||||
default destructive-action policy for migrated plugin app elicitations.
|
||||
Use `true` to accept safe Codex approval schemas without prompting, `false`
|
||||
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
|
||||
plugin approvals, or `"always"` to ask for every plugin write/destructive
|
||||
action without durable approval. The `"always"` mode clears durable Codex
|
||||
per-tool approval overrides for the affected app before starting the thread.
|
||||
Default: `true`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
|
||||
migrated plugin entry when global `codexPlugins.enabled` is also true.
|
||||
@@ -326,7 +331,8 @@ conversation bindings, or any non-Codex harness.
|
||||
Codex plugin identity from migration, for example `"google-calendar"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
|
||||
per-plugin destructive-action override. When omitted, the global
|
||||
`allow_destructive_actions` value is used.
|
||||
`allow_destructive_actions` value is used. The per-plugin value accepts the
|
||||
same `true`, `false`, `"auto"`, or `"always"` policies.
|
||||
|
||||
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
|
||||
entries written by migration are the durable install and repair eligibility set.
|
||||
|
||||
@@ -104,6 +104,7 @@ Examples:
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --all
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
```
|
||||
|
||||
@@ -111,7 +112,7 @@ JSON output includes:
|
||||
|
||||
- `ok`: whether any visible finding met the selected severity threshold
|
||||
- `checksRun`: number of health checks executed
|
||||
- `checksSkipped`: checks skipped by `--only` or `--skip`
|
||||
- `checksSkipped`: checks skipped by the selected profile, `--only`, or `--skip`
|
||||
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
|
||||
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
|
||||
|
||||
@@ -122,11 +123,13 @@ Exit codes:
|
||||
- `2`: command/runtime failure before lint findings could be emitted
|
||||
|
||||
Use `--severity-min info|warning|error` to control both what is printed and what
|
||||
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
|
||||
causes a non-zero lint exit. Use `--all` to run the complete lint inventory,
|
||||
including deeper opt-in checks excluded from the default automation set. Use `--only <id>` for narrow preflight gates and
|
||||
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
|
||||
lint run active.
|
||||
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
|
||||
must be paired with `--lint`; regular doctor and repair runs reject them.
|
||||
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and
|
||||
`--skip` must be paired with `--lint`; regular doctor and repair runs reject
|
||||
them.
|
||||
|
||||
## What it does (summary)
|
||||
|
||||
|
||||
@@ -57,6 +57,34 @@ Logging:
|
||||
The macOS app checks the gateway version against its own version. If they're
|
||||
incompatible, update the global CLI to match the app version.
|
||||
|
||||
## State directory on macOS
|
||||
|
||||
Keep OpenClaw state on a local, non-synced disk. Avoid iCloud Drive and other
|
||||
cloud-synced folders because sync latency and file locks can affect sessions,
|
||||
credentials, and Gateway state.
|
||||
|
||||
Set `OPENCLAW_STATE_DIR` to a local path only when you need an override.
|
||||
`openclaw doctor` warns about common cloud-synced state paths and recommends
|
||||
moving back to local storage. See
|
||||
[environment variables](/help/environment#path-related-env-vars) and
|
||||
[Doctor](/gateway/doctor).
|
||||
|
||||
## Debug app connectivity
|
||||
|
||||
Use the macOS debug CLI from a source checkout to exercise the same Gateway
|
||||
WebSocket handshake and discovery logic the app uses:
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run openclaw-mac connect --json
|
||||
swift run openclaw-mac discover --timeout 3000 --json
|
||||
```
|
||||
|
||||
`connect` accepts `--url`, `--token`, `--timeout`, and `--json`. `discover`
|
||||
accepts `--timeout`, `--json`, and `--include-local`. Compare discovery output
|
||||
with `openclaw gateway discover --json` when you need to separate CLI discovery
|
||||
from app-side connection issues.
|
||||
|
||||
## Smoke check
|
||||
|
||||
```bash
|
||||
|
||||
@@ -114,7 +114,18 @@ Example (in JS):
|
||||
window.location.href = "openclaw://agent?message=Review%20this%20design";
|
||||
```
|
||||
|
||||
The app prompts for confirmation unless a valid key is provided.
|
||||
Supported query parameters:
|
||||
|
||||
- `message`: prefilled agent prompt.
|
||||
- `sessionKey`: stable session identifier.
|
||||
- `thinking`: optional thinking profile.
|
||||
- `deliver`, `to`, or `channel`: delivery target.
|
||||
- `timeoutSeconds`: optional run timeout.
|
||||
- `key`: app-generated safety token for trusted local callers.
|
||||
|
||||
The app prompts for confirmation unless a valid key is provided. Unkeyed links
|
||||
show the message and URL before approval, and ignore delivery routing fields;
|
||||
keyed links use the normal Gateway run path.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as
|
||||
`gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local
|
||||
tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and
|
||||
the local node-host service all use the same safe loopback transport.
|
||||
When discovery returns both raw Tailnet IPs and stable hostnames, the app
|
||||
prefers Tailscale MagicDNS or LAN names so remote connections survive address
|
||||
changes better.
|
||||
If the local tunnel port differs from the remote gateway port, set
|
||||
`gateway.remote.remotePort` to the port on the remote host.
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ title: "macOS IPC"
|
||||
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
- Common Mac node commands include `canvas.*`, `camera.snap`, `camera.clip`,
|
||||
`screen.snapshot`, `screen.record`, `system.run`, and `system.notify`.
|
||||
- The node reports a `permissions` map so agents can see whether screen,
|
||||
camera, microphone, speech, automation, or accessibility access is available.
|
||||
|
||||
### Node service + app IPC
|
||||
|
||||
|
||||
@@ -1,228 +1,87 @@
|
||||
---
|
||||
summary: "OpenClaw macOS companion app (menu bar + gateway broker)"
|
||||
summary: "Install and use the OpenClaw macOS menu bar app"
|
||||
read_when:
|
||||
- Implementing macOS app features
|
||||
- Changing gateway lifecycle or node bridging on macOS
|
||||
- Installing the macOS app
|
||||
- Deciding between local and remote Gateway mode on macOS
|
||||
- Looking for macOS app release downloads
|
||||
title: "macOS app"
|
||||
---
|
||||
|
||||
The macOS app is the **menu-bar companion** for OpenClaw. It owns permissions,
|
||||
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
||||
capabilities to the agent as a node.
|
||||
The macOS app is the OpenClaw **menu bar companion**. Use it when you want a
|
||||
native tray UI, macOS permission prompts, notifications, WebChat, voice input,
|
||||
Canvas, or Mac-hosted node tools such as `system.run`.
|
||||
|
||||
## What it does
|
||||
If you only need the CLI and Gateway, start with [Getting started](/start/getting-started).
|
||||
|
||||
- Shows native notifications and status in the menu bar.
|
||||
- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Microphone,
|
||||
Speech Recognition, Automation/AppleScript).
|
||||
- Runs or connects to the Gateway (local or remote).
|
||||
- Exposes macOS-only tools (Canvas, Camera, Screen Recording, `system.run`).
|
||||
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
|
||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||
- Installs the global CLI (`openclaw`) on request via npm, pnpm, or bun (the app prefers npm, then pnpm, then bun; Node remains the recommended Gateway runtime).
|
||||
## Download
|
||||
|
||||
## Local vs remote mode
|
||||
Download macOS app builds from the
|
||||
[OpenClaw GitHub releases](https://github.com/openclaw/openclaw/releases).
|
||||
When a release includes macOS app assets, look for:
|
||||
|
||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||
otherwise it enables the launchd service via `openclaw gateway install`.
|
||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||
a local process.
|
||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
||||
The app does not spawn the Gateway as a child process.
|
||||
Gateway discovery now prefers Tailscale MagicDNS names over raw tailnet IPs,
|
||||
so the Mac app recovers more reliably when tailnet IPs change.
|
||||
- `OpenClaw-<version>.dmg` (preferred)
|
||||
- `OpenClaw-<version>.zip`
|
||||
|
||||
## Launchd control
|
||||
Some releases only include CLI, evidence, or Windows assets. If the newest
|
||||
release has no macOS app asset, use the newest release that does, or build the
|
||||
app from source with [macOS dev setup](/platforms/mac/dev-setup).
|
||||
|
||||
The app manages a per-user LaunchAgent labeled `ai.openclaw.gateway`
|
||||
(or `ai.openclaw.<profile>` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads).
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$UID/ai.openclaw.gateway
|
||||
launchctl bootout gui/$UID/ai.openclaw.gateway
|
||||
```
|
||||
|
||||
Replace the label with `ai.openclaw.<profile>` when running a named profile.
|
||||
|
||||
If the LaunchAgent isn't installed, enable it from the app or run
|
||||
`openclaw gateway install`.
|
||||
|
||||
If the gateway repeatedly disappears for minutes to hours and only resumes when you touch the Control UI or SSH into the host, see the troubleshooting note for macOS Maintenance Sleep / `ENETDOWN` crashes and launchd's respawn-protection gate in [Gateway troubleshooting](/gateway/troubleshooting#macos-gateway-silently-stops-responding-then-resumes-when-you-touch-the-dashboard).
|
||||
|
||||
## Node capabilities (mac)
|
||||
|
||||
The macOS app presents itself as a node. Common commands:
|
||||
|
||||
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
|
||||
- Camera: `camera.snap`, `camera.clip`
|
||||
- Screen: `screen.snapshot`, `screen.record`
|
||||
- System: `system.run`, `system.notify`
|
||||
|
||||
The node reports a `permissions` map so agents can decide what's allowed.
|
||||
|
||||
Node service + app IPC:
|
||||
|
||||
- When the headless node host service is running (remote mode), it connects to the Gateway WS as a node.
|
||||
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
||||
|
||||
Diagram (SCI):
|
||||
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
|
||||
## Exec approvals (system.run)
|
||||
|
||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||
Security + ask + allowlist are stored locally on the Mac in:
|
||||
|
||||
```
|
||||
~/.openclaw/exec-approvals.json
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "on-miss"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"allowlist": [{ "pattern": "/opt/homebrew/bin/rg" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
|
||||
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
|
||||
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
|
||||
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
|
||||
## Deep links
|
||||
|
||||
The app registers the `openclaw://` URL scheme for local actions.
|
||||
|
||||
### `openclaw://agent`
|
||||
|
||||
Triggers a Gateway `agent` request.
|
||||
|
||||
```bash
|
||||
open 'openclaw://agent?message=Hello%20from%20deep%20link'
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `message` (required)
|
||||
- `sessionKey` (optional)
|
||||
- `thinking` (optional)
|
||||
- `deliver` / `to` / `channel` (optional)
|
||||
- `timeoutSeconds` (optional)
|
||||
- `key` (optional unattended mode key)
|
||||
|
||||
Safety:
|
||||
|
||||
- Without `key`, the app prompts for confirmation.
|
||||
- Without `key`, the app enforces a short message limit for the confirmation prompt and ignores `deliver` / `to` / `channel`.
|
||||
- With a valid `key`, the run is unattended (intended for personal automations).
|
||||
|
||||
## Onboarding flow (typical)
|
||||
## First run
|
||||
|
||||
1. Install and launch **OpenClaw.app**.
|
||||
2. Complete the permissions checklist (TCC prompts).
|
||||
3. Ensure **Local** mode is active and the Gateway is running.
|
||||
4. Install the CLI if you want terminal access.
|
||||
2. Complete the macOS permission checklist.
|
||||
3. Pick **Local** or **Remote** mode.
|
||||
4. Install the `openclaw` CLI if the app asks for it.
|
||||
5. Open WebChat from the menu bar and send a test message.
|
||||
|
||||
## State dir placement (macOS)
|
||||
For the CLI/Gateway setup path, use [Getting started](/start/getting-started).
|
||||
For permission recovery, use [macOS permissions](/platforms/mac/permissions).
|
||||
|
||||
Avoid putting your OpenClaw state dir in iCloud or other cloud-synced folders.
|
||||
Sync-backed paths can add latency and occasionally cause file-lock/sync races for
|
||||
sessions and credentials.
|
||||
## Choose a Gateway mode
|
||||
|
||||
Prefer a local non-synced state path such as:
|
||||
| Mode | Use it when | Detail page |
|
||||
| ------ | --------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Local | This Mac should run the Gateway and keep it alive with launchd. | [Gateway on macOS](/platforms/mac/bundled-gateway) |
|
||||
| Remote | Another host runs the Gateway and this Mac should control it over SSH, LAN, or Tailnet. | [Remote control](/platforms/mac/remote) |
|
||||
|
||||
```bash
|
||||
OPENCLAW_STATE_DIR=~/.openclaw
|
||||
```
|
||||
Local mode requires an installed `openclaw` CLI. The app can install it, or you
|
||||
can follow [Gateway on macOS](/platforms/mac/bundled-gateway).
|
||||
|
||||
If `openclaw doctor` detects state under:
|
||||
## What the app owns
|
||||
|
||||
- `~/Library/Mobile Documents/com~apple~CloudDocs/...`
|
||||
- `~/Library/CloudStorage/...`
|
||||
- Menu bar status, notifications, health, and WebChat.
|
||||
- macOS permission prompts for screen, microphone, speech, automation, and accessibility.
|
||||
- Local node tools such as Canvas, camera/screen capture, notifications, and `system.run`.
|
||||
- Exec approval prompts for Mac-hosted commands.
|
||||
- Remote-mode SSH tunnels or direct Gateway connections.
|
||||
|
||||
it will warn and recommend moving back to a local path.
|
||||
The app does **not** replace the OpenClaw Gateway or general CLI docs. Core
|
||||
Gateway configuration, providers, plugins, channels, tools, and security live in
|
||||
their own docs.
|
||||
|
||||
## Build and dev workflow (native)
|
||||
## macOS detail pages
|
||||
|
||||
- `cd apps/macos && swift build`
|
||||
- `swift run OpenClaw` (or Xcode)
|
||||
- Package app: `scripts/package-mac-app.sh`
|
||||
| Task | Read |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Install or debug the CLI/Gateway service | [Gateway on macOS](/platforms/mac/bundled-gateway) |
|
||||
| Keep state out of cloud-synced folders | [Gateway on macOS](/platforms/mac/bundled-gateway#state-directory-on-macos) |
|
||||
| Debug app discovery and connectivity | [Gateway on macOS](/platforms/mac/bundled-gateway#debug-app-connectivity) |
|
||||
| Understand launchd behavior | [Gateway lifecycle](/platforms/mac/child-process) |
|
||||
| Fix permissions or signing/TCC issues | [macOS permissions](/platforms/mac/permissions) |
|
||||
| Connect to a remote Gateway | [Remote control](/platforms/mac/remote) |
|
||||
| Read menu bar status and health checks | [Menu bar](/platforms/mac/menu-bar), [Health checks](/platforms/mac/health) |
|
||||
| Use the embedded chat UI | [WebChat](/platforms/mac/webchat) |
|
||||
| Use voice wake or push-to-talk | [Voice wake](/platforms/mac/voicewake) |
|
||||
| Use Canvas and Canvas deep links | [Canvas](/platforms/mac/canvas) |
|
||||
| Host PeekabooBridge for UI automation | [Peekaboo bridge](/platforms/mac/peekaboo) |
|
||||
| Configure command approvals | [Exec approvals](/tools/exec-approvals), [advanced details](/tools/exec-approvals-advanced) |
|
||||
| Inspect Mac node commands and app IPC | [macOS IPC](/platforms/mac/xpc) |
|
||||
| Capture logs | [macOS logging](/platforms/mac/logging) |
|
||||
| Build from source | [macOS dev setup](/platforms/mac/dev-setup) |
|
||||
|
||||
## Debug gateway connectivity (macOS CLI)
|
||||
## Related
|
||||
|
||||
Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery
|
||||
logic that the macOS app uses, without launching the app.
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run openclaw-mac connect --json
|
||||
swift run openclaw-mac discover --timeout 3000 --json
|
||||
```
|
||||
|
||||
Connect options:
|
||||
|
||||
- `--url <ws://host:port>`: override config
|
||||
- `--mode <local|remote>`: resolve from config (default: config or local)
|
||||
- `--probe`: force a fresh health probe
|
||||
- `--timeout <ms>`: request timeout (default: `15000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
Discovery options:
|
||||
|
||||
- `--include-local`: include gateways that would be filtered as "local"
|
||||
- `--timeout <ms>`: overall discovery window (default: `2000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
<Tip>
|
||||
Compare against `openclaw gateway discover --json` to see whether the macOS app's discovery pipeline (`local.` plus the configured wide-area domain, with wide-area and Tailscale Serve fallbacks) differs from the Node CLI's `dns-sd` based discovery.
|
||||
</Tip>
|
||||
|
||||
## Remote connection plumbing (SSH tunnels)
|
||||
|
||||
When the macOS app runs in **Remote** mode, it opens an SSH tunnel so local UI
|
||||
components can talk to a remote Gateway as if it were on localhost.
|
||||
|
||||
### Control tunnel (Gateway WebSocket port)
|
||||
|
||||
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
|
||||
- **Local port:** the Gateway port (default `18789`), always stable.
|
||||
- **Remote port:** the same Gateway port on the remote host.
|
||||
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
|
||||
or restarts it if needed.
|
||||
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
||||
ExitOnForwardFailure + keepalive options.
|
||||
- **IP reporting:** the SSH tunnel uses loopback, so the gateway will see the node
|
||||
IP as `127.0.0.1`. Use **Direct (ws/wss)** transport if you want the real client
|
||||
IP to appear (see [macOS remote access](/platforms/mac/remote)).
|
||||
|
||||
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
||||
details, see [Gateway protocol](/gateway/protocol).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Gateway runbook](/gateway)
|
||||
- [Gateway (macOS)](/platforms/mac/bundled-gateway)
|
||||
- [macOS permissions](/platforms/mac/permissions)
|
||||
- [Canvas](/platforms/mac/canvas)
|
||||
- [Platforms](/platforms)
|
||||
- [Getting started](/start/getting-started)
|
||||
- [Gateway](/gateway)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
@@ -155,9 +155,13 @@ shorthand before OpenClaw builds app-server start options, and unresolved
|
||||
structured SecretRefs fail before any token or header is sent. When native Codex
|
||||
plugins are configured, OpenClaw uses the connected app-server's plugin control
|
||||
plane to install or refresh those plugins and then refreshes app inventory so
|
||||
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
|
||||
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
|
||||
and app inventory refreshes.
|
||||
plugin-owned apps are visible to the Codex thread. `app/list` is still the
|
||||
authoritative inventory and metadata source, but OpenClaw policy decides whether
|
||||
`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible
|
||||
app even if Codex currently marks it disabled. Unknown or missing app ids remain
|
||||
fail-closed; this path only activates marketplace plugins via `plugin/install`
|
||||
and refreshes inventory. Only connect OpenClaw to remote app-servers that are
|
||||
trusted to accept OpenClaw-managed plugin installs and app inventory refreshes.
|
||||
|
||||
## Approval and sandbox modes
|
||||
|
||||
|
||||
@@ -465,7 +465,13 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the
|
||||
remote app-server's own account.
|
||||
When native Codex plugins are configured, OpenClaw installs or refreshes those
|
||||
plugins through the connected app-server before exposing plugin-owned apps to
|
||||
the Codex thread.
|
||||
the Codex thread. `app/list` remains the source of truth for app ids,
|
||||
accessibility, and metadata, but OpenClaw owns the per-thread enablement
|
||||
decision: if policy allows a listed accessible app, OpenClaw sends
|
||||
`thread/start.config.apps[appId].enabled = true` even when `app/list` currently
|
||||
reports that app disabled. This path does not invent app installation for
|
||||
unknown ids; OpenClaw only activates marketplace plugins with `plugin/install`
|
||||
and then refreshes inventory.
|
||||
|
||||
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
|
||||
time when Codex reports one and tries the next ordered auth profile for the same
|
||||
|
||||
@@ -200,11 +200,11 @@ enabled.
|
||||
|
||||
OpenClaw sets app-level `destructive_enabled` from the effective global or
|
||||
per-plugin `allow_destructive_actions` policy and lets Codex enforce
|
||||
destructive tool metadata from its native app tool annotations. `true` and
|
||||
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
|
||||
`_default` app config is disabled with `open_world_enabled: false`. Enabled
|
||||
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
|
||||
expose a separate plugin open-world policy knob and does not maintain
|
||||
destructive tool metadata from its native app tool annotations. `true`,
|
||||
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
|
||||
false. The `_default` app config is disabled with `open_world_enabled: false`.
|
||||
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
|
||||
not expose a separate plugin open-world policy knob and does not maintain
|
||||
per-plugin destructive tool-name deny lists.
|
||||
|
||||
Tool approval mode is automatic by default for plugin apps so non-destructive
|
||||
@@ -225,6 +225,10 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
|
||||
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
|
||||
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
|
||||
plugin approvals before returning the Codex approval response.
|
||||
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
|
||||
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
|
||||
app before the thread starts, and only offers one-shot approval or denial so
|
||||
durable approvals cannot suppress later write-action prompts.
|
||||
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
|
||||
id, or an unsafe elicitation schema declines instead of prompting.
|
||||
|
||||
@@ -272,8 +276,9 @@ Codex thread bindings keep the app config they started with until OpenClaw
|
||||
establishes a new harness session or replaces a stale binding.
|
||||
|
||||
**Destructive action is declined:** check the global and per-plugin
|
||||
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
|
||||
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
|
||||
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
|
||||
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
|
||||
closed.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -211,6 +211,18 @@ each carrier call should start with fresh context, for example reception,
|
||||
booking, IVR, or Google Meet bridge flows where the same phone number may
|
||||
represent different meetings.
|
||||
|
||||
Voice Call stores generated session keys under the configured agent namespace
|
||||
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
|
||||
canonicalization after restarts. Raw explicit integration keys use the same
|
||||
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
|
||||
and its main aliases honor core `session.mainKey` and global scope. Foreign or
|
||||
malformed `agent:*` input is scoped as an opaque key under the configured agent;
|
||||
`global` and `unknown` remain global sentinels. Gateway startup promotes older
|
||||
raw keys in default or `{agentId}`-templated stores where the path proves one
|
||||
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
|
||||
they do not contain enough information to choose an owner; new calls use
|
||||
canonical agent-scoped history.
|
||||
|
||||
## Realtime voice conversations
|
||||
|
||||
`realtime` selects a full-duplex realtime voice provider for live call
|
||||
|
||||
@@ -29,10 +29,11 @@ Use the path that matches your OpenClaw install state:
|
||||
openclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
On a VPS or over SSH, use device-code during onboarding:
|
||||
On a VPS or over SSH, select xAI OAuth directly; OpenClaw uses device-code
|
||||
verification and does not require a localhost callback:
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon --auth-choice xai-device-code
|
||||
openclaw onboard --install-daemon --auth-choice xai-oauth
|
||||
```
|
||||
|
||||
OAuth does not require an xAI API key. OpenClaw does not require the Grok
|
||||
@@ -48,13 +49,6 @@ Use the path that matches your OpenClaw install state:
|
||||
openclaw models auth login --provider xai --method oauth
|
||||
```
|
||||
|
||||
Use the device-code flow instead when the Gateway runs over SSH, Docker, or
|
||||
a VPS and a localhost browser callback is awkward:
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider xai --device-code
|
||||
```
|
||||
|
||||
To make Grok the default model after signing in, apply it separately:
|
||||
|
||||
```bash
|
||||
@@ -86,8 +80,7 @@ Use the path that matches your OpenClaw install state:
|
||||
|
||||
<Note>
|
||||
OpenClaw uses the xAI Responses API as the bundled xAI transport. The same
|
||||
credential from `openclaw models auth login --provider xai --method oauth`,
|
||||
`openclaw models auth login --provider xai --device-code`, or
|
||||
credential from `openclaw models auth login --provider xai --method oauth` or
|
||||
`openclaw models auth login --provider xai --method api-key` can also power first-class
|
||||
`web_search`, `x_search`, remote `code_execution`, and xAI image/video generation.
|
||||
Speech and transcription currently require `XAI_API_KEY` or provider config.
|
||||
@@ -102,8 +95,9 @@ and, by default, `x_search` through an operator xAI Responses proxy.
|
||||
|
||||
## OAuth troubleshooting
|
||||
|
||||
- If browser OAuth cannot reach `127.0.0.1:56121`, use
|
||||
`openclaw models auth login --provider xai --device-code`.
|
||||
- For SSH, Docker, VPS, or other remote setups, use
|
||||
`openclaw models auth login --provider xai --method oauth`; xAI OAuth uses
|
||||
device-code verification instead of a localhost callback.
|
||||
- If sign-in succeeds but Grok is not the default model, run
|
||||
`openclaw models set xai/grok-4.3`.
|
||||
- To inspect saved xAI auth profiles, run:
|
||||
@@ -117,9 +111,9 @@ and, by default, `x_search` through an operator xAI Responses proxy.
|
||||
eligible, try the API-key path or check the subscription on xAI's side.
|
||||
|
||||
<Tip>
|
||||
Use `xai-device-code` when signing in from SSH, Docker, or a VPS. OpenClaw
|
||||
prints an xAI URL and short code; finish sign-in in any local browser while the
|
||||
remote process polls xAI for the completed token exchange.
|
||||
Use `xai-oauth` when signing in from SSH, Docker, or a VPS. OpenClaw prints an
|
||||
xAI URL and short code; finish sign-in in any local browser while the remote
|
||||
process polls xAI for the completed token exchange.
|
||||
</Tip>
|
||||
|
||||
## Built-in catalog
|
||||
@@ -498,12 +492,10 @@ Legacy aliases still normalize to the canonical bundled ids:
|
||||
|
||||
<Accordion title="Known limits">
|
||||
- xAI auth can use an API key, environment variable, plugin config fallback,
|
||||
browser OAuth, or device-code OAuth with an eligible xAI account. Browser
|
||||
OAuth uses a local callback on `127.0.0.1:56121`; for remote hosts, use
|
||||
`xai-device-code` unless you want to forward that port before opening the
|
||||
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
|
||||
the consent page may show Grok Build even though OpenClaw does not require
|
||||
the Grok Build app.
|
||||
or OAuth with an eligible xAI account. OAuth uses device-code verification
|
||||
without a localhost callback. xAI decides which accounts can receive OAuth
|
||||
API tokens, and the consent page may show Grok Build even though OpenClaw
|
||||
does not require the Grok Build app.
|
||||
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
|
||||
serves these models through the Responses API, but they do not accept the
|
||||
client-side or custom tools used by OpenClaw's shared agent loop. See the
|
||||
|
||||
@@ -20,6 +20,7 @@ title: "Tests"
|
||||
- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`.
|
||||
- `pnpm check:changed`: delegates to Crabbox/Testbox by default outside CI, then runs the smart changed check gate for the diff against `origin/main` inside the remote child. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
|
||||
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
|
||||
- Testbox-through-Crabbox proof: use the wrapper's final `exitCode` and timing JSON as the command result. The delegated Blacksmith GitHub Actions run may show `cancelled` after a successful SSH command because the Testbox is stopped from outside the keepalive action; verify the wrapper summary and command output before treating that as a test failure.
|
||||
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs are full-suite proof: they use fixed shard groups, expand to leaf configs for local parallel execution, and print the expected local shard fanout before starting. The extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
|
||||
|
||||
@@ -38,13 +38,13 @@ Do **not** use it when you need local files, your shell, your repo, or paired de
|
||||
<Steps>
|
||||
<Step title="Provide xAI credentials">
|
||||
Sign in with Grok OAuth using an eligible SuperGrok or X Premium subscription,
|
||||
use the remote-friendly device-code flow, or store an API key. OAuth works
|
||||
for `code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search
|
||||
config can also power Grok `web_search`.
|
||||
or store an API key. xAI OAuth uses device-code verification, so it works
|
||||
from remote hosts without a localhost callback. OAuth works for
|
||||
`code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search config
|
||||
can also power Grok `web_search`.
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider xai --method oauth
|
||||
openclaw models auth login --provider xai --device-code
|
||||
```
|
||||
|
||||
During a fresh install, the same auth choices are available inside
|
||||
@@ -52,7 +52,7 @@ Do **not** use it when you need local files, your shell, your repo, or paired de
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
openclaw onboard --install-daemon --auth-choice xai-device-code
|
||||
openclaw onboard --install-daemon --auth-choice xai-oauth
|
||||
```
|
||||
|
||||
Or use an API key:
|
||||
|
||||
@@ -523,6 +523,7 @@ should be rewritten in normal assistant voice.
|
||||
- Credential/token-like text is redacted.
|
||||
- Long blocks can be truncated.
|
||||
- Very large histories can drop older rows or replace an oversized row with `[sessions_history omitted: message too large]`.
|
||||
- Use `nextOffset` when present to page backward through older transcript windows.
|
||||
- Raw on-disk transcript inspection is the fallback when you need the full byte-for-byte transcript.
|
||||
|
||||
## Tool policy
|
||||
|
||||
@@ -192,6 +192,109 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const readScopedMcpEnv = (sessionKey: string) => {
|
||||
const delegate = (
|
||||
runtime as unknown as {
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
|
||||
}
|
||||
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
|
||||
options: {
|
||||
mcpServers?: Array<{
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
|
||||
};
|
||||
|
||||
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
|
||||
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
|
||||
value: "agent:worker:main",
|
||||
});
|
||||
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
|
||||
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
|
||||
value: "agent:research:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const exposedRuntime = runtime as unknown as {
|
||||
openclawToolsSessionDelegates: Map<string, unknown>;
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
|
||||
};
|
||||
|
||||
const firstDelegate =
|
||||
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
|
||||
|
||||
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
|
||||
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
|
||||
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
|
||||
firstDelegate,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
|
||||
const safeProbe = vi
|
||||
.spyOn(bridgeSafeDelegate, "probeAvailability")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
|
||||
expect(safeProbe).toHaveBeenCalledTimes(1);
|
||||
expect(defaultProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
@@ -1163,6 +1266,46 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
expect(baseStore["load"]).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases managed OpenClaw tools MCP delegates after close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const exposedRuntime = runtime as unknown as {
|
||||
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
|
||||
close: AcpRuntime["close"];
|
||||
};
|
||||
};
|
||||
const scopedDelegate =
|
||||
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
|
||||
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:main",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:main",
|
||||
},
|
||||
reason: "closed",
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({
|
||||
|
||||
@@ -50,6 +50,7 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
|
||||
openclawWrapperRoot?: string;
|
||||
openclawGatewayInstanceId?: string;
|
||||
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
|
||||
openclawToolsMcpBridgeEnabled?: boolean;
|
||||
};
|
||||
type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
openclawProcessCleanup?: AcpxProcessCleanupDeps;
|
||||
@@ -57,6 +58,10 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
|
||||
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
|
||||
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
|
||||
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
|
||||
|
||||
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
|
||||
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
|
||||
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
@@ -682,6 +687,33 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
|
||||
return Array.isArray(mcpServers) && mcpServers.length > 0;
|
||||
}
|
||||
|
||||
function withOpenClawToolsMcpSessionEnv(params: {
|
||||
enabled: boolean | undefined;
|
||||
mcpServers: AcpRuntimeOptions["mcpServers"];
|
||||
sessionKey: string;
|
||||
}): AcpRuntimeOptions["mcpServers"] {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
|
||||
return params.mcpServers;
|
||||
}
|
||||
let changed = false;
|
||||
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
|
||||
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
|
||||
return server;
|
||||
}
|
||||
changed = true;
|
||||
const env = [
|
||||
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
|
||||
{
|
||||
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
|
||||
value: sessionKey,
|
||||
},
|
||||
];
|
||||
return { ...server, env };
|
||||
});
|
||||
return changed ? nextServers : params.mcpServers;
|
||||
}
|
||||
|
||||
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
|
||||
export class AcpxRuntime implements AcpRuntime {
|
||||
private readonly sessionStore: ResetAwareSessionStore;
|
||||
@@ -693,6 +725,10 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
private readonly delegate: BaseAcpxRuntime;
|
||||
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
|
||||
private readonly probeDelegate: BaseAcpxRuntime;
|
||||
private readonly delegateOptions: AcpRuntimeOptions;
|
||||
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
|
||||
private readonly openclawToolsMcpBridgeEnabled: boolean;
|
||||
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
|
||||
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
|
||||
private readonly wrapperRoot: string | undefined;
|
||||
private readonly gatewayInstanceId: string | undefined;
|
||||
@@ -706,6 +742,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.wrapperRoot = options.openclawWrapperRoot;
|
||||
this.gatewayInstanceId = options.openclawGatewayInstanceId;
|
||||
this.processLeaseStore = options.openclawProcessLeaseStore;
|
||||
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
|
||||
this.cwd = options.cwd;
|
||||
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
|
||||
gatewayInstanceId: this.gatewayInstanceId,
|
||||
@@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
sessionStore: this.sessionStore,
|
||||
agentRegistry: this.scopedAgentRegistry,
|
||||
};
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
sharedOptions,
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
);
|
||||
this.delegateOptions = sharedOptions;
|
||||
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
|
||||
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
|
||||
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
|
||||
? new BaseAcpxRuntime(
|
||||
{
|
||||
...sharedOptions,
|
||||
mcpServers: [],
|
||||
},
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
this.delegateTestOptions,
|
||||
)
|
||||
: this.delegate;
|
||||
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
|
||||
? this.bridgeSafeDelegate
|
||||
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
}
|
||||
|
||||
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
|
||||
@@ -751,6 +789,57 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
|
||||
}
|
||||
|
||||
private resolveDelegateForSession(params: {
|
||||
command: string | undefined;
|
||||
sessionKey: string;
|
||||
}): BaseAcpxRuntime {
|
||||
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
|
||||
return this.bridgeSafeDelegate;
|
||||
}
|
||||
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
|
||||
}
|
||||
|
||||
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
|
||||
if (!this.openclawToolsMcpBridgeEnabled) {
|
||||
return this.delegate;
|
||||
}
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return this.delegate;
|
||||
}
|
||||
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Upstream acpx captures mcpServers at runtime construction. The managed
|
||||
// OpenClaw tools bridge needs per-session identity, so cache one delegate
|
||||
// per session with the scoped MCP env already embedded.
|
||||
const delegate = new BaseAcpxRuntime(
|
||||
{
|
||||
...this.delegateOptions,
|
||||
mcpServers: withOpenClawToolsMcpSessionEnv({
|
||||
enabled: this.openclawToolsMcpBridgeEnabled,
|
||||
mcpServers: this.delegateOptions.mcpServers,
|
||||
sessionKey: normalizedSessionKey,
|
||||
}),
|
||||
},
|
||||
this.delegateTestOptions,
|
||||
);
|
||||
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
|
||||
return delegate;
|
||||
}
|
||||
|
||||
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
|
||||
if (!this.openclawToolsMcpBridgeEnabled) {
|
||||
return;
|
||||
}
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return;
|
||||
}
|
||||
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
|
||||
}
|
||||
|
||||
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
|
||||
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
|
||||
return this.resolveDelegateForLoadedRecord(handle, record);
|
||||
@@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
): BaseAcpxRuntime {
|
||||
const recordCommand = readAgentCommandFromRecord(record);
|
||||
if (recordCommand) {
|
||||
return this.resolveDelegateForCommand(recordCommand);
|
||||
return this.resolveDelegateForSession({
|
||||
command: recordCommand,
|
||||
sessionKey: handle.sessionKey,
|
||||
});
|
||||
}
|
||||
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
|
||||
const agentName = readAgentFromHandle(handle);
|
||||
const command = resolveAgentCommandForName({
|
||||
agentName,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
|
||||
}
|
||||
|
||||
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
|
||||
@@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
agentName: input.agent,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
const delegate = this.resolveDelegateForCommand(command);
|
||||
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
|
||||
const claudeModelOverride = isClaudeAcpCommand(command)
|
||||
? normalizeClaudeAcpModelOverride(input.model)
|
||||
: undefined;
|
||||
@@ -1264,6 +1361,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
}
|
||||
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
// Fresh reset has no ACP handle to close the delegate's upstream client.
|
||||
// Keep the scoped delegate reachable so the next ensure can replace it;
|
||||
// close() owns cache release when the session lifecycle ends.
|
||||
this.sessionStore.markFresh(input.sessionKey);
|
||||
}
|
||||
|
||||
@@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
);
|
||||
let closeSucceeded;
|
||||
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
|
||||
try {
|
||||
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
|
||||
await delegate.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
@@ -1282,6 +1383,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
} finally {
|
||||
await this.cleanupProcessTreeForRecord(input.handle, record);
|
||||
}
|
||||
if (closeSucceeded) {
|
||||
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
|
||||
}
|
||||
if (closeSucceeded && input.discardPersistentState) {
|
||||
this.sessionStore.markFresh(input.handle.sessionKey);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
|
||||
}),
|
||||
probeAgent: params.pluginConfig.probeAgent,
|
||||
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
|
||||
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
|
||||
permissionMode: params.pluginConfig.permissionMode,
|
||||
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
|
||||
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),
|
||||
|
||||
@@ -1,6 +1,81 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createClickClackClient } from "./http-client.js";
|
||||
|
||||
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
|
||||
|
||||
async function listenLoopbackServer(server: Server): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("expected loopback TCP address"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
|
||||
let resolveClosed: (sentBytes: number) => void = () => {};
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
let sentBytes = 0;
|
||||
let stopped = false;
|
||||
let prefixSent = false;
|
||||
const prefixChunk = Buffer.from('{"user":{"id":"');
|
||||
const bodyChunk = Buffer.alloc(64 * 1024, 0x61);
|
||||
const suffixChunk = Buffer.from('"}}');
|
||||
const writeBuffer = (buffer: Buffer) => {
|
||||
sentBytes += buffer.length;
|
||||
if (!res.write(buffer)) {
|
||||
res.once("drain", writeChunks);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const writeChunks = () => {
|
||||
if (!prefixSent) {
|
||||
prefixSent = true;
|
||||
if (!writeBuffer(prefixChunk)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) {
|
||||
break;
|
||||
}
|
||||
if (!writeBuffer(bodyChunk)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!stopped) {
|
||||
sentBytes += suffixChunk.length;
|
||||
res.end(suffixChunk);
|
||||
}
|
||||
};
|
||||
res.writeHead(200, { connection: "close", "content-type": "application/json" });
|
||||
res.on("close", () => {
|
||||
stopped = true;
|
||||
resolveClosed(sentBytes);
|
||||
});
|
||||
req.on("aborted", () => {
|
||||
stopped = true;
|
||||
res.destroy();
|
||||
});
|
||||
writeChunks();
|
||||
});
|
||||
return { server, closed };
|
||||
}
|
||||
|
||||
function streamedErrorResponse(body: string, limit: number) {
|
||||
const encoded = new TextEncoder().encode(body);
|
||||
let readCount = 0;
|
||||
@@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) {
|
||||
}
|
||||
|
||||
describe("ClickClack HTTP client", () => {
|
||||
it("bounds oversized success JSON responses and closes the stream early", async () => {
|
||||
const { server, closed } = createOversizedJsonServer();
|
||||
const port = await listenLoopbackServer(server);
|
||||
const client = createClickClackClient({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
token: "test-token",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(client.me()).rejects.toThrow(
|
||||
"ClickClack response: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
const sentBytes = await closed;
|
||||
expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds error response bodies without using raw response.text()", async () => {
|
||||
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
|
||||
const fetchMock = vi.fn(async () => streamed.response);
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
|
||||
* delivery code.
|
||||
*/
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { WebSocket } from "ws";
|
||||
import type {
|
||||
ClickClackChannel,
|
||||
@@ -44,7 +47,7 @@ export function createClickClackClient(options: ClientOptions) {
|
||||
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
|
||||
throw new Error(`ClickClack ${response.status}: ${detail}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
return await readProviderJsonResponse<T>(response, "ClickClack response");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,6 +36,14 @@ describe("codex doctor contract", () => {
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyConfigRules[1]?.match({
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": { allow_destructive_actions: "always" },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the retired dynamic tools profile without dropping other Codex config", () => {
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"default": false
|
||||
},
|
||||
"allow_destructive_actions": {
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
|
||||
"default": true
|
||||
},
|
||||
"plugins": {
|
||||
@@ -121,7 +121,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"allow_destructive_actions": {
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,7 @@
|
||||
},
|
||||
"codexPlugins.allow_destructive_actions": {
|
||||
"label": "Allow Destructive Plugin Actions",
|
||||
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
|
||||
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
|
||||
"advanced": true
|
||||
},
|
||||
"codexPlugins.plugins": {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
readCodexNotificationItem,
|
||||
readNotificationItemId,
|
||||
shouldDisarmAssistantCompletionIdleWatch,
|
||||
updateActiveCompletionBlockerItemIds,
|
||||
updateActiveTurnItemIds,
|
||||
} from "./attempt-notifications.js";
|
||||
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
|
||||
@@ -92,6 +93,7 @@ export function applyCodexTurnNotificationState(params: {
|
||||
currentPromptTexts: string[];
|
||||
turnWatches: CodexAttemptTurnWatchController;
|
||||
activeTurnItemIds: Set<string>;
|
||||
activeCompletionBlockerItemIds: Set<string>;
|
||||
activeAppServerTurnRequests: number;
|
||||
pendingOpenClawDynamicToolCompletionIds: Set<string>;
|
||||
turnCrossedToolHandoff: boolean;
|
||||
@@ -121,6 +123,7 @@ export function applyCodexTurnNotificationState(params: {
|
||||
});
|
||||
params.onReportExecutionNotification(notification);
|
||||
updateActiveTurnItemIds(notification, params.activeTurnItemIds);
|
||||
updateActiveCompletionBlockerItemIds(notification, params.activeCompletionBlockerItemIds);
|
||||
if (notification.method === "item/completed" && params.activeTurnItemIds.size === 0) {
|
||||
params.onScheduleTerminalDynamicToolReleaseCheck();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,45 @@ export function updateActiveTurnItemIds(
|
||||
activeItemIds.delete(itemId);
|
||||
}
|
||||
|
||||
export function updateActiveCompletionBlockerItemIds(
|
||||
notification: CodexServerNotification,
|
||||
activeItemIds: Set<string>,
|
||||
): void {
|
||||
if (notification.method !== "item/started" && notification.method !== "item/completed") {
|
||||
return;
|
||||
}
|
||||
const itemId = readNotificationItemId(notification);
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/completed") {
|
||||
activeItemIds.delete(itemId);
|
||||
return;
|
||||
}
|
||||
const item = readCodexNotificationItem(notification.params);
|
||||
if (item && isCompletionBlockingItem(item)) {
|
||||
activeItemIds.add(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
function isCompletionBlockingItem(item: CodexThreadItem): boolean {
|
||||
// Codex emits paired item/started and item/completed notifications for these
|
||||
// execution items. Completion must not time out while any pair is still open.
|
||||
switch (item.type) {
|
||||
case "collabAgentToolCall":
|
||||
case "commandExecution":
|
||||
case "dynamicToolCall":
|
||||
case "fileChange":
|
||||
case "imageGeneration":
|
||||
case "imageView":
|
||||
case "mcpToolCall":
|
||||
case "webSearch":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCompletedAssistantNotification(notification: CodexServerNotification): boolean {
|
||||
if (!isJsonObject(notification.params)) {
|
||||
return false;
|
||||
|
||||
@@ -346,6 +346,7 @@ export async function startCodexAttemptThread(params: {
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal,
|
||||
}),
|
||||
configCwd: startupExecutionCwd,
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey: pluginAppCacheKey,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Codex tests cover attempt turn watches plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { updateActiveCompletionBlockerItemIds } from "./attempt-notifications.js";
|
||||
import { createCodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
|
||||
|
||||
describe("Codex app-server attempt turn watches", () => {
|
||||
@@ -23,6 +24,7 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
let terminalQueued = false;
|
||||
let activeRequests = 0;
|
||||
let activeItems = 0;
|
||||
let activeCompletionBlockers = 0;
|
||||
const interrupts: Array<Record<string, unknown>> = [];
|
||||
const timeouts: Array<Record<string, unknown>> = [];
|
||||
const events: Array<{ name: string; fields: Record<string, unknown> }> = [];
|
||||
@@ -36,6 +38,7 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
isTerminalTurnNotificationQueued: () => terminalQueued,
|
||||
getActiveAppServerTurnRequests: () => activeRequests,
|
||||
getActiveTurnItemCount: () => activeItems,
|
||||
getActiveCompletionBlockerItemCount: () => activeCompletionBlockers,
|
||||
turnCompletionIdleTimeoutMs: 10,
|
||||
turnAssistantCompletionIdleTimeoutMs: 10,
|
||||
turnAttemptIdleTimeoutMs: 10,
|
||||
@@ -69,6 +72,9 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
set activeItems(value: number) {
|
||||
activeItems = value;
|
||||
},
|
||||
set activeCompletionBlockers(value: number) {
|
||||
activeCompletionBlockers = value;
|
||||
},
|
||||
interrupts,
|
||||
timeouts,
|
||||
events,
|
||||
@@ -155,6 +161,32 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
expect(harness.abortController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for active completion blocker items before firing completion idle timeout", () => {
|
||||
const harness = createController();
|
||||
harness.activeCompletionBlockers = 1;
|
||||
|
||||
harness.controller.touchActivity("request:mcpServer/elicitation/request:response", {
|
||||
arm: true,
|
||||
});
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(harness.timeouts).toEqual([]);
|
||||
expect(harness.abortController.signal.aborted).toBe(false);
|
||||
|
||||
harness.activeCompletionBlockers = 0;
|
||||
harness.controller.touchActivity("notification:item/completed");
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(harness.timeouts).toMatchObject([
|
||||
{
|
||||
kind: "completion",
|
||||
idleMs: 10,
|
||||
timeoutMs: 10,
|
||||
lastActivityReason: "notification:item/completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases a completed assistant item after the assistant idle guard expires", () => {
|
||||
const harness = createController();
|
||||
|
||||
@@ -214,3 +246,41 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
expect(harness.abortController.signal.reason).toBe("turn_progress_idle_timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex completion blocker item tracking", () => {
|
||||
it.each([
|
||||
"collabAgentToolCall",
|
||||
"commandExecution",
|
||||
"dynamicToolCall",
|
||||
"fileChange",
|
||||
"imageGeneration",
|
||||
"imageView",
|
||||
"mcpToolCall",
|
||||
"webSearch",
|
||||
])("tracks the %s lifecycle", (type) => {
|
||||
const activeItemIds = new Set<string>();
|
||||
updateActiveCompletionBlockerItemIds(
|
||||
{ method: "item/started", params: { item: { id: "item-1", type } } },
|
||||
activeItemIds,
|
||||
);
|
||||
expect(activeItemIds).toEqual(new Set(["item-1"]));
|
||||
|
||||
updateActiveCompletionBlockerItemIds(
|
||||
{ method: "item/completed", params: { item: { id: "item-1", type } } },
|
||||
activeItemIds,
|
||||
);
|
||||
expect(activeItemIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it.each(["agentMessage", "contextCompaction", "plan", "reasoning", "subAgentActivity"])(
|
||||
"does not track the %s lifecycle",
|
||||
(type) => {
|
||||
const activeItemIds = new Set<string>();
|
||||
updateActiveCompletionBlockerItemIds(
|
||||
{ method: "item/started", params: { item: { id: "item-1", type } } },
|
||||
activeItemIds,
|
||||
);
|
||||
expect(activeItemIds).toEqual(new Set());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
isTerminalTurnNotificationQueued: () => boolean;
|
||||
getActiveAppServerTurnRequests: () => number;
|
||||
getActiveTurnItemCount: () => number;
|
||||
getActiveCompletionBlockerItemCount: () => number;
|
||||
turnCompletionIdleTimeoutMs: number;
|
||||
turnAssistantCompletionIdleTimeoutMs: number;
|
||||
turnAttemptIdleTimeoutMs: number;
|
||||
@@ -121,7 +122,8 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.isCompleted() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
params.getActiveAppServerTurnRequests() > 0 ||
|
||||
params.getActiveCompletionBlockerItemCount() > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -183,7 +185,8 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.isTerminalTurnNotificationQueued() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
params.getActiveAppServerTurnRequests() > 0 ||
|
||||
params.getActiveCompletionBlockerItemCount() > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -302,7 +305,8 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.isTerminalTurnNotificationQueued() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
params.getActiveAppServerTurnRequests() > 0 ||
|
||||
params.getActiveCompletionBlockerItemCount() > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1192,6 +1192,52 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
});
|
||||
|
||||
it("parses always native Codex plugin destructive policy", () => {
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
slack: {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
allow_destructive_actions: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
|
||||
expect(resolveCodexPluginsPolicy(config)).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
pluginPolicies: [
|
||||
{
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
{
|
||||
configKey: "slack",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported native Codex plugin destructive policy strings", () => {
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
|
||||
@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
|
||||
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
|
||||
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
|
||||
export type CodexDynamicToolsLoading = "searchable" | "direct";
|
||||
export type CodexPluginDestructivePolicy = boolean | "auto";
|
||||
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
|
||||
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
|
||||
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
|
||||
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
|
||||
|
||||
@@ -311,7 +311,11 @@ const codexAppServerApprovalPolicySchema = z.enum([
|
||||
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
|
||||
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
|
||||
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
|
||||
const codexPluginDestructivePolicySchema = z.union([
|
||||
z.boolean(),
|
||||
z.literal("auto"),
|
||||
z.literal("always"),
|
||||
]);
|
||||
const codexAppServerServiceTierSchema = z
|
||||
.preprocess(
|
||||
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
|
||||
@@ -495,8 +499,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
|
||||
allowDestructiveActions: boolean;
|
||||
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
|
||||
} {
|
||||
if (policy === "auto") {
|
||||
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
|
||||
if (policy === "auto" || policy === "always") {
|
||||
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
|
||||
}
|
||||
return {
|
||||
allowDestructiveActions: policy,
|
||||
|
||||
@@ -157,7 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
|
||||
function createPluginAppPolicyContext(
|
||||
params: {
|
||||
allowDestructiveActions?: boolean;
|
||||
destructiveApprovalMode?: "allow" | "deny" | "auto";
|
||||
destructiveApprovalMode?: "allow" | "deny" | "auto" | "always";
|
||||
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
|
||||
} = {},
|
||||
) {
|
||||
@@ -1017,6 +1017,96 @@ describe("Codex app-server elicitation bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose allow-always for always plugin policy", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always-policy", status: "accepted" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-always-policy",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerElicitationRequest({
|
||||
requestParams: buildConnectorPluginApprovalElicitation({
|
||||
_meta: {
|
||||
codex_approval_kind: "mcp_tool_call",
|
||||
source: "connector",
|
||||
connector_id: "connector_google_calendar",
|
||||
connector_name: "Google Calendar",
|
||||
persist: ["session", "always"],
|
||||
tool_title: "create_event",
|
||||
},
|
||||
}),
|
||||
paramsForRun: createParams(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
pluginAppPolicyContext: createPluginAppPolicyContext({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
apps: [
|
||||
{
|
||||
appId: "connector_google_calendar",
|
||||
pluginName: "google-calendar",
|
||||
mcpServerNames: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
expect(gatewayToolArg(0, 2)).toMatchObject({
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unexpected allow-always decisions to one-shot for always plugin policy", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-unexpected-always",
|
||||
status: "accepted",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-unexpected-always",
|
||||
decision: "allow-always",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerElicitationRequest({
|
||||
requestParams: buildConnectorPluginApprovalElicitation({
|
||||
_meta: {
|
||||
codex_approval_kind: "mcp_tool_call",
|
||||
source: "connector",
|
||||
connector_id: "connector_google_calendar",
|
||||
connector_name: "Google Calendar",
|
||||
persist: ["session", "always"],
|
||||
tool_title: "create_event",
|
||||
},
|
||||
}),
|
||||
paramsForRun: createParams(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
pluginAppPolicyContext: createPluginAppPolicyContext({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
apps: [
|
||||
{
|
||||
appId: "connector_google_calendar",
|
||||
pluginName: "google-calendar",
|
||||
mcpServerNames: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("declines denied auto plugin app approvals", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })
|
||||
|
||||
@@ -318,10 +318,13 @@ async function buildPluginPolicyElicitationResponse(params: {
|
||||
paramsForRun: params.paramsForRun,
|
||||
title: approvalPrompt.title,
|
||||
description: approvalPrompt.description,
|
||||
allowedDecisions: approvalPrompt.allowedDecisions,
|
||||
allowedDecisions: allowedPluginPolicyApprovalDecisions(mode, approvalPrompt),
|
||||
signal: params.signal,
|
||||
});
|
||||
return buildElicitationResponse(approvalPrompt, outcome);
|
||||
return buildElicitationResponse(
|
||||
approvalPrompt,
|
||||
oneShotPluginPolicyApprovalOutcome(mode, outcome),
|
||||
);
|
||||
}
|
||||
logPluginElicitationDecline("unmappable_schema", params.requestParams);
|
||||
return declineElicitationResponse();
|
||||
@@ -329,10 +332,28 @@ async function buildPluginPolicyElicitationResponse(params: {
|
||||
|
||||
function resolvePluginDestructiveApprovalMode(
|
||||
entry: PluginAppPolicyContextEntry,
|
||||
): "allow" | "deny" | "auto" {
|
||||
): "allow" | "deny" | "auto" | "always" {
|
||||
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
|
||||
}
|
||||
|
||||
function allowedPluginPolicyApprovalDecisions(
|
||||
mode: "allow" | "deny" | "auto" | "always",
|
||||
approvalPrompt: BridgeableApprovalElicitation,
|
||||
): ExecApprovalDecision[] {
|
||||
const allowedDecisions = approvalPrompt.allowedDecisions ?? ["allow-once", "deny"];
|
||||
if (mode !== "always") {
|
||||
return allowedDecisions;
|
||||
}
|
||||
return allowedDecisions.filter((decision) => decision !== "allow-always");
|
||||
}
|
||||
|
||||
function oneShotPluginPolicyApprovalOutcome(
|
||||
mode: "allow" | "deny" | "auto" | "always",
|
||||
outcome: AppServerApprovalOutcome,
|
||||
): AppServerApprovalOutcome {
|
||||
return mode === "always" && outcome === "approved-session" ? "approved-once" : outcome;
|
||||
}
|
||||
|
||||
function readPluginApprovalElicitation(
|
||||
entry: PluginAppPolicyContextEntry,
|
||||
requestParams: JsonObject,
|
||||
|
||||
@@ -170,6 +170,379 @@ describe("Codex plugin thread config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes destructive app access while clearing only durable approval overrides for always mode", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
let configReadCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
configReadCount += 1;
|
||||
if (configReadCount > 1) {
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/read": {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
enabled: false,
|
||||
},
|
||||
"calendar/read": {
|
||||
enabled: false,
|
||||
},
|
||||
"calendar/update": {
|
||||
approvalMode: "approve",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
|
||||
expect(apps?.["google-calendar-app"]).toEqual({
|
||||
enabled: true,
|
||||
destructive_enabled: true,
|
||||
open_world_enabled: true,
|
||||
default_tools_approval_mode: "auto",
|
||||
});
|
||||
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("config/read", { includeLayers: false });
|
||||
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools."calendar/create".approval_mode',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools."calendar/update".approval_mode',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits always policy apps when cwd effective approval overrides remain after cleanup", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
let configReadCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
configReadCount += 1;
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
source: configReadCount === 1 ? "user" : "project",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
configCwd: "/repo/project",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(request).toHaveBeenCalledWith("config/read", {
|
||||
includeLayers: false,
|
||||
cwd: "/repo/project",
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: effective approval overrides remain for calendar/create",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits always policy apps when approval override writes are overridden", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return { status: "okOverridden" };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
configCwd: "/repo/project",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: approval override for calendar/create is controlled by another config layer",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits always policy apps when durable approval override cleanup fails", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request: async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
throw new Error("readonly config");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: readonly config",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a restrictive app config when native plugin support is disabled", async () => {
|
||||
expect(
|
||||
shouldBuildCodexPluginThreadConfig({
|
||||
@@ -254,7 +627,7 @@ describe("Codex plugin thread config", () => {
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "app/list") {
|
||||
appListParams.push(params as v2.AppsListParams);
|
||||
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
|
||||
return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null };
|
||||
}
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
@@ -317,6 +690,117 @@ describe("Codex plugin thread config", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true, false)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request: async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.inventory?.records[0]?.apps).toStrictEqual([
|
||||
{
|
||||
id: "google-calendar-app",
|
||||
name: "google-calendar-app",
|
||||
accessible: true,
|
||||
enabled: false,
|
||||
needsAuth: false,
|
||||
},
|
||||
]);
|
||||
expect(config.configPatch?.apps).toMatchObject({
|
||||
"google-calendar-app": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(config.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
const appListParams: v2.AppsListParams[] = [];
|
||||
let pluginListCalls = 0;
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "plugin/list") {
|
||||
pluginListCalls += 1;
|
||||
const active = pluginListCalls > 1;
|
||||
return pluginList([
|
||||
pluginSummary("google-calendar", { installed: active, enabled: active }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
|
||||
}
|
||||
if (method === "app/list") {
|
||||
appListParams.push(params as v2.AppsListParams);
|
||||
return {
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
} satisfies v2.AppsListResponse;
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch?.apps).toMatchObject({
|
||||
"google-calendar-app": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install");
|
||||
expect(appListParams).toEqual([
|
||||
{
|
||||
cursor: undefined,
|
||||
limit: 100,
|
||||
forceRefetch: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
@@ -375,11 +859,59 @@ describe("Codex plugin thread config", () => {
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "allow",
|
||||
},
|
||||
message: "google-calendar-app is not accessible or enabled for google-calendar.",
|
||||
message: "google-calendar-app is not accessible for google-calendar.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
enabled: false,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request: async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
@@ -572,9 +1104,7 @@ describe("Codex plugin thread config", () => {
|
||||
let installed = false;
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([
|
||||
pluginSummary("google-calendar", { installed, enabled: installed }),
|
||||
]);
|
||||
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
|
||||
@@ -738,6 +1268,70 @@ describe("Codex plugin thread config", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed when app inventory entries are malformed", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () =>
|
||||
({
|
||||
data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[],
|
||||
nextCursor: null,
|
||||
}) satisfies v2.AppsListResponse,
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request: async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "app_not_ready",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "allow",
|
||||
},
|
||||
message: "google-calendar-app is not accessible for google-calendar.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
const first = buildCodexPluginThreadConfigInputFingerprint({
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type CodexPluginOwnedApp,
|
||||
type CodexPluginRuntimeRequest,
|
||||
} from "./plugin-inventory.js";
|
||||
import type { JsonObject, JsonValue } from "./protocol.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
|
||||
/** Policy context for one app id exposed by a configured Codex plugin. */
|
||||
export type PluginAppPolicyContextEntry = {
|
||||
@@ -52,7 +52,7 @@ export type PluginAppPolicyContext = {
|
||||
export type CodexPluginThreadConfigDiagnostic =
|
||||
| CodexPluginInventoryDiagnostic
|
||||
| {
|
||||
code: "plugin_activation_failed" | "app_not_ready";
|
||||
code: "plugin_activation_failed" | "app_not_ready" | "approval_overrides_clear_failed";
|
||||
plugin?: ResolvedCodexPluginPolicy;
|
||||
message: string;
|
||||
};
|
||||
@@ -72,6 +72,7 @@ export type CodexPluginThreadConfig = {
|
||||
export type BuildCodexPluginThreadConfigParams = {
|
||||
pluginConfig?: unknown;
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
appCache?: CodexAppInventoryCache;
|
||||
appCacheKey: string;
|
||||
nowMs?: number;
|
||||
@@ -125,6 +126,9 @@ export async function buildCodexPluginThreadConfig(
|
||||
nowMs: params.nowMs,
|
||||
suppressAppInventoryRefresh: true,
|
||||
});
|
||||
const appInventoryRefreshDeferredForActivation =
|
||||
inventory.records.some((record) => record.activationRequired) &&
|
||||
shouldRefreshMissingAppInventory(params, policy, inventory);
|
||||
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
|
||||
await refreshAppInventoryNow(params, appCache, {
|
||||
forceRefetch: true,
|
||||
@@ -166,10 +170,19 @@ export async function buildCodexPluginThreadConfig(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (activationResults.some((activation) => activation.ok && activation.installAttempted)) {
|
||||
const postInstallRefreshRequired = activationResults.some(
|
||||
(activation) => activation.ok && activation.installAttempted,
|
||||
);
|
||||
// Activation can become unnecessary or fail before it refreshes apps. Rebuild the
|
||||
// deferred missing snapshot so unrelated active plugin apps are not silently erased.
|
||||
const deferredMissingRefreshRequired =
|
||||
appInventoryRefreshDeferredForActivation &&
|
||||
!postInstallRefreshRequired &&
|
||||
shouldRefreshMissingAppInventory(params, policy, inventory);
|
||||
if (postInstallRefreshRequired || deferredMissingRefreshRequired) {
|
||||
await refreshAppInventoryNow(params, appCache, {
|
||||
forceRefetch: true,
|
||||
reason: "post_install",
|
||||
reason: postInstallRefreshRequired ? "post_install" : "deferred_missing",
|
||||
targetAppIds: collectInventoryOwnedAppIds(inventory),
|
||||
});
|
||||
inventory = await readCodexPluginInventory({
|
||||
@@ -219,27 +232,37 @@ export async function buildCodexPluginThreadConfig(
|
||||
const policyApps: Record<string, PluginAppPolicyContextEntry> = {};
|
||||
const pluginAppIds: Record<string, string[]> = {};
|
||||
for (const record of inventory.records) {
|
||||
if (record.activationRequired) {
|
||||
const activation = activationResults.find(
|
||||
(item) => item.identity.configKey === record.policy.configKey,
|
||||
);
|
||||
if (!activation?.ok) {
|
||||
continue;
|
||||
}
|
||||
const activation = activationResults.find(
|
||||
(item) => item.identity.configKey === record.policy.configKey,
|
||||
);
|
||||
if (activation?.ok === false || (record.activationRequired && !activation?.ok)) {
|
||||
continue;
|
||||
}
|
||||
if (record.appOwnership !== "proven") {
|
||||
continue;
|
||||
}
|
||||
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
|
||||
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
|
||||
if (!app.accessible || !app.enabled) {
|
||||
if (!isPluginAppReadyForThreadStart(app)) {
|
||||
diagnostics.push({
|
||||
code: "app_not_ready",
|
||||
plugin: record.policy,
|
||||
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
|
||||
message: `${app.id} is not accessible for ${record.policy.pluginName}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
record.policy.destructiveApprovalMode === "always" &&
|
||||
!(await clearPersistedAppToolApprovalOverrides({
|
||||
request: params.request,
|
||||
configCwd: params.configCwd,
|
||||
plugin: record.policy,
|
||||
app,
|
||||
diagnostics,
|
||||
}))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const appConfig: JsonObject = {
|
||||
enabled: true,
|
||||
destructive_enabled: record.policy.allowDestructiveActions,
|
||||
@@ -357,14 +380,103 @@ function buildPluginAppPolicyContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function clearPersistedAppToolApprovalOverrides(params: {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
plugin: ResolvedCodexPluginPolicy;
|
||||
app: CodexPluginOwnedApp;
|
||||
diagnostics: CodexPluginThreadConfigDiagnostic[];
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const overrideNames = await readPersistedAppToolApprovalOverrideNames(params);
|
||||
for (const toolName of overrideNames) {
|
||||
const response = await params.request("config/value/write", {
|
||||
keyPath: `apps.${quoteConfigKeyPathSegment(params.app.id)}.tools.${quoteConfigKeyPathSegment(
|
||||
toolName,
|
||||
)}.approval_mode`,
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
if (isOverriddenConfigWriteResponse(response)) {
|
||||
throw new Error(`approval override for ${toolName} is controlled by another config layer`);
|
||||
}
|
||||
}
|
||||
const remainingOverrideNames = await readPersistedAppToolApprovalOverrideNames(params);
|
||||
if (remainingOverrideNames.length > 0) {
|
||||
throw new Error(
|
||||
`effective approval overrides remain for ${remainingOverrideNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
params.diagnostics.push({
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: params.plugin,
|
||||
message: `Could not clear durable Codex app approval overrides for ${params.app.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPersistedAppToolApprovalOverrideNames(params: {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
app: CodexPluginOwnedApp;
|
||||
}): Promise<string[]> {
|
||||
const response = await params.request("config/read", {
|
||||
includeLayers: false,
|
||||
...(params.configCwd ? { cwd: params.configCwd } : {}),
|
||||
});
|
||||
const config = isJsonObject(response) ? response.config : undefined;
|
||||
const appsRoot = isJsonObject(config) ? config.apps : undefined;
|
||||
const nestedApps = isJsonObject(appsRoot) ? appsRoot.apps : undefined;
|
||||
const appConfig = isJsonObject(appsRoot)
|
||||
? (appsRoot[params.app.id] ??
|
||||
(isJsonObject(nestedApps) ? nestedApps[params.app.id] : undefined))
|
||||
: undefined;
|
||||
const tools = isJsonObject(appConfig) ? appConfig.tools : undefined;
|
||||
if (!isJsonObject(tools)) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(tools)
|
||||
.filter(([, value]) => hasPersistedToolApprovalOverride(value))
|
||||
.map(([toolName]) => toolName)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function hasPersistedToolApprovalOverride(value: JsonValue): boolean {
|
||||
return (
|
||||
isJsonObject(value) && (value.approval_mode !== undefined || value.approvalMode !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function isOverriddenConfigWriteResponse(response: unknown): boolean {
|
||||
return isJsonObject(response) && response.status === "okOverridden";
|
||||
}
|
||||
|
||||
function quoteConfigKeyPathSegment(segment: string): string {
|
||||
return `"${segment.replace(/["\\]/g, (char) => `\\${char}`)}"`;
|
||||
}
|
||||
|
||||
function shouldWaitForInitialAppInventory(
|
||||
params: BuildCodexPluginThreadConfigParams,
|
||||
policy: ResolvedCodexPluginsPolicy,
|
||||
inventory: CodexPluginInventory,
|
||||
): boolean {
|
||||
// Install/enable first so the initial app/list can observe newly activated plugin apps.
|
||||
if (inventory.records.some((record) => record.activationRequired)) {
|
||||
return false;
|
||||
}
|
||||
return shouldRefreshMissingAppInventory(params, policy, inventory);
|
||||
}
|
||||
|
||||
function shouldRefreshMissingAppInventory(
|
||||
params: BuildCodexPluginThreadConfigParams,
|
||||
policy: ResolvedCodexPluginsPolicy,
|
||||
inventory: CodexPluginInventory,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
params.appCacheKey &&
|
||||
policy.pluginPolicies.some((plugin) => plugin.enabled) &&
|
||||
@@ -419,6 +531,13 @@ function resolveThreadConfigAppsForRecord(params: {
|
||||
return params.record.apps;
|
||||
}
|
||||
|
||||
function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean {
|
||||
// `app/list` is the source of truth for inventory and access posture, but
|
||||
// OpenClaw owns the per-thread enablement decision. A listed app that is
|
||||
// accessible can be re-enabled for this thread via `config.apps[app.id]`.
|
||||
return app.accessible;
|
||||
}
|
||||
|
||||
function shouldForceRefreshForNotReadyPluginApps(
|
||||
params: BuildCodexPluginThreadConfigParams,
|
||||
policy: ResolvedCodexPluginsPolicy,
|
||||
@@ -434,7 +553,7 @@ function shouldForceRefreshForNotReadyPluginApps(
|
||||
(record) =>
|
||||
record.appOwnership === "proven" &&
|
||||
record.ownedAppIds.length > 0 &&
|
||||
(record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)),
|
||||
(record.apps.length === 0 || record.apps.some((app) => !app.accessible)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,8 @@ type CodexAppServerRequestResultMap = {
|
||||
"account/read": CodexGetAccountResponse;
|
||||
"app/list": CodexAppsListResponse;
|
||||
"config/mcpServer/reload": JsonValue;
|
||||
"config/read": JsonValue;
|
||||
"config/value/write": JsonValue;
|
||||
"environment/add": JsonValue;
|
||||
"experimentalFeature/enablement/set": JsonValue;
|
||||
"feedback/upload": JsonValue;
|
||||
|
||||
@@ -112,6 +112,44 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows config value writes in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ ok: true }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = {
|
||||
keyPath: 'apps."google-calendar-app".tools',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "config/value/write",
|
||||
requestParams: params,
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
sessionKey: "sandboxed-session",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows config reads in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ config: { apps: { apps: {} } } }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = { includeLayers: false };
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "config/read",
|
||||
requestParams: params,
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
sessionKey: "sandboxed-session",
|
||||
}),
|
||||
).resolves.toEqual({ config: { apps: { apps: {} } } });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config/read", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
|
||||
@@ -4416,6 +4416,131 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
|
||||
});
|
||||
|
||||
it("sends a thread/start app enable override when app/list cached the app as disabled", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const pluginConfig = {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: readCodexPluginConfig(pluginConfig),
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
runtimeIdentity: getMockRuntimeIdentity(),
|
||||
}),
|
||||
request: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: "google-calendar-app",
|
||||
name: "Google Calendar",
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
distributionChannel: null,
|
||||
branding: null,
|
||||
appMetadata: null,
|
||||
labels: null,
|
||||
installUrl: null,
|
||||
isAccessible: true,
|
||||
isEnabled: false,
|
||||
pluginDisplayNames: [],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return {
|
||||
marketplaces: [
|
||||
{
|
||||
name: "openai-curated",
|
||||
path: "/marketplaces/openai-curated",
|
||||
interface: null,
|
||||
plugins: [
|
||||
{
|
||||
id: "google-calendar",
|
||||
name: "google-calendar",
|
||||
source: { type: "remote" },
|
||||
installed: true,
|
||||
enabled: true,
|
||||
installPolicy: "AVAILABLE",
|
||||
authPolicy: "ON_USE",
|
||||
availability: "AVAILABLE",
|
||||
interface: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
marketplaceLoadErrors: [],
|
||||
featuredPluginIds: [],
|
||||
};
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return {
|
||||
plugin: {
|
||||
marketplaceName: "openai-curated",
|
||||
marketplacePath: "/marketplaces/openai-curated",
|
||||
summary: {
|
||||
id: "google-calendar",
|
||||
name: "google-calendar",
|
||||
source: { type: "remote" },
|
||||
installed: true,
|
||||
enabled: true,
|
||||
installPolicy: "AVAILABLE",
|
||||
authPolicy: "ON_USE",
|
||||
availability: "AVAILABLE",
|
||||
interface: null,
|
||||
},
|
||||
description: null,
|
||||
skills: [],
|
||||
apps: [
|
||||
{
|
||||
id: "google-calendar-app",
|
||||
name: "Google Calendar",
|
||||
description: null,
|
||||
installUrl: null,
|
||||
needsAuth: false,
|
||||
},
|
||||
],
|
||||
mcpServers: ["google-calendar"],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
throw new Error("app/list should use the cached inventory entry");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, { pluginConfig });
|
||||
await waitForMethod("turn/start");
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const threadStart = requests.find((entry) => entry.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as
|
||||
| { config?: { apps?: Record<string, { enabled?: boolean }> } }
|
||||
| undefined;
|
||||
expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(true);
|
||||
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
|
||||
});
|
||||
|
||||
it("keys plugin app inventory by inherited API key fallback credentials", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -1578,6 +1578,7 @@ export async function runCodexAppServerAttempt(
|
||||
let activeAppServerTurnRequests = 0;
|
||||
const pendingOpenClawDynamicToolCompletionIds = new Set<string>();
|
||||
const activeTurnItemIds = new Set<string>();
|
||||
const activeCompletionBlockerItemIds = new Set<string>();
|
||||
let turnCrossedToolHandoff = false;
|
||||
let pendingTerminalDynamicToolRelease:
|
||||
| {
|
||||
@@ -1627,6 +1628,7 @@ export async function runCodexAppServerAttempt(
|
||||
isTerminalTurnNotificationQueued: () => terminalTurnNotificationQueued,
|
||||
getActiveAppServerTurnRequests: () => activeAppServerTurnRequests,
|
||||
getActiveTurnItemCount: () => activeTurnItemIds.size,
|
||||
getActiveCompletionBlockerItemCount: () => activeCompletionBlockerItemIds.size,
|
||||
turnCompletionIdleTimeoutMs,
|
||||
turnAssistantCompletionIdleTimeoutMs,
|
||||
turnAttemptIdleTimeoutMs,
|
||||
@@ -1899,6 +1901,7 @@ export async function runCodexAppServerAttempt(
|
||||
currentPromptTexts: [codexTurnPromptText],
|
||||
turnWatches,
|
||||
activeTurnItemIds,
|
||||
activeCompletionBlockerItemIds,
|
||||
activeAppServerTurnRequests,
|
||||
pendingOpenClawDynamicToolCompletionIds,
|
||||
turnCrossedToolHandoff,
|
||||
|
||||
@@ -49,9 +49,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
@@ -78,6 +76,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
isTerminalTurnNotificationQueued: () => false,
|
||||
getActiveAppServerTurnRequests: () => 0,
|
||||
getActiveTurnItemCount: () => 0,
|
||||
getActiveCompletionBlockerItemCount: () => 0,
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 500,
|
||||
turnAttemptIdleTimeoutMs: 200,
|
||||
@@ -807,6 +806,93 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an eliciting MCP tool active past the completion timeout", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const bridgedResponse = {
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
} as const;
|
||||
vi.spyOn(elicitationBridge, "handleCodexAppServerElicitationRequest").mockResolvedValue(
|
||||
bridgedResponse,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-mcp-elicitation.jsonl"),
|
||||
path.join(tempDir, "workspace-mcp-elicitation"),
|
||||
);
|
||||
params.timeoutMs = 500;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 15,
|
||||
turnAssistantCompletionIdleTimeoutMs: 1_000,
|
||||
turnTerminalIdleTimeoutMs: 1_000,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
id: "mcp-1",
|
||||
type: "mcpToolCall",
|
||||
server: "computer-use",
|
||||
tool: "computer",
|
||||
status: "inProgress",
|
||||
arguments: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.handleServerRequest({
|
||||
id: "request-mcp-elicitation",
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
mode: "form",
|
||||
message: "Approve?",
|
||||
requestedSchema: { type: "object", properties: {} },
|
||||
serverName: "computer-use",
|
||||
_meta: null,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(bridgedResponse);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 40);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
await harness.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
id: "mcp-1",
|
||||
type: "mcpToolCall",
|
||||
server: "computer-use",
|
||||
tool: "computer",
|
||||
status: "completed",
|
||||
arguments: {},
|
||||
result: { content: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("counts pending user input requests as turn attempt progress", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
|
||||
@@ -19,6 +19,8 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
|
||||
["account/read", "allowed-control-plane"],
|
||||
["app/list", "allowed-control-plane"],
|
||||
["config/mcpServer/reload", "allowed-control-plane"],
|
||||
["config/read", "allowed-control-plane"],
|
||||
["config/value/write", "allowed-control-plane"],
|
||||
["environment/add", "allowed-control-plane"],
|
||||
["experimentalFeature/enablement/set", "allowed-control-plane"],
|
||||
["feedback/upload", "allowed-control-plane"],
|
||||
|
||||
@@ -145,6 +145,35 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("round-trips always plugin app policy context destructive approval mode", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
const pluginAppPolicyContext = {
|
||||
fingerprint: "plugin-policy-always",
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated" as const,
|
||||
pluginName: "google-calendar",
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always" as const,
|
||||
mcpServerNames: ["google-calendar"],
|
||||
},
|
||||
},
|
||||
pluginAppIds: {
|
||||
"google-calendar": ["google-calendar-app"],
|
||||
},
|
||||
};
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-123",
|
||||
cwd: tempDir,
|
||||
pluginAppPolicyContext,
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("normalizes v1 plugin app policy context destructive approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -421,6 +421,9 @@ function readDestructiveApprovalMode(
|
||||
if (value === "auto") {
|
||||
return bindingSchemaVersion === 1 ? "allow" : "auto";
|
||||
}
|
||||
if (value === "always" && bindingSchemaVersion === 2) {
|
||||
return "always";
|
||||
}
|
||||
if (value === "on-request" && bindingSchemaVersion === 1) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCodexTrajectoryRecorder,
|
||||
recordCodexTrajectoryCompletion,
|
||||
recordCodexTrajectoryContext,
|
||||
resolveCodexTrajectoryAppendFlags,
|
||||
resolveCodexTrajectoryPointerFlags,
|
||||
@@ -80,7 +81,9 @@ describe("Codex trajectory recorder", () => {
|
||||
expect(content).not.toContain("secret");
|
||||
expect(content).not.toContain("sk-test-secret-token");
|
||||
expect(content).not.toContain("sk-other-secret-token");
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== "win32") {
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -253,4 +256,235 @@ describe("Codex trajectory recorder", () => {
|
||||
expect(parsed.data?.truncated).toBe(true);
|
||||
expect(parsed.data?.reason).toBe("trajectory-event-size-limit");
|
||||
});
|
||||
|
||||
it("preserves usage when truncating oversized model completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const usage = {
|
||||
input: 384_954,
|
||||
output: 5_624,
|
||||
cacheRead: 333_824,
|
||||
reasoningTokens: 2_038,
|
||||
total: 724_402,
|
||||
};
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: usage,
|
||||
assistantTexts: ["done"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
expect(parsed.type).toBe("model.completed");
|
||||
expect(parsed.data).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
usage,
|
||||
});
|
||||
expect(parsed.data.messagesSnapshot).toBeUndefined();
|
||||
expect(parsed.data.droppedFields).toContain("messagesSnapshot");
|
||||
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("drops oversized preserved fields when needed to keep completion events bounded", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const oversizedUsage = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_value, index) => [`field-${index}`, "x".repeat(5_000)]),
|
||||
);
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: oversizedUsage,
|
||||
assistantTexts: ["x".repeat(32_000)],
|
||||
messagesSnapshot: [{ role: "assistant", content: "x".repeat(32_000) }],
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
expect(parsed.data).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
});
|
||||
expect(parsed.data.usage).toBeUndefined();
|
||||
expect(parsed.data.droppedFields).toEqual(
|
||||
expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]),
|
||||
);
|
||||
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("preserves usage on non-final oversized model completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const firstUsage = {
|
||||
input: 384_954,
|
||||
output: 5_624,
|
||||
cacheRead: 333_824,
|
||||
reasoningTokens: 2_038,
|
||||
total: 724_402,
|
||||
};
|
||||
const secondUsage = { input: 12, output: 3, total: 15 };
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: firstUsage,
|
||||
assistantTexts: ["first"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-2",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: secondUsage,
|
||||
assistantTexts: ["final answer"],
|
||||
messagesSnapshot: [{ role: "assistant", content: "final answer" }],
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const events = fs
|
||||
.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8")
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].data).toMatchObject({
|
||||
truncated: true,
|
||||
usage: firstUsage,
|
||||
});
|
||||
expect(events[1].data).toMatchObject({
|
||||
turnId: "turn-2",
|
||||
usage: secondUsage,
|
||||
assistantTexts: ["final answer"],
|
||||
});
|
||||
expect(events[1].data.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts secrets before preserving usage in truncated completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: {
|
||||
total: 1,
|
||||
apiKey: "sk-test-secret-token",
|
||||
authorization: "Bearer sk-other-secret-token",
|
||||
},
|
||||
assistantTexts: ["done"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
const preservedUsage = JSON.stringify(parsed.data.usage);
|
||||
expect(parsed.data.truncated).toBe(true);
|
||||
expect(preservedUsage).toContain("redacted");
|
||||
expect(preservedUsage).not.toContain("sk-test-secret-token");
|
||||
expect(preservedUsage).not.toContain("sk-other-secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]
|
||||
const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu;
|
||||
const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
|
||||
const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const;
|
||||
|
||||
type CodexTrajectoryOpenFlagConstants = Pick<
|
||||
typeof nodeFs.constants,
|
||||
@@ -82,19 +83,57 @@ function boundedTrajectoryLine(event: Record<string, unknown>): string | undefin
|
||||
if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${line}\n`;
|
||||
}
|
||||
const truncated = JSON.stringify({
|
||||
...event,
|
||||
data: {
|
||||
truncated: true,
|
||||
originalBytes: bytes,
|
||||
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
reason: "trajectory-event-size-limit",
|
||||
},
|
||||
});
|
||||
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${truncated}\n`;
|
||||
|
||||
const originalData =
|
||||
event.data && typeof event.data === "object" && !Array.isArray(event.data)
|
||||
? (event.data as Record<string, unknown>)
|
||||
: {};
|
||||
const originalDataKeys = Object.keys(originalData);
|
||||
const preservedDataKeys = new Set<string>();
|
||||
const baseData = {
|
||||
truncated: true,
|
||||
originalBytes: bytes,
|
||||
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
reason: "trajectory-event-size-limit",
|
||||
};
|
||||
const buildTruncatedLine = (includeDroppedFields: boolean): string | undefined => {
|
||||
const data: Record<string, unknown> = { ...baseData };
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
if (preservedDataKeys.has(key)) {
|
||||
data[key] = originalData[key];
|
||||
}
|
||||
}
|
||||
if (includeDroppedFields) {
|
||||
const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key));
|
||||
if (droppedFields.length > 0) {
|
||||
data.droppedFields = droppedFields;
|
||||
}
|
||||
}
|
||||
const truncated = JSON.stringify({ ...event, data });
|
||||
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${truncated}\n`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let best = buildTruncatedLine(true) ?? buildTruncatedLine(false);
|
||||
if (!best) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
if (!Object.hasOwn(originalData, key)) {
|
||||
continue;
|
||||
}
|
||||
preservedDataKeys.add(key);
|
||||
const next = buildTruncatedLine(true) ?? buildTruncatedLine(false);
|
||||
if (next) {
|
||||
best = next;
|
||||
continue;
|
||||
}
|
||||
preservedDataKeys.delete(key);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function resolveTrajectoryPointerFilePath(sessionFile: string): string {
|
||||
|
||||
@@ -23,7 +23,7 @@ export type CodexPluginConfigEntry = {
|
||||
enabled?: boolean;
|
||||
marketplaceName?: string;
|
||||
pluginName?: string;
|
||||
allow_destructive_actions?: boolean | "auto";
|
||||
allow_destructive_actions?: boolean | "auto" | "always";
|
||||
};
|
||||
|
||||
export type CodexPluginsConfigBlock = {
|
||||
|
||||
@@ -43,7 +43,7 @@ export type CodexPluginMigrationConfigEntry = {
|
||||
configKey: string;
|
||||
pluginName: string;
|
||||
enabled: boolean;
|
||||
allowDestructiveActions?: "auto";
|
||||
allowDestructiveActions?: "auto" | "always";
|
||||
};
|
||||
|
||||
type CodexPluginMigrationBlockSkipDetails = {
|
||||
@@ -168,15 +168,18 @@ function isLegacyDestructivePolicyRepair(
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacyDestructivePolicyConfigEntryRepair(
|
||||
function readExistingPluginAllowDestructiveActions(
|
||||
existing: unknown,
|
||||
pluginName: string,
|
||||
): boolean {
|
||||
): "auto" | "always" | undefined {
|
||||
const existingEntry = isRecord(existing) ? existing : undefined;
|
||||
return (
|
||||
existingEntry?.allow_destructive_actions === "on-request" &&
|
||||
existingEntry.pluginName === pluginName
|
||||
if (existingEntry?.pluginName !== pluginName) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeExistingAllowDestructiveActions(
|
||||
existingEntry.allow_destructive_actions,
|
||||
);
|
||||
return normalized === "auto" || normalized === "always" ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildPluginItems(
|
||||
@@ -203,12 +206,15 @@ function buildPluginItems(
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: plugin.pluginName,
|
||||
...(isLegacyDestructivePolicyConfigEntryRepair(
|
||||
existingPluginEntries[configKey],
|
||||
plugin.pluginName,
|
||||
)
|
||||
? { allow_destructive_actions: "auto" }
|
||||
: {}),
|
||||
...(() => {
|
||||
const allowDestructiveActions = readExistingPluginAllowDestructiveActions(
|
||||
existingPluginEntries[configKey],
|
||||
plugin.pluginName,
|
||||
);
|
||||
return allowDestructiveActions
|
||||
? { allow_destructive_actions: allowDestructiveActions }
|
||||
: {};
|
||||
})(),
|
||||
};
|
||||
const conflict =
|
||||
!ctx.overwrite &&
|
||||
@@ -234,8 +240,9 @@ function buildPluginItems(
|
||||
pluginName: plugin.pluginName,
|
||||
sourceInstalled: plugin.installed === true,
|
||||
sourceEnabled: plugin.enabled === true,
|
||||
...(plannedEntry.allow_destructive_actions === "auto"
|
||||
? { allowDestructiveActions: "auto" }
|
||||
...(plannedEntry.allow_destructive_actions === "auto" ||
|
||||
plannedEntry.allow_destructive_actions === "always"
|
||||
? { allowDestructiveActions: plannedEntry.allow_destructive_actions }
|
||||
: {}),
|
||||
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
|
||||
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
|
||||
@@ -310,13 +317,15 @@ export function readCodexPluginMigrationConfigEntry(
|
||||
configKey,
|
||||
pluginName,
|
||||
enabled,
|
||||
...(allowDestructiveActions === "auto" ? { allowDestructiveActions: "auto" } : {}),
|
||||
...(allowDestructiveActions === "auto" || allowDestructiveActions === "always"
|
||||
? { allowDestructiveActions }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readExistingAllowDestructiveActions(
|
||||
config: MigrationProviderContext["config"],
|
||||
): boolean | "auto" | undefined {
|
||||
): boolean | "auto" | "always" | undefined {
|
||||
const value = readMigrationConfigPath(config as Record<string, unknown>, [
|
||||
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
|
||||
"allow_destructive_actions",
|
||||
@@ -324,8 +333,16 @@ function readExistingAllowDestructiveActions(
|
||||
return normalizeExistingAllowDestructiveActions(value);
|
||||
}
|
||||
|
||||
function normalizeExistingAllowDestructiveActions(value: unknown): boolean | "auto" | undefined {
|
||||
return value === "auto" || value === "on-request" ? "auto" : asBoolean(value);
|
||||
function normalizeExistingAllowDestructiveActions(
|
||||
value: unknown,
|
||||
): boolean | "auto" | "always" | undefined {
|
||||
if (value === "auto" || value === "on-request") {
|
||||
return "auto";
|
||||
}
|
||||
if (value === "always") {
|
||||
return "always";
|
||||
}
|
||||
return asBoolean(value);
|
||||
}
|
||||
|
||||
function readExistingPluginPolicyRepairs(
|
||||
|
||||
@@ -2108,6 +2108,76 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves global always destructive plugin policy during migration", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] } satisfies v2.SkillsListResponse;
|
||||
}
|
||||
if (method === "hooks/list") {
|
||||
return { data: [] } satisfies v2.HooksListResponse;
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
runtime: createConfigRuntime(configState),
|
||||
});
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
config: configState,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records auth-required plugin installs as disabled explicit config entries", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
|
||||
@@ -207,4 +207,65 @@ describe("codex cli node sessions", () => {
|
||||
}),
|
||||
).rejects.toThrow("Codex CLI node command returned malformed payloadJSON.");
|
||||
});
|
||||
|
||||
it("keeps Codex history session previews on UTF-16 code point boundaries", async () => {
|
||||
const sessionId = "019e2007-1f7e-7eb1-a42b-8c01f4b9b5ce";
|
||||
const text = `${"a".repeat(136)}🤖tail`;
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "history.jsonl"),
|
||||
JSON.stringify({ session_id: sessionId, ts: 1778678322, text }),
|
||||
);
|
||||
|
||||
const command = createCodexCliSessionNodeHostCommands().find(
|
||||
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
|
||||
);
|
||||
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
|
||||
const parsed = JSON.parse(raw ?? "{}") as {
|
||||
sessions?: Array<{ lastMessage?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"a".repeat(136)}...`);
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
|
||||
});
|
||||
|
||||
it("keeps Codex session-file previews on UTF-16 code point boundaries", async () => {
|
||||
const sessionId = "019e23d1-f33d-78e3-959e-0f56f30a5248";
|
||||
const sessionDir = path.join(tempDir, "sessions", "2026", "05", "14");
|
||||
const sessionFile = path.join(sessionDir, `rollout-2026-05-14T00-10-22-${sessionId}.jsonl`);
|
||||
const text = `${"b".repeat(136)}🤖tail`;
|
||||
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-14T00:10:23.618Z",
|
||||
type: "session_meta",
|
||||
payload: { id: sessionId, cwd: "/tmp/codex-work" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-14T00:10:23.619Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const command = createCodexCliSessionNodeHostCommands().find(
|
||||
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
|
||||
);
|
||||
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
|
||||
const parsed = JSON.parse(raw ?? "{}") as {
|
||||
sessions?: Array<{ lastMessage?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"b".repeat(136)}...`);
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
|
||||
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgram,
|
||||
@@ -691,7 +692,10 @@ function normalizeTimeoutMs(value: unknown): number {
|
||||
}
|
||||
|
||||
function truncateText(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 3)}...` : value;
|
||||
if (value.length <= max) {
|
||||
return value;
|
||||
}
|
||||
return `${truncateUtf16Safe(value, Math.max(0, max - 3))}...`;
|
||||
}
|
||||
|
||||
function compareOptionalStringsDesc(a?: string, b?: string): number {
|
||||
|
||||
@@ -31,15 +31,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadline: createProviderOperationDeadlineMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
postMultipartRequest: postMultipartRequestMock,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
|
||||
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
|
||||
"openclaw/plugin-sdk/provider-http",
|
||||
);
|
||||
return {
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadline: createProviderOperationDeadlineMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
postMultipartRequest: postMultipartRequestMock,
|
||||
readProviderJsonResponse: actual.readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
|
||||
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
|
||||
@@ -63,6 +69,13 @@ function requireFirstMockObjectArg(mock: ReturnType<typeof vi.fn>, label: string
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("deepinfra image generation provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
@@ -86,11 +99,9 @@ describe("deepinfra image generation provider", () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
|
||||
}),
|
||||
},
|
||||
response: jsonResponse({
|
||||
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
|
||||
}),
|
||||
release,
|
||||
});
|
||||
|
||||
@@ -168,17 +179,15 @@ describe("deepinfra image generation provider", () => {
|
||||
|
||||
it("sends image edits as multipart OpenAI-compatible requests", async () => {
|
||||
postMultipartRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
|
||||
"base64",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
response: jsonResponse({
|
||||
data: [
|
||||
{
|
||||
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
|
||||
"base64",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ type DuckDuckGoResult = {
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
function isDecodableCodePoint(cp: number): boolean {
|
||||
return Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(
|
||||
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
|
||||
@@ -72,10 +76,12 @@ function decodeHtmlEntities(text: string): string {
|
||||
return "&";
|
||||
}
|
||||
if (normalized.startsWith("&#x")) {
|
||||
return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
|
||||
const codePoint = Number.parseInt(normalized.slice(3, -1), 16);
|
||||
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
||||
}
|
||||
if (normalized.startsWith("&#")) {
|
||||
return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
|
||||
const codePoint = Number.parseInt(normalized.slice(2, -1), 10);
|
||||
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
||||
}
|
||||
return entity;
|
||||
},
|
||||
|
||||
@@ -205,6 +205,20 @@ describe("duckduckgo web search provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out-of-range numeric html entities intact instead of throwing", () => {
|
||||
expect(() => ddgClientTesting.decodeHtmlEntities("Result � end")).not.toThrow();
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Result � end")).toBe(
|
||||
"Result � end",
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Hex � tail")).toBe("Hex � tail");
|
||||
// Surrogate-range entities would decode to lone UTF-16 surrogates; keep them intact.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
// A valid supplementary-plane entity still decodes.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Smile 😀")).toBe("Smile 😀");
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// A result whose text literally shows "<" arrives double-encoded as
|
||||
// "&lt;". Decoding & first would re-decode it into "<", corrupting
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import {
|
||||
@@ -645,7 +646,9 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "fal image generation failed");
|
||||
|
||||
const payload = parseFalImageGenerationResponse(await response.json());
|
||||
const payload = parseFalImageGenerationResponse(
|
||||
await readProviderJsonResponse(response, "fal.image-generation"),
|
||||
);
|
||||
const images: GeneratedImageAsset[] = [];
|
||||
let imageIndex = 0;
|
||||
for (const entry of payload.images) {
|
||||
|
||||
@@ -29,21 +29,30 @@ function expectExplicitDefaultAccountSelection(
|
||||
expect(account.appId).toBe(appId);
|
||||
}
|
||||
|
||||
function withEnvVar(key: string, value: string | undefined, run: () => void) {
|
||||
function setTestEnvValue(key: string, value: string | undefined): () => void {
|
||||
const prev = process.env[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
Reflect.deleteProperty(process.env, key);
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
Reflect.set(process.env, key, value);
|
||||
}
|
||||
return () => restoreTestEnvValue(key, prev);
|
||||
}
|
||||
|
||||
function restoreTestEnvValue(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(process.env, key);
|
||||
} else {
|
||||
Reflect.set(process.env, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function withEnvVar(key: string, value: string | undefined, run: () => void): void {
|
||||
const restore = setTestEnvValue(key, value);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +223,7 @@ describe("resolveFeishuCredentials", () => {
|
||||
|
||||
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
|
||||
const key = "FEISHU_APP_SECRET_TEST";
|
||||
const prev = process.env[key];
|
||||
process.env[key] = " secret_from_env ";
|
||||
const restore = setTestEnvValue(key, " secret_from_env ");
|
||||
|
||||
try {
|
||||
const creds = resolveFeishuCredentials(
|
||||
@@ -234,18 +242,13 @@ describe("resolveFeishuCredentials", () => {
|
||||
domain: "feishu",
|
||||
});
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
|
||||
const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
|
||||
const prev = process.env[key];
|
||||
process.env[key] = " secret_from_env_alias ";
|
||||
const restore = setTestEnvValue(key, " secret_from_env_alias ");
|
||||
|
||||
try {
|
||||
const creds = resolveFeishuCredentials(
|
||||
@@ -258,11 +261,7 @@ describe("resolveFeishuCredentials", () => {
|
||||
|
||||
expect(creds?.appSecret).toBe("secret_from_env_alias");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
// Feishu tests cover app registration plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { beginAppRegistration, pollAppRegistration } from "./app-registration.js";
|
||||
import { beginAppRegistration, pollAppRegistration, printQrCode } from "./app-registration.js";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
const { fetchWithSsrFGuardMock, renderQrTerminalMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
renderQrTerminalMock: vi.fn(async () => "terminal-qr"),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
vi.mock("./qr-terminal.js", () => ({
|
||||
renderQrTerminal: renderQrTerminalMock,
|
||||
}));
|
||||
|
||||
function mockFeishuJson(payload: unknown) {
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify(payload), { status: 200 }),
|
||||
@@ -23,6 +28,7 @@ describe("Feishu app registration", () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
renderQrTerminalMock.mockClear();
|
||||
});
|
||||
|
||||
it("defaults unsafe begin polling lifetimes from provider responses", async () => {
|
||||
@@ -59,4 +65,16 @@ describe("Feishu app registration", () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await expect(poll).resolves.toEqual({ status: "timeout" });
|
||||
});
|
||||
|
||||
it("prints scan-to-create QR codes with compact terminal rendering", async () => {
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
await printQrCode("https://accounts.feishu.cn/verify?device_code=long-device-code");
|
||||
|
||||
expect(renderQrTerminalMock).toHaveBeenCalledWith(
|
||||
"https://accounts.feishu.cn/verify?device_code=long-device-code",
|
||||
{ small: true },
|
||||
);
|
||||
expect(writeSpy).toHaveBeenCalledWith("terminal-qr\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ export async function pollAppRegistration(params: {
|
||||
* otherwise the pattern is corrupted and cannot be scanned.
|
||||
*/
|
||||
export async function printQrCode(url: string): Promise<void> {
|
||||
const output = await renderQrTerminal(url);
|
||||
const output = await renderQrTerminal(url, { small: true });
|
||||
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@ let FEISHU_USER_AGENT: string;
|
||||
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
|
||||
let priorFeishuTimeoutEnv: string | undefined;
|
||||
|
||||
function setFeishuTestEnvValue(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(process.env, key);
|
||||
} else {
|
||||
Reflect.set(process.env, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./channel.js", () => ({
|
||||
feishuPlugin: feishuPluginMock,
|
||||
}));
|
||||
@@ -213,10 +221,10 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
priorProxyEnv = {};
|
||||
priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
|
||||
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
|
||||
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, undefined);
|
||||
for (const key of proxyEnvKeys) {
|
||||
priorProxyEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
setFeishuTestEnvValue(key, undefined);
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
clearClientCache();
|
||||
@@ -238,18 +246,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of proxyEnvKeys) {
|
||||
const value = priorProxyEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
if (priorFeishuTimeoutEnv === undefined) {
|
||||
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
|
||||
} else {
|
||||
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
|
||||
setFeishuTestEnvValue(key, priorProxyEnv[key]);
|
||||
}
|
||||
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, priorFeishuTimeoutEnv);
|
||||
setFeishuClientRuntimeForTest();
|
||||
});
|
||||
|
||||
@@ -359,7 +358,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
|
||||
it("uses env timeout override when provided and no direct timeout is set", async () => {
|
||||
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
|
||||
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
|
||||
|
||||
createFeishuClient({
|
||||
appId: "app_8",
|
||||
@@ -373,7 +372,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
|
||||
it("ignores non-decimal env timeout overrides", async () => {
|
||||
for (const value of ["0x10", "1e3", "10.5"]) {
|
||||
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = value;
|
||||
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, value);
|
||||
|
||||
createFeishuClient({
|
||||
appId: `app-${value}`,
|
||||
@@ -387,7 +386,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
|
||||
it("prefers direct timeout over env override", async () => {
|
||||
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
|
||||
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
|
||||
|
||||
createFeishuClient({
|
||||
appId: "app_10",
|
||||
@@ -401,7 +400,10 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
|
||||
it("clamps env timeout override to max bound", async () => {
|
||||
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456);
|
||||
setFeishuTestEnvValue(
|
||||
FEISHU_HTTP_TIMEOUT_ENV_VAR,
|
||||
String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456),
|
||||
);
|
||||
|
||||
createFeishuClient({
|
||||
appId: "app_9",
|
||||
@@ -505,7 +507,7 @@ describe("createFeishuWSClient proxy handling", () => {
|
||||
});
|
||||
|
||||
it("creates a ws proxy agent when lowercase https_proxy is set", async () => {
|
||||
process.env.https_proxy = "http://lower-https:8001";
|
||||
setFeishuTestEnvValue("https_proxy", "http://lower-https:8001");
|
||||
|
||||
await createFeishuWSClient(baseAccount);
|
||||
|
||||
@@ -515,7 +517,7 @@ describe("createFeishuWSClient proxy handling", () => {
|
||||
});
|
||||
|
||||
it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => {
|
||||
process.env.HTTPS_PROXY = "http://upper-https:8002";
|
||||
setFeishuTestEnvValue("HTTPS_PROXY", "http://upper-https:8002");
|
||||
|
||||
await createFeishuWSClient(baseAccount);
|
||||
|
||||
@@ -525,7 +527,7 @@ describe("createFeishuWSClient proxy handling", () => {
|
||||
});
|
||||
|
||||
it("falls back to HTTP_PROXY for ws proxy agent creation", async () => {
|
||||
process.env.HTTP_PROXY = "http://upper-http:8999";
|
||||
setFeishuTestEnvValue("HTTP_PROXY", "http://upper-http:8999");
|
||||
|
||||
await createFeishuWSClient(baseAccount);
|
||||
|
||||
|
||||
@@ -855,7 +855,7 @@ describe("google-meet plugin", () => {
|
||||
});
|
||||
|
||||
it("registers the node-host command used by chrome-node transport", () => {
|
||||
const { nodeHostCommands } = setup();
|
||||
const { nodeHostCommands, nodeInvokePolicies } = setup();
|
||||
|
||||
const command = nodeHostCommands.find(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
@@ -865,7 +865,13 @@ describe("google-meet plugin", () => {
|
||||
throw new Error("expected googlemeet.chrome node host command");
|
||||
}
|
||||
expect(command.cap).toBe("google-meet");
|
||||
expect(command.dangerous).toBe(true);
|
||||
expect(typeof command.handle).toBe("function");
|
||||
expect(nodeInvokePolicies).toHaveLength(1);
|
||||
expect(nodeInvokePolicies[0]).toMatchObject({
|
||||
commands: ["googlemeet.chrome"],
|
||||
dangerous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
|
||||
@@ -2239,6 +2245,9 @@ describe("google-meet plugin", () => {
|
||||
try {
|
||||
const { methods, runCommandWithTimeout } = setup({
|
||||
defaultMode: "transcribe",
|
||||
chrome: {
|
||||
browserProfile: "meet-devtools",
|
||||
},
|
||||
});
|
||||
const callGatewayFromCli = mockLocalMeetBrowserRequest({
|
||||
inCall: true,
|
||||
@@ -3428,7 +3437,12 @@ describe("google-meet plugin", () => {
|
||||
},
|
||||
);
|
||||
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
||||
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
|
||||
const { tools, nodesInvoke } = setup({
|
||||
defaultTransport: "chrome",
|
||||
chrome: {
|
||||
browserProfile: "meet-devtools",
|
||||
},
|
||||
});
|
||||
const tool = tools[0] as {
|
||||
execute: (
|
||||
id: string,
|
||||
@@ -3458,6 +3472,7 @@ describe("google-meet plugin", () => {
|
||||
expect(focusCall[0]).toBe("browser.request");
|
||||
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
|
||||
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
|
||||
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
|
||||
expect(focusCall[3]).toEqual({ progress: false });
|
||||
expect(nodesInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
fetchGoogleMeetSpace,
|
||||
} from "./src/meet.js";
|
||||
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
|
||||
import {
|
||||
createGoogleMeetChromeNodeInvokePolicy,
|
||||
GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
} from "./src/node-invoke-policy.js";
|
||||
import { GoogleMeetRuntime } from "./src/runtime.js";
|
||||
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
|
||||
|
||||
@@ -1196,10 +1200,12 @@ export default definePluginEntry({
|
||||
);
|
||||
|
||||
api.registerNodeHostCommand({
|
||||
command: "googlemeet.chrome",
|
||||
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
cap: "google-meet",
|
||||
dangerous: true,
|
||||
handle: handleGoogleMeetNodeHostCommand,
|
||||
});
|
||||
api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config));
|
||||
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
|
||||
@@ -91,6 +91,41 @@ describe("google-meet node host bridge sessions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
children.length = 0;
|
||||
vi.mocked(spawnSync).mockClear();
|
||||
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
|
||||
try {
|
||||
const start = JSON.parse(
|
||||
await handleGoogleMeetNodeHostCommand(
|
||||
JSON.stringify({
|
||||
action: "start",
|
||||
url: "https://meet.google.com/xyz-abcd-uvw",
|
||||
mode: "transcribe",
|
||||
browserProfile: "Profile 2",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(start.launched).toBe(true);
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
"open",
|
||||
[
|
||||
"-a",
|
||||
"Google Chrome",
|
||||
"https://meet.google.com/xyz-abcd-uvw",
|
||||
"--args",
|
||||
"--profile-directory=Profile 2",
|
||||
],
|
||||
expect.objectContaining({ encoding: "utf8" }),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
it("clears output playback without closing the active bridge when the old output exits", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
children.length = 0;
|
||||
|
||||
@@ -332,12 +332,11 @@ function startChrome(params: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
if (params.launch !== false) {
|
||||
const argv = ["open", "-a", "Google Chrome"];
|
||||
const argv = ["open", "-a", "Google Chrome", url];
|
||||
const browserProfile = readString(params.browserProfile);
|
||||
if (browserProfile) {
|
||||
argv.push("--args", `--profile-directory=${browserProfile}`);
|
||||
}
|
||||
argv.push(url);
|
||||
const result = runCommandWithTimeout(argv, timeoutMs);
|
||||
if (result.code !== 0) {
|
||||
if (bridgeId) {
|
||||
|
||||
134
extensions/google-meet/src/node-invoke-policy.test.ts
Normal file
134
extensions/google-meet/src/node-invoke-policy.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
|
||||
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveGoogleMeetConfig } from "./config.js";
|
||||
import {
|
||||
createGoogleMeetChromeNodeInvokePolicy,
|
||||
GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
} from "./node-invoke-policy.js";
|
||||
|
||||
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
|
||||
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
|
||||
ok: true,
|
||||
payload: { ok: true },
|
||||
}));
|
||||
const ctx: OpenClawPluginNodeInvokePolicyContext = {
|
||||
nodeId: "node-1",
|
||||
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
params,
|
||||
config: {} as never,
|
||||
pluginConfig,
|
||||
invokeNode,
|
||||
};
|
||||
return { ctx, invokeNode };
|
||||
}
|
||||
|
||||
describe("Google Meet node invoke policy", () => {
|
||||
it("rewrites start executable fields from trusted config", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(
|
||||
resolveGoogleMeetConfig({
|
||||
chrome: {
|
||||
launch: false,
|
||||
browserProfile: "Trusted Profile",
|
||||
joinTimeoutMs: 45_000,
|
||||
audioInputCommand: ["trusted-capture", "--raw"],
|
||||
audioOutputCommand: ["trusted-play", "--raw"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "start",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
mode: "bidi",
|
||||
launch: true,
|
||||
browserProfile: "Attacker Profile",
|
||||
joinTimeoutMs: 1,
|
||||
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
|
||||
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
|
||||
audioInputCommand: ["malicious-capture"],
|
||||
audioOutputCommand: ["malicious-play"],
|
||||
});
|
||||
|
||||
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
|
||||
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
expect(invokeNode).toHaveBeenCalledWith({
|
||||
params: {
|
||||
action: "start",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
mode: "bidi",
|
||||
launch: false,
|
||||
browserProfile: "Trusted Profile",
|
||||
joinTimeoutMs: 45_000,
|
||||
audioInputCommand: ["trusted-capture", "--raw"],
|
||||
audioOutputCommand: ["trusted-play", "--raw"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses trusted configured external bridge commands for start", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(
|
||||
resolveGoogleMeetConfig({
|
||||
chrome: {
|
||||
audioBridgeHealthCommand: ["trusted-bridge", "status"],
|
||||
audioBridgeCommand: ["trusted-bridge", "start"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "start",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
mode: "bidi",
|
||||
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
|
||||
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
|
||||
});
|
||||
|
||||
await policy.handle(ctx);
|
||||
|
||||
const call = invokeNode.mock.calls[0]?.[0];
|
||||
expect(call?.params).toMatchObject({
|
||||
action: "start",
|
||||
audioBridgeHealthCommand: ["trusted-bridge", "status"],
|
||||
audioBridgeCommand: ["trusted-bridge", "start"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "start",
|
||||
url: "https://example.com/private",
|
||||
mode: "bidi",
|
||||
});
|
||||
|
||||
await expect(policy.handle(ctx)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
|
||||
message: "url must be an explicit https://meet.google.com/... URL",
|
||||
});
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps direct setup diagnostics but strips extra fields", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "setup",
|
||||
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
|
||||
});
|
||||
|
||||
await policy.handle(ctx);
|
||||
|
||||
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
|
||||
});
|
||||
|
||||
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
|
||||
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
|
||||
|
||||
await expect(policy.handle(ctx)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
|
||||
});
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
192
extensions/google-meet/src/node-invoke-policy.ts
Normal file
192
extensions/google-meet/src/node-invoke-policy.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
OpenClawPluginNodeInvokePolicyContext,
|
||||
OpenClawPluginNodeInvokePolicyResult,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { GoogleMeetConfig } from "./config.js";
|
||||
import { normalizeMeetUrl } from "./runtime.js";
|
||||
|
||||
export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome";
|
||||
|
||||
const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]);
|
||||
|
||||
type PolicyDecision =
|
||||
| { approved: true; params: Record<string, unknown> }
|
||||
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readPositiveNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function copyCommand(command: string[] | undefined): string[] | undefined {
|
||||
return command && command.length > 0 ? [...command] : undefined;
|
||||
}
|
||||
|
||||
function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") {
|
||||
return { ok: false as const, code, message };
|
||||
}
|
||||
|
||||
function approved(params: Record<string, unknown>): PolicyDecision {
|
||||
return { approved: true, params };
|
||||
}
|
||||
|
||||
function buildStartParams(
|
||||
params: Record<string, unknown>,
|
||||
config: GoogleMeetConfig,
|
||||
): PolicyDecision {
|
||||
let url: string;
|
||||
try {
|
||||
url = normalizeMeetUrl(params.url);
|
||||
} catch (error) {
|
||||
return {
|
||||
approved: false,
|
||||
result: denied(
|
||||
error instanceof Error ? error.message : "googlemeet.chrome start requires url",
|
||||
),
|
||||
};
|
||||
}
|
||||
const mode = readString(params.mode);
|
||||
if (mode && !START_MODES.has(mode)) {
|
||||
return {
|
||||
approved: false,
|
||||
result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`),
|
||||
};
|
||||
}
|
||||
const startParams: Record<string, unknown> = {
|
||||
action: "start",
|
||||
url,
|
||||
launch: params.launch === false ? false : config.chrome.launch,
|
||||
browserProfile: config.chrome.browserProfile,
|
||||
joinTimeoutMs: config.chrome.joinTimeoutMs,
|
||||
};
|
||||
if (mode) {
|
||||
startParams.mode = mode;
|
||||
}
|
||||
const audioInputCommand = copyCommand(config.chrome.audioInputCommand);
|
||||
if (audioInputCommand) {
|
||||
startParams.audioInputCommand = audioInputCommand;
|
||||
}
|
||||
const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand);
|
||||
if (audioOutputCommand) {
|
||||
startParams.audioOutputCommand = audioOutputCommand;
|
||||
}
|
||||
const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand);
|
||||
if (audioBridgeCommand) {
|
||||
startParams.audioBridgeCommand = audioBridgeCommand;
|
||||
}
|
||||
const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand);
|
||||
if (audioBridgeHealthCommand) {
|
||||
startParams.audioBridgeHealthCommand = audioBridgeHealthCommand;
|
||||
}
|
||||
return approved(startParams);
|
||||
}
|
||||
|
||||
function buildForwardParams(params: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const action = readString(params.action);
|
||||
switch (action) {
|
||||
case "setup":
|
||||
return { action };
|
||||
case "status": {
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
return bridgeId ? { action, bridgeId } : { action };
|
||||
}
|
||||
case "list": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const url = readString(params.url);
|
||||
const mode = readString(params.mode);
|
||||
if (url) {
|
||||
forwarded.url = url;
|
||||
}
|
||||
if (mode) {
|
||||
forwarded.mode = mode;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "stopByUrl": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const url = readString(params.url);
|
||||
const mode = readString(params.mode);
|
||||
const exceptBridgeId = readString(params.exceptBridgeId);
|
||||
if (url) {
|
||||
forwarded.url = url;
|
||||
}
|
||||
if (mode) {
|
||||
forwarded.mode = mode;
|
||||
}
|
||||
if (exceptBridgeId) {
|
||||
forwarded.exceptBridgeId = exceptBridgeId;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "pullAudio": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
const timeoutMs = readPositiveNumber(params.timeoutMs);
|
||||
if (bridgeId) {
|
||||
forwarded.bridgeId = bridgeId;
|
||||
}
|
||||
if (timeoutMs) {
|
||||
forwarded.timeoutMs = timeoutMs;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "pushAudio": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
const base64 = readString(params.base64);
|
||||
if (bridgeId) {
|
||||
forwarded.bridgeId = bridgeId;
|
||||
}
|
||||
if (base64) {
|
||||
forwarded.base64 = base64;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "clearAudio":
|
||||
case "stop": {
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
return bridgeId ? { action, bridgeId } : { action };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createGoogleMeetChromeNodeInvokePolicy(
|
||||
config: GoogleMeetConfig,
|
||||
): OpenClawPluginNodeInvokePolicy {
|
||||
return {
|
||||
commands: [GOOGLE_MEET_CHROME_NODE_COMMAND],
|
||||
dangerous: true,
|
||||
async handle(ctx: OpenClawPluginNodeInvokePolicyContext) {
|
||||
if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) {
|
||||
return denied(`unsupported Google Meet node command: ${ctx.command}`);
|
||||
}
|
||||
const params = asRecord(ctx.params);
|
||||
const action = readString(params.action);
|
||||
let decision: PolicyDecision;
|
||||
if (action === "start") {
|
||||
decision = buildStartParams(params, config);
|
||||
} else {
|
||||
const forwardParams = buildForwardParams(params);
|
||||
decision = forwardParams
|
||||
? approved(forwardParams)
|
||||
: { approved: false, result: denied("unsupported googlemeet.chrome action") };
|
||||
}
|
||||
if (!decision.approved) {
|
||||
return decision.result;
|
||||
}
|
||||
return await ctx.invokeNode({ params: decision.params });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export function setupGoogleMeetPlugin(
|
||||
const tools: unknown[] = [];
|
||||
const cliRegistrations: unknown[] = [];
|
||||
const nodeHostCommands: unknown[] = [];
|
||||
const nodeInvokePolicies: unknown[] = [];
|
||||
const nodesList = vi.fn(
|
||||
async () =>
|
||||
options.nodesListResult ?? {
|
||||
@@ -165,6 +166,7 @@ export function setupGoogleMeetPlugin(
|
||||
},
|
||||
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
|
||||
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
|
||||
registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy),
|
||||
});
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
@@ -184,6 +186,7 @@ export function setupGoogleMeetPlugin(
|
||||
nodesList,
|
||||
nodesInvoke,
|
||||
nodeHostCommands,
|
||||
nodeInvokePolicies,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ import { testing as geminiWebSearchTesting } from "./src/gemini-web-search-provi
|
||||
|
||||
let ssrfMock: { mockRestore: () => void } | undefined;
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockGoogleApiKeyAuth() {
|
||||
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-test-key",
|
||||
@@ -24,9 +31,8 @@ function installGoogleFetchMock(params?: {
|
||||
const mimeType = params?.mimeType ?? "image/png";
|
||||
const data = params?.data ?? "png-data";
|
||||
const inlineDataKey = params?.inlineDataKey ?? "inlineData";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
@@ -42,7 +48,7 @@ function installGoogleFetchMock(params?: {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
@@ -100,9 +106,8 @@ describe("Google image-generation provider", () => {
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
@@ -119,7 +124,7 @@ describe("Google image-generation provider", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
@@ -208,10 +213,7 @@ describe("Google image-generation provider", () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ candidates: { content: { parts: [] } } }),
|
||||
}),
|
||||
vi.fn().mockResolvedValue(jsonResponse({ candidates: { content: { parts: [] } } })),
|
||||
);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
@@ -229,9 +231,8 @@ describe("Google image-generation provider", () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
@@ -240,7 +241,7 @@ describe("Google image-generation provider", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
@@ -260,9 +261,8 @@ describe("Google image-generation provider", () => {
|
||||
source: "profile",
|
||||
mode: "token",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
@@ -278,7 +278,7 @@ describe("Google image-generation provider", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
@@ -305,6 +305,74 @@ describe("Google image-generation provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
|
||||
const imagePayload = imageBytes.toString("base64");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: Array.from({ length: 3 }, () => ({
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: imagePayload,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.images).toHaveLength(3);
|
||||
expect(result.images.map((image) => image.buffer.byteLength)).toEqual([
|
||||
imageBytes.byteLength,
|
||||
imageBytes.byteLength,
|
||||
imageBytes.byteLength,
|
||||
]);
|
||||
});
|
||||
|
||||
it("still rejects oversized Google image JSON responses", async () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: "x".repeat(35 * 1024 * 1024) }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("google.image-generation: JSON response exceeds");
|
||||
});
|
||||
|
||||
it("sends reference images and explicit resolution for edit flows", async () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
const fetchMock = installGoogleFetchMock();
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// Google provider module implements model/runtime integration.
|
||||
import {
|
||||
generatedImageAssetFromBase64,
|
||||
resolveInlineImageJsonResponseMaxBytes,
|
||||
type GeneratedImageAsset,
|
||||
type ImageGenerationProvider,
|
||||
} from "openclaw/plugin-sdk/image-generation";
|
||||
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
@@ -22,6 +25,8 @@ import { normalizeGoogleModelId, resolveGoogleGenerativeAiHttpRequestConfig } fr
|
||||
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
|
||||
const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
|
||||
const DEFAULT_OUTPUT_MIME = "image/png";
|
||||
const GOOGLE_MAX_IMAGE_RESULTS = 4;
|
||||
const MB = 1024 * 1024;
|
||||
const GOOGLE_SUPPORTED_SIZES = [
|
||||
"1024x1024",
|
||||
"1024x1536",
|
||||
@@ -49,6 +54,16 @@ function normalizeGoogleImageModel(model: string | undefined): string {
|
||||
return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL);
|
||||
}
|
||||
|
||||
function resolveGeneratedImageMaxBytes(req: {
|
||||
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
|
||||
}): number {
|
||||
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return Math.floor(configured * MB);
|
||||
}
|
||||
return MAX_IMAGE_BYTES;
|
||||
}
|
||||
|
||||
function mapSizeToImageConfig(
|
||||
size: string | undefined,
|
||||
): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined {
|
||||
@@ -149,14 +164,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxCount: 4,
|
||||
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
|
||||
supportsSize: true,
|
||||
supportsAspectRatio: true,
|
||||
supportsResolution: true,
|
||||
},
|
||||
edit: {
|
||||
enabled: true,
|
||||
maxCount: 4,
|
||||
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
|
||||
maxInputImages: 5,
|
||||
supportsSize: true,
|
||||
supportsAspectRatio: true,
|
||||
@@ -231,7 +246,12 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
|
||||
try {
|
||||
await assertOkOrThrowHttpError(res, "Google image generation failed");
|
||||
|
||||
const payload = await res.json();
|
||||
const payload = await readProviderJsonResponse(res, "google.image-generation", {
|
||||
maxBytes: resolveInlineImageJsonResponseMaxBytes(
|
||||
GOOGLE_MAX_IMAGE_RESULTS,
|
||||
resolveGeneratedImageMaxBytes(req),
|
||||
),
|
||||
});
|
||||
let imageIndex = 0;
|
||||
const images: GeneratedImageAsset[] = [];
|
||||
for (const part of googleResponseParts(payload)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
type ProviderRequestTransportOverrides,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
@@ -97,11 +98,11 @@ async function generateGeminiInlineDataText(params: {
|
||||
try {
|
||||
await assertOkOrThrowProviderError(res, params.httpErrorLabel);
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
const payload = await readProviderJsonResponse<{
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ text?: string }> };
|
||||
}>;
|
||||
};
|
||||
}>(res, params.httpErrorLabel);
|
||||
const parts = payload.candidates?.[0]?.content?.parts ?? [];
|
||||
const text = parts
|
||||
.map((part) => part?.text?.trim())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Google tests cover media understanding provider.video plugin behavior.
|
||||
import { createServer, type Server } from "node:http";
|
||||
import {
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
@@ -10,6 +11,49 @@ import { resolveGoogleGenerativeAiHttpRequestConfig } from "./runtime-api.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
|
||||
|
||||
async function listenLoopbackServer(server: Server): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("expected loopback TCP address"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
|
||||
let resolveClosed: (sentBytes: number) => void = () => {};
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
const server = createServer((_req, res) => {
|
||||
let sentBytes = 0;
|
||||
const chunk = Buffer.alloc(64 * 1024, 0x20);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
const timer = setInterval(() => {
|
||||
if (sentBytes >= LOOPBACK_RESPONSE_BYTES) {
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
sentBytes += chunk.length;
|
||||
res.write(chunk);
|
||||
}, 1);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
resolveClosed(sentBytes);
|
||||
});
|
||||
});
|
||||
return { server, closed };
|
||||
}
|
||||
|
||||
describe("describeGeminiVideo", () => {
|
||||
it("respects case-insensitive x-goog-api-key overrides", async () => {
|
||||
let seenKey: string | null = null;
|
||||
@@ -114,6 +158,29 @@ describe("describeGeminiVideo", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds oversized video JSON responses and closes the stream early", async () => {
|
||||
const { server, closed } = createOversizedJsonServer();
|
||||
const port = await listenLoopbackServer(server);
|
||||
const fetchFn = withFetchPreconnect(async () =>
|
||||
fetch(`http://127.0.0.1:${port}/google-video-json`),
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
describeGeminiVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
fetchFn,
|
||||
}),
|
||||
).rejects.toThrow(/JSON response exceeds 16777216 bytes/u);
|
||||
await expect(closed).resolves.toBeLessThan(LOOPBACK_RESPONSE_BYTES);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-Google video base URLs before sending authenticated requests", async () => {
|
||||
await expect(
|
||||
describeGeminiVideo({
|
||||
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
let buildGoogleSpeechProvider: typeof import("./speech-provider.js").buildGoogleSpeechProvider;
|
||||
let testing: typeof import("./speech-provider.js").testing;
|
||||
|
||||
const GOOGLE_TTS_JSON_CAP_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildGoogleSpeechProvider, testing } = await import("./speech-provider.js"));
|
||||
});
|
||||
@@ -56,6 +58,26 @@ function installGoogleTtsRequestMock(pcm = Buffer.from([1, 0, 2, 0])) {
|
||||
return postJsonRequestMock;
|
||||
}
|
||||
|
||||
function oversizedGoogleTtsJsonResponse(onCancel: () => void): Response {
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(GOOGLE_TTS_JSON_CAP_BYTES + 1));
|
||||
},
|
||||
cancel() {
|
||||
onCancel();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" }, status: 200 },
|
||||
);
|
||||
Object.defineProperty(response, "json", {
|
||||
value: async () => {
|
||||
throw new Error("unbounded json reader was used");
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Expected record");
|
||||
@@ -149,6 +171,39 @@ describe("Google speech provider", () => {
|
||||
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds oversized Gemini TTS success JSON responses and cancels the stream", async () => {
|
||||
let cancelCount = 0;
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
response: oversizedGoogleTtsJsonResponse(() => {
|
||||
cancelCount += 1;
|
||||
}),
|
||||
release,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: oversizedGoogleTtsJsonResponse(() => {
|
||||
cancelCount += 1;
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "oversized tts response",
|
||||
cfg: {},
|
||||
providerConfig: {
|
||||
apiKey: "google-test-key",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 12_000,
|
||||
}),
|
||||
).rejects.toThrow("Google TTS response: JSON response exceeds 16777216 bytes");
|
||||
expect(cancelCount).toBe(2);
|
||||
expect(release).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("transcodes Gemini PCM to Opus for voice-note targets", async () => {
|
||||
installGoogleTtsRequestMock(Buffer.from([5, 0, 6, 0]));
|
||||
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("google-opus"));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
@@ -503,7 +504,11 @@ async function synthesizeGoogleTtsPcmOnce(params: {
|
||||
}
|
||||
}
|
||||
try {
|
||||
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
|
||||
const payload = await readProviderJsonResponse<GoogleGenerateSpeechResponse>(
|
||||
res,
|
||||
"Google TTS response",
|
||||
);
|
||||
return extractGoogleSpeechPcm(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new GoogleTtsRetryableError(message);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user