Skip to content

Commit 28af82f

Browse files
committed
Report a seat whose bound credential the realm has refused
A seat whose bot key was invalidated kept answering bound from memory and failed every write the same way for the life of the process. The adapter now records the first 401 on any call made under the bound credential, the events-queue poll included, and current_identity reports credential_rejected without minting. A fresh bind clears it. The seat does not re-acquire on its own: regenerating the key evicts any other holder of the persona, so two colliding seats would rotate it against each other until the persona is on attach mode.
1 parent aa9c4df commit 28af82f

9 files changed

Lines changed: 139 additions & 3 deletions

File tree

‎clients/claude-code/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ and history reads cover the browse case.
302302
| `get_channel_description` | Read a channel's standing description — the short statement of what it's for, which a project keeps as its charter. `{description}`, or `null` when nobody has set one. Channel-level state, so `read_channel` won't show it. |
303303
| `set_channel_description` | Set a channel's description, replacing whatever it says now; pass an empty string to clear it. Idempotent. What you write is read back verbatim — a description the substrate can't store as given (too long, or spanning multiple lines) is refused with an error saying which, never silently trimmed or reflowed. Fails with a clear error when the bound identity can't edit the channel. |
304304
| `presence` | Report presence (`online` / `idle` / `offline`) for a single identity. |
305-
| `current_identity` | Return `{state: 'bound', identity: {...}}` once acquire has resolved, or `{state: 'unbound', identity: null}` while a lazy ephemeral session is still lurking. Passive — never triggers acquire. |
305+
| `current_identity` | Return `{state: 'bound', identity: {...}}` once acquire has resolved, `{state: 'credential_rejected', identity: {...}, message}` once the realm has refused the bound key as invalid (writes fail and inbound events stop until the seat restarts), or `{state: 'unbound', identity: null}` while a lazy ephemeral session is still lurking. Passive — never triggers acquire. |
306306
| `resolve` | Look up an identity by name. |
307307
| `download_file` | Fetch a `/user_uploads/...` attachment to a temp file; returns `{file_path, content_type, size}`. Use the Read tool on the returned path to view images. |
308308
| `upload_file` | Upload a local file (absolute path) to the realm; returns `{reference, filename, size}`. The `reference` is a ready-to-embed string — drop it into a `post` body wherever you want the attachment to render, the same way you write `@**Name**` mention markup yourself. |

