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
6 changes: 5 additions & 1 deletion .github/workflows/deploy-catalog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ jobs:
bun run --cwd packages/drive test
bun run --cwd apps/catalog check

- name: Build
working-directory: apps/catalog
run: bun run build

- name: Deploy
working-directory: apps/catalog
run: bun run deploy
run: bunx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Expand Down
10 changes: 10 additions & 0 deletions apps/catalog/catalog/capture-sets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,19 @@ describe("capture revision sets", () => {
fresh: false,
jobs: 3,
workerOutput: undefined,
workerVariantId: undefined,
})
})

test("accepts the parent-planned worker variant ID", () => {
expect(
parseCaptureOptions(
["--worker-output", ".tmp/workers", "--worker-variant-id", "abc123-opencode"],
"/tmp/opencode",
).workerVariantId,
).toBe("abc123-opencode")
})

test("defaults to the canonical v2 branch instead of a stale checkout HEAD", () => {
expect(parseCaptureOptions([], "/opencode").revisions).toEqual(["origin/v2"])
})
Expand Down
18 changes: 18 additions & 0 deletions apps/catalog/catalog/public-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { publicFilePath } from "./public-path"

describe("catalog public paths", () => {
const root = "/catalog/public"

test("resolves nested public assets", () => {
expect(publicFilePath(root, "/captures/opencode/home.frame.json")).toBe(
"/catalog/public/captures/opencode/home.frame.json",
)
})

test("rejects encoded traversal and malformed paths", () => {
expect(publicFilePath(root, "/%252e%252e%2fpackage.json")).toBeUndefined()
expect(publicFilePath(root, "/../package.json")).toBeUndefined()
expect(publicFilePath(root, "/%zz")).toBeUndefined()
})
})
25 changes: 25 additions & 0 deletions apps/catalog/catalog/public-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isAbsolute, relative, resolve, sep } from "node:path"

export function publicFilePath(root: string, pathname: string) {
const decoded = fullyDecode(pathname)
if (decoded === undefined || decoded.includes("\\")) return undefined

const path = resolve(root, decoded.replace(/^\/+/, ""))
const fromRoot = relative(root, path)
if (fromRoot === "" || isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(`..${sep}`))
return undefined
return path
}

function fullyDecode(value: string) {
try {
for (let index = 0; index < 8; index++) {
const decoded = decodeURIComponent(value)
if (decoded === value) return decoded
value = decoded
}
} catch {
return undefined
}
return undefined
}
1 change: 1 addition & 0 deletions apps/catalog/catalog/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("catalog worker", () => {
expect(assetPath("/lab/catalog")).toBe("/index.html")
expect(assetPath("/lab/catalog/")).toBe("/index.html")
expect(assetPath("/lab/catalog/deep-link")).toBe("/index.html")
expect(assetPath("/lab/catalogue")).toBeUndefined()
})

