Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM jumpserver/kael-base:20260909_014534 AS stage-build
FROM jumpserver/kael-base:20260920_104824 AS stage-build
ARG TARGETARCH

WORKDIR /opt/kael
Expand Down
2 changes: 1 addition & 1 deletion cmd/kael/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func run(settings config.Config, logger *zap.Logger) error {
bus := event.NewBus()
var capability ports.CapabilityProvider
if settings.PlatformGatewayEnabled {
capability, err = platformgateway.New(platformgateway.Config{CoreURL: settings.CoreHost, CoreTLSVerify: tlsVerify, DelegationKey: settings.PlatformDelegationKey, DelegationKeyID: settings.PlatformDelegationID, Issuer: settings.PlatformIssuer, Audience: settings.PlatformAudience, CACert: settings.PlatformCACert, ClientCert: settings.PlatformClientCert, ClientKey: settings.PlatformClientKey, AllowedMethods: settings.PlatformAllowedMethods, RegistryTTL: settings.PlatformRegistryTTL, Timeout: settings.PlatformTimeout, MaxResponse: settings.PlatformMaxResponse, OpenAPILoader: componentClient.OpenAPISchema})
capability, err = platformgateway.New(platformgateway.Config{CoreURL: settings.CoreHost, CoreTLSVerify: tlsVerify, CACert: settings.PlatformCACert, ClientCert: settings.PlatformClientCert, ClientKey: settings.PlatformClientKey, AllowedMethods: settings.PlatformAllowedMethods, RegistryTTL: settings.PlatformRegistryTTL, Timeout: settings.PlatformTimeout, MaxResponse: settings.PlatformMaxResponse, OpenAPILoader: componentClient.OpenAPISchema})
if err != nil {
return err
}
Expand Down
19 changes: 5 additions & 14 deletions config_example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,12 @@ BOOTSTRAP_TOKEN: <PleaseChangeMe>

# TRUST_FORWARDED_HEADERS: false

# Platform Gateway 默认且必须启用,因为 Lina 默认 general 依赖它访问授权 Core API;
# 显式关闭或密钥留空时 Kael 会在监听端口前失败,不会以“ready 但 general 不可用”运行。
# PLATFORM_DELEGATION_KEY 至少 32 字符,并与 Core CHAT_AI_DELEGATION_SECRET 完全一致。
# PLATFORM_DELEGATION_KEY_ID 对应 Core CHAT_AI_DELEGATION_KEY_ID;
# PLATFORM_DELEGATION_ISSUER 对应 Core CHAT_AI_DELEGATION_ISSUER;
# PLATFORM_DELEGATION_AUDIENCE 对应 Core CHAT_AI_DELEGATION_AUDIENCE。
# 启用前还必须确认 Core 已应用 Runtime Store migration、Redis 可用于 delegation nonce
# 防重放,且 /api/swagger.json 可访问并为候选操作提供 x-jms-required-permissions 与
# x-jms-permission-dynamic 元数据。
# Platform Gateway 默认且必须启用,因为 Lina 默认 general 依赖它访问授权 Core API。
# 业务请求沿用用户 Cookie(或已有 Authorization),写请求同时携带 CSRF token。
# 用户凭据只按 Run 保存在进程内存中,不写入 Journal 或模型输入。
# 启用前必须确认 Core 已应用 Runtime Store migration,且组件签名可访问
# /api/swagger.json,并提供 x-jms-required-permissions 与 x-jms-permission-dynamic。
PLATFORM_GATEWAY_ENABLED: true
# 此处故意留空以强制部署者配置;不要在镜像或仓库中内置共享密钥。
PLATFORM_DELEGATION_KEY: ""
# PLATFORM_DELEGATION_KEY_ID: v1
# PLATFORM_DELEGATION_ISSUER: jumpserver-ai
# PLATFORM_DELEGATION_AUDIENCE: jumpserver-core
# PLATFORM_CA_CERT: ""
# PLATFORM_CLIENT_CERT: ""
# PLATFORM_CLIENT_KEY: ""
Expand Down
16 changes: 8 additions & 8 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| [internal/runtime](../internal/runtime/harness.go) | 通过 stdio JSON-RPC 管理 Codex App Server、上下文、动态工具和回调 |
| [internal/model](../internal/model/types.go) | 模型配置、消息、usage 和错误值类型 |
| [internal/policy](../internal/policy/profiles.go) | Profile、工具风险、审批模式和 shell 参数级只读判定 |
| [internal/platformgateway](../internal/platformgateway/gateway.go) | Core OpenAPI Registry、Operation 筛选、请求构建、委托与结果脱敏 |
| [internal/platformgateway](../internal/platformgateway/gateway.go) | Core OpenAPI Registry、Operation 筛选、请求构建、用户凭据转发与结果脱敏 |
| [internal/ports](../internal/ports/store.go) | Store/Tx 和 CapabilityProvider 接口 |
| [internal/store](../internal/store/core.go) | 内存事务、Core 历史 Journal、Terminal 本地 JSONL 与保留策略 |
| [internal/event](../internal/event/bus.go) | DomainEvent、PanelDelivery 投影及提交后的订阅通知 |
Expand Down Expand Up @@ -79,15 +79,15 @@ Core TerminalConfig 的 `CHAT_AI_*` 是模型配置与凭据来源。每次 Run

每个缓存会话使用私有 HOME、CODEX_HOME 和空工作目录,不继承用户登录、插件、MCP 配置和应用 Secret。线程使用 `ephemeral=true`、`environments=[]`,禁用 shell、unified exec、Code Mode、浏览器、computer use、联网搜索、hooks、apps 和 subagents 等能力。

业务工具以 `kael_` 安全别名暴露为 dynamic tools。Kael 校验 thread、turn、Registration 与输入输出 schema,再进入业务审批和执行通道。相同 callId 的相同重投复用回执,修改参数则失败;同一 turn 内相同写操作不自动重复执行。成功的 final-result 工具之后拒绝后续业务工具并要求模型总结。
业务工具以 `kael_` 安全别名暴露为 dynamic tools。Kael 校验 thread、turn、Registration 与输入输出 schema,再进入业务审批和执行通道。相同 callId 的相同重投复用回执,修改参数则失败;同一 turn 内相同写操作不自动重复执行。Service 调用使用执行端解析的实际 Operation 风险,允许重复只读查询;Core 写入的去重摘要不包含 `progress`、`action` 等展示文案。成功的 final-result 工具之后拒绝后续业务工具并要求模型总结。

未集成的问询表单返回空答案,提示模型在普通对话中提问;未知 host request 拒绝执行。子进程 stderr 不直接进入业务错误或日志。

### 上下文与会话复用

同一用户、组织、Conversation、Panel,模型配置、Profile 指令、工具注册未变且历史仍为追加关系时,复用进程内 Codex thread,只提交新增历史和本轮 Context。历史变化、能力变更、模型配置变化或上次执行失败会使缓存失效;新线程从 Kael 的业务历史构建输入。

Context 是不可信数据,不构成权限或指令。`response_language` 只接受 `zh`、`zh_hant`、`en`、`ja`、`pt_br`、`es`、`ru`、`ko`、`vi`,映射为固定语言名称后附于本轮输入。用户明确指定语言时优先遵循;字段缺失或无效时跟随最新问题,无法判断时使用英语。语言变化不改变 thread 复用签名,当前 Run 仍使用已冻结快照。
Context 是不可信数据,不构成权限或指令。`response_language` 只接受 `zh`、`zh_hant`、`en`、`ja`、`pt_br`、`es`、`ru`、`ko`、`vi`,映射为固定语言名称后附于本轮输入。用户明确指定语言时优先遵循;字段缺失或无效时跟随最新问题,无法判断时使用英语。语言偏好覆盖工具调用前说明、进度、工具参数中的展示文案、提问及最终回答,API 描述与工具输出不改变该偏好。语言变化不改变 thread 复用签名,当前 Run 仍使用已冻结快照。

输入上限为 4 MiB,超限明确报错,不按固定历史条数静默裁剪;上下文压缩由 Codex 负责。同一 turn 最多处理 128 个动态工具请求。最多缓存 16 个 Panel 进程,空闲超过 5 分钟回收;容量满时优先回收空闲进程。

Expand Down Expand Up @@ -130,13 +130,13 @@ Gateway 通过组件身份加载 Core OpenAPI,按内容 hash 版本化,缓

Method 和 URL 由可信 Registry 构建。默认允许 `GET/POST/PUT/PATCH`,`DELETE` 需配置显式启用。`general` 使用源码内固定 Operation 范围,asset/audit/ops 进一步收窄,management 为管理员提供较宽范围;Kael 不读取 Core 自定义 Operation allowlist 配置。

搜索和调用使用相同权限筛选:读取 `x-jms-required-permissions`、`x-jms-permission-dynamic`,缺失、非法或 dynamic 元数据均拒绝,Principal 必须具备全部静态权限。Run 保留创建时权限快照用于一致选择,Core 对委托请求仍执行实时 RBAC。
搜索和调用使用相同权限筛选:读取 `x-jms-required-permissions`、`x-jms-permission-dynamic`,缺失、非法或 dynamic 元数据均拒绝,Principal 必须具备全部静态权限。Run 保留创建时权限快照用于一致选择,Core 对用户凭据认证的业务请求仍执行实时 RBAC。

Gateway 解析引用、移除请求 schema 的 `readOnly` 字段并规范化 required/nullable,验证参数及 query 序列化,拒绝敏感路径与字段。参数错误可作为结构化结果返回模型修正。

业务请求使用短期一次性 HMAC 委托,绑定 user/org、Conversation、Approval、Operation、Method、Path、query/body hash、issuer/audience/key ID、时间和 nonce。用户 Cookie/Bearer 仅用于身份查询,不用于 Gateway 业务请求;Core 负责验签、防重放与最终授权。
业务请求沿用发起 Run 的用户 Cookie(或已有 Authorization),Cookie 写请求同时携带 CSRF token;组织头来自已验证的 Principal。凭据只按 Run 保存在当前进程内存中,不进入 Journal、工具参数、模型输入或审计;创建、重新生成及显式恢复 Run 时从已认证请求绑定,运行结束、取消或服务关闭后清理。Gateway 不再需要平台委托共享密钥,Core 使用现有用户认证、CSRF 和 RBAC 校验。

Service 写操作必须经过独立 Approval,不受 Panel 的 never 模式豁免;执行前重新校验请求和原审批绑定。HTTP 默认超时 15 秒、响应上限 1 MiB,结果限长、脱敏后写入 ToolResult、结果卡片及审计。Gateway 不继承进程代理,支持私有 CA 与客户端证书。
Service 写操作必须经过独立 Approval,不受 Panel 的 never 模式豁免;执行前重新校验请求和原审批绑定。HTTP 默认超时 15 秒、响应上限 1 MiB,结果限长、脱敏后写入 ToolResult、结果卡片及审计。凭据缺失、CSRF 失败、连接失败与超时返回独立错误码,并记录脱敏诊断。Gateway 不继承进程代理、不跟随重定向,支持私有 CA 与客户端证书。

## 5. HTTP 与事件协议

Expand Down Expand Up @@ -230,11 +230,11 @@ Terminal 本地历史默认保留 7 天、容量上限 1 GiB、磁盘最低余

### 身份和部署入口

业务请求要求 `X-JMS-ORG`。Kael 使用请求 Cookie/Bearer 向 Core 的 profile 与 permissions 接口验证用户,每次请求重新取得权限;除 superuser 外要求 `chat_ai.use_chatai`。会话及关联资源按用户、组织校验所有权,管理接口另行校验管理员权限。
业务请求要求 `X-JMS-ORG`。Kael 使用请求 Cookie/Bearer 向 Core 的 profile 与 permissions 接口验证用户,每次请求重新取得权限;除 superuser 外要求 `chat_ai.use_chatai`。带 Authorization 时仅使用该头认证,不回退到 Cookie;鉴权请求不跟随重定向。会话及关联资源按用户、组织校验所有权,管理接口另行校验管理员权限。

Origin 校验默认关闭;只有 `ALLOWED_ORIGINS` 包含非空值时启用,允许精确列表或当前同源 Origin,不发送 CORS 响应头。Cookie 写请求另行校验 CSRF。网关终止 HTTPS 时可配置外部 Origin;`TRUST_FORWARDED_HEADERS` 默认关闭,仅在可信网关覆盖 forwarded headers 且 Kael 端口不直接暴露时使用。

Kael 不直接连接业务数据库。首次通过 BootstrapToken 注册 `kael` 组件,后续使用私有 AccessKey 文件。Platform Gateway 是必需依赖:`PLATFORM_GATEWAY_ENABLED` 必须为 true,delegation key 去除首尾空白后至少 32 字符且与 Core 匹配,Registry 初始化失败会阻止监听端口。
Kael 不直接连接业务数据库。首次通过 BootstrapToken 注册 `kael` 组件,后续使用私有 AccessKey 文件。Platform Gateway 是必需依赖:`PLATFORM_GATEWAY_ENABLED` 必须为 true,组件签名必须可访问 Core OpenAPI;不再配置 `PLATFORM_DELEGATION_KEY` 等委托参数。Registry 初始化失败会阻止监听端口。

### 配置与启动

Expand Down
10 changes: 7 additions & 3 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func New(options Options) (*Server, error) {
gin.SetMode(gin.ReleaseMode)
server := &Server{service: options.Service, authenticator: options.Authenticator, origin: options.Origin, logger: options.Logger}
engine := gin.New()
// Handlers that pass Gin's context must still propagate request values,
// cancellation and deadlines to the service layer.
engine.ContextWithFallback = true
engine.RedirectTrailingSlash = false
engine.RedirectFixedPath = false
engine.Use(server.requestID(), gin.Recovery())
Expand Down Expand Up @@ -153,6 +156,7 @@ func (s *Server) authorize() gin.HandlerFunc {
return
}
c.Set(principalKey, principal)
c.Request = c.Request.WithContext(identity.WithCoreCredentials(c.Request.Context(), identity.CredentialsFromRequest(c.Request)))
c.Next()
}
}
Expand Down Expand Up @@ -311,7 +315,7 @@ func (s *Server) regenerate(c *gin.Context) {
if !s.bind(c, &request) {
return
}
value, err := s.service.Regenerate(c, principal(c), c.Param("id"), request.PanelSessionID)
value, err := s.service.Regenerate(c.Request.Context(), principal(c), c.Param("id"), request.PanelSessionID)
if err != nil {
s.writeError(c, err)
return
Expand Down Expand Up @@ -479,7 +483,7 @@ func (s *Server) createRun(c *gin.Context) {
if !s.bind(c, &request) {
return
}
value, err := s.service.CreateRun(c, principal(c), request)
value, err := s.service.CreateRun(c.Request.Context(), principal(c), request)
if err != nil {
s.writeError(c, err)
return
Expand Down Expand Up @@ -523,7 +527,7 @@ func (s *Server) cancelRun(c *gin.Context) {
}

func (s *Server) resumeRun(c *gin.Context) {
value, err := s.service.ResumeRun(c, principal(c), c.Param("id"))
value, err := s.service.ResumeRun(c.Request.Context(), principal(c), c.Param("id"))
if err != nil {
s.writeError(c, err)
return
Expand Down
16 changes: 1 addition & 15 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ type Config struct {
TerminalAIMaxBytes int64
TerminalAIMinFreeBytes int64
PlatformGatewayEnabled bool
PlatformDelegationKey string
PlatformDelegationID string
PlatformIssuer string
PlatformAudience string
PlatformCACert string
PlatformClientCert string
PlatformClientKey string
Expand Down Expand Up @@ -84,10 +80,6 @@ func Load(path string) (Config, error) {
TerminalAIMaxBytes: v.GetInt64("TERMINAL_AI_MAX_BYTES"),
TerminalAIMinFreeBytes: v.GetInt64("TERMINAL_AI_MIN_FREE_BYTES"),
PlatformGatewayEnabled: v.GetBool("PLATFORM_GATEWAY_ENABLED"),
PlatformDelegationKey: strings.TrimSpace(v.GetString("PLATFORM_DELEGATION_KEY")),
PlatformDelegationID: strings.TrimSpace(v.GetString("PLATFORM_DELEGATION_KEY_ID")),
PlatformIssuer: strings.TrimSpace(v.GetString("PLATFORM_DELEGATION_ISSUER")),
PlatformAudience: strings.TrimSpace(v.GetString("PLATFORM_DELEGATION_AUDIENCE")),
PlatformCACert: v.GetString("PLATFORM_CA_CERT"),
PlatformClientCert: v.GetString("PLATFORM_CLIENT_CERT"),
PlatformClientKey: v.GetString("PLATFORM_CLIENT_KEY"),
Expand Down Expand Up @@ -144,9 +136,6 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("TERMINAL_AI_MAX_BYTES", int64(1<<30))
v.SetDefault("TERMINAL_AI_MIN_FREE_BYTES", int64(1<<30))
v.SetDefault("PLATFORM_GATEWAY_ENABLED", true)
v.SetDefault("PLATFORM_DELEGATION_KEY_ID", "v1")
v.SetDefault("PLATFORM_DELEGATION_ISSUER", "jumpserver-ai")
v.SetDefault("PLATFORM_DELEGATION_AUDIENCE", "jumpserver-core")
v.SetDefault("PLATFORM_ALLOWED_METHODS", []string{"GET", "POST", "PUT", "PATCH"})
v.SetDefault("PLATFORM_REGISTRY_TTL", "1h")
v.SetDefault("PLATFORM_TIMEOUT", "15s")
Expand Down Expand Up @@ -189,10 +178,7 @@ func (c Config) Validate() error {
if !c.PlatformGatewayEnabled {
return fmt.Errorf("PLATFORM_GATEWAY_ENABLED must be true because the default general assistant requires the Platform Gateway")
}
if len(c.PlatformDelegationKey) < 32 {
return fmt.Errorf("PLATFORM_DELEGATION_KEY must contain at least 32 characters after trimming surrounding whitespace and match Core")
}
if c.PlatformDelegationID == "" || c.PlatformIssuer == "" || c.PlatformAudience == "" || c.PlatformRegistryTTL <= 0 || c.PlatformTimeout <= 0 || c.PlatformMaxResponse < 1 {
if c.PlatformRegistryTTL <= 0 || c.PlatformTimeout <= 0 || c.PlatformMaxResponse < 1 {
return fmt.Errorf("Platform Gateway configuration is incomplete")
}
if (c.PlatformClientCert == "") != (c.PlatformClientKey == "") {
Expand Down
3 changes: 1 addition & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestLoadFlatKokoStyleEnvironment(t *testing.T) {

func TestLoadDiscoversFlatConfigAndDerivesDataPaths(t *testing.T) {
dir := t.TempDir()
content := "CORE_HOST: https://core.example.test\nNAME: kael-test\nHTTPD_PORT: 9083\nPLATFORM_DELEGATION_KEY: test-only-delegation-key-00000000\n"
content := "CORE_HOST: https://core.example.test\nNAME: kael-test\nHTTPD_PORT: 9083\n"
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -92,7 +92,6 @@ func TestTerminalHistoryRetentionConfig(t *testing.T) {

func writeConfig(t *testing.T, content string) string {
t.Helper()
t.Setenv("PLATFORM_DELEGATION_KEY", "test-only-delegation-key-00000000")
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
Expand Down
71 changes: 71 additions & 0 deletions internal/identity/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package identity

import (
"context"
"net/http"
"strings"
)

// CoreCredentials stays in memory and is never part of a persisted Principal,
// Run, tool argument or model input. Its private fields cannot be JSON encoded.
type CoreCredentials struct {
cookie string
authorization string
csrfToken string
}

func (CoreCredentials) String() string { return "[REDACTED]" }
func (CoreCredentials) GoString() string { return "[REDACTED]" }

// CredentialsFromRequest must only be used after authenticating the request
// and checking its origin and CSRF token.
func CredentialsFromRequest(request *http.Request) CoreCredentials {
// Match CoreAuthenticator: use exactly the authentication mechanism that
// was verified, without a cookie fallback for Authorization headers.
if authorization := strings.TrimSpace(request.Header.Get("Authorization")); authorization != "" {
return CoreCredentials{authorization: authorization}
}
csrfToken := strings.TrimSpace(request.Header.Get("X-CSRFToken"))
if csrfToken == "" {
csrfToken = strings.TrimSpace(request.Header.Get("X-CSRF-Token"))
}
return CoreCredentials{
cookie: strings.Join(request.Header.Values("Cookie"), "; "),
csrfToken: csrfToken,
}
}

type coreCredentialsKey struct{}

func WithCoreCredentials(ctx context.Context, credentials CoreCredentials) context.Context {
return context.WithValue(ctx, coreCredentialsKey{}, credentials)
}

func CoreCredentialsFromContext(ctx context.Context) CoreCredentials {
credentials, _ := ctx.Value(coreCredentialsKey{}).(CoreCredentials)
return credentials
}

// Apply authenticates a request to the configured Core endpoint. The caller
// must disable redirects so credentials cannot be sent to another endpoint.
func (c CoreCredentials) Apply(request *http.Request) error {
if c.cookie == "" && c.authorization == "" {
return ErrUnauthenticated
}
if c.cookie != "" {
request.Header.Set("Cookie", c.cookie)
}
if c.authorization != "" {
request.Header.Set("Authorization", c.authorization)
}
if c.csrfToken != "" {
request.Header.Set("X-CSRFToken", c.csrfToken)
}
if err := VerifyCSRF(request); err != nil {
return err
}
// Kael already checked the browser request's origin. Core sees this
// server-to-server request at its own origin, including over HTTPS.
request.Header.Set("Referer", request.URL.Scheme+"://"+request.URL.Host+"/")
return nil
}
Loading