Skip to content

fix(custody): carry the vault credential id in manifest bindings - #218

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/custody-binding-credential-id
Open

iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/custody-binding-credential-id

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Found by attempting the custody flip on a live deployment. The preflight refused, correctly, and the refusal was unfixable from config — the main account's vault credential id cannot be expressed in the handle manifest at all.

The blocker

The manifest binds an account label to a Claustrum credential id. The parser derived the expected id from the label (oauth:anthropic:<label>) and rejected any entry that disagreed. Main's credential lives on the unlabelled provider-default lane, oauth:anthropic, so both encodings refuse:

credential_id outcome
oauth:anthropic (the real one) corrupt-binding at parse
oauth:anthropic:main (the derived one) credential_identity_mismatch at runtime — the vault returns the real id

Verified against the parser before writing anything, and again after the fix:

corrupt: []
  main     -> {"status":"resolved","credentialId":"oauth:anthropic"}
  work-alt -> {"status":"resolved","credentialId":"oauth:anthropic:work-alt"}

Not specific to main. antigravity:google is unlabelled the same way; anything whose id does not happen to match its label hits this.

Why carry the id rather than special-case main

A derived identifier encodes a convention as a constraint. Conventions change; parser constraints do not.

The parse-time equality check compared the manifest against itself — two fields the same writer wrote in the same breath. It can catch corruption, not a wrong id. The check that means something is the runtime fence in custody-mode.ts:

credential.credentialId !== binding.credentialId -> refuse

That one has vault ground truth on one side. It is unchanged here. Dropping the derivation loses no failure path: a typo'd id gets not_found from the vault (uniform mask, fails closed), a mismatched id gets the fence.

Special-casing main would leave the derivation in place and add an exception to it — working today, broken again at the next unlabelled credential.

Changes

  • readCustodyHandles carries credential_id verbatim; all other validation (handle shape, label validity, superseded parsing, corrupt-label path) is untouched.
  • resolveCustodyHandle matches on label and returns the entry's own id.
  • Duplicate-label guard, new. The label is now the sole lookup key, so two entries sharing one had to stop silently resolving to whichever came first — every entry with a duplicated label goes to corruptLabels. This replaces what the derivation was incidentally doing.
  • Preflight freshness gate. It passed refresh_floor + 30min (~4.5h) to cache.get. In Claustrum that value is a staleness floor — a get below it forces an upstream refresh, so a readiness check silently rotated the credential it was about to use. Because the vault refreshes on a cadence close to the token lifetime, a credential sits below a 4.5h floor for most of its cycle: main is below it for ~4.5h of every ~8h. Replaced with a 5-minute serving margin (CUSTODY_PREFLIGHT_MIN_TTL_MS). The vault owns rotation; the preflight only needs the credential to serve the takeover.
  • Refusal text for TAKEOVER_INCOMPLETE_MAIN_REAL pointed at ck auth migrate-plugin, which exists on no deployed Claustrum binary — it is parked on an unmerged branch. Reworded to the supported path (ck auth mint-handle; this plugin writes the manifest entry).

Verification

Each behaviour is pinned by a mutation of implemented logic, not a missing symbol.

  • Restoring the derivation reddens both new manifest tests: returns the entry's own credential id verbatim (Expected "resolved", received "unresolved") and the duplicate-label test (Set {} vs Set { "alice" }).
  • Restoring the 4.5h floor reddens preflight hands a small serving margin to cache.get even when expiry is far below the refresh floor — the credential expires in 1h, comfortably above the 5-minute margin and far below the old floor. The test asserts the argument handed to cache.get, not just the outcome, so it cannot pass for the wrong reason.
  • Restoring the old guidance reddens the ck auth mint-handle assertion.
  • core 188/188 · opencode 1872/1872 · pi 114/114 · typecheck, format, biome clean.

Not included

Whether main should eventually be re-homed onto a 3-segment id in the vault. That is a real option with a migration — Insula's handle binding, the sealer, both recorders, the latch-watch and the rotation probe all key on the literal oauth:anthropic — and it should not happen as a side effect of unblocking a flip.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes custody takeover preflight being blocked for credentials whose vault id doesn't match their label, like the main account's oauth:anthropic. The manifest previously derived the expected credential id from the label and rejected real ids at parse time; it now carries credential_id verbatim, and the runtime fence in custody-mode.ts remains the authority.

