Skip to content

feat: add lightweight shared Agent Studios - #1640

Open
wu736139669 wants to merge 19 commits into
tiann:mainfrom
wu736139669:feat/shared-agent-studio
Open

feat: add lightweight shared Agent Studios#1640
wu736139669 wants to merge 19 commits into
tiann:mainfrom
wu736139669:feat/shared-agent-studio

Conversation

@wu736139669

Copy link
Copy Markdown
Contributor

Summary

  • add host-controlled shared Agent Studios for existing HAPI sessions
  • let guests view a redacted conversation and post discussion/suggestion items without HAPI credentials
  • keep Agent execution, terminal, files, permissions, and model controls owner-only
  • add revocable share links with namespace/session isolation and owner moderation
  • serve /studio/<token> through a dedicated Studio Lite entry instead of the full HAPI PWA
  • add smart conversation scrolling, latest-message jump, and collapsible long Agent replies

Validation

  • Hub typecheck passes
  • Studio store/migration/route tests pass
  • Studio Lite behavior tests pass
  • Studio Lite production build produces a dedicated entry (about 111 KB gzip for JS)
  • Existing HAPI /sessions entry remains unchanged

Notes

The full web typecheck in the local PR worktree was affected by incomplete dependency installation; the only known upstream baseline issue in the deployment worktree is the existing duplicate property in web/src/components/assistant-ui/markdown-a.test.tsx:64.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Generated share links lose the configured hub origin — the owner page copies window.location.origin, while HAPI explicitly supports a separately hosted PWA whose API client uses baseUrl. Both public studio clients then fetch relative /api/public/... URLs, so links created from app.hapi.run or another standalone host call the static host instead of the selected hub and cannot load the studio. Evidence: web/src/routes/studios/owner.tsx:93, web/src/studio/StudioLiteApp.tsx:74
    Suggested fix:

    const { api, baseUrl } = useAppContext()
    const shareUrl = room
        ? `${window.location.origin}/studio/${room.shareToken}?hub=${encodeURIComponent(baseUrl)}`
        : ''
    
    // Studio Lite: validate hub as http(s), then use it for GET and POST.
    const apiBase = new URLSearchParams(window.location.search).get('hub') ?? window.location.origin
    await fetch(new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiBase))
  • [Major] Public posting limit is bypassable and its bucket map is unbounded — guestId is supplied by the caller, and the forwarded-IP headers may also be caller-controlled in direct deployments. Varying either value creates a fresh bucket, allowing unlimited database writes; every unique key also remains in postRateBuckets forever. Evidence: hub/src/web/routes/studios.ts:157
    Suggested fix:

    // Enforce a bounded room-wide limit before accepting any post.
    if (!allowPost(`room:${room.id}`)) {
        return c.json({ error: 'Too many posts; try again shortly' }, 429)
    }
    // Also prune empty buckets periodically, or use a bounded TTL cache.
  • [Major] The 200-post cap permanently hides newer moderation items — listPosts sorts ascending before applying LIMIT, so after 200 lifetime posts every later discussion/suggestion is stored but absent from both owner and public responses. This can silently strand valid suggestions and is easy to trigger through the public endpoint. Evidence: hub/src/store/studioStore.ts:157
    Suggested fix:

    SELECT * FROM (
        SELECT * FROM studio_posts
        WHERE room_id = ?
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    )
    ORDER BY created_at ASC, id ASC
  • [Minor] New messages force readers back to the bottom — this effect depends on unreadCount; while the conversation tab is already active (the desktop/default state), incrementing unread count immediately invokes scrollToLatest. The jump button therefore cannot preserve a reader's position in history. Evidence: web/src/studio/StudioLiteApp.tsx:195
    Suggested fix:

    const previousTabRef = useRef(tab)
    useEffect(() => {
        const openedConversation =
            previousTabRef.current !== 'conversation' && tab === 'conversation'
        previousTabRef.current = tab
        if (openedConversation && unreadCount > 0) scrollToLatest('smooth')
    }, [scrollToLatest, tab, unreadCount])

Questions

  • None.

Summary

  • Review mode: initial
  • Three major issues: standalone/relay share links target the wrong API origin, public posting throttling is trivially bypassed with unbounded memory growth, and the post query hides all entries after the oldest 200. One minor regression breaks the advertised read-position behavior.

Testing

  • Not run (automation); PR content was not executed.
  • Add coverage for a configured remote hub share URL, rate-limit attempts with rotating guest IDs, retrieval after more than 200 posts, and receiving new messages while scrolled away from the bottom.

HAPI Bot

Comment thread web/src/routes/studios/owner.tsx Outdated
() => posts.filter((post) => post.roomId === room?.id && post.kind === 'discussion'),
[posts, room?.id]
)
const shareUrl = room ? `${window.location.origin}/studio/${room.shareToken}` : ''

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Generated share links lose the configured hub origin

This page uses the authenticated API client's configured baseUrl, but the copied URL is built from window.location.origin. In the supported standalone/official PWA setup those origins differ. The public clients then fetch relative /api/public/... URLs (StudioLiteApp.tsx:74), so the guest calls the static web host instead of the selected hub and the share cannot load.

Suggested fix:

const { api, baseUrl } = useAppContext()
const shareUrl = room
    ? `${window.location.origin}/studio/${room.shareToken}?hub=${encodeURIComponent(baseUrl)}`
    : ''

// Studio Lite: validate hub as http(s), then use it for both GET and POST.
const apiBase = new URLSearchParams(window.location.search).get('hub') ?? window.location.origin
await fetch(new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiBase))

Comment thread hub/src/web/routes/studios.ts Outdated
const remote = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
?? c.req.header('x-real-ip')
?? 'unknown'
if (!allowPost(`${room.id}:${remote}:${parsed.data.guestId}`)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Public posting limit is bypassable and its bucket map is unbounded

guestId is caller-supplied, and forwarded-IP headers may also be caller-controlled in direct deployments. Rotating either value creates a fresh bucket, so a token holder can write without an effective limit. Each unique key also remains in postRateBuckets forever, adding a memory-exhaustion path.

Suggested fix:

// Enforce a bounded room-wide limit before accepting any post.
if (!allowPost(`room:${room.id}`)) {
    return c.json({ error: 'Too many posts; try again shortly' }, 429)
}
// Also prune empty buckets periodically, or replace the map with a bounded TTL cache.

const rows = this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ?
ORDER BY created_at ASC, id ASC

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] The 200-post cap permanently hides newer moderation items

