Skip to content

Commit b8337cd

Browse files
authored
fix(app): permissions and questions from child sessions (#15105)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
1 parent 444178e commit b8337cd

8 files changed

Lines changed: 393 additions & 526 deletions

File tree

packages/app/e2e/session/session-composer-dock.spec.ts

Lines changed: 246 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from "../fixtures"
2-
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
2+
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
33
import {
44
permissionDockSelector,
55
promptSelector,
@@ -11,11 +11,23 @@ import {
1111
} from "../selectors"
1212

1313
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
14-
15-
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
16-
const session = await sdk.session.create({ title }).then((r) => r.data)
14+
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
15+
16+
async function withDockSession<T>(
17+
sdk: Sdk,
18+
title: string,
19+
fn: (session: { id: string; title: string }) => Promise<T>,
20+
opts?: { permission?: PermissionRule[] },
21+
) {
22+
const session = await sdk.session
23+
.create(opts?.permission ? { title, permission: opts.permission } : { title })
24+
.then((r) => r.data)
1725
if (!session?.id) throw new Error("Session create did not return an id")
18-
return fn(session)
26+
try {
27+
return await fn(session)
28+
} finally {
29+
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
30+
}
1931
}
2032

2133
test.setTimeout(120_000)
@@ -28,6 +40,85 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
2840
}
2941
}
3042

43+
async function clearPermissionDock(page: any, label: RegExp) {
44+
const dock = page.locator(permissionDockSelector)
45+
for (let i = 0; i < 3; i++) {
46+
const count = await dock.count()
47+
if (count === 0) return
48+
await dock.getByRole("button", { name: label }).click()
49+
await page.waitForTimeout(150)
50+
}
51+
}
52+
53+
async function withMockPermission<T>(
54+
page: any,
55+
request: {
56+
id: string
57+
sessionID: string
58+
permission: string
59+
patterns: string[]
60+
metadata?: Record<string, unknown>
61+
always?: string[]
62+
},
63+
opts: { child?: any } | undefined,
64+
fn: () => Promise<T>,
65+
) {
66+
let pending = [
67+
{
68+
...request,
69+
always: request.always ?? ["*"],
70+
metadata: request.metadata ?? {},
71+
},
72+
]
73+
74+
const list = async (route: any) => {
75+
await route.fulfill({
76+
status: 200,
77+
contentType: "application/json",
78+
body: JSON.stringify(pending),
79+
})
80+
}
81+
82+
const reply = async (route: any) => {
83+
const url = new URL(route.request().url())
84+
const id = url.pathname.split("/").pop()
85+
pending = pending.filter((item) => item.id !== id)
86+
await route.fulfill({
87+
status: 200,
88+
contentType: "application/json",
89+
body: JSON.stringify(true),
90+
})
91+
}
92+
93+
await page.route("**/permission", list)
94+
await page.route("**/session/*/permissions/*", reply)
95+
96+
const sessionList = opts?.child
97+
? async (route: any) => {
98+
const res = await route.fetch()
99+
const json = await res.json()
100+
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
101+
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
102+
await route.fulfill({
103+
status: res.status(),
104+
headers: res.headers(),
105+
contentType: "application/json",
106+
body: JSON.stringify(json),
107+
})
108+
}
109+
: undefined
110+
111+
if (sessionList) await page.route("**/session?*", sessionList)
112+
113+
try {
114+
return await fn()
115+
} finally {
116+
await page.unroute("**/permission", list)
117+
await page.unroute("**/session/*/permissions/*", reply)
118+
if (sessionList) await page.unroute("**/session?*", sessionList)
119+
}
120+
}
121+
31122
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
32123
await withDockSession(sdk, "e2e composer dock default", async (session) => {
33124
await gotoSession(session.id)
@@ -76,72 +167,175 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
76167

77168
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
78169
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
79-
await withDockSeed(sdk, session.id, async () => {
80-
await gotoSession(session.id)
81-
82-
await seedSessionPermission(sdk, {
170+
await gotoSession(session.id)
171+
await withMockPermission(
172+
page,
173+
{
174+
id: "per_e2e_once",
83175
sessionID: session.id,
84176
permission: "bash",
85-
patterns: ["README.md"],
86-
description: "Need permission for command",
87-
})
88-
89-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
90-
await expect(page.locator(promptSelector)).toHaveCount(0)
91-
92-
await page
93-
.locator(permissionDockSelector)
94-
.getByRole("button", { name: /allow once/i })
95-
.click()
96-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
97-
await expect(page.locator(promptSelector)).toBeVisible()
98-
})
177+
patterns: ["/tmp/opencode-e2e-perm-once"],
178+
metadata: { description: "Need permission for command" },
179+
},
180+
undefined,
181+
async () => {
182+
await page.goto(page.url())
183+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
184+
await expect(page.locator(promptSelector)).toHaveCount(0)
185+
186+
await clearPermissionDock(page, /allow once/i)
187+
await page.goto(page.url())
188+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
189+
await expect(page.locator(promptSelector)).toBeVisible()
190+
},
191+
)
99192
})
100193
})
101194

