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
7 changes: 6 additions & 1 deletion backend/cli/src/pty/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ export namespace Pty {
})
const id = Identifier.create("pty", false)
const command = Shell.preferred()
const args = command.endsWith("sh") ? ["-l"] : []
// A sandboxed zsh cannot safely lock history or run dotfiles that expect
// unrestricted home-directory access. Start it without user rc files so a
// fresh browser terminal opens cleanly instead of printing permission
// errors before the prompt. Other POSIX shells keep their login behavior.
const args = command.endsWith("zsh") ? ["-f"] : command.endsWith("sh") ? ["-l"] : []
const cwd = authority.workspace
const source = await OpenScience.subprocessEnv(process.env)
const env = Object.fromEntries(
Expand All @@ -113,6 +117,7 @@ export namespace Pty {
const runtime = {
...env,
TERM: "xterm-256color",
...(command.endsWith("zsh") ? { PROMPT: "%1~ %# ", PS1: "%1~ %# " } : {}),
OPENSCIENCE_TERMINAL: "1",
OPENSCIENCE_PROJECT_ID: Instance.project.id,
OPENSCIENCE_SESSION_ID: input.sessionID,
Expand Down
5 changes: 3 additions & 2 deletions backend/cli/src/science/kernel/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,11 +453,12 @@ export namespace KernelRuntime {
value.lastActivityAt = startedAt
return kernel.execute(code, options).then(
async (result) => {
value.executionCount = result.executionCount ?? value.executionCount + 1
const executionCount = result.executionCount ?? value.executionCount + 1
value.executionCount = executionCount
const completedAt = Date.now()
value.lastActivityAt = completedAt
await persist(value)
const complete = { ...result, executionCount: value.executionCount }
const complete = { ...result, executionCount }
const node = await provenance(
identity,
value,
Expand Down
147 changes: 126 additions & 21 deletions backend/cli/src/server/routes/folder-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
*
* Routes (all under `/api/resolve-folder`):
* GET /probe — can we list ~/Desktop? (mac FDA check)
* GET /dialog — open OS-native folder dialog (mac only)
* POST /dialog — open the host OS-native file/folder dialog
* POST /validate — { path } → resolved absolute path
* POST / — { name, hint?, children? } → best candidate
*/

import { Hono } from "hono"
import { Hono, type Context } from "hono"
import { spawn } from "child_process"
import fs from "fs/promises"
import os from "os"
Expand Down Expand Up @@ -159,6 +159,121 @@ function run(command: string, args: string[]): Promise<string> {
})
}

type NativePickerInput = {
kind: "folder" | "file"
title: string
multiple: boolean
}

export type NativePickerPlan = {
command: string
args: string[]
format: "lines" | "json"
}

const powershellString = (value: string) => `'${value.replaceAll("'", "''")}'`

export function nativePickerPlan(
input: NativePickerInput,
platform: NodeJS.Platform = process.platform,
): NativePickerPlan | undefined {
if (platform === "darwin") {
const script = [
"on run argv",
"set dialogTitle to item 1 of argv",
"set selectionKind to item 2 of argv",
'set allowMany to item 3 of argv is "true"',
'if selectionKind is "file" then',
"if allowMany then",
"set pickedItems to choose file with prompt dialogTitle with multiple selections allowed",
"else",
"set pickedItems to {choose file with prompt dialogTitle}",
"end if",
"else",
"if allowMany then",
"set pickedItems to choose folder with prompt dialogTitle with multiple selections allowed",
"else",
"set pickedItems to {choose folder with prompt dialogTitle}",
"end if",
"end if",
"set selectedPaths to {}",
"repeat with pickedItem in pickedItems",
"set end of selectedPaths to POSIX path of pickedItem",
"end repeat",
"set AppleScript's text item delimiters to linefeed",
"return selectedPaths as text",
"end run",
]
return {
command: "osascript",
args: [...script.flatMap((line) => ["-e", line]), "--", input.title, input.kind, String(input.multiple)],
format: "lines",
}
}

if (platform !== "win32") return

const setup = [
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
"Add-Type -AssemblyName System.Windows.Forms",
"[System.Windows.Forms.Application]::EnableVisualStyles()",
]
const dialog =
input.kind === "file"
? [
"$picker = New-Object System.Windows.Forms.OpenFileDialog",
`$picker.Title = ${powershellString(input.title)}`,
`$picker.Multiselect = ${input.multiple ? "$true" : "$false"}`,
"$picker.CheckFileExists = $true",
"$picker.CheckPathExists = $true",
"$result = $picker.ShowDialog()",
"if ($result -ne [System.Windows.Forms.DialogResult]::OK) { [Console]::Write('[]'); exit 0 }",
"$paths = @($picker.FileNames)",
]
: [
"$picker = New-Object System.Windows.Forms.FolderBrowserDialog",
`$picker.Description = ${powershellString(input.title)}`,
"$picker.ShowNewFolderButton = $true",
"$result = $picker.ShowDialog()",
"if ($result -ne [System.Windows.Forms.DialogResult]::OK) { [Console]::Write('[]'); exit 0 }",
"$paths = @($picker.SelectedPath)",
]
const script = [...setup, ...dialog, "[Console]::Write((ConvertTo-Json -Compress -InputObject $paths))"].join("; ")
return {
command: "powershell.exe",
args: ["-NoProfile", "-STA", "-Command", script],
format: "json",
}
}

export async function openNativePicker(input: NativePickerInput, platform: NodeJS.Platform = process.platform) {
const plan = nativePickerPlan(input, platform)
if (!plan) return
const output = await run(plan.command, plan.args)
const values = (() => {
if (plan.format === "lines") return output.split(/\r?\n/)
const parsed = JSON.parse(output || "[]") as unknown
return Array.isArray(parsed) ? parsed : []
})()
return values
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map(path.normalize)
}

async function pickerResponse(c: Context, input: NativePickerInput) {
const plan = nativePickerPlan(input)
if (!plan) return c.json({ unsupported: true, message: `native dialog unsupported on ${process.platform}` }, 501)
return openNativePicker(input).then(
(paths) => c.json({ paths: paths ?? [] }),
(error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
const cancelled = /User canceled|cancelled/i.test(message)
if (cancelled) return c.json({ error: "cancelled" }, 400)
return c.json({ error: message }, 500)
},
)
}

export const FolderResolveRoutes = lazy(() =>
new Hono()
.get("/probe", async (c) => {
Expand All @@ -168,25 +283,15 @@ export const FolderResolveRoutes = lazy(() =>
reason: result.reason,
})
})
.get("/dialog", async (c) => {
// Only macOS gets a reliable scriptable native dialog. Linux/Windows
// fall through to the in-app FolderPicker the SPA renders next.
if (process.platform !== "darwin") {
return c.json({ unsupported: true, message: `native dialog unsupported on ${process.platform}` }, 501)
}
try {
const script = ['set picked to choose folder with prompt "Open project folder"', "POSIX path of picked"]
const out = await run(
"osascript",
script.flatMap((s) => ["-e", s]),
)
const folder = out.trim().replace(/\/+$/, "")
return c.json({ paths: folder ? [folder] : [] })
} catch (e: any) {
const message = String(e?.message ?? e)
const cancelled = /User canceled|cancelled/i.test(message)
return c.json({ error: cancelled ? "cancelled" : message }, (cancelled ? 499 : 500) as any)
}
.get("/dialog", (c) => pickerResponse(c, { kind: "folder", title: "Open project folder", multiple: false }))
.post("/dialog", async (c) => {
const body = await c.req.json().catch(() => undefined)
if (!body || typeof body !== "object" || Array.isArray(body)) return c.json({ error: "invalid json" }, 400)
const kind = "kind" in body && body.kind === "file" ? "file" : "folder"
const title =
"title" in body && typeof body.title === "string" ? body.title.trim().slice(0, 160) : "Choose a location"
const multiple = "multiple" in body && body.multiple === true
return pickerResponse(c, { kind, title: title || "Choose a location", multiple })
})
.post("/validate", async (c) => {
let body: { path?: string; project?: string; projectID?: string } = {}
Expand Down
1 change: 1 addition & 0 deletions backend/cli/test/project/execution-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ test("trusted terminal derives its process contract from the owning session", as
status: "running",
})
expect(terminal.command).toBeTruthy()
if (terminal.command.endsWith("zsh")) expect(terminal.args).toEqual(["-f"])
expect(terminal.pid).toBeGreaterThan(0)
await Session.remove(session.id)
} finally {
Expand Down
28 changes: 28 additions & 0 deletions backend/cli/test/server/native-picker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { nativePickerPlan } from "../../src/server/routes/folder-resolve"

describe("native picker commands", () => {
test("uses Finder through AppleScript on macOS", () => {
const plan = nativePickerPlan({ kind: "folder", title: "Add source folders", multiple: true }, "darwin")

expect(plan?.command).toBe("osascript")
expect(plan?.format).toBe("lines")
expect(plan?.args).toContain("Add source folders")
expect(plan?.args.join("\n")).toContain("choose folder with prompt dialogTitle with multiple selections allowed")
})

test("uses the Windows system dialogs and safely quotes titles", () => {
const plan = nativePickerPlan({ kind: "file", title: "Researcher's source", multiple: true }, "win32")
const script = plan?.args.at(-1) ?? ""

expect(plan?.command).toBe("powershell.exe")
expect(plan?.format).toBe("json")
expect(script).toContain("System.Windows.Forms.OpenFileDialog")
expect(script).toContain("Researcher''s source")
expect(script).toContain("$picker.Multiselect = $true")
})

test("lets unsupported hosts use the in-app fallback", () => {
expect(nativePickerPlan({ kind: "folder", title: "Choose", multiple: false }, "linux")).toBeUndefined()
})
})
22 changes: 13 additions & 9 deletions frontend/workspace/e2e/home-projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,22 @@ test("home project search filters the recent list and clears back to it", async
await expect(card).toBeVisible()
})

test("existing folder import remains available through the in-app picker", async ({ page, directory, slug }) => {
test("existing folder import uses the host-native picker", async ({ page, directory, slug }) => {
const calls: Array<{ kind: string; title: string; multiple: boolean }> = []
await page.route("**/api/resolve-folder/dialog", async (route) => {
const body = route.request().postDataJSON() as { kind: string; title: string; multiple: boolean }
calls.push(body)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ paths: [directory] }),
})
})

await page.goto("/")
await page.getByRole("button", { name: "Import existing folder", exact: true }).first().click()

// The picker renders in "lite" mode (plain divs, no role=dialog), so target
// its controls at the page level.
const location = page.getByPlaceholder(/paste any absolute path/)
await expect(location).toBeVisible()
await location.fill(directory)
await location.press("Enter")
await page.getByRole("button", { name: "use this folder", exact: true }).click()

await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
expect(calls).toEqual([{ kind: "folder", title: "open project", multiple: true }])
})
55 changes: 55 additions & 0 deletions frontend/workspace/e2e/native-picker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect } from "./fixtures"
import { openFilesSources } from "./utils"

test("project sources use the host-native folder picker", async ({ page, directory }) => {
const calls: Array<{ kind: string; title: string; multiple: boolean }> = []
await page.route("**/api/resolve-folder/dialog", async (route) => {
const body = route.request().postDataJSON() as { kind: string; title: string; multiple: boolean }
calls.push(body)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ paths: [directory] }),
})
})

await page.goto("/")
await page
.getByRole("button", { name: /new project/i })
.first()
.click()
const dialog = page.getByRole("dialog", { name: "Create project" })
await dialog.getByRole("button", { name: /add source folders/i }).click()

await expect(dialog.getByRole("button", { name: `Remove source folder ${directory}` })).toBeVisible()
expect(calls).toEqual([{ kind: "folder", title: "Add source folders", multiple: true }])
})