The query orders oldest-first and only then applies LIMIT 200. Once a studio has 200 posts, every later discussion or suggestion is persisted but never returned to the owner or guests, silently stranding new moderation work.

Suggested fix:

SELECT * FROM (
    SELECT * FROM studio_posts
    WHERE room_id = ?
    ORDER BY created_at DESC, id DESC
    LIMIT ?
)
ORDER BY created_at ASC, id ASC

return undefined
}, [data?.messages, nearBottom, scrollToLatest])

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] New messages force readers back to the bottom

Because this effect also depends on unreadCount, a new message received while the conversation tab is already active increments the count and immediately calls scrollToLatest. On desktop that tab is always active, so the unread/jump control cannot preserve a reader's position.

Suggested fix:

const previousTabRef = useRef(tab)
useEffect(() => {
    const openedConversation =
        previousTabRef.current !== 'conversation' && tab === 'conversation'
    previousTabRef.current = tab
    if (openedConversation && unreadCount > 0) scrollToLatest('smooth')
}, [scrollToLatest, tab, unreadCount])

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] GitHub Pages fallback still ignores the selected hub - standalone share links include ?hub=..., but web/package.json publishes the full PWA as 404.html. A direct /studio/<token> request on app.hapi.run therefore loads this fallback component, whose GET and POST both use relative /api URLs. The request still goes to app.hapi.run instead of the shared room's hub. Evidence: web/src/routes/studios/public.tsx:21
    Suggested fix:

    const apiOrigin = resolveStudioApiOrigin(
        new URLSearchParams(window.location.search).get('hub'),
        window.location.origin
    )
    const response = await fetch(
        new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiOrigin)
    )
    // Use the same apiOrigin for POST.
  • [Major] Public transcript projection bypasses existing visibility rules - all stored rows are projected directly. User rows with invokedAt === null are queued/scheduled prompts, and assistant output marked isMeta or isCompactSummary is hidden by the existing export pipeline; this route can publish both through the share token. Evidence: hub/src/web/routes/studios.ts:79
    Suggested fix:

    if (record.role === 'user' && message.invokedAt === null) return null
    const output = isObject(record.content)
        && record.content.type === 'output'
        && isObject(record.content.data)
        ? record.content.data
        : null
    if (output?.isMeta === true || output?.isCompactSummary === true) return null
  • [Major] The mixed 200-post cap still hides valid room data - listPosts limits the combined discussion/suggestion stream before callers filter it. Two hundred suggestions make the public discussion response empty even when older discussions exist; two hundred discussions push older open suggestions out of the owner's moderation view. Evidence: hub/src/store/studioStore.ts:159
    Suggested fix:

    const publicPosts = store.studios.listPostsByKind(room.id, 'discussion', 200)
    const openSuggestions = store.studios.listOpenSuggestions(room.id)
    // Paginate recent history separately; do not cap before applying kind/status.

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • Three major issues remain: standalone Pages links still target the static host API, public transcript redaction exposes non-visible message rows, and post limiting can hide discussions or unresolved suggestions.

Testing

  • Not run (automation); PR code was not executed.
  • GitHub integration check passed; test was pending at review time.
  • Missing coverage: GitHub Pages/full-PWA fallback with a remote hub, queued/meta transcript filtering, and mixed post kinds beyond 200 rows.

HAPI Bot

Comment thread web/src/routes/studios/public.tsx Outdated
}

async function requestStudio(token: string): Promise<PublicStudioResponse> {
const response = await fetch(`/api/public/studios/${encodeURIComponent(token)}`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] GitHub Pages fallback still calls the static host API

The copied cross-origin link now carries ?hub=..., but GitHub Pages serves the full PWA's 404.html for /studio/<token>. This fallback route ignores that parameter here and again in the POST at line 69, so app.hapi.run requests app.hapi.run/api/... rather than the selected hub.

Suggested fix:

const apiOrigin = resolveStudioApiOrigin(
    new URLSearchParams(window.location.search).get('hub'),
    window.location.origin
)
const response = await fetch(
    new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiOrigin)
)
// Use apiOrigin for POST too.

function projectMessage(message: StoredMessage): PublicStudioMessage | null {
const record = unwrapRoleWrappedRecordEnvelope(message.content)
if (!record) return null
if (record.role === 'user') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Apply the existing message-visibility policy before publishing

This projects every stored user row, including queued or future scheduled prompts (invokedAt === null). The assistant branch also lacks the existing export filters for isMeta and isCompactSummary, so content hidden from normal exports can become public through the share token.

Suggested fix:

if (record.role === 'user' && message.invokedAt === null) return null
const output = isObject(record.content)
    && record.content.type === 'output'
    && isObject(record.content.data)
    ? record.content.data
    : null
if (output?.isMeta === true || output?.isCompactSummary === true) return null

SELECT * FROM studio_posts
WHERE room_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] The shared limit still drops items required by each view

The limit applies before kind/status filtering. If the newest 200 rows are suggestions, the public discussion list is empty despite older discussions; if they are discussions, an older open suggestion disappears from owner moderation. The room-wide limiter only slows this down.

Suggested fix:

