Skip to content

🤖 feat: sync CoderAIProvider drift, key rotation and kill switches - #265

Merged
ThomasK33 merged 3 commits into
mainfrom
ai-provider-drift
Oct 13, 2026
Merged

ThomasK33 merged 3 commits into
mainfrom
ai-provider-drift

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

The dormant CoderAIProvider reconciler now keeps Coder in line with the spec after the create. Each reconcile reads the provider and sends one sparse PATCH with every field that differs. The base URL and the key always travel in the same PATCH. A changed key is resent safely, and a dashboard delete, disable or key change stalls the object until the spec changes (kill switches). The controller is still not registered, so users see no change.

This PR also fixes the two findings deferred from #253.

Background

#154 adds AI providers, chat models and AI policies as controller CRDs. This is the fifth CoderAIProvider layer, after the API types (#235), the fake Coder routes (#237), the skeleton (#245) and the create (#253). The plan's A5 is split in two to keep this PR reviewable. NameReservedByEnv and the narrowed-destination rule from the #253 final assessment go in the next PR (A5b), which lands before activation (A7). The narrowed-destination rule is now an A7 gate.

Sync

Every reconcile of an object that has a Coder ID:

  1. GET /api/v2/ai/providers/{id}. The answer must match the ID, the name and the type.

  2. Kill switches, only after a full apply of the current generation (status.appliedGeneration == metadata.generation). The delete and the disable count also while a key resend is pending, so a Secret rotation never undoes a revocation. The key check waits for a pending resend, because the resend changes the key itself:

    Coder state Reason Effect
    The provider is gone DeletedExternally Stalled, zero writes
    The provider is disabled, and the spec enables it DisabledExternally Stalled, zero writes
    Coder's key list is not the one key this object sent KeyChangedExternally Stalled, zero writes

    A spec edit re-arms the object. The next sync then creates the provider again (only if the name is free), enables it, or sends its key again. A provider that is gone before a full apply is created again without a spec edit.

  3. One sparse PATCH with every field that differs: display name (an empty spec name matches Coder showing the provider name), icon, enabled flag, base URL, and the key when it must be sent. No difference means no PATCH. The answer must show every requested value.

  4. Key rules (plan 5.3):

    • The key is sent when the change token (Secret name, key, UID, resourceVersion) differs from status.credential, unless an in-memory SHA-256 of the last sent value shows that only the Secret's metadata changed. After a restart, that map is empty, so one resend can happen.
    • Before a key PATCH, the controller writes status.credential.resendAttemptTime (the resend marker). A crash after the PATCH leaves the marker set, and the next reconcile sends the key once more. Coder ends with exactly that key.
    • On success it records the new key ID and the new change token, and clears the marker.
  5. A 401, 403 or 429 is retried after 30 s. Another 4xx stalls with CoderRejected. A 5xx or a transport error is retried.

The base URL and the key in one PATCH means two things. Coder never pairs the old key with a new URL. And a provider is never enabled at a stale URL, because the enable and the URL go in the same request.

#253 follow-ups

  1. A 4xx create answer is confirmed by a read. A proxy can put a 4xx in place of a committed create answer. The controller now reads the provider name after any 4xx, and it clears the create marker only when the read proves that nothing of this object exists (404, with a site-owner operator user):

    • Nonce found: the object owns the provider.
    • Another provider: NameConflict.
    • 404: CreateRetrying for 401, 403, 409 and 429, and CoderRejected for any other status.
    • The read fails: the marker stays.

    Only a failed dial or TLS handshake still counts as "no effect" without a read. The shared createHadNoEffect 406 rule (from CoderTemplateTest) no longer applies to providers.

  2. A nonce match without a recorded credential is accepted. It no longer fails an assertion. The object records the provider with an empty change token, so the sync sends the current key.

  3. No enable at a stale base URL (see Sync).

Validation

