Skip to content

Commit 3480177

Browse files
committed
refactor: optimize capnweb integration to eliminate wasteful operations and improve security
Client (share-next.ts): - Reuse a single capnweb RPC session instance instead of creating a new one on every sync tick and every create() call; the session is lazily initialized and then cached for the lifetime of the process. - Cache the base URL after the first config read so Config.get() is not awaited on every operation. - Fix queue deduplication: the previous key logic evaluated `"id" in item` on the SyncData wrapper (which never has a top-level id), so every event generated a fresh ulid key and updates for the same message/part accumulated rather than collapsing. Keys are now type-scoped ("session", "message:<id>", "part:<id>", etc.) so repeated updates within the debounce window correctly overwrite each other. - Pipeline create + initial full-sync into a single RPC call: gatherFullSnapshot is started concurrently with session creation; both results are passed together to createShare so no extra round-trip is needed for the initial sync. - Parallelize gatherFullSnapshot: Session.get, Session.diff, and MessageV2.stream are now fetched with Promise.all instead of sequentially. - Combine message + model into one sync call in the MessageV2.Updated handler to avoid a second setTimeout enqueue for the same session. - Use RPC for remove() via the new deleteShare method (consistent with the rest of the RPC transport path). - Extract syncHttp helper to deduplicate the HTTP fallback path. RPC contract (both packages): - createShare now accepts optional initialData so the initial full snapshot can be sent in the same request as the share creation (one round trip instead of two). - Add deleteShare(shareID, secret) so the RPC transport can handle deletions without falling back to the HTTP API. Server (rpc.ts): - Move StorageAdapter instantiation to the constructor so adapters are created once per request instead of on every method call. - Extract applyData() helper so createShare and syncShare share the same data-merge logic rather than duplicating it. - createShare applies initialData when provided and stores the correct index counts immediately, eliminating the need for a follow-up syncShare call. - Implement deleteShare with the same secret-validation pattern as syncShare. Storage (storage.ts): - exists() now uses bucket.head() instead of bucket.get() so only object metadata is fetched; the previous implementation downloaded the full body just to check existence. Security (index.tsx): - /api/sessions was completely unauthenticated, exposing an enumeration of all shared sessions. It now requires the same x-opencode-share-key header as the RPC endpoint (SESSIONS_RPC_SHARED_KEY). If the key is not configured the endpoint returns 401 rather than defaulting to open access. https://claude.ai/code/session_01S5woDoxCnN62WNGjRhqWJ3
1 parent 21f5078 commit 3480177

6 files changed

Lines changed: 246 additions & 178 deletions

File tree

