Merge main into durable runtime readiness PR
This commit is contained in:
@@ -25,6 +25,12 @@ for future compatibility, but PROD deployment is not part of MVP acceptance.
|
||||
The repository records names only, never secret values or a live DB
|
||||
connection string; runtime health reports redacted env injection and DB
|
||||
connection result classifiers.
|
||||
- `hwlab-cloud-api` declares the DEV Code Agent provider contract with
|
||||
`OPENAI_API_KEY` from Secret reference
|
||||
`hwlab-code-agent-provider/openai-api-key` and
|
||||
`HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path. Source
|
||||
checks verify the Secret ref and base-url contract only; they do not prove
|
||||
provider connectivity and must not print API key material.
|
||||
- `deploy/k8s/prod` is a disabled placeholder gate only.
|
||||
- `deploy/frp` describes the D601-to-master reverse link without secrets.
|
||||
- `deploy/master-edge` describes public edge ownership and health boundaries.
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"HWLAB_CLOUD_DB_PORT": "5432",
|
||||
"HWLAB_CODE_AGENT_PROVIDER": "openai",
|
||||
"HWLAB_CODE_AGENT_MODEL": "gpt-5.5",
|
||||
"HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses",
|
||||
"OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -386,6 +386,22 @@
|
||||
"HWLAB_CLOUD_DB_PORT": {
|
||||
"type": "string",
|
||||
"const": "5432"
|
||||
},
|
||||
"HWLAB_CODE_AGENT_PROVIDER": {
|
||||
"type": "string",
|
||||
"const": "openai"
|
||||
},
|
||||
"HWLAB_CODE_AGENT_MODEL": {
|
||||
"type": "string",
|
||||
"const": "gpt-5.5"
|
||||
},
|
||||
"HWLAB_CODE_AGENT_OPENAI_BASE_URL": {
|
||||
"type": "string",
|
||||
"const": "http://172.26.26.227:17680/v1/responses"
|
||||
},
|
||||
"OPENAI_API_KEY": {
|
||||
"type": "string",
|
||||
"const": "secretRef:hwlab-code-agent-provider/openai-api-key"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
"name": "HWLAB_CODE_AGENT_MODEL",
|
||||
"value": "gpt-5.5"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"value": "http://172.26.26.227:17680/v1/responses"
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"valueFrom": {
|
||||
|
||||
@@ -18,8 +18,8 @@ and verifies:
|
||||
connected.
|
||||
- The DB contract reports required env names, redacted Secret refs, env
|
||||
injection, connection attempt status, connection result, `liveConnected`,
|
||||
`liveDbEvidence`, and redaction/safety fields without exposing DB URL or
|
||||
secret values.
|
||||
`liveDbEvidence`, DNS/TCP/auth/schema readiness layers, and redaction/safety
|
||||
fields without exposing DB URL or secret values.
|
||||
- `gateway.session.register`, `box.resource.register`,
|
||||
`box.capability.report`, `hardware.invoke.shell`, `audit.event.query`, and
|
||||
`evidence.record.query` round-trip through the same runtime store.
|
||||
@@ -45,6 +45,8 @@ The source-controlled DEV DNS contract for that endpoint is
|
||||
`cloud-api-db.hwlab-dev.svc.cluster.local:5432`. The actual connection string
|
||||
still comes from `hwlab-cloud-api-dev-db/database-url`; source records only the
|
||||
non-secret host/service/namespace/port contract.
|
||||
`.invalid` hosts, including `hwlab-dev-db.invalid`, are forbidden as DEV
|
||||
runtime targets and may be used only as negative fixtures.
|
||||
|
||||
- `db.configReady`: required env names are present.
|
||||
- `db.connectionAttempted`: the runtime tried the live connection path.
|
||||
@@ -55,6 +57,8 @@ non-secret host/service/namespace/port contract.
|
||||
presence, fixtures, and disabled probes must keep this false.
|
||||
- `db.redaction` and `db.safety`: confirm values/endpoints are redacted and
|
||||
secret material was not read by the health path.
|
||||
- `db.readinessLayers`: records DNS, TCP, auth, and schema status separately so
|
||||
DNS/TCP repair cannot be confused with authenticated schema readiness.
|
||||
- `db.ready`: currently aliases `db.liveConnected`.
|
||||
|
||||
Env presence alone is not treated as green. If the runtime has env injection
|
||||
@@ -63,3 +67,13 @@ but `db.connectionAttempted` is false, `db.liveConnected` is false, or
|
||||
reported blocker.
|
||||
|
||||
Fixture: `fixtures/cloud-api-runtime/minimal-runtime.json`.
|
||||
|
||||
## Code Agent Provider Boundary
|
||||
|
||||
`hwlab-cloud-api` supports OpenAI Responses for the Code Agent chat endpoint.
|
||||
DEV source contracts require `OPENAI_API_KEY` from Secret
|
||||
`hwlab-code-agent-provider/openai-api-key` and
|
||||
`HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy URL. Runtime
|
||||
health and `/v1` may report provider status, missing env names, Secret ref
|
||||
names, model, and redacted egress status, but must never print the API key or a
|
||||
Bearer token.
|
||||
|
||||
@@ -21,6 +21,12 @@ heavyweight e2e run by itself.
|
||||
- HWLAB runtime services must keep the frozen service IDs in `README.md`.
|
||||
- UniDesk services may support scheduling, CI, or CD only. They are not accepted
|
||||
as substitutes for HWLAB runtime services.
|
||||
- Code Agent provider credentials are DEV-only runtime inputs: `OPENAI_API_KEY`
|
||||
must come from Secret `hwlab-code-agent-provider/openai-api-key`, and
|
||||
`HWLAB_CODE_AGENT_OPENAI_BASE_URL` must route through the DEV egress/proxy
|
||||
path, not direct public `api.openai.com`.
|
||||
- Cloud API DB readiness is layered as DNS, TCP, auth, and schema. `.invalid`
|
||||
hosts are never desired DEV runtime targets; they are negative fixtures only.
|
||||
|
||||
## M3 MVP Acceptance
|
||||
|
||||
@@ -70,6 +76,9 @@ operator, script, or agent attempts one of these actions:
|
||||
- Promote SOURCE, LOCAL, DRY-RUN, or edge-only result as real DEV evidence.
|
||||
- Treat Cloud Workbench polish, generic console work, artifact reports, or
|
||||
desired state plans as M3 PASS evidence.
|
||||
- Leave an emergency DEV runtime hotfix as the only source of truth after it
|
||||
unblocks a P0 path. The follow-up must codify source manifests, contracts,
|
||||
docs, and validation without rerunning the live patch.
|
||||
|
||||
## Evidence Log
|
||||
|
||||
@@ -118,6 +127,23 @@ Current unblock order:
|
||||
5. Repair the `:16667`/`frp`/edge/router path and rerun read-only DEV edge
|
||||
health before any M3, M4, or M5 live loop.
|
||||
|
||||
For #164-style hotfix follow-up, treat provider and DB fixes as independent
|
||||
contracts:
|
||||
|
||||
- Code Agent provider: source and k8s workload env must declare
|
||||
`OPENAI_API_KEY` from `hwlab-code-agent-provider/openai-api-key` and
|
||||
`HWLAB_CODE_AGENT_OPENAI_BASE_URL` through DEV egress/proxy. Validation may
|
||||
prove Secret ref shape and key-presence evidence only; it must not print the
|
||||
API key.
|
||||
- DB live: source and runtime health must reject `.invalid` as a DEV target and
|
||||
report DNS/TCP/auth/schema readiness separately. `liveDbEvidence=true` is
|
||||
allowed only after live runtime connectivity evidence, never from fixtures or
|
||||
env presence alone.
|
||||
- Follow-up automation: update `deploy/deploy.json`,
|
||||
`deploy/k8s/base/workloads.yaml`, source contract code, docs, and tests; do
|
||||
not apply manifests, restart services, mutate PROD, or repeat the manual
|
||||
hotfix from the source PR.
|
||||
|
||||
## Phase Gates
|
||||
|
||||
Run phases in order. A later phase may start only after every earlier required
|
||||
|
||||
@@ -84,6 +84,49 @@ this agreement offline. A live pass still requires `/health/live` to report
|
||||
`db.ready=true`, `db.connected=true`, and `liveDbEvidence=true` with secret
|
||||
values redacted.
|
||||
|
||||
The DB readiness contract is layered:
|
||||
|
||||
| Layer | Required evidence |
|
||||
| --- | --- |
|
||||
| DNS | The configured target resolves from the `hwlab-cloud-api` runtime path and is not a `.invalid` placeholder. |
|
||||
| TCP | A redacted TCP probe reaches the configured Postgres port. |
|
||||
| Auth | Authenticated database access succeeds without printing the connection string. |
|
||||
| Schema | Required HWLAB schema/migration checks pass without using fixture output as live evidence. |
|
||||
|
||||
`*.invalid` and `hwlab-dev-db.invalid` are forbidden DEV runtime targets. They
|
||||
may appear only as negative test fixtures; source and runtime health must not
|
||||
treat them as desired DEV DB endpoints.
|
||||
|
||||
## Code Agent Provider Contract
|
||||
|
||||
`hwlab-cloud-api` runs the DEV Code Agent provider through OpenAI Responses.
|
||||
Source-controlled manifests must declare only env names, Secret references, and
|
||||
non-secret egress settings:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Provider env | `HWLAB_CODE_AGENT_PROVIDER=openai` |
|
||||
| Model env | `HWLAB_CODE_AGENT_MODEL=gpt-5.5` |
|
||||
| Provider Secret | `OPENAI_API_KEY` from `hwlab-code-agent-provider/openai-api-key` |
|
||||
| DEV egress/base URL | `HWLAB_CODE_AGENT_OPENAI_BASE_URL=http://172.26.26.227:17680/v1/responses` |
|
||||
|
||||
DEV pods must not call `https://api.openai.com/v1/responses` directly. The base
|
||||
URL must use the approved DEV egress/proxy path so provider reachability is a
|
||||
repeatable deployment contract instead of a one-off runtime patch.
|
||||
|
||||
Reports, smokes, and health payloads may show Secret name/key presence,
|
||||
`missingEnv`, provider status, model, trace IDs, and redacted egress status.
|
||||
They must never print the OpenAI API key, bearer token, database URL password,
|
||||
kubeconfig material, or other secret values.
|
||||
|
||||
## Hotfix To Source Rule
|
||||
|
||||
DEV runtime hotfixes are temporary recovery actions. After a hotfix proves the
|
||||
minimal live path, the durable follow-up is source automation: update manifests,
|
||||
contracts, docs, and tests so future rollouts reproduce the same env and safety
|
||||
boundaries. The runner must not re-execute the live hotfix, restart services,
|
||||
read Secrets, mutate PROD, or claim live evidence from source-only changes.
|
||||
|
||||
## Durable Runtime Readiness Contract
|
||||
|
||||
DB live readiness and durable runtime readiness are separate gates. A
|
||||
@@ -132,5 +175,7 @@ M4, or M5 live evidence.
|
||||
read-only k3s visibility command.
|
||||
- [docs/dev-acceptance-matrix.md](../dev-acceptance-matrix.md): DEV endpoint
|
||||
and health acceptance.
|
||||
- [pikasTech/HWLAB#164](https://github.com/pikasTech/HWLAB/issues/164):
|
||||
Code Agent provider and DB live hotfix follow-up contract.
|
||||
- [pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61): manual
|
||||
rollout review and automation requirements.
|
||||
|
||||
@@ -6,11 +6,17 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder,
|
||||
inspectCodeAgentProviderEnv
|
||||
} from "./code-agent-contract.mjs";
|
||||
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_CODEX_COMMAND = "codex";
|
||||
const DEFAULT_MODEL = "gpt-5.2";
|
||||
const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model;
|
||||
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
|
||||
const CODE_AGENT_PROVIDER_SECRET_REF = "hwlab-code-agent-provider/openai-api-key";
|
||||
const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", "");
|
||||
const CODE_AGENT_SYSTEM_PROMPT = [
|
||||
"你是 HWLAB 云工作台的 Code Agent。",
|
||||
"请用中文直接回答用户的工作台问题。",
|
||||
@@ -127,13 +133,18 @@ export function validateCodeAgentChatSchema(payload) {
|
||||
|
||||
export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
const providerPlan = resolveProviderPlan(env, options);
|
||||
const missingEnv = [];
|
||||
const providerContract = inspectCodeAgentProviderEnv(env);
|
||||
const missingEnv = providerPlan.mode === "openai"
|
||||
? providerContract.missingEnv
|
||||
: [];
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
if (providerPlan.mode !== "openai" && !env.OPENAI_API_KEY) {
|
||||
missingEnv.push("OPENAI_API_KEY");
|
||||
}
|
||||
|
||||
const blocked = missingEnv.length > 0;
|
||||
const blocked = providerPlan.mode === "openai"
|
||||
? !providerContract.ready
|
||||
: missingEnv.length > 0;
|
||||
return {
|
||||
endpoint: "POST /v1/agent/chat",
|
||||
provider: providerPlan.provider,
|
||||
@@ -157,23 +168,15 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
"availability.status"
|
||||
],
|
||||
status: blocked ? "blocked" : "available",
|
||||
blocker: blocked ? "凭证缺口" : null,
|
||||
blocker: blocked ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null,
|
||||
reason: blocked ? "provider_unavailable" : null,
|
||||
summary: blocked
|
||||
? `真实后端已接入,但当前 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 未注入,因此不能返回真实回复。`
|
||||
: "真实后端已接入,发送会走真实 /v1/agent/chat;只有实际 completed 回复才标记 DEV-LIVE。",
|
||||
? `真实后端已接入,但当前 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 未满足,因此不能返回真实回复。`
|
||||
: "真实后端已接入,发送会走 DEV egress/proxy 后的真实 /v1/agent/chat;只有实际 completed 回复才标记 DEV-LIVE。",
|
||||
missingEnv,
|
||||
secretRefs: blocked
|
||||
? [
|
||||
{
|
||||
env: "OPENAI_API_KEY",
|
||||
secretName: "hwlab-code-agent-provider",
|
||||
secretKey: "openai-api-key",
|
||||
present: false,
|
||||
redacted: true
|
||||
}
|
||||
]
|
||||
: [],
|
||||
secretRefs: blocked ? providerContract.secretRefs : [],
|
||||
egress: providerContract.egress,
|
||||
safety: providerContract.safety,
|
||||
ready: !blocked
|
||||
};
|
||||
}
|
||||
@@ -233,16 +236,18 @@ async function callConfiguredProvider({
|
||||
}
|
||||
|
||||
async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
throw providerUnavailable("OPENAI_API_KEY is not configured for openai-responses", {
|
||||
missingEnv: ["OPENAI_API_KEY"],
|
||||
const providerContract = inspectCodeAgentProviderEnv(env);
|
||||
if (!providerContract.ready) {
|
||||
throw providerUnavailable(providerContract.blocker, {
|
||||
missingEnv: providerContract.missingEnv,
|
||||
provider: providerPlan.provider,
|
||||
model: providerPlan.model,
|
||||
backend: providerPlan.backend
|
||||
backend: providerPlan.backend,
|
||||
egress: providerContract.egress
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL || "https://api.openai.com/v1/responses";
|
||||
const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs));
|
||||
let response;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||
|
||||
export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({
|
||||
contractVersion: "v1",
|
||||
environment: ENVIRONMENT_DEV,
|
||||
provider: "openai",
|
||||
runtimeProvider: "openai-responses",
|
||||
backend: "hwlab-cloud-api/openai-responses",
|
||||
model: "gpt-5.5",
|
||||
requiredEnv: Object.freeze([
|
||||
"OPENAI_API_KEY",
|
||||
"HWLAB_CODE_AGENT_OPENAI_BASE_URL"
|
||||
]),
|
||||
secretRefs: Object.freeze([
|
||||
Object.freeze({
|
||||
env: "OPENAI_API_KEY",
|
||||
secretName: "hwlab-code-agent-provider",
|
||||
secretKey: "openai-api-key"
|
||||
})
|
||||
]),
|
||||
egress: Object.freeze({
|
||||
env: "HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
target: "dev-egress-proxy",
|
||||
defaultBaseUrl: "http://172.26.26.227:17680/v1/responses",
|
||||
forbiddenDirectBaseUrl: "https://api.openai.com/v1/responses",
|
||||
reason: "DEV cloud-api pods must use the D601 egress/proxy path for OpenAI Responses."
|
||||
}),
|
||||
forbidden: Object.freeze({
|
||||
prodAllowed: false,
|
||||
secretPlaintextAllowed: false,
|
||||
directPublicOpenAiFromDevPod: false
|
||||
})
|
||||
});
|
||||
|
||||
export function codeAgentSecretRefPlaceholder() {
|
||||
const ref = DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0];
|
||||
return `secretRef:${ref.secretName}/${ref.secretKey}`;
|
||||
}
|
||||
|
||||
export function buildCodeAgentProviderManifestPlaceholder() {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
return {
|
||||
contractVersion: contract.contractVersion,
|
||||
environment: contract.environment,
|
||||
provider: contract.provider,
|
||||
runtimeProvider: contract.runtimeProvider,
|
||||
backend: contract.backend,
|
||||
model: contract.model,
|
||||
requiredEnv: [...contract.requiredEnv],
|
||||
secretRefs: contract.secretRefs.map((item) => ({ ...item })),
|
||||
egress: { ...contract.egress },
|
||||
fixtureEvidence: {
|
||||
providerConnected: false,
|
||||
summary: "This manifest contract records provider env names, Secret reference names, and DEV egress policy only. It is not live provider evidence."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectCodeAgentProviderEnv(env = {}) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const missingEnv = contract.requiredEnv.filter((name) => !hasEnvValue(env, name));
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const baseUrl = firstNonEmpty(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL);
|
||||
const directPublicOpenAi =
|
||||
baseUrl === contract.egress.forbiddenDirectBaseUrl ||
|
||||
/^https:\/\/api\.openai\.com\/v1\/responses\/?$/u.test(baseUrl);
|
||||
const egressReady = Boolean(baseUrl) && !directPublicOpenAi;
|
||||
const ready = missingEnv.length === 0 && egressReady;
|
||||
|
||||
return {
|
||||
contractVersion: contract.contractVersion,
|
||||
environment: contract.environment,
|
||||
provider: contract.runtimeProvider,
|
||||
model: firstNonEmpty(env.HWLAB_CODE_AGENT_MODEL, contract.model),
|
||||
backend: contract.backend,
|
||||
status: ready ? "available" : "blocked",
|
||||
ready,
|
||||
requiredEnv: contract.requiredEnv.map((name) => ({
|
||||
name,
|
||||
present: hasEnvValue(env, name),
|
||||
redacted: name === secretRef.env,
|
||||
source: name === secretRef.env ? "k8s-secret-ref" : "runtime-env"
|
||||
})),
|
||||
missingEnv,
|
||||
secretRefs: [
|
||||
{
|
||||
env: secretRef.env,
|
||||
secretName: secretRef.secretName,
|
||||
secretKey: secretRef.secretKey,
|
||||
present: hasEnvValue(env, secretRef.env),
|
||||
envInjected: hasEnvValue(env, secretRef.env),
|
||||
secretPresent: "not_observed_by_runtime",
|
||||
secretKeyPresent: "not_observed_by_runtime",
|
||||
redacted: true
|
||||
}
|
||||
],
|
||||
egress: {
|
||||
env: contract.egress.env,
|
||||
present: hasEnvValue(env, contract.egress.env),
|
||||
target: contract.egress.target,
|
||||
baseUrlConfigured: Boolean(baseUrl),
|
||||
directPublicOpenAi,
|
||||
ready: egressReady,
|
||||
valueRedacted: true
|
||||
},
|
||||
safety: {
|
||||
devOnly: true,
|
||||
prodAllowed: contract.forbidden.prodAllowed,
|
||||
secretsRead: false,
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
directPublicOpenAiFromDevPod: directPublicOpenAi
|
||||
},
|
||||
blocker: ready ? null : codeAgentProviderBlocker({ missingEnv, directPublicOpenAi, baseUrl })
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentProviderBlocker({ missingEnv, directPublicOpenAi, baseUrl }) {
|
||||
if (directPublicOpenAi) {
|
||||
return "DEV Code Agent OpenAI base URL points at public api.openai.com instead of the DEV egress/proxy path";
|
||||
}
|
||||
if (!baseUrl) {
|
||||
return "DEV Code Agent OpenAI base URL is missing; provider calls must use the DEV egress/proxy path";
|
||||
}
|
||||
return `Code Agent provider runtime config is missing ${missingEnv.join(", ")}`;
|
||||
}
|
||||
|
||||
function hasEnvValue(env, name) {
|
||||
return typeof env?.[name] === "string" && env[name].trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -23,6 +23,12 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({
|
||||
port: 5432,
|
||||
portName: "postgres"
|
||||
}),
|
||||
readinessLayers: Object.freeze([
|
||||
"dns",
|
||||
"tcp",
|
||||
"auth",
|
||||
"schema"
|
||||
]),
|
||||
nonSecretDefaults: Object.freeze({
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "require",
|
||||
HWLAB_CLOUD_DB_SERVICE_NAME: "cloud-api-db",
|
||||
@@ -33,7 +39,11 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({
|
||||
forbidden: Object.freeze({
|
||||
prodAllowed: false,
|
||||
secretPlaintextAllowed: false,
|
||||
liveDbEvidenceFromFixtures: false
|
||||
liveDbEvidenceFromFixtures: false,
|
||||
invalidRuntimeHosts: Object.freeze([
|
||||
".invalid",
|
||||
"hwlab-dev-db.invalid"
|
||||
])
|
||||
})
|
||||
});
|
||||
|
||||
@@ -96,6 +106,7 @@ export function buildDbHealthContract(env = process.env) {
|
||||
liveDbEvidence: connected,
|
||||
liveDbConnectedEvidence: connected
|
||||
},
|
||||
readinessLayers: buildReadinessLayers(connection),
|
||||
blocker: configReady ? "live DB connection has not been attempted by this health contract" : missingEnvBlocker(missingEnv),
|
||||
evidence: configReady ? "env_presence_only_no_live_db" : "env_contract_blocked"
|
||||
};
|
||||
@@ -124,6 +135,8 @@ export function buildDbEnvManifestPlaceholder() {
|
||||
requiredEnv: [...DEV_DB_ENV_CONTRACT.requiredEnv],
|
||||
secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map((item) => ({ ...item })),
|
||||
dns: { ...DEV_DB_ENV_CONTRACT.dns },
|
||||
readinessLayers: [...DEV_DB_ENV_CONTRACT.readinessLayers],
|
||||
forbiddenRuntimeHosts: [...DEV_DB_ENV_CONTRACT.forbidden.invalidRuntimeHosts],
|
||||
nonSecretDefaults: { ...DEV_DB_ENV_CONTRACT.nonSecretDefaults },
|
||||
fixtureEvidence: {
|
||||
liveDbEvidence: false,
|
||||
@@ -173,6 +186,7 @@ export function summarizeDbContract(db = buildDbHealthContract()) {
|
||||
),
|
||||
connection: summarizeConnection(db.connection),
|
||||
redaction: summarizeRedaction(db.redaction),
|
||||
readinessLayers: buildReadinessLayers(db.connection),
|
||||
blocker: db.blocker ?? null,
|
||||
fixtureEvidence: false
|
||||
};
|
||||
@@ -257,6 +271,14 @@ export function parseDbUrlContract(rawUrl) {
|
||||
errorCode: "MISSING_HOST"
|
||||
};
|
||||
}
|
||||
if (isForbiddenRuntimeHost(url.hostname)) {
|
||||
return {
|
||||
ok: false,
|
||||
result: "forbidden_runtime_host",
|
||||
classification: "db_url_forbidden_invalid_host",
|
||||
errorCode: "FORBIDDEN_INVALID_HOST"
|
||||
};
|
||||
}
|
||||
const port = url.port ? Number.parseInt(url.port, 10) : 5432;
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return {
|
||||
@@ -382,6 +404,7 @@ function applyConnectionResult(base, connection) {
|
||||
liveDbEvidence: liveConnected,
|
||||
liveDbConnectedEvidence: liveConnected
|
||||
},
|
||||
readinessLayers: buildReadinessLayers(connection),
|
||||
blocker: liveConnected ? null : blockerForConnection(connection),
|
||||
evidence: liveConnected ? "live_db_tcp_connection_ready" : "live_db_tcp_connection_blocked"
|
||||
};
|
||||
@@ -391,6 +414,9 @@ function blockerForConnection(connection) {
|
||||
if (connection.result === "invalid_url") {
|
||||
return "DB URL is injected but cannot be parsed as a supported postgres URL";
|
||||
}
|
||||
if (connection.result === "forbidden_runtime_host") {
|
||||
return "DB URL is injected but points at a forbidden .invalid DEV runtime host";
|
||||
}
|
||||
if (connection.result === "unsupported_protocol") {
|
||||
return "DB URL is injected but does not use a supported postgres protocol";
|
||||
}
|
||||
@@ -421,6 +447,38 @@ function normalizeTimeoutMs(value) {
|
||||
return Math.min(parsed, 5000);
|
||||
}
|
||||
|
||||
function isForbiddenRuntimeHost(hostname) {
|
||||
const normalized = String(hostname ?? "").toLowerCase();
|
||||
return DEV_DB_ENV_CONTRACT.forbidden.invalidRuntimeHosts.some((host) =>
|
||||
host.startsWith(".") ? normalized.endsWith(host) : normalized === host
|
||||
);
|
||||
}
|
||||
|
||||
function buildReadinessLayers(connection = {}) {
|
||||
const result = connection?.result ?? "unknown";
|
||||
const connected = result === "connected";
|
||||
const dnsReady = connected || ["refused", "timeout", "network_unreachable", "error"].includes(result);
|
||||
const tcpReady = connected;
|
||||
return {
|
||||
dns: {
|
||||
status: dnsReady ? "pass" : "not_proven",
|
||||
result
|
||||
},
|
||||
tcp: {
|
||||
status: tcpReady ? "pass" : "not_proven",
|
||||
result
|
||||
},
|
||||
auth: {
|
||||
status: "not_proven",
|
||||
result: connected ? "tcp_connected_auth_not_checked" : result
|
||||
},
|
||||
schema: {
|
||||
status: "not_proven",
|
||||
result: connected ? "tcp_connected_schema_not_checked" : result
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeConnection(connection) {
|
||||
if (!connection) {
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,8 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.equal(healthPayload.codeAgent.egress.directPublicOpenAi, false);
|
||||
assert.equal(healthPayload.codeAgent.safety.secretMaterialRead, false);
|
||||
assert.equal(JSON.stringify(healthPayload.codeAgent).includes("sk-"), false);
|
||||
assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
||||
assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
@@ -156,7 +158,7 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
|
||||
const originalProbeDisabled = process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
|
||||
process.env.HWLAB_CLOUD_DB_URL = "postgres://user:password@db.example.invalid:5432/hwlab";
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "require";
|
||||
process.env.HWLAB_CLOUD_DB_PROBE_DISABLED = "1";
|
||||
delete process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
|
||||
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -172,9 +174,12 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
|
||||
assert.equal(payload.db.liveDbEvidence, false);
|
||||
assert.equal(payload.db.safety.liveDbEvidence, false);
|
||||
assert.equal(payload.db.ready, false);
|
||||
assert.equal(payload.db.connectionAttempted, false);
|
||||
assert.equal(payload.db.connectionResult, "not_attempted");
|
||||
assert.equal(payload.db.evidence, "env_presence_only_probe_disabled");
|
||||
assert.equal(payload.db.connectionAttempted, true);
|
||||
assert.equal(payload.db.connectionResult, "forbidden_runtime_host");
|
||||
assert.equal(payload.db.evidence, "live_db_tcp_connection_blocked");
|
||||
assert.equal(payload.db.connection.networkAttempted, false);
|
||||
assert.equal(payload.db.connection.classification, "db_url_forbidden_invalid_host");
|
||||
assert.equal(payload.db.readinessLayers.dns.status, "not_proven");
|
||||
assert.equal(JSON.stringify(payload.db).includes("password"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false);
|
||||
} finally {
|
||||
@@ -220,14 +225,17 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
|
||||
assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses");
|
||||
assert.equal(payload.codeAgent.mode, "openai");
|
||||
assert.equal(payload.codeAgent.status, "blocked");
|
||||
assert.equal(payload.codeAgent.blocker, "凭证缺口");
|
||||
assert.match(payload.codeAgent.blocker, /DEV Code Agent OpenAI base URL is missing/u);
|
||||
assert.equal(payload.codeAgent.reason, "provider_unavailable");
|
||||
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY"]);
|
||||
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]);
|
||||
assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.equal(payload.codeAgent.ready, false);
|
||||
assert.equal(payload.codeAgent.egress.present, false);
|
||||
assert.equal(payload.codeAgent.egress.valueRedacted, true);
|
||||
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
|
||||
assert.match(payload.codeAgent.summary, /真实后端已接入/u);
|
||||
assert.match(payload.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
@@ -238,6 +246,34 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1 describes Code Agent egress blocker without leaking API key", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.codeAgent.status, "blocked");
|
||||
assert.equal(payload.codeAgent.egress.directPublicOpenAi, true);
|
||||
assert.match(payload.codeAgent.blocker, /public api\.openai\.com/u);
|
||||
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat returns structured completed Code Agent payload", async () => {
|
||||
const server = createCloudApiServer({
|
||||
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
|
||||
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
|
||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||
|
||||
@@ -99,7 +99,7 @@ async function runLocalContractSmoke() {
|
||||
assert.deepEqual(failed.error.missingCommands, ["codex"]);
|
||||
assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(failed.availability.status, "blocked");
|
||||
assert.equal(failed.availability.blocker, "凭证缺口");
|
||||
assert.match(failed.availability.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(failed.availability.reason, "provider_unavailable");
|
||||
assert.match(failed.availability.summary, /真实后端已接入/u);
|
||||
assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
@@ -205,13 +205,17 @@ function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStat
|
||||
}
|
||||
|
||||
const missingEnv = Array.isArray(payload?.error?.missingEnv) ? payload.error.missingEnv : [];
|
||||
if (payload?.status === "failed" && payload.error?.code === "provider_unavailable" && missingEnv.includes("OPENAI_API_KEY")) {
|
||||
if (
|
||||
payload?.status === "failed" &&
|
||||
payload.error?.code === "provider_unavailable" &&
|
||||
(missingEnv.includes("OPENAI_API_KEY") || missingEnv.includes("HWLAB_CODE_AGENT_OPENAI_BASE_URL"))
|
||||
) {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: "BLOCKED/credential",
|
||||
blocker: "credential",
|
||||
blocker: missingEnv.includes("OPENAI_API_KEY") ? "credential" : "provider-config",
|
||||
devLiveReplyPass: false,
|
||||
reason: "provider_unavailable with missing OPENAI_API_KEY means the provider credential is absent"
|
||||
reason: "provider_unavailable with missing Code Agent provider env means the provider contract is incomplete"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +279,7 @@ function usage() {
|
||||
notes: [
|
||||
"Default mode is local schema/readiness contract only and cannot pass #143 DEV-LIVE.",
|
||||
"--live posts a minimal chat prompt to real DEV and passes only on completed plus non-empty assistant reply.",
|
||||
"provider_unavailable with missing OPENAI_API_KEY is reported as BLOCKED/credential."
|
||||
"provider_unavailable with missing OPENAI_API_KEY or missing/forbidden HWLAB_CODE_AGENT_OPENAI_BASE_URL is reported as BLOCKED/provider-config."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ async function smokeCloudApi() {
|
||||
assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(health.body.db.secretRefs[0].redacted, true);
|
||||
assert.equal(health.body.codeAgent.status, "blocked");
|
||||
assert.equal(health.body.codeAgent.blocker, "凭证缺口");
|
||||
assert.match(health.body.codeAgent.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(health.body.codeAgent.reason, "provider_unavailable");
|
||||
assert.ok(health.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(health.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
@@ -228,7 +228,7 @@ async function smokeCloudApi() {
|
||||
assert.ok(adapter.body.methods.includes("evidence.record.query"));
|
||||
assert.equal(adapter.body.codeAgent.endpoint, "POST /v1/agent/chat");
|
||||
assert.equal(adapter.body.codeAgent.status, "blocked");
|
||||
assert.equal(adapter.body.codeAgent.blocker, "凭证缺口");
|
||||
assert.match(adapter.body.codeAgent.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(adapter.body.codeAgent.provider, "codex-cli");
|
||||
assert.equal(adapter.body.codeAgent.model, "gpt-m1-local");
|
||||
assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli");
|
||||
|
||||
@@ -4,6 +4,10 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
@@ -142,6 +146,7 @@ function validateSource(ctx, manifest) {
|
||||
const cli = servicesById.get("hwlab-cli");
|
||||
expectEqual(ctx, cloudApi?.env?.HWLAB_PUBLIC_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cloud-api.env.HWLAB_PUBLIC_ENDPOINT", "cloud API public endpoint env");
|
||||
validateCloudApiDbDnsSource(ctx, cloudApi?.env ?? {});
|
||||
validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {});
|
||||
expectEqual(ctx, cli?.env?.HWLAB_CLI_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cli.env.HWLAB_CLI_ENDPOINT", "CLI endpoint env");
|
||||
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_PUBLIC_PORT, String(expectedPorts.api), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_PUBLIC_PORT", "tunnel API public port env");
|
||||
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_WEB_PUBLIC_PORT, String(expectedPorts.frontend), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_WEB_PUBLIC_PORT", "tunnel frontend public port env");
|
||||
@@ -193,6 +198,28 @@ function validateCloudApiDbDnsSource(ctx, env) {
|
||||
expectEqual(ctx, env.HWLAB_CLOUD_DB_PORT, String(dns.port), "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API DB service port");
|
||||
}
|
||||
|
||||
function validateCloudApiCodeAgentSource(ctx, env) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_PROVIDER, contract.provider, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API Code Agent provider");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_MODEL, contract.model, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API Code Agent model");
|
||||
expectEqual(
|
||||
ctx,
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
contract.egress.defaultBaseUrl,
|
||||
"$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API Code Agent DEV egress/proxy base URL"
|
||||
);
|
||||
expect(
|
||||
ctx,
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl,
|
||||
"forbidden_direct_egress",
|
||||
"$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API Code Agent must not point directly at public api.openai.com",
|
||||
{ expected: contract.egress.target, actual: env.HWLAB_CODE_AGENT_OPENAI_BASE_URL }
|
||||
);
|
||||
expectEqual(ctx, env.OPENAI_API_KEY, codeAgentSecretRefPlaceholder(), "$.services.hwlab-cloud-api.env.OPENAI_API_KEY", "cloud API Code Agent OpenAI Secret reference");
|
||||
}
|
||||
|
||||
function renderPlan(source) {
|
||||
const proxyPorts = [...new Set(source.proxies.map((proxy) => Number(proxy.remotePort)))].sort((a, b) => a - b);
|
||||
return {
|
||||
@@ -291,6 +318,7 @@ function validateK3sArtifacts(ctx, source, rendered, services, workloads, health
|
||||
}
|
||||
|
||||
validateCloudApiDbDnsArtifacts(ctx, services, workloads);
|
||||
validateCloudApiCodeAgentArtifacts(ctx, workloads);
|
||||
|
||||
expectEqual(ctx, healthContract.data?.endpoint, source.endpoints.api?.url, "deploy/k8s/dev/health-contract.yaml.data.endpoint", "health contract endpoint");
|
||||
expectEqual(ctx, masterEdge.endpoint, source.endpoints.api?.url, "deploy/master-edge/health-contract.json.endpoint", "master edge endpoint");
|
||||
@@ -325,6 +353,32 @@ function validateCloudApiDbDnsArtifacts(ctx, services, workloads) {
|
||||
expectEqual(ctx, env.get("HWLAB_CLOUD_DB_PORT"), String(dns.port), "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API workload DB port");
|
||||
}
|
||||
|
||||
function validateCloudApiCodeAgentArtifacts(ctx, workloads) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0];
|
||||
const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry]));
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API workload Code Agent provider");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API workload Code Agent model");
|
||||
expectEqual(
|
||||
ctx,
|
||||
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value,
|
||||
contract.egress.defaultBaseUrl,
|
||||
"deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API workload Code Agent DEV egress/proxy base URL"
|
||||
);
|
||||
expect(
|
||||
ctx,
|
||||
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl,
|
||||
"forbidden_direct_egress",
|
||||
"deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API workload Code Agent must not point directly at public api.openai.com",
|
||||
{ expected: contract.egress.target, actual: env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value }
|
||||
);
|
||||
expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.name, secretRef.secretName, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.name", "cloud API workload Code Agent Secret name");
|
||||
expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.key, secretRef.secretKey, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.key", "cloud API workload Code Agent Secret key");
|
||||
}
|
||||
|
||||
async function buildPlan() {
|
||||
const ctx = { checks: 0, diagnostics: [] };
|
||||
const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([
|
||||
|
||||
@@ -942,6 +942,12 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
});
|
||||
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const closed = await inspectMobileWorkbench(page, { explorerOpen: false });
|
||||
const workspaceScroll = await inspectWorkbenchScrollContract(page, { panelSelector: "#conversation-list" });
|
||||
await page.click('[data-route="help"]');
|
||||
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
|
||||
const help = await inspectHelpMarkdownRoute(page);
|
||||
await page.click('[data-route="workspace"]');
|
||||
await page.waitForFunction(() => document.querySelector('[data-view="workspace"]')?.hidden === false, null, { timeout: 8000 });
|
||||
await page.click("#explorer-toggle");
|
||||
const open = await inspectMobileWorkbench(page, { explorerOpen: true });
|
||||
await page.click('[data-side-tab-jump="wiring"]');
|
||||
@@ -960,6 +966,25 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
visibleText: closed.visibleText
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "mobile-outer-scroll-lock",
|
||||
status: workspaceScroll.outerScrollLocked && help.rootStillLocked && help.helpPanelScrolls ? "pass" : "blocked",
|
||||
summary: "390x844 page locks outer scrolling while the internal Markdown help pane owns overflow scrolling.",
|
||||
observations: {
|
||||
workspace: workspaceScroll,
|
||||
help: {
|
||||
rootStillLocked: help.rootStillLocked,
|
||||
helpPanelScrolls: help.helpPanelScrolls,
|
||||
helpScroll: help.helpScroll
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "mobile-help-markdown-route",
|
||||
status: help.nonDefaultMarkdownHelp && help.termsPresent ? "pass" : "blocked",
|
||||
summary: "Help opens as a non-default internal route rendered from help.md by the vendored Markdown renderer.",
|
||||
observations: help
|
||||
},
|
||||
{
|
||||
id: "mobile-default-hit-test",
|
||||
status: closed.primaryControlsReachable ? "pass" : "blocked",
|
||||
@@ -1197,6 +1222,130 @@ async function inspectMobileWorkbench(page, { explorerOpen }) {
|
||||
}, { explorerOpen });
|
||||
}
|
||||
|
||||
async function inspectWorkbenchScrollContract(page, { panelSelector }) {
|
||||
return page.evaluate(async ({ panelSelector }) => {
|
||||
function metrics(selector) {
|
||||
const element = selector === "html" ? document.documentElement : selector === "body" ? document.body : document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
selector,
|
||||
overflow: style.overflow,
|
||||
overflowY: style.overflowY,
|
||||
height: element.getBoundingClientRect().height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
window.scrollTo(0, 240);
|
||||
document.documentElement.scrollTop = 240;
|
||||
document.body.scrollTop = 240;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rootAfterScrollAttempt = {
|
||||
windowScrollY: window.scrollY,
|
||||
htmlScrollTop: document.documentElement.scrollTop,
|
||||
bodyScrollTop: document.body.scrollTop
|
||||
};
|
||||
|
||||
const panel = document.querySelector(panelSelector);
|
||||
const beforePanelScroll = panel?.scrollTop ?? null;
|
||||
if (panel) {
|
||||
panel.scrollTop = 0;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
const afterPanelScroll = panel?.scrollTop ?? null;
|
||||
const html = metrics("html");
|
||||
const body = metrics("body");
|
||||
const shell = metrics("[data-app-shell]");
|
||||
const panelMetrics = metrics(panelSelector);
|
||||
const rootScrollLocked =
|
||||
html?.overflow === "hidden" &&
|
||||
body?.overflow === "hidden" &&
|
||||
Math.abs((shell?.height ?? 0) - window.innerHeight) <= 2 &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2 &&
|
||||
window.scrollY === 0 &&
|
||||
document.documentElement.scrollTop === 0 &&
|
||||
document.body.scrollTop === 0;
|
||||
return {
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
outerScrollLocked: rootScrollLocked,
|
||||
panelScrolls: Boolean(panelMetrics && panelMetrics.scrollHeight > panelMetrics.clientHeight && afterPanelScroll > beforePanelScroll),
|
||||
rootAfterScrollAttempt,
|
||||
html,
|
||||
body,
|
||||
shell,
|
||||
panel: panelMetrics
|
||||
};
|
||||
}, { panelSelector });
|
||||
}
|
||||
|
||||
async function inspectHelpMarkdownRoute(page) {
|
||||
return page.evaluate(async () => {
|
||||
const workspace = document.querySelector('[data-view="workspace"]');
|
||||
const gate = document.querySelector('[data-view="gate"]');
|
||||
const help = document.querySelector('[data-view="help"]');
|
||||
const helpContent = document.querySelector("#help-content");
|
||||
const heading = helpContent?.querySelector("h1");
|
||||
const renderedLists = helpContent?.querySelectorAll("li").length ?? 0;
|
||||
const codeCount = helpContent?.querySelectorAll("code").length ?? 0;
|
||||
const text = helpContent?.textContent ?? "";
|
||||
const helpScroll = await (async () => {
|
||||
if (!helpContent) return null;
|
||||
helpContent.scrollTop = 0;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const before = helpContent.scrollTop;
|
||||
helpContent.scrollTop = helpContent.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
before,
|
||||
after: helpContent.scrollTop,
|
||||
clientHeight: helpContent.clientHeight,
|
||||
scrollHeight: helpContent.scrollHeight,
|
||||
overflowY: getComputedStyle(helpContent).overflowY
|
||||
};
|
||||
})();
|
||||
const rootStillLocked =
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2;
|
||||
return {
|
||||
hash: window.location.hash,
|
||||
helpState: helpContent?.dataset.helpState ?? null,
|
||||
workspaceHidden: workspace ? workspace.hidden : null,
|
||||
gateHidden: gate ? gate.hidden : null,
|
||||
helpHidden: help ? help.hidden : null,
|
||||
heading: heading?.textContent?.trim() ?? null,
|
||||
renderedLists,
|
||||
codeCount,
|
||||
hasRawMarkdownHeading: Boolean(helpContent?.textContent?.includes("# 云工作台内部使用说明")),
|
||||
termsPresent:
|
||||
["左侧资源与功能导航", "Agent 对话与工作清单", "same-origin", "/v1", ":16666"].every((term) => text.includes(term)) &&
|
||||
codeCount >= 8,
|
||||
helpPanelScrolls: Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before),
|
||||
rootStillLocked,
|
||||
helpScroll,
|
||||
nonDefaultMarkdownHelp:
|
||||
window.location.hash === "#help" &&
|
||||
workspace?.hidden === true &&
|
||||
gate?.hidden === true &&
|
||||
help?.hidden === false &&
|
||||
helpContent?.dataset.helpState === "ready" &&
|
||||
heading?.textContent?.trim() === "云工作台内部使用说明" &&
|
||||
renderedLists >= 8 &&
|
||||
codeCount >= 8 &&
|
||||
!helpContent?.textContent?.includes("# 云工作台内部使用说明") &&
|
||||
rootStillLocked &&
|
||||
Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_DB_ENV_CONTRACT, summarizeDbContract } from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
|
||||
@@ -17,9 +24,9 @@ const namespace = "hwlab-dev";
|
||||
const tcpPorts = [16667, 7000, 7402];
|
||||
const CODE_AGENT_PROVIDER_SECRET_CONTRACT = Object.freeze({
|
||||
sourceIssue: "pikasTech/HWLAB#143",
|
||||
env: "OPENAI_API_KEY",
|
||||
secretName: "hwlab-code-agent-provider",
|
||||
secretKey: "openai-api-key"
|
||||
env: DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].env,
|
||||
secretName: DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretName,
|
||||
secretKey: DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretKey
|
||||
});
|
||||
const runtimeServices = [
|
||||
{ serviceId: "hwlab-cloud-api", port: 6667 },
|
||||
@@ -347,6 +354,7 @@ function inspectCloudApiDbContract(deploy) {
|
||||
valuesRedacted: true,
|
||||
liveDbEvidence: false
|
||||
},
|
||||
readinessLayers: Object.fromEntries(DEV_DB_ENV_CONTRACT.readinessLayers.map((layer) => [layer, { status: "not_proven" }])),
|
||||
blocker: configReady
|
||||
? "Manifest declares DB env/Secret references but does not prove runtime env injection or live DB connection"
|
||||
: "Manifest is missing DB env/Secret references"
|
||||
@@ -363,17 +371,27 @@ function inspectCloudApiDbContract(deploy) {
|
||||
|
||||
function inspectCodeAgentProviderContract(deploy) {
|
||||
const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {};
|
||||
const expectedRef = `secretRef:${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretName}/${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretKey}`;
|
||||
const expectedRef = codeAgentSecretRefPlaceholder();
|
||||
const secretRefPresent = env[CODE_AGENT_PROVIDER_SECRET_CONTRACT.env] === expectedRef;
|
||||
const egressBaseUrl = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL;
|
||||
const egressReady = egressBaseUrl === DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl;
|
||||
const directPublicOpenAi = egressBaseUrl === DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl;
|
||||
return {
|
||||
status: secretRefPresent ? "degraded" : "blocked",
|
||||
status: secretRefPresent && egressReady ? "degraded" : "blocked",
|
||||
sourceIssue: CODE_AGENT_PROVIDER_SECRET_CONTRACT.sourceIssue,
|
||||
provider: DEV_CODE_AGENT_PROVIDER_CONTRACT.runtimeProvider,
|
||||
requiredEnv: [
|
||||
{
|
||||
name: CODE_AGENT_PROVIDER_SECRET_CONTRACT.env,
|
||||
present: Boolean(env[CODE_AGENT_PROVIDER_SECRET_CONTRACT.env]),
|
||||
redacted: true,
|
||||
source: "k8s-secret-ref"
|
||||
},
|
||||
{
|
||||
name: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.env,
|
||||
present: Boolean(egressBaseUrl),
|
||||
redacted: false,
|
||||
source: "runtime-env"
|
||||
}
|
||||
],
|
||||
secretRef: {
|
||||
@@ -387,9 +405,17 @@ function inspectCodeAgentProviderContract(deploy) {
|
||||
secretValueRead: false,
|
||||
redacted: true
|
||||
},
|
||||
egress: {
|
||||
env: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.env,
|
||||
target: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target,
|
||||
present: Boolean(egressBaseUrl),
|
||||
ready: egressReady,
|
||||
directPublicOpenAi,
|
||||
valueRedacted: true
|
||||
},
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
note: "Manifest-only Code Agent provider Secret reference; this is not runtime Secret injection or provider connectivity evidence."
|
||||
note: "Manifest-only Code Agent provider Secret reference and DEV egress/base-url contract; this is not runtime Secret injection or provider connectivity evidence."
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
buildDbHealthContract,
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
||||
import { probeRegistryCapabilities } from "./registry-capabilities.mjs";
|
||||
@@ -18,7 +22,7 @@ const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const defaultReportPath = "reports/dev-gate/dev-preflight-report.json";
|
||||
const issue = "pikasTech/HWLAB#34";
|
||||
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#35", "#39", "#43", "#48", "#49", "#57", "#66"].map(
|
||||
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#35", "#39", "#43", "#48", "#49", "#57", "#66", "#143", "#164"].map(
|
||||
(id) => `pikasTech/HWLAB${id}`
|
||||
);
|
||||
const forbiddenActions = [
|
||||
@@ -1241,6 +1245,7 @@ export async function runPreflight(argv) {
|
||||
|
||||
validateRegistryCapabilities(reporter, registryCapabilities);
|
||||
validateCloudApiDbContract(reporter, deploy, contracts[3], contracts[4]);
|
||||
validateCodeAgentProviderContract(reporter, deploy, contracts[3]);
|
||||
validateArtifactPublishReport(reporter, optionalReports.artifactPublish, artifactIdentity, targetShortCommit, targetCommit, args.targetRef);
|
||||
validateEdgeHealthReport(reporter, optionalReports.edgeHealth);
|
||||
await validateEdgeContracts(reporter, masterEdge);
|
||||
@@ -1257,3 +1262,121 @@ export async function runPreflight(argv) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCodeAgentProviderContract(reporter, deploy, workloads) {
|
||||
const staticContract = inspectCodeAgentProviderStaticContract(deploy, workloads);
|
||||
const evidence = [
|
||||
{
|
||||
staticContract,
|
||||
fixtureEvidence: false,
|
||||
providerConnected: false
|
||||
}
|
||||
];
|
||||
|
||||
if (staticContract.ready) {
|
||||
reporter.check(
|
||||
"code-agent-provider-env-contract",
|
||||
"agent",
|
||||
"pass",
|
||||
"cloud-api DEV Code Agent manifest declares the OpenAI provider Secret reference and DEV egress/proxy base URL without secret values.",
|
||||
evidence
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = [
|
||||
...staticContract.missingDeployEnv,
|
||||
...staticContract.missingK8sEnv,
|
||||
...staticContract.missingSecretRefs,
|
||||
...staticContract.missingEgressContract
|
||||
];
|
||||
reporter.check(
|
||||
"code-agent-provider-env-contract",
|
||||
"agent",
|
||||
"blocked",
|
||||
`cloud-api DEV Code Agent provider contract is missing ${missing.join(", ")}.`,
|
||||
evidence
|
||||
);
|
||||
reporter.block({
|
||||
type: "agent_blocker",
|
||||
scope: "code-agent-provider-env-contract",
|
||||
summary: `cloud-api DEV Code Agent provider contract is incomplete; missing ${missing.join(", ")}.`,
|
||||
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
|
||||
});
|
||||
}
|
||||
|
||||
function inspectCodeAgentProviderStaticContract(deploy, workloads) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const cloudApi = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api") ?? {};
|
||||
const deployEnv = cloudApi.env ?? {};
|
||||
const workloadEnv = getWorkloadEnvByServiceId(workloads, "hwlab-cloud-api");
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const expectedSecretRef = codeAgentSecretRefPlaceholder();
|
||||
const workloadSecretRef = workloadEnv.get(secretRef.env)?.valueFrom?.secretKeyRef;
|
||||
const fields = contract.requiredEnv.map((name) => ({
|
||||
name,
|
||||
manifestPresent: Object.hasOwn(deployEnv, name),
|
||||
k8sPresent: workloadEnv.has(name),
|
||||
redacted: name === secretRef.env,
|
||||
source: name === secretRef.env ? "k8s-secret-ref" : "runtime-env"
|
||||
}));
|
||||
const secretRefMatches =
|
||||
deployEnv[secretRef.env] === expectedSecretRef &&
|
||||
workloadSecretRef?.name === secretRef.secretName &&
|
||||
workloadSecretRef?.key === secretRef.secretKey;
|
||||
const deployBaseUrl = deployEnv[contract.egress.env];
|
||||
const workloadBaseUrl = workloadEnv.get(contract.egress.env)?.value;
|
||||
const egressChecks = [
|
||||
["deployEnv.HWLAB_CODE_AGENT_PROVIDER", deployEnv.HWLAB_CODE_AGENT_PROVIDER, contract.provider],
|
||||
["deployEnv.HWLAB_CODE_AGENT_MODEL", deployEnv.HWLAB_CODE_AGENT_MODEL, contract.model],
|
||||
["deployEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", deployBaseUrl, contract.egress.defaultBaseUrl],
|
||||
["workloadEnv.HWLAB_CODE_AGENT_PROVIDER", workloadEnv.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider],
|
||||
["workloadEnv.HWLAB_CODE_AGENT_MODEL", workloadEnv.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model],
|
||||
["workloadEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", workloadBaseUrl, contract.egress.defaultBaseUrl]
|
||||
];
|
||||
const missingEgressContract = egressChecks
|
||||
.filter(([, actualValue, expectedValue]) => !Object.is(actualValue, expectedValue))
|
||||
.map(([name, actualValue, expectedValue]) => `${name} expected ${expectedValue} got ${actualValue ?? "missing"}`);
|
||||
if (deployBaseUrl === contract.egress.forbiddenDirectBaseUrl || workloadBaseUrl === contract.egress.forbiddenDirectBaseUrl) {
|
||||
missingEgressContract.push("HWLAB_CODE_AGENT_OPENAI_BASE_URL must not point directly at public api.openai.com");
|
||||
}
|
||||
const missingDeployEnv = fields.filter((field) => !field.manifestPresent).map((field) => field.name);
|
||||
const missingK8sEnv = fields.filter((field) => !field.k8sPresent).map((field) => field.name);
|
||||
const missingSecretRefs = secretRefMatches ? [] : [`${secretRef.secretName}/${secretRef.secretKey}`];
|
||||
|
||||
return {
|
||||
contractVersion: contract.contractVersion,
|
||||
environment: contract.environment,
|
||||
provider: contract.runtimeProvider,
|
||||
model: contract.model,
|
||||
ready:
|
||||
missingDeployEnv.length === 0 &&
|
||||
missingK8sEnv.length === 0 &&
|
||||
missingSecretRefs.length === 0 &&
|
||||
missingEgressContract.length === 0,
|
||||
requiredEnv: fields,
|
||||
secretRef: {
|
||||
env: secretRef.env,
|
||||
secretName: secretRef.secretName,
|
||||
secretKey: secretRef.secretKey,
|
||||
present: secretRefMatches,
|
||||
redacted: true
|
||||
},
|
||||
egress: {
|
||||
env: contract.egress.env,
|
||||
target: contract.egress.target,
|
||||
deployPresent: Boolean(deployBaseUrl),
|
||||
workloadPresent: Boolean(workloadBaseUrl),
|
||||
directPublicOpenAi: deployBaseUrl === contract.egress.forbiddenDirectBaseUrl || workloadBaseUrl === contract.egress.forbiddenDirectBaseUrl,
|
||||
valueRedacted: true
|
||||
},
|
||||
missingDeployEnv,
|
||||
missingK8sEnv,
|
||||
missingSecretRefs,
|
||||
missingEgressContract,
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
providerConnected: false,
|
||||
fixtureEvidence: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
parseDbUrlContract
|
||||
} from "../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../internal/cloud/code-agent-contract.mjs";
|
||||
import {
|
||||
ENVIRONMENT_DEV,
|
||||
SERVICE_IDS,
|
||||
@@ -178,8 +182,21 @@ assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_HOST?.value, DEV_DB_ENV_CONTRACT
|
||||
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT?.value, String(DEV_DB_ENV_CONTRACT.dns.port), "cloud-api workload DB port");
|
||||
assertCloudApiDbDnsContract(k8sServices, cloudApi.env);
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5", "cloud-api Code Agent model");
|
||||
assert.equal(cloudApi.env.OPENAI_API_KEY, "secretRef:hwlab-code-agent-provider/openai-api-key", "cloud-api Code Agent OpenAI key must be a Secret reference placeholder");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api Code Agent model");
|
||||
assert.equal(
|
||||
cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl,
|
||||
"cloud-api Code Agent OpenAI base URL must use DEV egress/proxy"
|
||||
);
|
||||
assert.notEqual(
|
||||
cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl,
|
||||
"cloud-api Code Agent OpenAI base URL must not point directly at public api.openai.com"
|
||||
);
|
||||
assert.equal(cloudApi.env.OPENAI_API_KEY, codeAgentSecretRefPlaceholder(), "cloud-api Code Agent OpenAI key must be a Secret reference placeholder");
|
||||
assertCodeAgentProviderWorkloadContract(cloudApiWorkloadEnv);
|
||||
assertDbForbiddenInvalidHostContract();
|
||||
assertValidationDoesNotExposeSecretValues(cloudApi.env, cloudApiWorkloadEnv);
|
||||
|
||||
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
|
||||
|
||||
@@ -225,3 +242,36 @@ function assertCloudApiDbDnsContract(services, env) {
|
||||
const secretUrl = parseDbUrlContract(`postgres://${env.HWLAB_CLOUD_DB_HOST}:${env.HWLAB_CLOUD_DB_PORT}/hwlab`);
|
||||
assert.equal(secretUrl.ok, true, "cloud-api DB stable host/port must form a supported postgres URL");
|
||||
}
|
||||
|
||||
function assertCodeAgentProviderWorkloadContract(env) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const apiKey = env[secretRef.env]?.valueFrom?.secretKeyRef;
|
||||
assert.equal(env.HWLAB_CODE_AGENT_PROVIDER?.value, contract.provider, "cloud-api workload Code Agent provider");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_MODEL?.value, contract.model, "cloud-api workload Code Agent model");
|
||||
assert.equal(
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value,
|
||||
contract.egress.defaultBaseUrl,
|
||||
"cloud-api workload Code Agent OpenAI base URL must use DEV egress/proxy"
|
||||
);
|
||||
assert.notEqual(
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value,
|
||||
contract.egress.forbiddenDirectBaseUrl,
|
||||
"cloud-api workload Code Agent OpenAI base URL must not point directly at public api.openai.com"
|
||||
);
|
||||
assert.equal(apiKey?.name, secretRef.secretName, "cloud-api workload Code Agent OpenAI Secret name");
|
||||
assert.equal(apiKey?.key, secretRef.secretKey, "cloud-api workload Code Agent OpenAI Secret key");
|
||||
}
|
||||
|
||||
function assertDbForbiddenInvalidHostContract() {
|
||||
const invalidHost = parseDbUrlContract("postgres://user:password@hwlab-dev-db.invalid:5432/hwlab");
|
||||
assert.equal(invalidHost.ok, false, "DEV DB contract must reject .invalid runtime hosts");
|
||||
assert.equal(invalidHost.result, "forbidden_runtime_host", "DEV DB invalid host result");
|
||||
assert.equal(invalidHost.classification, "db_url_forbidden_invalid_host", "DEV DB invalid host classification");
|
||||
}
|
||||
|
||||
function assertValidationDoesNotExposeSecretValues(manifestEnv, workloadEnv) {
|
||||
const serialized = JSON.stringify({ manifestEnv, workloadEnv });
|
||||
assert.equal(/sk-[A-Za-z0-9._-]{16,}/u.test(serialized), false, "validation contract must not include OpenAI key material");
|
||||
assert.equal(/postgres:\/\/[^" ]+:[^" ]+@/u.test(serialized), false, "validation contract must not include DB URL credentials");
|
||||
}
|
||||
|
||||
@@ -199,6 +199,8 @@ assert.match(app, /return "workspace";/);
|
||||
assert.match(styles, /\.help-view\s*{[^}]*overflow:\s*hidden;/s);
|
||||
assert.match(styles, /\.help-panel\s*{[^}]*overflow:\s*hidden;/s);
|
||||
assert.match(styles, /\.help-content\s*{[^}]*overflow:\s*auto;/s);
|
||||
assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hidden;[^}]*overscroll-behavior:\s*contain;/s);
|
||||
assert.match(styles, /\.workbench-shell\s*{[^}]*height:\s*100dvh;[^}]*max-height:\s*100dvh;[^}]*overflow:\s*hidden;[^}]*overscroll-behavior:\s*contain;/s);
|
||||
assert.match(helpMarkdown, /^# 云工作台内部使用说明/m);
|
||||
for (const helpTerm of [
|
||||
"左侧资源与功能导航",
|
||||
|
||||
@@ -42,7 +42,10 @@ body {
|
||||
}
|
||||
|
||||
body > [data-app-shell] {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -66,11 +69,13 @@ ul {
|
||||
.workbench-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 92px 292px minmax(460px, 1fr) 366px;
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
background: rgba(15, 17, 16, 0.88);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user