Skip to content

Acceptance harness and pre-release hardening - #6

Merged
dennisdornon merged 39 commits into
mainfrom
feat/acceptance-harness
Jul 22, 2026
Merged

Acceptance harness and pre-release hardening#6
dennisdornon merged 39 commits into
mainfrom
feat/acceptance-harness

Conversation

@dennisdornon

@dennisdornon dennisdornon commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What this is

33 commits preparing @mainwp/control for the next release. Three arcs:

Acceptance harness (tests/acceptance/). Packs the tarball into a fresh consumer (or runs from source) and drives the installed mainwpcontrol binary through 20 scenarios against fixture and live Dashboards: read cross-checks against an independent verifier, error contracts, and safety flows proven by mock-server request recording (dry-run previews once and never confirms, --force skips only the prompt, failed preview fails closed). A graded agent layer drives claude -p through four scenarios and grades the ordered CLI argv plus final answer against ground truth computed before the model runs. Live-writes baseline: 17 passed / 0 failed / 3 skipped (destructive scenarios are fixture-only).

Release-audit remediation. Five fix/verify rounds against Codex's pre-release audit, each round independently re-verified with direct probes. The headline items:

  • Keychain credentials are stored bound to the profile's canonical Dashboard identity. Repointing a profile at a different host in profiles.json gets an AuthError, not the password.
  • Destructive confirms are fail-closed end to end: a dispatch-stage audit entry before every confirm call, and a transport failure after dispatch exits 3 (OUTCOME_UNKNOWN) with an audit record instead of a generic network error.
  • Remote schemas never contribute regexes to ajv: pattern/patternProperties are stripped structurally from every tree node, and the whole tree (arrays and literal data included) counts against a 32-level depth budget.
  • Provider boundaries: tool results are key-redacted before entering provider-bound history, redirects are refused, hosted providers refuse http:// base URLs.

Post-audit polish pass (final commit before the changelog). fsync-before-rename atomic writes, prototype-pollution-safe sanitizers, bidi control stripping, bounded audit free text, case-sensitive ability-name validation, explicit live-test gating, and subprocess/artifact bounds in the harness.

Breaking change

Existing beta users must run mainwpcontrol login once per profile: unbound credentials stored by earlier versions are refused for authenticated requests. The changelog leads with this.

Not in this PR

Version bump, tag, and npm publish happen after merge.

Verification

  • npm run typecheck, npm run lint (0 errors), npm test including the process suite: 938/938 passing
  • npm run build and git diff --check clean
  • Live acceptance baseline 17/0/3 against the testbed Dashboard
  • Codex verified the remediation state at 8e4ab76 with direct probes; the polish pass on top has matching tests for every change

Summary by CodeRabbit

  • New Features
    • Added/expanded acceptance-test harness scenarios (agent and write flows) and documentation.
    • Added more CLI --json/envelope contract coverage and deterministic verification tooling.
  • Bug Fixes
    • Improved job/batch status parsing (including cancelled) and stricter API response validation.
    • Refined jobs watch/batch timeout and improved SIGINT handling to consistently exit with 130.
  • Security
    • Strengthened redaction and sanitization for errors, config/doctor/login output, and destructive execution safety/auditing.
  • Documentation
    • Updated terminal guides (Windows/PowerShell JSON input), cron instructions, and required Node.js version (20.18.1+).
  • Chores
    • Tightened CI Node.js test matrix and added test-results/ to .gitignore.

…ecurity bumps

Findings from a full-codebase quality review, all three tiers approved.

Bug fixes:
- Chat context truncation could orphan a tool result mid tool-calling loop
  (unsafe fallback cut), causing provider 400s on the next call. Truncation
  logic extracted into ContextWindow with a strict user-boundary cut rule
  that defers truncation when no safe boundary exists.
- Caller-cancelled HTTP requests reported as "Request timed out"; now
  attributed correctly as "Request cancelled".
- Keychain delete failures were silent in non-TTY (CI) runs.
- Dangling activeProfile now warns instead of silently self-correcting.

Security / consistency:
- Sensitive-key redaction unified into src/utils/redaction.ts (was 3
  divergent implementations; the debug-output one missed compound keys
  like apiToken). Normalized substring matching over a superset list.
- DESTRUCTIVE_PATTERNS broadened (reset-, restore-, rollback-, wipe-,
  purge-, uninstall-) with documented verb-conservative rationale.
- Control-flag strip (dry_run/confirm/user_confirmed) centralized in one
  buildEffectiveParams path shared by POST body and GET/DELETE query.
- undici 7.24 -> 7.28.0 (TLS validation bypass + queue poisoning fixes),
  @oclif/core 4.0 -> 4.11, plugin-help 6.0 -> 6.2, fast-uri 3.1.3.
  npm audit now reports 0 vulnerabilities.
- login.ts output sanitized with stripControlChars like other paths.
- Exit 130 (SIGINT) documented as intentional carve-out in README.

Tests:
- InputSanitizer enforcement (size/depth/array/key limits, error-message
  redaction) now actually tested; was zero coverage on the control itself.
- jobs/watch.test.ts rewritten against real exports instead of local
  re-implementations (was tautological).
- New ContextWindow unit tests incl. mid-tool-loop orphan regression.
- AbortError cancel-vs-timeout attribution regression tests.
- Shared createCommandHarness() replaces 4 duplicated e2e factories.

Tidiness:
- Dead code removed: estimateTokens/getContextStats/maxContextTokens
  plumbing, ExponentialBackoff.getDelayForAttempt.
- doctor + config show migrated to shared formatter helpers
  (formatDivider/formatSection/formatStatusIcon), output unchanged.
- Anthropic/Gemini providers: shared splitSystemMessage(), redundant
  makeRequest wrappers removed, DEBUG-gated SSE parse-skip logging.
- getExecutor/getBatchManager share one cached client config (single
  keychain lookup per process).

Verification: typecheck clean, lint 0 errors, 695 tests passing across
unit + process suites (same 6 pre-existing live-Dashboard failures as
main), npm audit clean, doctor/config-show/--json output verified.

Claude-Session: https://claude.ai/code/session_011xJvnx5BXFSfETmsWgdbKD
…oval-boundary truncation, transport/policy consistency

Three findings from a Codex adversarial review of 454a2dc, verified against
source before acting.

Finding 1 (high) — control-flag strip bypassable via PHP bracket
canonicalization. buildEffectiveParams() stripped only exact keys, so a key
like `confirm]` survived and, on the GET/DELETE query path, serialized to
`input[confirm%5D]=true` which PHP parses back to `input.confirm`. Schema
validation doesn't cover it (no removeAdditional; skipped when an ability has
no input_schema). Fixed at the real chokepoint both CLI and chat share
(InputSanitizer, called by executor.execute on every request): reject any
input key containing `[` or `]`. Also assert dry_run/confirm mutual exclusion
at the executor boundary, not just the flag layer.

Finding 2 (medium) — my earlier truncation fix was incomplete. ChatEngine
injects a synthetic `User approved: yes` user message between an assistant
tool-call and its confirm result; the "cut only before a user message" rule
treated that as a real boundary and could re-orphan the tool result. Refined
findSafeCut: a user message immediately followed by a tool message is the
synthetic approval, not a genuine turn start, so it's not a cut boundary.

Finding 3 (medium) — accepted the consistency half, rejected the removal.
Kept the name-based destructive override (deliberate defense-in-depth against
a server under-reporting destructiveness). Fixed the real inconsistency:
getHttpMethod read raw annotations and could route a destructive-named,
server-marked-readonly ability as GET while policy demanded preview+confirm.
Extracted isKnownDestructiveName() as the single source of truth and routed
HTTP-method selection through the same resolved classification. Corrected the
classify() doc comment that falsely claimed "annotations only, no heuristics".

Tests: bracket-key rejection (sanitizer), method never GET for
destructive-named-readonly + both-flags rejection (executor), approval-boundary
defer + catch-up (ContextWindow). Full suite 702 passing (+7), same 6
pre-existing live-Dashboard failures; typecheck + lint clean.

Claude-Session: https://claude.ai/code/session_011xJvnx5BXFSfETmsWgdbKD
… with policy