packages/cloudsession/src/index.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ function isAuthorizedRpcRequest(c: { req: { header: (name: string) => string | u
3434
return received === configured
3535
}
3636

37+
function isAuthorizedAdminRequest(c: { req: { header: (name: string) => string | undefined }; env: Env }) {
38+
// The admin key reuses the same SESSIONS_RPC_SHARED_KEY env var.
39+
// If it is not configured the endpoint is inaccessible to prevent
40+
// unauthenticated enumeration of all sessions.
41+
const configured = c.env.SESSIONS_RPC_SHARED_KEY
42+
if (!configured) return false
43+
const received = c.req.header("x-opencode-share-key")
44+
return received === configured
45+
}
46+
3747
/**
3848
* Main Hono application
3949
*/
@@ -318,10 +328,13 @@ app.get("/api/share/:id/metadata", async (c) => {
318328
})
319329

320330
/**
321-
* List all sessions (admin endpoint - could be protected)
331+
* List all sessions (admin endpoint — requires SESSIONS_RPC_SHARED_KEY)
322332
* GET /api/sessions
323333
*/
324334
app.get("/api/sessions", async (c) => {
335+
if (!isAuthorizedAdminRequest(c)) {
336+
return c.json({ error: "Unauthorized" }, 401)
337+
}
325338
const { index } = getStorageAdapter(c)
326339
const list = await index.list({ prefix: "index/" })
327340

packages/cloudsession/src/rpc-contract.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export type ProbeValueOutput = {
3939
export type ProbeCallback = (msg: string) => string | Promise<string>
4040

4141
export interface ShareRpc extends RpcTarget {
42-
createShare: (sessionID: string) => Promise<SyncInfo>
42+
createShare: (sessionID: string, initialData?: SyncData[]) => Promise<SyncInfo>
4343
syncShare: (shareID: string, secret: string, data: SyncData[]) => Promise<{ success: boolean; syncCount: number }>
44+
deleteShare: (shareID: string, secret: string) => Promise<{ success: boolean }>
4445
probeValue: (input: ProbeValueInput) => ProbeValueOutput
4546
probeCallback: (cb: ProbeCallback) => Promise<string>
4647
}

packages/cloudsession/src/rpc.ts

Lines changed: 87 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ type Env = {
1313
}
1414

1515
export class ShareRpcImpl extends RpcTarget {
16+
private readonly sessions: StorageAdapter<AgentSession>
17+
private readonly index: StorageAdapter<SessionIndex>
18+
1619
constructor(private env: Env) {
1720
super()
21+
this.sessions = createStorageAdapter<AgentSession>(env.SESSIONS_STORE)
22+
this.index = createStorageAdapter<SessionIndex>(env.SESSIONS_STORE)
1823
}
1924

20-
async createShare(sessionID: string): Promise<SyncInfo> {
21-
const { sessions, index } = this.storage()
25+
async createShare(sessionID: string, initialData?: SyncData[]): Promise<SyncInfo> {
2226
const shareID = sessionID.slice(-8)
2327
const secret = uuidv5(sessionID, this.env.SESSIONS_SHARED_SECRET)
2428
const now = Date.now()
@@ -54,27 +58,35 @@ export class ShareRpcImpl extends RpcTarget {
5458
},
5559
}
5660

61+
// Apply any initial data provided (pipeline create+sync into one round trip)
62+
if (initialData && initialData.length > 0) {
63+
applyData(initial, initialData)
64+
initial.metadata.syncCount = 1
65+
}
66+
5767
const initialIndex: SessionIndex = {
5868
id: shareID,
5969
sessionID,
60-
title: "",
61-
directory: "",
62-
messageCount: 0,
63-
partCount: 0,
64-
diffCount: 0,
65-
modelCount: 0,
70+
title: initial.session.title,
71+
directory: initial.session.directory,
72+
messageCount: initial.messages.length,
73+
partCount: initial.parts.length,
74+
diffCount: initial.diffs.length,
75+
modelCount: initial.models.length,
6676
lastUpdated: now,
67-
syncCount: 0,
77+
syncCount: initial.metadata.syncCount,
6878
createdAt: now,
6979
}
7080

71-
await Promise.all([sessions.put(`share/${shareID}`, initial), index.put(`index/${shareID}`, initialIndex)])
81+
await Promise.all([
82+
this.sessions.put(`share/${shareID}`, initial),
83+
this.index.put(`index/${shareID}`, initialIndex),
84+
])
7285
return info
7386
}
7487

7588
async syncShare(shareID: string, secret: string, data: SyncData[]) {
76-
const { sessions, index } = this.storage()
77-
const agentSession = await sessions.get(`share/${shareID}`)
89+
const agentSession = await this.sessions.get(`share/${shareID}`)
7890
if (!agentSession) {
7991
throw new Error("Share not found")
8092
}
@@ -93,48 +105,7 @@ export class ShareRpcImpl extends RpcTarget {
93105
},
94106
}
95107

96-
for (const item of data) {
97-
if (item.type === "session") {
98-
next.session = item.data
99-
continue
100-
}
101-
102-
if (item.type === "message") {
103-
const idx = next.messages.findIndex((message) => message.id === item.data.id)
104-
if (idx === -1) {
105-
next.messages.push(item.data)
106-
continue
107-
}
108-
next.messages[idx] = item.data
109-
continue
110-
}
111-
112-
if (item.type === "part") {
113-
const idx = next.parts.findIndex((part) => part.id === item.data.id)
114-
if (idx === -1) {
115-
next.parts.push(item.data)
116-
continue
117-
}
118-
next.parts[idx] = item.data
119-
continue
120-
}
121-
122-
if (item.type === "session_diff") {
123-
next.diffs = [...next.diffs, ...item.data]
124-
continue
125-
}
126-
127-
if (item.type === "model") {
128-
for (const model of item.data) {
129-
const idx = next.models.findIndex((entry) => entry.id === model.id)
130-
if (idx === -1) {
131-
next.models.push(model)
132-
continue
133-
}
134-
next.models[idx] = model
135-
}
136-
}
137-
}
108+
applyData(next, data)
138109

139110
const updatedIndex: SessionIndex = {
140111
id: shareID,
@@ -150,7 +121,7 @@ export class ShareRpcImpl extends RpcTarget {
150121
createdAt: next.metadata.createdAt,
151122
}
152123

153-
await Promise.all([sessions.put(`share/${shareID}`, next), index.put(`index/${shareID}`, updatedIndex)])
124+
await Promise.all([this.sessions.put(`share/${shareID}`, next), this.index.put(`index/${shareID}`, updatedIndex)])
154125

155126
const doID = this.env.SESSIONS_BROADCAST.idFromName(shareID)
156127
const stub = this.env.SESSIONS_BROADCAST.get(doID)
@@ -159,6 +130,21 @@ export class ShareRpcImpl extends RpcTarget {
159130
return { success: true, syncCount: next.metadata.syncCount }
160131
}
161132

133+
async deleteShare(shareID: string, secret: string): Promise<{ success: boolean }> {
134+
const agentSession = await this.sessions.get(`share/${shareID}`)
135+
if (!agentSession) {
136+
throw new Error("Share not found")
137+
}
138+
139+
if (agentSession.metadata.secret !== secret) {
140+
throw new Error("Invalid secret")
141+
}
142+
143+
await Promise.all([this.sessions.delete(`share/${shareID}`), this.index.delete(`index/${shareID}`)])
144+
145+
return { success: true }
146+
}
147+
162148
probeValue(input: ProbeValueInput): ProbeValueOutput {
163149
return {
164150
when: input.when.toISOString(),
@@ -171,11 +157,53 @@ export class ShareRpcImpl extends RpcTarget {
171157
async probeCallback(cb: ProbeCallback): Promise<string> {
172158
return await cb("server-called")
173159
}
160+
}
174161

175-
private storage(): { sessions: StorageAdapter<AgentSession>; index: StorageAdapter<SessionIndex> } {
176-
return {
177-
sessions: createStorageAdapter<AgentSession>(this.env.SESSIONS_STORE),
178-
index: createStorageAdapter<SessionIndex>(this.env.SESSIONS_STORE),
162+
/**
163+
* Apply a batch of sync data items to an AgentSession in place.
164+
* Extracted so createShare and syncShare share the same merge logic.
165+
*/
166+
function applyData(session: AgentSession, data: SyncData[]): void {
167+
for (const item of data) {
168+
if (item.type === "session") {
169+
session.session = item.data
170+
continue
171+
}
172+
173+
if (item.type === "message") {
174+
const idx = session.messages.findIndex((m) => m.id === item.data.id)
175+
if (idx === -1) {
176+
session.messages.push(item.data)
177+
} else {
178+
session.messages[idx] = item.data
179+
}
180+
continue
181+
}
182+
183+
if (item.type === "part") {
184+
const idx = session.parts.findIndex((p) => p.id === item.data.id)
185+
if (idx === -1) {
186+
session.parts.push(item.data)
187+
} else {
188+
session.parts[idx] = item.data
189+
}
190+
continue
191+
}
192+
193+
if (item.type === "session_diff") {
194+
session.diffs = [...session.diffs, ...item.data]
195+
continue
196+
}
197+
198+
if (item.type === "model") {
199+
for (const model of item.data) {
200+
const idx = session.models.findIndex((m) => m.id === model.id)
201+
if (idx === -1) {
202+
session.models.push(model)
203+
} else {
204+
session.models[idx] = model
205+
}
206+
}
179207
}
180208
}
181209
}

packages/cloudsession/src/storage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ export class R2StorageAdapter<T> implements StorageAdapter<T> {
7575
}
7676

7777
async exists(key: string): Promise<boolean> {
78-
const obj = await this.bucket.get(key)
78+
// head() fetches only object metadata — no body download
79+
const obj = await this.bucket.head(key)
7980
return !!obj
8081
}
8182

packages/opencode/src/share/rpc-contract.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export type ProbeValueOutput = {
3939
export type ProbeCallback = (msg: string) => string | Promise<string>
4040

4141
export interface ShareRpc extends RpcTarget {
42-
createShare: (sessionID: string) => Promise<SyncInfo>
42+
createShare: (sessionID: string, initialData?: SyncData[]) => Promise<SyncInfo>
4343
syncShare: (shareID: string, secret: string, data: SyncData[]) => Promise<{ success: boolean; syncCount: number }>
44+
deleteShare: (shareID: string, secret: string) => Promise<{ success: boolean }>
4445
probeValue: (input: ProbeValueInput) => ProbeValueOutput
4546
probeCallback: (cb: ProbeCallback) => Promise<string>
4647
}

0 commit comments

Comments
 (0)