const publicPosts = store.studios.listPostsByKind(room.id, 'discussion', 200)
const openSuggestions = store.studios.listOpenSuggestions(room.id)
// Paginate recent history separately; filter in SQL before LIMIT.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Cross-origin Studio reads trigger a disallowed preflight — the explicit cache-control request header is not CORS-safelisted, while the hub only allows authorization, content-type, and last-event-id. Official/standalone Studio links therefore cannot GET the remote hub before rendering. Evidence: web/src/studio/StudioLiteApp.tsx:78, web/src/routes/studios/public.tsx:24, related context hub/src/web/server.ts:247
    Suggested fix:

    const response = await fetch(url, {
        cache: 'no-store'
    })
  • [Major] GitHub Pages still bypasses the Lite entry — the build keeps the full PWA as 404.html, and Pages serves that file for direct /studio/<token> requests. Relay share links therefore load the full application bundle instead of the dedicated Studio build. Evidence: web/package.json:9, related deployment web/.github/workflows/webapp.yml not found; repository workflow .github/workflows/webapp.yml:41
    Suggested fix:

    <script>
    const match = location.pathname.match(/^\/studio\/([^/]+)/)
    if (match) {
        const target = new URL('/studio.html', location.origin)
        target.search = location.search
        target.searchParams.set('token', match[1])
        location.replace(target)
    }
    </script>
    return pathMatch?.[1] ?? new URLSearchParams(location.search).get('token') ?? ''
  • [Major] Owner polling returns every suggestion without a bound — a token holder may keep adding up to 60 suggestions per minute, and the owner page refetches the entire accumulated set every two seconds. This creates an ever-growing SQLite scan, JSON response, and browser render path. Evidence: hub/src/web/routes/studios.ts:98, related poll web/src/routes/studios/owner.tsx:82
    Suggested fix:

    const suggestionPage = store.studios.listSuggestionPage(roomId, {
        limit: 200,
        before: cursor
    })
    return { posts: [...discussions, ...suggestionPage.items], nextSuggestionCursor: suggestionPage.nextCursor }
  • [Major] Transcript limiting happens before visibility projection — getMessages(..., 200) selects the newest raw rows by sequence, then drops queued/meta/tool-only rows. A tool-heavy turn can therefore return few or zero visible messages despite older visible conversation, and scheduled messages are not ordered by the normal invokedAt display position. Evidence: hub/src/web/routes/studios.ts:161
    Suggested fix:

    const messages = await collectProjectedMessagesByPosition(
        options.store.messages,
        room.sessionId,
        projectMessage,
        200
    )
    // Page backward with getMessagesByPosition until 200 projected rows or EOF.

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • Four major issues remain: remote Studio GETs fail CORS preflight, Pages direct links do not use Studio Lite, suggestion moderation payloads grow without bound, and transcript pagination can discard valid visible conversation.

Testing

  • Not run (automation); PR code was not executed.
  • GitHub integration passed; test was in progress at review time.
  • Missing coverage: cross-origin public GET preflight, Pages /studio/<token> entry selection, paginated large suggestion queues, and more than 200 raw rows with fewer than 200 visible messages.

HAPI Bot