test("strips the catalog prefix from assets", () => {
Expand Down
10 changes: 4 additions & 6 deletions apps/catalog/scripts/capture-opencode-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ function isCaptureId(value: string): value is CaptureId {

try {
const captured =
options.workerOutput === undefined && variants.length > 1 && options.jobs > 1
options.workerOutput === undefined && options.flow === undefined && variants.length > 1 && options.jobs > 1
? await captureVariantProcesses(options, variants)
: await Effect.runPromise(Effect.forEach(variants, captureVariant, { concurrency: 1 }))
const expectedIds = captured[0]?.map((capture) => capture.id) ?? []
Expand Down Expand Up @@ -424,6 +424,8 @@ async function captureVariantProcesses(
"1",
"--worker-output",
output,
"--worker-variant-id",
variant.id,
]
const child = Bun.spawn(args, {
cwd: fileURLToPath(new URL("..", import.meta.url)),
Expand Down Expand Up @@ -459,10 +461,6 @@ async function prepareCaptureSets(options: ReturnType<typeof parseCaptureOptions
const revision = await git(options.opencode, "rev-parse", `${ref}^{commit}`)
if (revisions.has(revision)) continue
const committedAt = await git(options.opencode, "show", "-s", "--format=%cI", revision)
if (ref === "HEAD") {
revisions.set(revision, { ref, committedAt, path: options.opencode })
continue
}
const path = fileURLToPath(new URL(`../.tmp/capture-worktrees/${revision}/`, import.meta.url))
const preparedRevision = await preparedWorktreeRevision(path)
if (options.fresh || preparedRevision !== revision) {
Expand All @@ -483,7 +481,7 @@ async function prepareCaptureSets(options: ReturnType<typeof parseCaptureOptions
for (const [revision, preparedRevision] of revisions) {
for (const theme of options.themes) {
variants.push({
id: captureSetId(revision, theme, revisions.size > 1),
id: options.workerVariantId ?? captureSetId(revision, theme, revisions.size > 1),
label: captureSetLabel(revision, theme),
source: captureSource(options.opencode),
revision,
Expand Down
4 changes: 4 additions & 0 deletions apps/catalog/scripts/capture-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CaptureOptions {
readonly fresh: boolean
readonly jobs: number
readonly workerOutput: string | undefined
readonly workerVariantId: string | undefined
}

export function parseCaptureOptions(args: ReadonlyArray<string>, defaultOpenCode: string): CaptureOptions {
Expand All @@ -19,6 +20,7 @@ export function parseCaptureOptions(args: ReadonlyArray<string>, defaultOpenCode
let fresh = false
let jobs = 3
let workerOutput: string | undefined
let workerVariantId: string | undefined

for (let index = 0; index < args.length; index++) {
const argument = args[index]
Expand All @@ -34,6 +36,7 @@ export function parseCaptureOptions(args: ReadonlyArray<string>, defaultOpenCode
else if (argument === "--flow") flow = value
else if (argument === "--jobs") jobs = Number(value)
else if (argument === "--worker-output") workerOutput = resolve(value)
else if (argument === "--worker-variant-id") workerVariantId = value
else throw new Error(`Unknown capture argument: ${argument}`)
}

Expand All @@ -47,6 +50,7 @@ export function parseCaptureOptions(args: ReadonlyArray<string>, defaultOpenCode
fresh,
jobs,
workerOutput,
workerVariantId,
}
}

Expand Down
17 changes: 2 additions & 15 deletions apps/catalog/scripts/generate-og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
baselineOffset,
drawBlockGlyph,
} from "opencode-drive/frame"
import type { FrameArtifact } from "../catalog/schema"

const CardWidth = 1200
const CardHeight = 630
Expand All @@ -38,21 +39,7 @@ for (const [file, family] of [
if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register OG font: ${path}`)
}

interface FrameSpan {
readonly text: string
readonly fg: readonly [number, number, number, number]
readonly bg: readonly [number, number, number, number]
readonly attributes: number
readonly width: number
}

interface FrameArtifact {
readonly cols: number
readonly rows: number
readonly lines: ReadonlyArray<{ readonly spans: ReadonlyArray<FrameSpan> }>
}

function color([red, green, blue, alpha]: FrameSpan["fg"], opacity = 1) {
function color([red, green, blue, alpha]: FrameArtifact["lines"][number]["spans"][number]["fg"], opacity = 1) {
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
}

Expand Down
17 changes: 6 additions & 11 deletions apps/catalog/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fileURLToPath } from "node:url"
import { publicFilePath } from "./catalog/public-path"
import index from "./src/index.html"

const publicDirectory = new URL("./public/", import.meta.url)
const publicDirectory = fileURLToPath(new URL("./public/", import.meta.url))
const port = Number(process.env.PORT ?? "4187")

const contentTypes = new Map([
Expand All @@ -20,30 +22,23 @@ const server = Bun.serve({
},
async fetch(request) {
const url = new URL(request.url)
const path = normalizePath(url.pathname)
const path = publicFilePath(publicDirectory, url.pathname)
if (!path) return new Response("Not found", { status: 404 })

const file = Bun.file(new URL(path, publicDirectory))
const file = Bun.file(path)
if (!(await file.exists())) return new Response("Not found", { status: 404 })

return new Response(file, {
headers: {
"cache-control": "no-store",
"content-type": contentType(path),
"content-type": contentType(url.pathname),
},
})
},
})

console.log(`OpenCode terminal catalog: http://localhost:${server.port}`)

function normalizePath(pathname: string) {
const decoded = decodeURIComponent(pathname)
const path = decoded.replace(/^\/+/, "")
if (path === "" || path.includes("..") || path.includes("\\")) return undefined
return path
}

function contentType(path: string) {
const dot = path.lastIndexOf(".")
const extension = dot === -1 ? "" : path.slice(dot)
Expand Down
13 changes: 9 additions & 4 deletions apps/catalog/src/components/TerminalFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,15 @@ export function TerminalFrame({ frame, label, lazy = false }: TerminalFrameProps
function loadFrame(src: string) {
const existing = cache.get(src)
if (existing) return existing
const pending = fetch(`${catalogBasePath()}${src}`).then(async (response) => {
if (!response.ok) throw new Error(`Failed to load terminal frame: ${response.status}`)
return response.json() as Promise<FrameArtifact>
})
const pending = fetch(`${catalogBasePath()}${src}`)
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to load terminal frame: ${response.status}`)
return response.json() as Promise<FrameArtifact>
})
.catch((cause) => {
if (cache.get(src) === pending) cache.delete(src)
throw cause
})
cache.set(src, pending)
return pending
}
Expand Down
5 changes: 4 additions & 1 deletion apps/catalog/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const path = assetPath(url.pathname)
if (path === undefined) return new Response("Not found", { status: 404 })
const assetUrl = new URL(url)
assetUrl.pathname = path
const catalog = path === "/index.html"
Expand Down Expand Up @@ -45,7 +46,9 @@ export default {
}

export function assetPath(pathname: string) {
const path = pathname.slice("/lab/catalog".length)
const prefix = "/lab/catalog"
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return undefined
const path = pathname.slice(prefix.length)
return path === "" || path === "/" || !path.includes(".") ? "/index.html" : path
}

Expand Down
6 changes: 5 additions & 1 deletion apps/catalog/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
},
"routes": [
{
"pattern": "dev.opencode.ai/lab/catalog*",
"pattern": "dev.opencode.ai/lab/catalog",
"zone_name": "opencode.ai",
},
{
"pattern": "dev.opencode.ai/lab/catalog/*",
"zone_name": "opencode.ai",
},
],
Expand Down
9 changes: 8 additions & 1 deletion packages/drive/src/instance/control.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { rm } from "node:fs/promises"
import { connect, createServer } from "node:net"
import { connect, createServer, type Socket } from "node:net"
import type {
ResponseConfiguration,
ResponseUpdate,
Expand All @@ -20,7 +20,12 @@ export async function listenControl(
) => Promise<ResponseConfiguration>
},
) {
const idleSockets = new Set<Socket>()
const server = createServer((socket) => {
idleSockets.add(socket)
socket.on("close", () => idleSockets.delete(socket))
socket.on("error", () => idleSockets.delete(socket))
socket.setTimeout(30_000, () => socket.destroy())
let buffer = ""
socket.setEncoding("utf8")
socket.on("data", (data) => {
Expand All @@ -31,6 +36,7 @@ export async function listenControl(
return
}
if (!buffer.includes("\n")) return
idleSockets.delete(socket)
socket.removeAllListeners("data")
const progress = (percent: number) => socket.write(`progress ${percent}\n`)
void handle(buffer.slice(0, buffer.indexOf("\n")), progress).then(
Expand All @@ -55,6 +61,7 @@ export async function listenControl(
}
await listen(server, path)
return async () => {
for (const socket of idleSockets) socket.destroy()
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(path, { force: true })
}
Expand Down
4 changes: 3 additions & 1 deletion packages/drive/src/instance/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ function isServiceInfo(value: unknown): value is { readonly pid: number } {
typeof value === "object" &&
value !== null &&
"pid" in value &&
typeof value.pid === "number"
typeof value.pid === "number" &&
Number.isSafeInteger(value.pid) &&
value.pid > 1
)
}
33 changes: 33 additions & 0 deletions packages/drive/test/instance/control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { mkdtemp, rm } from "node:fs/promises"
import { connect } from "node:net"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import { listenControl } from "../../src/instance/control.js"

const roots: Array<string> = []

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})

describe("instance control", () => {
it("closes while an idle client is connected", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-drive-control-"))
roots.push(root)
const path = join(root, "control.sock")
const close = await listenControl(path, {
restart: async () => undefined,
stop: async () => ({ screenshots: [] }),
responses: async () => ({ types: [], tools: [] }),
})
const socket = connect(path)
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
})

await expect(close()).resolves.toBeUndefined()
expect(socket.destroyed).toBe(true)
})
})
28 changes: 28 additions & 0 deletions packages/drive/test/instance/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Effect from "effect/Effect"
import { stopService } from "../../src/instance/service.js"

const roots: Array<string> = []

afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})

describe("stopService", () => {
it("ignores unsafe process identifiers", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-drive-service-"))
roots.push(root)
const state = join(root, "state")
await mkdir(join(state, "opencode"), { recursive: true })
await Bun.write(join(state, "opencode", "service.json"), JSON.stringify({ pid: -1 }))
const kill = vi.spyOn(process, "kill")

await Effect.runPromise(stopService(state))

expect(kill).not.toHaveBeenCalled()
})
})