New internal/controller/coderaiprovider_sync_test.go (envtest plus the fake Coder). Most steps use a new reconciler, as a restart would.

  • S13: a Ready provider sends no write on later reconciles, with a display name and with an empty one.
  • Drift: dashboard edits of the display name and icon are restored by one PATCH that does not touch the key.
  • Key rotation:
    • A new Secret value gives exactly one PATCH and a new change token and key ID.
    • A label change gives zero PATCHes.
    • After a restart, one label change gives one resend.
    • Switching apiKeySecretRef.key inside the same Secret resends.
  • 🤖 feat: create the Coder provider for a CoderAIProvider #253 follow-up 3: after a failed first apply, a base URL change PATCH that fails leaves the provider disabled at the old URL. The next PATCH sends both, and the provider ends enabled at the new URL. A later URL change keeps the key.
  • S12 combined edit: a new Secret and a new origin go in one PATCH.
  • Resend crash: the key PATCH commits and then hangs. The marker is stored. The next reconcile sends the key once more, and Coder ends with exactly that key.
  • A dashboard delete or disable while a key resend is pending stalls with zero writes.
  • A provider deleted between the create and the first sync gives Pending, and the next reconcile resolves it by name.
  • A key update answer with a zero key ID is a mismatch, and the resend marker stays.
  • S14: each kill switch stalls with zero writes over three reconciles. A spec edit whose first apply fails does not count as applied, so the next reconcile applies again. The object then ends Ready, with the provider created again, enabled again, or holding its key again.
  • S3 end to end: Coder echoes the key in Message, Detail and validations of a rejected create and a rejected key PATCH. The key appears in no status, annotation, log or error.
  • 🤖 feat: create the Coder provider for a CoderAIProvider #253 follow-up 1: a 406 and a 400 after commit each end with one owned provider and one create.
  • 🤖 feat: create the Coder provider for a CoderAIProvider #253 follow-up 2: a late-committed nonce provider without a recorded credential is recorded, and the current key replaces the unknown one.
  • The hang tests also scan the returned error and the status for the key.
  • A 404 in place of a committed sync PATCH keeps ownership, and the next reconcile reports Ready without a second create.
  • A rejected create whose confirmation read fails once is not sent again.
  • A create answer without a provider ID fails with assertion failed:, and the next reconcile reads Coder and reports Ready.