Comment thread web/src/studio/StudioLiteApp.tsx Outdated
async function loadStudio(token: string, apiOrigin: string): Promise<PublicStudioResponse> {
const response = await fetch(new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiOrigin), {
cache: 'no-store',
headers: { 'cache-control': 'no-cache' }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Remove the non-safelisted request header

This public GET is cross-origin for the official/standalone PWA. The explicit cache-control header triggers a CORS preflight, but hub/src/web/server.ts:247 does not allow that header, so the browser blocks the Studio before it can render. The same header exists in web/src/routes/studios/public.tsx:24.

Suggested fix:

const response = await fetch(url, {
    cache: 'no-store'
})

Comment thread web/package.json Outdated
"scripts": {
"dev": "vite",
"build": "vite build && cp dist/index.html dist/404.html",
"build": "vite build && cp dist/index.html dist/404.html && rm -rf dist-studio && vite build --config vite.studio.config.ts && cp dist-studio/studio.html dist/studio.html && mkdir -p dist/studio-assets && cp -R dist-studio/studio-assets/. dist/studio-assets/ && rm -rf dist-studio",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Route Pages deep links to the Lite HTML

GitHub Pages serves 404.html for /studio/<token>, and this command still copies the full PWA there. Thus the primary relay share URL never loads the dedicated Studio entry and pays the full application bundle cost.

Suggested fix:

<script>
const match = location.pathname.match(/^\/studio\/([^/]+)/)
if (match) {
    const target = new URL('/studio.html', location.origin)
    target.search = location.search
    target.searchParams.set('token', match[1])
    location.replace(target)
}
</script>

Then let Studio Lite read token from the query when the path is /studio.html; preserve the current full-PWA fallback for non-Studio routes.

Comment thread hub/src/web/routes/studios.ts Outdated
function ownerPosts(store: Store, roomId: string) {
return [
...store.studios.listPostsByKind(roomId, 'discussion', 200),
...store.studios.listPostsByKind(roomId, 'suggestion', null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Paginate suggestions instead of returning the entire history

Passing null removes every bound. A guest can add 60 suggestions per minute indefinitely, while the owner page polls this endpoint every two seconds, so storage reads, response size, and DOM work grow for the lifetime of the room.

Suggested fix:

const suggestionPage = store.studios.listSuggestionPage(roomId, {
    limit: 200,
    before: cursor
})
return {
    posts: [...discussions, ...suggestionPage.items],
    nextSuggestionCursor: suggestionPage.nextCursor
}

Comment thread hub/src/web/routes/studios.ts Outdated
const session = engine?.resolveSessionAccess(room.sessionId, room.namespace)
if (!engine || !session?.ok) return c.json({ error: 'Studio session unavailable' }, 503)

const messages = options.store.messages.getMessages(room.sessionId, 200)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Apply the limit after projection and use display-position paging

This selects 200 raw sequence rows before filtering. Tool/meta/queued rows can consume the whole page, hiding older visible conversation; sequence order also misplaces scheduled prompts relative to their invokedAt display position.

Suggested fix:

const messages = await collectProjectedMessagesByPosition(
    options.store.messages,
    room.sessionId,
    projectMessage,
    200
)
// Page backward with getMessagesByPosition until 200 projected rows or EOF.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Public Studio writes remain durably unbounded — the in-memory limiter permits 60 posts/minute and resets on restart, while each unauthenticated post may contain 2,000 characters. A holder of one shared token can therefore append roughly 165 MiB/day of text to the owner's SQLite database indefinitely. Evidence: hub/src/web/routes/studios.ts:130, hub/src/web/routes/studios.ts:208, hub/src/web/routes/studios.ts:211
    Suggested fix:

    const post = options.store.studios.createPostWithinRoomLimit(
        { roomId: room.id, ...parsed.data },
        MAX_STUDIO_POSTS_PER_ROOM
    )
    if (!post) return c.json({ error: 'Studio post limit reached' }, 429)

    Implement the limit atomically with the insert; add owner purge/retention for discussions and resolved suggestions.

  • [Major] Owners cannot moderate public discussion posts — discussions are returned immediately to every guest, but the only decision endpoint rejects every non-suggestion post. A guest can permanently vandalize the discussion, leaving revocation of the whole room as the owner's only removal mechanism. Evidence: hub/src/web/routes/studios.ts:190, hub/src/web/routes/studios.ts:294
    Suggested fix:

    if (!post || post.status !== 'open') return c.json({ error: 'Open post not found' }, 404)
    if (parsed.data.action === 'dismiss') {
        return c.json({ post: options.store.studios.decidePost(post.id, room.id, 'dismissed') })
    }
    if (post.kind !== 'suggestion') return c.json({ error: 'Discussion posts cannot be submitted' }, 400)
    
    const publicPosts = options.store.studios
        .listPostsByKind(room.id, 'discussion', 200)
        .filter((post) => post.status === 'open')
  • [Minor] Public JSON exposes a stable cross-room guest identifier — both guest clients persist one global hapi.studio.guestId, and the public GET serializes full StudioPost rows. Anyone holding multiple room links can correlate the same browser's participation even though the UI never needs guestId. Evidence: hub/src/web/routes/studios.ts:190, related context web/src/studio/StudioLiteApp.tsx:53
    Suggested fix:

    const publicPosts = options.store.studios.listPostsByKind(room.id, 'discussion', 200).map(
        ({ id, roomId, authorName, kind, text, createdAt }) =>
            ({ id, roomId, authorName, kind, text, createdAt })
    )

    Define a separate PublicStudioPost response type so private moderation fields cannot be reintroduced accidentally.

  • [Minor] Paginated suggestion moderation leaves older rows stale and undercounts the queue — resolving a suggestion loaded beyond the first 200 only invalidates the first-page query; because its cursor is unchanged, olderSuggestions keeps the item as open and permits a second action that returns 404. The badge also counts loaded rows instead of the server's openSuggestionCount. Evidence: web/src/routes/studios/owner.tsx:124, web/src/routes/studios/owner.tsx:216
    Suggested fix:

    const decisionMutation = useMutation({
        mutationFn: (input) => api.decideStudioPost(studioId, input.postId, input),
        onSuccess: ({ post }) => {
            if (post) setOlderSuggestions((items) => items.map((item) => item.id === post.id ? post : item))
            void refresh()
        }
    })
    const pendingCount = query.data?.openSuggestionCount ?? suggestions.filter((post) => post.status === 'open').length

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • One durable storage-exhaustion risk, one public-content moderation gap, and two privacy/pagination correctness issues remain at the latest head.

Testing

  • Not run (automation); PR code was not executed.
  • GitHub integration passed; test was in progress at review time.
  • Missing coverage: durable post quota/retention, dismissing public discussions, public post-field redaction, and moderating/counting suggestions loaded after page 1.

HAPI Bot

Comment thread hub/src/web/routes/studios.ts Outdated
if (!allowPost(`room:${room.id}`)) {
return c.json({ error: 'Too many posts; try again shortly' }, 429)
}
const post = options.store.studios.createPost({ roomId: room.id, ...parsed.data })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Add a durable per-room storage bound

This limiter only throttles to 60 posts/minute in memory and resets on restart. With 2,000-character bodies, one shared token can append roughly 165 MiB/day of text to SQLite indefinitely.

Suggested fix:

const post = options.store.studios.createPostWithinRoomLimit(
    { roomId: room.id, ...parsed.data },
    MAX_STUDIO_POSTS_PER_ROOM
)
if (!post) return c.json({ error: 'Studio post limit reached' }, 429)

Make the quota check and insert one SQLite transaction; provide owner purge/retention for discussions and resolved suggestions.

Comment thread hub/src/web/routes/studios.ts Outdated
const body = await c.req.json().catch(() => null)
const parsed = decidePostSchema.safeParse(body)
if (!parsed.success) return c.json({ error: 'Invalid body' }, 400)
const post = options.store.studios.getPost(c.req.param('postId'), room.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Allow owners to remove public discussions

Discussion posts are published immediately, but this guard makes only suggestions dismissible. There is no other delete/dismiss path, so one guest can leave permanent public content unless the owner revokes the entire room.

Suggested fix:

if (!post || post.status !== 'open') return c.json({ error: 'Open post not found' }, 404)
if (parsed.data.action === 'dismiss') {
    return c.json({ post: options.store.studios.decidePost(post.id, room.id, 'dismissed') })
}
if (post.kind !== 'suggestion') return c.json({ error: 'Discussion posts cannot be submitted' }, 400)

Also filter dismissed discussions out of the public response.

Comment thread hub/src/web/routes/studios.ts Outdated
if (page.length < 200) break
}
const publicMessages = messages.slice(-200)
const publicPosts = options.store.studios.listPostsByKind(room.id, 'discussion', 200)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Redact the stable guest ID from public JSON

Both guest clients persist one global hapi.studio.guestId, while this response serializes full stored rows. A reader with multiple room links can therefore correlate the same browser across Studios even though the UI only needs display fields.

Suggested fix:

const publicPosts = options.store.studios.listPostsByKind(room.id, 'discussion', 200).map(
    ({ id, roomId, authorName, kind, text, createdAt }) =>
        ({ id, roomId, authorName, kind, text, createdAt })
)

Use a separate PublicStudioPost type to keep moderation-only fields private.

mutationFn: (input: { accessMode?: 'view' | 'contribute'; rotateToken?: boolean }) => api.updateStudio(studioId, input),
onSuccess: refresh
})
const decisionMutation = useMutation({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Reconcile locally paged suggestions after moderation

When the owner acts on a suggestion loaded beyond page 1, invalidating the first-page query does not update olderSuggestions; its cursor usually stays unchanged, so the row remains open and a second click returns 404. The badge at line 216 also ignores the server's openSuggestionCount.

Suggested fix:

onSuccess: ({ post }) => {
    if (post) setOlderSuggestions((items) => items.map((item) => item.id === post.id ? post : item))
    void refresh()
}
const pendingCount = query.data?.openSuggestionCount
    ?? suggestions.filter((post) => post.status === 'open').length

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Lifetime quota is unrecoverable after a discussion flood - the cap counts every historical row, while the owner payload exposes only the latest 200 discussions and token rotation/reactivation preserves all posts. A guest can fill 2,000 discussion rows in about 34 minutes; after that every contribution returns 429, and dismissing the visible 200 still leaves the older rows unreachable and counted. Evidence: hub/src/store/studioStore.ts:280, related context hub/src/web/routes/studios.ts:116, hub/src/store/studioStore.ts:148
    Suggested fix:

    clearPosts(roomId: string): void {
        this.db.prepare('DELETE FROM studio_posts WHERE room_id = ?').run(roomId)
    }
    
    // Expose through an authenticated owner-only route with confirmation.
    // Also query open discussions in SQL so moderation reveals the next page.
  • [Major] Public polling can rescan an entire tool-heavy transcript - the loop has no raw-row/page bound and stops only after finding 200 visible messages or reaching the beginning. A valid share-link holder can repeatedly force every historical row to be fetched and decoded; Studio Lite polls this endpoint every five seconds. Evidence: hub/src/web/routes/studios.ts:192, related context web/src/studio/StudioLiteApp.tsx:146
    Suggested fix:

    const MAX_RAW_MESSAGES_SCANNED = 2_000
    let scanned = 0
    while (messages.length < 200 && scanned < MAX_RAW_MESSAGES_SCANNED) {
        const page = options.store.messages.getMessagesByPosition(room.sessionId, 200, before)
        scanned += page.length
        // existing projection/cursor logic
    }

    A persisted public-message projection would avoid rescanning entirely.

  • [Minor] Moderating a paged suggestion duplicates the resolved row - olderSuggestions keeps the updated row, then the refreshed owner payload also includes it among the 50 resolved suggestions. Because the first-page cursor is unchanged for a decision beyond page 1, the reset effect does not run and React receives duplicate keys/items. Evidence: web/src/routes/studios/owner.tsx:129, related context web/src/routes/studios/owner.tsx:88
    Suggested fix:

    if (result.post) {
        setOlderSuggestions((current) =>
            current.filter((post) => post.id !== result.post!.id)
        )
    }
    void refresh()
  • [Minor] Studio links ignore the configured Vite base path - both the generated share URL and the static 404 redirect are rooted at /. On the documented /<repo>/ standalone deployment, links leave the app scope and the 404 handler does not recognize /<repo>/studio/<token>. Evidence: web/src/studio/studioUrl.ts:14, related context web/public/404.html:8, web/README.md:186
    Suggested fix:

    const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
    const page = new URL(`studio/${encodeURIComponent(token)}`, appBase)

    Derive the 404 redirect prefix from the segment before /studio/ and target <prefix>/studio.html.

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • Two major availability risks and two minor correctness/deployment regressions remain at the latest head.

Testing

  • Not run (automation); PR code was not executed.
  • GitHub integration and test checks passed.
  • Missing coverage: quota exhaustion recovery, bounded transcript scanning, resolving suggestions beyond page 1 without duplicates, and Studio links under a non-root Vite base.

HAPI Bot

const limit = Math.max(1, Math.floor(maxPosts))
return this.db.transaction(() => {
const row = this.db.prepare(
'SELECT COUNT(*) AS count FROM studio_posts WHERE room_id = ?'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Make the durable quota recoverable

This counts every historical post forever. The owner only receives the latest 200 discussions, and dismissed rows stay in that window; rotating or revoking/reopening the token also preserves all rows. A guest can therefore fill 2,000 discussions and permanently force 429 responses without DB intervention.

Suggested fix:

clearPosts(roomId: string): void {
    this.db.prepare('DELETE FROM studio_posts WHERE room_id = ?').run(roomId)
}

Expose the purge through an authenticated owner-only action with confirmation, and query open discussions in SQL so dismissing one page reveals the next.

Comment thread hub/src/web/routes/studios.ts Outdated

const messages: PublicStudioMessage[] = []
let before: { at: number; seq: number } | undefined
while (messages.length < 200) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Bound raw transcript scanning

This loop can walk the entire session history when most rows are tool calls/results. Since the public client polls every five seconds, a share-link holder can repeatedly force all rows to be fetched and decoded.

Suggested fix:

const MAX_RAW_MESSAGES_SCANNED = 2_000
let scanned = 0
while (messages.length < 200 && scanned < MAX_RAW_MESSAGES_SCANNED) {
    const page = options.store.messages.getMessagesByPosition(room.sessionId, 200, before)
    scanned += page.length
    // existing projection/cursor logic
}

A persisted visible-message projection would remove the repeated scan entirely.

Comment thread web/src/routes/studios/owner.tsx Outdated
api.decideStudioPost(studioId, input.postId, input),
onSuccess: (result) => {
if (result.post) {
setOlderSuggestions((current) => current.map((post) => post.id === result.post!.id ? result.post! : post))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Remove the handled item from the paged cache

For a suggestion loaded beyond page 1, the first-page cursor usually stays unchanged. This mapped row remains in olderSuggestions, while the refresh also returns it in the resolved-suggestion list, producing duplicate items and React keys.

Suggested fix:

if (result.post) {
    setOlderSuggestions((current) =>
        current.filter((post) => post.id !== result.post!.id)
    )
}
void refresh()

Comment thread web/src/studio/studioUrl.ts Outdated
}

export function buildStudioShareUrl(pageOrigin: string, token: string, hubBaseUrl: string): string {
const page = new URL(`/studio/${encodeURIComponent(token)}`, pageOrigin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Preserve the Vite base path

Rooting the URL at /studio/ breaks the documented standalone /<repo>/ deployment. The accompanying 404 handler is also anchored to root, so the generated link cannot recover through /<repo>/404.html.

Suggested fix:

const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
const page = new URL(`studio/${encodeURIComponent(token)}`, appBase)

Update the 404 redirect to derive the prefix before /studio/ and redirect to <prefix>/studio.html.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls c.req.json() before either schema validation or the rate limiter. The shared Bun server accepts request bodies up to 68 MiB, so anyone holding a valid share link can repeatedly send oversized invalid JSON that consumes memory/CPU without ever consuming a rate-limit slot. Evidence: hub/src/web/routes/studios.ts:224, related context hub/src/web/server.ts:486
    Suggested fix:

    import { bodyLimit } from 'hono/body-limit'
    
    const publicPostBodyLimit = bodyLimit({
        maxSize: 8 * 1024,
        onError: (c) => c.json({ error: 'Post body too large' }, 413)
    })
    
    app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
        // existing handler
    })
  • [Minor] Filter open discussions before applying the 200-row limit — the query selects the newest 200 discussions and only then removes dismissed rows. Every dismissal therefore shrinks the public/owner window without backfilling older open posts; after dismissing one page, still-open discussions can become unreachable except through the destructive “clear all” action. Evidence: hub/src/web/routes/studios.ts:208, related context hub/src/web/routes/studios.ts:116
    Suggested fix:

    listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
        return (this.db.prepare(`
            SELECT * FROM studio_posts
            WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
            ORDER BY created_at DESC, id DESC
            LIMIT ?
        `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
    }
  • [Minor] Standalone subpath support is still incomplete — the dedicated Studio build hardcodes /, so /hapi/studio.html references /studio-assets/... instead of /hapi/studio-assets/...; creating a Studio also navigates to root /studios/.... Both paths break the documented /<repo>/ deployment even though generated share URLs now preserve the base. Evidence: web/vite.studio.config.ts:7, related context web/src/components/SessionHeader.tsx:363, web/README.md:186
    Suggested fix:

    // vite.studio.config.ts: use the same configured base as the main build
    const base = process.env.VITE_BASE_URL || '/'
    export default defineConfig({ base, /* existing config */ })
    
    // SessionHeader.tsx: keep navigation inside the Vite base
    const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
    window.location.assign(new URL(`studios/${result.room.id}`, appBase))

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • One major availability/security risk and two minor correctness/deployment regressions remain at the latest head.

Testing

  • Not run (automation; PR content was not executed).
  • GitHub integration passed; test was pending at review time.
  • Missing coverage: oversized public post rejection before JSON parsing, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.

HAPI Bot

if (room.accessMode !== 'contribute') {
return c.json({ error: 'This studio is view only' }, 403)
}
const body = await c.req.json().catch(() => null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Bound the request body before JSON parsing

This public endpoint parses the body before validation or rate limiting, while the shared Bun server accepts up to 68 MiB. A valid share-link holder can repeatedly submit oversized invalid bodies; those requests consume memory/CPU and never consume a rate-limit slot.

Suggested fix:

import { bodyLimit } from 'hono/body-limit'

const publicPostBodyLimit = bodyLimit({
    maxSize: 8 * 1024,
    onError: (c) => c.json({ error: 'Post body too large' }, 413)
})

app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
    // existing handler
})

}
const publicMessages = messages.slice(-200)
const publicPosts = options.store.studios
.listPostsByKind(room.id, 'discussion', 200)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Apply the status filter before the limit

Selecting the newest 200 discussions and then filtering dismissed rows means each dismissal permanently consumes a window slot. Older open discussions do not backfill, so they can disappear from both public and owner views while still stored as open.

Suggested fix:

listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
    return (this.db.prepare(`
        SELECT * FROM studio_posts
        WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}

Comment thread web/vite.studio.config.ts

export default defineConfig({
appType: 'spa',
base: '/',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Thread the configured base into the Studio build

This hardcoded / makes a subpath deployment's /hapi/studio.html load assets from /studio-assets/.... The creation flow also uses window.location.assign('/studios/...'), leaving the Vite base. The documented /<repo>/ deployment therefore remains broken.

Suggested fix:

const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })

const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Exclude sidechain/subagent output from the public transcript — the existing message pipeline marks subagent assistant turns with data.isSidechain, but the new projector only removes meta and compact-summary rows before extracting assistant text. A shared link can therefore expose intermediate subagent prose, including file paths and findings that belong to owner-only Agent execution details. Evidence: hub/src/web/routes/studios.ts:100, related context web/src/chat/normalizeAgent.ts:237
    Suggested fix:

    if (isObject(record.content)) {
        const data = isObject(record.content.data) ? record.content.data : null
        if (
            data?.isMeta === true
            || data?.isCompactSummary === true
            || data?.isSidechain === true
        ) return null
    }
  • [Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls c.req.json() before schema validation or rate limiting. The shared Bun server accepts request bodies up to 68 MiB, so a valid link holder can repeatedly submit oversized invalid bodies that consume memory/CPU without consuming a rate-limit slot. Evidence: hub/src/web/routes/studios.ts:224, related context hub/src/web/server.ts:486
    Suggested fix:

    import { bodyLimit } from 'hono/body-limit'
    
    const publicPostBodyLimit = bodyLimit({
        maxSize: 8 * 1024,
        onError: (c) => c.json({ error: 'Post body too large' }, 413)
    })
    
    app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
        // existing handler
    })
  • [Minor] Filter open discussions before applying the 200-row limit — the query selects the newest 200 discussions and only then removes dismissed rows. Dismissed rows consume the window, so older open discussions can disappear from both public and owner views while remaining stored as open. Evidence: hub/src/web/routes/studios.ts:208
    Suggested fix:

    listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
        return (this.db.prepare(`
            SELECT * FROM studio_posts
            WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
            ORDER BY created_at DESC, id DESC
            LIMIT ?
        `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
    }
  • [Minor] Thread the configured base through the Studio build and owner navigation — the dedicated build hardcodes /, and Studio creation navigates to root /studios/.... A documented subpath deployment such as /hapi/ therefore loads Studio assets and owner routes from the wrong location. Evidence: web/vite.studio.config.ts:7, related context web/src/components/SessionHeader.tsx:363, web/README.md:186
    Suggested fix:

    const base = process.env.VITE_BASE_URL || '/'
    export default defineConfig({ base, /* existing config */ })
    
    const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
    window.location.assign(new URL(`studios/${result.room.id}`, appBase))

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • Two major privacy/availability risks and two minor correctness/deployment regressions remain at the latest head. The new commit only changes the guest-facing “Owner” label to “Host”; it does not resolve the prior findings.

Testing

  • Not run (automation; PR content was not executed).
  • GitHub integration and test checks passed at review time.
  • Missing coverage: sidechain transcript redaction, oversized public post rejection before JSON parsing, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.

HAPI Bot

if (record.role === 'user' && message.invokedAt === null) return null
if (isObject(record.content)) {
const data = isObject(record.content.data) ? record.content.data : null
if (data?.isMeta === true || data?.isCompactSummary === true) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Exclude sidechain/subagent output from the public transcript

The established message format marks subagent assistant turns with data.isSidechain (see web/src/chat/normalizeAgent.ts:237). This projector only filters meta and compact-summary rows, so sharees can receive intermediate subagent prose such as file paths and findings that belong to owner-only Agent execution details.

Suggested fix:

if (
    data?.isMeta === true
    || data?.isCompactSummary === true
    || data?.isSidechain === true
) return null

if (room.accessMode !== 'contribute') {
return c.json({ error: 'This studio is view only' }, 403)
}
const body = await c.req.json().catch(() => null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Bound the request body before JSON parsing

This public endpoint parses the body before validation or rate limiting, while the shared Bun server accepts up to 68 MiB. A valid share-link holder can repeatedly submit oversized invalid bodies; those requests consume memory/CPU and never consume a rate-limit slot.

Suggested fix:

import { bodyLimit } from 'hono/body-limit'

const publicPostBodyLimit = bodyLimit({
    maxSize: 8 * 1024,
    onError: (c) => c.json({ error: 'Post body too large' }, 413)
})

app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
    // existing handler
})

}
const publicMessages = messages.slice(-200)
const publicPosts = options.store.studios
.listPostsByKind(room.id, 'discussion', 200)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Apply the status filter before the limit

Selecting the newest 200 discussions and then filtering dismissed rows means each dismissal consumes a window slot. Older open discussions do not backfill, so they can disappear from both public and owner views while still stored as open.

Suggested fix:

listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
    return (this.db.prepare(`
        SELECT * FROM studio_posts
        WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}

Comment thread web/vite.studio.config.ts

export default defineConfig({
appType: 'spa',
base: '/',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Thread the configured base into the Studio build

This hardcoded / makes a subpath deployment's /hapi/studio.html load assets from /studio-assets/.... Studio creation also navigates to root /studios/..., so the documented /<repo>/ deployment remains broken.

Suggested fix:

const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })

const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Exclude sidechain/subagent output from the public transcript — the projector filters meta and compact-summary rows, but the established Claude message format marks subagent assistant turns with data.isSidechain. extractAssistantPlainText still extracts those turns, so a share link can expose intermediate subagent prose and owner-only execution details. Evidence: hub/src/web/routes/studios.ts:100, related context web/src/chat/normalizeAgent.ts:237
    Suggested fix:

    if (
        data?.isMeta === true
        || data?.isCompactSummary === true
        || data?.isSidechain === true
    ) return null
  • [Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls c.req.json() before validation or rate limiting, while the shared Bun server accepts request bodies up to 68 MiB. Oversized invalid requests therefore consume memory/CPU without consuming a rate-limit slot. Evidence: hub/src/web/routes/studios.ts:224, related context hub/src/web/server.ts:486
    Suggested fix:

    import { bodyLimit } from 'hono/body-limit'
    
    const publicPostBodyLimit = bodyLimit({
        maxSize: 8 * 1024,
        onError: (c) => c.json({ error: 'Post body too large' }, 413)
    })
    
    app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
        // existing handler
    })
  • [Minor] Filter open discussions before applying the 200-row limit — listPostsByKind limits all discussions first and the route removes dismissed rows afterward. Dismissed rows consume the window, so older open discussions can disappear from public and owner views while still stored as open. Evidence: hub/src/store/studioStore.ts:199, related context hub/src/web/routes/studios.ts:208
    Suggested fix:

    listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
        return (this.db.prepare(`
            SELECT * FROM studio_posts
            WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
            ORDER BY created_at DESC, id DESC
            LIMIT ?
        `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
    }
  • [Minor] Preserve the configured Vite base for Studio assets and owner navigation — the dedicated Studio build hardcodes /, and creation navigates to root /studios/.... A documented subpath deployment such as /hapi/ therefore requests Studio assets and the owner route from the wrong location. Evidence: web/vite.studio.config.ts:7, related context web/src/components/SessionHeader.tsx:363, web/README.md:186
    Suggested fix:

    const base = process.env.VITE_BASE_URL || '/'
    export default defineConfig({ base, /* existing config */ })
    
    const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
    window.location.assign(new URL(`studios/${result.room.id}`, appBase))

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • Two major privacy/availability risks and two minor correctness/deployment regressions remain at the latest head. The new commit changes Studio scrolling and guest suggestion UX but does not resolve the prior findings.

Testing

  • Not run (automation; PR content was not executed).
  • GitHub integration passed; test was still pending at review time.
  • Missing coverage: sidechain transcript redaction, oversized public post rejection before JSON parsing, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.

HAPI Bot

if (record.role === 'user' && message.invokedAt === null) return null
if (isObject(record.content)) {
const data = isObject(record.content.data) ? record.content.data : null
if (data?.isMeta === true || data?.isCompactSummary === true) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Exclude sidechain/subagent output from the public transcript

The established Claude message format marks subagent assistant turns with data.isSidechain (web/src/chat/normalizeAgent.ts:237). This projector filters only meta and compact-summary rows, while extractAssistantPlainText still extracts sidechain prose, exposing intermediate owner-only execution details through the share link.

Suggested fix:

if (
    data?.isMeta === true
    || data?.isCompactSummary === true
    || data?.isSidechain === true
) return null

if (room.accessMode !== 'contribute') {
return c.json({ error: 'This studio is view only' }, 403)
}
const body = await c.req.json().catch(() => null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Bound the request body before JSON parsing

This unauthenticated endpoint parses the body before validation or rate limiting, while hub/src/web/server.ts:486 accepts up to 68 MiB. Oversized invalid requests consume memory/CPU and never consume a rate-limit slot.

Suggested fix:

import { bodyLimit } from 'hono/body-limit'

const publicPostBodyLimit = bodyLimit({
    maxSize: 8 * 1024,
    onError: (c) => c.json({ error: 'Post body too large' }, 413)
})

app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
    // existing handler
})

const params = limit === null ? [roomId, kind] : [roomId, kind, limit]
const rows = this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ? AND kind = ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Apply the status filter before the limit

The route requests the newest 200 discussions and then removes dismissed rows. Those dismissed rows consume the window, so older open discussions do not backfill and can disappear from both public and owner views.

Suggested fix:

listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
    return (this.db.prepare(`
        SELECT * FROM studio_posts
        WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}

Comment thread web/vite.studio.config.ts

export default defineConfig({
appType: 'spa',
base: '/',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Preserve the configured base in the Studio build

This hardcoded / makes a subpath deployment such as /hapi/ load Studio assets from /studio-assets/.... Creation also navigates to root /studios/... at web/src/components/SessionHeader.tsx:363, so the owner route is misplaced too.

Suggested fix:

const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })

const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Exclude sidechain/subagent output from the public transcript — the projector filters meta and compact-summary rows, but Claude marks subagent assistant turns with data.isSidechain. extractAssistantPlainText still extracts those turns, so a share link can expose intermediate subagent prose and owner-only execution details. Evidence: hub/src/web/routes/studios.ts:100, related context web/src/chat/normalizeAgent.ts:237
    Suggested fix:

    if (
        data?.isMeta === true
        || data?.isCompactSummary === true
        || data?.isSidechain === true
    ) return null
  • [Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls c.req.json() before validation or rate limiting, while the Bun server accepts request bodies up to 68 MiB. Oversized invalid requests therefore consume memory and CPU without consuming a rate-limit slot. Evidence: hub/src/web/routes/studios.ts:224, related context hub/src/web/server.ts:486
    Suggested fix:

    import { bodyLimit } from 'hono/body-limit'
    
    const publicPostBodyLimit = bodyLimit({
        maxSize: 8 * 1024,
        onError: (c) => c.json({ error: 'Post body too large' }, 413)
    })
    
    app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
        // existing handler
    })
  • [Major] Prevent guest names from adding hidden instructions to approved suggestions — authorName accepts control characters, and the owner UI renders it as collapsed single-line text, but submission interpolates the raw value into the agent message. A guest can use a newline in the name to append prompt content that is not present in the editable suggestion textarea the owner approves. Evidence: hub/src/web/routes/studios.ts:348, related validation hub/src/web/routes/studios.ts:27
    Suggested fix:

    const authorNameSchema = z.string()
      .trim()
      .min(1)
      .max(40)
      .refine((value) => [...value].every((character) => {
          const code = character.charCodeAt(0)
          return code >= 32 && code !== 127
      }))
    
    const postSchema = z.object({
        // ...
        authorName: authorNameSchema
    })
  • [Minor] Filter open discussions before applying the 200-row limit — listPostsByKind limits all discussions first and the route removes dismissed rows afterward. Dismissed rows consume the window, so older open discussions can disappear from public and owner views while still stored as open. Evidence: hub/src/store/studioStore.ts:199, related context hub/src/web/routes/studios.ts:208
    Suggested fix:

    listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
        return (this.db.prepare(`
            SELECT * FROM studio_posts
            WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
            ORDER BY created_at DESC, id DESC
            LIMIT ?
        `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
    }
  • [Minor] Preserve the configured Vite base for Studio assets and owner navigation — the dedicated Studio build hardcodes /, and creation navigates to root /studios/.... A documented subpath deployment such as /hapi/ therefore requests Studio assets and the owner route from the wrong location. Evidence: web/vite.studio.config.ts:7, related context web/src/components/SessionHeader.tsx:363, web/README.md:186
    Suggested fix:

    const base = process.env.VITE_BASE_URL || '/'
    export default defineConfig({ base, /* existing config */ })
    
    const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
    window.location.assign(new URL(`studios/${result.room.id}`, appBase))

Questions

  • None.

Summary

Review mode: follow-up after new commits

  • The new commit only adjusts Studio scrolling CSS. Four prior findings remain unresolved, and the latest full-diff review found one additional moderation-boundary issue in suggestion attribution.

Testing

  • Not run (automation; PR content was not executed).
  • GitHub integration passed; test was pending at review time.
  • Missing coverage: sidechain transcript redaction, oversized public post rejection before JSON parsing, control-character guest names, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.

HAPI Bot

if (record.role === 'user' && message.invokedAt === null) return null
if (isObject(record.content)) {
const data = isObject(record.content.data) ? record.content.data : null
if (data?.isMeta === true || data?.isCompactSummary === true) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Exclude sidechain/subagent output from the public transcript

Claude marks subagent assistant turns with data.isSidechain; the shared extractor still returns their text. A Studio capability link can therefore expose intermediate subagent prose and execution details that the normal chat pipeline treats separately.

Suggested fix:

if (
    data?.isMeta === true
    || data?.isCompactSummary === true
    || data?.isSidechain === true
) return null

if (room.accessMode !== 'contribute') {
return c.json({ error: 'This studio is view only' }, 403)
}
const body = await c.req.json().catch(() => null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Bound the public post body before parsing JSON

This unauthenticated endpoint parses the body before validation or rate limiting, while the shared Bun server permits requests up to 68 MiB. Oversized invalid bodies can consume memory and CPU without consuming a rate-limit slot.

Suggested fix:

import { bodyLimit } from 'hono/body-limit'

const publicPostBodyLimit = bodyLimit({
    maxSize: 8 * 1024,
    onError: (c) => c.json({ error: 'Post body too large' }, 413)
})

app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
    // existing handler
})

if (!claimed) return c.json({ error: 'Suggestion already handled' }, 409)
try {
await engine.sendMessage(room.sessionId, {
text: `[Studio suggestion from ${post.authorName}]\n${text}`,

@github-actions github-actions Bot Aug 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Prevent guest names from adding hidden prompt lines

authorName accepts control characters. The owner UI collapses the name to a single line, but this interpolation preserves newlines, so a guest can append agent instructions that are absent from the editable suggestion textarea the owner approves.

Suggested fix:

const authorNameSchema = z.string()
    .trim()
    .min(1)
    .max(40)
    .refine((value) => [...value].every((character) => {
        const code = character.charCodeAt(0)
        return code >= 32 && code !== 127
    }))

const postSchema = z.object({
    // ...
    authorName: authorNameSchema
})

const params = limit === null ? [roomId, kind] : [roomId, kind, limit]
const rows = this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ? AND kind = ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Filter open discussions before applying the limit

The route filters dismissed discussions only after this query has selected the newest 200 rows. Dismissed rows consume that window, causing older still-open discussions to disappear from public and owner views.

Suggested fix:

listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
    return (this.db.prepare(`
        SELECT * FROM studio_posts
        WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}

Comment thread web/vite.studio.config.ts

export default defineConfig({
appType: 'spa',
base: '/',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Preserve the configured base in the Studio build

This hardcoded root makes documented subpath deployments such as /hapi/ load Studio assets from /studio-assets/.... Creation also navigates to root /studios/... in SessionHeader.tsx.

Suggested fix:

const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })

const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant