From d0803019b5dcaff4559468965a2f3b42ba8cc998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 9 May 2026 12:05:10 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20ephemeral=20agent=20token=20for=20serve?= =?UTF-8?q?=20=E2=86=94=20gateway=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serve generates random UUID on startup - registration sends agentToken to gateway, stored in KV - gateway injects X-Agent-Token header when proxying to agent - serve rejects /api/* requests without valid token - healthz remains unauthenticated - tunnel URL is now protected — direct access returns 401 小橘 --- packages/cli-workflow/src/commands/serve/app.ts | 13 ++++++++++++- packages/cli-workflow/src/commands/serve/serve.ts | 10 +++++++--- packages/cli-workflow/src/commands/serve/tunnel.ts | 6 ++++-- packages/workflow-gateway/src/index.ts | 11 ++++++++--- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/cli-workflow/src/commands/serve/app.ts b/packages/cli-workflow/src/commands/serve/app.ts index c76e121..4b07306 100644 --- a/packages/cli-workflow/src/commands/serve/app.ts +++ b/packages/cli-workflow/src/commands/serve/app.ts @@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js"; const MAX_BODY_SIZE = 1_048_576; // 1 MB -export function createApp(storageRoot: string): Hono { +export function createApp(storageRoot: string, agentToken: string | null): Hono { const app = new Hono(); app.onError((_err, c) => { @@ -37,6 +37,17 @@ export function createApp(storageRoot: string): Hono { await next(); }); + // ── Agent token auth (skip healthz) ─────────────────────────────── + if (agentToken !== null) { + app.use("/api/*", async (c, next) => { + const token = c.req.header("X-Agent-Token"); + if (token !== agentToken) { + return c.json({ error: "unauthorized" }, 401); + } + await next(); + }); + } + app.get("/healthz", (c) => c.json({ ok: true })); app.get("/api/healthz", (c) => c.json({ ok: true })); diff --git a/packages/cli-workflow/src/commands/serve/serve.ts b/packages/cli-workflow/src/commands/serve/serve.ts index 214aab0..88df855 100644 --- a/packages/cli-workflow/src/commands/serve/serve.ts +++ b/packages/cli-workflow/src/commands/serve/serve.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { hostname as osHostname } from "node:os"; import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { serve } from "bun"; @@ -15,8 +16,8 @@ import type { ServeOptions } from "./types.js"; const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev"; const HEARTBEAT_INTERVAL_MS = 60_000; -export function startServer(storageRoot: string, options: ServeOptions): void { - const app = createApp(storageRoot); +export function startServer(storageRoot: string, options: ServeOptions, agentToken: string | null): void { + const app = createApp(storageRoot, agentToken); const server = serve({ fetch: app.fetch, @@ -93,7 +94,8 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis } const options = parsed.value; - startServer(storageRoot, options); + const agentToken = options.noTunnel ? null : randomUUID(); + startServer(storageRoot, options, agentToken); if (options.noTunnel) { printCliLine("tunnel disabled (--no-tunnel)"); @@ -120,6 +122,7 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis options.name, tunnel.url, options.gatewaySecret, + agentToken!, ); if (registered) { printCliLine(`registered with gateway as "${options.name}"`); @@ -131,6 +134,7 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis options.name, tunnel.url, options.gatewaySecret, + agentToken!, HEARTBEAT_INTERVAL_MS, ); diff --git a/packages/cli-workflow/src/commands/serve/tunnel.ts b/packages/cli-workflow/src/commands/serve/tunnel.ts index dd253df..97be797 100644 --- a/packages/cli-workflow/src/commands/serve/tunnel.ts +++ b/packages/cli-workflow/src/commands/serve/tunnel.ts @@ -39,12 +39,13 @@ export async function registerWithGateway( name: string, tunnelUrl: string, secret: string, + agentToken: string, ): Promise { try { const resp = await fetch(`${gatewayUrl}/register`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, url: tunnelUrl, secret }), + body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }), }); if (!resp.ok) { const body = await resp.text(); @@ -78,9 +79,10 @@ export function startHeartbeat( name: string, tunnelUrl: string, secret: string, + agentToken: string, intervalMs: number, ): ReturnType { return setInterval(() => { - registerWithGateway(gatewayUrl, name, tunnelUrl, secret).catch(() => {}); + registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {}); }, intervalMs); } diff --git a/packages/workflow-gateway/src/index.ts b/packages/workflow-gateway/src/index.ts index c536f09..387e5e8 100644 --- a/packages/workflow-gateway/src/index.ts +++ b/packages/workflow-gateway/src/index.ts @@ -12,6 +12,7 @@ type Env = { type EndpointRecord = { name: string; url: string; + agentToken: string; registeredAt: number; lastHeartbeat: number; }; @@ -44,8 +45,8 @@ app.get("/healthz", (c) => c.json({ ok: true })); // ── Register / heartbeat ──────────────────────────────────────────── app.post("/register", async (c) => { - const body = await c.req.json<{ name?: string; url?: string; secret?: string }>(); - const { name, url, secret } = body; + const body = await c.req.json<{ name?: string; url?: string; secret?: string; agentToken?: string }>(); + const { name, url, secret, agentToken } = body; if (!name || !url) { return c.json({ error: "name and url required" }, 400); @@ -60,6 +61,7 @@ app.post("/register", async (c) => { const record: EndpointRecord = { name, url: url.replace(/\/+$/, ""), // strip trailing slash + agentToken: agentToken ?? existing?.agentToken ?? "", registeredAt: existing?.registeredAt ?? now, lastHeartbeat: now, }; @@ -119,9 +121,12 @@ app.all("/api/:agent/*", async (c) => { const pathAfterAgent = url.pathname.replace(`/api/${agent}`, ""); const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`; - // Forward headers (skip host) const headers = new Headers(c.req.raw.headers); headers.delete("host"); + headers.delete("Authorization"); // don't forward dashboard key to agent + if (record.agentToken) { + headers.set("X-Agent-Token", record.agentToken); + } try { const resp = await fetch(targetUrl, {