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",
|
"gitopsBranch": "v0.1-gitops",
|
||||||
"runtimePath": "deploy/gitops/g14/runtime-v01",
|
"runtimePath": "deploy/gitops/g14/runtime-v01",
|
||||||
"unideskSshEndpointEnv": { "name": "UNIDESK_MAIN_SERVER_IP", "value": "74.48.78.17" },
|
"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": [
|
"services": [
|
||||||
{
|
{
|
||||||
"id": "agentrun-mgr",
|
"id": "agentrun-mgr",
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ PATCH /api/v1/commands/:commandId/status
|
|||||||
|
|
||||||
所有 API 成功和失败响应都必须是 JSON。失败响应至少包含 `failureKind`、`message` 和 trace correlation;不得出现空 stdout/空 response 被误判为成功的情况。
|
所有 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
|
### v0.1.1 Session state 存储 API
|
||||||
|
|
||||||
在 `P1 SessionRef 持久化` 升级到「per-session RWO PVC 直接挂载」后,manager 必须提供下列受控 API 来管理 session 的 PVC 生命周期:
|
在 `P1 SessionRef 持久化` 升级到「per-session RWO PVC 直接挂载」后,manager 必须提供下列受控 API 来管理 session 的 PVC 生命周期:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| Argo CD AppProject | `argocd/agentrun-v01` |
|
| Argo CD AppProject | `argocd/agentrun-v01` |
|
||||||
| Argo CD Application | `argocd/agentrun-g14-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 边界
|
## 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。
|
- replica、resource request/limit、health path、ports、env key、ConfigMap/SecretRef 名称和 key。
|
||||||
- runtime namespace、ServiceAccount、RBAC intent、PVC intent、NetworkPolicy intent。
|
- runtime namespace、ServiceAccount、RBAC intent、PVC intent、NetworkPolicy intent。
|
||||||
- Postgres 和 Code Agent provider 的 SecretRef 名称、key 名称与 mount/env intent;Secret 值不在 source 或 GitOps 中出现。
|
- 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` 的内容:
|
禁止写入 `deploy/deploy.json` 的内容:
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
|
|||||||
|
|
||||||
- CLI 配置必须显式校验;部署关键值不得静默 fallback。
|
- CLI 配置必须显式校验;部署关键值不得静默 fallback。
|
||||||
- CLI 调用 manager REST API;不得直接连 Postgres 或读 Kubernetes Secret value。
|
- 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 可以显示 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` 的执行上下文注入。
|
- 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 子命令作为通过证据。
|
- Debug 子命令可以用于开发,但综合联调和测试规格不得用 debug 子命令作为通过证据。
|
||||||
|
|||||||
@@ -44,6 +44,27 @@ interface EnvVarValue {
|
|||||||
value: string;
|
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 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"]);
|
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 gitopsBranch = stringField(deploy, "gitopsBranch", "v0.1-gitops");
|
||||||
const runtimePath = stringField(deploy, "runtimePath", "deploy/gitops/g14/runtime-v01");
|
const runtimePath = stringField(deploy, "runtimePath", "deploy/gitops/g14/runtime-v01");
|
||||||
const unideskSshEndpointEnv = optionalUnideskSshEndpointEnv(deploy);
|
const unideskSshEndpointEnv = optionalUnideskSshEndpointEnv(deploy);
|
||||||
|
const managerApiKeyEnv = managerApiKeySecretEnv(deploy);
|
||||||
|
const publicExposure = optionalFrpTcpExposure(deploy);
|
||||||
const catalog = await loadCatalog(options, gitopsBranch);
|
const catalog = await loadCatalog(options, gitopsBranch);
|
||||||
const image = imageForService(catalog, "agentrun-mgr", options);
|
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", "kustomization.yaml"), kustomizationYaml());
|
||||||
await writeFile(path.join(options.outDir, "runtime-v01", "namespace.yaml"), namespaceYaml(runtimeNamespace));
|
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", "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));
|
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> {
|
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 imageRef = repositoryDigestForService(image);
|
||||||
const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown";
|
const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown";
|
||||||
const unideskSshEndpointEnvYaml = unideskSshEndpointEnv ? ` - name: ${unideskSshEndpointEnv.name}\n value: ${JSON.stringify(unideskSshEndpointEnv.value)}\n` : "";
|
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
|
return `apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
@@ -322,6 +347,7 @@ spec:
|
|||||||
value: ${JSON.stringify(imageRef)}
|
value: ${JSON.stringify(imageRef)}
|
||||||
- name: AGENTRUN_RUNNER_SERVICE_ACCOUNT
|
- name: AGENTRUN_RUNNER_SERVICE_ACCOUNT
|
||||||
value: "agentrun-v01-runner"
|
value: "agentrun-v01-runner"
|
||||||
|
${apiKeyEnvYaml}
|
||||||
${unideskSshEndpointEnvYaml}
|
${unideskSshEndpointEnvYaml}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
@@ -390,6 +416,70 @@ roleRef:
|
|||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: Role
|
kind: Role
|
||||||
name: agentrun-v01-mgr-provider-secret-manager
|
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 });
|
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 };
|
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 JsonRecord = { [key: string]: JsonValue };
|
||||||
|
|
||||||
export type FailureKind =
|
export type FailureKind =
|
||||||
|
| "auth-missing"
|
||||||
|
| "auth-failed"
|
||||||
| "schema-invalid"
|
| "schema-invalid"
|
||||||
| "tenant-policy-denied"
|
| "tenant-policy-denied"
|
||||||
| "secret-unavailable"
|
| "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 type { JsonRecord, JsonValue } from "../common/types.js";
|
||||||
import { AgentRunError } from "../common/errors.js";
|
import { AgentRunError } from "../common/errors.js";
|
||||||
|
import { managerAuthorizationHeader, managerClientAuthConfigFromEnv } from "./auth.js";
|
||||||
|
|
||||||
|
export interface ManagerClientOptions {
|
||||||
|
apiKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ManagerClient {
|
export class ManagerClient {
|
||||||
constructor(readonly baseUrl: string) {}
|
constructor(readonly baseUrl: string, readonly options: ManagerClientOptions = {}) {}
|
||||||
|
|
||||||
async get(path: string): Promise<JsonValue> {
|
async get(path: string): Promise<JsonValue> {
|
||||||
return this.request("GET", path);
|
return this.request("GET", path);
|
||||||
@@ -25,9 +30,12 @@ export class ManagerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async request(method: string, path: string, body?: JsonValue): Promise<JsonValue> {
|
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) {
|
if (body !== undefined) {
|
||||||
init.headers = { "content-type": "application/json" };
|
headers["content-type"] = "application/json";
|
||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
const response = await fetch(new URL(path, this.baseUrl), init);
|
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 { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js";
|
||||||
import type { SessionPvcSummary } from "./session-pvc.js";
|
import type { SessionPvcSummary } from "./session-pvc.js";
|
||||||
import type { SessionPvcOptions } 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 { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
||||||
import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js";
|
import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js";
|
||||||
import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.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 };
|
providerProfileOptions?: { namespace?: string; kubectlCommand?: string };
|
||||||
toolCredentialOptions?: { namespace?: string; kubectlCommand?: string };
|
toolCredentialOptions?: { namespace?: string; kubectlCommand?: string };
|
||||||
aipodSpecDir?: string;
|
aipodSpecDir?: string;
|
||||||
|
auth?: ManagerAuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartedManagerServer {
|
export interface StartedManagerServer {
|
||||||
@@ -69,12 +71,15 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr
|
|||||||
const providerProfileDefaults = options.providerProfileOptions;
|
const providerProfileDefaults = options.providerProfileOptions;
|
||||||
const toolCredentialDefaults = options.toolCredentialOptions;
|
const toolCredentialDefaults = options.toolCredentialOptions;
|
||||||
const aipodSpecDir = options.aipodSpecDir ?? process.env.AGENTRUN_AIPOD_SPEC_DIR;
|
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 server = createServer(async (req, res) => {
|
||||||
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
try {
|
try {
|
||||||
const method = req.method ?? "GET";
|
const method = req.method ?? "GET";
|
||||||
const url = new URL(req.url ?? "/", "http://agentrun.local");
|
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 });
|
writeJson(res, 200, { ok: true, data, traceId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const agentError = normalizeError(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;
|
const path = url.pathname;
|
||||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||||
const database = await store.health();
|
const database = await store.health();
|
||||||
const ready = path === "/health/live" ? true : database.ready;
|
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/backends") return await listBackendCapabilities(providerProfileDefaults) as JsonValue;
|
||||||
if (method === "GET" && path === "/api/v1/tool-credentials") return await listToolCredentials(toolCredentialDefaults) 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);
|
const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile);
|
||||||
return dedupeEnvVars([
|
return dedupeEnvVars([
|
||||||
{ name: "AGENTRUN_MGR_URL", value: options.managerUrl },
|
{ 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_RUN_ID", value: options.run.id },
|
||||||
{ name: "AGENTRUN_COMMAND_ID", value: options.commandId },
|
{ name: "AGENTRUN_COMMAND_ID", value: options.commandId },
|
||||||
{ name: "AGENTRUN_ATTEMPT_ID", value: context.attemptId },
|
{ 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.equal(((health.runnerWorkReady as { valuesPrinted?: unknown } | undefined)?.valuesPrinted), false);
|
||||||
assert.ok((((health.runnerWorkReady as { requiredImageTools?: string[] } | undefined)?.requiredImageTools) ?? []).includes("npm"));
|
assert.ok((((health.runnerWorkReady as { requiredImageTools?: string[] } | undefined)?.requiredImageTools) ?? []).includes("npm"));
|
||||||
assert.equal(health.secretRefs?.valuesPrinted, false);
|
assert.equal(health.secretRefs?.valuesPrinted, false);
|
||||||
|
await assertManagerAuthBoundary();
|
||||||
await assertLongResultUsesTerminalAssistant(client, store);
|
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 {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
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> {
|
async function assertLongResultUsesTerminalAssistant(client: ManagerClient, store: MemoryAgentRunStore): Promise<void> {
|
||||||
const run = store.createRun({
|
const run = store.createRun({
|
||||||
tenantId: "unidesk",
|
tenantId: "unidesk",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ type SelfTestRunContext = Pick<SelfTestContext, "workspace" | "codexHome"> & Par
|
|||||||
export async function createSelfTestContext(root: string): Promise<SelfTestContext> {
|
export async function createSelfTestContext(root: string): Promise<SelfTestContext> {
|
||||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-"));
|
const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-"));
|
||||||
const previousSelftestWorkReadyBinPath = process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH;
|
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 codexHome = path.join(tmp, "codex-home");
|
||||||
const deepseekHome = path.join(tmp, "deepseek-home");
|
const deepseekHome = path.join(tmp, "deepseek-home");
|
||||||
const minimaxM3Home = path.join(tmp, "minimax-m3-home");
|
const minimaxM3Home = path.join(tmp, "minimax-m3-home");
|
||||||
@@ -67,6 +71,10 @@ export async function createSelfTestContext(root: string): Promise<SelfTestConte
|
|||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
if (previousSelftestWorkReadyBinPath === undefined) delete process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH;
|
if (previousSelftestWorkReadyBinPath === undefined) delete process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH;
|
||||||
else process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH = previousSelftestWorkReadyBinPath;
|
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 });
|
await rm(tmp, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user