Bug Fixes

  • Duplicate labels now mark every sharing entry as corrupt instead of silently resolving to the first one.
  • Preflight passed refresh_floor + 30min as min_ttl_ms to cache.get, which treats it as a staleness floor and forced an upstream refresh during a readiness check; it now uses a 5-minute serving margin (CUSTODY_PREFLIGHT_MIN_TTL_MS).
  • TAKEOVER_INCOMPLETE_MAIN_REAL guidance pointed at the nonexistent ck auth migrate-plugin; it now suggests ck auth mint-handle.

Written for commit 5659207. Summary will update on new commits.

Review in cubic

A derived identifier encodes a convention as a constraint. The
custody handle manifest previously derived the expected vault
credential id from the account label (`oauth:anthropic:<label>`)
and rejected any entry that disagreed. That encodes the writer's
naming convention as a parser invariant, so the parse-time
comparison can only catch two fields disagreeing that the same
writer wrote together — it is a corruption check, not an
authorization check.

The live flip it blocked: the main account's vault credential id
is the unlabelled provider-default `oauth:anthropic`, which the
derivation rule rejected as non-canonical. Writing the true id
yielded `corrupt-binding` at parse time; writing the derived id
yielded `credential_identity_mismatch` at runtime because the
vault returns the real one.

Carry the identifier verbatim. `readCustodyHandles` no longer
requires `entry.credential_id === custodyCredentialId(label)`;
`resolveCustodyHandle` matches on label alone and returns the
entry's own `credentialId`. The runtime fence in
`packages/opencode/src/custody-mode.ts` (`credential.credentialId
!== binding.credentialId` -> refuse) keeps vault ground truth on
one side and is unchanged.

Label becomes the sole lookup key, so a new guard rejects
duplicate labels per provider block: every entry sharing the
duplicated label goes to `corruptLabels` rather than silently
picking a winner.

The preflight freshness gate was passing
`min_ttl_ms = refresh_floor + 30min` (~4.5 h) to `cache.get`.
In Claustrum that value is a staleness FLOOR — a get below it
forces an upstream refresh. The vault refreshes these
credentials close to their token lifetime, so a credential sits
below a 4.5 h floor for most of its cycle, silently rotating
during a readiness check. Replace with a small serving margin
(5 min, named constant) — the vault owns rotation, and the
preflight only needs "fresh enough to serve the takeover".

The `TAKEOVER_INCOMPLETE_MAIN_REAL` refusal still pointed at
`ck auth migrate-plugin`, which does not exist on a deployed
Claustrum binary. Reword to the supported path: mint a handle
with `ck auth mint-handle` and the manifest entry is written
by this plugin.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 8 files

Confidence score: 3/5

  • In packages/core/src/claustrum.ts, local login can resolve a valid vault credential under a non-derived ID but fail to remove that manifest binding because cleanup still enforces oauth:anthropic:${label}; this can leave stale custody state behind. Update cleanup to use the resolved credential ID and add coverage for mismatched IDs.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/core/src/claustrum.ts">

<violation number="1" location="packages/core/src/claustrum.ts:412">
P1: When a valid vault credential ID differs from `oauth:anthropic:${label}`, local login resolves the new manifest binding but cannot clear it because `removeCustodyHandleManifestEntry` still enforces the derived ID. Accept any validated non-empty credential ID in the removal validation.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

!isValidCustodyCredentialId(entry.credential_id) ||
(provider === 'anthropic' &&
entry.credential_id !== custodyCredentialId(entry.label))
!isValidCustodyCredentialId(entry.credential_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a valid vault credential ID differs from oauth:anthropic:${label}, local login resolves the new manifest binding but cannot clear it because removeCustodyHandleManifestEntry still enforces the derived ID. Accept any validated non-empty credential ID in the removal validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/claustrum.ts, line 412:

<comment>When a valid vault credential ID differs from `oauth:anthropic:${label}`, local login resolves the new manifest binding but cannot clear it because `removeCustodyHandleManifestEntry` still enforces the derived ID. Accept any validated non-empty credential ID in the removal validation.</comment>

<file context>
@@ -388,13 +409,15 @@ export function readCustodyHandles(
-      !isValidCustodyCredentialId(entry.credential_id) ||
-      (provider === 'anthropic' &&
-        entry.credential_id !== custodyCredentialId(entry.label))
+      !isValidCustodyCredentialId(entry.credential_id)
     ) {
       corruptLabels.add(entry.label)
</file context>

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