* fix(config): use Object.hasOwn in restoreEnvVarRefs to avoid prototype pollution
When key comes from Object.entries(incoming) on user config data,
'key in parsed' traverses the prototype chain. If a config key
collides with an Object.prototype property (e.g. 'toString'),
the check incorrectly returns true and calls restoreEnvVarRefs
with a prototype function instead of a config value.
Replace with Object.hasOwn(parsed, key) to check only own properties.
* test(config): prove inherited env refs are ignored
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This reverts commit 39528edd74.
That push was an accident: the fs-import fix it described had already landed
on main, and a rebase reduced the commit to unrelated in-progress
gateway-protocol schema edits (nodes/session-placement) that were staged in
the pushing worktree and belong to another work stream. Restore the reviewed
main state; the protocol work can land through its own PR.
* fix(shared): reject HTTP status codes outside the 100-599 range
extractHttpStatusMatch only checked Number.isFinite, so 3-digit
sequences such as "000", "099", "600", and "999" were accepted as
valid HTTP statuses. Downstream code then surfaced them as "HTTP 0"
in user-visible messages and fed them into retry/classification
branches (e.g. isCloudflareOrHtmlErrorPage checks `code < 500` and
`code < 600`). Restrict the extracted code to the IANA HTTP status
code range (100-599).
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* fix(shared): validate all parsed HTTP status prefixes
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(config): restore process.env after invalid config rejection
When config validation fails, env vars from the rejected config
(env.vars in openclaw.json) leaked into process.env and were never
cleaned up. In long-running gateways this meant rejected config
values could persist until process restart.
Add restoreEnvChangesIfUnchanged calls in both sync (loadConfigLocal)
and async (readConfigFileSnapshotInternal) validation-failure paths,
matching the pattern already used by suspicious-config recovery paths.
* fix(config): restore env before observer and on duplicate-agent-dir rejection
- Add restoreEnvChangesIfUnchanged before DuplicateAgentDirError throw
in the sync loadConfigLocal path so env vars from rejected config
do not leak when duplicate agent directories are detected.
- Move restoreEnvChangesIfUnchanged before observeLoadConfigSnapshot
in the sync validation-failure path so config health/audit
observation does not see rejected env values.
- Add entry-point regression tests exercising sync loadConfig,
async readConfigFileSnapshot, and duplicate-agent-dir rejection
paths through createConfigIO with real config files.
* fix(config): restore env after invalid snapshot
Co-authored-by: xialonglee <li.xialong@xydigit.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(gateway): bound usage.list days parameter to 100 years
parseDays accepted any finite number including 1e300, which downstream
resolveTrailingDays would feed into Date arithmetic as a day offset,
overflowing the ECMAScript Date range and surfacing as a misleading
"calendar day does not exist in requested time zone" error. Reject
day counts above 100 years (36600); callers wanting truly unbounded
history should use `range: "all"`.
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* fix(gateway): clamp oversized usage day ranges
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
#101773 removed the node:fs/promises import while switching plugin manifest
reads to fs-safe helpers, but getSkillCodeSafetySummary (added later by
#109363) still reads SKILL.md via fs.readFile. Main has been red since the
merge (check-prod-types, check-lint, check-test-types, package-boundary
compile, compact-large-5). Restore the import; skill-file reads keep their
pre-existing unbounded contract.
* fix(cli): restore terminal state before exit in logs and hooks commands
* fix(cli): route logs/hooks error exit through canonical defaultRuntime.exit
* fix(cli): route terminal reset to stderr in JSON mode to keep stdout parseable
* fix(cli): centralize stream-aware terminal reset exit
Add optional resetStream parameter to RuntimeEnv.exit so JSON-mode
callers can route the terminal reset to stderr through the shared
defaultRuntime.exit path, keeping structured stdout parseable.
- Extend RuntimeEnv.exit signature with optional resetStream option
- Route JSON-mode logs fatal exit through unified defaultRuntime.exit
instead of manually pairing restoreTerminalState with process.exit
- Update test mock to exercise real terminal restore during exit
* refactor(cli): tighten terminal exit contract
Co-authored-by: Peter Lee <li.xialong@xydigit.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
aa0c92186b (#101773) removed the file-wide node:fs/promises import
while converting the plugin-manifest read to fs-safe helpers, but
getSkillCodeSafetySummary still calls fs.readFile for skill content.
Every main push since fails check-prod-types/lint/test-types and the
security shard (TS2304 + runtime ReferenceError). Restore the import;
migrating the skill read to bounded fs-safe helpers is the PR author's
follow-up.
* fix(cli): reject --channel/--to for systemEvent cron jobs with a clear error
* test(cli): add regression coverage for cron-add delivery-option validation
* fix(cli): fix optionSource reference and add chat-delivery guard to cron edit
- Fix bare optionSource() call in cron-add guard to use cmd.getOptionValueSource()
- Add equivalent resulting-job validation to cron edit: reject --channel/--to/--account/--thread-id when the resolved job is a main-session systemEvent
- Add parity tests covering fast-path reject, existing-job reject, isolated allow, and clear-flag allow cases
* test(cli): return a real CronJob from default cron.get mock
* fix(cli): reject dropped cron delivery targets
Co-authored-by: Peter Lee <li.xialong@xydigit.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
parseAuditCursor in audit.ts did not trim whitespace before running the
digit regex, while the parallel parseCursor in tasks.ts did. A client
passing " 11 " (e.g. URL-decoded from query strings) would get a 400
INVALID_REQUEST instead of the correct page. Trim the cursor before
validation so behavior matches tasks.list and the gateway cursor contract.
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* feat(ui): drop the model-auth-expiring sidebar chip
The warning-severity expiring chip nagged for OAuth tokens that roll
continuously; the error-severity expired chip stays and still surfaces
actually broken auth.
* feat(ui): let ledge visits sometimes scare the sidebar logo away
Some idle lobster visits now spook the brand mark: a beat after the crab
settles in, the logo fades out (no stand-in), and it pops back when the
visit ends. Seeded 30% roll per arrival; perch visits, offline summons,
and reduced-motion users are exempt.
* fix(vault): keep request deadline through response body
* test(vault): use generic stalled-body fixture
* test(vault): mark fixture token synthetic
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* improve(gateway): restart only the changed account on channel config reload
When a config change is scoped entirely to one channel account
(channels.{kind}.accounts.{accountId}[.*]), restart just that account
instead of the whole channel. Wholesale channel restarts disconnect every
account on the channel; on gateways running many accounts, each
account-scoped config write (adding an account, changing one account's
settings) briefly dropped every other account's connection.
The reload plan gains a restartChannelAccounts bucket populated only when
all of a channel's changed paths are account-scoped; any channel-global
path falls back to the existing wholesale restart, and a channel scheduled
for wholesale restart drops its per-account entries so each (channel,
account) pair restarts at most once. The executor runs per-account
restarts through the existing stopChannel/startChannel accountId parameter.
* fix(gateway): preserve account reload admission
* fix(gateway): scope surgical reloads to isolated plugins
* test(gateway): avoid preactivating reload snapshot
* docs(channels): define account reload isolation contract
* fix(gateway): preflight scoped account reloads
* fix(gateway): re-drain live scoped reload targets
* docs: refresh generated docs map
* fix(mattermost): align scoped reload with durable ingress
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>