This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/release/v1.2 by this push:
new bad1ae8203 fix(agent-service): authenticate to the LLM proxy as the
delegating user (#5605)
bad1ae8203 is described below
commit bad1ae820389418f756556cde10a9298a7aefd92
Author: Jiadong Bai <[email protected]>
AuthorDate: Thu Jun 11 06:45:19 2026 +0000
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)
---------
(backported from commit ae3128d999c48d0f014be2eea2eb4fc0f4ee9e66)
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 806989450f..313930d10a 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(