diff --git a/deploy/README.md b/deploy/README.md index 679c208f..9e4fbba7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. diff --git a/deploy/deploy.json b/deploy/deploy.json index 35ae5627..584d6642 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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" } }, diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 21da3741..cfa514fa 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -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" } } }, diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 42959c8b..5365db0f 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -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": { diff --git a/docs/cloud-api-runtime.md b/docs/cloud-api-runtime.md index c5d650c0..6c1ea28c 100644 --- a/docs/cloud-api-runtime.md +++ b/docs/cloud-api-runtime.md @@ -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. diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 75794ab5..49868704 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -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 diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 84c50073..5953e40a 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -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. + ## Runtime Substitution Ban UniDesk services, provider-gateway, backend-core, microservice proxies, local @@ -98,5 +141,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. diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 6752f1ee..3fd75d2f 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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; diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.mjs new file mode 100644 index 00000000..c8089d02 --- /dev/null +++ b/internal/cloud/code-agent-contract.mjs @@ -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 ""; +} diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index b6d635c7..0b7ab905 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -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 { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 652572bc..5b009757 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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"); @@ -152,7 +154,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)); @@ -168,9 +170,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 { @@ -216,14 +221,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); @@ -234,6 +242,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 }) => ({ diff --git a/package.json b/package.json index 8d21d5d3..7767b70b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 41450690..7aa9d680 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-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." ] }; } diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index 4eb46726..6973a479 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -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"); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index ce8bab44..a084aa58 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -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([ diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index 61432675..bff117a0 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -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." }; } diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 8099eb77..27a50e35 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -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 + }; +} diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 00ec16ae..5e5029fa 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -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"); +}