diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 27190f2eb24e..c2989ef268f6 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -40,8 +40,8 @@ export namespace Flag { export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export declare const OPENCODE_CLIENT: string - export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] - export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] + export declare const OPENCODE_SERVER_PASSWORD: string | undefined + export declare const OPENCODE_SERVER_USERNAME: string | undefined export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") // Experimental @@ -152,3 +152,19 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", { enumerable: true, configurable: false, }) + +Object.defineProperty(Flag, "OPENCODE_SERVER_PASSWORD", { + get() { + return process.env["OPENCODE_SERVER_PASSWORD"] + }, + enumerable: true, + configurable: false, +}) + +Object.defineProperty(Flag, "OPENCODE_SERVER_USERNAME", { + get() { + return process.env["OPENCODE_SERVER_USERNAME"] + }, + enumerable: true, + configurable: false, +}) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts index 6c200eecd89a..a78452039c4a 100644 --- a/packages/opencode/src/server/routes/trigger.ts +++ b/packages/opencode/src/server/routes/trigger.ts @@ -99,6 +99,29 @@ export const TriggerRoutes = lazy(() => return c.json(await Trigger.fire(c.req.valid("param").id)) }, ) + .post( + "/:id/fire/webhook", + describeRoute({ + summary: "Fire trigger webhook", + description: "Invoke a lightweight scheduled trigger immediately through an authenticated webhook endpoint.", + operationId: "trigger.fire_webhook", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.fire(c.req.valid("param").id)) + }, + ) .post( "/:id/enable", describeRoute({ diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 5aaef4f152e2..043a4ef1d427 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -1,8 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" +import { Trigger } from "../../src/trigger" import { tmpdir } from "../fixture/fixture" +function auth(password: string, username = "opencode") { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + afterEach(async () => { await Instance.disposeAll() }) @@ -139,4 +144,59 @@ describe("trigger routes", () => { }, }) }) + + test("fires trigger from webhook endpoint", async () => { + await using tmp = await tmpdir({ git: true }) + const item = await Instance.provide({ + directory: tmp.path, + fn: () => Trigger.create({ interval: 5_000 }), + }) + const app = Server.ControlPlaneRoutes() + + const fire = await app.request(`/trigger/${item.id}/fire/webhook?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", + }) + + expect(fire.status).toBe(200) + expect(await fire.json()).toMatchObject({ + id: item.id, + runs: 1, + time: { + created: item.time.created, + last: expect.any(Number), + }, + }) + }) + + test("requires server auth for webhook trigger fire", async () => { + await using tmp = await tmpdir({ git: true }) + const item = await Instance.provide({ + directory: tmp.path, + fn: () => Trigger.create({ interval: 5_000 }), + }) + const prev = process.env.OPENCODE_SERVER_PASSWORD + delete process.env.OPENCODE_SERVER_USERNAME + process.env.OPENCODE_SERVER_PASSWORD = "secret" + + try { + const app = Server.ControlPlaneRoutes() + const url = `/trigger/${item.id}/fire/webhook?directory=${encodeURIComponent(tmp.path)}` + + const bad = await app.request(url, { method: "POST" }) + expect(bad.status).toBe(401) + + const good = await app.request(url, { + method: "POST", + headers: { + Authorization: auth("secret"), + }, + }) + + expect(good.status).toBe(200) + expect(await good.json()).toMatchObject({ id: item.id, runs: 1 }) + } finally { + if (prev === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = prev + } + }) })