fix: 支持 AgentRun render-only REST 入口 (#170)
Co-authored-by: AgentRun Codex <agentrun-codex@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 <token>`,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 生命周期:
|
||||
|
||||
@@ -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` 的内容:
|
||||
|
||||
|
||||
@@ -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>`。客户端 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 子命令作为通过证据。
|
||||
|
||||
@@ -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<JsonRecord>
|
||||
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<JsonRecord>
|
||||
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<ArtifactCatalog> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+102
@@ -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 <redacted>", {
|
||||
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;
|
||||
}
|
||||
+11
-3
@@ -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<JsonValue> {
|
||||
return this.request("GET", path);
|
||||
@@ -25,9 +30,12 @@ export class ManagerClient {
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body?: JsonValue): Promise<JsonValue> {
|
||||
const init: RequestInit = { method };
|
||||
const headers: Record<string, string> = {};
|
||||
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);
|
||||
|
||||
+8
-3
@@ -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<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]>; toolCredentialDefaults?: NonNullable<ManagerServerOptions["toolCredentialOptions"]>; aipodSpecDir?: string }): Promise<JsonValue> {
|
||||
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<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]>; toolCredentialDefaults?: NonNullable<ManagerServerOptions["toolCredentialOptions"]>; aipodSpecDir?: string }): Promise<JsonValue> {
|
||||
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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
};
|
||||
|
||||
async function assertManagerAuthBoundary(): Promise<void> {
|
||||
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<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
async function assertLongResultUsesTerminalAssistant(client: ManagerClient, store: MemoryAgentRunStore): Promise<void> {
|
||||
const run = store.createRun({
|
||||
tenantId: "unidesk",
|
||||
|
||||
@@ -33,6 +33,10 @@ type SelfTestRunContext = Pick<SelfTestContext, "workspace" | "codexHome"> & Par
|
||||
export async function createSelfTestContext(root: string): Promise<SelfTestContext> {
|
||||
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<SelfTestConte
|
||||
cleanup: async () => {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user