The A4 tests were updated where behavior intentionally changed. A Ready object now costs one GET per reconcile (the plan's resync read), and the old apply guards became the sync tests above.

Mutation check: I broke the sync and create code in 19 ways (a 404 update not confirmed by a read, a rejected create not remembered, a zero ID handled softly, a provider gone right after the create not handled, a zero key ID accepted, a disable skipped during a resend, URL not sent with the key, enable sent before the URL, each of the three kill switches off, a deleted provider always created again, the in-memory hash ignored, the resend marker not written first, the status pointer not refreshed after the marker write, a PATCH without a difference, a kill switch that blocks a spec edit, a dashboard key not replaced on re-arm, a 4xx cleared without a read, a nil credential that fails an assertion). Each change made a test fail. One of them found a real bug while I wrote the tests: the status update after the resend marker replaces provider.Status, so the new key ID was lost. That is fixed.

Local gates on the pushed tree: make verify-vendor, make test, make test-integration, make build, make lint, GOFLAGS=-mod=vendor go tool govulncheck ./..., go test -race ./internal/controller/, and regeneration with no diff.

Review round 1

The automatic Codex review on ee660340 left three P2 findings:

  1. "Handle a provider disappearing after creation": fixed in 2c8585b8. The nil step after a delete between the create and the first sync is gone. The reconcile reports Pending, and the next one looks up the name again.
  2. "Reject zero key IDs in update responses": fixed in 2c8585b8. A zero key ID is a mismatch, and the resend marker stays.
  3. "Honor pending key resends when the provider is missing": rejected. A delete in Coder after a full apply is a kill switch by design (plan D11, threat T15: an admin deletes a leaked provider, and the controller must not create it again). A Secret rotation does not change the generation, so it must not re-arm the object. 2c8585b8 makes the disable kill switch apply during a pending resend too, for the same reason. A spec edit re-arms the object as before.

Review round 2

The requested reviews on 2c8585b8: the security review found no issues. The normal review left three findings, all fixed in ef5e6bc7:

  1. P2 "Confirm a 404 before discarding provider ownership": a 404 to a sync PATCH is now confirmed by a read by ID. A proxy can put a 404 in place of a committed PATCH that already replaced the nonce. While the provider exists, ownership stays and the reconcile retries.
  2. P2 "Preserve non-retryable create rejections across confirmation retries": when the read after a rejected create fails, the wait reason ConfirmingRejectedCreate remembers the rejection. A later read that confirms absence then stalls with CoderRejected instead of sending the create again.
  3. P1 "Keep zero provider IDs as fail-fast errors": a provider answer without an ID is an assertion failed: error again. Only the recoverable case, a nonce match without a recorded credential, stays soft.

Review round 3

The requested reviews on ef5e6bc7: the security review found no issues. The normal review left one valid P2, "Validate the key when the PATCH omits it". If a spec edit re-arms the provider and someone rotates the key in Coder between the GET and a PATCH that sends no key, the next reconcile reports KeyChangedExternally. The independent advisor agreed it is not a blocker. The outcome is fail-safe (Stalled, zero writes), the dashboard change is the kind that the kill switch respects (D11), and another spec edit recovers it. Under the review-round cap (from round 4, blockers only), it is deferred to A5b as #268, together with the advisor's note that a dashboard key change during a pending resend is overwritten by the Secret's value.

Review round 4 (rebased head)

The requested reviews on 6a7811ea: the security review found no issues. The normal review left two findings. Neither is a blocker, so both are deferred to #268 (A5b, before activation):

  1. P2 "Preserve rejected creates across owner-check retries": the worst case is one more POST of the same rejected spec, which Coder rejects again.
  2. P1 "Fail fast on mismatched sync responses": the CoderAnswerMismatch stall is the plan's design (section 4.1 item 8) and matches the merged CoderTemplateTest handler (waitOnWrongAnswer). The condition keeps the assertion failed: text. The missing piece is an error log, which CoderAIProvider: check the key list on a key-less sync PATCH, and report key changes during a resend #268 adds.

Shared test change

fakeFault gets a Message field that replaces the error answer's message. The default stays the same, so other fake users do not change.

Tracked for later layers

Rebase

The branch is rebased onto main at 554c65a3, which includes #258 (the shared contract now also refuses a control plane's license and database Secrets). There were no conflicts. All gates passed again on the rebased tree. The review rounds above refer to the commits before this rebase. The same changes are now bb1bc714-based commits ending in the current head.

Size

266 production lines added and 58 removed. 543 test lines added and 100 removed. No generated files change.

Refs #154


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $40.56

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T21:56:52.803461Z 6a7811e Manual request
🔒 Security Review ✅ Completed 2026-10-13T21:55:20.891877Z 6a7811e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ee660340d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/coderaiprovider_controller.go Outdated
Comment thread internal/controller/coderaiprovider_sync.go Outdated
Comment thread internal/controller/coderaiprovider_sync.go
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 2c8585b811

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c8585b811

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/coderaiprovider_sync.go
Comment thread internal/controller/coderaiprovider_create.go Outdated
Comment thread internal/controller/coderaiprovider_create.go Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef5e6bc715

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/coderaiprovider_sync.go
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: ef5e6bc715

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

The CoderAIProvider reconciler (still not registered) now reads the Coder
provider on every reconcile and sends one sparse PATCH with every field
that differs from the spec. The base URL and the key travel in the same
PATCH, so Coder never enables a provider at a stale URL. A changed key
Secret value is resent behind a resend marker, and an in-memory SHA-256
skips resends for metadata-only Secret changes. After a full apply, a
dashboard delete, disable or key change stalls the object until the spec
changes. A 4xx create answer is now confirmed by a read before the create
marker is cleared, and a nonce match without a recorded credential is
recorded instead of failing an assertion.

Refs #154

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_

Change-Id: Id83a2bc2b262042bfce91b4e712d60f1ec7094df
… key IDs

A provider deleted between the create and the first sync no longer
dereferences a nil step: the next reconcile looks up the name again.
A key update answer with a zero key ID is a mismatch, and the resend
marker stays. A dashboard disable during a pending key resend is still a
kill switch, so a Secret rotation cannot undo it.

Refs #154

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_

Change-Id: I3c31c5d8dcada80daf2f2b4a4dcbded1d576e0b9
…s stalled

A 404 to a sync PATCH is confirmed by a read before the controller drops
ownership, because a proxy can put a 404 in place of a committed PATCH
that already replaced the nonce. A rejected create whose confirmation read
fails is remembered, so a later read that confirms absence stalls instead
of sending the create again. A create answer without a provider ID is an
assertion failure again.

Refs #154

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_

Change-Id: I20e41722d73b684b02a240971521de86ecee71ed
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 6a7811ea0d

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a7811ea0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/controller/coderaiprovider_create.go
Comment thread internal/controller/coderaiprovider_sync.go
@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 7bc97e3 Oct 13, 2026
10 checks passed
@ThomasK33
ThomasK33 deleted the ai-provider-drift branch October 13, 2026 22:16
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