feat(cli): bounded always-on diagnostic sink for unexpected failures - #2573
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Fix round 1Blocking finding (HIGH): failure-sink construction sat outside the unknown-exception boundary and The runtime claim the review left unverified is now verified. with exit code -532462766 instead of Fix: Regressions added:
Both failed on the pre-fix head with the output above and pass now. Verification for this round, from the worktree:
Not verified this round: the full The four non-blocking findings (the |
|
Review (agent half of the gate; Codex credits exhausted, SC-9). Two fresh-context reviewer passes (read-only, Opus 5). Round 1 at cc1cb67: verdict FIX_FIRST on one HIGH. The sink was constructed at Round 2 (scoped to the fix diff) at eefc7d8: verdict SHIP. Confirmed the construction site has no reachable throw path, the fallback branches are themselves catch-all, the record file name is always hex so the reference cannot steer Properties confirmed by round 1: Non-blocking findings and dispositions:
Commit 11ced97 changes one string constant, one doc comment and two doc counts; no logic changed, so no further review pass is owed. Merge gate: hosted ci-required green at the final head plus the aging floor. |
Closes #2468
Refs #2351
Summary
The CLI's unknown-exception boundary from #2466 kept the full exception only when the harness
startup trace was enabled (
TASKDECK_CLI_TEST_TRACE_CORRELATION). In an ordinary operator runthere is no trace, so the exception was retained nowhere at all and the operator got the generic
line plus a "diagnostics were not captured" notice. This adds the always-on sink #2468 asked for.
backend/src/Taskdeck.Cli/CliFailureSink.cs(new, internal):<data directory>/diagnostics/cli-failure-<yyyyMMddTHHmmssZ>-<reference>.txt. The data directoryis the directory of the resolved SQLite data source, via the existing
CliFirstRunBootstrapper.ResolveDataDirectory(madeinternalso there is one resolution, nottwo). That helper already falls back to the working directory for a non-file data source such as
:memory:; the sink inherits that fallback and says so in a comment.ProductVersion.Value), argvpassed through
SensitiveDataRedactor.Redact, andSensitiveDataRedactor.SummarizeExceptionoutput. Never a raw stack trace, never a raw
Exception.Message.MaximumRecordBytes = 8 * 1024(truncated with anexplicit marker, on a UTF-8 character boundary) and
MaximumRecordCount = 20(oldest evictedfirst; the timestamp prefix makes ordinal name order chronological).
FileMode.CreateNew, so a stale file or a planted symlink at the target path makesthe write fail rather than being appended to or followed (
O_CREAT|O_EXCLon POSIX). On nonWindows the owner-only mode is set at creation via
FileStreamOptions.UnixCreateMode, so thereis no world-readable window.
TryRecordreturns false and prints nothing.CliUnexpectedFailure.Handlegained two optional parameters (CliFailureSink?,IReadOnlyList<string>?), so existing three-argument callers and tests are unchanged. It tries theharness trace first exactly as before, then the sink; "captured" is true if either succeeded. The
reference is the trace correlation when a trace is enabled, otherwise 12 lowercase hex characters
from
RandomNumberGenerator.Stderr contract: every existing string constant is unchanged. The only change is that an ordinary
run now also carries a correlation reference in the existing
Error [UNEXPECTED_ERROR]: <generic message> (trace correlation: <id>)format. The reference isprinted only when a sink actually kept the record; when nothing captured it, the output is the
plain generic line plus the unchanged
DiagnosticsUnavailableNotice, as before. This keeps theexisting rule that the CLI never advertises a reference to a record it did not keep.
Program.csbuilds the sink from the environment before the host is built (so a failure inside thehost build still has a sink), then re-points it at the configuration-resolved connection string
once
builder.Configurationknows it, and passes it plusargsto the boundary.CliTestHarnessgained anenableStartupTraceconstructor flag (default true, so no existing testchanges behaviour) because the harness always sets the trace environment variable and test (a) has
to prove the ordinary no-trace run.
Docs: the CLI paragraph in
docs/security/SECURITY_LOGGING_REDACTION.mdnow describes the realretention (location, contents, bounds, permissions, fail-open) instead of saying the CLI has no
always-on sink. The CLI row and the R7 note in
docs/security/UNKNOWN_EXCEPTION_SURFACE_INVENTORY.mdwere updated only where they had becomefalse: the shifted
Program.cs/CliUnexpectedFailure.csline numbers, the "harness-only" and"#2468 tracks" statements, and the test counts.
Red evidence
Both required regressions failed against the old code first.
(a)
RealCli_WithoutTheHarnessTrace_KeepsOneRedactedRecordUnderTheDataDirectory, run before anysink existed:
(f)
TryRecord_DoesNotTouchAFileAlreadyAtTheTargetPath, run against a first sink implementationthat used
FileMode.Createinstead ofFileMode.CreateNew:Switching that single line to
FileMode.CreateNewturned it green.Verification
All commands run from the worktree
C:/Users/jekyt/source/Taskdeck-Beta/.worktrees/codex-2468-cli-failure-sinkon Windows 11.dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~CliFailureSink|FullyQualifiedName~CliUnexpectedError|FullyQualifiedName~CliStartupTrace"Passed: 28, Failed: 0, Skipped: 0, Total: 28.
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1Passed: 202, Failed: 0, Skipped: 0, Total: 202 (3 m 29 s).
dotnet build backend/Taskdeck.sln -c Release0 Error(s), 12 Warning(s), all pre-existing nullable warnings in test projects untouched by this
change.
node scripts/check-docs-governance.mjs- "Docs governance check passed." (exit 0).node scripts/check-unknown-exception-boundary.mjs- "Unknown-exception boundary check passed."(exit 0).
git diff --check- clean.Cases required by the issue and where they live:
CliUnexpectedErrorSafetyTests.RealCli_WithoutTheHarnessTrace_KeepsOneRedactedRecordUnderTheDataDirectory(real child process via
CliTestHarness).CliFailureSinkTests.Record_IsOwnerReadWriteOnly_OnPosix.CliFailureSinkTests.TryRecord_EvictsTheOldestRecordsAtTheRetentionCap.CliFailureSinkTests.TryRecord_TruncatesAnOversizedRecordWithAMarker.ExitCodes.Failure:CliFailureSinkTests.TryRecord_FailsOpenWhenTheDiagnosticsDirectoryCannotBeCreatedandCliFailureSinkTests.Handle_WhenTheSinkCannotWrite_SaysDiagnosticsWereNotCapturedAndLeaksNothing.CliFailureSinkTests.TryRecord_DoesNotTouchAFileAlreadyAtTheTargetPath.Not verified
dotnet test backend/Taskdeck.sln -c Release -m:1was NOT run.Another worker was running dotnet on this machine and RAM is tight (about 2.5 GB free), so it is
left to
ci-required. Only the CLI project and the solution build were run locally.Record_IsOwnerReadWriteOnly_OnPosixand in the pre-existingFailureSink_IsOwnerReadWriteOnly_OnPosix.FileStreamOptions.UnixCreateModeis thereforeproven only by the Linux CI leg.
FileMode.CreateNewmapping toO_CREAT|O_EXCL, not measured: thetest plants a regular file, not a symlink, because creating a symlink on Windows needs elevation.
backend/src/Taskdeck.Cli,backend/tests/Taskdeck.Cli.Testsand the two security docs was touched.Risk notes
(trace correlation: <12 hex>)where it previously printed nothing after the generic message.Anything parsing that line for an exact match would see the suffix. All existing assertions in
CliUnexpectedErrorSafetyTestsstill pass unmodified.files of at most 8 KB, so 160 KB. The records are owner-only on POSIX and inherit NTFS ACLs on
Windows.
paths, SQLite constraint text and provider URLs, because that is what makes a local diagnostic
useful. Tokens and the argv secrets the redactor knows about are replaced with
[redacted], anda raw stack trace is never written. The protection for the rest is the file mode, not redaction -
the same trade-off the pre-existing
startup-<correlation>.failurefile already makes.CliFirstRunBootstrapper.ResolveDataDirectorywent fromprivatetointernal. No behaviourchange; it is now called by the sink so the two agree on the data directory.
Program.csre-points the sink after configuration resolves. Between managed entry and thatpoint the sink uses the environment-derived connection string, which can differ from an
appsettings-supplied one; in that window the record lands next to the environment's database
instead. That is the only directory available before configuration exists, and it is why the sink
is built twice rather than once.