Skip to content
Open
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 client/app/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function AppRouter() {
<Switch>
<Route path="/" component={HomeRoute} />
<Route path="/dev/*?" component={DevLazy} />
{/* The wildcard is the workspace tab id (`view:orders`, `overview`, …) —
{/* The wildcard is the destination path (`views/orders`, `overview`, …) —
the URL is the tab address, read downstream with `useParams`. This
pattern is its only definition. Keyed by workspace id only, so tab
switches never remount the workspace tree. */}
Expand Down
2 changes: 1 addition & 1 deletion client/app/shell/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ function WorkspaceButton({ workspace, dragOverlay = false, dragState }: Workspac
const [location] = useLocation()
const href = `/workspace/${workspace.id}`
const label = workspaceDisplayName(workspace)
// A workspace URL carries a tab suffix (`/workspace/:id/view:orders`), so the
// A workspace URL carries a destination (`/workspace/:id/views/orders`), so the
// rail matches the workspace segment, not the whole path. The trailing slash
// keeps `/workspace/ws1` from lighting up a sibling `/workspace/ws1-other`.
const active = location === href || location.startsWith(`${href}/`)
Expand Down
114 changes: 60 additions & 54 deletions client/features/applets/applet-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ import {
const VIEW: AppletIdentity = { kind: 'view', name: 'board' }
const WIDGET: AppletIdentity = { kind: 'widget', name: 'clock' }

function subscribeFocus(workspaceId: string) {
const calls: [string, Record<string, unknown> | undefined][] = []
const unbind = appletRuntime(workspaceId).on('focusTab', (tab, params) =>
calls.push([tab, params])
)
function subscribeNavigation(workspaceId: string) {
const calls: string[] = []
const unbind = appletRuntime(workspaceId).on('navigate', href => calls.push(href))
return { calls, unbind }
}

Expand All @@ -39,59 +37,67 @@ function subscribeChat(workspaceId: string) {
}

describe('bridge validation', () => {
test('emits a well-formed call and narrows malformed params to undefined', () => {
test('emits URL navigation and rejects malformed addresses', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeFocus(ws)
const { calls } = subscribeNavigation(ws)
const { bridge } = appletRuntime(ws).connect(VIEW)
const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {})
bridge.navigate('moi:/views/orders?order=o-1')
bridge.navigate('moi:/overview')
bridge.navigate(['invalid'])
bridge.navigate('javascript:alert(1)')
expect(calls).toEqual(['moi:/views/orders?order=o-1', 'moi:/overview'])
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})

bridge.focusTab('view:orders', { order: 'o-1' })
bridge.focusTab('overview')
// Valid JSON, wrong shape — params must degrade, not leak through.
bridge.focusTab('view:orders', ['not', 'a', 'record'])
bridge.focusTab('view:orders', null)
test('resolves native anchor hrefs in the source workspace and disposes safely', () => {
const { bridge, dispose } = appletRuntime('ws-1').connect(VIEW)
expect(bridge.resolveHref('moi:/views/orders?order=o-1')).toBe(
'/workspace/ws-1/views/orders?order=o-1'
)
expect(bridge.resolveHref('https://example.com/')).toBe('https://example.com/')
expect(() => bridge.resolveHref('javascript:alert(1)')).toThrow()
dispose()
expect(bridge.resolveHref('moi:/overview')).toBe('')
})

expect(calls).toEqual([
['view:orders', { order: 'o-1' }],
['overview', undefined],
['view:orders', undefined],
['view:orders', undefined]
])
test('resolves applet links with the host router base', () => {
const { bridge } = appletRuntime('prefixed').connect(VIEW, '/prefix')
expect(bridge.resolveHref('moi:/views/orders')).toBe('/prefix/workspace/prefixed/views/orders')
})

test('drops calls with a malformed tab id instead of emitting', () => {
test('drops calls with malformed addresses instead of emitting', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeFocus(ws)
const { calls } = subscribeNavigation(ws)
const { bridge } = appletRuntime(ws).connect(VIEW)

bridge.focusTab('not-a-tab')
bridge.focusTab('view:multi/segment')
bridge.focusTab(42)
bridge.focusTab({ toString: () => 'agent' })
bridge.navigate('not-a-tab')
bridge.navigate('view:multi/segment')
bridge.navigate(42)
bridge.navigate({ toString: () => 'moi:/scratchpad' })

expect(calls).toEqual([])
})

test('emitting with no subscribers (screen unmounted) is a no-op', () => {
const ws = `ws-${crypto.randomUUID()}`
const { bridge } = appletRuntime(ws).connect(VIEW)
expect(() => bridge.focusTab('agent')).not.toThrow()
expect(() => bridge.navigate('moi:/scratchpad')).not.toThrow()
})

test('an unbound subscriber stops receiving; others keep receiving', () => {
const ws = `ws-${crypto.randomUUID()}`
const first = subscribeFocus(ws)
const second = subscribeFocus(ws)
const first = subscribeNavigation(ws)
const second = subscribeNavigation(ws)
const { bridge } = appletRuntime(ws).connect(VIEW)

bridge.focusTab('agent')
bridge.navigate('moi:/scratchpad')
first.unbind()
bridge.focusTab('overview')
bridge.navigate('moi:/overview')

expect(first.calls).toEqual([['agent', undefined]])
expect(second.calls).toEqual([
['agent', undefined],
['overview', undefined]
])
expect(first.calls).toEqual(['moi:/scratchpad'])
expect(second.calls).toEqual(['moi:/scratchpad', 'moi:/overview'])
})
})

Expand Down Expand Up @@ -314,13 +320,13 @@ describe('sendChatMessage rate limiting', () => {
describe('disposal', () => {
test('a disposed connection is inert even while subscribers are live', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeFocus(ws)
const { calls } = subscribeNavigation(ws)

const { bridge, dispose } = appletRuntime(ws).connect(VIEW)
bridge.focusTab('agent')
bridge.navigate('moi:/scratchpad')
dispose()
bridge.focusTab('agent')
expect(calls).toEqual([['agent', undefined]])
bridge.navigate('moi:/scratchpad')
expect(calls).toEqual(['moi:/scratchpad'])
})
})

Expand All @@ -332,29 +338,29 @@ function fakeModule() {
__attachBridge: (next: AppletBridge) => {
bridge = next
},
focusTab: (tab: unknown, params?: unknown) => bridge?.focusTab(tab, params)
navigate: (href: unknown) => bridge?.navigate(href)
}
}

describe('attachAppletBridge', () => {
test('wires a module to its workspace runtime; invalidateApplet neuters it', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeFocus(ws)
const { calls } = subscribeNavigation(ws)

const mod = fakeModule()
attachAppletBridge(mod, ws, appletKey('views', ws, 'board'), VIEW)
mod.focusTab('view:board')
expect(calls).toEqual([['view:board', undefined]])
mod.navigate('moi:/views/board')
expect(calls).toEqual(['moi:/views/board'])

// The rebuild path: invalidation must leave the OLD module instance inert.
invalidateApplet('views', ws, 'board')
mod.focusTab('view:board')
expect(calls).toEqual([['view:board', undefined]])
mod.navigate('moi:/views/board')
expect(calls).toEqual(['moi:/views/board'])
})

test('invalidateAppletSegment disposes bridges kind-wide', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeFocus(ws)
const { calls } = subscribeNavigation(ws)

const mod = fakeModule()
const key = appletKey('widgets', ws, 'clock')
Expand All @@ -363,7 +369,7 @@ describe('attachAppletBridge', () => {
setCachedApplet(key, Promise.resolve(mod))
attachAppletBridge(mod, ws, key, WIDGET)
invalidateAppletSegment('widgets')
mod.focusTab('overview')
mod.navigate('moi:/overview')
expect(calls).toEqual([])
})

Expand All @@ -375,29 +381,29 @@ describe('attachAppletBridge', () => {

test('re-attaching under a key disposes the previous connection', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeFocus(ws)
const { calls } = subscribeNavigation(ws)
const key = appletKey('views', ws, 'board')

const oldMod = fakeModule()
attachAppletBridge(oldMod, ws, key, VIEW)
const newMod = fakeModule()
attachAppletBridge(newMod, ws, key, VIEW)

oldMod.focusTab('agent')
newMod.focusTab('overview')
expect(calls).toEqual([['overview', undefined]])
oldMod.navigate('moi:/scratchpad')
newMod.navigate('moi:/overview')
expect(calls).toEqual(['moi:/overview'])
})
})

describe('workspace isolation', () => {
test('bridges reach only their own workspace runtime', () => {
const wsA = `ws-${crypto.randomUUID()}`
const wsB = `ws-${crypto.randomUUID()}`
const a = subscribeFocus(wsA)
const b = subscribeFocus(wsB)
const a = subscribeNavigation(wsA)
const b = subscribeNavigation(wsB)

appletRuntime(wsA).connect(VIEW).bridge.focusTab('agent')
expect(a.calls).toEqual([['agent', undefined]])
appletRuntime(wsA).connect(VIEW).bridge.navigate('moi:/scratchpad')
expect(a.calls).toEqual(['moi:/scratchpad'])
expect(b.calls).toEqual([])
})
})
Expand Down
57 changes: 29 additions & 28 deletions client/features/applets/applet-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
// The workspace applet runtime — the host side of the applet `moi` module.
//
// Every applet bundle inlines its own copy of the `moi` virtual module (see
// MOI_MODULE_SOURCE in server/applets/build-applet.ts), so each loaded module
// instance holds a private `bridge` slot. Right after the dynamic import, the
// host connects that instance to the workspace's runtime by attaching a thin
// Every applet bundle inlines its own copy of the `moi` runtime module (see
// server/applets/runtime/moi.ts), so each loaded module instance holds a
// private `bridge` slot. Right after the dynamic import, the host connects that
// instance to the workspace's runtime by attaching a thin
// bridge (`attachAppletBridge`); invalidation disposes it (`disposeAppletBridge`),
// leaving a stale module instance — old timers, old listeners — inert instead
// of steering the app. One runtime per workspace id.
//
// Applet calls surface as runtime EVENTS: the bridge validates the untrusted
// args, then emits, and each host feature subscribes to its own concern with
// `useAppletEvent` (navigation owns `focusTab`; chat will own `sendChatMessage`)
// `useAppletEvent` (navigation owns `navigate`; chat will own `sendChatMessage`)
// — no central handlers object assembled by the screen. Applet → host only;
// if a host → applet direction is ever added (`moi.on(...)`), `dispose` must
// also unbind those listeners or a disposed module leaks.
import { snapshotTextAttachment } from '@/lib/moi-attachments'
import type { AttachmentInput, AttachmentOrigin } from '@/lib/types'
import type { AppletBridge, AppletKind, AttachmentInput, AttachmentOrigin } from '@/lib/types'
import { isWorkspaceAttachmentPath, MAX_UPLOAD_BYTES } from '@/lib/message-attachments'
import { useEffect } from 'react'

Expand All @@ -24,8 +24,10 @@ import { createNanoEvents } from 'nanoevents'
import { reportAppletError } from '@/client/features/applets/applet-log'
import { createRateLimiter, type RateLimiter } from '@/client/lib/rate-limit'
import { useLatestRef } from '@/client/lib/use-latest-ref'
import type { AppletKind, WorkspaceTabId } from '@/lib/types'
import { isParamsRecord, isWorkspaceTabId } from '@/lib/workspace-tabs'
import { isParamsRecord } from '@/lib/workspace-tabs'
import { resolveWorkspaceHref } from '@/lib/navigation'

export type { AppletBridge } from '@/lib/types'

// Which applet a bridge belongs to, supplied by the host at attach time.
export type AppletIdentity = { kind: AppletKind; name: string }
Expand All @@ -44,24 +46,12 @@ export type AppletChatMessage = {
// Events a workspace runtime emits — already validated, typed for host code.
export type AppletEvents = {
addChatAttachment: (attachment: AttachmentInput & AttachmentOrigin) => void
// Client-local replace-navigation to a workspace tab. `params` reach the
// target view as its `params` prop via navigation state — JSON-plain only
// (history state is structured-cloned).
focusTab: (tab: WorkspaceTabId, params?: Record<string, unknown>) => void
navigate: (href: string) => void
// A message for the workspace's active chat, sent as if the user typed
// `message`, with attachments prepared before the send.
sendChatMessage: (message: AppletChatMessage) => void
}

// What a bundle's `moi` module calls. Args are `unknown` on purpose: they
// cross the trust boundary from agent-authored code, and the runtime narrows
// them before emitting.
export type AppletBridge = {
addChatAttachment: (input: unknown) => void
focusTab: (tab: unknown, params?: unknown) => void
sendChatMessage: (input: unknown, context?: unknown) => void
}

// A message longer than this is a bug, not a chat message — it would land in
// a bubble verbatim.
const MAX_MESSAGE_CHARS = 1000
Expand Down Expand Up @@ -134,11 +124,11 @@ function createRuntime(workspaceId: string) {
return emitter.on(event, cb)
},
// One connection per loaded module instance. The bridge validates every
// call — a malformed tab id or params shape from applet code drops the
// call — a malformed address or chat input from applet code drops the
// call instead of being emitted — and `dispose` flips the connection dead
// so a disposed module can never act again. Emitting with no subscribers
// (workspace screen unmounted) is a no-op by nanoevents semantics.
connect(identity: AppletIdentity) {
connect(identity: AppletIdentity, base = '') {
let alive = true
const source = appletSource(identity)
const bridge: AppletBridge = {
Expand All @@ -152,10 +142,20 @@ function createRuntime(workspaceId: string) {
drop(identity, `addChatAttachment() was dropped: ${errorMessage(error)}`)
}
},
focusTab(tab, params) {
navigate(href) {
if (!alive) return
if (!isWorkspaceTabId(tab)) return
emitter.emit('focusTab', tab, isParamsRecord(params) ? params : undefined)
try {
if (typeof href !== 'string') throw new Error('Navigation requires a URL string')
resolveWorkspaceHref(workspaceId, href, base)
emitter.emit('navigate', href)
} catch (error) {
drop(identity, `navigate() was dropped: ${errorMessage(error)}`)
}
},
resolveHref(href) {
if (!alive) return ''
if (typeof href !== 'string') throw new Error('resolveHref requires a URL string')
return resolveWorkspaceHref(workspaceId, href, base)
},
sendChatMessage(input, legacyContext) {
if (!alive) return
Expand Down Expand Up @@ -304,14 +304,15 @@ export function attachAppletBridge(
mod: unknown,
workspaceId: string,
key: string,
identity: AppletIdentity
identity: AppletIdentity,
base = ''
): void {
const attach = (mod as BridgeModule).__attachBridge
if (typeof attach !== 'function') return
// A key is re-attached only after invalidation disposed it, but never leave
// a live orphan connection behind if that ordering ever changes.
connections.get(key)?.()
const { bridge, dispose } = appletRuntime(workspaceId).connect(identity)
const { bridge, dispose } = appletRuntime(workspaceId).connect(identity, base)
connections.set(key, dispose)
attach(bridge)
}
Expand Down
Loading
Loading