From b1528e041bb504cab6c38fe8fda7d121264cf9b6 Mon Sep 17 00:00:00 2001 From: Rahim Nathwani Date: Fri, 13 Mar 2026 10:16:23 -0700 Subject: [PATCH] fix: agent edits now propagate to browser in real-time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bugs prevented the example app from working end-to-end and caused agent edits to silently fail to update connected browser clients. **Core collab bug** (`src/bridge/collab-client.ts`) `CollabClient.authenticationFailed` set `lastAuthenticationFailureReason` but never set `terminalCloseReason`. The editor's sync-status handler checks `terminalCloseReason === 'permission-denied'` to trigger `refreshCollabSessionAndReconnect()`. Without this assignment, expired collab session tokens (TTL 5 min) were never refreshed — the HocuspocusProvider stayed disconnected and all Yjs updates from the server were invisible to the browser. **Server fixes** (`server/index.ts`) - Removed `enforceBridgeClientCompatibility` from `/d` and `/documents` routes — this hosted-product middleware required browser version headers that SDK clients don't send, causing 426 errors. - Set `COLLAB_EMBEDDED_WS=1` by default so the collab session builder uses the main HTTP port instead of PORT+1, keeping the WebSocket URL correct for the multiplexed setup. - Serve `dist/` as a static directory so the built editor bundle is accessible after `npm run build`. **Agent bridge fix** (`packages/agent-bridge/src/index.ts`) `setPresence` was calling `POST /bridge/presence`, which requires an active browser viewer. Changed to `POST /documents/:slug/presence` — the server-side endpoint that works headlessly. **Example app fix** (`apps/proof-example/examples/agent-http-bridge.ts`) - Use `ownerSecret` as the bridge token instead of `accessToken`; the bridge-protected routes require owner_bot role, not editor role. - Output `tokenUrl` (share URL with embedded access token) so the printed link opens the document without a separate login step. - Update README: correct the demo command and remove stale private-repo paths. Co-Authored-By: Claude Sonnet 4.6 --- apps/proof-example/README.md | 28 +++++++++++++------ .../examples/agent-http-bridge.ts | 15 +++++++--- packages/agent-bridge/src/index.ts | 2 +- server/index.ts | 12 ++++++-- src/bridge/collab-client.ts | 1 + 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/apps/proof-example/README.md b/apps/proof-example/README.md index f9ce3c1..0e18bd1 100644 --- a/apps/proof-example/README.md +++ b/apps/proof-example/README.md @@ -1,10 +1,6 @@ # Proof Example -This workspace is the extraction target for the public `Proof SDK` demo app. - -The current private repo still runs the hosted product, but shared editor, server, and bridge code now lives behind the workspace packages in [packages/doc-core](/Users/danshipper/CascadeProjects/every-proof/.worktrees/proof-sdk-split/packages/doc-core), [packages/doc-editor](/Users/danshipper/CascadeProjects/every-proof/.worktrees/proof-sdk-split/packages/doc-editor), [packages/doc-server](/Users/danshipper/CascadeProjects/every-proof/.worktrees/proof-sdk-split/packages/doc-server), [packages/doc-store-sqlite](/Users/danshipper/CascadeProjects/every-proof/.worktrees/proof-sdk-split/packages/doc-store-sqlite), and [packages/agent-bridge](/Users/danshipper/CascadeProjects/every-proof/.worktrees/proof-sdk-split/packages/agent-bridge). - -When the public repo is extracted, this app should become the neutral self-host example for: +This is the self-host example app for the Proof SDK, demonstrating: - creating a document - loading a shared document @@ -12,12 +8,26 @@ When the public repo is extracted, this app should become the neutral self-host - agent bridge reads and writes - anonymous or token-based access +Shared editor, server, and bridge code lives in the workspace packages: +[packages/doc-core](../../packages/doc-core), +[packages/doc-editor](../../packages/doc-editor), +[packages/doc-server](../../packages/doc-server), +[packages/doc-store-sqlite](../../packages/doc-store-sqlite), and +[packages/agent-bridge](../../packages/agent-bridge). + ## Agent Bridge Demo -Run the reference external-agent flow: +First build the editor and start the server from the repo root: + +```bash +npm run build +npm run serve +``` + +Then run the reference external-agent flow: ```bash -npm run proof-sdk:demo:agent +npm --workspace proof-example run demo:agent ``` Environment variables: @@ -26,4 +36,6 @@ Environment variables: - `PROOF_DEMO_TITLE`: optional document title override - `PROOF_DEMO_MARKDOWN`: optional initial markdown override -The demo creates a document through `POST /documents`, then uses `@proof/agent-bridge` to publish presence, read state, and add a comment through the neutral `/documents/:slug/bridge/*` API. +The demo creates a document through `POST /documents`, then uses `@proof/agent-bridge` to publish +presence, read state, and add a comment through the neutral `/documents/:slug/*` API. It prints a +`viewUrl` you can open in a browser to see the result. diff --git a/apps/proof-example/examples/agent-http-bridge.ts b/apps/proof-example/examples/agent-http-bridge.ts index b884c91..359649d 100644 --- a/apps/proof-example/examples/agent-http-bridge.ts +++ b/apps/proof-example/examples/agent-http-bridge.ts @@ -7,8 +7,10 @@ import { interface CreateDocumentResponse { slug: string; + ownerSecret: string; accessToken: string; shareUrl?: string; + tokenUrl?: string; agent?: { stateApi?: string; }; @@ -56,14 +58,14 @@ async function run(): Promise { const provider = new DemoAgentProvider(); const created = await createDocument(baseUrl, title, markdown); - if (!created.slug || !created.accessToken) { - throw new Error('Create response did not include slug/accessToken'); + if (!created.slug || !created.ownerSecret) { + throw new Error('Create response did not include slug/ownerSecret'); } const bridge = createAgentBridgeClient({ baseUrl, auth: { - shareToken: created.accessToken, + bridgeToken: created.ownerSecret, }, }); @@ -98,10 +100,15 @@ async function run(): Promise { text: review.text || review.message.content, }); + const viewUrl = created.tokenUrl + ?? (created.shareUrl && created.accessToken + ? `${created.shareUrl}?token=${encodeURIComponent(created.accessToken)}` + : `${baseUrl}/d/${created.slug}`); + console.log(JSON.stringify({ success: true, slug: created.slug, - shareUrl: created.shareUrl ?? `${baseUrl}/d/${created.slug}`, + viewUrl, stateApi: created.agent?.stateApi ?? `${baseUrl}/documents/${created.slug}/state`, commentPosted: true, }, null, 2)); diff --git a/packages/agent-bridge/src/index.ts b/packages/agent-bridge/src/index.ts index 6c3ea31..a6d6989 100644 --- a/packages/agent-bridge/src/index.ts +++ b/packages/agent-bridge/src/index.ts @@ -195,7 +195,7 @@ export function createAgentBridgeClient(config: AgentBridgeClientConfig) { }); }, setPresence(slug: string, input: AgentBridgePresenceInput, options: AgentBridgeRequestOptions = {}): Promise { - return requestJson(config, buildBridgePath(slug, '/presence'), { + return requestJson(config, `${documentBasePath(slug)}/presence`, { method: 'POST', body: JSON.stringify(input), ...options, diff --git a/server/index.ts b/server/index.ts index 268cc05..83c1a8e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,13 +13,18 @@ import { shareWebRoutes } from './share-web-routes.js'; import { capabilitiesPayload, enforceApiClientCompatibility, - enforceBridgeClientCompatibility, } from './client-capabilities.js'; import { getBuildInfo } from './build-info.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PORT = Number.parseInt(process.env.PORT || '4000', 10); + +// WebSocket is multiplexed on the main HTTP port via setupWebSocket — tell the +// collab session builder to keep the same port rather than offsetting to PORT+1. +if (!process.env.COLLAB_EMBEDDED_WS) { + process.env.COLLAB_EMBEDDED_WS = '1'; +} const DEFAULT_ALLOWED_CORS_ORIGINS = [ 'http://localhost:3000', 'http://127.0.0.1:3000', @@ -43,6 +48,7 @@ async function main(): Promise { const allowedCorsOrigins = parseAllowedCorsOrigins(); app.use(express.json({ limit: '10mb' })); + app.use(express.static(path.join(__dirname, '..', 'dist'))); app.use(express.static(path.join(__dirname, '..', 'public'))); app.use((req, res, next) => { @@ -120,8 +126,8 @@ async function main(): Promise { app.use('/api', enforceApiClientCompatibility, apiRoutes); app.use('/api/agent', agentRoutes); app.use(apiRoutes); - app.use('/d', createBridgeMountRouter(enforceBridgeClientCompatibility)); - app.use('/documents', createBridgeMountRouter(enforceBridgeClientCompatibility)); + app.use('/d', createBridgeMountRouter()); + app.use('/documents', createBridgeMountRouter()); app.use('/documents', agentRoutes); app.use(shareWebRoutes); diff --git a/src/bridge/collab-client.ts b/src/bridge/collab-client.ts index 3ef2ba5..2c74fb6 100644 --- a/src/bridge/collab-client.ts +++ b/src/bridge/collab-client.ts @@ -610,6 +610,7 @@ export class CollabClient { provider.on('authenticationFailed', (event: { reason?: string }) => { const reason = typeof event?.reason === 'string' ? event.reason : 'permission-denied'; this.lastAuthenticationFailureReason = reason; + this.terminalCloseReason = 'permission-denied'; this.connectionStatus = 'disconnected'; this.hasSynced = false; this.lastDisconnectAt = Date.now();