- doctor and config show human output now strips terminal escape sequences
  from error- and config-derived text, matching the --json envelope path
  (login/keychain/profile-store warnings got the same treatment)
- getHttpMethod reads annotations with strict boolean checks so a
  non-boolean value (readonly: "true") can't diverge transport from
  SafetyController's validated classification
- debug-context redaction now recurses into arrays, with the 300-char
  string truncation preserved for nested data
- drop redundant sensitive-key entries (api_key, authorization) already
  covered by normalization/substring matching
- replace real username in path-redaction test fixture
- tests: doctor process test proving escape stripping end-to-end,
  string-typed annotation method-selection test, base-command redaction
  unit tests

Claude-Session: https://claude.ai/code/session_011xJvnx5BXFSfETmsWgdbKD
… with safety policy

Codex review MF1 + MF2 sub-points:
- abilities run: a dry_run preview that errors or returns success:false now
  aborts destructive execution (exit 4, audit-logged declined) instead of
  continuing to confirm; the successful preview is rendered before the
  confirmation prompt and included in the JSON envelope. --force skips only
  the prompt, never the preview.
- abilities run --wait: failed/partial terminal batch statuses now exit 4
  (previously only timeout did).
- system-prompt: ability safety labels and the destructive-warning list now
  derive from SafetyController.classify() so LLM-facing text matches runtime
  (destructive-name override included).
- abilities-executor: DELETE is only selected when annotations themselves say
  destructive+idempotent; a name-override-destructive ability goes out as POST
  since its annotations are distrusted.

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
…ormed tool calls

Codex review MF3/MF4/MF5/SF1:
- Provider-safe tool-name aliasing (slash names rejected by all three provider
  APIs), collision-checked, resolved back to real ability names in the
  envelope parser before ability lookup; pass-through for unaliased names.
- Message carries native assistant toolCalls so continuations serialize valid
  OpenAI tool_calls / Anthropic tool_use / Gemini functionCall blocks with
  matching ids; destructive-approval resume preserves the original call id
  instead of inventing execute_<name>.
- Envelope strictness: unparseable argument JSON, non-object input, multiple
  tool calls, answer+tool envelopes, and finishReason length/content_filter
  are protocol errors that feed the retry path — never executed as {}.
- Chat path now runs the same AJV schema validation as the CLI before
  execution; failures return a tool-result error to the model.
- Replace retired model defaults: claude-sonnet-4-20250514 -> claude-sonnet-4-6,
  gemini-1.5-flash -> gemini-3.5-flash; refresh advertised lists.
- --max-context-messages gets min 0; ContextWindow throws on negatives.

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
…gate live tests

Codex review MF6/SF2/SF3/SF4 + watchlist:
- sanitizeSingleLine() applied to provider-resolution warnings and displayed
  config paths in config show (raw MAINWP_LLM_PROVIDER / XDG_CONFIG_HOME were
  rendered unsanitized).
- jobs watch: progress suppression uses resolved jsonOutput (settings
  defaultJsonOutput no longer corrupts the JSON envelope); failed/partial jobs
  exit 4, SIGINT/SIGTERM exit 130/143 with an error envelope, never success.
- Live integration tests excluded from default and process vitest configs and
  gated on MAINWP_LIVE_TEST=1 (npm test no longer depends on the testbed).
- CHANGELOG audit claim corrected to production-only scope (full npm audit has
  29 dev-chain advisories; --omit=dev is clean).
- engines.node >=20.18.1 (undici@7.28.0 floor); http-client always size-checks
  the buffered body instead of trusting parseable Content-Length.
- audit-logger: document that execution on a declined entry records the
  fail-closed abort reason.

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
…ws, parse errors exit 1

Self-review follow-ups:
- --json emits exactly one document on batch timeout/failed/partial: the
  error envelope carries the partial status in details instead of a success
  envelope preceding it (human mode still prints results before the error).
  Timeout process test now asserts the single-document contract.
- Preview summaries no longer say "No items would be affected" when the
  dry_run response shape is unrecognized — the operator sees the raw data
  with an explicit unrecognized-format warning instead of false reassurance.
- oclif flag/arg parse failures (e.g. --dry-run --confirm exclusive
  validation) exit 1 (user input error) instead of oclif's default 2, which
  our contract reserves for auth/config errors.
- pretest builds the CLI so process tests can never validate a stale bin.

Deferred with rationale (not regressions): credentialed provider smoke tests
(needs CI secrets), sanitize-on-ingest refactor (output-boundary sanitizing
would strip the CLI's own ANSI formatting), dev-dep chain upgrade (separate
branch), mainwpctl/mainwpcontrol naming decision (product call).

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
Codex adversarial re-review (needs-attention, one medium finding): profile
name, dashboard URL, username, and the available-profiles list rendered via
stripControlChars, which preserves CR/LF/tab — a crafted profiles.json could
inject forged output lines. All profile-derived one-line fields now use
sanitizeSingleLine; process regression test covers hostile profile values.

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
…fixes

- CHANGELOG Unreleased now records the user-visible behavior changes from the
  review remediation: fail-closed preview, provider-valid chat tool calling,
  strict tool-call rejection, single JSON envelope on batch failure, exit-code
  changes (parse errors 1, failed/partial jobs 4, watch signals 130/143),
  Node 20.18.1 floor, and live-test gating
- README: Node requirement corrected to 20.18.1+, exit-130 row covers jobs
  watch, em dashes removed per house style

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
… and single-row sanitization

- Carry part-level thoughtSignature through ToolCall, the envelope parser,
  chat history, and stream accumulation, and re-emit it on Gemini
  continuation requests (Gemini 3 returns 400 without it)
- Context truncation: a user message directly after a tool result is the
  synthetic approval/decline echo, not a turn boundary; regression tests at
  maxMessages 1/2/3 with the production message order
- Convert remaining single-row output (login, profile/keychain warnings,
  ability names, table cells, list items, preview labels) from
  stripControlChars to sanitizeSingleLine

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
…asts from CR review

Findings from the CodeRabbit loop (iterations 02-03), all verified against
the code before fixing:

- chat-engine: a non-SchemaValidationError thrown during input validation
  propagated out of executeTool after the assistant tool-call message was
  already in history, leaving a dangling tool call that corrupted the next
  provider request. Such errors now return the same { type: 'error' }
  result as the execution catch-all, so a tool message always follows.
- chat-engine: fallback tool-call IDs used a per-turn counter, so call_1,
  call_2... repeated across turns in retained history. Replaced with an
  engine-lifetime counter.
- abilities run: PREVIEW_FAILED details carried the raw previewFailure
  value into --json envelopes; now only the sanitized { reason }.
- keychain: delete() and set() read .message off a bare cast, which threw
  a TypeError when keytar rejected with null or undefined. Both now
  normalize the rejection value first. New keychain.test.ts pins the
  warn-and-continue behavior for non-Error rejections.

One finding rejected: CR asked to convert the Anthropic model list to
dated IDs, but every listed ID is valid and current-generation models are
alias-only. Recorded as anthropic-model-ids-are-current in
REVIEW_DECISIONS.md.

Typecheck clean; 724/724 tests pass (5 new).

Claude-Session: https://claude.ai/code/session_01AJRE68wop5Ppj3jPRAqE55
The Dashboard's json_encode turns empty associative arrays into [], so
schemas arrive with inputSchema: [] or properties: []; providers also
reject type: ['object', 'null'] at the top level. Sanitize recursively
without mutating the input.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
Head-coder audit remediation (four findings):

- Chat: typing "no"/"cancel" at the approval prompt was intercepted in
  chat.ts and only nulled pendingPreview, orphaning the tool_use in
  history (providers 400 on the next turn) and skipping the declined
  audit entry. Route all replies through sendMessage() so declines hit
  handlePreviewResponse; remove the now-dead cancelPendingPreview().
- Safety: add update-site- and activate- to DESTRUCTIVE_NAME_PATTERNS
  so plugin/theme/core updates and activations on live sites are
  classified destructive regardless of server annotations.
