Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clients/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,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. |
Expand Down
9 changes: 9 additions & 0 deletions packages/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,15 @@ export interface IdentityPort {
* a programmer error, not a typed failure.
*/
currentIdentity(): Effect.Effect<Identity, never>
/**
* 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<boolean, never>
/**
* Bind the adapter to an identity with the given display name. Per
* substrate:
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`)
Expand Down
24 changes: 24 additions & 0 deletions packages/mcp/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,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(
Expand Down
15 changes: 14 additions & 1 deletion packages/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ type CurrentIdentityResult =
readonly last_post_body: string
}>
}
| {
readonly state: 'credential_rejected'
readonly identity: SerializedIdentity
readonly message: string
}
| { readonly state: 'unbound'; readonly identity: null }

/**
Expand Down Expand Up @@ -641,7 +646,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 },
Expand All @@ -663,6 +668,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))
Expand Down
1 change: 1 addition & 0 deletions packages/memory/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect

const identity: IdentityPort = {
currentIdentity: () => readBound(),
credentialRejected: () => Effect.succeed(false),
acquire: (name) =>
Ref.get(bound).pipe(
Effect.flatMap(
Expand Down
7 changes: 7 additions & 0 deletions packages/testing/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ export interface ContractEnv {
body: MessageBody,
opts?: PostOpts,
) => Effect.Effect<void>
/**
* 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<void>
/**
* Longest channel description this substrate stores, in characters. The
* description tests build their over-length input from it rather than
Expand Down
58 changes: 57 additions & 1 deletion packages/testing/contract/identity.ts
Original file line number Diff line number Diff line change
@@ -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 { Cause, Effect, Exit, Option } from 'effect'
import type { ContractEnv } from '../contract.ts'

Expand Down Expand Up @@ -234,4 +240,54 @@ export const identityContract = (env: () => ContractEnv): void => {
expect(error).toBeInstanceOf(UnknownIdentity)
}),
))
const postHello = (): Effect.Effect<unknown, unknown> => {
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)
}),
))
}
27 changes: 26 additions & 1 deletion packages/zulip/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
ApiKey,
BotEmail,
decodeUserUploadPath,
isCredentialRejected,
isTransientZulipError,
makeZulipHttp,
ZulipApiError,
Expand Down Expand Up @@ -631,6 +632,26 @@ export const zulipAdapter = (
),
)
const boundRef = yield* SynchronizedRef.make<Option.Option<BoundState>>(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 = <A, E, R>(call: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
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<BoundState> =>
SynchronizedRef.get(boundRef).pipe(
Expand Down Expand Up @@ -1188,6 +1209,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.
Expand Down Expand Up @@ -1227,7 +1249,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),
Expand All @@ -1247,6 +1271,7 @@ export const zulipAdapter = (
http,
origin: minted.origin,
}
yield* Ref.set(credentialRejectedRef, false)
return [
{ identity: ident, credentials, origin: minted.origin },
Option.some(next),
Expand Down
7 changes: 7 additions & 0 deletions packages/zulip/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,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

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/zulip/in-process-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const inProcessContractFactory: AttachmentContractFactory = async () => {
Effect.asVoid,
Effect.orDie,
),
revokeBoundCredential: () =>
adapter.identity
.currentIdentity()
.pipe(Effect.flatMap((me) => realm.revokeApiKey(Number(me.id)))),
channelDescriptionLimit: CHANNEL_DESCRIPTION_LIMIT,
threadNameLimit: TOPIC_LIMIT,
// The realm and its adapters hold nothing outside the heap, so there is
Expand Down
16 changes: 16 additions & 0 deletions packages/zulip/in-process-realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export interface InProcessRealm {
/** Deactivate the member with this user id, as an administrator would. */
readonly deactivateUser: (userId: number) => Effect.Effect<void>
readonly setPresence: (userId: number, status: PresenceStatus) => Effect.Effect<void>
/** Invalidates the user's API key, so the next call made with it is refused. */
readonly revokeApiKey: (userId: number) => Effect.Effect<void>
}

const REALM_HOST = 'zulip.example.com'
Expand Down Expand Up @@ -1382,5 +1384,19 @@ export const makeInProcessRealm: Effect.Effect<InProcessRealm> = 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 }),
},
),
),
),
),
}
})