Skip to content
Closed
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
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { Instance } from "../../project/instance"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
Expand Down Expand Up @@ -88,7 +89,10 @@ export const SessionListCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
// Scope session list to the current working directory to prevent
// cross-worktree session leakage when multiple worktrees share the
// same git root commit (and thus the same project_id).
const sessions = [...Session.list({ roots: true, limit: args.maxCount, directory: Instance.directory })]

if (sessions.length === 0) {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export function DialogSessionList() {

const [searchResults] = createResource(search, async (query) => {
if (!query) return undefined
// The server-side session list already scopes to the current directory
// by default, so search results will also be directory-scoped.
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Instance } from "../../project/instance"

const log = Log.create({ service: "server" })

Expand All @@ -42,6 +43,7 @@ export const SessionRoutes = lazy(() =>
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
workspaceID: z.string().optional().meta({ description: "Filter sessions by workspace ID" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
Expand All @@ -53,9 +55,15 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const query = c.req.valid("query")
// Default to the current instance directory when no directory filter is
// specified. This ensures sessions are scoped to the active working
// directory, preventing cross-worktree session leakage when multiple
// worktrees share the same git root commit (and thus the same project_id).
const directory = query.directory ?? Instance.directory
const sessions: Session.Info[] = []
for await (const session of Session.list({
directory: query.directory,
directory,
workspaceID: query.workspaceID,
roots: query.roots,
start: query.start,
search: query.search,
Expand Down
20 changes: 17 additions & 3 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export namespace Session {
title?: string
parentID?: string
directory: string
workspaceID?: string
permission?: PermissionNext.Ruleset
}) {
const result: Info = {
Expand All @@ -301,7 +302,7 @@ export namespace Session {
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
workspaceID: WorkspaceContext.workspaceID,
workspaceID: input.workspaceID ?? WorkspaceContext.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
Expand Down Expand Up @@ -541,9 +542,22 @@ export namespace Session {
const project = Instance.project
const conditions = [eq(SessionTable.project_id, project.id)]

if (WorkspaceContext.workspaceID) {
conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID))
// Use explicit workspaceID parameter if provided, otherwise fall back to context.
// When filtering by workspace, also include sessions with NULL workspace_id
// (pre-migration sessions) so they are not silently hidden.
const wsID = input?.workspaceID ?? WorkspaceContext.workspaceID
if (wsID) {
conditions.push(
or(
eq(SessionTable.workspace_id, wsID),
isNull(SessionTable.workspace_id),
)!,
)
}

// When multiple worktrees share the same project_id (e.g. git worktrees
// from the same repo), filter by directory to scope sessions to the
// current working directory.
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
Expand Down
69 changes: 69 additions & 0 deletions packages/opencode/test/server/session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from "path"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
import { Database, eq } from "../../src/storage/db"
import { SessionTable } from "../../src/session/session.sql"

const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
Expand Down Expand Up @@ -87,4 +89,71 @@ describe("Session.list", () => {
},
})
})

test("includes sessions with NULL workspace_id when filtering by workspaceID", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// Create a session (will have workspace_id = undefined since no workspace context)
const session = await Session.create({ title: "pre-migration-session" })

// Verify the session has no workspace_id
expect(session.workspaceID).toBeUndefined()

// When filtering by a workspaceID, sessions with NULL workspace_id
// should still be included (they are pre-migration sessions)
const sessions = [...Session.list({ workspaceID: "test-workspace-id" })]
const ids = sessions.map((s) => s.id)

expect(ids).toContain(session.id)
},
})
})

test("directory filter prevents cross-worktree session leakage", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// Create a session in the current directory
const localSession = await Session.create({ title: "local-session" })

// Create a session in a different directory (simulating a different worktree)
const otherDir = path.join(projectRoot, "..", "__other_worktree")
const otherSession = await Instance.provide({
directory: otherDir,
fn: async () => Session.create({ title: "other-worktree-session" }),
})

// Both sessions share the same project_id (same git root)
expect(localSession.projectID).toBe(otherSession.projectID)

// Without directory filter, both sessions appear
const allSessions = [...Session.list({})]
const allIds = allSessions.map((s) => s.id)
expect(allIds).toContain(localSession.id)
expect(allIds).toContain(otherSession.id)

// With directory filter, only the local session appears
const scopedSessions = [...Session.list({ directory: projectRoot })]
const scopedIds = scopedSessions.map((s) => s.id)
expect(scopedIds).toContain(localSession.id)
expect(scopedIds).not.toContain(otherSession.id)
},
})
})

test("createNext accepts explicit workspaceID", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.createNext({
directory: projectRoot,
workspaceID: "explicit-workspace-123",
title: "session-with-workspace",
})

expect(session.workspaceID).toBe("explicit-workspace-123")
},
})
})
})
Loading