- Redaction: credential scrubbing (Basic/Bearer, user:pass@host URLs,
  home paths) was only wired to audit-log paths. Extract it to
  utils/error-sanitizer.ts and apply in errorOutput()/formatError()
  (message, details, hint) plus the raw streaming error throws in
  sse-reader and openai-compatible.
- Policy: extract executeAbilityWithPolicy() as the shared choke point
  (flag validation + classification + destructive-requires-flag) for
  both the abilities run command and chat, matching the documented
  invariant; run.ts now passes the fetched Ability instead of
  re-fetching by name.

747 tests pass; typecheck and lint clean.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…nvelope tests

Audit-remainders item 1: prose around a JSON tool call no longer breaks
parsing; fence patterns and pure-JSON behavior unchanged.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…OU truncation window

Audit-remainders item 2: chmod dir/file to 0700/0600 on every write
(failures swallowed), delete ensureLogFile's check-then-act 'w' open.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…tored ones at display

Audit-remainders item 3: validateUrl rejects user:pass@ on save only
(legacy profiles keep loading), maskUrlUserinfo/maskUrlUserinfoInText
applied at login, config show, and doctor including JSON and echoed
fetch errors.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…l exit 5 and exit 130 tests

Audit-remainders item 5: the e2e harness does produce the real exit
code, so the >=1 fallback assertion is gone; exit 5 forced via an
unreadable settings.json, exit 130 via TTY-emulated SIGINT (ETX) at
the login password prompt.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…jections don't poison the queue

Audit-remainders item 6: pendingPreview and history had no concurrency
protection beyond the REPL's incidental serialization; an inFlight
promise chain now guards programmatic callers.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…s wire shape

Audit-remainders item 4 resolved without a code fix: the Messages API
reference states consecutive same-role turns are combined into a single
turn, so the decline-path double-user shape is valid. Tests pin the
current conversion so changing it becomes a conscious decision.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
… 4 not 5

Move sanitizeInputSchema to src/validation/sanitize-schema.ts and apply it
inside SchemaValidator.getCompiledSchema() so the deterministic path
(abilities run) and chat's executeTool both tolerate PHP artifacts
(properties: [], inputSchema: [], type: ["object","null"]). Schemas AJV
still rejects raise APIError ABILITY_SCHEMA_INVALID (exit 4) — a
server-supplied bad schema is not our internal error. CLI bug remainders
sprint, item 1.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
login --url with embedded userinfo previously died inside undici's fetch
(opaque NetworkError → AuthError) before ProfileStore.save() could reject
it. Export validateDashboardUrl from profile-store and call it right after
URL normalization so the friendly ConfigError (exit 2) fires with no
connection attempt. CLI bug remainders sprint, item 2.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
A 404 during the mandatory destructive preview rendered as bare
"Error: Resource not found" with no hint which ability failed. Add an
optional tool field to the ChatResponse error variant, populate it at all
four producer sites in chat-engine, and render "[tool] Error: ..." in
formatResponse when present (engine-level errors stay bare). Export
formatResponse for direct test coverage. CLI bug remainders sprint, item 3.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
Contract tests for all three modes: --dry-run preview envelope (mode,
preview block, data.data nesting), destructive execute carrying the
approved dry_run's preview, direct execute with no preview key. The batch
branches spread jobId twice (explicit + ...result); keep only the spread.
No envelope shape changes. CLI bug remainders sprint, item 4.

Claude-Session: https://claude.ai/code/session_01JcosLpBNAwDRsrkq1ESkXX
…ve targets

Pack the tarball into a fresh consumer (or run from source), then drive
the installed mainwpcontrol binary through 20 scenarios: read
cross-checks against an independent verifier, error contracts, safety
flows verified by mock-server request recording (dry-run previews once
and never confirms, force skips only the prompt, failed preview fails
closed), and guarded live writes (sync, plugin toggle roundtrip).
Artifacts land in test-results/ (now gitignored) with per-scenario and
per-invocation timings plus a credential-redaction audit.

The agent layer (agent-run.ts) is planned but not yet implemented, so
test:acceptance:human chains fixture and writes only for now.

Claude-Session: https://claude.ai/code/session_017zX6UxnvwCQK7DKBhXr771
Four scenarios drive claude -p with Bash scoped to mainwpcontrol and grade
the ordered CLI argv plus final answer against IndependentVerifier ground
truth computed before the model runs. Three read scenarios run live; the
confirm-delete scenario targets a per-scenario fixture Dashboard because
MainWP Child key-locking makes a live victim non-repeatable (the mcp
reference scopes it the same way). The child claude gets strict MCP
isolation, no Bash sandbox, and a CLI-usage system prompt; grading
tolerates failed intermediate attempts since a CLI agent discovers input
schemas by trying. Speed telemetry (wall clock, claude/API duration, TTFT,
invocation count) lands in results.json and summary.md, closing the gap
the mcp harness has. docs/acceptance-testing.md documents the harness.

Claude-Session: https://claude.ai/code/session_017zX6UxnvwCQK7DKBhXr771
The agent may pipe CLI output through shell filters (seen live:
grep -A4 on the plugin name dropped the slug line), so requiring the
slug in captured tool results failed a correctly answered scenario.
Accept either the slug or the plugin name when structurally tied to
the expected active value; the loose text fallback stays slug-only so
a plugin merely listed in an inventory cannot pass.
Implements the accepted findings from the 2026-07-17 Codex review
(triage in .mwpdev/reviews/, local-only). Stale findings already fixed
on this branch were rejected there with evidence.

- Recognize the Dashboard's real queued envelope: normalize snake_case
  job_id into ExecutionResult.jobId so abilities run --wait actually
  polls; Dashboard-faithful fixture proves it. Shared job-id validator
  bounds IDs at both intake points (queued envelope, batch polling).
- HTTP client: abort timeout now covers body read, bodies stream with
  a byte cap, 2xx requires JSON content type and parseable JSON; empty
  or HTML 2xx is INVALID_RESPONSE, never fabricated success.
- Discovery: validate ability entries, cap pagination, warn-and-keep-
  first on duplicate names, remove ambiguous short aliases. Missing or
  malformed annotations now classify destructive (fail closed); every
  real Dashboard ability declares all three annotation keys.
- Batch polling: reject unknown statuses, job-ID mismatches, invalid
  numerics, oversized arrays, and terminal-state regressions; add
  cancelled status with BATCH_CANCELLED mapping.
- Parse-time flag errors under --json emit one JSON envelope on stdout
  with exit 1; real-SIGINT process tests pin jobs watch at exit 130.
- Live tests: TLS-disable and connectivity gated behind MAINWP_LIVE_TEST,
  password moved off argv to MAINWP_APP_PASSWORD, testbed env path
  configurable via MAINWP_TESTBED_ENV.
- Provider transport: base URLs restricted to http/https, SSE line/
  buffer caps with idle and absolute timeouts, error bodies read
  bounded (16 KiB) before truncation. Malformed streamed tool-call
  arguments now surface as protocol errors instead of vanishing.
- Config hardening: random-suffix O_EXCL atomic writes, keychain store
  before profile persist on login, honest keychain-delete results in
  profile delete, audit input bounded at 8 KiB with truncation marker,
  O_NOFOLLOW append. CI matrix pins the exact Node 20.18.1 floor.
…d chat envelopes

Codex adversarial review found three fail-open paths in this hardening
round, all fixed here:

- Login treated a failed keychain read as "nothing stored", so a later
  profile-save failure could roll back by deleting a credential that
  still existed. Keychain.getStored() now reports found, not-found, and
  read-error distinctly, and login aborts before overwriting when the
  previous credential is unreadable.
- errorOutput() piped details through the unbounded sanitizeForTerminal()
  before the cycle-guarded sanitizer, so cyclic or deep error details
  crashed --json instead of emitting an envelope. Both sanitizers now
  bound depth and track the ancestor path: cycles truncate, legitimately
  shared references survive, and strip-before-redact order is preserved
  so control characters cannot split credential patterns past the
  redaction regexes.
- Content that failed JSON parsing but carried envelope keys after prose
  ('Deleting: {"tool": ...') was accepted as a final answer. It returns
  to the retryable protocol-error path; prose with unrelated braces is
  still an answer.

