This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5605-539a68551288a951d4c0a6d262105a8e6c211790 in repository https://gitbox.apache.org/repos/asf/texera.git
commit ae3128d999c48d0f014be2eea2eb4fc0f4ee9e66 Author: Jiadong Bai <[email protected]> AuthorDate: Wed Jun 10 23:32:48 2026 -0700 fix(agent-service): authenticate to the LLM proxy as the delegating user (#5605) ### What changes were proposed in this PR? Since #5421, the access-control-service LLM gateway requires a REGULAR/ADMIN-role user JWT and injects `LITELLM_MASTER_KEY` into the downstream request itself, but the agent-service still authenticated with the static `LLM_API_KEY` default (`"dummy"`), so every agent generation call returned 401 Unauthorized. This PR makes the delegating user's JWT the only credential the agent-service ever sends: - Creating an agent (`POST /agents`) now requires the user's JWT in the `Authorization: Bearer` header (the standard place for credentials, instead of a `userToken` field in the JSON payload); requests without it are rejected with 401, since an agent without a user could never call the gateway anyway. The frontend's JWT interceptor already attaches this header, so `agent.service.ts` simply stops duplicating the token in the request body. - The OpenAI client authenticates with the delegating user's JWT; the `LLM_API_KEY` env var is removed from the service (`env.ts`), the helm chart (deployment env, `llm-api-key` secret entry, `llmApiKey` values), and the single-node `.env`. The master key now lives only in the access-control-service and LiteLLM. - Elysia `VALIDATION`/`PARSE` errors are mapped to 400 instead of falling through to a generic 500 (surfaced by Copilot's review comment on the schema-violation tests). ```mermaid sequenceDiagram participant FE as Frontend (user JWT) participant AS as agent-service participant ACS as access-control-service (LLM gateway) participant LLM as LiteLLM FE->>AS: POST /agents<br/>Authorization: Bearer (user JWT) — required, else 401 FE->>AS: send message rect rgb(255, 235, 235) Note over AS,ACS: before: Authorization: Bearer dummy → 401 end AS->>ACS: POST /api/chat/completions<br/>Authorization: Bearer (user JWT) ✅ ACS->>LLM: forward with Authorization: Bearer (LITELLM_MASTER_KEY) LLM-->>AS: completion AS-->>FE: agent response ``` ### Any related issues, documentation, discussions? Closes #5604 ### How was this PR tested? Updated `server.test.ts`: agent creation sends a minted JWT in the `Authorization` header; added cases for a missing header, a non-Bearer scheme, and an invalid token (93 tests pass via `bun test`). Also verified end-to-end on a local Kubernetes deployment (this branch's chart + agent-service image, everything else from `ghcr.io/apache` nightlies): creation without the header → 401, with the header → 200, and a websocket message produced a real `gpt-5-mini` completion through access-control-service → LiteLLM. `typecheck` and `format:check` pass; the frontend change typechecks via `tsc --noEmit`. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Fable 5 (1M context) --------- Co-authored-by: Bob Bai <[email protected]> --- agent-service/src/api/auth-api.ts | 6 ++ agent-service/src/config/env.ts | 1 - agent-service/src/server.test.ts | 74 +++++++++++------ agent-service/src/server.ts | 78 ++++++++++-------- agent-service/src/types/agent.ts | 1 - bin/k8s/templates/agent-service-deployment.yaml | 5 -- bin/k8s/templates/agent-service-secret.yaml | 11 +-- bin/k8s/values-development.yaml | 4 - bin/k8s/values.yaml | 4 - bin/single-node/.env | 1 - .../workspace/service/agent/agent.service.spec.ts | 95 ++++++++++++++++++++++ .../app/workspace/service/agent/agent.service.ts | 24 ++---- 12 files changed, 209 insertions(+), 95 deletions(-) diff --git a/agent-service/src/api/auth-api.ts b/agent-service/src/api/auth-api.ts index 087f93ac46..4a166269c7 100644 --- a/agent-service/src/api/auth-api.ts +++ b/agent-service/src/api/auth-api.ts @@ -57,6 +57,12 @@ export function validateToken(token: string): boolean { return !isTokenExpired(token); } +export function extractBearerToken(header: string | undefined): string | undefined { + if (!header) return undefined; + const [scheme, token] = header.split(" "); + return scheme?.toLowerCase() === "bearer" && token ? token : undefined; +} + export function createAuthHeaders(token: string): Record<string, string> { return { Authorization: `Bearer ${token}`, diff --git a/agent-service/src/config/env.ts b/agent-service/src/config/env.ts index 16a25b9be7..3513286b14 100644 --- a/agent-service/src/config/env.ts +++ b/agent-service/src/config/env.ts @@ -22,7 +22,6 @@ import { z } from "zod"; const EnvSchema = z.object({ PORT: z.coerce.number().default(3001), API_PREFIX: z.string().default("/api"), - LLM_API_KEY: z.string().default("dummy"), TEXERA_SERVICE_LOG_LEVEL: z .enum(["ERROR", "WARN", "INFO", "DEBUG"]) .transform(v => v.toLowerCase() as "error" | "warn" | "info" | "debug") diff --git a/agent-service/src/server.test.ts b/agent-service/src/server.test.ts index 0f618e599c..b8de8736bd 100644 --- a/agent-service/src/server.test.ts +++ b/agent-service/src/server.test.ts @@ -24,20 +24,40 @@ import { env } from "./config/env"; const API = env.API_PREFIX; const app = buildApp(); +function mintTestToken(): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + sub: "tester", + userId: 1, + email: "[email protected]", + role: "REGULAR", + exp: Math.floor(Date.now() / 1000) + 3600, + }) + ).toString("base64url"); + return `${header}.${payload}.test-signature`; +} + +const TOKEN = mintTestToken(); + function url(path: string): string { return `http://localhost${path}`; } -async function postJson(path: string, body: unknown): Promise<Response> { +async function postJson(path: string, body: unknown, headers: Record<string, string> = {}): Promise<Response> { return app.handle( new Request(url(path), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body), }) ); } +async function createAgent(body: Record<string, unknown> = {}, token: string | null = TOKEN): Promise<Response> { + return postJson(`${API}/agents`, { modelType: "m", ...body }, token ? { Authorization: `Bearer ${token}` } : {}); +} + async function patchJson(path: string, body: unknown): Promise<Response> { return app.handle( new Request(url(path), { @@ -75,8 +95,8 @@ describe(`GET ${API}/healthcheck`, () => { }); describe(`POST ${API}/agents`, () => { - test("creates an agent with no delegate", async () => { - const res = await postJson(`${API}/agents`, { modelType: "test-model", name: "Tester" }); + test("creates an agent for the delegating user", async () => { + const res = await createAgent({ modelType: "test-model", name: "Tester" }); expect(res.status).toBe(200); const agent = await readJson<{ @@ -90,12 +110,11 @@ describe(`POST ${API}/agents`, () => { expect(agent.name).toBe("Tester"); expect(agent.modelType).toBe("test-model"); expect(agent.state).toBe("AVAILABLE"); - expect(agent.delegate).toBeUndefined(); }); test("auto-numbers agent ids monotonically", async () => { - const a = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); - const b = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const a = await readJson<{ id: string }>(await createAgent()); + const b = await readJson<{ id: string }>(await createAgent()); const aNum = Number(a.id.split("-")[1]); const bNum = Number(b.id.split("-")[1]); @@ -103,20 +122,29 @@ describe(`POST ${API}/agents`, () => { }); test("rejects invalid token", async () => { - const res = await postJson(`${API}/agents`, { - modelType: "m", - userToken: "obviously-not-a-jwt", - }); + const res = await createAgent({}, "obviously-not-a-jwt"); expect(res.status).toBe(401); const body = await readJson<{ error: string }>(res); expect(body.error).toBe("Invalid or expired token"); }); + test("rejects missing Authorization header", async () => { + const res = await createAgent({}, null); + expect(res.status).toBe(401); + const body = await readJson<{ error: string }>(res); + expect(body.error).toBe("Authorization header with a Bearer token is required"); + }); + + test("rejects non-Bearer Authorization header", async () => { + const res = await postJson(`${API}/agents`, { modelType: "m" }, { Authorization: `Basic ${TOKEN}` }); + expect(res.status).toBe(401); + const body = await readJson<{ error: string }>(res); + expect(body.error).toBe("Authorization header with a Bearer token is required"); + }); + test("rejects missing modelType", async () => { - const res = await postJson(`${API}/agents`, { name: "no-model" }); - // Body schema violation; the exact status depends on the Elysia version but - // it is always a 4xx or 5xx, never a successful 2xx. - expect(res.status).toBeGreaterThanOrEqual(400); + const res = await createAgent({ modelType: undefined, name: "no-model" }); + expect(res.status).toBe(400); }); }); @@ -129,8 +157,8 @@ describe(`GET ${API}/agents`, () => { }); test("lists every created agent", async () => { - await postJson(`${API}/agents`, { modelType: "m", name: "one" }); - await postJson(`${API}/agents`, { modelType: "m", name: "two" }); + await createAgent({ name: "one" }); + await createAgent({ name: "two" }); const res = await getJson(`${API}/agents`); const body = await readJson<{ agents: { name: string }[] }>(res); @@ -141,7 +169,7 @@ describe(`GET ${API}/agents`, () => { describe(`GET ${API}/agents/:id`, () => { test("returns the agent plus its workflow snapshot", async () => { - const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const created = await readJson<{ id: string }>(await createAgent()); const res = await getJson(`${API}/agents/${created.id}`); expect(res.status).toBe(200); @@ -161,7 +189,7 @@ describe(`GET ${API}/agents/:id`, () => { describe(`DELETE ${API}/agents/:id`, () => { test("destroys the agent and a follow-up GET returns 404", async () => { - const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const created = await readJson<{ id: string }>(await createAgent()); const delRes = await del(`${API}/agents/${created.id}`); expect(delRes.status).toBe(200); @@ -179,21 +207,21 @@ describe(`DELETE ${API}/agents/:id`, () => { describe("Agent control routes", () => { test("POST /:id/stop returns stopping", async () => { - const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const created = await readJson<{ id: string }>(await createAgent()); const res = await postJson(`${API}/agents/${created.id}/stop`, {}); expect(res.status).toBe(200); expect(await readJson<unknown>(res)).toEqual({ status: "stopping" }); }); test("POST /:id/clear resets history", async () => { - const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const created = await readJson<{ id: string }>(await createAgent()); const res = await postJson(`${API}/agents/${created.id}/clear`, {}); expect(res.status).toBe(200); expect(await readJson<unknown>(res)).toEqual({ status: "cleared" }); }); test("GET /:id/operator-results returns an empty map on the framework build", async () => { - const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const created = await readJson<{ id: string }>(await createAgent()); const res = await getJson(`${API}/agents/${created.id}/operator-results`); expect(res.status).toBe(200); expect(await readJson<unknown>(res)).toEqual({ results: {} }); @@ -202,7 +230,7 @@ describe("Agent control routes", () => { describe(`PATCH ${API}/agents/:id/settings`, () => { test("updates settings and returns the new values", async () => { - const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" })); + const created = await readJson<{ id: string }>(await createAgent()); const res = await patchJson(`${API}/agents/${created.id}/settings`, { maxSteps: 7, diff --git a/agent-service/src/server.ts b/agent-service/src/server.ts index d5eeae82c9..0da3f69379 100644 --- a/agent-service/src/server.ts +++ b/agent-service/src/server.ts @@ -23,7 +23,7 @@ import { createOpenAI } from "@ai-sdk/openai"; import { TexeraAgent } from "./agent/texera-agent"; import { getVisibleResultHeaders } from "./agent/tools/tools-utility"; import { getBackendConfig } from "./api/backend-api"; -import { extractUserFromToken, validateToken } from "./api/auth-api"; +import { extractBearerToken, extractUserFromToken, validateToken } from "./api/auth-api"; import { retrieveWorkflow } from "./api/workflow-api"; import { WorkflowSystemMetadata } from "./agent/util/workflow-system-metadata"; import { env } from "./config/env"; @@ -46,15 +46,18 @@ let agentCounter = 0; async function createAgentInstance( modelType: string, - customName?: string, - delegateConfig?: AgentDelegateConfig + delegateConfig: AgentDelegateConfig, + customName?: string ): Promise<{ agentId: string; agent: TexeraAgent }> { const agentId = `agent-${++agentCounter}`; const config = getBackendConfig(); const openai = createOpenAI({ baseURL: `${config.modelsEndpoint}/api`, - apiKey: env.LLM_API_KEY, + // The LLM gateway (access-control-service) enforces a REGULAR/ADMIN-role + // JWT (apache/texera#5421) and injects the LiteLLM master key downstream, + // so the delegating user's JWT is the only credential this service sends. + apiKey: delegateConfig.userToken, }); // Reasoning effort variants are configured as separate model entries in litellm-config.yaml @@ -68,7 +71,7 @@ async function createAgentInstance( await agent.initialize(); - if (delegateConfig?.workflowId && delegateConfig.userToken) { + if (delegateConfig.workflowId) { try { const workflow = await retrieveWorkflow(delegateConfig.userToken, delegateConfig.workflowId); delegateConfig.workflowName = workflow.name; @@ -91,7 +94,7 @@ async function createAgentInstance( } agentStore.set(agentId, agent); - log.info({ agentId, delegate: !!delegateConfig }, "created agent"); + log.info({ agentId, userId: delegateConfig.userInfo?.uid }, "created agent"); return { agentId, agent }; } @@ -138,26 +141,27 @@ function getAgent(agentId: string): TexeraAgent { return agent; } +// Status codes for handler-thrown errors; anything unlisted is a 500. +const ERROR_STATUS: Record<string, number> = { + "Agent not found": 404, + "Invalid or expired token": 401, + "Authorization header with a Bearer token is required": 401, + "modelType is required": 400, +}; + const agentsRouter = new Elysia({ prefix: "/agents" }) // Error handler must live on the same Elysia instance whose routes throw, or // its scope will not see the errors. Elysia 1.x defaults to local scoping for // .onError, so attach here rather than on the outer app. - .onError(({ error, set }) => { + .onError(({ code, error, set }) => { log.error({ err: error }, "request error"); const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage === "Agent not found") { - set.status = 404; - return { error: "Agent not found" }; - } - if (errorMessage === "Invalid or expired token") { - set.status = 401; - return { error: "Invalid or expired token" }; - } - if (errorMessage === "modelType is required") { + // Body schema violations and malformed JSON are client errors, not 500s. + if (code === "VALIDATION" || code === "PARSE") { set.status = 400; - return { error: "modelType is required" }; + return { error: errorMessage || "Invalid request body" }; } - set.status = 500; + set.status = ERROR_STATUS[errorMessage] ?? 500; return { error: errorMessage || "Internal server error" }; }) .get("/", () => { @@ -167,29 +171,33 @@ const agentsRouter = new Elysia({ prefix: "/agents" }) .post( "/", - async ({ body }) => { - const { modelType, name, userToken, workflowId, computingUnitId, settings } = body as CreateAgentRequest; + async ({ body, headers }) => { + const { modelType, name, workflowId, computingUnitId, settings } = body as CreateAgentRequest; if (!modelType) { throw new Error("modelType is required"); } - let delegateConfig: AgentDelegateConfig | undefined; - if (userToken) { - if (!validateToken(userToken)) { - throw new Error("Invalid or expired token"); - } - - const userInfo = extractUserFromToken(userToken); - delegateConfig = { - userToken, - userInfo, - workflowId, - computingUnitId, - }; + // The agent always calls the LLM gateway as the delegating user, so an + // agent without a user token would be unable to generate anything. The + // token travels in the Authorization header, never in the payload. + const userToken = extractBearerToken(headers.authorization); + if (!userToken) { + throw new Error("Authorization header with a Bearer token is required"); + } + if (!validateToken(userToken)) { + throw new Error("Invalid or expired token"); } - const { agentId, agent } = await createAgentInstance(modelType, name, delegateConfig); + const userInfo = extractUserFromToken(userToken); + const delegateConfig: AgentDelegateConfig = { + userToken, + userInfo, + workflowId, + computingUnitId, + }; + + const { agentId, agent } = await createAgentInstance(modelType, delegateConfig, name); if (settings) { log.info( @@ -220,7 +228,6 @@ const agentsRouter = new Elysia({ prefix: "/agents" }) body: t.Object({ modelType: t.String(), name: t.Optional(t.String()), - userToken: t.Optional(t.String()), workflowId: t.Optional(t.Number()), computingUnitId: t.Optional(t.Number()), settings: t.Optional( @@ -630,7 +637,6 @@ function printStartupMessage(app: ReturnType<typeof buildApp>) { console.log(""); console.log("Environment:"); - console.log(` LLM_API_KEY: ${env.LLM_API_KEY === "dummy" ? "dummy (default)" : "set"}`); console.log(` LLM_ENDPOINT: ${getBackendConfig().modelsEndpoint}`); console.log(` WORKFLOW_COMPILING_SERVICE_ENDPOINT: ${getBackendConfig().compileEndpoint}`); console.log(` TEXERA_DASHBOARD_SERVICE_ENDPOINT: ${getBackendConfig().apiEndpoint}`); diff --git a/agent-service/src/types/agent.ts b/agent-service/src/types/agent.ts index 765f5a7cb4..694b51785f 100644 --- a/agent-service/src/types/agent.ts +++ b/agent-service/src/types/agent.ts @@ -147,7 +147,6 @@ export interface AgentInfo { export interface CreateAgentRequest { modelType: string; name?: string; - userToken?: string; workflowId?: number; computingUnitId?: number; settings?: AgentSettingsApi; diff --git a/bin/k8s/templates/agent-service-deployment.yaml b/bin/k8s/templates/agent-service-deployment.yaml index 5437cd7d9c..399b3a9058 100644 --- a/bin/k8s/templates/agent-service-deployment.yaml +++ b/bin/k8s/templates/agent-service-deployment.yaml @@ -56,11 +56,6 @@ spec: # computing unit id at request time. - name: EXECUTION_ENDPOINT_TEMPLATE value: http://computing-unit-{cuid}.{{ .Values.workflowComputingUnitPool.name }}-svc.{{ .Values.workflowComputingUnitPool.namespace }}.svc.cluster.local:{{ .Values.workflowComputingUnitPool.service.port }} - - name: LLM_API_KEY - valueFrom: - secretKeyRef: - name: {{ .Release.Name }}-agent-service-secret - key: llm-api-key # The service loads operator metadata from the dashboard service on # startup, so gate readiness on its health endpoint before the gateway # routes traffic here. /api/healthcheck needs no auth. diff --git a/bin/k8s/templates/agent-service-secret.yaml b/bin/k8s/templates/agent-service-secret.yaml index 61746a0aeb..abe7adb2a0 100644 --- a/bin/k8s/templates/agent-service-secret.yaml +++ b/bin/k8s/templates/agent-service-secret.yaml @@ -15,10 +15,12 @@ # specific language governing permissions and limitations # under the License. -# Shared secret for the agent service and LiteLLM. Holds the agent's gateway -# key, LiteLLM's master key, and the upstream provider API keys. Provide real -# values via `--set` or a values override file; do not commit them. -{{- if or .Values.agentService.enabled .Values.litellm.enabled }} +# Secret for the LLM gateway. Holds LiteLLM's master key (consumed by LiteLLM +# and the access-control-service, never by the agent service, which +# authenticates with the delegating user's JWT) and the upstream provider API +# keys. Provide real values via `--set` or a values override file; do not +# commit them. +{{- if .Values.litellm.enabled }} apiVersion: v1 kind: Secret metadata: @@ -26,7 +28,6 @@ metadata: namespace: {{ .Release.Namespace }} type: Opaque stringData: - llm-api-key: "{{ .Values.agentService.env.llmApiKey }}" litellm-master-key: "{{ .Values.litellm.masterKey }}" {{- range $key, $value := .Values.litellm.providerApiKeys }} {{ $key }}: "{{ $value }}" diff --git a/bin/k8s/values-development.yaml b/bin/k8s/values-development.yaml index 5537b39acc..dc7078e468 100644 --- a/bin/k8s/values-development.yaml +++ b/bin/k8s/values-development.yaml @@ -233,10 +233,6 @@ agentService: service: type: ClusterIP port: 3001 - env: - # Authenticates the agent service to the in-cluster LLM gateway - # (access-control-service / LiteLLM), not to the upstream provider. - llmApiKey: "dummy" litellm: enabled: true diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml index 32687a7ac5..2974c27c88 100644 --- a/bin/k8s/values.yaml +++ b/bin/k8s/values.yaml @@ -215,10 +215,6 @@ agentService: service: type: ClusterIP port: 3001 - env: - # Authenticates the agent service to the in-cluster LLM gateway - # (access-control-service / LiteLLM), not to the upstream provider. - llmApiKey: "dummy" litellm: enabled: true diff --git a/bin/single-node/.env b/bin/single-node/.env index 54aa2f5b32..555e14db7d 100644 --- a/bin/single-node/.env +++ b/bin/single-node/.env @@ -93,7 +93,6 @@ LITELLM_BASE_URL=http://litellm:4000 # Configurations for agent-service to connect to Texera's services LLM_ENDPOINT=http://nginx:8080 -LLM_API_KEY=dummy TEXERA_DASHBOARD_SERVICE_ENDPOINT=http://dashboard-service:8080 WORKFLOW_COMPILING_SERVICE_ENDPOINT=http://workflow-compiling-service:9090 WORKFLOW_EXECUTION_SERVICE_ENDPOINT=http://workflow-runtime-coordinator-service:8085 diff --git a/frontend/src/app/workspace/service/agent/agent.service.spec.ts b/frontend/src/app/workspace/service/agent/agent.service.spec.ts new file mode 100644 index 0000000000..cacf82c40d --- /dev/null +++ b/frontend/src/app/workspace/service/agent/agent.service.spec.ts @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { AgentService, AgentInfo } from "./agent.service"; +import { NotificationService } from "../../../common/service/notification/notification.service"; +import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { DashboardWorkflowComputingUnit } from "../../../common/type/workflow-computing-unit"; +import { commonTestProviders } from "../../../common/testing/test-utils"; + +describe("AgentService", () => { + let service: AgentService; + let httpMock: HttpTestingController; + let selectedUnit: DashboardWorkflowComputingUnit | null; + + const apiAgent = { + id: "agent-1", + name: "Bob", + modelType: "gpt-5-mini", + state: "AVAILABLE", + createdAt: "2026-06-11T00:00:00.000Z", + }; + + beforeEach(() => { + selectedUnit = null; + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + AgentService, + { provide: NotificationService, useValue: { error: () => {}, success: () => {}, info: () => {} } }, + { provide: WorkflowPersistService, useValue: {} }, + { + provide: ComputingUnitStatusService, + useValue: { getSelectedComputingUnitValue: () => selectedUnit }, + }, + ...commonTestProviders, + ], + }); + service = TestBed.inject(AgentService); + httpMock = TestBed.inject(HttpTestingController); + // The constructor syncs the local agent cache with the backend. + httpMock.expectOne(req => req.method === "GET" && req.url === "/api/agents").flush({ agents: [] }); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe("createAgent", () => { + it("creates an agent without putting the user token in the payload", () => { + let created: AgentInfo | undefined; + service.createAgent("gpt-5-mini", "Bob").subscribe(agent => (created = agent)); + + const req = httpMock.expectOne(r => r.method === "POST" && r.url === "/api/agents"); + expect(req.request.body.userToken).toBeUndefined(); + expect(req.request.body.modelType).toEqual("gpt-5-mini"); + expect(req.request.body.name).toEqual("Bob"); + expect(req.request.body.workflowId).toBeUndefined(); + expect(req.request.body.computingUnitId).toBeUndefined(); + req.flush(apiAgent); + + expect(created?.id).toEqual("agent-1"); + expect(created?.modelType).toEqual("gpt-5-mini"); + }); + + it("includes workflowId and the selected computing unit id in the payload", () => { + selectedUnit = { computingUnit: { cuid: 7 } } as unknown as DashboardWorkflowComputingUnit; + service.createAgent("gpt-5-mini", "Bob", 42).subscribe(); + + const req = httpMock.expectOne(r => r.method === "POST" && r.url === "/api/agents"); + expect(req.request.body.workflowId).toEqual(42); + expect(req.request.body.computingUnitId).toEqual(7); + expect(req.request.body.userToken).toBeUndefined(); + req.flush(apiAgent); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/agent/agent.service.ts b/frontend/src/app/workspace/service/agent/agent.service.ts index 2009734030..462e7679ce 100644 --- a/frontend/src/app/workspace/service/agent/agent.service.ts +++ b/frontend/src/app/workspace/service/agent/agent.service.ts @@ -37,7 +37,6 @@ import { import { NotificationService } from "../../../common/service/notification/notification.service"; import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; import { AppSettings } from "../../../common/app-setting"; -import { AuthService } from "../../../common/service/user/auth.service"; import { AgentState, ReActStep, ModelMessage } from "./agent-types"; import { Workflow, WorkflowContent } from "../../../common/type/workflow"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; @@ -685,31 +684,26 @@ export class AgentService { /** * Create a new agent with the specified model type. - * Uses the user's current auth token for delegate mode. + * The user's JWT travels in the Authorization header (added by the JWT + * interceptor), which the agent service requires for delegate mode. * @param modelType - The LLM model type to use * @param customName - Optional custom name for the agent * @param workflowId - Optional workflow ID for delegate mode */ public createAgent(modelType: string, customName?: string, workflowId?: number): Observable<AgentInfo> { return defer(() => { - const userToken = AuthService.getAccessToken(); - const body: any = { modelType, name: customName, }; - // Include user token and workflowId for delegate mode if available - if (userToken) { - body.userToken = userToken; - if (workflowId !== undefined) { - body.workflowId = workflowId; - } - // Include computing unit ID for workflow execution - const selectedUnit = this.computingUnitStatusService.getSelectedComputingUnitValue(); - if (selectedUnit) { - body.computingUnitId = selectedUnit.computingUnit.cuid; - } + if (workflowId !== undefined) { + body.workflowId = workflowId; + } + // Include computing unit ID for workflow execution + const selectedUnit = this.computingUnitStatusService.getSelectedComputingUnitValue(); + if (selectedUnit) { + body.computingUnitId = selectedUnit.computingUnit.cuid; } return this.http.post<ApiAgentInfo>(`${this.AGENT_API_BASE}/agents`, body).pipe(
