Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

New features, integrations, and notable improvements to Open-Inspect — newest first.

## July 26, 2026

**Better Auth browser authentication.** Browser sign-in now uses control-plane-owned Better Auth
sessions with GitHub and optional Google providers. The web app forwards an exact allowlist of
authentication routes through a signed proxy, and browser resource requests require both that signed
web-service channel and the browser session. Legacy browser tokens are retired during migration, so
existing users must sign in again after upgrading.

## July 24, 2026

**Claude Opus 5.** Adds `claude-opus-5` to the model picker and integrations, with adaptive thinking
Expand Down
9 changes: 8 additions & 1 deletion packages/control-plane/src/auth/user/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ export function createUserAuth(config: UserAuthConfig) {
},
}
: {}),
...(config.google ? { google: config.google } : {}),
...(config.google
? {
google: {
...config.google,
disableIdTokenSignIn: true,
},
}
: {}),
},
user: {
modelName: "auth_users",
Expand Down
35 changes: 23 additions & 12 deletions packages/control-plane/src/routes/session-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
MAX_SESSION_ATTACHMENTS_PER_MESSAGE,
callbackContextSchema,
sendPromptRequestSchema,
sessionAttachmentReferencesSchema,
type CallbackContext,
type SessionAttachmentReference,
Expand Down Expand Up @@ -45,30 +47,39 @@ async function handleSessionPrompt(
const sessionId = match.groups?.id;
if (!sessionId) return error("Session ID required");

const body = (await request.json()) as {
content: string;
source?: string;
model?: string;
reasoningEffort?: string;
attachments?: unknown;
callbackContext?: CallbackContext;
};
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return error("Invalid JSON body", 400);
}

const enforcement = applyIdentityEnforcement(ctx, "prompt", rawBody);
if (enforcement.rejection) return enforcement.rejection;

if (!body.content) {
const bodyResult = sendPromptRequestSchema.safeParse(rawBody);
if (!bodyResult.success) {
return error("content is required");
}
const enforcement = applyIdentityEnforcement(ctx, "prompt", body);
if (enforcement.rejection) return enforcement.rejection;
const body = bodyResult.data;

const attachments = validateAttachments(body.attachments);
if (attachments instanceof Response) return attachments;

let callbackContext: CallbackContext | undefined;
if (mayAttachCallbackContext(ctx) && body.callbackContext !== undefined) {
const callbackContextResult = callbackContextSchema.safeParse(body.callbackContext);
if (!callbackContextResult.success) {
return error("Invalid callbackContext", 400);
}
callbackContext = callbackContextResult.data;
}

// The author comes from the verified principal (user → canonical id, bot →
// asserted actor); an actorless bot prompt is system-initiated and stays
// anonymous. callbackContext is a completion notification channel — only
// the bots that own callbacks may attach one.
const authorId = enforcement.enforced.participantUserId ?? "anonymous";
const callbackContext = mayAttachCallbackContext(ctx) ? body.callbackContext : undefined;
if (callbackContext === undefined && body.callbackContext !== undefined) {
logger.warn("Dropped callbackContext from unauthorized principal", {
event: "identity.callback_context_dropped",
Expand Down
48 changes: 17 additions & 31 deletions packages/control-plane/src/session/durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,6 @@ export class SessionDO extends DurableObject<Env> {
if (!this._presenceService) {
this._presenceService = new PresenceService({
getAuthenticatedClients: () => this.wsManager.getAuthenticatedClients(),
getClientInfo: (ws) => this.getClientInfo(ws),
messenger: this.messenger,
send: (ws, msg) => this.safeSend(ws, msg),
getSandboxSocket: () => this.wsManager.getSandboxSocket(),
Expand Down Expand Up @@ -1192,17 +1191,22 @@ export class SessionDO extends DurableObject<Env> {
return;
}

switch (data.type) {
case "ping":
this.safeSend(ws, { type: "pong", timestamp: Date.now() });
break;
if (data.type === "ping") {
this.safeSend(ws, { type: "pong", timestamp: Date.now() });
return;
}

case "subscribe":
await this.handleSubscribe(ws, data);
break;
if (data.type === "subscribe") {
await this.handleSubscribe(ws, data);
return;
}

const client = this.getClientInfo(ws);
if (!client) return;

switch (data.type) {
case "prompt":
await this.handlePromptMessage(ws, data);
await this.handlePromptMessage(ws, client, data);
break;

case "stop":
Expand All @@ -1214,11 +1218,11 @@ export class SessionDO extends DurableObject<Env> {
break;

case "fetch_history":
this.handleFetchHistory(ws, data);
this.handleFetchHistory(ws, client, data);
break;

case "presence":
this.presenceService.updatePresence(ws, data);
this.presenceService.updatePresence(client, data);
break;
}
} catch (e) {
Expand Down Expand Up @@ -1419,23 +1423,14 @@ export class SessionDO extends DurableObject<Env> {
*/
private async handlePromptMessage(
ws: WebSocket,
client: ClientInfo,
data: {
content: string;
model?: string;
reasoningEffort?: string;
attachments?: SessionAttachmentReference[];
}
): Promise<void> {
const client = this.getClientInfo(ws);
if (!client) {
this.safeSend(ws, {
type: "error",
code: "NOT_SUBSCRIBED",
message: "Must subscribe first",
});
return;
}

await this.messageQueue.handlePromptMessage(ws, client, data);
}

Expand All @@ -1444,18 +1439,9 @@ export class SessionDO extends DurableObject<Env> {
*/
private handleFetchHistory(
ws: WebSocket,
client: ClientInfo,
data: { cursor?: { timestamp: number; id: string }; limit?: number }
): void {
const client = this.getClientInfo(ws);
if (!client) {
this.safeSend(ws, {
type: "error",
code: "NOT_SUBSCRIBED",
message: "Must subscribe first",
});
return;
}

// Validate cursor
if (
!data.cursor ||
Expand Down
14 changes: 1 addition & 13 deletions packages/control-plane/src/session/presence-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ function createTestHarness() {

const deps: PresenceServiceDeps = {
getAuthenticatedClients: vi.fn(() => clients.values()),
getClientInfo: vi.fn(() => null),
messenger: { broadcast: vi.fn(), sendToSandbox: vi.fn(() => true) },
send: vi.fn(() => true),
getSandboxSocket: vi.fn(() => null),
Expand Down Expand Up @@ -227,24 +226,13 @@ describe("PresenceService", () => {
describe("updatePresence", () => {
it("updates client status/lastSeen and broadcasts", () => {
const client = createMockClient({ status: "active", lastSeen: 1000 });
vi.mocked(harness.deps.getClientInfo).mockReturnValue(client);
const ws = {} as WebSocket;

harness.service.updatePresence(ws, { status: "idle" });
harness.service.updatePresence(client, { status: "idle" });

expect(client.status).toBe("idle");
expect(client.lastSeen).toBeGreaterThan(1000);
expect(harness.deps.messenger.broadcast).toHaveBeenCalled();
});

it("skips when client not found (no broadcast)", () => {
vi.mocked(harness.deps.getClientInfo).mockReturnValue(null);
const ws = {} as WebSocket;

harness.service.updatePresence(ws, { status: "idle" });

expect(harness.deps.messenger.broadcast).not.toHaveBeenCalled();
});
});

describe("handleTyping", () => {
Expand Down
12 changes: 4 additions & 8 deletions packages/control-plane/src/session/presence-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type { SessionMessenger } from "./messenger";
*/
export interface PresenceServiceDeps {
getAuthenticatedClients: () => IterableIterator<ClientInfo>;
getClientInfo: (ws: WebSocket) => ClientInfo | null;
messenger: SessionMessenger;
send: (ws: WebSocket, message: ServerMessage) => boolean;
getSandboxSocket: () => WebSocket | null;
Expand Down Expand Up @@ -82,15 +81,12 @@ export class PresenceService {
* Update client presence status and broadcast.
*/
updatePresence(
ws: WebSocket,
client: ClientInfo,
data: { status: "active" | "idle"; cursor?: { line: number; file: string } }
): void {
const client = this.deps.getClientInfo(ws);
if (client) {
client.status = data.status;
client.lastSeen = Date.now();
this.broadcastPresence();
}
client.status = data.status;
client.lastSeen = Date.now();
this.broadcastPresence();
}

/**
Expand Down
Loading
Loading