* fix(doctor): diagnose direct web fetch proxy routing
* test(doctor): cover proxy diagnostic through public API
* test(doctor): validate emitted proxy diagnostic type
Root cause of the flaky 120s timeout in "logs a swallowed finalize error
without blocking the background run" (CI run 29559328261, shard
agentic-gateway-methods): the preceding ACP plugin-subagent test registers a
subagent run whose completion keeps running in the background after the test
ends. That leaked chain (completeSubagentRunAttempt ->
emitSubagentEndedHookForRun -> ensureSubagentRegistryPluginRuntimeLoaded ->
ensureRuntimePluginsLoaded -> loadOpenClawPlugins) starts by calling
clearActivatedPluginRuntimeState(), which clears the process-wide detached
task lifecycle runtime registration. When it lands between the next test's
setDetachedTaskLifecycleRuntime(spy) install and that test's background
finalize, finalizeTaskRunByRunId re-reads the registration at call time,
falls back to the default executor, and finalizes the run successfully - so
the finalize spy is never called while the run itself completes with an ok
frame.
The prior event-driven rework (#109653, 883995f08d) could not fix this: it
only waits longer for a spy call that never comes. The older 2s
waitForAssertion flake ("expected 1 call, got 0") was the same mechanism.
Fix at the harness boundary: gateway agent handler tests now pin the
subagent-registry deps with a no-op ensureRuntimePluginsLoaded (matching
subagent-registry's own suites), applied at harness setup, in the shared
afterEach, and through the one wholesale deps override, so no leaked
ended-run hook can reload the plugin runtime and clear a later test's
runtime seam mid-test.
Proof:
- Deterministic repro (temporary env-gated interleaving patch in
subagent-registry.ts, not committed): hold the ACP plugin-child run's
ended-hook load until the next finalize-only runtime registration appears,
then reload synchronously. Pre-fix this reproduces the exact CI failure
(target times out at 120000ms; registration observed ABSENT right after
the reload). Post-fix, with the identical forced interleaving, all 238
tests pass and the registration stays present.
- Stress post-fix: 10x agent.test.ts and 5x src/gateway/server-methods with
OPENCLAW_VITEST_MAX_WORKERS=6 - 0 failures (3765 tests per shard run).
- The same wipe-then-re-register window exists in prod plugin activation
(loadOpenClawPlugins cache-miss path); flagged separately for an
owner-reviewed atomic-swap follow-up rather than bundled here.
* perf(sdk): load Claude CLI identity from a narrow plugin artifact
src/plugin-sdk/anthropic-cli.ts snapshots CLAUDE_CLI_BACKEND_ID at module
scope through the sync facade loader, which jiti-evaluates the full
anthropic api.js barrel on source checkouts: 130.86s of self time per
cold worker on CI (Testbox import profile), silently stalling every job
whose graph reaches cli-runner/prepare.ts. A narrow cli-api.js artifact
carries the two static facts; Testbox proof: the reliability+helpers
agents-core pair drops from 157.6s to 14.3s.
* perf(ci): stripe unit-fast and tooling node tests on the full plan
core-fast ran the import-bound unit-fast graph as one job (247s vitest,
181s module evaluation) and core-tooling as one serial job (241s); both
now stripe on the full plan like the compact plan, so the compact-only
expansion is deleted and the docker helper config rides with the
isolated shard on both plans. Group hints refreshed from main run
29551077288; stale per-file commands hints dropped (the consolidation
landed); cache-writer selection and the warm workflow track the stripe
names.
* fix(state): tolerate vanished sqlite sidecars in agent-db permission sweep
existsSync+chmodSync raced SQLite's own WAL/SHM cleanup: a checkpoint or
close between the two calls throws ENOENT (observed from the transcript
reconcile worker in server-startup-web-fetch-bind on CI). chmod directly
and swallow only ENOENT, which removes the TOCTOU window.
Stream bounded browser camera frames directly to Gemini Live and keep camera media off the Gateway. Add lifecycle, function-calling, fake-camera E2E, and live-smoke coverage.
Co-authored-by: shushushu <1064076525@qq.com>
* fix(ai): track Responses output items per index and require terminal stream events
* test(ai): add live Responses stream coverage
* test(infra): mirror packages live glob in live-config test
* fix: queued MEDIA attachments survive delivery retries
Local media carried in a legacy MEDIA: text directive was invisible to
queue staging (which reads only structured mediaUrl(s)), so the raw
directive was persisted with no queue-owned copy and a retry read the
already-deleted producer path — burning the retry budget and dropping the
attachment plus its text.
Project each source payload's effective media through the canonical
createOutboundPayloadPlan before staging and fold directive-derived local
sources into the queue copy's structured fields, so the existing spool
takes custody. The custody copy anchors both mediaUrl and mediaUrls to the
effective set so the staged copy overrides the preserved in-text directive
on replay; the rendered-batch plan is recomputed from the deduplicated
effective media to avoid count inflation. Raw pre-hook text, the copy-free
live send, the same media capability, and sensitive-media fail-closed are
all unchanged. No schema/config/TTL change.
Extends the ownership invariant from #108501 / #108502 to the legacy
MEDIA: text-directive carrier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(outbound): localize queue media custody
Trim the direct helper matrix in favor of end-to-end queue recovery proof.
Co-authored-by: Masato Hoshino <g515hoshino@gmail.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(plugins): canonicalize manifest plugin ids to lowercase
Plugin config policy lists (plugins.deny, plugins.allow, plugins.entries)
are lowercase-normalized through normalizePluginId, but a plugin's
self-declared manifest id was only trimmed, never lowercased. A plugin
publishing "id": "Malicious-Scraper" therefore never matched an operator
denylist entry of "malicious-scraper" and fell through to default
activation, loading its hooks, channels, secret integrations, and tools.
The same gap let a mixed-case spelling evade the core reserved-id check.
Canonicalize the id at the manifest parse boundary where
PluginManifestRecord.id is minted, so every downstream policy consumer
compares against the same canonical form.
* test(plugins): prove mixed-case deny at gateway startup
* test(plugins): assert denied gateway record is absent
* test(plugins): materialize allowed secret fixture
* fix(plugins): compare a derived policy key instead of rewriting manifest identity
Manifest ids stay exactly as declared. Deny, allow, and per-entry checks now
compare a lowercase policy key derived at each enforcement boundary, matching
the lowercase-normalized config lists.
The previous approach lowercased PluginManifestRecord.id at the parse boundary.
That id is also matched against the plugin runtime export id, and a mismatch is
a hard load failure, so an existing plugin declaring the same mixed-case id in
both its manifest and its runtime export would stop loading after upgrade.
The Gateway fixture now declares a mixed-case manifest id with a matching
mixed-case runtime export, covering that upgrade case, and drops the allowlist
that previously scoped discovery by a lowercase id and masked the denylist
behavior under test.
* fix(plugins): close mixed-case policy gaps
* test(plugins): use tracked policy fixture temp dir
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(acp): persist failed bound turns so transcripts match the channel
A bound ACP turn that failed delivered an error reply to the channel but
wrote nothing to the session transcript: persistAcpDispatchTranscript ran
only on the success path, and turnPromptText was block-scoped inside the
try so the catch could not reach it. The bound transcript then silently
diverged from what the user saw, and the next turn resumed from history
that never mentioned the failure. Persist failures were also swallowed at
verbose level.
Persist exactly once, after the turn's outcome settles:
- success writes after finalization, so a finalizer failure is no longer
recorded as a clean success;
- failures record the streamed output ahead of the error text, snapshotted
before delivery so the accumulator cannot fold the error in twice;
- a dispatch flag (not prompt-text truthiness) gates the failure write, so
attachment-only turns are covered and pre-dispatch setup failures do not
mint blank-prompt records.
Found while live-testing the ACP connectors: codex and gemini bind lanes
pass 5/5, claude times out with turns that complete but never appear in the
bound transcript. This fixes the silent-divergence class and makes that
failure observable; the claude-specific trigger is tracked separately.
* fix(acp): narrow acp resolution before reading meta in the shared persist helper
When the Cloudflare docs search API returns a non-OK status,
fetchDocsSearch threw without cancelling the response body.
This could leave the underlying connection open until GC.
Cancel the body before throwing, matching the pattern established
in reef guard adapters (#109196) and fetchOpenRouterModels.
* fix(video-generation): bound stalled DashScope download body reads
* test(video-generation): narrow DashScope download asset for tsgo
* test(video-generation): narrow optional DashScope video buffer for tsgo
* test(video-generation): bind DashScope buffer before tsgo asserts
* fix(video-generation): fail closed on exhausted DashScope download budget
When the operation deadline passes a function that returns 0, the
previous resolver gate (resolved > 0) dropped the exhausted budget
and fell back to the 120s default. Also, readResponseWithLimit skips
chunk idle timeout when chunkTimeoutMs is 0/falsy, so a zero budget
would read the body unbounded instead of failing fast.
Now the resolver preserves zero/negative values and the body stage
throws immediately when the remaining budget is exhausted.
Co-Authored-By: nebulacoder-v8.0 <noreply@zte.com.cn>
* fix(video-generation): reject exhausted download deadline before fetch
Check the resolved download timeout before fetchWithTimeoutGuarded so an
already-exhausted operation does not initiate network I/O. Previously the
deadline guard ran after headers, which still sent bytes over the wire.
Co-Authored-By: nebulacoder-v8.0 <noreply@zte.com.cn>
* fix(video-generation): release failed DashScope downloads
---------
Co-authored-by: nebulacoder-v8.0 <noreply@zte.com.cn>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* refactor(sdk): collapse plan updates to typed steps
Per owner decision, remove the shipped steps: string[] SDK field and its unreleased planSteps replacement early. Collapse onPlanUpdate to one typed steps field. Retain wire-level string normalization for external Codex plugin version skew.
* chore(sdk): tighten rebased surface budget
* perf(ci): cache packaged dependency reify in the docker e2e functional image
Split the shared functional E2E image into manifest -> deps -> app stages so
the 328-package npm reify is layer-keyed on package.json + npm-shrinkwrap.json
alone. Per-PR tarballs (changed dist bytes, unchanged deps) now hit the cached
dependency layer on warm builders (Blacksmith sticky docker layers, local
buildkit) instead of re-running npm install on every build; the per-PR work
drops to manifest extract + package extract + packaged postinstall.
The deps stage reify also matches the shipped shrinkwrap exactly; the previous
npm install -g <tarball> path silently drifted ~15 transitive packages to newer
registry versions and lost the node-domexception -> @nolyfill/domexception
alias. npm install (not npm ci) because the generated shrinkwrap does not
guarantee npm ci's strict manifest sync (p-retry pins @types/retry@0.12.0 while
the shrinkwrap resolves 0.12.5).
Local proof (Apple Silicon, docker-container builder): per-PR warm image build
59s -> 43s with the 29s npm layer CACHED; lane total 164s -> 139s warm,
229s -> 160s cold; system-agent-first-run lane passes end to end, and the
installed tree matches the old image structure except for the documented
version-drift fixes.
* test: update e2e image guard for the staged dependency layer
The guard pinned the retired npm install -g invocation; assert the new
invariants instead: manifest-keyed deps layer, no global-prefix install,
deps COPY into /app, and postinstall ordered before the self-link so its
prune walks cannot cycle.
* test: stabilize four flaky tests at root cause
- test/non-isolated-runner.ts: move shared-worker cleanup from onAfterRunSuite
to onAfterRunFiles. Vitest early-returns runSuite for files that fail during
collection, skipping onAfterRunSuite, so a crashed sibling left its evaluated
real modules cached and the next file's vi.mock factories silently never
applied — the "res.setHeader is not a function" failures in
http-utils.authorize-request.test.ts. onAfterRunFiles fires per file
regardless of collect/run outcome. Regression meta-test spawns a child
vitest run (collect-crash sibling + mock-dependent file) and fails on the
old runner with flavor:real vs flavor:mocked.
- src/gateway/http-utils.authorize-request.test.ts: export every http-common
binding http-auth-utils imports so the factory stays isolate:false-safe.
- src/gateway/session-message-events.test.ts: drop the beforeAll 60s override;
the suite harness cold-imports the full gateway server graph, which can
legitimately exceed 60s under contention. Sibling suites use the shared
project hookTimeout (120s/180s) for the same boot.
- src/gateway/server-methods/agent.sessions-and-models.test-utils.ts: the
finalize-throw test polled a 2s waitForAssertion for an off-turn background
run; signal finalize via a promise resolved from the spy (event-driven,
bounded by the test timeout) and keep the bounded poll for the follow-up
respond/warn observations.
- ui/src/pages/chat/chat-responsive.browser.test.ts: budget cold-app first
renders at 30s (real-app cases boot a cold Vite dev server whose first
transform can starve past 10s under 6-worker contention) and replace
one-shot isVisible reads with bounded locator waits for the state-driven
hover reveal.
Proof: focused runs green; 5x repeats of the three gateway files and 5x
repeats of chat-responsive with OPENCLAW_VITEST_MAX_WORKERS=6 all green;
runner regression test fails pre-fix, passes post-fix.
* test: isolate runner meta-test child env from GitHub Actions
The child vitest inherited GITHUB_ACTIONS/CI from the runner job, which
turned on ANSI colors (breaking the plain-substring assertions) and let
the child's github-actions reporter emit ::error annotations the parent
job rendered as its own failures. Drop GITHUB_ACTIONS/FORCE_COLOR and set
NO_COLOR, which overrides every tinyrainbow enable path.
* test: wait for parseable pid in tsdown-build pid-file polls
waitForFile existence polling can catch writeFileSync's open-truncate
0-byte window, yielding NaN pids and false isProcessAlive failures (the
same class as #109140). Poll until the file parses to a positive pid;
covers all three sibling pid-read sites in the suite.
* fix(dreaming): drop heartbeat assistant responses from dream corpus
The dreaming ingestion pipeline filters heartbeat user messages
(containing [OpenClaw heartbeat poll]) via sanitizeSessionText, but the
paired assistant response is only dropped when it is the exact string
HEARTBEAT_OK. Local models frequently respond with natural-language
acknowledgments (e.g. "Heartbeat received. Main is active.") which pass
through the sanitizer unchanged and enter the dream corpus as
low-confidence (0.58) memory snippets.
Fix: track heartbeat user message drops in buildSessionEntry and skip
the immediately following assistant response. This cross-message coupling
is safe because the heartbeat prompt pattern is injected by the runtime
and cannot be spoofed by user input, unlike [cron:...] or
System (untrusted): ... patterns (see PR #70737).
Refs: #103720
* test(dreaming): add heartbeat assistant response filter test
* fix(dreaming): use provenance-based heartbeat detection instead of text matching
Replace text-based heartbeat detection (isGeneratedHeartbeatPromptMessage)
with provenance-based authentication. The heartbeat turn now carries
provenance: { kind: "heartbeat" } when persisted to the transcript,
and buildSessionEntry checks for this provenance instead of matching
user-spoofable text content.
Changes:
- src/sessions/input-provenance.ts: Add "heartbeat" to
INPUT_PROVENANCE_KIND_VALUES
- src/auto-reply/reply/get-reply-run.ts: Attach heartbeat provenance
to user turn input when isHeartbeat is true
- packages/memory-host-sdk/src/host/session-files.ts: Check
message.provenance.kind === "heartbeat" instead of text matching
- packages/memory-host-sdk/src/host/session-files.test.ts: Update
heartbeat test to include provenance; add lookalike regression test
This addresses the ClawSweeper review finding [P1] about user-spoofable
text matching. The cross-message coupling is now authenticated by
runtime provenance, not user-typed content.
Refs: #103720
* fix(dreaming): add targeted heartbeat-derived corpus repair
* fix(dreaming): fix TS return type for clearScopedLegacySessionIngestionJson catch
* fix(dreaming): match chunked SQLite seen-state keys by stored scope
* fix(dreaming): repair SQLite-backed session transcript lookup in doctor
The findHeartbeatContaminatedCorpusLines function could not read
SQLite-backed session transcripts. When a corpus ref had no .jsonl
extension (SQLite logical path format), it appended .jsonl, read an
empty file, and silently skipped — leaving pre-fix heartbeat
contamination intact for SQLite users.
Fix:
- Add loadTranscriptLinesFromSqlite helper that reads events via
the canonical loadTranscriptEventsSync API
- Serialize all events to preserve original event positions for
corpus #L<n> line-number references
- Fall back to SQLite reader when filesystem read fails on a
non-.jsonl corpus ref path
Test: add SQLite-backed session regression test that seeds a
session, writes corpus refs without .jsonl extension, and
verifies both audit detection and targeted repair.
* fix(dreaming): address autoreview P1 and P2 for checkpoint clearing and early return
P1: Remove clearScopedSessionIngestionState call after heartbeat line
removal. Clearing the cursor causes the next ingestion to re-process the
transcript from the beginning, duplicating normal corpus lines that were
deliberately retained.
P2: Remove early return after heartbeat cleanup so the self-ingestion
narrative content check and archiveDiary request still run.
* fix(dreaming): resolve corpus ref session path without duplicate agent ID
The session path in corpus refs is already in the format
'sessions/main/abc.jsonl' (includes agent subdirectory). The
previous code prepended source.agentId again, creating a path
like 'agents/main/sessions/main/abc.jsonl' — which silently
returned empty in production workspaces.
Fix: use path.basename to extract just the filename from the
session path, then construct the transcript path correctly.
* refactor(dreaming): split repair utils into separate file to satisfy LOC ratchet
The dreaming-repair.ts file grew from 337 to 831 lines, exceeding the
500-line LOC ratchet limit. Split helper utilities into a new
dreaming-repair-utils.ts file:
- dreaming-repair.ts (365 lines): imports, types, public API functions
(auditDreamingArtifacts, repairDreamingArtifacts)
- dreaming-repair-utils.ts (498 lines): all helper functions, constants,
and internal types
* fix(ts): add missing type imports for return types in dreaming-repair.ts
* fix(deadcode): remove unused exports from dreaming-repair-utils.ts
* fix: resolve merge conflict marker in get-reply-run.ts
* fix: remove unnecessary export from INPUT_PROVENANCE_KIND_VALUES
* fix(dreaming): converge targeted repair path with wholesale archive+clear
When heartbeat contamination is found, archive the entire session-corpus directory and clear all SQLite checkpoints instead of doing targeted line-by-line rewrite. This ensures the cleaned corpus gets re-ingested under the new provenance-based forward filter.
* fix(dreaming): authenticate heartbeat transcript turns
Co-authored-by: Erick Kinnee <1707617+ekinnee@users.noreply.github.com>
---------
Co-authored-by: Erick Kinnee <erick@ekinnee.dev>
Co-authored-by: Erick Kinnee <ekinnee@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
`config file`, `config patch --dry-run --json`, and `config unset --json` ran
the config path through shortenHomePath, emitting `~/…` or the literal
`$OPENCLAW_HOME/…` token in machine output — breaking `cat $(openclaw config
file)` and corrupting JSON path fields. `agents list --json` already resolves.
Emit the raw resolved snapshot path at these machine-output sites; human table
rendering keeps the friendly abbreviation.