‎packages/core/ports.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,15 @@ export interface IdentityPort {
505505
* a programmer error, not a typed failure.
506506
*/
507507
currentIdentity(): Effect.Effect<Identity, never>
508+
/**
509+
* Whether the realm has refused the bound credential as invalid since this
510+
* adapter bound it. Passive: reads what earlier calls already learned and
511+
* never touches the realm or mints. A rejected credential stays rejected
512+
* until `release()` and a fresh `acquire`, because nothing else replaces
513+
* it. Always `false` on a substrate with no credentials to invalidate, and
514+
* `false` before any bind.
515+
*/
516+
credentialRejected(): Effect.Effect<boolean, never>
508517
/**
509518
* Bind the adapter to an identity with the given display name. Per
510519
* substrate:

‎packages/mcp/server.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const buildFakeAdapter = (
209209
}
210210
const identityPort: IdentityPort = {
211211
currentIdentity: () => Effect.succeed(identity),
212+
credentialRejected: () => Effect.succeed(false),
212213
acquire: (name) =>
213214
Effect.suspend(() => {
214215
events.push(`acquire(${name})`)

‎packages/mcp/tools.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,30 @@ test('current_identity returns the bound identity after ensureBound resolves', (
346346
),
347347
))
348348

349+
test('current_identity reports credential_rejected once the realm has refused the bound key', () =>
350+
Effect.runPromise(
351+
Effect.scoped(
352+
Effect.gen(function* () {
353+
const base = yield* rigAdapter()
354+
const adapter: MemoryAdapterRig = {
355+
...base,
356+
identity: { ...base.identity, credentialRejected: () => Effect.succeed(true) },
357+
}
358+
const deps = yield* buildDeps(adapter)
359+
yield* deps.ensureBound()
360+
const rig = yield* mountAndConnect(adapter, deps)
361+
const expected = yield* adapter.identity.currentIdentity()
362+
const result = yield* Effect.promise(() =>
363+
rig.client.callTool({ name: 'current_identity', arguments: {} }),
364+
)
365+
expect(result.structuredContent).toMatchObject({
366+
state: 'credential_rejected',
367+
identity: { id: expected.id, name: expected.name, kind: expected.kind },
368+
})
369+
}),
370+
),
371+
))
372+
349373
test('current_identity before acquire returns the unbound sentinel (passive, never triggers acquire)', () =>
350374
Effect.runPromise(
351375
Effect.scoped(

‎packages/mcp/tools.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,11 @@ type CurrentIdentityResult =
274274
readonly last_post_body: string
275275
}>
276276
}
277+
| {
278+
readonly state: 'credential_rejected'
279+
readonly identity: SerializedIdentity
280+
readonly message: string
281+
}
277282
| { readonly state: 'unbound'; readonly identity: null }
278283

279284
interface InternalCache extends ToolsMemory {
@@ -623,7 +628,7 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
623628
{
624629
name: 'current_identity',
625630
description:
626-
'Return the identity this session is bound to. Passive — never triggers acquire. Returns {state: "unbound", identity: null} for ephemeral sessions that have not yet performed an attribution-producing action; {state: "bound", identity: {id, name, kind}, recent_threads?} once acquire has resolved. recent_threads is a best-effort orientation list of {channel, thread, last_post_ts, last_post_body} for the threads this identity most recently posted in; it is omitted when the enrichment lookup fails — the binding self-check itself never fails on it.',
631+
'Return the identity this session is bound to. Passive — never triggers acquire. Returns {state: "unbound", identity: null} for ephemeral sessions that have not yet performed an attribution-producing action; {state: "bound", identity: {id, name, kind}, recent_threads?} once acquire has resolved; {state: "credential_rejected", identity, message} when the realm has refused the bound credential as invalid, so writes fail and inbound events stop until the seat restarts. recent_threads is a best-effort orientation list of {channel, thread, last_post_ts, last_post_body} for the threads this identity most recently posted in; it is omitted when the enrichment lookup fails — the binding self-check itself never fails on it.',
627632
inputSchema: {
628633
type: 'object',
629634
properties: { cwd: cwdField },
@@ -645,6 +650,14 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
645650
return { state: 'unbound', identity: null }
646651
}
647652
cache.rememberIdentity(current.identity)
653+
if (await run(adapter.identity.credentialRejected())) {
654+
return {
655+
state: 'credential_rejected',
656+
identity: identityShape(current.identity),
657+
message:
658+
"The realm has refused this seat's credential as invalid. Writes will keep failing and inbound events are not being delivered. Restart the seat to bind a fresh credential.",
659+
}
660+
}
648661
const bound = { state: 'bound' as const, identity: identityShape(current.identity) }
649662
try {
650663
const threads = await run(adapter.history.recentThreads(current.identity.id))

‎packages/memory/adapter.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ export const memoryAdapter = (config: MemoryAdapterConfig = {}): Effect.Effect<M
388388

389389
const identity: IdentityPort = {
390390
currentIdentity: () => readBound(),
391+
credentialRejected: () => Effect.succeed(false),
391392
acquire: (name) =>
392393
Ref.get(bound).pipe(
393394
Effect.flatMap(

‎packages/zulip/adapter.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,62 @@ effectTest('publisher.post returns a MessageRef built from the response id', ()
888888
}),
889889
)
890890

891+
const seedRefusedSend = (stub: StubHttpClient, response: StubResponse): Effect.Effect<void> =>
892+
Effect.gen(function* () {
893+
yield* seedSendMessage(stub, 1)
894+
yield* stub.respond('POST', '/api/v1/messages', response)
895+
})
896+
897+
effectTest('identity.credentialRejected is false until the realm refuses the bound key', () =>
898+
Effect.gen(function* () {
899+
const stub = yield* makeStubHttpClient
900+
yield* seedRefusedSend(stub, {
901+
body: { result: 'error', msg: 'Invalid API key', code: 'UNAUTHORIZED' },
902+
status: 401,
903+
})
904+
const adapter = yield* buildAdapter(stub)
905+
expect(yield* adapter.identity.credentialRejected()).toBe(false)
906+
yield* Effect.flip(
907+
adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')),
908+
)
909+
expect(yield* adapter.identity.credentialRejected()).toBe(true)
910+
}),
911+
)
912+
913+
effectTest(
914+
'identity.credentialRejected stays false when the realm refuses a write for another reason',
915+
() =>
916+
Effect.gen(function* () {
917+
const stub = yield* makeStubHttpClient
918+
yield* seedRefusedSend(stub, {
919+
body: { result: 'error', msg: 'Message too long', code: 'BAD_REQUEST' },
920+
status: 400,
921+
})
922+
const adapter = yield* buildAdapter(stub)
923+
yield* Effect.flip(
924+
adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')),
925+
)
926+
expect(yield* adapter.identity.credentialRejected()).toBe(false)
927+
}),
928+
)
929+
930+
effectTest('identity.credentialRejected clears when the seat releases and binds again', () =>
931+
Effect.gen(function* () {
932+
const stub = yield* makeStubHttpClient
933+
yield* seedRefusedSend(stub, {
934+
body: { result: 'error', msg: 'Invalid API key', code: 'UNAUTHORIZED' },
935+
status: 401,
936+
})
937+
const adapter = yield* buildAdapter(stub)
938+
yield* Effect.flip(
939+
adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')),
940+
)
941+
yield* adapter.identity.release()
942+
yield* adapter.identity.acquire(decodeBotNameSync('hermes-agent'))
943+
expect(yield* adapter.identity.credentialRejected()).toBe(false)
944+
}),
945+
)
946+
891947
/**
892948
* A `bindOnDemand` that counts its invocations and binds through the very
893949
* adapter it is configured on. The adapter cannot be referenced inside its own

‎packages/zulip/adapter.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
ApiKey,
108108
BotEmail,
109109
decodeUserUploadPath,
110+
isCredentialRejected,
110111
isTransientZulipError,
111112
makeZulipHttp,
112113
ZulipApiError,
@@ -624,6 +625,26 @@ export const zulipAdapter = (
624625
),
625626
)
626627
const boundRef = yield* SynchronizedRef.make<Option.Option<BoundState>>(Option.none())
628+
const credentialRejectedRef = yield* Ref.make(false)
629+
630+
// Every call the bound credential makes, the events-queue poll included,
631+
// goes out through this, so the first refusal of the key itself is
632+
// recorded wherever it happens rather than only when a write loses.
633+
const recordRejection = <A, E, R>(call: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
634+
call.pipe(
635+
Effect.tapError((e) =>
636+
e instanceof ZulipApiError && isCredentialRejected(e)
637+
? Ref.set(credentialRejectedRef, true)
638+
: Effect.void,
639+
),
640+
)
641+
const watchBoundHttp = (inner: BotHttp): BotHttp => ({
642+
get: (path, schema, params) => recordRejection(inner.get(path, schema, params)),
643+
post: (path, schema, body) => recordRejection(inner.post(path, schema, body)),
644+
patch: (path, schema, body) => recordRejection(inner.patch(path, schema, body)),
645+
delete: (path, schema, body) => recordRejection(inner.delete(path, schema, body)),
646+
uploadRaw: (filename, data) => recordRejection(inner.uploadRaw(filename, data)),
647+
})
627648

628649
const requireBound = (): Effect.Effect<BoundState> =>
629650
SynchronizedRef.get(boundRef).pipe(
@@ -1157,6 +1178,7 @@ export const zulipAdapter = (
11571178
// need a coherent error — dying matches the port
11581179
// contract of "throws when unbound".
11591180
currentIdentity: () => requireBound().pipe(Effect.map((b) => b.identity)),
1181+
credentialRejected: () => Ref.get(credentialRejectedRef),
11601182
// check-bound -> mint -> set is one atomic SynchronizedRef transition:
11611183
// the effectful mint round-trip runs while the ref is locked, so two
11621184
// concurrent acquires can never both observe `none` and double-mint.
@@ -1196,7 +1218,9 @@ export const zulipAdapter = (
11961218
config.hostHeader,
11971219
),
11981220
).pipe(Effect.provideService(HttpClient.HttpClient, httpClient))
1199-
const http = wrapBotHttp(innerHttp, recipientDirectory, minted.userId)
1221+
const http = watchBoundHttp(
1222+
wrapBotHttp(innerHttp, recipientDirectory, minted.userId),
1223+
)
12001224
const ident: Identity = {
12011225
id: yield* decodeIdentityId(String(minted.userId)),
12021226
name: yield* decodeDisplayName(name),
@@ -1216,6 +1240,7 @@ export const zulipAdapter = (
12161240
http,
12171241
origin: minted.origin,
12181242
}
1243+
yield* Ref.set(credentialRejectedRef, false)
12191244
return [
12201245
{ identity: ident, credentials, origin: minted.origin },
12211246
Option.some(next),

‎packages/zulip/http.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,13 @@ const classifyEnvelope = (
268268
})
269269
}
270270

271+
/**
272+
* Whether the realm refused the request's credential itself, as opposed to
273+
* refusing the request. Zulip answers an invalidated or deactivated bot key
274+
* with 401 and no other request failure.
275+
*/
276+
export const isCredentialRejected = (err: ZulipApiError): boolean => err.status === 401
277+
271278
const TRANSPORT_FAILURE_STATUS = 0
272279

273280
/**

0 commit comments

Comments
 (0)