fix(desktop): make proxy credential updates explicit and Host-owned - #3704
fix(desktop): make proxy credential updates explicit and Host-owned#3704Sun-GLiang wants to merge 10 commits into
Conversation
b94e9ab to
c5ec403
Compare
jackwener
left a comment
There was a problem hiding this comment.
Reviewed exact head c5ec40335dcaa0d19756bff067bced9cf2cf687c. I found two blocking correctness issues in the new proxy-credential write path: one cross-client race that can recreate a credential after authentication was disabled, and one partial-commit path that applies proxy policy even though the API reports failure. The hosted checks are green and the branch merges cleanly, but these state-consistency issues need to be resolved before approval.
| else await setCredential(client, PROXY_CREDENTIAL, proxy.password); | ||
| } | ||
| else if (proxy.credential?.kind === "replace") | ||
| await setCredential(client, PROXY_CREDENTIAL, proxy.credential.secret); |
There was a problem hiding this comment.
[P1] Make proxy policy and credential replacement one Host-owned operation across clients. The new queue is local to one RuntimeHostSettingsModule, so two supported Desktop clients have independent lanes. Client A can start a replacement and pause at the credential CAS; client B can then set authEnabled=false and delete the credential; when A receives credential_stale, its retry rereads the now-empty locator and recreates the secret with expected: null. A production-adapter/CAS probe ended with authEnabled=false while the vault again contained A's replacement secret. The existing "disable wins" test uses one module and therefore cannot cover this inter-client ordering. Please move the policy decision plus keep/replace/delete into one Runtime Host atomic or recoverable operation that validates both the policy revision and credential basis, and add a two-client test asserting the vault remains empty after disable.
There was a problem hiding this comment.
Fixed in 2948461. Desktop no longer performs a client-local credential CAS/retry. It sends the observed policy revision, credential basis, complete proxy policy, and keep/replace/delete intent through the Host-owned runtime.policy.network-proxy.update operation. The Host rejects a stale client before it can recreate a deleted credential.
Added coordinator and storage tests with two clients observing the same initial basis: client B disables authentication, then client A attempts the stale replacement. The replacement is rejected, the final policy remains authEnabled: false, and the vault remains empty.
| ): Promise<void> { | ||
| if (patch.network?.proxy) { | ||
| const proxy = patch.network.proxy; | ||
| await client.updateRuntimePolicy((policy) => ({ |
There was a problem hiding this comment.
[P2] Do not commit proxy policy before the credential write can still fail. A normal update containing both policy fields and credential: replace/delete first commits set_network_proxy here, then performs vault persistence below. If that second step rejects, the caller receives an error even though the Host is already using the new host/auth/username with the old credential. This is also reachable from the schema-v1 settings-plus-credentials import path. A production-adapter failure probe observed the rejected call with enabled=true, authEnabled=true, username=new-user, and the old secret still stored; the success control stored the replacement. Existing failure coverage sends a credential-only patch, so it never checks policy after the failure. Please make this a single Host-side compound operation, or define an explicit recoverable commit protocol, and test each persistence cut for committed/unchanged/known recovery semantics.
There was a problem hiding this comment.
Updated in 7be1502. The Desktop path now sends one Host-owned runtime.policy.network-proxy.update operation with the observed policy revision, credential basis, complete policy, and explicit keep/replace/delete intent. The Host validates both bases in its mutation lane.
Correction to my earlier reply: this implementation does not persist a transaction intent and does not perform automatic recovery. Storage orders replacement before enabling credential use, and stops policy use before deletion. A failure before the first publication leaves policy and credential unchanged; a later failure after one durable effect returns commit_outcome_unknown. Persistence-cut tests now cover both the unchanged and known-unknown cases for replacement and disable/delete.
|
CI status note: This PR does not change the slash-command/menu implementation or that test relative to current |
2a5df45 to
11ad303
Compare
cfbb008 to
6d1daf7
Compare
Wrong review state: no P1 findings, so this should not have requested changes.
Astro-Han
left a comment
There was a problem hiding this comment.
The core fix is right, and it is right at the level that matters. #3696's root cause is that the masking sentinel and the real secret were one representation: password lived on the persisted NetworkProxySettings, the renderer bound it as the editable value, and applyHostPatch accepted anything !== SENSITIVE_PLACEHOLDER as a new credential. This PR deletes the field, replaces it with a per-read derived passwordConfigured, and makes the write side an explicit keep/replace/delete operation that cannot round-trip a read value. That removes the duplicate representation rather than patching the mask, and it does not introduce a new authority — the vault stays sole owner.
Two things to resolve before merge, then some smaller items.
Blocking — the outstanding review rests on a premise the branch does not implement
This is not a code defect, so I am not giving it a severity; it blocks for a different reason.
The reply to @jackwener's P2 states that proxy policy and credential changes "now use one Host-side recoverable operation backed by a persisted transaction intent", with "persistence-cut coverage for the compound operation" and an intent that "is cleared after recovery", citing 2948461d4. That commit is not in this branch's history (force-pushed; head is 6d1daf75f), and none of it is present: git diff <merge-base> HEAD adds zero occurrences of a transaction intent, and the failure path is throw commitOutcomeUnknown('Network proxy update committed only some effects') after ordering the two commits and tracking a durableChange flag. There is no persisted intent, no recovery, and no failure-injection test for the compound commit.
The implemented behavior is defensible — @jackwener asked for committed/unchanged/known recovery semantics, and commit_outcome_unknown is a known label. But the reviewer should not approve on the stated description. Please either correct the reply or add what it describes.
For the record, @jackwener's P1 is genuinely fixed: runtime.policy.network-proxy.update validates expectedPolicyRevision and expectedCredential inside one inLane on the Host, Desktop no longer does a client-local CAS/retry, and both storage-level and coordinator-level two-client tests were added.
Scope — this is two intents
prod +1021 -160 (26 files)
test +1507 -41 (15 files)
Cohesively, (a) the data-model fix, the new Host operation with its epoch bump, and the legacy settings.json purge are one revertable intent and are the PR. (b) The config export/import schema-v1 adaptation and the runRuntimeHostSettingsExclusive lane refactor are a second: different subsystem, independently reviewable, and the source of the first P2 below — which has nothing to do with mask corruption. Splitting also drops what a reviewer must hold at once from 41 files to roughly 30 / 11.
P2 — a credentials-only export carries and re-applies the whole settings payload
runtime-host-config-ipc-main.ts:195. The gate widened to selected.has('settings') || selected.has('credentials'), and for a credentials request the payload is restoreHostSettingsSecrets(settings, secrets). Since buildConfigBundle derives the manifest from data presence, 'settings' enters includedData; on the other side applyConfigImport applies it on presence alone with no category filter (config-transfer-service.ts:102).
So exporting credentials only to move API keys to a second machine also ships privacy, chatDefaults, workspaceInstructions, subagents, shell, projects, personalization and the full proxy host/port — and importing overwrites all of the target's settings. The manifest does list settings and the import summary does say "Settings applied", so it is disclosed and recoverable, which is why this is P2 and not higher. It is still not what the user asked for. The schema-v1 motivation is legitimate; narrow it to the credential-bearing fields, or make the export state the dependency rather than smuggle the section.
P2 — window blur commits a partially typed password
password-input.tsx:129. relatedTarget is null on window blur, so destination is falsy and onFocusExit() fires. Cmd-Tab mid-typing replaces the stored credential with a partial password. It is recoverable by retyping, but the user gets no signal — proxy auth simply starts failing later with nothing pointing back here. Worth a guard on document.hasFocus() or relatedTarget === null && document.activeElement === input.
(I checked that onBlurCapture lands on a real DOM node: InputGroup spreads ...rest onto its inner role="group" div, so currentTarget.contains(relatedTarget) works and clicking the Eye button correctly does not commit.)
P2 — a dead secret-carrying override, and an unstated behavior change
toProxyTestInput no longer sets password, and it is the only production producer of TestProxyInput.proxy (the command-palette path passes undefined). So credentialOverride(input.proxy?.password) at runtime-host-settings-ipc-main.ts:215 is unreachable in production, and password on the network-proxy.test operation is a secret-carrying IPC and protocol field with zero producers. This PR already bumps the epoch 50 to 51, which makes it the cheap moment to drop it.
Separately: "Test current configuration" now commits the typed password first (runAfterProxyPasswordCommit), so testing a candidate password without saving it is no longer possible. That is a reasonable design, but it is a behavior change and is not in the Summary.
Ready to delete
preserveSensitivePlaceholders(settings-ipc-helpers.ts:73) — this PR edits it without noticing it has zero production callers, here and onmain; its only consumer is its own test.FileSettingsStore.testNetworkProxy()(packages/storage/src/settings-store.ts:41,186) — this PR edits its validation, and the edit is only needed because it reads apasswordfield that no longer exists. Zero callers repo-wide: the interface declaration and the implementation are the only two references.- The dual-shape
registerRuntimeHostSettingsIpcdeps union (runtime-host-settings-ipc-main.ts:164) — the legacy branch has no production caller, and the two branches produce different lanes, so a caller that takes the legacy one silently gets an unshared lane.
Security — clean
No new logging of secrets; every new error string is value-free. All fixture secrets are synthetic, and the diff removes several old proxy-secret fixtures. The settings.json purge is temp-file plus rename, and no data is lost because clientOwnedSettingsPatch never forwarded network — the vault has been the authoritative store all along. Adding runtime.policy.network-proxy.update to REMOTE_OWNER_OPERATION_GRANTS does not widen the trust boundary, since credential.vault.set is already there.
AI use: Claude Code assisted with source and issue investigation; the analysis and conclusions are my own.
6d1daf7 to
321b868
Compare
|
@Astro-Han Addressed the review in 7be1502:
I kept the schema-v1 compatibility change in this PR because secret removal changes where those schema-v1 credentials come from, but reduced it to credential-bearing fields so it no longer carries or reapplies unrelated settings. |
7be1502 to
f7f4f2f
Compare
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
f7f4f2f to
06e3dc0
Compare
M4n5ter
left a comment
There was a problem hiding this comment.
Reviewed at exact head 06e3dc03f45d2944e57e3b7a5ba533e344597b50 against base 6762085e2ba2372b7a8b296e08ebb67c0294dc2e. The hosted test, package, and windows_recovery checks are green.
The concrete correctness findings from the previous round are resolved here: credentials-only export is narrowed to credential-bearing schema-v1 fields, window blur no longer commits a partial draft, the dead candidate-password override and dead helpers are gone, and the failure-cut coverage now matches the implemented ordered-publication / commit_outcome_unknown contract. I found no additional P0–P3 correctness issue in this head.
Global-design blocker: this is still three independently reviewable changes presented as one small mask fix
The current review unit is 44 files with roughly +1080/-239 non-test lines. The problem is not the line count; it is that the branch carries three contracts with separate failure modes, rollback boundaries, and reviewers:
- schema-v1 import/export compatibility plus legacy settings-secret cleanup;
- one Host policy/vault CAS authority, including preflight, publication ordering, and unknown partial outcome;
- Renderer/Desktop removal of the sentinel plus draft, commit, cancel, reveal, and test UX.
Each is independently coherent and independently shippable. Keeping them together makes a credential representation fix also the review and rollback vehicle for a migration contract, a Host transaction contract, and a UI draft lifecycle. Please split these into three verticals.
If there is a real version-compatibility constraint that requires all three to land atomically, state that constraint explicitly and re-title/re-charter the PR as a credential-lifecycle/security migration. In that case it should be reviewed as the larger migration it is, not as a narrow mask corruption fix.
The test suite should follow the same boundary. Keep the invariant-bearing coverage: one offline authenticated-proxy E2E, Host two-client/CAS and durable failure cuts, draft save concurrency/failure cases, and minimal migration plus credential-only transfer cases. Move surface-local tests with their verticals rather than using one combined matrix to justify the combined PR; constant-only epoch assertions, repeated protocol-shape checks, and duplicate focus mechanics do not need to remain in this review unit.
Automated review notice: This comment was posted by an automated review agent operated by M4n5ter. It is not an independent human review and does not replace one.
|
I have updated the PR title and charter to match the change that is actually being reviewed. The title is now:
The PR description now presents this as a proxy-credential lifecycle migration rather than an input-mask-only fix. It separates the review into three explicit contracts:
I have not split the branch at this point. These contracts are independently testable, but the current implementation delivers them as one compatibility boundary. Splitting them would require a temporary dual-shape compatibility layer or restoring part of the unsafe representation during the intermediate merges:
The revised description does not claim an all-or-nothing storage transaction. It states the implemented contract directly: shared preflight, ordered publication, and The verification section is now organized around the invariant-bearing coverage for each review boundary: minimal migration and credential-only transfer cases, Host two-client/CAS and durable failure cuts, Renderer draft concurrency/failure cases, and one offline authenticated-proxy E2E. I have not moved or pruned tests while the review-unit decision remains open. Please re-evaluate the review unit under the revised title and charter. If maintainers still require a physical split after considering these compatibility dependencies, the fallback will be ordered stacked PRs rather than treating the three contracts as unrelated changes. |
Summary
This PR fixes #3696 as a proxy-credential lifecycle migration rather than an input-mask-only patch.
AppSettingsand Renderer input values; reads expose onlypasswordConfigured, while writes use explicit keep/replace/delete semanticscommit_outcome_unknownresult for partial durable failuresettings.json, keeping the credential vault as the only durable secret authorityWhy this lands together
The defect crosses one read/write representation boundary: a vault-owned secret was projected into ordinary Settings as an editable sentinel and then accepted back as a credential. Fixing only one layer leaves an unsafe intermediate contract.
passwordfrom Settings requires a read-only configured-state projection and a write-only credential mutation.settings.jsonfiles need cleanup so compatibility paths cannot retain or reintroduce the removed secret field.These areas remain separately testable, but under this implementation they are not separately releasable without adding a temporary dual representation or compatibility surface that preserves the defect. One revert restores the previous complete lifecycle; partial rollback of a dependent layer is not supported.
Review boundaries
The review can be evaluated as three explicit contracts inside that compatibility cut:
The focused suite keeps the invariant-bearing coverage for each boundary: minimal migration and credential-only transfer cases, Host two-client and durable failure cuts, Renderer draft concurrency/failure cases, and one offline authenticated-proxy E2E.
UI behavior
The recording shows sequential draft input, working show/hide, safe reload state, Escape cancellation without closing Settings, replacement after a saved password, and the proxy-only removal of Copy.
Verification
Proxy-Authorizationnpm run lintnpm run format:checknpm run buildnpm run typechecknpx knip --workspace apps/desktopnpx knip --workspace packages/uiAI use
Select exactly one:
Tool(s) and scope: OpenAI Codex implemented the fix, tests, verification, review-driven corrections, and this scope clarification.
Checklist
Does this PR entail a change in behavior?