Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions packages/cli/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,20 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) {
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
let replied = false

// The server already ignores a duplicate reply, but the client kept sending
// them: the dialog only unmounts once `permission.replied` comes back, so a
// held or mashed key re-POSTed the same request for as long as it stayed up,
// and a reply storm eventually wedged `setRawMode` with EIO and killed the
// keyboard for the rest of the session (#2565). The latch is per request
// rather than per prompt because a confirmation answers and then drops back
// to the first stage, where a fresh prompt would otherwise answer again.
const reply = (body: { reply: "once" | "always" | "reject"; message?: string }) => {
if (replied) return
replied = true
void sdk.client.permission.reply({ ...body, requestID: props.request.id })
}

const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))

Expand Down Expand Up @@ -273,21 +287,14 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) {
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
reply({ reply: "always" })
}}
/>
</Match>
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
message: message || undefined,
})
reply({ reply: "reject", message: message || undefined })
}}
onCancel={() => {
setStore("stage", "permission")
Expand Down Expand Up @@ -568,16 +575,10 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) {
setStore("stage", "reject")
return
}
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
})
reply({ reply: "reject" })
return
}
void sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
reply({ reply: "once" })
}}
/>
)
Expand Down
89 changes: 87 additions & 2 deletions packages/cli/test/cli/tui/permission-bash-delete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async function waitFor(predicate: () => boolean | Promise<boolean>) {
}
}

async function mountPermission(permission: string) {
async function mountPermission(permission: string, opts?: { parentID?: string }) {
const replies: { method: string; path: string; body: unknown }[] = []
const [current, setRequest] = createSignal(request(permission))
const fetcher = (async (input: Request) => {
Expand All @@ -57,7 +57,11 @@ async function mountPermission(permission: string) {
if (url.pathname === "/project/current") return Response.json({ id: "permission-project" })
if (url.pathname === "/config/providers") return Response.json({ providers: [], default: {} })
if (url.pathname === "/provider") return Response.json({ all: [], default: {}, connected: [], authenticated: [] })
if (["/session", "/agent", "/command", "/experimental/workspace", "/experimental/workspace/status", "/lsp", "/formatter"].includes(url.pathname)) {
// a parentID is what routes Reject through the reject-reason stage
if (url.pathname === "/session") {
return Response.json(opts?.parentID ? [{ id: "ses_permission", parentID: opts.parentID }] : [])
}
if (["/agent", "/command", "/experimental/workspace", "/experimental/workspace/status", "/lsp", "/formatter"].includes(url.pathname)) {
return Response.json([])
}
return Response.json({})
Expand Down Expand Up @@ -211,6 +215,87 @@ for (const stage of ["selected", "confirmation"]) {
}
}

// #2565: the dialog is torn down only after the server's `permission.replied`
// event lands, so a held or repeatedly mashed key used to re-POST the same
// reply for as long as the dialog stayed up. One answer per mount.
test("PermissionPrompt answers a held RETURN once", async () => {
const { app, replies } = await mountPermission("bash_delete")
try {
await app.mockInput.pressKeys(["RETURN", "RETURN", "RETURN"])
await app.renderOnce()
await waitFor(() => replies.length > 0)
// give any stray extra reply a chance to land before asserting
await Bun.sleep(50)
expect(replies).toEqual([{
method: "POST",
path: "/permission/per_bash_delete/reply",
body: { reply: "once" },
}])
} finally {
app.renderer.destroy()
}
})

test("PermissionPrompt answers a held ESCAPE once", async () => {
const { app, replies } = await mountPermission("bash_delete")
try {
await app.mockInput.pressKeys(["ESCAPE", "ESCAPE"])
await app.renderOnce()
await waitFor(() => replies.length > 0)
await Bun.sleep(50)
expect(replies).toEqual([{
method: "POST",
path: "/permission/per_bash_delete/reply",
body: { reply: "reject" },
}])
} finally {
app.renderer.destroy()
}
})

test("always confirmation answers a held RETURN once", async () => {
const { app, replies } = await mountPermission("bash")
try {
await app.mockInput.pressKeys(["ARROW_RIGHT", "RETURN"])
await app.renderOnce()
expect(app.captureCharFrame()).toContain("until MiMoCode is restarted")
expect(replies).toEqual([])
await app.mockInput.pressKeys(["RETURN", "RETURN"])
await app.renderOnce()
await waitFor(() => replies.length > 0)
await Bun.sleep(50)
expect(replies).toEqual([{
method: "POST",
path: "/permission/per_bash/reply",
body: { reply: "always" },
}])
} finally {
app.renderer.destroy()
}
})

// the reject stage reaches its own prompt through RejectPrompt, which has its
// own confirm handler and needs the same one-shot guard
test("reject stage answers a held RETURN once", async () => {
const { app, replies } = await mountPermission("bash", { parentID: "ses_parent" })
try {
await app.mockInput.pressKeys(["ARROW_RIGHT", "ARROW_RIGHT", "RETURN"])
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Reject permission")
await app.mockInput.pressKeys(["RETURN", "RETURN"])
await app.renderOnce()
await waitFor(() => replies.length > 0)
await Bun.sleep(50)
expect(replies).toEqual([{
method: "POST",
path: "/permission/per_bash/reply",
body: { reply: "reject" },
}])
} finally {
app.renderer.destroy()
}
})

const WARNING = RGBA.fromHex("#e0af68")

const theme = {
Expand Down