The rest of the round: username-only credentialed URLs now redact,
userinfo masking is greedy through the last @ so passwords containing @
mask fully, keychain delete distinguishes notFound from failure and
profile delete reports it as the goal state, oclif parse errors emit
INPUT_ERROR envelopes to match the exit code, jobs watch prints
cancelled status, and the acceptance harness redacts base64 Basic-auth
forms of the credentials.
…rovider boundaries

Codex's release audit surfaced eight blockers and ten smaller issues.
Triage (with per-claim verification) is in .mwpdev/reviews/
codex-release-audit-triage-2026-07-20.md; this commit fixes everything
accepted there.

Destructive-confirm outcomes are now fail-closed end to end. A
dispatch-stage audit entry is written before every confirm call, and a
transport failure after dispatch produces an outcomeUnknown audit entry
plus an OUTCOME_UNKNOWN error (exit 3) instead of a generic network
failure with no audit trail. Same treatment in chat, which keeps the
session alive and history coherent. A process test drops the socket
mid-confirm to prove the whole chain.

Keychain credentials now store a v1 envelope binding the password to the
canonical Dashboard identity. Editing profiles.json to point an existing
profile at a different host gets an AuthError instead of the password.
Legacy bare-string entries keep working and re-bind on the next login.
Profile skipSSLVerification must be strictly boolean; a string "false"
no longer disables TLS verification.

Provider boundary: tool results are key-redacted before entering
provider-bound chat history (local display stays raw), all three
provider fetch paths reject redirects with redirect 'manual', hosted
providers refuse HTTP base URLs and warn on any override, and the local
provider allows HTTP only to loopback and private-range hosts.

One-shot chat failures now exit non-zero through the documented JSON
envelope instead of printing a raw ChatResponse and exiting 0. Empty
provider streams are errors rather than blank successful answers, the
REPL error path runs the message sanitizer, and malformed --input JSON
reports the parse position instead of echoing the raw payload.

Previews with absent or unrecognized data now warn honestly instead of
claiming no items would be affected. Dashboard schemas get a recursion
depth cap and pattern-length caps. Acceptance runs with unverified
scenarios exit 1. Docs: cron guide stores the app password in a
chmod-600 env file instead of the crontab, batch-update guide gains
backup/canary/rollback/maintenance-window prerequisites and drops the
"zero risk" claim, README fixes the PowerShell JSON advice and scopes
the global --json claim to exclude help and autocomplete.
Codex's second pass confirmed the main fixes but caught five leftovers.

Chat now carries OUTCOME_UNKNOWN as a stable code on the error response,
and one-shot chat maps it to UnknownOutcomeError (exit 3) instead of
downgrading it to a generic CHAT_ERROR (exit 4). The path is not
reachable in one-shot mode today (previews cannot be approved
non-interactively), but the code no longer lies if that changes.

Legacy keychain entries re-bind opportunistically: an unbound credential
read for an authenticated request is rewritten as a v1 envelope bound to
that request's Dashboard URL, so existing users get identity protection
without a re-login. A failed rewrite leaves the legacy entry untouched.

Provider error bodies get key-based redaction: values of sensitive-
looking keys in JSON-shaped text (api_key, authToken, ...) become
[REDACTED] before the body reaches an error message. The scanner skips
object/array values so nested keys are still reached.

Schema sanitization traverses contains, propertyNames, dependentSchemas,
unevaluatedItems, and unevaluatedProperties, so depth and pattern caps
cannot be bypassed through those keywords, and short catastrophic
patterns (nested quantifiers like ^(a+)+$) are dropped alongside
overlong ones.

The incorrect PowerShell backslash-escaping advice is removed from the
two remaining workflow guides (input-from-file, plugin-deployment-
verification); both now point Windows users at --input-file.
Codex's third verification pass accepted everything except two designs,
and it was right about both.

Legacy keychain entries are no longer auto-bound on first use. Binding
an unknown password to whatever URL the profile holds at that moment
just blesses a file that may already be tampered with. An unbound
credential read for an authenticated request now gets an AuthError with
a one-time-upgrade hint to run login, which binds the credential with
the user seeing and providing the URL. Display paths (doctor, config
show) that read without a URL still work.

The nested-quantifier ReDoS heuristic is replaced with a guaranteed-safe
policy: remotely supplied pattern and patternProperties constraints are
stripped from Dashboard schemas everywhere in the tree and never reach
ajv. No heuristic reliably separates safe regexes from catastrophic
ones, and a hostile pattern could stall the CLI before any request is
sent. The cost is client-side only; the Dashboard re-validates input.
Codex's fourth pass found a bypass: draft-07 dependencies was not in the
traversed keyword list, so a pattern nested under it reached ajv (and
the depth cap was equally bypassable there).

The sanitizer now walks every object/array value in the remote schema,
known keyword or not, deleting pattern/patternProperties at every schema
node and enforcing the depth cap on the whole tree. The keyword lists
only decide which positions get the PHP empty-array normalization; the
scrub no longer depends on them. Data-carrying keys (const, enum,
default, examples) are copied verbatim so literal values keep keys named
"pattern", and properties maps keep fields literally named pattern.
dependencies joins the map keys so a dependency keyed by such a field
survives too.

Regression tests cover Codex's dependencies probe, an invented future
keyword hiding a pattern, depth capping through unknown keywords, and
the preserved-verbatim cases (required: [], defaults, examples,
properties.pattern).
Codex's fifth pass found two depth bypasses in the sanitizer: nested
arrays recursed without incrementing depth, and data-bearing keys
(const, enum, default, examples) were copied verbatim with no depth
check at all, so 60-level probes survived under both.

Arrays now consume a depth level in every walker, and the walkers check
the cap themselves so an over-deep array chain collapses to {} like any
other subtree. Literal data keeps its preserve-verbatim semantics (no
schema-key deletion inside it) but is measured by an iterative
depth check first; a value nesting past the budget drops the whole
keyword rather than being partially rewritten. The measurement uses an
explicit heap stack so checking an arbitrarily deep hostile structure
cannot itself exhaust the call stack.

Regression tests cover 60-level array chains under an unknown keyword
and 60-level mixed object/array values under each of the four data keys.
…olish pass

Structural hardening across the surfaces the release audit touched, with
tests for each change:

- atomicWriteFile opens the temp file exclusively and fsyncs before the
  rename, so a crash cannot leave a renamed zero-length file
- redactSensitiveKeys gains a 32-level depth cap and ancestor-tracking
  cycle guard; it and the terminal sanitizer accumulate into
  null-prototype objects so a crafted __proto__ key cannot pollute
  prototypes; stripControlChars also drops Unicode bidi/isolate controls
- error sanitizer redacts sensitive query-string params and redacts a
  sensitive key's value outright instead of recursing into it, reusing
  the shared isSensitiveKey list; keychain set() failures pass through
  sanitizeKeychainError like the rest of the file; profile-store stops
  echoing malformed URLs that could carry embedded credentials
- audit log free text bounded at 2 KiB with multi-byte-safe truncation
  and a visible marker; chmod repair failures warn instead of vanishing
- ability names are validated case-sensitively at discovery, so a
  case-variant like Mainwp/Delete-Site-V1 is refused rather than
  normalized past destructive classification; abilities run --input
  rejects non-object JSON locally; batch-manager validates jobId before
  any use
- live-test gating requires an explicit MAINWP_LIVE_TEST=1/true (was
  truthy on "false"), drops a hardcoded testbed path, and refuses to run
  live with incomplete credentials
- acceptance harness: bounded subprocess output buffers, a 15-minute
  hard kill for claude runs, a PATH-shim guard enforcing per-scenario
  ability allowlists and confirm policy, TLS-skip conditioned on the
  write-host allowlist, 0600/0700 artifact permissions, pagination bound
- docs: cron fence language tags, a plaintext-credential trade-off note
  for cron.env, canary-run wording fixes
CHANGELOG.md had not been updated since the ChatEngine serialization
commit; everything after it (the CLI bug-remainders sprint, the Codex
review triage, the five release-audit rounds, and the polish pass just
committed) was undocumented. This backfills the Unreleased section from
those commit messages, user-facing changes only.

