* fix(cron): trim trailing whitespace from recognized job object keys (#95407)
Some tool-call extraction/serialization pipelines can produce cron object
keys with trailing spaces (e.g. 'schedule ' instead of 'schedule'), causing
gateway validation to reject the job.
Add repairPaddedCronKeys() to canonicalizeCronToolObject() that trims only
recognized CRON_RECOVERABLE_OBJECT_KEYS. Non-recognized keys (including
special ones like '__proto__') are never trimmed, preventing prototype
pollution. When both padded and canonical forms exist, the canonical key
wins.
Tests:
- add job with trailing-space keys -> trimmed
- update patch with trailing-space keys -> trimmed
- non-recognized padded keys left intact (safety)
- canonical key preserved over padded duplicate
- clean keys unchanged
133 tests pass (128 existing + 5 new).
* fix(cron): preserve padded duplicate keys when canonical form already exists (#95407)
When both a padded key (e.g. 'schedule ') and its canonical form
('schedule') exist, the padded key is now preserved so strict gateway
validation rejects the ambiguous input rather than silently picking one
value. Only padded keys without a canonical counterpart are trimmed.
* fix(gateway.tls): reject empty/whitespace certPath and keyPath
gateway.tls.certPath and keyPath both accept "" and whitespace-only
strings at the schema layer (z.string().optional() with no .min(1)), and
the runtime fallback cfg.certPath ?? path.join(baseDir, "...") only
triggers on null/undefined, so empty strings reach generateSelfSignedCert
unchanged. From there path.dirname("") === "." and openssl receives
"-out "" -keyout """, producing a cryptic error.
Sibling field caPath already guards against this via truthy check, so
this brings certPath/keyPath to the same defensive style.
Three changes:
1. Schema: certPath/keyPath tightened to z.string().trim().min(1).optional()
2. Runtime: replace ?? with explicit truthy check, aligning with caPath
3. chmod errors now throw instead of .catch(() => {})
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: add :unknown type to catch callback variables
* fix(gateway.tls): restore best-effort chmod for generated cert/key
* fix(gateway.tls): preserve non-empty cert/key path bytes
Schema z.string().trim().min(1) and the runtime cfg.certPath.trim() both
trimmed non-empty paths. The schema trim silently rewrote validated config
data, and the runtime trim duplicated resolveUserPath, which already trims
and expands ~ in resolveHomeRelativePath.
Keep blank/whitespace rejection, drop the transformation: schema uses
.refine (validate only), runtime passes the original string to resolveUserPath.
Non-empty paths keep exact bytes; blank values are still rejected/defaulted.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Move iOS accent and status colors through design tokens so raw SwiftUI color literals are blocked outside token definitions.
Set the app-wide tint in SwiftUI and UIKit from code, without relying on Assets.xcassets AccentColor.
* fix(opencode-go): abort stalled SSE streams at provider-owned raw boundary
opencode-go routes through the shared OpenAI-compatible completions provider,
where a stalled SSE socket (provider emits tokens then never closes the stream)
hangs the gateway until stuckSessionAbortMs (~622s) and surfaces as
'LLM request failed' / 'Request was aborted'. Issue #93610 reports ~90% of
opencode-go cron jobs failing intermittently this way.
Add a provider-owned stream wrapper at the opencode-go raw SSE boundary that
injects an AbortController into the underlying OpenAI SDK request and aborts
it after a configurable idle window (default 30s, far below 622s) elapses
without any forward-progress event. The wrapper is:
- Provider-scoped: only applies when model.provider === 'opencode-go'; the
shared openai-completions.ts path is untouched.
- Abortable: calls controller.abort() on the injected AbortSignal, which
propagates through OpenAI SDK requestOptions.signal and genuinely
interrupts the underlying fetch/stream (not just iterator return()).
- Idle-based: every event (text/tool/thinking delta, including delayed
usage-only chunks) refreshes the timer; natural completion (done/error)
cancels it. Normal delayed usage-only completion is preserved.
- Boundary-terminal: pushes a terminal { type: 'error', reason: 'aborted' }
event downstream so consumers do not hang.
TDD: stream-termination.test.ts covers (a) stalled stream after first
progress is aborted within the idle window with a downstream 'aborted'
terminal event, and (b) normal delayed completion within the idle window
is not aborted and the done event is forwarded unchanged.
* fix(opencode-go): align stalled-stream idle default with runtime (120s)
Match the runtime's shared `DEFAULT_LLM_IDLE_TIMEOUT_MS` (120s) so
non-cron interactive opencode-go runs see no behavior change versus the
existing watchdog. Cron runs — for which the runtime disables its idle
watchdog entirely (`resolveLlmIdleTimeoutMs` returns 0 when trigger is
cron and no explicit timeout is set) — still get provider-owned
termination well before the ~622s stuck-session recovery.
Refs #93610
* fix(opencode-go): satisfy CI lint and test type checks
- Remove unnecessary `?? {}` fallback in spread (oxlint
no-useless-fallback-in-spread).
- Drop non-narrowing `!` on the wrapper return type; use
`await Promise.resolve(...)` to collapse the
`StreamLike | Promise<StreamLike>` union before `for await`.
Refs #93610
* fix(opencode-go): arm stalled-stream idle timer only after first event
The wrapper armed the idle timer before the first upstream event, which
would mis-abort slow time-to-first-byte requests — including the
opencode-go cron runs that the runtime deliberately leaves uncapped via
resolveLlmIdleTimeoutMs. Arm only after the first forwarded event, and
add regression coverage for the slow-first-event path.
* fix(opencode-go): cover stalled stream first event
* fix(opencode-go): respect explicit stream timeout
* fix(opencode-go): preserve first-event timer after synthetic start
* fix(opencode-go): satisfy stream termination test lint
* fix(opencode-go): distinguish synthetic stream preambles
* fix(opencode-go): route stalled streams through failover
The generic assistant error text "LLM request failed." (GENERIC_ASSISTANT_ERROR_TEXT) is
produced by formatUserFacingAssistantErrorText when the underlying provider error cannot
be formatted into a specific category. For local providers (LM Studio, Ollama) this wraps
connection/availability failures when the model is not loaded or the endpoint is unreachable.
Without this match, the error is not classified as any transient type (rate_limit, overloaded,
network, server_error, timeout), so cron retry and payload.fallbacks never engage — even
though the configured fallback chain should handle provider availability failures.
Add /^llm request failed\.$/i as an exact-match regex in the timeout error patterns. This
strictly matches only the bare "LLM request failed." string, not variants like
"LLM request failed: provider rejected the request schema or tool payload." (which is a
format/schema error, not transient). Variants with specific transient reasons (connection
refused, network error, etc.) are classified through their own existing patterns.
Closes#93931
The `image edit` CLI command could not request multiple edited images while
the sibling `image generate` could, even though the shared runImageGenerate
action and generateImage thread `count` for both capabilities and providers
(xai, litellm, openai) honor edit-mode count (edit.maxCount 4). PR #94156
added --quality/--openai-moderation to both commands but left --count off
edit only. Add --count to the edit command registration, action, and
CAPABILITY_METADATA, mirroring image generate exactly.
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add OPENROUTER_SHORT_TO_API_MODEL_ID map for short model refs like
openrouter/deepseek-v4-flash that OpenClaw surfaces but OpenRouter API
expects as deepseek/deepseek-v4-flash.
- In normalizeOpenRouterApiModelId, expand short refs before falling back
to the existing namespaced strip logic.
- Add unit tests covering short refs, long refs, native routes, and
pass-through cases.
- Add standalone reproduction script that verifies all normalization cases.
Voice/audio messages sent to Feishu (opus) play fine but show no duration
on the bubble. Feishu derives the voice-bubble duration from the `duration`
parameter of the file upload API (`im/v1/files`); the audio message content
only carries `{file_key}` and has no duration field, so the duration was
never set.
`sendMediaFeishu` now probes the outgoing audio with `ffprobe` and passes the
result as the upload `duration` (ms). It probes the buffer that is actually
sent (after the existing voice transcode, which caps length via
`MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS`), so the reported length matches what
is played. Probing is best-effort: on failure it logs and omits the duration,
and the message still sends. The audio message content is unchanged.
Fixes#53798
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The persisted iMessage echo-dedupe cache normalized text with CRLF->LF + trim only, not the leading attributedBody corruption-marker stripping the in-memory echo cache applies (#93511). The persisted 12h cache is the only matcher once the 4s in-memory text TTL expires, so a delayed reflected own-message echo whose text decoded with a leading NUL/replacement/BOM marker did not match the clean stored send -- the agent's own message was re-ingested as fresh inbound, causing a self-reply loop.
Extract the marker-stripping into a leaf module shared by both echo caches (the in-memory cache already imports the persisted one, so importing back would be a cycle) and apply it in the persisted normalizeText, so both caches strip identically.
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>