test("source files and folders use the host-native picker", async ({ page, openSession }) => {
const selected = {
folder: "/tmp/native-source-folder",
file: "/tmp/native-source-file.csv",
}
const calls: string[] = []
await page.route("**/api/resolve-folder/dialog", async (route) => {
const body = route.request().postDataJSON() as { kind: "folder" | "file" }
calls.push(body.kind)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ paths: [selected[body.kind]] }),
})
})

await openSession()
await openFilesSources(page)
await page.getByRole("button", { name: "Connect another location", exact: true }).click()

const form = page.getByRole("form", { name: "Connect file or folder access" })
const input = form.getByPlaceholder("Choose or paste a file or folder path")
await form.getByRole("button", { name: "Choose folder", exact: true }).click()
await expect(input).toHaveValue(selected.folder)
await form.getByRole("button", { name: "Choose file", exact: true }).click()
await expect(input).toHaveValue(selected.file)
expect(calls).toEqual(["folder", "file"])
})
4 changes: 4 additions & 0 deletions frontend/workspace/e2e/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ test("opening Compute from a new route creates a durable session and keeps the s
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:\\?|#|$)`))
await expect(page.getByRole("region", { name: "Compute", exact: true })).toBeVisible()
await expect(page.getByRole("tab", { name: "Kernels", exact: true })).toHaveAttribute("aria-selected", "true")
const panel = page.getByTestId("kernel-panel")
await expect(panel.getByText("No kernels yet", { exact: true })).toBeVisible()
await expect(panel).not.toContainText("Unavailable")
await expect(panel.locator("details.kernel-panel__scope")).toHaveCount(0)
} finally {
if (sessionID) await sdk.session.delete({ sessionID }).catch(() => undefined)
}
Expand Down
Loading