Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/app/component-tests/extension-host.fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const router: Router = { routing: () => false, path: () => "/" }

const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }

const servers: Servers = { list: () => [] }
const servers: Servers = { list: () => [], get: () => undefined }

const workspaces: Workspaces = { on: () => () => undefined }

Expand Down Expand Up @@ -348,6 +348,7 @@ function standIn(overrides: Partial<Interface>): Interface {
keybind: () => [],
matches: () => false,
servers: () => ["local"],
server: () => undefined,
...overrides,
}
}
Expand Down
82 changes: 81 additions & 1 deletion packages/app/e2e/regression/home.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { expect, test, type Page } from "@playwright/test"
import { holdRoute, seed, sessionHref } from "../utils/app"
import { holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockRemoteServer } from "../utils/workspace"
import { expectAppVisible } from "../utils/waits"

test.use({ serviceWorkers: "block" })
Expand Down Expand Up @@ -85,8 +87,11 @@ test("Home shows loaded sessions before the location request resolves", async ({
test("Home and the directory picker load without newer browser APIs", async ({ page }) => {
await page.addInitScript(() => {
// Safari 16.6 has none of these APIs. Remove them before the web entry runs.
// SAFETY: `Partial` only makes the static method optional so `delete` type-checks; the target is the real global.
delete (Map as Partial<typeof Map>).groupBy
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).withResolvers
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).try
})
await openHome(page, { fileList: () => [] })
Expand All @@ -101,6 +106,81 @@ test("Home and the directory picker load without newer browser APIs", async ({ p
await expect(target).toBeVisible()
})

test("adding a project to a signed-out server re-pairs it, then continues at the paired address", async ({ page }) => {
// The saved address rejects the new token, so saving moves the server to the link's address.
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { name: "Remote", password: "old-password" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()

await remote.getByRole("button", { name: "Add project", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(`${paired}/auth/connect/one-time-code`)
await editor.getByRole("button", { name: "Save", exact: true }).click()

// The folder picker reads the paired address; the removed one would reject the request.
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})

test("a server whose saved password changes works with the new password", async ({ page }) => {
const accepted = { password: "old-password" }
await mockOpenCodeServer(page, {
server: REMOTE_SERVER,
directory: "/remote/project",
project: project({ id: "proj_remote", directory: "/remote/project" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: () => accepted.password,
})
// Saved with the password the server accepts until the test changes it.
await seed(page, {
storage: {
"opencode.global.dat:server": {
list: [{ type: "http", displayName: "Remote", http: { url: REMOTE_SERVER, password: "old-password" } }],
},
},
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })

// Use the server once, so its controller exists with the old password.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await picker.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(picker).toHaveCount(0)

await remote.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Edit", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByPlaceholder("password").fill("new-password")
// The server now rejects the old password, as after `opencode service set password`.
accepted.password = "new-password"
await editor.getByRole("button", { name: "Save", exact: true }).click()
await expect(editor).toHaveCount(0)

// The picker reads the folder through the server's controller, which must use the saved password now.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})

const recovery = "C:/OpenCode/Worktrees/project-menu-recovery"

const worktree =
Expand Down
86 changes: 83 additions & 3 deletions packages/app/e2e/regression/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"

const directory = "C:/Projects/settings-demo"
Expand Down Expand Up @@ -463,7 +464,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
worktrees: () => inventory,
onWorktreeRemove: (body) => {
inventory.splice(
inventory.findIndex((item) => item.directory === (body as { directory: string }).directory),
inventory.findIndex((item) => item.directory === body.directory),
1,
)
},
Expand Down Expand Up @@ -499,6 +500,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
// Home never listed this session, so only the new worktree's directory read can show it.
view.sessions.push(session({ id: "ses_discovered", directory: discovered, title: "Discovered session", projectID }))
inventory.push({ directory: discovered, strategy: "git" })
// SAFETY: a worktree.updated event carries only the project ID, which is all the app reads from it.
await view.push([
{ id: "evt_settings_worktree_updated", created: Date.now(), type: "worktree.updated", data: { projectID } },
] as OpenCodeEvent[])
Expand Down Expand Up @@ -582,7 +584,9 @@ test.describe("worktrees prefetch", () => {

test("server Worktrees hover only prefetches metadata", async ({ page }) => {
const { settings } = await open(page)
const calls = { projects: 0, worktrees: [] as string[], refreshes: [] as string[] }

const calls = { projects: 0, worktrees: new Array<string>(), refreshes: new Array<string>() }

page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/worktree/refresh")
calls.refreshes.push(request.postDataJSON().projectID)
Expand Down Expand Up @@ -754,7 +758,9 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await settings.getByRole("button", { name: "Add server" }).click()

const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
await expect(editor.getByLabel("Pairing link", { exact: true })).toBeFocused()
await expect(editor.getByPlaceholder("password")).toHaveCount(0)
await editor.getByRole("button", { name: "Use password", exact: true }).click()
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
const name = editor.getByPlaceholder("Localhost", { exact: true })
await name.click()
Expand All @@ -766,6 +772,80 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await expect(settings).toBeVisible()
})

test("the add server dialog pairs from a one-time link and explains a spent one", async ({ page }) => {
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
const { settings } = await open(page)
const link = `${paired}/auth/connect/one-time-code`

const addServer = async () => {
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(link)
await editor.getByRole("button", { name: "Add server", exact: true }).click()

return editor
}

const added = await addServer()
await expect(added).toBeHidden()
await expect(settings.getByText(paired, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to settings" }).click()

const spent = await addServer()
await expect(spent.getByRole("alert")).toHaveText(
"This pairing link expired or was already used. Run opencode pair to get a new one.",
)
})

test("a pairing code with several addresses keeps the first one that works with its token", async ({ page }) => {
// Both addresses reach one server. The second spends the code first, so the first answers 401, and the token works on
// it as on every address of that server.
const first = "http://127.0.0.1:4098"
const second = "http://127.0.0.1:4099"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })

for (const [server, pairing] of [
[first, { code: "spent-by-the-other-address", token: "session-token" }],
[second, { code: "one-time-code", token: "session-token" }],
] as const) {
await mockOpenCodeServer(page, {
server,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing,
})
}

const { settings } = await open(page)
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor
.getByLabel("Pairing link", { exact: true })
.fill(JSON.stringify({ code: "one-time-code", urls: [first, second] }))
await editor.getByRole("button", { name: "Add server", exact: true }).click()

await expect(editor).toBeHidden()
await expect(settings.getByText(first, { exact: true })).toBeVisible()
await expect(settings.getByText(second, { exact: true })).toHaveCount(0)
})

test("the tab layout preference switches to vertical tabs and survives reload", async ({ page }) => {
const { settings } = await open(page)
const layout = settings.locator('[data-action="settings-tab-layout"]')
Expand Down
25 changes: 25 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export interface MockServerConfig {
strictDirectory?: boolean
// Answers 401 UnauthorizedError unless a request carries this password; a function may change it mid-test.
password?: Resolvable<string>
// Serves GET /auth/connect/:code: `code` redeems once for `token` (send it as the password); others answer 401.
pairing?: { code: string; token: string }
}

export type MockPtyInfo = {
Expand Down Expand Up @@ -401,6 +403,29 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
})

if (config.pairing) {
const pairing = { ...config.pairing, redeemed: false }

await page.route(`${server}/auth/connect/*`, (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: corsHeaders })
const code = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1) ?? "")

if (code !== pairing.code || pairing.redeemed) {
return route.fulfill({
status: 401,
headers: corsHeaders,
json: Schema.encodeSync(MockUnauthorized)(
new MockUnauthorized({ message: "Pairing link expired or already used" }),
),
})
}

pairing.redeemed = true

return route.fulfill({ headers: corsHeaders, json: { token: pairing.token } })
})
}

if (config.pty) {
const host = new URL(server).host
await page.routeWebSocket(
Expand Down
33 changes: 23 additions & 10 deletions packages/app/src/home/projects/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export function createHomeProjectsController(home: HomeController) {
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = ServerConnection.authenticate
const revealProject = useRevealProject()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })

Expand All @@ -41,6 +40,23 @@ export function createHomeProjectsController(home: HomeController) {
{ initialValue: _state },
)

function edit(conn: ServerConnection.Http, onSave?: (saved: ServerConnection.Http) => void) {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} onSave={(saved) => onSave?.(saved)} />)
})
}

// An expired pairing session or a changed password signs the app out of an HTTP server; signing in again edits it,
// and the action that asked for sign-in continues once the dialog saves, as it does for extension servers. It continues
// with the saved connection: the one it started with still carries the rejected credentials.
function authenticate(conn: ServerConnection.Any, onConnected?: (conn: ServerConnection.Any) => void) {
if (conn.type !== "http" || !home.server.health(conn)?.unauthorized)
return ServerConnection.authenticate(conn, () => onConnected?.(conn))
edit(conn, onConnected)

return true
}

function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
Expand Down Expand Up @@ -78,14 +94,10 @@ export function createHomeProjectsController(home: HomeController) {
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
edit: (conn: ServerConnection.Http) => edit(conn),
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
focus: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => home.selection.focusServer(conn))) return
if (authenticate(conn, (next) => home.selection.focusServer(next))) return
home.selection.focusServer(conn)
},
},
Expand All @@ -94,12 +106,12 @@ export function createHomeProjectsController(home: HomeController) {
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.select(conn, directory))) return
if (authenticate(conn, (next) => home.project.select(next, directory))) return
home.project.select(conn, directory)
},
add: home.project.add,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
if (authenticate(conn, (next) => home.project.openProjectNewSession(next, directory))) return
home.project.openProjectNewSession(conn, directory)
},
canImportSession: !!platform.openAttachmentPickerDialog,
Expand All @@ -119,6 +131,7 @@ export function createHomeProjectsController(home: HomeController) {

const api = home.server.context(conn).sdk.api.session

// SAFETY: the payload is SessionTransfer.Data's own encoding, which the import endpoint decodes and validates.
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
Expand Down Expand Up @@ -152,7 +165,7 @@ export function createHomeProjectsController(home: HomeController) {
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => choose(conn))) return
if (authenticate(conn, (next) => choose(next))) return

if (home.server.health(conn)?.healthy === false) return
choose(conn)
Expand Down
Loading
Loading