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
18 changes: 14 additions & 4 deletions packages/app/src/runtime/server/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getFilename } from "@opencode/util/path"
import { retry } from "@opencode/util/retry"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types"
import { normalizeProjectInfo } from "./utils"
import { normalizeProjectInfo, type ProjectIconHttp } from "./utils"
import type { ServerConnection } from "@/runtime/server/registry"
import { formatServerError } from "@/runtime/server/errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { ServerScope } from "@/runtime/server/scope"
Expand Down Expand Up @@ -58,30 +59,37 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>

type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly icon?: (input: { projectID: string; v?: string }) => Promise<Uint8Array>
}

type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }

// Metadata only, in the server's order (most recently active first). Worktree inventories load per project when a
// view shows it (see workspaces/inventory).
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, http?: ProjectIconHttp) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects
.list()
.then((items) => items.flatMap((project) => (project?.id ? [normalizeProjectInfo(project)] : []))),
.then((items) => items.flatMap((project) => (project?.id ? [normalizeProjectInfo(project, http)] : []))),
),
})

export function loadProjects(input: {
scope: ServerScope
project: ProjectApi
http?: ServerConnection.HttpBase
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
return input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.project)).then((data) =>
const http = input.http && {
...input.http,
icon: input.project.icon,
onIcon: (id: string, url: string) => input.setGlobalStore("project", (item) => item.id === id, "icon", "url", url),
}
return input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.project, http)).then((data) =>
input.setGlobalStore(
"project",
data.map((project) =>
Expand All @@ -96,6 +104,7 @@ export async function bootstrapGlobal(input: {
readonly location: LocationApi
readonly project: ProjectApi
}
http?: ServerConnection.HttpBase
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
Expand All @@ -107,6 +116,7 @@ export async function bootstrapGlobal(input: {
loadProjects({
scope: input.scope,
project: input.serverAPI.project,
http: input.http,
setGlobalStore: input.setGlobalStore,
queryClient: input.queryClient,
}),
Expand Down
48 changes: 48 additions & 0 deletions packages/app/src/runtime/server/global-sync/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,54 @@ describe("normalizeProjectInfo", () => {
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
})

test("resolves unauthenticated server icon routes directly and fetches password-protected icons into blobs without leaking credentials", async () => {
const base: Project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] }
expect(
normalizeProjectInfo({ ...base, icon: { url: "https://example.com/a.svg" } }, { url: "http://127.0.0.1:4096" })
.icon?.url,
).toBe("https://example.com/a.svg")
expect(
normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v1" } }, { url: "http://127.0.0.1:4096" })
.icon?.url,
).toBe("http://127.0.0.1:4096/api/project/prj/icon?v=v1")

const loaded: string[] = []
const http = {
url: "http://remote:4096",
password: "secret",
icon: async () => new TextEncoder().encode("<svg />"),
onIcon: (_id: string, url: string) => loaded.push(url),
}
const initial = normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v1" } }, http)
expect(initial.icon?.url).toBeUndefined()
await Promise.resolve()
const cached = normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v1" } }, http).icon?.url
expect(cached).toStartWith("blob:")
expect(cached).not.toContain("secret")
expect(cached).not.toContain(btoa("opencode:secret"))
expect(loaded).toEqual([cached!])

normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v2" } }, http)
await Promise.resolve()
expect(loaded[1]).toStartWith("blob:")
expect(loaded[1]).not.toBe(cached)
normalizeProjectInfo({ ...base, icon: undefined }, http)

let attempts = 0
const failing = {
url: "http://remote:4096",
password: "secret",
icon: async () => {
attempts += 1
throw new Error("missing")
},
}
normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=bad" } }, failing)
await Promise.resolve()
normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=bad" } }, failing)
expect(attempts).toBe(1)
})
})

test.each([
Expand Down
48 changes: 46 additions & 2 deletions packages/app/src/runtime/server/global-sync/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types"
import type { Project as CurrentProject } from "@opencode/client/promise"
import type { ServerConnection } from "@/runtime/server/registry"
import { unwrap } from "solid-js/store"

export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key"
Expand Down Expand Up @@ -136,20 +137,63 @@ export function normalizeProviderList(
return result
}

export function normalizeProjectInfo(project: Project | CurrentProject): Project {
export type ProjectIconHttp = ServerConnection.HttpBase & {
readonly icon?: (input: { projectID: string; v?: string }) => Promise<Uint8Array>
readonly onIcon?: (projectID: string, url: string) => void
}

const iconBlobs = new Map<string, string>()

function resolveProjectIcon(projectID: string, icon: CurrentProject["icon"], http?: ProjectIconHttp) {
const raw = icon?.url
const key = raw?.startsWith("/") && http?.url && http.password ? `${http.url}${raw}` : undefined
const prefix = http?.url ? `${http.url}/api/project/${encodeURIComponent(projectID)}/icon` : undefined
if (prefix) {
for (const [k, blob] of iconBlobs) {
if (k !== key && k.startsWith(prefix)) {
if (blob) URL.revokeObjectURL(blob)
iconBlobs.delete(k)
}
}
}
if (!raw?.startsWith("/")) return icon
if (!http?.url) return { ...icon, url: undefined }
if (!http.password) return { ...icon, url: new URL(raw, http.url).toString() }
const cached = key ? iconBlobs.get(key) : undefined
if (key && !iconBlobs.has(key) && http.icon) {
iconBlobs.set(key, "")
const v = new URL(raw, http.url).searchParams.get("v") ?? undefined
void http
.icon({ projectID, v })
.then((bytes) => {
const blob = URL.createObjectURL(
new Blob([new Uint8Array(bytes)], bytes[0] === 0x3c ? { type: "image/svg+xml" } : undefined),
)
if (iconBlobs.get(key) !== "") return URL.revokeObjectURL(blob)
iconBlobs.set(key, blob)
http.onIcon?.(projectID, blob)
})
.catch(() => undefined)
}
return { ...icon, url: cached || undefined }
}

export function normalizeProjectInfo(project: Project | CurrentProject, http?: ProjectIconHttp): Project {
const worktree = "canonical" in project ? project.canonical : project.worktree

return {
...project,
icon: resolveProjectIcon(project.id, project.icon, http),
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
}
}

export function updateProjectInfo(project: Project, update: CurrentProject): Project {
export function updateProjectInfo(project: Project, update: CurrentProject, http?: ProjectIconHttp): Project {
return {
...project,
...update,
icon: resolveProjectIcon(update.id, update.icon, http),
worktree: update.canonical,
worktrees: project.worktrees,
}
Expand Down
15 changes: 12 additions & 3 deletions packages/app/src/runtime/server/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
http: serverSDK.server.http,
scope: serverSDK.scope,
setGlobalStore,
queryClient,
Expand Down Expand Up @@ -241,12 +242,20 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
setGlobalStore("project", (projects) => {
const rest = projects.filter((project) => project.id !== update.id)

if (update.time.archived) return rest
if (update.time.archived) {
normalizeProjectInfo({ ...update, icon: undefined }, serverSDK.server.http)
return rest
}

const previous = projects.find((project) => project.id === update.id)
const http = {
...serverSDK.server.http,
icon: serverSDK.api.project.icon,
onIcon: (id: string, url: string) => setGlobalStore("project", (item) => item.id === id, "icon", "url", url),
}
// The wire payload carries no worktrees; keep the inventory this project already loaded.
const next = withWorktreeInventory(
previous ? updateProjectInfo(previous, update) : normalizeProjectInfo(update),
previous ? updateProjectInfo(previous, update, http) : normalizeProjectInfo(update, http),
worktrees.cached(update.id),
)

Expand All @@ -258,7 +267,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {

// Observes the list the bootstrap loads without fetching it again, so readers can tell "no projects" from "not loaded".
const projectsQuery = useQuery(() => ({
...loadProjectsQuery(serverSDK.scope, serverSDK.api.project),
...loadProjectsQuery(serverSDK.scope, serverSDK.api.project, serverSDK.server.http),
enabled: false,
}))

Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/settings/workspaces/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export function workspaceInventoryQuery(

if (shouldRefresh) void context.sync.worktrees.refresh(project.id)

return normalizeProjectInfo({ ...project, worktrees })
return normalizeProjectInfo(
{ ...project, worktrees },
{ ...context.sdk.server.http, icon: context.sdk.api.project.icon },
)
}),
),
staleTime: 30_000,
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,10 @@ export type ProjectListInput = { readonly archived?: boolean | undefined }
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = (input?: ProjectListInput) => Effect.Effect<ProjectListOutput, E>

export type ProjectIconInput = { readonly projectID: Project.ID; readonly v?: string | undefined }
export type ProjectIconOutput = globalThis.Uint8Array
export type ProjectIconOperation<E = never> = (input: ProjectIconInput) => Effect.Effect<ProjectIconOutput, E>

export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly canonical?: AbsolutePath | undefined
Expand All @@ -1727,6 +1731,7 @@ export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => E

export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly icon: ProjectIconOperation<E>
readonly update: ProjectUpdateOperation<E>
}

Expand Down
10 changes: 10 additions & 0 deletions packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ import type {
CredentialRemoveOutput,
ProjectListInput,
ProjectListOutput,
ProjectIconInput,
ProjectIconOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormListInput,
Expand Down Expand Up @@ -1134,6 +1136,13 @@ const EndpointProjectList = (raw: RawClient["server.project"]) => (input?: Proje
raw["project.list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapClientError)),
)

const EndpointProjectIcon = (raw: RawClient["server.project"]) => (input: ProjectIconInput) =>
preserveEffect<ProjectIconOutput>()(
raw["project.icon"]({ params: { projectID: input["projectID"] }, query: { v: input["v"] } }).pipe(
Effect.mapError(mapClientError),
),
)

const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
Expand All @@ -1144,6 +1153,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj

const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
icon: EndpointProjectIcon(raw),
update: EndpointProjectUpdate(raw),
})

Expand Down
15 changes: 15 additions & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ import type {
CredentialRemoveOutput,
ProjectListInput,
ProjectListOutput,
ProjectIconInput,
ProjectIconOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormListInput,
Expand Down Expand Up @@ -1514,6 +1516,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
icon: (input: ProjectIconInput, requestOptions?: RequestOptions) =>
request<ProjectIconOutput>(
{
method: "GET",
path: `/api/project/${encodeURIComponent(input.projectID)}/icon`,
query: { v: input["v"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
binary: true,
},
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
Expand Down
16 changes: 12 additions & 4 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2192,6 +2192,7 @@ export type ConfigEntry =
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
icon?: string
permissions?: PermissionRuleset
policies?: Array<{
action: "provider.use" | "tool.use" | "integration.use"
Expand Down Expand Up @@ -2873,6 +2874,10 @@ export type ProjectNotFoundError = {
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"

export type FileNotFoundError = { readonly _tag: "FileNotFoundError"; readonly path: string; readonly message: string }
export const isFileNotFoundError = (value: unknown): value is FileNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FileNotFoundError"

export type PermissionNotFoundError = {
readonly _tag: "PermissionNotFoundError"
readonly requestID: string
Expand All @@ -2881,10 +2886,6 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"

export type FileNotFoundError = { readonly _tag: "FileNotFoundError"; readonly path: string; readonly message: string }
export const isFileNotFoundError = (value: unknown): value is FileNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FileNotFoundError"

export type RpcError = {
readonly _tag: "RpcError"
readonly type: string
Expand Down Expand Up @@ -6284,6 +6285,13 @@ export type ProjectListInput = { readonly archived?: { readonly archived?: boole

export type ProjectListOutput = Array<Project>

export type ProjectIconInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly v?: { readonly v?: string | undefined }["v"]
}

export type ProjectIconOutput = globalThis.Uint8Array

export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly canonical?: {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update"])
expect(Object.keys(client.project)).toEqual(["list", "icon", "update"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export function normalize(input: unknown): Result {
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
icon: Info.fields.icon,
snapshots: Info.fields.snapshots,
media: Info.fields.media,
tool_output: Info.fields.tool_output,
Expand Down
Loading
Loading