feat(launching): record and revalidate launch receipts - #341
Conversation
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe PR adds launch receipts that capture launch configuration and lightweight workspace fingerprints. It revalidates previous receipts, reports configuration drift, records new receipts after startup, and displays informational drift notices without blocking successful launches. ChangesLaunch receipt tracking
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GameLauncher
participant LaunchReceiptService
participant Workspace
participant GameProfileLauncherViewModel
GameLauncher->>LaunchReceiptService: Revalidate previous receipt
LaunchReceiptService->>Workspace: Check receipt, executable, and archives
Workspace-->>LaunchReceiptService: Return current fingerprints
LaunchReceiptService-->>GameLauncher: Return drift warnings
GameLauncher->>GameLauncher: Start game process
GameLauncher->>LaunchReceiptService: Record new launch receipt
GameLauncher-->>GameProfileLauncherViewModel: Return successful launch with warnings
GameProfileLauncherViewModel-->>GameProfileLauncherViewModel: Show informational drift notice
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
📋 Issue PlannerBuilt with CodeRabbit's Coding Plans for faster development and fewer bugs. View plan used: ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code Review SummaryStatus: 1 Issue Found (Already Reported) | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
Files Reviewed (15 files)
Notes
|
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Resolved in this revision
Files Reviewed (5 files)
Previous Review Summaries (4 snapshots, latest commit 3b7c430)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 3b7c430)Status: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)SUGGESTION
Resolved in this revision
Files Reviewed (2 files)
Fix these issues in Kilo Cloud Previous review (commit ba47c9d)Status: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Files Reviewed (3 files)
Fix these issues in Kilo Cloud Previous review (commit 970d237)Status: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
WARNING
Files Reviewed (17 files)
Fix these issues in Kilo Cloud Previous review (commit 070d578)Status: 1 Issue Found (Already Reported) | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
Files Reviewed (16 files)
Notes
Reviewed by glm-5.2 · Input: 53.1K · Output: 12.6K · Cached: 294.7K |
…, environment and result-surfaced drift
070d578 to
970d237
Compare
|
Rebased onto
Instead the four commits unique to this branch were replayed onto The rebase applied cleanly but the test suite has not been run against the rebased branch, so CI here is the first verification. #340 received the equivalent rebase and passed 1,553 tests. No approvals existed, so nothing was dismissed by the force-push. |
| } | ||
|
|
||
| report.Receipt = receipt; | ||
| CompareExecutable(receipt.Executable, report); |
There was a problem hiding this comment.
WARNING: Unguarded receipt-field access can abort a launch that the documented invariant says must never block on receipts.
The JSON parse immediately above is wrapped defensively, but CompareExecutable(receipt.Executable, report) and the foreach (... in receipt.ArchiveRoots) below it are not. A corrupt or tampered receipt that still parses successfully — e.g. "Executable": null, "ArchiveRoots": null, or a null archive-root entry — produces an unhandled NullReferenceException here; an uncaught SecurityException or transient IO error from FileInfo inside CompareExecutable has the same effect. RevalidateAsync is awaited on the launch path (RevalidateLaunchReceiptAsync -> LaunchProfileAsync), so the exception propagates and fails the launch, contradicting the stated guarantee that receipt and drift issues never block a launch. Wrap this block in the same try/catch used for the parse, or null-guard the receipt fields before use.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| receipt.ManifestVersions[manifestId] = version; | ||
| } | ||
|
|
||
| foreach (var (variableName, value) in context.EnvironmentVariables) |
There was a problem hiding this comment.
Deterministic hashes expose secrets
When a profile supplies a low-entropy credential as an environment variable, RecordLaunchAsync stores its unsalted SHA-256 digest in the workspace receipt, allowing anyone who reads the receipt to recover the credential by hashing likely values offline. How this was verified: Profile environment values flow directly into the receipt context and are transformed with unkeyed, unsalted SHA-256 before being written to disk.
Prompt To Fix With AI
This is a comment left during a code review.
Path: GenHub/GenHub/Features/Launching/LaunchReceiptService.cs
Line: 68
Comment:
**Deterministic hashes expose secrets**
When a profile supplies a low-entropy credential as an environment variable, `RecordLaunchAsync` stores its unsalted SHA-256 digest in the workspace receipt, allowing anyone who reads the receipt to recover the credential by hashing likely values offline. **How this was verified:** Profile environment values flow directly into the receipt context and are transformed with unkeyed, unsalted SHA-256 before being written to disk.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // Names, never values. These lines reach the log and the post-launch notice, both of | ||
| // which travel further than the machine that produced them, and a profile-defined | ||
| // variable can carry a credential. Which variable changed is the actionable part. | ||
| foreach (var (variableName, recordedHash) in receipt.EnvironmentVariableHashes) |
There was a problem hiding this comment.
WARNING: Null EnvironmentVariableHashes dereference can still abort the launch.
This foreach dereferences receipt.EnvironmentVariableHashes with no null guard. A receipt that parses but deserializes with "EnvironmentVariableHashes": null (a tampered or corrupt file) reaches CompareEnvironment through CompareUpcomingLaunch on the launch path, where the iteration throws NullReferenceException; LaunchProfileAsync's catch-all then turns that into a launch failure. That is the same corrupt-receipt outcome the new RevalidateAsync guard (lines 136-159) and the RevalidateAsync_WithNullReceiptFields_ReportsDriftWithoutThrowing test were added to prevent, so the tolerance added on the read path is not extended to the comparison path and a null-field receipt still blocks a launch.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| report.DriftedFields.Add($"Game type changed from {receipt.GameType} to {upcoming.GameType}"); | ||
| } | ||
|
|
||
| if (receipt.Executable is not null && |
There was a problem hiding this comment.
SUGGESTION: A null recorded Executable silently skips executable drift rather than reporting it.
When receipt.Executable is null (a corrupt or tampered receipt that deserializes with "Executable": null) but upcoming.ExecutablePath is set, this if is false and no drift line is added. The sibling CompareArchiveRootConfiguration handles the analogous case by reporting "newly configured" when the recorded root is null but an upcoming root exists (lines 308-312). Note that the surrounding try/catch would otherwise have turned a null dereference into a generic "Receipt could not be compared" drift line, so this guard actually leaves the executable field as the one comparison that reports nothing for a null recorded value. For consistency, consider reporting the executable as newly/differently configured here too (e.g. Executable path changed from (none) to {upcoming.ExecutablePath}).
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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
`@GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs`:
- Around line 311-383: Add a test alongside
LaunchProfileCommand_WithReceiptDrift_ShowsInformationalNotice that supplies
more than MaxReceiptDriftNoticeLines receipt warnings, executes the launch, and
verifies the ShowInfo message contains no more than five drift-warning lines
while retaining the launch-configuration notice. Use the existing
GameLaunchInfo, IProfileLauncherFacade, and notification-service verification
patterns.
In
`@GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs`:
- Around line 1089-1126: Extend
LaunchProfileAsync_WhenReceiptRecordingFails_StillSucceeds with a thrown
receipt-recording failure by configuring RecordLaunchAsync to use ThrowsAsync,
such as an IOException or OperationCanceledException, instead of returning
OperationResult.CreateFailure. Execute the launch and assert the result still
succeeds, covering the exception-handling path in
GameLauncher.RecordLaunchReceiptAsync.
In
`@GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs`:
- Around line 565-577: Update the Dispose teardown in
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs
lines 565-577 to catch Directory.Delete failures matching IOException or
UnauthorizedAccessException, preserving best-effort cleanup. Apply the same
filtered exception handling to the Dispose teardown around
Directory.Delete(_retailRoot, recursive: true) in
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
lines 1131-1143.
In `@GenHub/GenHub/Features/Launching/GameLauncher.cs`:
- Line 1397: The post-spawn receipt write must never turn an already-started
launch into a failure. In GenHub/GenHub/Features/Launching/GameLauncher.cs lines
1397-1397, call RecordLaunchReceiptAsync with CancellationToken.None and wrap
its body in a try/catch that logs failures and returns. In
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
lines 1089-1126, add coverage configuring RecordLaunchAsync to throw and assert
LaunchProfileAsync still reports success.
In `@GenHub/GenHub/Features/Launching/LaunchReceiptService.cs`:
- Around line 369-376: Update LaunchReceiptService so HashEnvironmentValue
accepts a salt and hashes the salt together with the environment value. Generate
a cryptographically random per-receipt salt in RecordLaunchAsync, store it in
the receipt’s EnvironmentHashSalt property, and pass it to every initial hash
call. Update CompareEnvironment to rehash current values with the recorded salt,
preserving empty-string fallback for legacy receipts, and add the corresponding
property to LaunchReceipt.
🪄 Autofix
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: ASSERTIVE
Plan: Pro Plus
Run ID: 9fab1367-fda5-488d-9cf4-6e484663cf01
📒 Files selected for processing (17)
GenHub/GenHub.Core/Constants/FileTypes.csGenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.csGenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.csGenHub/GenHub.Core/Models/Launching/LaunchReceipt.csGenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.csGenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.csGenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.csGenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.csGenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.csGenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.csGenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.csGenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.csGenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.csGenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.csGenHub/GenHub/Features/Launching/GameLauncher.csGenHub/GenHub/Features/Launching/LaunchReceiptService.csGenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs
| /// <summary> | ||
| /// Verifies that a successful launch carrying receipt drift shows an informational | ||
| /// notice naming the drift, while the success presentation stays unchanged. | ||
| /// </summary> | ||
| /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||
| [Fact] | ||
| public async Task LaunchProfileCommand_WithReceiptDrift_ShowsInformationalNotice() | ||
| { | ||
| var notificationService = new Mock<INotificationService>(); | ||
| var launcherFacade = new Mock<IProfileLauncherFacade>(); | ||
| var launchInfo = new GameLaunchInfo | ||
| { | ||
| LaunchId = "launch-1", | ||
| ProfileId = "profile-1", | ||
| WorkspaceId = "profile-1", | ||
| ProcessInfo = new GameProcessInfo { ProcessId = 123 }, | ||
| ReceiptDriftWarnings = ["Executable size changed from 1 to 2 bytes: generalszh"], | ||
| }; | ||
| launcherFacade.Setup(x => x.LaunchProfileAsync("profile-1", It.IsAny<bool>(), It.IsAny<CancellationToken>())) | ||
| .ReturnsAsync(ProfileOperationResult<GameLaunchInfo>.CreateSuccess(launchInfo)); | ||
|
|
||
| var vm = CreateLauncherViewModel(launcherFacade, notificationService); | ||
| var profileItem = CreateProfileItem("profile-1", "Test Profile"); | ||
|
|
||
| await vm.LaunchProfileCommand.ExecuteAsync(profileItem); | ||
|
|
||
| Assert.Contains("launched successfully", vm.StatusMessage); | ||
| notificationService.Verify( | ||
| x => x.ShowSuccess("Game Launched", It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<bool>()), | ||
| Times.Once); | ||
| notificationService.Verify( | ||
| x => x.ShowInfo( | ||
| "Launch Configuration Changed", | ||
| It.Is<string>(m => | ||
| m.Contains("Launch configuration changed since the last run") && | ||
| m.Contains("Executable size changed from 1 to 2 bytes")), | ||
| It.IsAny<int?>(), | ||
| It.IsAny<bool>()), | ||
| Times.Once); | ||
| notificationService.Verify( | ||
| x => x.ShowError(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<bool>()), | ||
| Times.Never); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Verifies that a successful launch without receipt drift shows no notice. | ||
| /// </summary> | ||
| /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||
| [Fact] | ||
| public async Task LaunchProfileCommand_WithoutReceiptDrift_ShowsNoNotice() | ||
| { | ||
| var notificationService = new Mock<INotificationService>(); | ||
| var launcherFacade = new Mock<IProfileLauncherFacade>(); | ||
| var launchInfo = new GameLaunchInfo | ||
| { | ||
| LaunchId = "launch-1", | ||
| ProfileId = "profile-1", | ||
| WorkspaceId = "profile-1", | ||
| ProcessInfo = new GameProcessInfo { ProcessId = 123 }, | ||
| }; | ||
| launcherFacade.Setup(x => x.LaunchProfileAsync("profile-1", It.IsAny<bool>(), It.IsAny<CancellationToken>())) | ||
| .ReturnsAsync(ProfileOperationResult<GameLaunchInfo>.CreateSuccess(launchInfo)); | ||
|
|
||
| var vm = CreateLauncherViewModel(launcherFacade, notificationService); | ||
| var profileItem = CreateProfileItem("profile-1", "Test Profile"); | ||
|
|
||
| await vm.LaunchProfileCommand.ExecuteAsync(profileItem); | ||
|
|
||
| Assert.Contains("launched successfully", vm.StatusMessage); | ||
| notificationService.Verify( | ||
| x => x.ShowInfo(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<bool>()), | ||
| Times.Never); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Add a test for the drift notice cap.
GameProfileLauncherViewModel defines MaxReceiptDriftNoticeLines = 5. The two new tests cover one drift warning and zero drift warnings. Neither test supplies more warnings than the cap.
The cap is a stated requirement of this change. Without a test, a regression that removes it produces an unbounded notification body and no test fails. Add a case with more than five warnings and assert the rendered message includes at most five drift lines.
🤖 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
`@GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs`
around lines 311 - 383, Add a test alongside
LaunchProfileCommand_WithReceiptDrift_ShowsInformationalNotice that supplies
more than MaxReceiptDriftNoticeLines receipt warnings, executes the launch, and
verifies the ShowInfo message contains no more than five drift-warning lines
while retaining the launch-configuration notice. Use the existing
GameLaunchInfo, IProfileLauncherFacade, and notification-service verification
patterns.
| public void Dispose() | ||
| { | ||
| try | ||
| { | ||
| Directory.Delete(_root, recursive: true); | ||
| } | ||
| catch (IOException) | ||
| { | ||
| // Best effort; a leftover temp directory is not worth failing the run over. | ||
| } | ||
|
|
||
| GC.SuppressFinalize(this); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Both new test fixtures catch only IOException during teardown. Directory.Delete also throws UnauthorizedAccessException when a file in the temporary directory is read-only or a handle is still open. On Windows that is a realistic outcome for the executable and archive fixtures, and the escaping exception fails a test run that otherwise passed.
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs#L565-L577: change the filter aroundDirectory.Delete(_root, recursive: true)tocatch (Exception ex) when (ex is IOException or UnauthorizedAccessException).GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs#L1131-L1143: apply the same filter aroundDirectory.Delete(_retailRoot, recursive: true).
📍 Affects 2 files
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs#L565-L577(this comment)GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs#L1131-L1143
🤖 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
`@GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs`
around lines 565 - 577, Update the Dispose teardown in
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs
lines 565-577 to catch Directory.Delete failures matching IOException or
UnauthorizedAccessException, preserving best-effort cleanup. Apply the same
filtered exception handling to the Dispose teardown around
Directory.Delete(_retailRoot, recursive: true) in
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
lines 1131-1143.
| foreach (var (variableName, value) in context.EnvironmentVariables) | ||
| { | ||
| receipt.EnvironmentVariableHashes[variableName] = | ||
| HashEnvironmentValue(value, receipt.EnvironmentHashSalt); |
There was a problem hiding this comment.
Receipt hashes remain guessable
When a profile supplies a low-entropy secret as an environment value, the receipt stores its HMAC alongside the key required to calculate candidate hashes, allowing anyone who obtains the receipt to test likely values offline and recover the secret. How this was verified: Profile environment values flow into the persisted receipt, which serializes both each value's HMAC and EnvironmentHashSalt.
Prompt To Fix With AI
This is a comment left during a code review.
Path: GenHub/GenHub/Features/Launching/LaunchReceiptService.cs
Line: 69-72
Comment:
**Receipt hashes remain guessable**
When a profile supplies a low-entropy secret as an environment value, the receipt stores its HMAC alongside the key required to calculate candidate hashes, allowing anyone who obtains the receipt to test likely values offline and recover the secret. **How this was verified:** Profile environment values flow into the persisted receipt, which serializes both each value's HMAC and `EnvironmentHashSalt`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary
Record what each successful launch consisted of and cheaply compare subsequent launches against that receipt so configuration and filesystem drift is visible.
Changes
Testing
dotnet test GenHub/GenHub.sln -c Release— 1,476 tests passed.Risks and rollback
Routine revalidation deliberately avoids content hashing, so a same-size replacement with a deliberately preserved timestamp is not detected. Receipt persistence is best-effort and does not fail an otherwise successful launch. Reverting this PR removes receipt recording and drift notifications.
Related issues
Fixes #323
Greptile Summary
This PR adds versioned launch receipts and drift reporting.
Confidence Score: 4/5
This PR should not merge until launch receipts stop exposing an offline verification oracle for profile environment secrets.
Profile-defined environment values are transformed into HMACs, but the receipt serializes the HMAC key alongside those hashes, allowing anyone who obtains the file to test likely values and recover low-entropy credentials.
Files Needing Attention: GenHub/GenHub/Features/Launching/LaunchReceiptService.cs; GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs
Security Review
The revised environment hashing still permits offline recovery of low-entropy secrets because each receipt stores both the keyed hashes and the key required to verify guesses.
Important Files Changed
Sequence Diagram
sequenceDiagram participant UI participant Launcher as GameLauncher participant Receipt as LaunchReceiptService participant Workspace participant Process UI->>Launcher: Launch profile Launcher->>Receipt: Revalidate previous receipt Receipt-->>Launcher: Filesystem drift + previous receipt Launcher->>Workspace: Prepare workspace Workspace-->>Launcher: WorkspaceInfo Launcher->>Receipt: Compare upcoming configuration Receipt-->>Launcher: Configuration drift Launcher->>Process: Start game Process-->>Launcher: ProcessInfo Launcher->>Receipt: Record successful launch Launcher-->>UI: LaunchInfo + drift warningsPrompt To Fix All With AI
Reviews (6): Last reviewed commit: "fix(launching): salt receipt environment..." | Re-trigger Greptile
Context used: