From 198f1b903a19e9951e10e7a4bfa80fcec2124bee Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:22:51 +0100 Subject: [PATCH 1/2] 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. --- clients/claude-code/README.md | 2 +- packages/core/ports.ts | 9 ++++++ packages/mcp/server.test.ts | 1 + packages/mcp/tools.test.ts | 24 +++++++++++++++ packages/mcp/tools.ts | 15 ++++++++- packages/memory/adapter.ts | 1 + packages/zulip/adapter.test.ts | 56 ++++++++++++++++++++++++++++++++++ packages/zulip/adapter.ts | 27 +++++++++++++++- packages/zulip/http.ts | 7 +++++ 9 files changed, 139 insertions(+), 3 deletions(-) diff --git a/clients/claude-code/README.md b/clients/claude-code/README.md index 09c628e..287ec76 100644 --- a/clients/claude-code/README.md +++ b/clients/claude-code/README.md @@ -302,7 +302,7 @@ and history reads cover the browse case. | `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. | | `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. | | `presence` | Report presence (`online` / `idle` / `offline`) for a single identity. | -| `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. | +| `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. | | `resolve` | Look up an identity by name. | | `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. | | `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. | diff --git a/packages/core/ports.ts b/packages/core/ports.ts index 90a188d..57aa914 100644 --- a/packages/core/ports.ts +++ b/packages/core/ports.ts @@ -505,6 +505,15 @@ export interface IdentityPort { * a programmer error, not a typed failure. */ currentIdentity(): Effect.Effect + /** + * Whether the realm has refused the bound credential as invalid since this + * adapter bound it. Passive: reads what earlier calls already learned and + * never touches the realm or mints. A rejected credential stays rejected + * until `release()` and a fresh `acquire`, because nothing else replaces + * it. Always `false` on a substrate with no credentials to invalidate, and + * `false` before any bind. + */ + credentialRejected(): Effect.Effect /** * Bind the adapter to an identity with the given display name. Per * substrate: diff --git a/packages/mcp/server.test.ts b/packages/mcp/server.test.ts index 4573173..97568b9 100644 --- a/packages/mcp/server.test.ts +++ b/packages/mcp/server.test.ts @@ -209,6 +209,7 @@ const buildFakeAdapter = ( } const identityPort: IdentityPort = { currentIdentity: () => Effect.succeed(identity), + credentialRejected: () => Effect.succeed(false), acquire: (name) => Effect.suspend(() => { events.push(`acquire(${name})`) diff --git a/packages/mcp/tools.test.ts b/packages/mcp/tools.test.ts index eec9599..949752e 100644 --- a/packages/mcp/tools.test.ts +++ b/packages/mcp/tools.test.ts @@ -346,6 +346,30 @@ test('current_identity returns the bound identity after ensureBound resolves', ( ), )) +test('current_identity reports credential_rejected once the realm has refused the bound key', () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const base = yield* rigAdapter() + const adapter: MemoryAdapterRig = { + ...base, + identity: { ...base.identity, credentialRejected: () => Effect.succeed(true) }, + } + const deps = yield* buildDeps(adapter) + yield* deps.ensureBound() + const rig = yield* mountAndConnect(adapter, deps) + const expected = yield* adapter.identity.currentIdentity() + const result = yield* Effect.promise(() => + rig.client.callTool({ name: 'current_identity', arguments: {} }), + ) + expect(result.structuredContent).toMatchObject({ + state: 'credential_rejected', + identity: { id: expected.id, name: expected.name, kind: expected.kind }, + }) + }), + ), + )) + test('current_identity before acquire returns the unbound sentinel (passive, never triggers acquire)', () => Effect.runPromise( Effect.scoped( diff --git a/packages/mcp/tools.ts b/packages/mcp/tools.ts index 9a2fd2b..0e92c28 100644 --- a/packages/mcp/tools.ts +++ b/packages/mcp/tools.ts @@ -274,6 +274,11 @@ type CurrentIdentityResult = readonly last_post_body: string }> } + | { + readonly state: 'credential_rejected' + readonly identity: SerializedIdentity + readonly message: string + } | { readonly state: 'unbound'; readonly identity: null } interface InternalCache extends ToolsMemory { @@ -623,7 +628,7 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA { name: 'current_identity', description: - '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.', + '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.', inputSchema: { type: 'object', properties: { cwd: cwdField }, @@ -645,6 +650,14 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA return { state: 'unbound', identity: null } } cache.rememberIdentity(current.identity) + if (await run(adapter.identity.credentialRejected())) { + return { + state: 'credential_rejected', + identity: identityShape(current.identity), + message: + "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.", + } + } const bound = { state: 'bound' as const, identity: identityShape(current.identity) } try { const threads = await run(adapter.history.recentThreads(current.identity.id)) diff --git a/packages/memory/adapter.ts b/packages/memory/adapter.ts index 0b1028a..fe4df74 100644 --- a/packages/memory/adapter.ts +++ b/packages/memory/adapter.ts @@ -388,6 +388,7 @@ export const memoryAdapter = (config: MemoryAdapterConfig = {}): Effect.Effect readBound(), + credentialRejected: () => Effect.succeed(false), acquire: (name) => Ref.get(bound).pipe( Effect.flatMap( diff --git a/packages/zulip/adapter.test.ts b/packages/zulip/adapter.test.ts index ea14ee8..531d403 100644 --- a/packages/zulip/adapter.test.ts +++ b/packages/zulip/adapter.test.ts @@ -888,6 +888,62 @@ effectTest('publisher.post returns a MessageRef built from the response id', () }), ) +const seedRefusedSend = (stub: StubHttpClient, response: StubResponse): Effect.Effect => + Effect.gen(function* () { + yield* seedSendMessage(stub, 1) + yield* stub.respond('POST', '/api/v1/messages', response) + }) + +effectTest('identity.credentialRejected is false until the realm refuses the bound key', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedRefusedSend(stub, { + body: { result: 'error', msg: 'Invalid API key', code: 'UNAUTHORIZED' }, + status: 401, + }) + const adapter = yield* buildAdapter(stub) + expect(yield* adapter.identity.credentialRejected()).toBe(false) + yield* Effect.flip( + adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')), + ) + expect(yield* adapter.identity.credentialRejected()).toBe(true) + }), +) + +effectTest( + 'identity.credentialRejected stays false when the realm refuses a write for another reason', + () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedRefusedSend(stub, { + body: { result: 'error', msg: 'Message too long', code: 'BAD_REQUEST' }, + status: 400, + }) + const adapter = yield* buildAdapter(stub) + yield* Effect.flip( + adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')), + ) + expect(yield* adapter.identity.credentialRejected()).toBe(false) + }), +) + +effectTest('identity.credentialRejected clears when the seat releases and binds again', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedRefusedSend(stub, { + body: { result: 'error', msg: 'Invalid API key', code: 'UNAUTHORIZED' }, + status: 401, + }) + const adapter = yield* buildAdapter(stub) + yield* Effect.flip( + adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')), + ) + yield* adapter.identity.release() + yield* adapter.identity.acquire(decodeBotNameSync('hermes-agent')) + expect(yield* adapter.identity.credentialRejected()).toBe(false) + }), +) + /** * A `bindOnDemand` that counts its invocations and binds through the very * adapter it is configured on. The adapter cannot be referenced inside its own diff --git a/packages/zulip/adapter.ts b/packages/zulip/adapter.ts index e0ede22..f7bb2c5 100644 --- a/packages/zulip/adapter.ts +++ b/packages/zulip/adapter.ts @@ -107,6 +107,7 @@ import { ApiKey, BotEmail, decodeUserUploadPath, + isCredentialRejected, isTransientZulipError, makeZulipHttp, ZulipApiError, @@ -624,6 +625,26 @@ export const zulipAdapter = ( ), ) const boundRef = yield* SynchronizedRef.make>(Option.none()) + const credentialRejectedRef = yield* Ref.make(false) + + // Every call the bound credential makes, the events-queue poll included, + // goes out through this, so the first refusal of the key itself is + // recorded wherever it happens rather than only when a write loses. + const recordRejection = (call: Effect.Effect): Effect.Effect => + call.pipe( + Effect.tapError((e) => + e instanceof ZulipApiError && isCredentialRejected(e) + ? Ref.set(credentialRejectedRef, true) + : Effect.void, + ), + ) + const watchBoundHttp = (inner: BotHttp): BotHttp => ({ + get: (path, schema, params) => recordRejection(inner.get(path, schema, params)), + post: (path, schema, body) => recordRejection(inner.post(path, schema, body)), + patch: (path, schema, body) => recordRejection(inner.patch(path, schema, body)), + delete: (path, schema, body) => recordRejection(inner.delete(path, schema, body)), + uploadRaw: (filename, data) => recordRejection(inner.uploadRaw(filename, data)), + }) const requireBound = (): Effect.Effect => SynchronizedRef.get(boundRef).pipe( @@ -1157,6 +1178,7 @@ export const zulipAdapter = ( // need a coherent error — dying matches the port // contract of "throws when unbound". currentIdentity: () => requireBound().pipe(Effect.map((b) => b.identity)), + credentialRejected: () => Ref.get(credentialRejectedRef), // check-bound -> mint -> set is one atomic SynchronizedRef transition: // the effectful mint round-trip runs while the ref is locked, so two // concurrent acquires can never both observe `none` and double-mint. @@ -1196,7 +1218,9 @@ export const zulipAdapter = ( config.hostHeader, ), ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)) - const http = wrapBotHttp(innerHttp, recipientDirectory, minted.userId) + const http = watchBoundHttp( + wrapBotHttp(innerHttp, recipientDirectory, minted.userId), + ) const ident: Identity = { id: yield* decodeIdentityId(String(minted.userId)), name: yield* decodeDisplayName(name), @@ -1216,6 +1240,7 @@ export const zulipAdapter = ( http, origin: minted.origin, } + yield* Ref.set(credentialRejectedRef, false) return [ { identity: ident, credentials, origin: minted.origin }, Option.some(next), diff --git a/packages/zulip/http.ts b/packages/zulip/http.ts index 71a1aca..f81c76a 100644 --- a/packages/zulip/http.ts +++ b/packages/zulip/http.ts @@ -268,6 +268,13 @@ const classifyEnvelope = ( }) } +/** + * Whether the realm refused the request's credential itself, as opposed to + * refusing the request. Zulip answers an invalidated or deactivated bot key + * with 401 and no other request failure. + */ +export const isCredentialRejected = (err: ZulipApiError): boolean => err.status === 401 + const TRANSPORT_FAILURE_STATUS = 0 /** From 90461ed79489720b6d9a214ce18018974f6ce318 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:43:31 +0100 Subject: [PATCH 2/2] Test credentialRejected in the shared contract The tests describe port behaviour, so they run against every adapter. The contract gains an optional hook that invalidates the bound credential. --- packages/testing/contract.ts | 7 ++++ packages/testing/contract/identity.ts | 58 ++++++++++++++++++++++++++- packages/zulip/adapter.test.ts | 56 -------------------------- packages/zulip/in-process-contract.ts | 4 ++ packages/zulip/in-process-realm.ts | 16 ++++++++ 5 files changed, 84 insertions(+), 57 deletions(-) diff --git a/packages/testing/contract.ts b/packages/testing/contract.ts index 7cda6fa..8c1bc7d 100644 --- a/packages/testing/contract.ts +++ b/packages/testing/contract.ts @@ -96,6 +96,13 @@ export interface ContractEnv { body: MessageBody, opts?: PostOpts, ) => Effect.Effect + /** + * Optional. Invalidate the credential the bound self holds, so the + * substrate refuses its next call as unauthenticated. Substrates with no + * credentials to invalidate (Memory) omit it and the suite skips the + * tests that need a refused credential. + */ + readonly revokeBoundCredential?: () => Effect.Effect /** * Longest channel description this substrate stores, in characters. The * description tests build their over-length input from it rather than diff --git a/packages/testing/contract/identity.ts b/packages/testing/contract/identity.ts index f98c8c6..aad76a3 100644 --- a/packages/testing/contract/identity.ts +++ b/packages/testing/contract/identity.ts @@ -1,7 +1,13 @@ /** The shared contract's tests for IdentityPort. */ import { expect, test } from 'bun:test' -import { decodeBotNameSync, decodeDisplayNameSync, UnknownIdentity } from '@commy/core/ports' +import { + decodeBotNameSync, + decodeChannelNameSync, + decodeDisplayNameSync, + decodeMessageBodySync, + UnknownIdentity, +} from '@commy/core/ports' import { Effect, Exit, Option } from 'effect' import type { ContractEnv } from '../contract.ts' @@ -77,4 +83,54 @@ export const identityContract = (env: () => ContractEnv): void => { expect(error).toBeInstanceOf(UnknownIdentity) }), )) + const postHello = (): Effect.Effect => { + const channel = env().seedChannel('credential-check') + return channel.pipe( + Effect.flatMap((ref) => env().comms.publisher.post(ref.name, decodeMessageBodySync('hello'))), + ) + } + test('identity.credentialRejected is false while the bound credential has not been refused', () => + Effect.runPromise( + Effect.gen(function* () { + yield* postHello() + expect(yield* env().comms.identity.credentialRejected()).toBe(false) + }), + )) + test('identity.credentialRejected stays false when a write is refused for another reason', () => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.flip( + env().comms.publisher.post( + decodeChannelNameSync('no-such-channel'), + decodeMessageBodySync('hello'), + ), + ) + expect(yield* env().comms.identity.credentialRejected()).toBe(false) + }), + )) + test('identity.credentialRejected is true once the substrate has refused the bound credential', () => + Effect.runPromise( + Effect.gen(function* () { + const revoke = env().revokeBoundCredential + if (revoke === undefined) return + const ref = yield* env().seedChannel('credential-check') + yield* revoke() + yield* Effect.flip(env().comms.publisher.post(ref.name, decodeMessageBodySync('hello'))) + expect(yield* env().comms.identity.credentialRejected()).toBe(true) + }), + )) + test('identity.credentialRejected clears when the seat releases and binds again', () => + Effect.runPromise( + Effect.gen(function* () { + const revoke = env().revokeBoundCredential + if (revoke === undefined) return + const me = yield* env().comms.identity.currentIdentity() + const ref = yield* env().seedChannel('credential-check') + yield* revoke() + yield* Effect.flip(env().comms.publisher.post(ref.name, decodeMessageBodySync('hello'))) + yield* env().comms.identity.release() + yield* env().comms.identity.acquire(decodeBotNameSync(me.name)) + expect(yield* env().comms.identity.credentialRejected()).toBe(false) + }), + )) } diff --git a/packages/zulip/adapter.test.ts b/packages/zulip/adapter.test.ts index 531d403..ea14ee8 100644 --- a/packages/zulip/adapter.test.ts +++ b/packages/zulip/adapter.test.ts @@ -888,62 +888,6 @@ effectTest('publisher.post returns a MessageRef built from the response id', () }), ) -const seedRefusedSend = (stub: StubHttpClient, response: StubResponse): Effect.Effect => - Effect.gen(function* () { - yield* seedSendMessage(stub, 1) - yield* stub.respond('POST', '/api/v1/messages', response) - }) - -effectTest('identity.credentialRejected is false until the realm refuses the bound key', () => - Effect.gen(function* () { - const stub = yield* makeStubHttpClient - yield* seedRefusedSend(stub, { - body: { result: 'error', msg: 'Invalid API key', code: 'UNAUTHORIZED' }, - status: 401, - }) - const adapter = yield* buildAdapter(stub) - expect(yield* adapter.identity.credentialRejected()).toBe(false) - yield* Effect.flip( - adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')), - ) - expect(yield* adapter.identity.credentialRejected()).toBe(true) - }), -) - -effectTest( - 'identity.credentialRejected stays false when the realm refuses a write for another reason', - () => - Effect.gen(function* () { - const stub = yield* makeStubHttpClient - yield* seedRefusedSend(stub, { - body: { result: 'error', msg: 'Message too long', code: 'BAD_REQUEST' }, - status: 400, - }) - const adapter = yield* buildAdapter(stub) - yield* Effect.flip( - adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')), - ) - expect(yield* adapter.identity.credentialRejected()).toBe(false) - }), -) - -effectTest('identity.credentialRejected clears when the seat releases and binds again', () => - Effect.gen(function* () { - const stub = yield* makeStubHttpClient - yield* seedRefusedSend(stub, { - body: { result: 'error', msg: 'Invalid API key', code: 'UNAUTHORIZED' }, - status: 401, - }) - const adapter = yield* buildAdapter(stub) - yield* Effect.flip( - adapter.publisher.post(generalChannel.name, decodeMessageBodySync('hello world')), - ) - yield* adapter.identity.release() - yield* adapter.identity.acquire(decodeBotNameSync('hermes-agent')) - expect(yield* adapter.identity.credentialRejected()).toBe(false) - }), -) - /** * A `bindOnDemand` that counts its invocations and binds through the very * adapter it is configured on. The adapter cannot be referenced inside its own diff --git a/packages/zulip/in-process-contract.ts b/packages/zulip/in-process-contract.ts index eba3429..972dceb 100644 --- a/packages/zulip/in-process-contract.ts +++ b/packages/zulip/in-process-contract.ts @@ -60,6 +60,10 @@ export const inProcessContractFactory: ContractFactory = async () => { Effect.asVoid, Effect.orDie, ), + revokeBoundCredential: () => + adapter.identity + .currentIdentity() + .pipe(Effect.flatMap((me) => realm.revokeApiKey(Number(me.id)))), channelDescriptionLimit: CHANNEL_DESCRIPTION_LIMIT, // The realm and its adapters hold nothing outside the heap, so there is // nothing to tear down. diff --git a/packages/zulip/in-process-realm.ts b/packages/zulip/in-process-realm.ts index 10e3a4d..800cca8 100644 --- a/packages/zulip/in-process-realm.ts +++ b/packages/zulip/in-process-realm.ts @@ -59,6 +59,8 @@ export interface InProcessRealm { /** An active member of the realm, returning its user id. */ readonly addUser: (fullName: string, kind: 'human' | 'bot') => Effect.Effect readonly setPresence: (userId: number, status: PresenceStatus) => Effect.Effect + /** Invalidates the user's API key, so the next call made with it is refused. */ + readonly revokeApiKey: (userId: number) => Effect.Effect } const REALM_HOST = 'zulip.example.com' @@ -1333,5 +1335,19 @@ export const makeInProcessRealm: Effect.Effect = Effect.gen(func ...s, presence: HashMap.set(s.presence, userId, status), })), + revokeApiKey: (userId) => + newKey.pipe( + Effect.flatMap((apiKey) => + SubscriptionRef.update(stateRef, (s) => + Option.match( + findUser(s, (u) => u.userId === userId), + { + onNone: () => s, + onSome: (user) => putUser(s, { ...user, apiKey }), + }, + ), + ), + ), + ), } })