The one entry that must not be lost: the credential-binding change is
breaking for existing beta users. Unbound keychain credentials stored by
earlier versions are refused for authenticated requests, and each
profile needs a one-time re-login. It leads the Changed section.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds acceptance-test infrastructure, strengthens destructive execution and provider protocol handling, introduces bounded and sanitized I/O, updates credential and configuration safety, standardizes JSON/error contracts, and documents the resulting CLI, automation, and runtime requirements.

Changes

Core safety and execution

Layer / File(s) Summary
Policy enforcement and destructive execution
src/core/*, src/commands/abilities/run.ts
Execution now shares policy validation, fails closed when previews fail, records dispatch and unknown outcomes, and includes preview data in destructive result envelopes.
Chat and provider protocol handling
src/chat/*
Chat sends are serialized, tool aliases and native IDs are preserved, malformed protocol payloads are rejected, provider history is redacted, and context truncation avoids orphaned tool messages.
Transport and response validation
src/core/http-client.ts, src/chat/providers/*, src/core/batch-manager.ts
Redirects, response sizes, JSON shapes, SSE streams, job IDs, statuses, and queued-job responses receive bounded and stricter validation.
Credential, config, and output protection
src/config/*, src/utils/*, src/output/*, src/lib/base-command.ts
Credentials are identity-bound and masked, writes use safer primitives, audit logs are bounded and permission-healed, and terminal/error output is sanitized.
CLI behavior and verification
src/commands/*, src/__tests__/*
JSON envelopes, exit codes, signal cancellation, keychain rollback, input validation, and command harness behavior are covered by updated process and E2E tests.
Acceptance harness
tests/acceptance/*, docs/acceptance-testing.md
Fixture/live and source/packed acceptance modes add independent verification, guarded writes, agent scenarios, artifact recording, redaction audits, and reproducible reports.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AcceptanceRunner
  participant CLIInvoker
  participant MainWPControl
  participant MockServer
  participant IndependentVerifier
  AcceptanceRunner->>CLIInvoker: run scenario command
  CLIInvoker->>MainWPControl: execute CLI with isolated environment
  MainWPControl->>MockServer: request ability or destructive preview
  MockServer-->>MainWPControl: JSON result or preview failure
  MainWPControl-->>CLIInvoker: JSON envelope and exit code
  AcceptanceRunner->>IndependentVerifier: verify catalog and resource state
  IndependentVerifier-->>AcceptanceRunner: independent response
  AcceptanceRunner->>AcceptanceRunner: record artifacts and evaluate scenario
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main themes of the change: acceptance harness work and release hardening.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/acceptance-harness

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​oclif/​plugin-help@​6.2.38 ⏵ 6.2.531001007794 -2100
Addedtsx@​4.21.01001008192100
Updated@​oclif/​plugin-autocomplete@​3.2.39 ⏵ 3.2.53991009295 -1100
Updatedundici@​7.24.4 ⏵ 7.28.093 -3100 +31100 +198100
Updated@​oclif/​core@​4.9.0 ⏵ 4.11.1498 +110010096 -1100

View full report

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/chat/providers/gemini.ts (1)

227-238: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Streaming tool-call IDs should preserve Gemini-provided IDs and include an index. The streaming path always synthesizes fc_${Date.now()} and drops part.functionCall.id, so multiple functionCall parts in the same chunk can collide and later functionResponse matching loses the original correlation ID. Mirror the convertResponse fallback with part.functionCall.id ?? \fc_${Date.now()}_${index}``.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/providers/gemini.ts` around lines 227 - 238, Update the streaming
functionCall handling to preserve the provider-supplied ID and avoid collisions
by using part.functionCall.id when present, otherwise falling back to the
existing timestamp-based ID with the available part index appended. Keep the
rest of the yielded toolCall structure unchanged.
🧹 Nitpick comments (9)
src/__tests__/process/fixtures/cli-runner.ts (1)

108-137: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Add an error handler to runCLIWithSignal so spawn failures reject/resolve instead of hanging.

Unlike runWithStdin (which handles child.on('error') at Line 207), this helper only resolves on 'close'. If spawn fails (e.g., bad BIN_PATH), no 'close' fires and the promise stays pending until the Vitest timeout, masking the real failure.

♻️ Add error handling
     child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
     child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
+    child.on('error', (err) => {
+      clearTimeout(signalTimer);
+      clearTimeout(timeoutTimer);
+      resolve({ stdout: '', stderr: err.message, exitCode: 1, duration: Date.now() - start });
+    });
     child.on('close', (code, closeSignal) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/process/fixtures/cli-runner.ts` around lines 108 - 137, Update
runCLIWithSignal to register a child.on('error') handler that settles the
promise when spawn fails, matching the existing runWithStdin behavior. Ensure
the handler clears both timers and rejects or resolves with the underlying spawn
error instead of waiting indefinitely, while preserving the current close-event
result handling.
src/core/batch-manager.ts (1)

260-267: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate terminal-status predicate across batch-manager.ts and watch.ts. Both files independently define an identical "is job status terminal" check, updated in lockstep for cancelled in this PR but with nothing enforcing they stay in sync going forward.

  • src/core/batch-manager.ts#L260-L267: export the client-facing terminal predicate (equivalent to the current private isTerminalStatus) as a shared helper alongside JobStatusType.
  • src/commands/jobs/watch.ts#L40-L43: drop the locally duplicated isTerminalStatus and import the shared helper from batch-manager.ts instead.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/batch-manager.ts` around lines 260 - 267, The terminal-status logic
is duplicated across the batch manager and watch command. In
src/core/batch-manager.ts lines 260-267, export the client-facing predicate
equivalent to private isTerminalStatus alongside JobStatusType; in
src/commands/jobs/watch.ts lines 40-43, remove the local isTerminalStatus and
import and use the shared helper instead.
src/lib/base-command.ts (1)

314-343: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound redactDebugValue recursion like sanitizeErrorValue.

No depth limit or cycle (WeakSet) guard here, unlike the sibling hardening added to sanitizeErrorValue in this same PR. A deeply-nested or self-referential debug-context value would recurse unbounded.

♻️ Proposed bounded refactor
-  private redactDebugContext(context: Record<string, unknown>): Record<string, unknown> {
+  private redactDebugContext(
+    context: Record<string, unknown>,
+    depth = 0,
+    seen: WeakSet<object> = new WeakSet()
+  ): Record<string, unknown> {
     const redacted: Record<string, unknown> = {};

     for (const [key, value] of Object.entries(context)) {
-      redacted[key] = isSensitiveKey(key) ? '[REDACTED]' : this.redactDebugValue(value);
+      redacted[key] = isSensitiveKey(key) ? '[REDACTED]' : this.redactDebugValue(value, depth, seen);
     }

     return redacted;
   }

-  private redactDebugValue(value: unknown): unknown {
+  private redactDebugValue(value: unknown, depth = 0, seen: WeakSet<object> = new WeakSet()): unknown {
     if (typeof value === 'string' && value.length > 300) {
       return `${value.slice(0, 297)}...`;
     }

+    if (depth >= MAX_DEBUG_DEPTH) {
+      return '[TRUNCATED]';
+    }
+
     if (Array.isArray(value)) {
-      return value.map((item) => this.redactDebugValue(item));
+      if (seen.has(value)) return '[TRUNCATED]';
+      seen.add(value);
+      return value.map((item) => this.redactDebugValue(item, depth + 1, seen));
     }

     if (value && typeof value === 'object') {
-      return this.redactDebugContext(value as Record<string, unknown>);
+      if (seen.has(value)) return '[TRUNCATED]';
+      seen.add(value);
+      return this.redactDebugContext(value as Record<string, unknown>, depth + 1, seen);
     }

     return value;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/base-command.ts` around lines 314 - 343, Bound recursion in
redactDebugValue and redactDebugContext using the same depth limit and WeakSet
cycle guard established by sanitizeErrorValue. Ensure deeply nested values are
safely truncated or redacted and self-referential structures do not recurse
indefinitely, while preserving existing string truncation and sensitive-key
handling.
tests/acceptance/lib/artifacts.ts (1)

134-134: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

No-op .replace('Z', 'Z').

This replaces Z with itself and does nothing. Either drop it or replace with the intended transform (e.g. stripping the trailing Z).

Proposed tidy-up
-  const timestamp = startTime.replace(/[-:.]/g, '').replace('Z', 'Z');
+  const timestamp = startTime.replace(/[-:.]/g, '');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/acceptance/lib/artifacts.ts` at line 134, Remove the no-op
.replace('Z', 'Z') from the timestamp construction in the artifact timestamp
logic, while preserving the existing removal of hyphens, colons, and periods.
tests/acceptance/scenarios/read.ts (1)

97-112: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a page-count safety cap to the read.ts pagination loops. All four loops terminate only on items.length >= total or an empty page. A Dashboard that reports a wrong total while returning non-empty pages will loop forever and hang the run — exactly the failure mode cliListAllSites in tests/acceptance/scenarios/types.ts (L138-181) already guards against with MAX_SITE_PAGES. Mirror that guard so a paging bug fails loudly instead of hanging.

  • tests/acceptance/scenarios/read.ts#L97-L112: add a max-page guard to cliListAll and throw when exceeded.
  • tests/acceptance/scenarios/read.ts#L114-L127: add the same guard to verifierListAll.
  • tests/acceptance/scenarios/read.ts#L171-L189: add the same guard to verifierListUpdates.
  • tests/acceptance/scenarios/read.ts#L191-L210: add the same guard to cliListUpdates.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/acceptance/scenarios/read.ts` around lines 97 - 112, read.ts pagination
can loop indefinitely when the reported total is incorrect; add a shared
MAX_SITE_PAGES-style cap and throw once it is exceeded. Apply the guard to
cliListAll (tests/acceptance/scenarios/read.ts:97-112), verifierListAll
(114-127), verifierListUpdates (171-189), and cliListUpdates (191-210),
preserving normal termination when totals are reached or pages are empty.
src/chat/tool-envelope.ts (1)

64-110: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Single-pass extractFirstJsonObject
extractFirstJsonObject is quadratic on brace-heavy responses like "{".repeat(n) + "}". Provider maxTokens and SSE line-buffer limits bound some inputs, but LLMResponse.content is still not hard-capped here, so a malformed response can make parsing unnecessarily expensive. A one-pass scan or explicit length cap would avoid that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/tool-envelope.ts` around lines 64 - 110, The nested scanning in
extractFirstJsonObject can rescan brace-heavy malformed input quadratically.
Refactor this function to scan the text once while tracking JSON string, escape,
and brace-depth state, returning the first valid object candidate without
restarting from each opening brace; alternatively enforce an explicit maximum
scan length before parsing.
src/utils/format.test.ts (1)

124-181: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

LGTM! Note: none of these cases cover the malformed/whitespace-normalized-URL edge case raised in the consolidated comment on src/utils/format.ts#L113-L128 — once that's fixed, consider adding a regression test here (e.g. a dashboardUrl with an embedded tab/leading space that still round-trips through new URL() with non-empty userinfo).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/format.test.ts` around lines 124 - 181, Add a regression test in
the maskUrlUserinfo test suite for a dashboardUrl containing leading or embedded
whitespace that new URL() normalizes while retaining non-empty userinfo. Assert
that maskUrlUserinfo masks the normalized credentials correctly, covering the
malformed/whitespace-normalized URL edge case described for maskUrlUserinfo.
src/core/http-client.ts (1)

195-198: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: cancel the redirect response body before recursing.

The 3xx response body is left unconsumed here before handleRedirect issues the next request. For undici-backed fetch, an unread body can delay socket release/reuse. Redirect bodies are typically empty so impact is small, but an explicit void response.body?.cancel().catch(() => {}) keeps connection handling tidy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/http-client.ts` around lines 195 - 198, In the redirect branch of
the HTTP client’s request flow, cancel the unread response body before invoking
handleRedirect, safely ignoring any cancellation rejection, then preserve the
existing recursive redirect behavior.
tests/acceptance/lib/commands.ts (1)

36-42: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: avoid O(n²) recomputation in appendBounded.

reduce over all retained chunks runs on every data event, so buffering a large stream in many chunks is quadratic. A running total kept across calls (e.g., module/closure state or a small wrapper object) trims in amortized O(1).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/acceptance/lib/commands.ts` around lines 36 - 42, Optimize
appendBounded by avoiding a full chunks.reduce recalculation on every call.
Maintain the retained byte total across invocations, decrementing it as chunks
are removed and trimming until it is within MAX_STREAM_BYTES; preserve the
existing bounded-buffer behavior and function contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/config/show.ts`:
- Around line 274-337: Sanitize the masked secret values before displaying them
in the config output: wrap config.profile.credentialsMasked in
sanitizeSingleLine within the Credentials row and
config.llmProvider.apiKeyMasked in sanitizeSingleLine within the API Key row.
Leave the existing masking and other display behavior unchanged.

In `@src/commands/doctor.ts`:
- Around line 444-466: Update displayReport to sanitize check.message with
sanitizeSingleLine() before colorizing and logging it, preventing embedded line
breaks from creating extra report rows. Leave check.details using
stripControlChars().split('\n') so verbose details continue to support multiline
output.

In `@src/config/keychain.ts`:
- Around line 271-276: Update the docstring near the expectedDashboardUrl
handling to state that legacy unbound entries are readable only when no expected
URL is supplied, while authenticated use with an expected URL throws and
requires a one-time re-login before the credential is rebound. Keep the
identity-bound mismatch behavior and per-invocation environment-variable note
unchanged.

In `@src/utils/format.ts`:
- Around line 113-128: Update maskUrlUserinfo in src/utils/format.ts (lines
113-128) to fail closed: after detecting credentials, apply the redaction and
return a placeholder such as [URL_WITH_CREDENTIALS] if the substitution leaves
the URL unchanged; src/commands/config/show.ts (lines 198-199) and
src/commands/doctor.ts (lines 214-215) require no direct changes because they
inherit this fix.

---

Outside diff comments:
In `@src/chat/providers/gemini.ts`:
- Around line 227-238: Update the streaming functionCall handling to preserve
the provider-supplied ID and avoid collisions by using part.functionCall.id when
present, otherwise falling back to the existing timestamp-based ID with the
available part index appended. Keep the rest of the yielded toolCall structure
unchanged.

---

Nitpick comments:
In `@src/__tests__/process/fixtures/cli-runner.ts`:
- Around line 108-137: Update runCLIWithSignal to register a child.on('error')
handler that settles the promise when spawn fails, matching the existing
runWithStdin behavior. Ensure the handler clears both timers and rejects or
resolves with the underlying spawn error instead of waiting indefinitely, while
preserving the current close-event result handling.

In `@src/chat/tool-envelope.ts`:
- Around line 64-110: The nested scanning in extractFirstJsonObject can rescan
brace-heavy malformed input quadratically. Refactor this function to scan the
text once while tracking JSON string, escape, and brace-depth state, returning
the first valid object candidate without restarting from each opening brace;
alternatively enforce an explicit maximum scan length before parsing.

In `@src/core/batch-manager.ts`:
- Around line 260-267: The terminal-status logic is duplicated across the batch
manager and watch command. In src/core/batch-manager.ts lines 260-267, export
the client-facing predicate equivalent to private isTerminalStatus alongside
JobStatusType; in src/commands/jobs/watch.ts lines 40-43, remove the local
isTerminalStatus and import and use the shared helper instead.

In `@src/core/http-client.ts`:
- Around line 195-198: In the redirect branch of the HTTP client’s request flow,
cancel the unread response body before invoking handleRedirect, safely ignoring
any cancellation rejection, then preserve the existing recursive redirect
behavior.

In `@src/lib/base-command.ts`:
- Around line 314-343: Bound recursion in redactDebugValue and
redactDebugContext using the same depth limit and WeakSet cycle guard
established by sanitizeErrorValue. Ensure deeply nested values are safely
truncated or redacted and self-referential structures do not recurse
indefinitely, while preserving existing string truncation and sensitive-key
handling.

In `@src/utils/format.test.ts`:
- Around line 124-181: Add a regression test in the maskUrlUserinfo test suite
for a dashboardUrl containing leading or embedded whitespace that new URL()
normalizes while retaining non-empty userinfo. Assert that maskUrlUserinfo masks
the normalized credentials correctly, covering the
malformed/whitespace-normalized URL edge case described for maskUrlUserinfo.

In `@tests/acceptance/lib/artifacts.ts`:
- Line 134: Remove the no-op .replace('Z', 'Z') from the timestamp construction
in the artifact timestamp logic, while preserving the existing removal of
hyphens, colons, and periods.

In `@tests/acceptance/lib/commands.ts`:
- Around line 36-42: Optimize appendBounded by avoiding a full chunks.reduce
recalculation on every call. Maintain the retained byte total across
invocations, decrementing it as chunks are removed and trimming until it is
within MAX_STREAM_BYTES; preserve the existing bounded-buffer behavior and
function contract.

In `@tests/acceptance/scenarios/read.ts`:
- Around line 97-112: read.ts pagination can loop indefinitely when the reported
total is incorrect; add a shared MAX_SITE_PAGES-style cap and throw once it is
exceeded. Apply the guard to cliListAll
(tests/acceptance/scenarios/read.ts:97-112), verifierListAll (114-127),
verifierListUpdates (171-189), and cliListUpdates (191-210), preserving normal
termination when totals are reached or pages are empty.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee99b1e7-b7c4-4f44-a4e2-0926ddc37a70

📥 Commits

Reviewing files that changed from the base of the PR and between a4228eb and 3e98dd0.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (127)
  • .github/workflows/ci.yml
  • .gitignore
  • CHANGELOG.md
  • README.md
  • docs/acceptance-testing.md
  • docs/workflows/daily-health-check.md
  • docs/workflows/input-from-file.md
  • docs/workflows/monthly-batch-updates.md
  • docs/workflows/plugin-deployment-verification.md
  • package.json
  • src/__tests__/e2e/batch-polling-flow.test.ts
  • src/__tests__/e2e/chat-destructive-flow.test.ts
  • src/__tests__/e2e/command-workflows.test.ts
  • src/__tests__/e2e/exit-codes.test.ts
  • src/__tests__/e2e/json-contract.test.ts
  • src/__tests__/e2e/login-abilities-flow.test.ts
  • src/__tests__/e2e/non-tty-behavior.test.ts
  • src/__tests__/e2e/test-helpers.ts
  • src/__tests__/process/abilities-run.test.ts
  • src/__tests__/process/auth.test.ts
  • src/__tests__/process/batch-wait.test.ts
  • src/__tests__/process/config-show.test.ts
  • src/__tests__/process/doctor.test.ts
  • src/__tests__/process/exit-codes.test.ts
  • src/__tests__/process/fixtures/api-responses.ts
  • src/__tests__/process/fixtures/cli-runner.ts
  • src/__tests__/process/live-api.test.ts
  • src/__tests__/process/live-workflow-docs.test.ts
  • src/__tests__/process/safety.test.ts
  • src/__tests__/process/scenarios.test.ts
  • src/chat/chat-engine.test.ts
  • src/chat/chat-engine.ts
  • src/chat/context-window.test.ts
  • src/chat/context-window.ts
  • src/chat/providers/anthropic.test.ts
  • src/chat/providers/anthropic.ts
  • src/chat/providers/gemini.test.ts
  • src/chat/providers/gemini.ts
  • src/chat/providers/openai-compatible.ts
  • src/chat/providers/provider-fetch.test.ts
  • src/chat/providers/provider-fetch.ts
  • src/chat/providers/provider.test.ts
  • src/chat/providers/provider.ts
  • src/chat/providers/sse-reader.test.ts
  • src/chat/providers/sse-reader.ts
  • src/chat/providers/streamed-tool-arguments.test.ts
  • src/chat/providers/tool-protocol.test.ts
  • src/chat/system-prompt.test.ts
  • src/chat/system-prompt.ts
  • src/chat/tool-envelope.test.ts
  • src/chat/tool-envelope.ts
  • src/commands/abilities/list.ts
  • src/commands/abilities/run.ts
  • src/commands/chat.test.ts
  • src/commands/chat.ts
  • src/commands/config/show.ts
  • src/commands/doctor.ts
  • src/commands/jobs/watch.test.ts
  • src/commands/jobs/watch.ts
  • src/commands/login.ts
  • src/commands/profile/delete.ts
  • src/config/atomic-write.test.ts
  • src/config/fs-utils.test.ts
  • src/config/fs-utils.ts
  • src/config/keychain.test.ts
  • src/config/keychain.ts
  • src/config/profile-store.test.ts
  • src/config/profile-store.ts
  • src/core/abilities-executor.test.ts
  • src/core/abilities-executor.ts
  • src/core/batch-manager.test.ts
  • src/core/batch-manager.ts
  • src/core/execute-ability-with-policy.test.ts
  • src/core/execute-ability-with-policy.ts
  • src/core/http-client.test.ts
  • src/core/http-client.ts
  • src/core/job-id.ts
  • src/core/safety-controller.test.ts
  • src/core/safety-controller.ts
  • src/lib/base-command.test.ts
  • src/lib/base-command.ts
  • src/output/formatter.test.ts
  • src/output/formatter.ts
  • src/output/json-envelope.test.ts
  • src/output/json-envelope.ts
  • src/utils/audit-logger.permissions.test.ts
  • src/utils/audit-logger.test.ts
  • src/utils/audit-logger.ts
  • src/utils/error-sanitizer.test.ts
  • src/utils/error-sanitizer.ts
  • src/utils/errors.ts
  • src/utils/format.test.ts
  • src/utils/format.ts
  • src/utils/prompt.ts
  • src/utils/redaction.test.ts
  • src/utils/redaction.ts
  • src/utils/retry.test.ts
  • src/utils/retry.ts
  • src/utils/terminal-sanitizer.test.ts
  • src/utils/terminal-sanitizer.ts
  • src/validation/input-sanitizer.test.ts
  • src/validation/input-sanitizer.ts
  • src/validation/sanitize-schema.test.ts
  • src/validation/sanitize-schema.ts
  • src/validation/schema-validator.test.ts
  • src/validation/schema-validator.ts
  • tests/acceptance/agent-run.ts
  • tests/acceptance/fixtures.ts
  • tests/acceptance/lib/artifacts.ts
  • tests/acceptance/lib/cli.ts
  • tests/acceptance/lib/commands.ts
  • tests/acceptance/lib/env.ts
  • tests/acceptance/lib/guards.ts
  • tests/acceptance/lib/local-registry.ts
  • tests/acceptance/lib/pack.ts
  • tests/acceptance/lib/redact.ts
  • tests/acceptance/lib/verify.ts
  • tests/acceptance/run.ts
  • tests/acceptance/scenarios/configuration.ts
  • tests/acceptance/scenarios/errors.ts
  • tests/acceptance/scenarios/index.ts
  • tests/acceptance/scenarios/read.ts
  • tests/acceptance/scenarios/safety.ts
  • tests/acceptance/scenarios/types.ts
  • tests/acceptance/scenarios/writes.ts
  • vitest.config.ts
  • vitest.process.config.ts
💤 Files with no reviewable changes (2)
  • src/utils/retry.ts
  • src/utils/retry.test.ts

Comment thread src/commands/config/show.ts
Comment thread src/commands/doctor.ts
Comment thread src/config/keychain.ts Outdated
Comment thread src/utils/format.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/__tests__/process/fixtures/cli-runner.ts`:
- Around line 122-126: Update the child-process handling around the error
listener and close path so child.on('error') records or handles spawn failures
without resolving the run or clearing timeoutTimer after a failed kill. Keep the
close handler as the terminal resolution path, while preserving appropriate
cleanup for genuine spawn failures and the hard-kill fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f774e196-ba01-4828-8ff9-6a5085af9cbf

📥 Commits

Reviewing files that changed from the base of the PR and between 3e98dd0 and 4d991fc.

📒 Files selected for processing (13)
  • src/__tests__/process/fixtures/cli-runner.ts
  • src/commands/config/show.ts
  • src/commands/doctor.ts
  • src/commands/jobs/watch.test.ts
  • src/commands/jobs/watch.ts
  • src/config/keychain.ts
  • src/core/batch-manager.ts
  • src/core/http-client.ts
  • src/lib/base-command.ts
  • src/utils/format.test.ts
  • src/utils/format.ts
  • tests/acceptance/lib/artifacts.ts
  • tests/acceptance/scenarios/read.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • src/utils/format.test.ts
  • src/utils/format.ts
  • tests/acceptance/lib/artifacts.ts
  • src/commands/doctor.ts
  • src/lib/base-command.ts
  • src/commands/jobs/watch.test.ts
  • src/commands/jobs/watch.ts
  • src/core/http-client.ts
  • src/commands/config/show.ts
  • tests/acceptance/scenarios/read.ts
  • src/config/keychain.ts
  • src/core/batch-manager.ts

Comment thread src/__tests__/process/fixtures/cli-runner.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/__tests__/process/fixtures/mock-server.ts`:
- Line 44: Update requestWaiters and waitForRequest() so every queued waiter has
timeout or cancellation handling and cannot remain pending indefinitely when its
request is missed. During reset() and shutdown, reject all pending waiters
before clearing the queue, preserving normal resolution when a matching request
arrives. Ensure the runCLIWithSignal readiness gate receives the rejection
rather than hanging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40458767-b01a-490c-9e73-30b0f0aa4a09

📥 Commits

Reviewing files that changed from the base of the PR and between 1de9f1d and 8d34d19.

📒 Files selected for processing (3)
  • src/__tests__/process/batch-wait.test.ts
  • src/__tests__/process/fixtures/cli-runner.ts
  • src/__tests__/process/fixtures/mock-server.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/tests/process/fixtures/cli-runner.ts
  • src/tests/process/batch-wait.test.ts

private server: Server | null = null;
private routes: Route[] = [];
private recorded: RecordedRequest[] = [];
private requestWaiters: Array<{ match: (r: RecordedRequest) => boolean; resolve: () => void }> = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Settle or time-bound every queued waiter.

waitForRequest() can return a promise that never settles, while reset() simply drops its waiter. If the expected request is missed, the runCLIWithSignal(..., server.waitForRequest(...)) readiness gate can hang indefinitely. Add timeout/cancellation support and reject pending waiters before clearing them during reset or shutdown.

Also applies to: 92-92, 181-187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/process/fixtures/mock-server.ts` at line 44, Update
requestWaiters and waitForRequest() so every queued waiter has timeout or
cancellation handling and cannot remain pending indefinitely when its request is
missed. During reset() and shutdown, reject all pending waiters before clearing
the queue, preserving normal resolution when a matching request arrives. Ensure
the runCLIWithSignal readiness gate receives the rejection rather than hanging.

…tion

The real find: maskUrlUserinfo failed open. new URL() strips tab/newline
before detecting userinfo, but the replace regex excludes whitespace and
is anchored, so a stored URL like https://admin:sec\nret@host passed the
credential check yet came back unmasked. Detected-but-unmaskable URLs
now return [URL_WITH_CREDENTIALS_REDACTED]; regression tests cover
newline, tab, and leading-whitespace variants.

The rest of the review, all accepted:

- config show passes credentialsMasked and apiKeyMasked through
  sanitizeSingleLine; the mask keeps literal head/tail bytes of the
  secret, which can carry escapes from MAINWP_APP_PASSWORD
- doctor renders check.message with sanitizeSingleLine (single-row
  field); verbose details stay multiline via stripControlChars
- keychain docstring reworded: legacy entries are refused for
  authenticated use pending a one-time login, not auto-rebound
- isTerminalStatus deduplicated into batch-manager and re-exported from
  watch; watch.test builds its mock over importOriginal
- redactDebugValue bounded like the shared sanitizers: 32-level depth
  cap, ancestor-tracking WeakSet
- http-client cancels the unread redirect response body before following
- runCLIWithSignal resolves on spawn error instead of hanging; gated on
  the 'spawn' event so a failed kill() cannot disarm the SIGKILL
  fallback while the child still runs ('close' stays the terminal path)
- acceptance read.ts pagination loops capped at 100 pages; artifacts
  drops a no-op replace('Z', 'Z')

Declined two nitpicks: the extractFirstJsonObject single-pass rewrite
(deliberate, test-pinned scanner; provider-response sizes make the
second pass free) and appendBounded's quadratic reduce (buffer is
byte-capped, so the chunk array stays ~100 elements, test-only code).
The advisory (host confusion via a literal backslash authority
delimiter) landed against fast-uri 3.0.0-3.1.3 after the last CI run and
turned the npm audit gate red on every matrix job. fast-uri reaches
production through ajv.

Deliberately narrower than `npm audit fix`, which also refreshed five
unrelated in-range transitives and pruned orphaned lock entries; this
bumps fast-uri alone (`npm update fast-uri`) so the release diff stays
reviewable. Most of the lockfile diff is npm 11 rewriting entry order;
the only version change is fast-uri, verified by diffing the parsed
package list before and after.
@dennisdornon
dennisdornon force-pushed the feat/acceptance-harness branch from 7368b43 to 2aa7dae Compare July 22, 2026 11:55
@dennisdornon
dennisdornon marked this pull request as draft July 22, 2026 16:25
The process tests spawn the built CLI as a child per assertion, and the
first full CI runs of this branch surfaced platform gaps. A five-round
isolation probe on a Windows runner (scratch branch, since deleted)
found the root cause and disproved two plausible theories along the way
(Defender scanning and pipe-mode stdio: exclusions changed nothing, and
every stdio mode spawns in ~800ms outside the harness).

The root cause: buildEnv() handed children a hand-built minimal
environment. POSIX children don't care; Windows children lose the OS
plumbing (SystemRoot, TEMP, PATHEXT, APPDATA, ...) and node boots into
multi-second fallback paths — 20-40s per child, measured against ~800ms
with a full env. Under parallel vitest files those children stacked and
starved the runner. On win32 buildEnv now inherits the OS environment,
strips MAINWP*, and applies the same overrides; isolated probe runs
dropped smoke from 121s to 9s, safety from 449s to 20s, and the full
process suite from a 20-minute timeout to 3m15s.

Also in this commit:

- SIGINT delivery was a race: a 750ms timer can fire before the CLI
  installs its handler on a slow runner. runCLIWithSignal accepts a
  readiness promise, MockServer gains waitForRequest(), and the
  jobs-watch SIGINT tests fire the signal only after the first status
  poll proves the handler is live.
- Two contracts are POSIX-only and skip on win32: the jobs-watch SIGINT
  cancellation flow (child.kill('SIGINT') on Windows terminates without
  running handlers) and the audit-log permission self-heal assertions
  (Windows has no mode bits; stat reports 0o666 regardless).
- Child and test timeouts keep extra headroom on win32 (60s/90s): free
  when healthy, and a slow runner cannot SIGKILL a working child
  mid-boot, which reports as empty output.
- The matrix runs with fail-fast off: canceled cells hid whether their
  platform actually passes and cost a misdiagnosed round.
@dennisdornon
dennisdornon force-pushed the feat/acceptance-harness branch from 2aa7dae to 9d3bc88 Compare July 22, 2026 18:14
@dennisdornon
dennisdornon marked this pull request as ready for review July 22, 2026 20:39
The testbed Dashboard now exposes WordPress-core abilities without a -vN
suffix (core/get-site-info, core/get-environment-info). CLI discovery
skips unversioned entries by design, so the scenario's raw-catalog
expectation failed the live run 16/1/3. The independent expectation now
applies the same name rule, and the fixture catalog carries an
unversioned entry so the fixture target keeps exercising the contract.
@dennisdornon
dennisdornon merged commit babd44b into main Jul 22, 2026
9 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jul 27, 2026
@dennisdornon
dennisdornon deleted the feat/acceptance-harness branch July 27, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant