From e675acf5efcba55c05b3b291135254821d1269b6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 20 Aug 2026 13:12:27 -0400 Subject: [PATCH 1/2] chore: simplify catalog deployment --- .github/workflows/deploy-catalog.yml | 4 +++- apps/catalog/scripts/generate-og.ts | 17 ++--------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deploy-catalog.yml b/.github/workflows/deploy-catalog.yml index eed5e13..2973802 100644 --- a/.github/workflows/deploy-catalog.yml +++ b/.github/workflows/deploy-catalog.yml @@ -44,7 +44,9 @@ jobs: - name: Deploy working-directory: apps/catalog - run: bun run deploy + run: | + bun run build + bunx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/apps/catalog/scripts/generate-og.ts b/apps/catalog/scripts/generate-og.ts index b6cbde2..f47b2c7 100644 --- a/apps/catalog/scripts/generate-og.ts +++ b/apps/catalog/scripts/generate-og.ts @@ -17,6 +17,7 @@ import { baselineOffset, drawBlockGlyph, } from "opencode-drive/frame" +import type { FrameArtifact } from "../catalog/schema" const CardWidth = 1200 const CardHeight = 630 @@ -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 }> -} - -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})` } From 48388f6ad8292e719389fd2787b10fd57332f1d7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 20 Aug 2026 13:31:53 -0400 Subject: [PATCH 2/2] fix: address repository audit findings --- .github/workflows/deploy-catalog.yml | 8 +++-- apps/catalog/catalog/capture-sets.test.ts | 10 ++++++ apps/catalog/catalog/public-path.test.ts | 18 ++++++++++ apps/catalog/catalog/public-path.ts | 25 ++++++++++++++ apps/catalog/catalog/worker.test.ts | 1 + .../catalog/scripts/capture-opencode-drive.ts | 10 +++--- apps/catalog/scripts/capture-sets.ts | 4 +++ apps/catalog/server.ts | 17 ++++------ apps/catalog/src/components/TerminalFrame.tsx | 13 +++++--- apps/catalog/worker.ts | 5 ++- apps/catalog/wrangler.jsonc | 6 +++- packages/drive/src/instance/control.ts | 9 ++++- packages/drive/src/instance/service.ts | 4 ++- packages/drive/test/instance/control.test.ts | 33 +++++++++++++++++++ packages/drive/test/instance/service.test.ts | 28 ++++++++++++++++ 15 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 apps/catalog/catalog/public-path.test.ts create mode 100644 apps/catalog/catalog/public-path.ts create mode 100644 packages/drive/test/instance/control.test.ts create mode 100644 packages/drive/test/instance/service.test.ts diff --git a/.github/workflows/deploy-catalog.yml b/.github/workflows/deploy-catalog.yml index 2973802..1a366ad 100644 --- a/.github/workflows/deploy-catalog.yml +++ b/.github/workflows/deploy-catalog.yml @@ -42,11 +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 build - bunx wrangler deploy + run: bunx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/apps/catalog/catalog/capture-sets.test.ts b/apps/catalog/catalog/capture-sets.test.ts index 23a4814..c54baeb 100644 --- a/apps/catalog/catalog/capture-sets.test.ts +++ b/apps/catalog/catalog/capture-sets.test.ts @@ -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"]) }) diff --git a/apps/catalog/catalog/public-path.test.ts b/apps/catalog/catalog/public-path.test.ts new file mode 100644 index 0000000..a3af702 --- /dev/null +++ b/apps/catalog/catalog/public-path.test.ts @@ -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() + }) +}) diff --git a/apps/catalog/catalog/public-path.ts b/apps/catalog/catalog/public-path.ts new file mode 100644 index 0000000..0d01266 --- /dev/null +++ b/apps/catalog/catalog/public-path.ts @@ -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 +} diff --git a/apps/catalog/catalog/worker.test.ts b/apps/catalog/catalog/worker.test.ts index ec90f71..84a4d38 100644 --- a/apps/catalog/catalog/worker.test.ts +++ b/apps/catalog/catalog/worker.test.ts @@ -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", () => { diff --git a/apps/catalog/scripts/capture-opencode-drive.ts b/apps/catalog/scripts/capture-opencode-drive.ts index d486e9e..3303aac 100644 --- a/apps/catalog/scripts/capture-opencode-drive.ts +++ b/apps/catalog/scripts/capture-opencode-drive.ts @@ -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) ?? [] @@ -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)), @@ -459,10 +461,6 @@ async function prepareCaptureSets(options: ReturnType 1), + id: options.workerVariantId ?? captureSetId(revision, theme, revisions.size > 1), label: captureSetLabel(revision, theme), source: captureSource(options.opencode), revision, diff --git a/apps/catalog/scripts/capture-sets.ts b/apps/catalog/scripts/capture-sets.ts index 5bc8484..3330a8f 100644 --- a/apps/catalog/scripts/capture-sets.ts +++ b/apps/catalog/scripts/capture-sets.ts @@ -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, defaultOpenCode: string): CaptureOptions { @@ -19,6 +20,7 @@ export function parseCaptureOptions(args: ReadonlyArray, 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] @@ -34,6 +36,7 @@ export function parseCaptureOptions(args: ReadonlyArray, 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}`) } @@ -47,6 +50,7 @@ export function parseCaptureOptions(args: ReadonlyArray, defaultOpenCode fresh, jobs, workerOutput, + workerVariantId, } } diff --git a/apps/catalog/server.ts b/apps/catalog/server.ts index 78f11c7..7e8480f 100644 --- a/apps/catalog/server.ts +++ b/apps/catalog/server.ts @@ -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([ @@ -20,16 +22,16 @@ 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), }, }) }, @@ -37,13 +39,6 @@ const server = Bun.serve({ 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) diff --git a/apps/catalog/src/components/TerminalFrame.tsx b/apps/catalog/src/components/TerminalFrame.tsx index dc92ce6..a1ad8b0 100644 --- a/apps/catalog/src/components/TerminalFrame.tsx +++ b/apps/catalog/src/components/TerminalFrame.tsx @@ -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 - }) + 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 + }) + .catch((cause) => { + if (cache.get(src) === pending) cache.delete(src) + throw cause + }) cache.set(src, pending) return pending } diff --git a/apps/catalog/worker.ts b/apps/catalog/worker.ts index 54d3054..4422432 100644 --- a/apps/catalog/worker.ts +++ b/apps/catalog/worker.ts @@ -18,6 +18,7 @@ export default { async fetch(request: Request, env: Env): Promise { 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" @@ -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 } diff --git a/apps/catalog/wrangler.jsonc b/apps/catalog/wrangler.jsonc index 25be42a..6b3d935 100644 --- a/apps/catalog/wrangler.jsonc +++ b/apps/catalog/wrangler.jsonc @@ -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", }, ], diff --git a/packages/drive/src/instance/control.ts b/packages/drive/src/instance/control.ts index 93aafae..433ae35 100644 --- a/packages/drive/src/instance/control.ts +++ b/packages/drive/src/instance/control.ts @@ -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, @@ -20,7 +20,12 @@ export async function listenControl( ) => Promise }, ) { + const idleSockets = new Set() 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) => { @@ -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( @@ -55,6 +61,7 @@ export async function listenControl( } await listen(server, path) return async () => { + for (const socket of idleSockets) socket.destroy() await new Promise((resolve) => server.close(() => resolve())) await rm(path, { force: true }) } diff --git a/packages/drive/src/instance/service.ts b/packages/drive/src/instance/service.ts index 6721b05..5b17d84 100644 --- a/packages/drive/src/instance/service.ts +++ b/packages/drive/src/instance/service.ts @@ -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 ) } diff --git a/packages/drive/test/instance/control.test.ts b/packages/drive/test/instance/control.test.ts new file mode 100644 index 0000000..ac22872 --- /dev/null +++ b/packages/drive/test/instance/control.test.ts @@ -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 = [] + +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((resolve, reject) => { + socket.once("connect", resolve) + socket.once("error", reject) + }) + + await expect(close()).resolves.toBeUndefined() + expect(socket.destroyed).toBe(true) + }) +}) diff --git a/packages/drive/test/instance/service.test.ts b/packages/drive/test/instance/service.test.ts new file mode 100644 index 0000000..ce1a94a --- /dev/null +++ b/packages/drive/test/instance/service.test.ts @@ -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 = [] + +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() + }) +})