From 18589fea2dd571033221524d98862e20501d95d6 Mon Sep 17 00:00:00 2001 From: Aeg1sx Date: Fri, 10 Apr 2026 22:04:47 +0900 Subject: [PATCH] Harden review agent job launch endpoints --- apps/pi-extension/server.test.ts | 73 +++++++++++++++++++- apps/pi-extension/server/agent-jobs.ts | 21 ++++++ apps/pi-extension/server/network.test.ts | 15 ++++- apps/pi-extension/server/network.ts | 7 +- apps/pi-extension/server/serverReview.ts | 3 + packages/review-editor/App.tsx | 7 +- packages/server/agent-jobs.test.ts | 84 ++++++++++++++++++++++++ packages/server/agent-jobs.ts | 17 +++++ packages/server/annotate.ts | 3 +- packages/server/index.ts | 3 +- packages/server/remote.test.ts | 15 ++++- packages/server/remote.ts | 11 ++++ packages/server/review.ts | 6 +- packages/ui/hooks/useAgentJobs.ts | 20 ++++-- 14 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 packages/server/agent-jobs.test.ts diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 007ac539..c80101d4 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,7 @@ const tempDirs: string[] = []; const originalCwd = process.cwd(); const originalHome = process.env.HOME; const originalPort = process.env.PLANNOTATOR_PORT; +const originalPath = process.env.PATH; function makeTempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); @@ -39,6 +40,14 @@ function initRepo(): string { return repoDir; } +function installFakeCli(name: string): void { + const dir = makeTempDir(`plannotator-${name}-cli-`); + const path = join(dir, name); + writeFileSync(path, "#!/bin/sh\nexit 0\n", "utf-8"); + chmodSync(path, 0o755); + process.env.PATH = [dir, originalPath].filter(Boolean).join(":"); +} + function reservePort(): Promise { return new Promise((resolve, reject) => { const server = createNetServer(); @@ -75,6 +84,11 @@ afterEach(() => { } else { process.env.PLANNOTATOR_PORT = originalPort; } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -251,6 +265,63 @@ describe("pi review server", () => { } }); + test("requires the review session token to launch agent jobs", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = initRepo(); + installFakeCli("codex"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8"); + + const gitContext = await getGitContext(); + const diff = await runGitDiff("uncommitted", gitContext.defaultBranch); + + const server = await startReviewServer({ + rawPatch: diff.patch, + gitRef: diff.label, + error: diff.error, + diffType: "uncommitted", + gitContext, + origin: "pi", + htmlContent: "review", + }); + + try { + const diffResponse = await fetch(`${server.url}/api/diff`); + const diffPayload = await diffResponse.json() as { agentJobToken?: string }; + expect(diffPayload.agentJobToken).toBeTruthy(); + + const unauthorized = await fetch(`${server.url}/api/agents/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "codex", + command: ["/usr/bin/true"], + label: "Injected", + }), + }); + expect(unauthorized.status).toBe(403); + + const authorized = await fetch(`${server.url}/api/agents/jobs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Plannotator-Agent-Token": diffPayload.agentJobToken!, + }, + body: JSON.stringify({ + provider: "codex", + command: ["/usr/bin/true"], + label: "Injected", + }), + }); + expect(authorized.status).toBe(201); + } finally { + server.stop(); + } + }); + test("git-add endpoint stages and unstages files in review mode", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/apps/pi-extension/server/agent-jobs.ts b/apps/pi-extension/server/agent-jobs.ts index 2ca1b1b8..d36d2e0f 100644 --- a/apps/pi-extension/server/agent-jobs.ts +++ b/apps/pi-extension/server/agent-jobs.ts @@ -31,6 +31,7 @@ const BASE = "/api/agents"; const JOBS = `${BASE}/jobs`; const JOBS_STREAM = `${JOBS}/stream`; const CAPABILITIES = `${BASE}/capabilities`; +const AGENT_JOB_TOKEN_HEADER = "x-plannotator-agent-token"; // --------------------------------------------------------------------------- // which() helper for Node.js @@ -66,6 +67,7 @@ export interface AgentJobHandlerOptions { } | null>; /** Called when a job completes successfully — parse results and push annotations. */ onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise; + authToken?: string; } export function createAgentJobHandler(options: AgentJobHandlerOptions) { @@ -307,6 +309,13 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) { return Array.from(jobs.values()).map((e) => ({ ...e.info })); } + function isAuthorizedRequest(req: IncomingMessage): boolean { + if (!options.authToken) return true; + const tokenHeader = req.headers[AGENT_JOB_TOKEN_HEADER]; + if (Array.isArray(tokenHeader)) return tokenHeader.includes(options.authToken); + return tokenHeader === options.authToken; + } + // --- HTTP handler --- return { killAll, @@ -377,6 +386,10 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) { // --- POST /api/agents/jobs (launch) --- if (url.pathname === JOBS && req.method === "POST") { + if (!isAuthorizedRequest(req)) { + json(res, { error: "Unauthorized agent job request" }, 403); + return true; + } try { const body = await parseBody(req); const provider = typeof body.provider === "string" ? body.provider : ""; @@ -430,6 +443,10 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) { // --- DELETE /api/agents/jobs/:id (kill one) --- if (url.pathname.startsWith(JOBS + "/") && url.pathname !== JOBS_STREAM && req.method === "DELETE") { + if (!isAuthorizedRequest(req)) { + json(res, { error: "Unauthorized agent job request" }, 403); + return true; + } const id = url.pathname.slice(JOBS.length + 1); if (!id) { json(res, { error: "Missing job ID" }, 400); @@ -446,6 +463,10 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) { // --- DELETE /api/agents/jobs (kill all) --- if (url.pathname === JOBS && req.method === "DELETE") { + if (!isAuthorizedRequest(req)) { + json(res, { error: "Unauthorized agent job request" }, 403); + return true; + } const count = killAll(); json(res, { ok: true, killed: count }); return true; diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts index c261b32b..65513170 100644 --- a/apps/pi-extension/server/network.test.ts +++ b/apps/pi-extension/server/network.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { getServerPort, isRemoteSession } from "./network"; +import { getServerHostname, getServerPort, isRemoteSession } from "./network"; const savedEnv: Record = {}; const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"]; @@ -94,3 +94,16 @@ describe("pi port selection", () => { expect(getServerPort()).toEqual({ port: 9999, portSource: "env" }); }); }); + +describe("pi server hostname", () => { + test("binds local sessions to loopback", () => { + clearEnv(); + expect(getServerHostname()).toBe("127.0.0.1"); + }); + + test("binds remote sessions to loopback", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + expect(getServerHostname()).toBe("127.0.0.1"); + }); +}); diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts index 1399aa05..eaa18fd8 100644 --- a/apps/pi-extension/server/network.ts +++ b/apps/pi-extension/server/network.ts @@ -8,6 +8,7 @@ import type { Server } from "node:http"; import { release } from "node:os"; const DEFAULT_REMOTE_PORT = 19432; +const LOOPBACK_HOST = "127.0.0.1"; /** * Check if running in a remote session (SSH, devcontainer, etc.) @@ -67,6 +68,10 @@ export function getServerPort(): { return { port: 0, portSource: "random" }; } +export function getServerHostname(): string { + return LOOPBACK_HOST; +} + const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; @@ -81,7 +86,7 @@ export async function listenOnPort( server.once("error", reject); server.listen( result.port, - isRemoteSession() ? "0.0.0.0" : "127.0.0.1", + getServerHostname(), () => { server.removeListener("error", reject); resolve(); diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index c69180aa..c9e3f956 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -190,6 +190,7 @@ export async function startReviewServer(options: { let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; + const agentJobToken = crypto.randomUUID(); // Agent jobs — background process manager (late-binds serverUrl via getter) let serverUrl = ""; @@ -205,6 +206,7 @@ export async function startReviewServer(options: { const agentJobs = createAgentJobHandler({ mode: "review", getServerUrl: () => serverUrl, + authToken: agentJobToken, getCwd: resolveAgentCwd, async buildCommand(provider) { @@ -423,6 +425,7 @@ export async function startReviewServer(options: { shareBaseUrl, repoInfo, isWSL: wslFlag, + agentJobToken, ...(options.agentCwd && { agentCwd: options.agentCwd }), ...(isPRMode && { prMetadata: prMeta, platformUser }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 15f1ab6d..a0c8f60c 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -68,6 +68,7 @@ interface DiffData { diffType?: string; gitContext?: GitContext; sharingEnabled?: boolean; + agentJobToken?: string; } // Simple diff parser to extract files from unified diff @@ -155,6 +156,7 @@ const ReviewApp: React.FC = () => { const [diffType, setDiffType] = useState('uncommitted'); const [gitContext, setGitContext] = useState(null); const [agentCwd, setAgentCwd] = useState(null); + const [agentJobToken, setAgentJobToken] = useState(null); const [isLoadingDiff, setIsLoadingDiff] = useState(false); const [diffError, setDiffError] = useState(null); const [isSendingFeedback, setIsSendingFeedback] = useState(false); @@ -220,7 +222,7 @@ const ReviewApp: React.FC = () => { // The same !!origin proxy is used elsewhere in this file (draft hook, feedback guard, conditional UI) // so this should be addressed as a broader refactor. const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: !!origin }); - const agentJobs = useAgentJobs({ enabled: !!origin }); + const agentJobs = useAgentJobs({ enabled: !!origin, authToken: agentJobToken ?? undefined }); // Dockview center panel API for the review workspace. const [dockApi, setDockApi] = useState(null); @@ -624,6 +626,7 @@ const ReviewApp: React.FC = () => { diffType?: string; gitContext?: GitContext; agentCwd?: string; + agentJobToken?: string; sharingEnabled?: boolean; repoInfo?: { display: string; branch?: string }; prMetadata?: PRMetadata; @@ -646,12 +649,14 @@ const ReviewApp: React.FC = () => { diffType: data.diffType, gitContext: data.gitContext, sharingEnabled: data.sharingEnabled, + agentJobToken: data.agentJobToken, }); setFiles(apiFiles); if (data.origin) setOrigin(data.origin); if (data.diffType) setDiffType(data.diffType); if (data.gitContext) setGitContext(data.gitContext); if (data.agentCwd) setAgentCwd(data.agentCwd); + if (data.agentJobToken) setAgentJobToken(data.agentJobToken); if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); if (data.repoInfo) setRepoInfo(data.repoInfo); if (data.prMetadata) setPrMetadata(data.prMetadata); diff --git a/packages/server/agent-jobs.test.ts b/packages/server/agent-jobs.test.ts new file mode 100644 index 00000000..7366c2a7 --- /dev/null +++ b/packages/server/agent-jobs.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentJobHandler } from "./agent-jobs"; + +const originalPath = process.env.PATH; +const tempDirs: string[] = []; + +function makeFakeCli(name: string): void { + const dir = mkdtempSync(join(tmpdir(), "plannotator-agent-jobs-")); + const path = join(dir, name); + writeFileSync(path, "#!/bin/sh\nexit 0\n", "utf-8"); + chmodSync(path, 0o755); + tempDirs.push(dir); + process.env.PATH = [dir, originalPath].filter(Boolean).join(":"); +} + +afterEach(() => { + process.env.PATH = originalPath; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("agent job auth", () => { + test("rejects launch requests without the session token", async () => { + makeFakeCli("codex"); + + const handler = createAgentJobHandler({ + mode: "review", + getServerUrl: () => "http://localhost:19432", + getCwd: () => process.cwd(), + authToken: "session-token", + }); + + const req = new Request("http://localhost/api/agents/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "codex", + command: ["/usr/bin/true"], + label: "Injected", + }), + }); + + const res = await handler.handle(req, new URL(req.url)); + expect(res?.status).toBe(403); + expect(await res?.json()).toEqual({ error: "Unauthorized agent job request" }); + }); + + test("accepts authenticated launch requests and preserves raw commands when no builder is configured", async () => { + makeFakeCli("codex"); + + const handler = createAgentJobHandler({ + mode: "review", + getServerUrl: () => "http://localhost:19432", + getCwd: () => process.cwd(), + authToken: "session-token", + }); + + const req = new Request("http://localhost/api/agents/jobs", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Plannotator-Agent-Token": "session-token", + }, + body: JSON.stringify({ + provider: "codex", + command: ["/usr/bin/true"], + label: "Shell-compatible codex wrapper", + }), + }); + + const res = await handler.handle(req, new URL(req.url)); + expect(res?.status).toBe(201); + + const payload = await res?.json() as { job: { command: string[]; label: string } }; + expect(payload.job.command).toEqual(["/usr/bin/true"]); + expect(payload.job.label).toBe("Shell-compatible codex wrapper"); + + handler.killAll(); + }); +}); diff --git a/packages/server/agent-jobs.ts b/packages/server/agent-jobs.ts index 513fdb53..a6614408 100644 --- a/packages/server/agent-jobs.ts +++ b/packages/server/agent-jobs.ts @@ -45,6 +45,7 @@ const BASE = "/api/agents"; const JOBS = `${BASE}/jobs`; const JOBS_STREAM = `${JOBS}/stream`; const CAPABILITIES = `${BASE}/capabilities`; +const AGENT_JOB_TOKEN_HEADER = "x-plannotator-agent-token"; // --------------------------------------------------------------------------- // Factory @@ -77,6 +78,8 @@ export interface AgentJobHandlerOptions { * Use for result ingestion (e.g., reading an output file and pushing annotations). */ onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise; + /** Optional session token required for mutating job routes. */ + authToken?: string; } export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJobHandler { @@ -328,6 +331,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob return Array.from(jobs.values()).map((e) => ({ ...e.info })); } + function isAuthorizedRequest(req: Request): boolean { + if (!options.authToken) return true; + return req.headers.get(AGENT_JOB_TOKEN_HEADER) === options.authToken; + } + // --- HTTP handler --- return { killAll, @@ -401,6 +409,9 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob // --- POST /api/agents/jobs (launch) --- if (url.pathname === JOBS && req.method === "POST") { + if (!isAuthorizedRequest(req)) { + return Response.json({ error: "Unauthorized agent job request" }, { status: 403 }); + } try { const body = await req.json(); const provider = typeof body.provider === "string" ? body.provider : ""; @@ -457,6 +468,9 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob // --- DELETE /api/agents/jobs/:id (kill one) --- if (url.pathname.startsWith(JOBS + "/") && url.pathname !== JOBS_STREAM && req.method === "DELETE") { + if (!isAuthorizedRequest(req)) { + return Response.json({ error: "Unauthorized agent job request" }, { status: 403 }); + } const id = url.pathname.slice(JOBS.length + 1); if (!id) { return Response.json({ error: "Missing job ID" }, { status: 400 }); @@ -470,6 +484,9 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob // --- DELETE /api/agents/jobs (kill all) --- if (url.pathname === JOBS && req.method === "DELETE") { + if (!isAuthorizedRequest(req)) { + return Response.json({ error: "Unauthorized agent job request" }, { status: 403 }); + } const count = killAll(); return Response.json({ ok: true, killed: count }); } diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index c61de8d7..b0e02cfb 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,7 +11,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers"; @@ -128,6 +128,7 @@ export async function startAnnotateServer( for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { server = Bun.serve({ + hostname: getServerHostname(), port: configuredPort, async fetch(req, server) { diff --git a/packages/server/index.ts b/packages/server/index.ts index 91f01f5d..d2c416aa 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -11,7 +11,7 @@ import type { Origin } from "@plannotator/shared/agents"; import { resolve } from "path"; -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { openEditorDiff } from "./ide"; import { saveToObsidian, @@ -200,6 +200,7 @@ export async function startPlannotatorServer( for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { server = Bun.serve({ + hostname: getServerHostname(), port: configuredPort, async fetch(req, server) { diff --git a/packages/server/remote.test.ts b/packages/server/remote.test.ts index 6cc4eba9..52b1f7a8 100644 --- a/packages/server/remote.test.ts +++ b/packages/server/remote.test.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, test } from "bun:test"; -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; // Save and restore env between tests const savedEnv: Record = {}; @@ -135,3 +135,16 @@ describe("getServerPort", () => { expect(getServerPort()).toBe(0); }); }); + +describe("getServerHostname", () => { + test("always returns loopback for local sessions", () => { + clearEnv(); + expect(getServerHostname()).toBe("127.0.0.1"); + }); + + test("always returns loopback for remote sessions", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + expect(getServerHostname()).toBe("127.0.0.1"); + }); +}); diff --git a/packages/server/remote.ts b/packages/server/remote.ts index 379afca0..e5c0d930 100644 --- a/packages/server/remote.ts +++ b/packages/server/remote.ts @@ -9,6 +9,7 @@ */ const DEFAULT_REMOTE_PORT = 19432; +const LOOPBACK_HOST = "127.0.0.1"; function getRemoteOverride(): boolean | null { const remote = process.env.PLANNOTATOR_REMOTE; @@ -63,3 +64,13 @@ export function getServerPort(): number { // Remote sessions use fixed port for port forwarding; local uses random return isRemoteSession() ? DEFAULT_REMOTE_PORT : 0; } + +/** + * Bind review servers to loopback only. + * + * Remote workflows still work via SSH/devcontainer port forwarding, but the + * server no longer accepts direct LAN requests by default. + */ +export function getServerHostname(): string { + return LOOPBACK_HOST; +} diff --git a/packages/server/review.ts b/packages/server/review.ts index 4c785c8f..769c4f83 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,7 +9,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import type { Origin } from "@plannotator/shared/agents"; import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath } from "./vcs"; import { getRepoInfo } from "./repo"; @@ -125,12 +125,14 @@ export async function startReviewServer( let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; + const agentJobToken = crypto.randomUUID(); // Agent jobs — background process manager (late-binds serverUrl via getter) let serverUrl = ""; const agentJobs = createAgentJobHandler({ mode: "review", getServerUrl: () => serverUrl, + authToken: agentJobToken, getCwd: () => { if (options.agentCwd) return options.agentCwd; return resolveVcsCwd(currentDiffType, gitContext?.cwd) ?? process.cwd(); @@ -348,6 +350,7 @@ export async function startReviewServer( for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { server = Bun.serve({ + hostname: getServerHostname(), port: configuredPort, async fetch(req, server) { @@ -365,6 +368,7 @@ export async function startReviewServer( shareBaseUrl, repoInfo, isWSL: wslFlag, + agentJobToken, ...(options.agentCwd && { agentCwd: options.agentCwd }), ...(isPRMode && { prMetadata, platformUser }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), diff --git a/packages/ui/hooks/useAgentJobs.ts b/packages/ui/hooks/useAgentJobs.ts index 016ef3be..df0b54cc 100644 --- a/packages/ui/hooks/useAgentJobs.ts +++ b/packages/ui/hooks/useAgentJobs.ts @@ -28,9 +28,10 @@ interface UseAgentJobsReturn { } export function useAgentJobs( - options?: { enabled?: boolean }, + options?: { enabled?: boolean; authToken?: string }, ): UseAgentJobsReturn { const enabled = options?.enabled ?? true; + const authToken = options?.authToken; const [jobs, setJobs] = useState([]); const [jobLogs, setJobLogs] = useState>(new Map()); const [capabilities, setCapabilities] = useState(null); @@ -167,7 +168,10 @@ export function useAgentJobs( try { const res = await fetch(JOBS_URL, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(authToken ? { 'X-Plannotator-Agent-Token': authToken } : {}), + }, body: JSON.stringify(params), }); if (!res.ok) return null; @@ -177,26 +181,30 @@ export function useAgentJobs( return null; } }, - [], + [authToken], ); const killJob = useCallback(async (id: string) => { try { await fetch(`${JOBS_URL}/${encodeURIComponent(id)}`, { method: 'DELETE', + headers: authToken ? { 'X-Plannotator-Agent-Token': authToken } : undefined, }); } catch { // SSE will reconcile } - }, []); + }, [authToken]); const killAll = useCallback(async () => { try { - await fetch(JOBS_URL, { method: 'DELETE' }); + await fetch(JOBS_URL, { + method: 'DELETE', + headers: authToken ? { 'X-Plannotator-Agent-Token': authToken } : undefined, + }); } catch { // SSE will reconcile } - }, []); + }, [authToken]); return { jobs, jobLogs, capabilities, launchJob, killJob, killAll }; }