Creating a cron job over a connection with no tenant in context
(gateway-token / CLI / master-scope UI session) panicked with a
nil-pointer dereference.
Root cause — tenant asymmetry between insert and readback:
- AddJob INSERT uses tenantIDForInsert(ctx), which falls back to
MasterTenantID when the context has no tenant (row inserted OK).
- The readback (scanJob / GetJob) rejects a nil-tenant context with
"tenant_id required", so GetJob returned (nil, false) and AddJob
swallowed it as (nil, nil).
- handleCreate then dereferenced job.ID (UpdateJob patch) → panic.
Fix (defense-in-depth):
- handleCreate (internal/gateway/methods/cron.go): add a nil-job guard
before any job.ID use; return ErrInternal instead of panicking. Apply
the post-create stateless/command patch under a tenant-consistent
context (WithTenantID(ctx, job.TenantID)) so lockCronJobForMutation
finds the row for nil-tenant callers.
- AddJob (store/pg + store/sqlitestore cron_crud.go): read back under
WithTenantID(ctx, tenantIDForInsert(ctx)) so insert and readback share
the same tenant; return an explicit error if readback fails instead of
swallowing it.
Tests:
- internal/store/sqlitestore/cron_crud_test.go: AddJob with a nil-tenant
context returns a non-nil job + nil error (real SQLite DB).
- internal/gateway/methods/cron_test.go: handleCreate does not panic and
skips UpdateJob when the store returns (nil, nil).
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
* feat(cron): deterministic command payloads (run a shell command, no LLM)
Cron jobs always run an agent turn today, so deterministic work (health
probes, backups, syncs) pays model tokens on every fire. This adds a
"command" payload kind that runs a shell command directly in the gateway
process with zero model tokens, mirroring openclaw's command cron.
- store: CronPayload.Command (*CronCommandSpec — argv/cwd/env/input/
timeouts/output cap). Persists in the existing payload JSON blob, so
there is NO migration and no schema version bump.
- internal/cronexec: in-process runner with wall-clock + no-output
timeouts, per-stream output capping, and process-group termination so a
timed-out command's forked children are also killed.
- gateway_cron handler: command jobs run in-process and deliver stdout on
success (honoring the NO_REPLY sentinel). A non-zero exit / timeout
returns an error so the run is recorded as error and retried per
cron.max_retries; failures are NOT delivered, mirroring the agent path
(only successful output is announced — no channel spam).
- surfaces: cron.create RPC, the agent `cron` tool, and a new
`goclaw cron create` CLI all accept command payloads.
- security: gated by cron.command_enabled (default false). Commands run
with the gateway process's privileges, so the feature is opt-in per
gateway; when disabled the RPC and tool reject command payloads and the
handler refuses to run them.
- i18n (en/vi/zh), docs (08-scheduling-cron.md), and tests for the runner
and the handler command path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cron): gate command payloads on the update surfaces too
handleUpdate (RPC + agent tool) passed CronJobPatch.Command straight to
UpdateJob, which switches the payload to command kind for any non-nil
Command — without the command_enabled gate or ValidateCronCommandSpec that
create enforces. A normal job could therefore be mutated into a command job
(or persisted with an invalid spec, e.g. empty argv) on a gateway where
command cron is disabled, breaking the disabled-gateway contract.
Both update surfaces now require cron.command_enabled and validate the spec
before UpdateJob, matching create. The agent tool parses the command via the
same path as add and drops the raw keys so a shell-string command can't break
the generic patch unmarshal. Regression tests added for RPC and tool update
(command disabled + invalid argv), plus a positive enabled-valid case.
Addresses review feedback from @mrgoonie on #1279.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>