102195
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
103196
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
104-
await withDockSeed(sdk, session.id, async () => {
105-
await gotoSession(session.id)
106-
107-
await seedSessionPermission(sdk, {
197+
await gotoSession(session.id)
198+
await withMockPermission(
199+
page,
200+
{
201+
id: "per_e2e_reject",
108202
sessionID: session.id,
109203
permission: "bash",
110-
patterns: ["REJECT.md"],
111-
})
112-
113-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
114-
await expect(page.locator(promptSelector)).toHaveCount(0)
115-
116-
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
117-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
118-
await expect(page.locator(promptSelector)).toBeVisible()
119-
})
204+
patterns: ["/tmp/opencode-e2e-perm-reject"],
205+
},
206+
undefined,
207+
async () => {
208+
await page.goto(page.url())
209+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
210+
await expect(page.locator(promptSelector)).toHaveCount(0)
211+
212+
await clearPermissionDock(page, /deny/i)
213+
await page.goto(page.url())
214+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
215+
await expect(page.locator(promptSelector)).toBeVisible()
216+
},
217+
)
120218
})
121219
})
122220

123221
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
124222
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
125-
await withDockSeed(sdk, session.id, async () => {
126-
await gotoSession(session.id)
127-
128-
await seedSessionPermission(sdk, {
223+
await gotoSession(session.id)
224+
await withMockPermission(
225+
page,
226+
{
227+
id: "per_e2e_always",
129228
sessionID: session.id,
130229
permission: "bash",
131-
patterns: ["README.md"],
132-
description: "Need permission for command",
230+
patterns: ["/tmp/opencode-e2e-perm-always"],
231+
metadata: { description: "Need permission for command" },
232+
},
233+
undefined,
234+
async () => {
235+
await page.goto(page.url())
236+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
237+
await expect(page.locator(promptSelector)).toHaveCount(0)
238+
239+
await clearPermissionDock(page, /allow always/i)
240+
await page.goto(page.url())
241+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
242+
await expect(page.locator(promptSelector)).toBeVisible()
243+
},
244+
)
245+
})
246+
})
247+
248+
test("child session question request blocks parent dock and unblocks after submit", async ({
249+
page,
250+
sdk,
251+
gotoSession,
252+
}) => {
253+
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
254+
await gotoSession(session.id)
255+
256+
const child = await sdk.session
257+
.create({
258+
title: "e2e composer dock child question",
259+
parentID: session.id,
260+
})
261+
.then((r) => r.data)
262+
if (!child?.id) throw new Error("Child session create did not return an id")
263+
264+
try {
265+
await withDockSeed(sdk, child.id, async () => {
266+
await seedSessionQuestion(sdk, {
267+
sessionID: child.id,
268+
questions: [
269+
{
270+
header: "Child input",
271+
question: "Pick one child option",
272+
options: [
273+
{ label: "Continue", description: "Continue child" },
274+
{ label: "Stop", description: "Stop child" },
275+
],
276+
},
277+
],
278+
})
279+
280+
const dock = page.locator(questionDockSelector)
281+
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
282+
await expect(page.locator(promptSelector)).toHaveCount(0)
283+
284+
await dock.locator('[data-slot="question-option"]').first().click()
285+
await dock.getByRole("button", { name: /submit/i }).click()
286+
287+
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
288+
await expect(page.locator(promptSelector)).toBeVisible()
133289
})
290+
} finally {
291+
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
292+
}
293+
})
294+
})
134295

135-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
136-
await expect(page.locator(promptSelector)).toHaveCount(0)
296+
test("child session permission request blocks parent dock and supports allow once", async ({
297+
page,
298+
sdk,
299+
gotoSession,
300+
}) => {
301+
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
302+
await gotoSession(session.id)
137303

138-
await page
139-
.locator(permissionDockSelector)
140-
.getByRole("button", { name: /allow always/i })
141-
.click()
142-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
143-
await expect(page.locator(promptSelector)).toBeVisible()
144-
})
304+
const child = await sdk.session
305+
.create({
306+
title: "e2e composer dock child permission",
307+
parentID: session.id,
308+
})
309+
.then((r) => r.data)
310+
if (!child?.id) throw new Error("Child session create did not return an id")
311+
312+
try {
313+
await withMockPermission(
314+
page,
315+
{
316+
id: "per_e2e_child",
317+
sessionID: child.id,
318+
permission: "bash",
319+
patterns: ["/tmp/opencode-e2e-perm-child"],
320+
metadata: { description: "Need child permission" },
321+
},
322+
{ child },
323+
async () => {
324+
await page.goto(page.url())
325+
const dock = page.locator(permissionDockSelector)
326+
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
327+
await expect(page.locator(promptSelector)).toHaveCount(0)
328+
329+
await clearPermissionDock(page, /allow once/i)
330+
await page.goto(page.url())
331+
332+
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
333+
await expect(page.locator(promptSelector)).toBeVisible()
334+
},
335+
)
336+
} finally {
337+
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
338+
}
145339
})
146340
})
147341

packages/app/src/pages/directory-layout.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { useNavigate, useParams } from "@solidjs/router"
4-
import { SDKProvider, useSDK } from "@/context/sdk"
4+
import { SDKProvider } from "@/context/sdk"
55
import { SyncProvider, useSync } from "@/context/sync"
66
import { LocalProvider } from "@/context/local"
77

88
import { DataProvider } from "@opencode-ai/ui/context"
9-
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
109
import { decode64 } from "@/utils/base64"
1110
import { showToast } from "@opencode-ai/ui/toast"
1211
import { useLanguage } from "@/context/language"
@@ -15,19 +14,11 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
1514
const params = useParams()
1615
const navigate = useNavigate()
1716
const sync = useSync()
18-
const sdk = useSDK()
1917

2018
return (
2119
<DataProvider
2220
data={sync.data}
2321
directory={props.directory}
24-
onPermissionRespond={(input: {
25-
sessionID: string
26-
permissionID: string
27-
response: "once" | "always" | "reject"
28-
}) => sdk.client.permission.respond(input)}
29-
onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
30-
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
3122
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
3223
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
3324
>

0 commit comments

Comments
 (0)