diff --git a/deploy/deploy.json b/deploy/deploy.json index 923ff86..cdb93b3 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -4,6 +4,19 @@ "gitopsBranch": "v0.1-gitops", "runtimePath": "deploy/gitops/g14/runtime-v01", "unideskSshEndpointEnv": { "name": "UNIDESK_MAIN_SERVER_IP", "value": "74.48.78.17" }, + "managerApiKeyEnv": { "name": "AGENTRUN_API_KEY", "secretRef": { "name": "agentrun-v01-api-key", "key": "HWLAB_API_KEY" } }, + "managerPublicExposure": { + "enabled": true, + "kind": "frp-tcp", + "name": "agentrun-v01-frpc", + "serverAddr": "74.48.78.17", + "serverPort": 7000, + "localIP": "agentrun-mgr.agentrun-v01.svc.cluster.local", + "localPort": 8080, + "remotePort": 22880, + "publicBaseUrl": "https://agentrun.74-48-78-17.nip.io/", + "masterBaseUrl": "http://127.0.0.1:22880" + }, "services": [ { "id": "agentrun-mgr", diff --git a/docs/reference/spec-v01-agentrun-mgr.md b/docs/reference/spec-v01-agentrun-mgr.md index 6d68e08..383889e 100644 --- a/docs/reference/spec-v01-agentrun-mgr.md +++ b/docs/reference/spec-v01-agentrun-mgr.md @@ -80,6 +80,12 @@ PATCH /api/v1/commands/:commandId/status 所有 API 成功和失败响应都必须是 JSON。失败响应至少包含 `failureKind`、`message` 和 trace correlation;不得出现空 stdout/空 response 被误判为成功的情况。 +### API 鉴权边界 + +`/health`、`/health/live` 和 `/health/readiness` 是公开健康探针,不要求鉴权。`/api/v1/**` 在 runtime 中必须要求 `Authorization: Bearer `,server 侧只从 `AGENTRUN_API_KEY` 或 `AGENTRUN_API_KEY_FILE` 读取期望 token;缺少 server token 时启动为本地/自测宽松模式,但 runtime Deployment 必须通过 `managerApiKeyEnv` 注入 `AGENTRUN_API_KEY`。鉴权失败返回 JSON:缺 server token 且 runtime 要求鉴权时为 `failureKind=auth-missing` / HTTP 503,客户端未带或带错 token 时为 `failureKind=auth-failed` / HTTP 401。 + +UniDesk 或其他客户端可以参考 HWLAB 的 key 发现风格,把本机 `HWLAB_API_KEY` 映射成 AgentRun REST bearer token,但这只是客户端凭据来源约定,不代表 AgentRun 依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 HWLAB 用户会话。AgentRun manager 只校验 bearer token 是否等于自身 `AGENTRUN_API_KEY`,不读取 HWLAB 的鉴权状态。 + ### v0.1.1 Session state 存储 API 在 `P1 SessionRef 持久化` 升级到「per-session RWO PVC 直接挂载」后,manager 必须提供下列受控 API 来管理 session 的 PVC 生命周期: diff --git a/docs/reference/spec-v01-cicd.md b/docs/reference/spec-v01-cicd.md index a3dc69b..d1cd576 100644 --- a/docs/reference/spec-v01-cicd.md +++ b/docs/reference/spec-v01-cicd.md @@ -37,7 +37,7 @@ | Argo CD AppProject | `argocd/agentrun-v01` | | Argo CD Application | `argocd/agentrun-g14-v01` | -公网入口暂不作为 `v0.1` 必备规格。若后续需要 UniDesk/HWLAB 跨服务入口,必须在本仓库新增受审查的 ingress/FRP/edge spec,不得临时固化 NodePort、host port、pod IP 或一次性 port-forward。 +`v0.1` manager 允许按仓库声明的 FRP TCP 暴露给 UniDesk render-only client:`deploy/deploy.json` 的 `managerPublicExposure` 是 GitOps 渲染真相,当前把 `agentrun-mgr.agentrun-v01.svc.cluster.local:8080` 通过 `agentrun-v01-frpc` 暴露到 master `127.0.0.1:22880`,公共 URL 为 `https://agentrun.74-48-78-17.nip.io/`。master 侧 frps allow port 与 Caddy vhost 由 UniDesk `config/agentrun.yaml` 和 `bun scripts/cli.ts agentrun control-plane expose --confirm` 维护,模式对齐 Sub2API;本仓库只渲染 G14 runtime 内的 frpc Deployment/ConfigMap。不得临时固化 NodePort、host port、pod IP、一次性 port-forward,或增加 HWLAB 转发层。 ## Bun + TypeScript CI 边界 @@ -117,7 +117,7 @@ Env identity 的输入至少包含:`imageRef.repoUrl`、`imageRef.commitId`、 - replica、resource request/limit、health path、ports、env key、ConfigMap/SecretRef 名称和 key。 - runtime namespace、ServiceAccount、RBAC intent、PVC intent、NetworkPolicy intent。 - Postgres 和 Code Agent provider 的 SecretRef 名称、key 名称与 mount/env intent;Secret 值不在 source 或 GitOps 中出现。 -- public/ingress intent 的声明占位;`v0.1` 默认不要求公网入口。 +- `managerPublicExposure` FRP intent,用于 UniDesk render-only client 直连 AgentRun REST;只允许声明 proxy name、FRP server、ClusterIP local target、remote port、public HTTPS URL 和 master local URL,不承载 master Caddy 配置。 禁止写入 `deploy/deploy.json` 的内容: diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index 7175be1..48632dd 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -126,6 +126,8 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 - CLI 配置必须显式校验;部署关键值不得静默 fallback。 - CLI 调用 manager REST API;不得直接连 Postgres 或读 Kubernetes Secret value。 +- CLI 可以直接连接公网 HTTPS manager,例如 `https://agentrun.74-48-78-17.nip.io/`,但它仍只是 REST client:CLI 渲染、k8s 风格命令、human 输出、分页、`--full|--raw` 渐进披露都保留在客户端;manager 只返回稳定 JSON 业务事实,不承载 UniDesk 或其他客户端的展示逻辑。 +- 调用 runtime `/api/v1/**` 时 CLI 必须发送 `Authorization: Bearer `。客户端 token 可以来自 `AGENTRUN_API_KEY`、`HWLAB_API_KEY` 或对应 `*_FILE`,但输出只能展示来源、是否存在、hash/preview 等 redacted 元数据,不得打印 token value。`/health*` 探针不要求鉴权。 - CLI 可以显示 SecretRef 名称、key、credential source、tool credential scope 和 readiness,但不得显示 Secret value、Codex `auth.json`、Codex `config.toml`、DSN password、token、SSH private key 或 URL credential。 - CLI 不得把 GitHub token、UniDesk SSH client token、provider key 或长期 SSH key 通过 `transientEnv` 传入 runner;涉及 agent shell/tool 授权时,必须先按 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md) 的 `toolCredentials` 装配路径实现,再提供 CLI 参数。非敏感服务地址例如 `UNIDESK_MAIN_SERVER_IP` 可以作为 runner-job `transientEnv` 的执行上下文注入。 - Debug 子命令可以用于开发,但综合联调和测试规格不得用 debug 子命令作为通过证据。 diff --git a/scripts/src/gitops-render.ts b/scripts/src/gitops-render.ts index 5bb6686..7dd8cec 100644 --- a/scripts/src/gitops-render.ts +++ b/scripts/src/gitops-render.ts @@ -44,6 +44,27 @@ interface EnvVarValue { value: string; } +interface SecretEnvVarValue { + name: string; + secretRef: { + name: string; + key: string; + }; +} + +interface FrpTcpExposure { + enabled: true; + kind: "frp-tcp"; + name: string; + serverAddr: string; + serverPort: number; + localIP: string; + localPort: number; + remotePort: number; + publicBaseUrl: string; + masterBaseUrl: string; +} + const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git"; const unideskSshEndpointEnvNames = new Set(["UNIDESK_MAIN_SERVER_IP", "UNIDESK_MAIN_SERVER_HOST", "UNIDESK_FRONTEND_URL"]); @@ -64,6 +85,8 @@ export async function renderGitops(options: RenderOptions): Promise const gitopsBranch = stringField(deploy, "gitopsBranch", "v0.1-gitops"); const runtimePath = stringField(deploy, "runtimePath", "deploy/gitops/g14/runtime-v01"); const unideskSshEndpointEnv = optionalUnideskSshEndpointEnv(deploy); + const managerApiKeyEnv = managerApiKeySecretEnv(deploy); + const publicExposure = optionalFrpTcpExposure(deploy); const catalog = await loadCatalog(options, gitopsBranch); const image = imageForService(catalog, "agentrun-mgr", options); @@ -77,9 +100,9 @@ export async function renderGitops(options: RenderOptions): Promise await writeFile(path.join(options.outDir, "runtime-v01", "kustomization.yaml"), kustomizationYaml()); await writeFile(path.join(options.outDir, "runtime-v01", "namespace.yaml"), namespaceYaml(runtimeNamespace)); await writeFile(path.join(options.outDir, "runtime-v01", "postgres.yaml"), postgresYaml(runtimeNamespace)); - await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit, unideskSshEndpointEnv)); + await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit, unideskSshEndpointEnv, managerApiKeyEnv, publicExposure)); await writeFile(path.join(options.outDir, "runtime-v01", "runner-rbac.yaml"), runnerRbacYaml(runtimeNamespace)); - return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: repositoryDigestForService(image), sourceCommit: options.sourceCommit, envIdentity: image.envIdentity ?? null, artifactStatus: image.status ?? null, unideskSshEndpointEnv: unideskSshEndpointEnv ? { name: unideskSshEndpointEnv.name, valuesPrinted: false } : null }; + return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: repositoryDigestForService(image), sourceCommit: options.sourceCommit, envIdentity: image.envIdentity ?? null, artifactStatus: image.status ?? null, unideskSshEndpointEnv: unideskSshEndpointEnv ? { name: unideskSshEndpointEnv.name, valuesPrinted: false } : null, managerAuth: { env: managerApiKeyEnv.name, secretRef: { name: managerApiKeyEnv.secretRef.name, key: managerApiKeyEnv.secretRef.key }, valuesPrinted: false }, publicExposure: publicExposure ? { kind: publicExposure.kind, name: publicExposure.name, remotePort: publicExposure.remotePort, masterBaseUrl: publicExposure.masterBaseUrl, publicBaseUrl: publicExposure.publicBaseUrl, valuesPrinted: false } : null }; } async function loadCatalog(options: RenderOptions, gitopsBranch: string): Promise { @@ -246,10 +269,12 @@ spec: `; } -function managerYaml(namespace: string, image: CatalogService, sourceCommit: string, unideskSshEndpointEnv: EnvVarValue | null): string { +function managerYaml(namespace: string, image: CatalogService, sourceCommit: string, unideskSshEndpointEnv: EnvVarValue | null, apiKeyEnv: SecretEnvVarValue, publicExposure: FrpTcpExposure | null): string { const imageRef = repositoryDigestForService(image); const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown"; const unideskSshEndpointEnvYaml = unideskSshEndpointEnv ? ` - name: ${unideskSshEndpointEnv.name}\n value: ${JSON.stringify(unideskSshEndpointEnv.value)}\n` : ""; + const apiKeyEnvYaml = ` - name: ${apiKeyEnv.name}\n valueFrom:\n secretKeyRef:\n name: ${apiKeyEnv.secretRef.name}\n key: ${apiKeyEnv.secretRef.key}\n`; + const frpcYaml = publicExposure ? `---\n${frpTcpExposureYaml(namespace, publicExposure)}` : ""; return `apiVersion: v1 kind: ServiceAccount metadata: @@ -322,6 +347,7 @@ spec: value: ${JSON.stringify(imageRef)} - name: AGENTRUN_RUNNER_SERVICE_ACCOUNT value: "agentrun-v01-runner" +${apiKeyEnvYaml} ${unideskSshEndpointEnvYaml} readinessProbe: httpGet: @@ -390,6 +416,70 @@ roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: agentrun-v01-mgr-provider-secret-manager +${frpcYaml} +`; +} + +function frpTcpExposureYaml(namespace: string, exposure: FrpTcpExposure): string { + const configMapName = `${exposure.name}-config`; + return `apiVersion: v1 +kind: ConfigMap +metadata: + name: ${configMapName} + namespace: ${namespace} + labels: + app.kubernetes.io/managed-by: agentrun + app.kubernetes.io/name: ${exposure.name} + app.kubernetes.io/part-of: agentrun +data: + frpc.toml: | + serverAddr = ${JSON.stringify(exposure.serverAddr)} + serverPort = ${exposure.serverPort} + loginFailExit = true + + [[proxies]] + name = ${JSON.stringify(exposure.name)} + type = "tcp" + localIP = ${JSON.stringify(exposure.localIP)} + localPort = ${exposure.localPort} + remotePort = ${exposure.remotePort} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${exposure.name} + namespace: ${namespace} + labels: + app.kubernetes.io/managed-by: agentrun + app.kubernetes.io/name: ${exposure.name} + app.kubernetes.io/part-of: agentrun +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${exposure.name} + template: + metadata: + labels: + app.kubernetes.io/managed-by: agentrun + app.kubernetes.io/name: ${exposure.name} + app.kubernetes.io/part-of: agentrun + spec: + containers: + - name: frpc + image: fatedier/frpc:v0.68.1 + imagePullPolicy: IfNotPresent + args: + - -c + - /etc/frp/frpc.toml + volumeMounts: + - name: config + mountPath: /etc/frp + readOnly: true + volumes: + - name: config + configMap: + name: ${configMapName} `; } @@ -477,3 +567,69 @@ function optionalUnideskSshEndpointEnv(deploy: JsonRecord): EnvVarValue | null { if (typeof envValue !== "string" || envValue.length === 0) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv.value must be a non-empty string", { httpStatus: 2 }); return { name, value: envValue }; } + +function managerApiKeySecretEnv(deploy: JsonRecord): SecretEnvVarValue { + const value = deploy.managerApiKeyEnv; + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentRunError("schema-invalid", "managerApiKeyEnv must be an object", { httpStatus: 2 }); + const record = value as JsonRecord; + const name = record.name; + const secretRef = record.secretRef; + if (name !== "AGENTRUN_API_KEY") throw new AgentRunError("schema-invalid", "managerApiKeyEnv.name must be AGENTRUN_API_KEY", { httpStatus: 2 }); + if (typeof secretRef !== "object" || secretRef === null || Array.isArray(secretRef)) throw new AgentRunError("schema-invalid", "managerApiKeyEnv.secretRef must be an object", { httpStatus: 2 }); + const ref = secretRef as JsonRecord; + const secretName = ref.name; + const key = ref.key; + if (typeof secretName !== "string" || !/^agentrun-v01-[a-z0-9-]+$/u.test(secretName)) throw new AgentRunError("schema-invalid", "managerApiKeyEnv.secretRef.name must be an agentrun-v01 Secret name", { httpStatus: 2 }); + if (typeof key !== "string" || key !== "HWLAB_API_KEY") throw new AgentRunError("schema-invalid", "managerApiKeyEnv.secretRef.key must be HWLAB_API_KEY", { httpStatus: 2 }); + return { name, secretRef: { name: secretName, key } }; +} + +function optionalFrpTcpExposure(deploy: JsonRecord): FrpTcpExposure | null { + const value = deploy.managerPublicExposure; + if (value === undefined) return null; + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentRunError("schema-invalid", "managerPublicExposure must be an object", { httpStatus: 2 }); + const record = value as JsonRecord; + if (record.enabled !== true) return null; + if (record.kind !== "frp-tcp") throw new AgentRunError("schema-invalid", "managerPublicExposure.kind must be frp-tcp", { httpStatus: 2 }); + const exposure: FrpTcpExposure = { + enabled: true, + kind: "frp-tcp", + name: requiredDnsLabel(record, "name"), + serverAddr: requiredNonEmptyString(record, "serverAddr"), + serverPort: requiredPort(record, "serverPort"), + localIP: requiredNonEmptyString(record, "localIP"), + localPort: requiredPort(record, "localPort"), + remotePort: requiredPort(record, "remotePort"), + publicBaseUrl: requiredHttpUrl(record, "publicBaseUrl"), + masterBaseUrl: requiredHttpUrl(record, "masterBaseUrl"), + }; + if (!exposure.localIP.endsWith(".svc.cluster.local")) throw new AgentRunError("schema-invalid", "managerPublicExposure.localIP must be a cluster service DNS name", { httpStatus: 2 }); + if (!exposure.publicBaseUrl.startsWith("https://")) throw new AgentRunError("schema-invalid", "managerPublicExposure.publicBaseUrl must use https://", { httpStatus: 2 }); + if (!exposure.masterBaseUrl.startsWith("http://127.0.0.1:")) throw new AgentRunError("schema-invalid", "managerPublicExposure.masterBaseUrl must point to the master-local FRP port", { httpStatus: 2 }); + return exposure; +} + +function requiredDnsLabel(record: JsonRecord, key: string): string { + const value = requiredNonEmptyString(record, key); + if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u.test(value)) throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be a DNS label`, { httpStatus: 2 }); + return value; +} + +function requiredNonEmptyString(record: JsonRecord, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be a non-empty string`, { httpStatus: 2 }); + return value.trim(); +} + +function requiredPort(record: JsonRecord, key: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be a TCP port`, { httpStatus: 2 }); + return value; +} + +function requiredHttpUrl(record: JsonRecord, key: string): string { + const value = requiredNonEmptyString(record, key); + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be http(s)`, { httpStatus: 2 }); + return value; +} diff --git a/src/common/types.ts b/src/common/types.ts index ede532b..10a8ad4 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -3,6 +3,8 @@ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue export type JsonRecord = { [key: string]: JsonValue }; export type FailureKind = + | "auth-missing" + | "auth-failed" | "schema-invalid" | "tenant-policy-denied" | "secret-unavailable" diff --git a/src/mgr/auth.ts b/src/mgr/auth.ts new file mode 100644 index 0000000..af12f4b --- /dev/null +++ b/src/mgr/auth.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync } from "node:fs"; +import type { IncomingMessage } from "node:http"; +import { AgentRunError } from "../common/errors.js"; +import type { JsonRecord } from "../common/types.js"; + +export interface ManagerAuthConfig { + apiKey: string | null; + source: string; + required?: boolean; +} + +export function managerServerAuthConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ManagerAuthConfig { + const direct = normalizedSecret(env.AGENTRUN_API_KEY); + if (direct) return { apiKey: direct, source: "AGENTRUN_API_KEY", required: true }; + const file = normalizedSecret(env.AGENTRUN_API_KEY_FILE); + if (file) { + const fileKey = readEnvFileSecret(file, ["AGENTRUN_API_KEY", "HWLAB_API_KEY"]); + return { apiKey: fileKey, source: "AGENTRUN_API_KEY_FILE", required: true }; + } + return { apiKey: null, source: "not-configured", required: false }; +} + +export function managerClientAuthConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ManagerAuthConfig { + const agentRunKey = normalizedSecret(env.AGENTRUN_API_KEY); + if (agentRunKey) return { apiKey: agentRunKey, source: "AGENTRUN_API_KEY" }; + const hwlabKey = normalizedSecret(env.HWLAB_API_KEY); + if (hwlabKey) return { apiKey: hwlabKey, source: "HWLAB_API_KEY" }; + const agentRunFile = normalizedSecret(env.AGENTRUN_API_KEY_FILE); + if (agentRunFile) return { apiKey: readEnvFileSecret(agentRunFile, ["AGENTRUN_API_KEY", "HWLAB_API_KEY"]), source: "AGENTRUN_API_KEY_FILE" }; + const hwlabFile = normalizedSecret(env.HWLAB_API_KEY_FILE); + if (hwlabFile) return { apiKey: readEnvFileSecret(hwlabFile, ["HWLAB_API_KEY", "AGENTRUN_API_KEY"]), source: "HWLAB_API_KEY_FILE" }; + return { apiKey: null, source: "not-configured" }; +} + +export function assertManagerRequestAuthorized(req: IncomingMessage, path: string, auth: ManagerAuthConfig): void { + if (isManagerPublicPath(path)) return; + if (!auth.apiKey) { + if (auth.required !== true) return; + throw new AgentRunError("auth-missing", "agentrun-mgr API key is not configured", { + httpStatus: 503, + details: { auth: managerAuthSummary(auth) }, + }); + } + const actual = bearerToken(req.headers.authorization); + if (actual !== auth.apiKey) { + throw new AgentRunError("auth-failed", "agentrun-mgr API requires Authorization: Bearer ", { + httpStatus: 401, + details: { auth: managerAuthSummary(auth) }, + }); + } +} + +export function managerAuthSummary(auth: ManagerAuthConfig): JsonRecord { + return { + required: auth.required === true || auth.apiKey !== null, + scheme: "Bearer", + source: auth.source, + valuesPrinted: false, + }; +} + +export function managerAuthorizationHeader(apiKey: string | null): string | null { + return apiKey ? `Bearer ${apiKey}` : null; +} + +function isManagerPublicPath(path: string): boolean { + return path === "/health" || path === "/health/live" || path === "/health/readiness"; +} + +function bearerToken(value: string | string[] | undefined): string | null { + const header = Array.isArray(value) ? value[0] : value; + if (!header) return null; + const match = header.match(/^Bearer\s+(.+)$/iu); + return match ? normalizedSecret(match[1]) : null; +} + +function readEnvFileSecret(file: string, keys: string[]): string | null { + if (!existsSync(file)) return null; + const text = readFileSync(file, "utf8"); + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); + if (!match || !keys.includes(match[1] ?? "")) continue; + return normalizedSecret(unquoteEnvValue(match[2] ?? "")); + } + return null; +} + +function unquoteEnvValue(value: string): string { + const trimmed = value.trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function normalizedSecret(value: string | undefined | null): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/mgr/client.ts b/src/mgr/client.ts index 6905c0b..5144c2d 100644 --- a/src/mgr/client.ts +++ b/src/mgr/client.ts @@ -1,8 +1,13 @@ import type { JsonRecord, JsonValue } from "../common/types.js"; import { AgentRunError } from "../common/errors.js"; +import { managerAuthorizationHeader, managerClientAuthConfigFromEnv } from "./auth.js"; + +export interface ManagerClientOptions { + apiKey?: string | null; +} export class ManagerClient { - constructor(readonly baseUrl: string) {} + constructor(readonly baseUrl: string, readonly options: ManagerClientOptions = {}) {} async get(path: string): Promise { return this.request("GET", path); @@ -25,9 +30,12 @@ export class ManagerClient { } private async request(method: string, path: string, body?: JsonValue): Promise { - const init: RequestInit = { method }; + const headers: Record = {}; + const authHeader = managerAuthorizationHeader(this.options.apiKey === undefined ? managerClientAuthConfigFromEnv().apiKey : this.options.apiKey); + if (authHeader) headers.authorization = authHeader; + const init: RequestInit = { method, headers }; if (body !== undefined) { - init.headers = { "content-type": "application/json" }; + headers["content-type"] = "application/json"; init.body = JSON.stringify(body); } const response = await fetch(new URL(path, this.baseUrl), init); diff --git a/src/mgr/server.ts b/src/mgr/server.ts index fd6dc46..5b00818 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -14,6 +14,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js"; import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js"; import type { SessionPvcSummary } from "./session-pvc.js"; import type { SessionPvcOptions } from "./session-pvc.js"; +import { assertManagerRequestAuthorized, managerAuthSummary, managerServerAuthConfigFromEnv, type ManagerAuthConfig } from "./auth.js"; import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js"; import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js"; import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.js"; @@ -53,6 +54,7 @@ export interface ManagerServerOptions { providerProfileOptions?: { namespace?: string; kubectlCommand?: string }; toolCredentialOptions?: { namespace?: string; kubectlCommand?: string }; aipodSpecDir?: string; + auth?: ManagerAuthConfig; } export interface StartedManagerServer { @@ -69,12 +71,15 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr const providerProfileDefaults = options.providerProfileOptions; const toolCredentialDefaults = options.toolCredentialOptions; const aipodSpecDir = options.aipodSpecDir ?? process.env.AGENTRUN_AIPOD_SPEC_DIR; + const auth = options.auth ?? managerServerAuthConfigFromEnv(); + const authSummary = managerAuthSummary(auth); const server = createServer(async (req, res) => { const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; try { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://agentrun.local"); - const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}), ...(toolCredentialDefaults ? { toolCredentialDefaults } : {}), ...(aipodSpecDir ? { aipodSpecDir } : {}) }); + assertManagerRequestAuthorized(req, url.pathname, auth); + const data = await route({ method, url, body: await readBody(req), store, sourceCommit, authSummary, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}), ...(toolCredentialDefaults ? { toolCredentialDefaults } : {}), ...(aipodSpecDir ? { aipodSpecDir } : {}) }); writeJson(res, 200, { ok: true, data, traceId }); } catch (error) { const agentError = normalizeError(error); @@ -237,12 +242,12 @@ function compactRecoveryActions(value: JsonValue | undefined): JsonValue[] { }); } -async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults, toolCredentialDefaults, aipodSpecDir }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable; sessionPvcDefaults?: NonNullable; providerProfileDefaults?: NonNullable; toolCredentialDefaults?: NonNullable; aipodSpecDir?: string }): Promise { +async function route({ method, url, body, store, sourceCommit, authSummary, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults, toolCredentialDefaults, aipodSpecDir }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; authSummary?: JsonRecord; runnerJobDefaults?: NonNullable; sessionPvcDefaults?: NonNullable; providerProfileDefaults?: NonNullable; toolCredentialDefaults?: NonNullable; aipodSpecDir?: string }): Promise { const path = url.pathname; if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) { const database = await store.health(); const ready = path === "/health/live" ? true : database.ready; - return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, runnerWorkReady: staticWorkReadyCapabilitySummary(), secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } }; + return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, auth: authSummary ?? null, runnerWorkReady: staticWorkReadyCapabilitySummary(), secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } }; } if (method === "GET" && path === "/api/v1/backends") return await listBackendCapabilities(providerProfileDefaults) as JsonValue; if (method === "GET" && path === "/api/v1/tool-credentials") return await listToolCredentials(toolCredentialDefaults) as JsonValue; diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index d860e28..3beea4f 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -231,6 +231,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile); return dedupeEnvVars([ { name: "AGENTRUN_MGR_URL", value: options.managerUrl }, + { name: "AGENTRUN_API_KEY", valueFrom: { secretKeyRef: { name: "agentrun-v01-api-key", key: "HWLAB_API_KEY" } } }, { name: "AGENTRUN_RUN_ID", value: options.run.id }, { name: "AGENTRUN_COMMAND_ID", value: options.commandId }, { name: "AGENTRUN_ATTEMPT_ID", value: context.attemptId }, diff --git a/src/selftest/cases/10-manager-memory.ts b/src/selftest/cases/10-manager-memory.ts index 4a022d7..0334ec9 100644 --- a/src/selftest/cases/10-manager-memory.ts +++ b/src/selftest/cases/10-manager-memory.ts @@ -18,13 +18,36 @@ const selfTest: SelfTestCase = async () => { assert.equal(((health.runnerWorkReady as { valuesPrinted?: unknown } | undefined)?.valuesPrinted), false); assert.ok((((health.runnerWorkReady as { requiredImageTools?: string[] } | undefined)?.requiredImageTools) ?? []).includes("npm")); assert.equal(health.secretRefs?.valuesPrinted, false); + await assertManagerAuthBoundary(); await assertLongResultUsesTerminalAssistant(client, store); - return { name: "manager-memory", tests: ["manager-memory-lifecycle", "manager-result-long-trace"] }; + return { name: "manager-memory", tests: ["manager-memory-lifecycle", "manager-auth-boundary", "manager-result-long-trace"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } }; +async function assertManagerAuthBoundary(): Promise { + const store = new MemoryAgentRunStore(); + const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store, auth: { apiKey: "self-test-secret", source: "self-test" } }); + try { + const healthResponse = await fetch(new URL("/health/readiness", server.baseUrl)); + assert.equal(healthResponse.status, 200); + const healthEnvelope = await healthResponse.json() as JsonRecord; + assert.equal(healthEnvelope.ok, true); + const deniedResponse = await fetch(new URL("/api/v1/queue/tasks", server.baseUrl)); + assert.equal(deniedResponse.status, 401); + const deniedEnvelope = await deniedResponse.json() as JsonRecord; + assert.equal(deniedEnvelope.ok, false); + assert.equal(deniedEnvelope.failureKind, "auth-failed"); + assert.equal((deniedEnvelope.message as string).includes("Bearer"), true); + const client = new ManagerClient(server.baseUrl, { apiKey: "self-test-secret" }); + const page = await client.get("/api/v1/queue/tasks") as JsonRecord; + assert.equal(Array.isArray(page.items), true); + } finally { + await new Promise((resolve) => server.server.close(() => resolve())); + } +} + async function assertLongResultUsesTerminalAssistant(client: ManagerClient, store: MemoryAgentRunStore): Promise { const run = store.createRun({ tenantId: "unidesk", diff --git a/src/selftest/harness.ts b/src/selftest/harness.ts index e4fd235..3d2519d 100644 --- a/src/selftest/harness.ts +++ b/src/selftest/harness.ts @@ -33,6 +33,10 @@ type SelfTestRunContext = Pick & Par export async function createSelfTestContext(root: string): Promise { const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-")); const previousSelftestWorkReadyBinPath = process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH; + const previousAgentRunApiKey = process.env.AGENTRUN_API_KEY; + const previousAgentRunApiKeyFile = process.env.AGENTRUN_API_KEY_FILE; + delete process.env.AGENTRUN_API_KEY; + delete process.env.AGENTRUN_API_KEY_FILE; const codexHome = path.join(tmp, "codex-home"); const deepseekHome = path.join(tmp, "deepseek-home"); const minimaxM3Home = path.join(tmp, "minimax-m3-home"); @@ -67,6 +71,10 @@ export async function createSelfTestContext(root: string): Promise { if (previousSelftestWorkReadyBinPath === undefined) delete process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH; else process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH = previousSelftestWorkReadyBinPath; + if (previousAgentRunApiKey === undefined) delete process.env.AGENTRUN_API_KEY; + else process.env.AGENTRUN_API_KEY = previousAgentRunApiKey; + if (previousAgentRunApiKeyFile === undefined) delete process.env.AGENTRUN_API_KEY_FILE; + else process.env.AGENTRUN_API_KEY_FILE = previousAgentRunApiKeyFile; await rm(tmp, { recursive: true, force: true }); }, };