From 9efe2bb7c2af1ae86cb6b2e03a6d0b5359f17b98 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 17:37:42 +0000 Subject: [PATCH 1/5] fix: codify #164 runtime credential and db contracts --- deploy/README.md | 6 + deploy/deploy.json | 1 + deploy/deploy.schema.json | 16 +++ deploy/k8s/base/workloads.yaml | 4 + docs/cloud-api-runtime.md | 18 ++- docs/operator-runbook.md | 26 ++++ docs/reference/dev-runtime-boundary.md | 45 +++++++ internal/cloud/code-agent-chat.mjs | 53 +++++---- internal/cloud/code-agent-contract.mjs | 137 ++++++++++++++++++++++ internal/cloud/db-contract.mjs | 60 +++++++++- internal/cloud/server.test.mjs | 48 +++++++- package.json | 2 +- scripts/code-agent-chat-smoke.mjs | 14 ++- scripts/m1-contract-smoke.mjs | 4 +- scripts/src/deploy-contract-plan.mjs | 54 +++++++++ scripts/src/dev-edge-health-smoke-lib.mjs | 40 +++++-- scripts/src/dev-gate-preflight.mjs | 125 +++++++++++++++++++- scripts/validate-contract.mjs | 54 ++++++++- 18 files changed, 656 insertions(+), 51 deletions(-) create mode 100644 internal/cloud/code-agent-contract.mjs 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"); +} From cd95b39e0e387d3f8961ec40d3a59f918bdd99f5 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 17:55:01 +0000 Subject: [PATCH 2/5] docs: clarify durable runtime readiness boundary --- docs/reference/dev-runtime-boundary.md | 34 ++++++++++++++++++++++++++ internal/cloud/server.test.mjs | 4 +++ scripts/cloud-api-runtime-smoke.mjs | 2 ++ 3 files changed, 40 insertions(+) diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 84c50073..f54cbdc1 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -84,6 +84,40 @@ 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. +## Durable Runtime Readiness Contract + +DB live readiness and durable runtime readiness are separate gates. A +`/health/live` payload with `db.connected=true` and `db.liveDbEvidence=true` +only proves that the Cloud API reached the DEV DB endpoint without exposing +secret values. It does not prove that runtime writes are persisted through a +durable store. + +When health reports: + +- `runtime.adapter: "memory"` +- `runtime.durable: false` +- `runtime.status: "degraded"` + +the Cloud API is using the process-local runtime store in +`internal/db/runtime-store.mjs`. Gateway sessions, box resources, operations, +audit events, and evidence records accepted through the runtime can be lost on +pod restart, redeploy, or scale-out, and cannot be treated as durable M3/M4/M5 +evidence. Users may see accepted operations or evidence disappear after a +runtime replacement, and agent-loop/MVP acceptance must remain degraded or +blocked even if the DB connection layer is green. + +Readiness reports must keep these dimensions distinct: + +| Dimension | Green condition | Degraded/blocking condition | +| --- | --- | --- | +| DB live | `db.ready=true`, `db.connected=true`, `db.liveDbEvidence=true` | Missing env, failed connection, disabled probe, or no `liveDbEvidence` | +| Runtime durability | `runtime.durable=true` with a durable adapter | `runtime.adapter="memory"` or `runtime.durable=false` | + +Do not declare DEV-LIVE complete while `runtime.adapter="memory"` or +`runtime.durable=false`, even when DB live readiness is connected. The next +fix boundary is a durable runtime store and migration path; documenting or +testing this contract must not mutate runtime state or PROD. + ## Runtime Substitution Ban UniDesk services, provider-gateway, backend-core, microservice proxies, local diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 652572bc..b8e5a570 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -117,6 +117,10 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(payload.db.evidence, "live_db_tcp_connection_ready"); assert.equal(payload.db.safety.liveDbEvidence, true); assert.equal(payload.db.safety.liveDbConnectedEvidence, true); + assert.equal(payload.runtime.adapter, "memory"); + assert.equal(payload.runtime.durable, false); + assert.equal(payload.runtime.status, "degraded"); + assert.match(payload.runtime.reason, /process-local/u); assert.equal(payload.db.redaction.valuesRedacted, true); assert.equal(payload.db.redaction.endpointRedacted, true); assert.equal(payload.db.redaction.secretMaterialRead, false); diff --git a/scripts/cloud-api-runtime-smoke.mjs b/scripts/cloud-api-runtime-smoke.mjs index 2c2c3091..855eb1f0 100644 --- a/scripts/cloud-api-runtime-smoke.mjs +++ b/scripts/cloud-api-runtime-smoke.mjs @@ -92,8 +92,10 @@ async function run() { assert.equal(health.body.db.connectionResult, "not_attempted_missing_env"); assert.equal(health.body.db.ready, false); assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); + assert.equal(health.body.runtime.adapter, "memory"); assert.equal(health.body.runtime.durable, false); assert.equal(health.body.runtime.status, "degraded"); + assert.match(health.body.runtime.reason, /process-local/u); logOk("health degraded DB contract"); const v1 = await requestJson(`${baseUrl}/v1`); From cceff2e4b0459f67d51734295f5129ac1d04394e Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 17:59:09 +0000 Subject: [PATCH 3/5] test: verify workbench scroll and markdown help --- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 149 ++++++++++++++++++ web/hwlab-cloud-web/scripts/check.mjs | 2 + web/hwlab-cloud-web/styles.css | 5 + 3 files changed, 156 insertions(+) diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 0ed04fb7..1127a7e7 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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, "\\$&"); } diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index b4f8379a..543ecd37 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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 [ "左侧资源与功能导航", diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 153f6e5d..3e581917 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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); } From 559ef9c3ceae6f9d7eacc7a4be3a6493bde37834 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 18:01:14 +0000 Subject: [PATCH 4/5] fix: show code agent completion evidence --- scripts/code-agent-chat-smoke.mjs | 47 ++++++++++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 73 ++++++++++++++-- web/hwlab-cloud-web/app.mjs | 85 ++++++++++++++++--- web/hwlab-cloud-web/scripts/check.mjs | 21 ++++- 4 files changed, 203 insertions(+), 23 deletions(-) diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 41450690..c45e243f 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -10,6 +10,8 @@ import { const defaultLiveUrl = "http://74.48.78.17:16666/"; const defaultLiveMessage = "请用一句话回复 HWLAB Code Agent readiness。"; +const trustedLiveProviders = new Set(["openai-responses", "codex-cli"]); +const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const args = parseArgs(process.argv.slice(2)); @@ -68,6 +70,19 @@ async function runLocalContractSmoke() { assert.equal(sourceReadiness.devLiveReplyPass, false); logOk("local stub completion is not #143 DEV-LIVE pass"); + const echoReadiness = classifyCodeAgentChatReadiness({ + ...completed, + provider: "echo-mock", + model: "mock-model", + backend: "hwlab-cloud-api/echo-mock" + }, { realDevLive: true, httpStatus: 200 }); + assert.equal(echoReadiness.status, "blocked"); + assert.equal(echoReadiness.level, "BLOCKED/untrusted-completion"); + assert.equal(echoReadiness.blocker, "untrusted-completion"); + assert.equal(echoReadiness.devLiveReplyPass, false); + assert.match(echoReadiness.reason, /echo\/mock\/stub/u); + logOk("echo/mock completion is not real DEV-LIVE pass"); + const failed = await handleCodeAgentChat( { traceId: "trc_code-agent-chat-smoke-failed", @@ -186,6 +201,16 @@ async function runLiveReadinessSmoke(args) { function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) { const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : ""; if (payload?.status === "completed" && assistantReply) { + const evidence = inspectRealCompletionEvidence(payload); + if (!evidence.ok) { + return { + status: "blocked", + level: realDevLive ? "BLOCKED/untrusted-completion" : "SOURCE", + blocker: "untrusted-completion", + devLiveReplyPass: false, + reason: `completed response is missing real provider/model/trace/conversation evidence or looks like echo/mock/stub: ${evidence.reason}` + }; + } if (realDevLive) { return { status: "pass", @@ -224,6 +249,28 @@ function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStat }; } +function inspectRealCompletionEvidence(payload) { + const provider = String(payload?.provider ?? "").trim().toLowerCase(); + const backend = String(payload?.backend ?? "").trim().toLowerCase(); + const missing = []; + if (!payload?.conversationId && !payload?.sessionId) missing.push("conversationId"); + if (!payload?.traceId) missing.push("traceId"); + if (!payload?.messageId) missing.push("messageId"); + if (!payload?.provider) missing.push("provider"); + if (!payload?.model) missing.push("model"); + if (!payload?.backend) missing.push("backend"); + if (missing.length > 0) { + return { ok: false, reason: `missing ${missing.join(",")}` }; + } + if (!trustedLiveProviders.has(provider)) { + return { ok: false, reason: `provider ${payload.provider} is not an accepted real provider` }; + } + if (untrustedProviderPattern.test(`${provider} ${backend}`)) { + return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` }; + } + return { ok: true, reason: "provider/model/trace/conversation evidence present" }; +} + function summarizePayload(payload) { const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : ""; const error = payload?.error diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 0ed04fb7..66f048e3 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -83,6 +83,7 @@ const requiredTrustedRecordTerms = Object.freeze([ "audit.event.query + SOURCE 回退", "evidence.record.query + SOURCE 回退", "conversationId、traceId 和 messageId", + "provider/model/trace/conversation", "失败原因=", "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定", "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定", @@ -119,6 +120,20 @@ const workbenchMarkers = Object.freeze([ "command-form" ]); +const requiredCodeAgentEvidenceTerms = Object.freeze([ + "provider", + "model", + "backend", + "conversation", + "trace", + "message", + "providerTrace", + "openai-responses", + "codex-cli", + "echo/mock/stub", + "untrusted_completion" +]); + const forbiddenWritePatterns = Object.freeze([ /callRpc\(\s*["']hardware\./u, /hardware\.operation\.request/u, @@ -271,6 +286,11 @@ function runStaticSmoke() { evidence: ["BLOCKED 凭证缺口", "provider_unavailable", "OPENAI_API_KEY", "hwlab-code-agent-provider/openai-api-key", "completed -> dev-live guard"] }); + addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed Code Agent replies expose provider/model/trace/conversation evidence and reject echo/mock/stub completions.", { + blocker: "observability_blocker", + evidence: requiredCodeAgentEvidenceTerms + }); + addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { blocker: "safety_blocker", evidence: forbiddenWritePatterns.map(String) @@ -657,10 +677,14 @@ function hasCodeAgentReadinessVisibility({ html, app }) { /hwlab-code-agent-provider\/openai-api-key/u.test(app) && /只有真实 completed 回复才标 DEV-LIVE/u.test(app) && /不能因为只有 conversationId 标为开发实况/u.test(app) && - /Boolean\(message\.provider\)/u.test(app) && - /Boolean\(message\.model\)/u.test(app) && - /Boolean\(message\.backend\)/u.test(app) && - /status === "completed" \? "dev-live"/u.test(app) && + /isTrustedCodeAgentProvider/u.test(app) && + /Boolean\(value\?\.model\)/u.test(app) && + /Boolean\(value\?\.backend\)/u.test(app) && + /Boolean\(value\?\.traceId\)/u.test(app) && + /Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/u.test(app) && + /isRealCompletedChatMessage/u.test(app) && + /isRealCompletedChatResult/u.test(app) && + /hasRealCodeAgentEvidence/u.test(app) && !/status === "failed" \? "dev-live"/u.test(app) && !/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) && !/provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu.test(source) && @@ -668,6 +692,32 @@ function hasCodeAgentReadinessVisibility({ html, app }) { ); } +function hasCodeAgentCompletedEvidenceVisibility({ app }) { + const messageEvidenceBody = functionBody(app, "messageEvidenceFields"); + const realEvidenceBody = functionBody(app, "hasRealCodeAgentEvidence"); + return ( + requiredCodeAgentEvidenceTerms.every((term) => app.includes(term)) && + /conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) && + /providerTrace:\s*result\.providerTrace/u.test(app) && + /function\s+providerTraceSummary\s*\(/u.test(app) && + /recordField\("provider",\s*message\.provider\)/u.test(messageEvidenceBody) && + /recordField\("model",\s*message\.model\)/u.test(messageEvidenceBody) && + /recordField\("backend",\s*message\.backend\)/u.test(messageEvidenceBody) && + /recordField\("conversation",\s*message\.conversationId\)/u.test(messageEvidenceBody) && + /recordField\("trace",\s*message\.traceId\)/u.test(messageEvidenceBody) && + /recordField\("message",\s*message\.messageId\)/u.test(messageEvidenceBody) && + /providerTraceSummary\(message\.providerTrace\)/u.test(messageEvidenceBody) && + /isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) && + /TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) && + /UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) && + /value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) && + /isRealCompletedChatResult\(result\)/u.test(app) && + /realCompleted \? "completed" : "failed"/u.test(app) && + /Code Agent 完成证据不足/u.test(app) && + /本次不会标记为真实完成/u.test(app) + ); +} + function noForbiddenWriteSurface(source) { return forbiddenWritePatterns.every((pattern) => !pattern.test(source)); } @@ -848,7 +898,18 @@ async function fetchText(url) { } function liveHtmlLooksLikeWorkbench(html) { - return /HWLAB 云工作台/u.test(html) && labelsPresent(html, workbenchMarkers) && !/]*>\s*<(?:main|section)[^>]*(?:gate|diagnostics|status|help)/iu.test(html); + const workspaceTag = firstTagForDataView(html, "workspace"); + const gateTag = firstTagForDataView(html, "gate"); + const helpTag = firstTagForDataView(html, "help"); + return ( + /HWLAB 云工作台/u.test(html) && + labelsPresent(html, workbenchMarkers) && + workspaceTag !== null && + !/\bhidden\b/u.test(workspaceTag) && + gateTag !== null && + /\bhidden\b/u.test(gateTag) && + (helpTag === null || /\bhidden\b/u.test(helpTag)) + ); } async function inspectLiveDom(url) { @@ -892,7 +953,7 @@ async function inspectLiveDom(url) { dom.htmlOverflow === "hidden" && dom.workspaceHidden === false && dom.gateHidden === true && - dom.diagnosticsHidden === true && + (dom.diagnosticsHidden === true || dom.diagnosticsHidden === null) && dom.outerScrollLocked && dom.labelsPresent; return { diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index adc97bfb..68c852ad 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -48,6 +48,8 @@ const M3_TRUSTED_ROUTE = Object.freeze({ const LIVE_M3_ID_FIELDS = Object.freeze(["operationId", "traceId", "auditId", "evidenceId"]); const LIVE_M3_PASS_STATUSES = Object.freeze(["pass", "passed", "verified", "succeeded", "trusted", "completed"]); const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]); +const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli"]); +const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); let rpcSequence = 0; @@ -281,27 +283,37 @@ function initCommandBar() { const result = await sendAgentMessage(value, state.conversationId, traceId); state.conversationId = result.conversationId || result.sessionId || state.conversationId; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); - const status = result.status === "completed" ? "completed" : "failed"; + const realCompleted = isRealCompletedChatResult(result); + const status = realCompleted ? "completed" : "failed"; state.chatMessages[index] = { ...pendingMessage, - title: result.status === "completed" ? sourceTitle(result) : "Code Agent 返回失败", - text: result.status === "completed" + title: realCompleted ? sourceTitle(result) : result.status === "completed" ? "Code Agent 完成证据不足" : "Code Agent 返回失败", + text: realCompleted ? result.reply?.content || "Code Agent 没有返回文本。" + : result.status === "completed" + ? untrustedCompletionMessage(result) : failureMessage(result), status, traceId: result.traceId, + conversationId: result.conversationId || result.sessionId || state.conversationId, messageId: result.messageId, provider: result.provider, model: result.model, backend: result.backend, + providerTrace: result.providerTrace, updatedAt: result.updatedAt, - error: result.error, + error: result.error ?? (result.status === "completed" && !realCompleted + ? { + code: "untrusted_completion", + message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。" + } + : undefined), availability: result.availability }; if (result.availability) { state.codeAgentAvailability = result.availability; } - if (result.status !== "completed") { + if (!realCompleted) { el.commandInput.value = value; } renderAgentChatStatus(status, result); @@ -1320,14 +1332,33 @@ function messageCard(message) { article.append(textSpan(roleLabel(message.role), "message-role")); article.append(textSpan(message.title, "message-title")); article.append(textSpan(message.text, "message-copy")); - const meta = [message.provider, message.model, message.backend, message.traceId].filter(Boolean).join(" / "); - const messageMeta = [message.messageId, meta].filter(Boolean).join(" / "); + const messageMeta = messageEvidenceFields(message).join(" / "); if (messageMeta) { article.append(textSpan(messageMeta, "message-meta")); } return article; } +function messageEvidenceFields(message) { + return [ + recordField("provider", message.provider), + recordField("model", message.model), + recordField("backend", message.backend), + recordField("conversation", message.conversationId), + recordField("trace", message.traceId), + recordField("message", message.messageId), + providerTraceSummary(message.providerTrace) + ].filter(Boolean); +} + +function providerTraceSummary(providerTrace) { + if (!providerTrace || typeof providerTrace !== "object") return null; + const responseId = providerTrace.responseId ?? providerTrace.id; + if (responseId) return recordField("providerTrace", responseId); + if (providerTrace.command) return "providerTrace=codex-cli"; + return null; +} + function statusCard(item) { const article = document.createElement("article"); article.className = "status-card"; @@ -1461,8 +1492,8 @@ function codeAgentPromptText(availability) { function codeAgentControlSummary(availability) { if (latestCompletedAgentMessage()) { return state.conversationId - ? `当前会话 ${state.conversationId};最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。` - : "最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。"; + ? `当前会话 ${state.conversationId};最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口,并带有 provider/model/trace/conversation 证据。` + : "最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口,并带有 provider/model/trace/conversation 证据。"; } if (availability?.status === "blocked") { return codeAgentBlockedSummary(availability); @@ -1474,6 +1505,11 @@ function codeAgentBlockedSummary(availability) { return availability?.summary ?? "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。"; } +function untrustedCompletionMessage(result) { + const provider = result?.provider ?? "unknown"; + return `Code Agent 返回 completed,但缺少真实 provider/model/trace/conversation 证据,或 provider=${provider} 属于 echo/mock/stub;本次不会标记为真实完成。`; +} + function codeAgentAvailabilitySummary() { if (state.codeAgentAvailability?.status === "blocked") { return "同源 API 可响应;Code Agent 为 BLOCKED/凭证缺口,真实后端已接入但 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入。"; @@ -1487,14 +1523,35 @@ function codeAgentAvailabilitySummary() { function latestCompletedAgentMessage() { return [...state.chatMessages].reverse().find((message) => message.role === "agent" && - message.status === "completed" && - !message.error && - Boolean(message.provider) && - Boolean(message.model) && - Boolean(message.backend) + isRealCompletedChatMessage(message) ) ?? null; } +function isRealCompletedChatResult(result) { + const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; + return result?.status === "completed" && assistantReply.length > 0 && hasRealCodeAgentEvidence(result); +} + +function isRealCompletedChatMessage(message) { + return message?.status === "completed" && !message.error && hasRealCodeAgentEvidence(message); +} + +function hasRealCodeAgentEvidence(value) { + return ( + isTrustedCodeAgentProvider(value?.provider) && + Boolean(value?.model) && + Boolean(value?.backend) && + Boolean(value?.traceId) && + Boolean(value?.conversationId || value?.sessionId) && + !UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN.test(`${value?.provider ?? ""} ${value?.backend ?? ""}`) + ); +} + +function isTrustedCodeAgentProvider(provider) { + const normalized = String(provider ?? "").trim().toLowerCase(); + return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized); +} + function latestChatResult() { return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null; } diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index b4f8379a..b07b36f8 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -50,6 +50,7 @@ const requiredTrustedRecordTerms = Object.freeze([ "audit.event.query + SOURCE 回退", "evidence.record.query + SOURCE 回退", "conversationId、traceId 和 messageId", + "provider/model/trace/conversation", "失败原因=", "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定", "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定", @@ -283,9 +284,23 @@ assert.match(app, /OPENAI_API_KEY/); assert.match(app, /hwlab-code-agent-provider\/openai-api-key/); assert.match(app, /只有真实 completed 回复才标 DEV-LIVE/); assert.match(app, /不能因为只有 conversationId 标为开发实况/); -assert.match(app, /Boolean\(message\.provider\)/); -assert.match(app, /Boolean\(message\.model\)/); -assert.match(app, /Boolean\(message\.backend\)/); +assert.match(app, /TRUSTED_CODE_AGENT_PROVIDERS/); +assert.match(app, /UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/); +assert.match(app, /isRealCompletedChatResult/); +assert.match(app, /isRealCompletedChatMessage/); +assert.match(app, /hasRealCodeAgentEvidence/); +assert.match(app, /isTrustedCodeAgentProvider\(value\?\.provider\)/); +assert.match(app, /Boolean\(value\?\.model\)/); +assert.match(app, /Boolean\(value\?\.backend\)/); +assert.match(app, /Boolean\(value\?\.traceId\)/); +assert.match(app, /Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/); +assert.match(app, /providerTrace:\s*result\.providerTrace/); +assert.match(app, /providerTraceSummary\(message\.providerTrace\)/); +assert.match(app, /recordField\("conversation", message\.conversationId\)/); +assert.match(app, /recordField\("provider", message\.provider\)/); +assert.match(app, /Code Agent 完成证据不足/); +assert.match(app, /本次不会标记为真实完成/); +assert.match(app, /untrusted_completion/); assert.match(app, /status === "completed" \? "dev-live"/); assert.doesNotMatch(app, /status === "failed" \? "dev-live"/); assert.doesNotMatch(`${html}\n${app}`, /provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu); From 8ff93656ed1b301e55ef5f8031c20d0ed4f2b3fd Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 18:06:24 +0000 Subject: [PATCH 5/5] test: add m3 source contract dry-run plan --- docs/dev-evidence-blocker-aggregator.md | 2 +- docs/m3-hardware-loop.md | 42 ++- docs/reference/m3-loop-rollout-runbook.md | 20 ++ reports/dev-gate/dev-m3-hardware-loop.json | 253 +++++++++++++++ scripts/dev-m3-hardware-loop-smoke.mjs | 293 +++++++++++++++++- .../src/dev-evidence-blocker-aggregator.mjs | 5 +- scripts/validate-dev-gate-report.mjs | 69 ++++- scripts/validate-m3-rollout-runbook.mjs | 3 + 8 files changed, 663 insertions(+), 24 deletions(-) diff --git a/docs/dev-evidence-blocker-aggregator.md b/docs/dev-evidence-blocker-aggregator.md index 9d795214..3aeafc2c 100644 --- a/docs/dev-evidence-blocker-aggregator.md +++ b/docs/dev-evidence-blocker-aggregator.md @@ -77,7 +77,7 @@ green evidence. | M0 | `SOURCE` | `docs/m0-contract-audit.md`, protocol schemas, evidence chain examples, M0 validator. | | M1 | `LOCAL` | `docs/m1-local-smoke.md`, `fixtures/mvp/runtime.json`, `scripts/m1-contract-smoke.mjs`. | | M2 | `DEV-LIVE` for frontend/route only | DEV deploy smoke fixture plus active read-only `16666`/`16667` endpoint smoke and accepted frontend revision. | -| M3 | `BLOCKED` | Hardware trusted loop topology fixture and local patch-panel smoke exist, but DEV-LIVE operation/trace/audit/evidence IDs are missing. | +| M3 | `BLOCKED` | Hardware trusted loop topology fixture, local patch-panel smoke, and the no-write DEV plan exist, but DEV-LIVE operation/trace/audit/evidence IDs for `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` are missing. | | M4 | `BLOCKED` | Agent automation loop fixture and local smoke exist, but live preflight is blocked at DB live readiness. | | M5 | `BLOCKED` | MVP e2e dry-run plan is green, but bounded DEV-LIVE acceptance is blocked by DB/M3/M4 and source/artifact coverage. | diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index 51056fda..059d254d 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -72,16 +72,33 @@ remains `hwlab-patch-panel`, which calls the box internal port API. ## DEV Runtime Smoke -Use this command only for the DEV simulator runtime: +Use the no-write plan first: ```sh -node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production +node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run ``` -The smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first checks -the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the first -critical blocker from `docs/dev-acceptance-matrix.md`. When DEV ingress is not -observable, the report is `blocked` and the script does not run the live +The dry-run plan writes `reports/dev-gate/dev-m3-hardware-loop.json` by +default, records `dryRunPlan.evidenceLevel: "DRY-RUN"`, lists the live write +preconditions, enumerates the endpoints that a later live run would call, and +keeps `liveOperation.status: "not_run"`. It does not call DEV endpoints, does +not send `/ports/write` or `/signals/route`, and cannot be promoted to +`DEV-LIVE`. + +Use this command only for the approved DEV simulator runtime window: + +```sh +node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod +``` + +The legacy `--confirmed-non-production` flag is accepted as compatibility, but +new handoffs should use `--expect-non-prod`. The script refuses the default +mode and refuses `--dry-run` combined with `--live`. + +The live smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first +checks the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the +first critical blocker from `docs/dev-acceptance-matrix.md`. When DEV ingress +is not observable, the report is `blocked` and the script does not run the live `do.write true -> di.read true` operation. If DEV ingress is reachable, the script requires explicit direct DEV simulator @@ -95,11 +112,14 @@ HWLAB_DEV_GATEWAY_SIMU_2_URL HWLAB_DEV_PATCH_PANEL_URL ``` -Those targets must be HWLAB DEV simulator services, not UniDesk substitutes. -The script then checks two box simulators, two gateway simulators, active -patch-panel wiring for `box-simu-1 DO1 -> box-simu-2 DI1`, a live direct call -that reads `DI1=true`, and returned audit/evidence identifiers. Fixture-backed -local M3 output is never accepted as live DEV evidence. +Those targets must be two distinct HWLAB DEV simulator services for each +simulator kind, plus one HWLAB DEV patch panel, not UniDesk substitutes. The +script then checks two box simulators, two gateway simulators, active +patch-panel wiring for +`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`, a live direct +call that reads `DI1=true`, and returned operation, trace, audit, and evidence +identifiers. Fixture-backed local M3 output is never accepted as live DEV +evidence. ## Read-Only Continuation Evidence diff --git a/docs/reference/m3-loop-rollout-runbook.md b/docs/reference/m3-loop-rollout-runbook.md index 0fa7dc16..dc8c32a4 100644 --- a/docs/reference/m3-loop-rollout-runbook.md +++ b/docs/reference/m3-loop-rollout-runbook.md @@ -58,6 +58,26 @@ Cloud Web polish, generic console work, artifact reports, and desired-state planning are support surfaces only. They can help explain the system, but they do not count as M3 PASS evidence. +After the DB and Code Agent provider unblock, the next acceptable M3 step is +still not a blind live write. Run the no-write source contract first: + +```sh +node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run +``` + +The dry-run output is `DRY-RUN` evidence. It must list the live write +preconditions, the exact endpoints a later live run would call, and the +required evidence fields while keeping `liveOperation.status: "not_run"`. +Only an explicitly approved live window may use: + +```sh +node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod +``` + +The live command remains DEV-only and must stop before mutation unless the two +box-simu identities, two gateway-simu identities, and patch-panel-owned M3 +wiring are all observed. + ## Manual Acceptance Steps | Step | What to observe | Must have evidence | Failure class | Must not misread | diff --git a/reports/dev-gate/dev-m3-hardware-loop.json b/reports/dev-gate/dev-m3-hardware-loop.json index ce13d93a..4d8e5260 100644 --- a/reports/dev-gate/dev-m3-hardware-loop.json +++ b/reports/dev-gate/dev-m3-hardware-loop.json @@ -29,6 +29,8 @@ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", "node --check scripts/validate-dev-m3-cardinality.mjs", "node scripts/validate-dev-m3-cardinality.mjs", + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" @@ -40,13 +42,18 @@ "environment": "dev", "requiredBoxSimulators": 2, "requiredGatewaySimulators": 2, + "requiredPatchPanels": 1, + "requiredRoute": "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", "realHardwareAllowed": false, "prodAllowed": false }, "safetyGates": { "liveFlagRequired": true, "confirmDevRequired": true, + "expectNonProdRequired": true, "confirmedNonProductionRequired": true, + "dryRunPlanSupported": true, + "dryRunCallsLiveEndpoints": false, "prodForbidden": true, "realHardwareForbidden": true, "secretReadForbidden": true, @@ -71,6 +78,16 @@ "http://74.48.78.17:16667" ] }, + { + "id": "dry-run-live-write-plan", + "status": "not_run", + "summary": "Plan-only mode enumerated live write prerequisites and endpoints without calling DEV endpoints or mutating DEV state.", + "evidence": [ + "endpointCount=17", + "mutatingEndpointCount=2", + "dryRunCallsLiveEndpoints=false" + ] + }, { "id": "dev-ingress-health", "status": "pass", @@ -245,6 +262,13 @@ "classification": "direct_target_m3_wiring_missing", "sourceIssue": "pikasTech/HWLAB#64", "summary": "DEV patch-panel is callable, but live wiring does not contain res_boxsimu_1:DO1 -> res_boxsimu_2:DI1; active=res_boxsim_alpha:uart0->res_boxsim_beta:uart0, res_boxsim_alpha:gpio0->res_boxsim_beta:gpio0; configured=res_boxsim_alpha:uart0->res_boxsim_beta:uart0, res_boxsim_alpha:gpio0->res_boxsim_beta:gpio0." + }, + { + "type": "safety_blocker", + "scope": "m3-live-write-authorization", + "status": "open", + "classification": "dry_run_plan_only", + "summary": "Plan-only mode is intentionally non-mutating; run the bounded live smoke only after explicit DEV/non-PROD approval and after read-only preconditions identify the exact HWLAB targets." } ], "summary": { @@ -471,5 +495,234 @@ "syncableConnectionCount": 0 }, "summary": "Direct DEV M3 targets were probed read-only, but required identities or patch-panel M3 wiring were not satisfied." + }, + "dryRunPlan": { + "mode": "plan-only", + "evidenceLevel": "DRY-RUN", + "route": "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + "dryRunCallsLiveEndpoints": false, + "liveWriteWillRun": false, + "liveWriteRequiresHumanApproval": true, + "liveCommand": "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", + "legacyLiveCommand": "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", + "liveWritePreconditions": [ + "operator has authorized a bounded DEV M3 live smoke window", + "command includes --live, --confirm-dev, and --expect-non-prod", + "PROD, real hardware, secret reads, service restarts, and force pushes remain forbidden", + "DEV ingress identifies HWLAB DEV on the frozen 16666/16667 boundary", + "two distinct hwlab-box-simu targets report res_boxsimu_1 and res_boxsimu_2", + "two distinct hwlab-gateway-simu targets report distinct DEV gateway sessions", + "one hwlab-patch-panel reports active wiring for res_boxsimu_1:DO1 -> res_boxsimu_2:DI1", + "cross-device propagation is owned by hwlab-patch-panel; box loopback, SOURCE, LOCAL, fixture, and DRY-RUN output cannot be marked DEV-LIVE" + ], + "endpointPlan": [ + { + "phase": "dev-ingress-precondition", + "targetId": "dev-api-edge", + "serviceId": "hwlab-cloud-api", + "method": "GET", + "path": "/health/live", + "url": "http://74.48.78.17:16667/health/live", + "urlSource": "frozen-dev-endpoint", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "dev-ingress-precondition", + "targetId": "dev-api-edge-json-rpc", + "serviceId": "hwlab-cloud-api", + "method": "POST", + "path": "/json-rpc", + "url": "http://74.48.78.17:16667/json-rpc", + "urlSource": "frozen-dev-endpoint", + "mutatesDevState": false, + "calledInDryRun": false, + "requestShape": "system.health" + }, + { + "phase": "dev-ingress-precondition", + "targetId": "dev-frontend", + "serviceId": "hwlab-cloud-web", + "method": "GET", + "path": "/health/live", + "url": "http://74.48.78.17:16666/health/live", + "urlSource": "frozen-dev-frontend-endpoint", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-1", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_BOX_SIMU_1_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-1", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_BOX_SIMU_1_URL/status", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-2", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_BOX_SIMU_2_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-2", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_BOX_SIMU_2_URL/status", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-1", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_GATEWAY_SIMU_1_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-1", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_GATEWAY_SIMU_1_URL/status", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-2", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_GATEWAY_SIMU_2_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-2", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_GATEWAY_SIMU_2_URL/status", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/status", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "patch-panel-route-precondition", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "GET", + "path": "/wiring", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/wiring", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "live-write-source-do1", + "targetId": "box-simu-1", + "serviceId": "hwlab-box-simu", + "method": "POST", + "path": "/ports/write", + "url": "$HWLAB_DEV_BOX_SIMU_1_URL/ports/write", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_1_URL", + "mutatesDevState": true, + "calledInDryRun": false + }, + { + "phase": "live-write-patch-panel-route", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "POST", + "path": "/signals/route", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/signals/route", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": true, + "calledInDryRun": false + }, + { + "phase": "live-read-target-di1", + "targetId": "box-simu-2", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_BOX_SIMU_2_URL/status", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + } + ], + "evidenceFields": [ + "operationId", + "traceId", + "auditId", + "evidenceId", + "sourceResourceId=res_boxsimu_1", + "sourcePort=DO1", + "routeOwner=hwlab-patch-panel", + "routeResponse.propagatedBy=hwlab-patch-panel", + "targetResourceId=res_boxsimu_2", + "targetPort=DI1", + "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" + ], + "refusalPolicy": [ + "no arguments defaults to refusal", + "--dry-run/--plan cannot be combined with --live/--allow-live", + "live mode refuses without --confirm-dev and --expect-non-prod", + "a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass" + ] } } diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index 21a8a4b7..f729fbe5 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -19,6 +19,9 @@ const issue = "pikasTech/HWLAB#38"; const deployWorkloadsPath = "deploy/k8s/base/workloads.yaml"; const deployServicesPath = "deploy/k8s/base/services.yaml"; const requiredManifestCardinalityCommand = "node scripts/validate-dev-m3-cardinality.mjs"; +const dryRunPlanCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run"; +const liveSmokeCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod"; +const legacyLiveSmokeCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"; const requiredM3BoxIds = Object.freeze(["boxsimu_1", "boxsimu_2"]); const requiredM3BoxResources = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]); const requiredM3Connection = Object.freeze({ @@ -84,10 +87,15 @@ function parseArgs(argv) { flags.add(arg); } - return { flags, values }; + return { + flags, + values, + dryRun: flags.has("--dry-run") || flags.has("--plan"), + live: flags.has("--live") || flags.has("--allow-live") + }; } -function requireSafetyGates(flags) { +function requireSafetyGates({ flags, dryRun, live }) { const forbiddenFlags = [ "--prod", "--real-hardware", @@ -104,13 +112,32 @@ function requireSafetyGates(flags) { assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`); } - const live = flags.has("--live") || flags.has("--allow-live"); - assert.ok(live, "DEV M3 smoke requires --live or --allow-live"); + assert.ok( + !(dryRun && live), + "DEV M3 smoke refuses to combine --dry-run/--plan with --live/--allow-live; plan mode never runs the M3 write path" + ); + + if (dryRun) { + return { + mode: "dry-run", + liveWriteAllowed: false + }; + } + + assert.ok( + live, + "DEV M3 smoke refuses to run by default; use --dry-run for a no-write plan or --live --confirm-dev --expect-non-prod after approval" + ); assert.ok(flags.has("--confirm-dev"), "live DEV smoke requires --confirm-dev"); assert.ok( - flags.has("--confirmed-non-production"), - "live DEV smoke requires --confirmed-non-production" + flags.has("--expect-non-prod") || flags.has("--confirmed-non-production"), + "live DEV smoke requires --expect-non-prod (legacy --confirmed-non-production is accepted only as compatibility)" ); + + return { + mode: "live", + liveWriteAllowed: true + }; } function isoNow() { @@ -484,6 +511,152 @@ function joinUrl(baseUrl, routePath) { return new URL(routePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString(); } +function endpointReference(target, routePath) { + const baseUrl = process.env[target.urlEnv]; + if (baseUrl) { + return { + url: joinUrl(baseUrl, routePath), + urlSource: `env:${target.urlEnv}` + }; + } + + return { + url: `$${target.urlEnv}${routePath}`, + urlSource: `required-env:${target.urlEnv}` + }; +} + +function plannedServiceEndpoint(target, routePath, { method = "GET", phase, mutatesDevState = false }) { + const endpoint = endpointReference(target, routePath); + return { + phase, + targetId: target.id, + serviceId: target.serviceId, + method, + path: routePath, + ...endpoint, + mutatesDevState, + calledInDryRun: false + }; +} + +function createLiveOperationPlan() { + const box1 = serviceTargets.find((target) => target.id === "box-simu-1"); + const box2 = serviceTargets.find((target) => target.id === "box-simu-2"); + const patchPanel = serviceTargets.find((target) => target.id === "patch-panel"); + const endpointPlan = [ + { + phase: "dev-ingress-precondition", + targetId: "dev-api-edge", + serviceId: "hwlab-cloud-api", + method: "GET", + path: "/health/live", + url: joinUrl(DEV_ENDPOINT, "/health/live"), + urlSource: "frozen-dev-endpoint", + mutatesDevState: false, + calledInDryRun: false + }, + { + phase: "dev-ingress-precondition", + targetId: "dev-api-edge-json-rpc", + serviceId: "hwlab-cloud-api", + method: "POST", + path: "/json-rpc", + url: joinUrl(DEV_ENDPOINT, "/json-rpc"), + urlSource: "frozen-dev-endpoint", + mutatesDevState: false, + calledInDryRun: false, + requestShape: "system.health" + }, + { + phase: "dev-ingress-precondition", + targetId: "dev-frontend", + serviceId: "hwlab-cloud-web", + method: "GET", + path: "/health/live", + url: joinUrl(DEV_FRONTEND_ENDPOINT, "/health/live"), + urlSource: "frozen-dev-frontend-endpoint", + mutatesDevState: false, + calledInDryRun: false + } + ]; + + for (const target of serviceTargets) { + endpointPlan.push( + plannedServiceEndpoint(target, "/health/live", { + phase: "direct-target-precondition" + }), + plannedServiceEndpoint(target, target.statusPath, { + phase: "direct-target-precondition" + }) + ); + if (target.wiringPath) { + endpointPlan.push( + plannedServiceEndpoint(target, target.wiringPath, { + phase: "patch-panel-route-precondition" + }) + ); + } + } + + endpointPlan.push( + plannedServiceEndpoint(box1, "/ports/write", { + method: "POST", + phase: "live-write-source-do1", + mutatesDevState: true + }), + plannedServiceEndpoint(patchPanel, patchPanel.routePath, { + method: "POST", + phase: "live-write-patch-panel-route", + mutatesDevState: true + }), + plannedServiceEndpoint(box2, "/status", { + phase: "live-read-target-di1" + }) + ); + + return { + mode: "plan-only", + evidenceLevel: "DRY-RUN", + route: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + dryRunCallsLiveEndpoints: false, + liveWriteWillRun: false, + liveWriteRequiresHumanApproval: true, + liveCommand: liveSmokeCommand, + legacyLiveCommand: legacyLiveSmokeCommand, + liveWritePreconditions: [ + "operator has authorized a bounded DEV M3 live smoke window", + "command includes --live, --confirm-dev, and --expect-non-prod", + "PROD, real hardware, secret reads, service restarts, and force pushes remain forbidden", + "DEV ingress identifies HWLAB DEV on the frozen 16666/16667 boundary", + "two distinct hwlab-box-simu targets report res_boxsimu_1 and res_boxsimu_2", + "two distinct hwlab-gateway-simu targets report distinct DEV gateway sessions", + "one hwlab-patch-panel reports active wiring for res_boxsimu_1:DO1 -> res_boxsimu_2:DI1", + "cross-device propagation is owned by hwlab-patch-panel; box loopback, SOURCE, LOCAL, fixture, and DRY-RUN output cannot be marked DEV-LIVE" + ], + endpointPlan, + evidenceFields: [ + "operationId", + "traceId", + "auditId", + "evidenceId", + "sourceResourceId=res_boxsimu_1", + "sourcePort=DO1", + "routeOwner=hwlab-patch-panel", + "routeResponse.propagatedBy=hwlab-patch-panel", + "targetResourceId=res_boxsimu_2", + "targetPort=DI1", + "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" + ], + refusalPolicy: [ + "no arguments defaults to refusal", + "--dry-run/--plan cannot be combined with --live/--allow-live", + "live mode refuses without --confirm-dev and --expect-non-prod", + "a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass" + ] + }; +} + function isHwlabDevIdentity(probe) { const body = probe.json; if (!probe.ok || !body || typeof body !== "object" || Array.isArray(body)) { @@ -1075,6 +1248,7 @@ async function runLiveM3Targets(targets) { probes.push({ id: "patch-panel", kind: "signals.route", probe: route }); assert.ok(route.ok, "patch-panel signal route failed"); assert.equal(route.json?.accepted, true, "patch-panel signal route accepted"); + assert.equal(route.json?.propagatedBy, "hwlab-patch-panel", "patch-panel signal route owner"); assert.equal(route.json?.deliveryCount, 1, "patch-panel signal route delivery count"); const after = await probeJson(joinUrl(box2Target.baseUrl, "/status")); @@ -1122,7 +1296,9 @@ function baseReport({ commitId, observedAt }) { "node --check scripts/dev-m3-hardware-loop-smoke.mjs", "node --check scripts/validate-dev-m3-cardinality.mjs", requiredManifestCardinalityCommand, - "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", + dryRunPlanCommand, + liveSmokeCommand, + legacyLiveSmokeCommand, "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ], @@ -1133,13 +1309,18 @@ function baseReport({ commitId, observedAt }) { environment: ENVIRONMENT_DEV, requiredBoxSimulators: 2, requiredGatewaySimulators: 2, + requiredPatchPanels: 1, + requiredRoute: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", realHardwareAllowed: false, prodAllowed: false }, safetyGates: { liveFlagRequired: true, confirmDevRequired: true, + expectNonProdRequired: true, confirmedNonProductionRequired: true, + dryRunPlanSupported: true, + dryRunCallsLiveEndpoints: false, prodForbidden: true, realHardwareForbidden: true, secretReadForbidden: true, @@ -1193,6 +1374,94 @@ function baseReport({ commitId, observedAt }) { }; } +async function runDryRunPlan({ values }) { + await ensureContractFiles(); + + const reportPath = resolveReportPath(values.get("output")); + const observedAt = isoNow(); + const report = baseReport({ + commitId: currentCommit(), + observedAt + }); + const plan = createLiveOperationPlan(); + + report.dryRunPlan = plan; + report.readOnlySupplementalEvidence = await collectReadOnlySupplementalEvidence(); + report.liveChecks.push( + { + id: "dry-run-live-write-plan", + status: "not_run", + summary: "Plan-only mode enumerated live write prerequisites and endpoints without calling DEV endpoints or mutating DEV state.", + evidence: [ + `endpointCount=${plan.endpointPlan.length}`, + `mutatingEndpointCount=${plan.endpointPlan.filter((endpoint) => endpoint.mutatesDevState).length}`, + "dryRunCallsLiveEndpoints=false" + ] + }, + { + id: "two-box-simu-online", + status: "not_run", + summary: "Plan-only mode requires two distinct live DEV box-simu identities before any write; no DEV target was probed.", + evidence: ["required=res_boxsimu_1,res_boxsimu_2"] + }, + { + id: "two-gateway-simu-online", + status: "not_run", + summary: "Plan-only mode requires two distinct live DEV gateway-simu identities before any write; no DEV target was probed.", + evidence: ["required=gwsimu_1,gwsimu_2"] + }, + { + id: "patch-panel-healthy", + status: "not_run", + summary: "Plan-only mode requires a callable DEV hwlab-patch-panel before any write; no DEV target was probed.", + evidence: ["required=hwlab-patch-panel"] + }, + { + id: "wiring-do1-di1-applied", + status: "not_run", + summary: "Plan-only mode requires patch-panel-owned res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 wiring before any write.", + evidence: [`route=${plan.route}`] + }, + { + id: "direct-call-do-write-di-read", + status: "not_run", + summary: "Plan-only mode did not send /ports/write or /signals/route; this output is DRY-RUN only and cannot be labeled DEV-LIVE.", + evidence: ["No live DEV write was sent."] + }, + { + id: "audit-evidence-traceable", + status: "not_run", + summary: "Plan-only mode listed the required operation, trace, audit, and evidence fields but did not create them.", + evidence: plan.evidenceFields + } + ); + report.liveOperation = { + status: "not_run", + operationId: "not_observed", + traceId: "not_observed", + auditId: "not_observed", + evidenceId: "not_observed", + summary: "Dry-run plan only; no DEV-LIVE operation was attempted or claimed." + }; + report.blockers.push({ + type: "safety_blocker", + scope: "m3-live-write-authorization", + status: "open", + classification: "dry_run_plan_only", + summary: "Plan-only mode is intentionally non-mutating; run the bounded live smoke only after explicit DEV/non-PROD approval and after read-only preconditions identify the exact HWLAB targets." + }); + report.summary = { + status: "blocked", + classification: "dry_run_plan_only", + observedAt, + result: "DRY-RUN plan emitted live write prerequisites, endpoint plan, and required evidence fields; it did not call DEV endpoints or produce DEV-LIVE evidence." + }; + + await writeReport(report, reportPath); + console.log(`[dev-m3-smoke] mode=dry-run status=blocked report=${relativePath(reportPath)}`); + console.log("[dev-m3-smoke] dry-run: no DEV endpoint calls and no live write were sent"); +} + function addNotRunM3Checks(report, reason) { for (const id of [ "two-box-simu-online", @@ -1223,8 +1492,14 @@ async function writeReport(report, reportPath) { } async function main() { - const { flags, values } = parseArgs(process.argv.slice(2)); - requireSafetyGates(flags); + const args = parseArgs(process.argv.slice(2)); + const safety = requireSafetyGates(args); + if (safety.mode === "dry-run") { + await runDryRunPlan(args); + return; + } + + const { values } = args; await ensureContractFiles(); const reportPath = resolveReportPath(values.get("output")); diff --git a/scripts/src/dev-evidence-blocker-aggregator.mjs b/scripts/src/dev-evidence-blocker-aggregator.mjs index ebfae73e..bc99ecc0 100644 --- a/scripts/src/dev-evidence-blocker-aggregator.mjs +++ b/scripts/src/dev-evidence-blocker-aggregator.mjs @@ -522,7 +522,10 @@ function collectM3Evidence(reports) { `auditId=${m3.liveOperation?.auditId ?? "not_observed"}`, `evidenceId=${m3.liveOperation?.evidenceId ?? "not_observed"}` ], - commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"], + commands: [ + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod" + ], summary: liveSummary }) ]; diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 1b86e14a..1b04b3c3 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -85,8 +85,11 @@ const requiredDevM3ValidationCommands = [ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", "node --check scripts/validate-dev-m3-cardinality.mjs", "node scripts/validate-dev-m3-cardinality.mjs", - "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod" ]; +const legacyDevM3LiveCommand = + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"; const requiredDevM3Docs = [ "docs/dev-acceptance-matrix.md", "docs/m3-hardware-loop.md" @@ -1334,6 +1337,10 @@ async function validateDevM3Report(report, label) { `${label}.validationCommands missing ${requiredCommand}` ); } + assert.ok( + report.validationCommands.includes(legacyDevM3LiveCommand), + `${label}.validationCommands missing legacy compatibility command ${legacyDevM3LiveCommand}` + ); assertObject(report.runtimeTarget, `${label}.runtimeTarget`); assert.equal(report.runtimeTarget.endpoint, "http://74.48.78.17:16667", `${label}.runtimeTarget.endpoint`); @@ -1348,6 +1355,16 @@ async function validateDevM3Report(report, label) { assert.equal(report.runtimeTarget.environment, "dev", `${label}.runtimeTarget.environment`); assert.equal(report.runtimeTarget.requiredBoxSimulators, 2, `${label}.runtimeTarget.requiredBoxSimulators`); assert.equal(report.runtimeTarget.requiredGatewaySimulators, 2, `${label}.runtimeTarget.requiredGatewaySimulators`); + if (Object.hasOwn(report.runtimeTarget, "requiredPatchPanels")) { + assert.equal(report.runtimeTarget.requiredPatchPanels, 1, `${label}.runtimeTarget.requiredPatchPanels`); + } + if (Object.hasOwn(report.runtimeTarget, "requiredRoute")) { + assert.equal( + report.runtimeTarget.requiredRoute, + "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + `${label}.runtimeTarget.requiredRoute` + ); + } assert.equal(report.runtimeTarget.realHardwareAllowed, false, `${label}.runtimeTarget.realHardwareAllowed`); assert.equal(report.runtimeTarget.prodAllowed, false, `${label}.runtimeTarget.prodAllowed`); @@ -1355,7 +1372,9 @@ async function validateDevM3Report(report, label) { for (const field of [ "liveFlagRequired", "confirmDevRequired", + "expectNonProdRequired", "confirmedNonProductionRequired", + "dryRunPlanSupported", "prodForbidden", "realHardwareForbidden", "secretReadForbidden", @@ -1364,6 +1383,9 @@ async function validateDevM3Report(report, label) { ]) { assert.equal(report.safetyGates[field], true, `${label}.safetyGates.${field}`); } + if (Object.hasOwn(report.safetyGates, "dryRunCallsLiveEndpoints")) { + assert.equal(report.safetyGates.dryRunCallsLiveEndpoints, false, `${label}.safetyGates.dryRunCallsLiveEndpoints`); + } assertArray(report.liveChecks, `${label}.liveChecks`); assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`); @@ -1449,7 +1471,14 @@ async function validateDevM3Report(report, label) { ["pass", "gap", "source-ready", "manifest-ready"].includes(evidence.status), `${evidenceLabel}.status must be pass, gap, source-ready, or manifest-ready` ); - assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`); + if (Array.isArray(evidence.source)) { + assertStringArray(evidence.source, `${evidenceLabel}.source`, { minLength: 1 }); + for (const [sourceIndex, sourcePath] of evidence.source.entries()) { + assertRepoRelativePath(sourcePath, `${evidenceLabel}.source[${sourceIndex}]`); + } + } else { + assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`); + } assertString(evidence.summary, `${evidenceLabel}.summary`); assertObject(evidence.evidence, `${evidenceLabel}.evidence`); assertString(evidence.requiredFollowUp, `${evidenceLabel}.requiredFollowUp`); @@ -1462,6 +1491,42 @@ async function validateDevM3Report(report, label) { } } + if (Object.hasOwn(report, "dryRunPlan")) { + const plan = report.dryRunPlan; + assertObject(plan, `${label}.dryRunPlan`); + assert.equal(plan.mode, "plan-only", `${label}.dryRunPlan.mode`); + assert.equal(plan.evidenceLevel, "DRY-RUN", `${label}.dryRunPlan.evidenceLevel`); + assert.equal(plan.route, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", `${label}.dryRunPlan.route`); + assert.equal(plan.dryRunCallsLiveEndpoints, false, `${label}.dryRunPlan.dryRunCallsLiveEndpoints`); + assert.equal(plan.liveWriteWillRun, false, `${label}.dryRunPlan.liveWriteWillRun`); + assertStringArray(plan.liveWritePreconditions, `${label}.dryRunPlan.liveWritePreconditions`, { minLength: 4 }); + assertStringArray(plan.evidenceFields, `${label}.dryRunPlan.evidenceFields`, { minLength: 4 }); + assertArray(plan.endpointPlan, `${label}.dryRunPlan.endpointPlan`); + assert.ok(plan.endpointPlan.length >= 8, `${label}.dryRunPlan.endpointPlan must enumerate precondition and live endpoints`); + assert.ok( + plan.endpointPlan.some((endpoint) => endpoint.serviceId === "hwlab-patch-panel" && endpoint.path === "/signals/route" && endpoint.mutatesDevState === true), + `${label}.dryRunPlan.endpointPlan must include the patch-panel route write endpoint` + ); + assert.ok( + plan.endpointPlan.some((endpoint) => endpoint.serviceId === "hwlab-box-simu" && endpoint.path === "/ports/write" && endpoint.mutatesDevState === true), + `${label}.dryRunPlan.endpointPlan must include the source box DO1 write endpoint` + ); + assert.ok( + plan.endpointPlan.every((endpoint) => endpoint.calledInDryRun === false), + `${label}.dryRunPlan.endpointPlan endpoints must not be called in dry-run` + ); + for (const requiredField of [ + "operationId", + "traceId", + "auditId", + "evidenceId", + "routeResponse.propagatedBy=hwlab-patch-panel", + "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" + ]) { + assert.ok(plan.evidenceFields.includes(requiredField), `${label}.dryRunPlan.evidenceFields missing ${requiredField}`); + } + } + assertObject(report.liveOperation, `${label}.liveOperation`); assertStatus(report.liveOperation.status, `${label}.liveOperation.status`); for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) { diff --git a/scripts/validate-m3-rollout-runbook.mjs b/scripts/validate-m3-rollout-runbook.mjs index 718ee0eb..acfe3bc0 100644 --- a/scripts/validate-m3-rollout-runbook.mjs +++ b/scripts/validate-m3-rollout-runbook.mjs @@ -41,6 +41,9 @@ async function main() { assertIncludes(doc, "deploy/artifact-catalog.dev.json", "m3 runbook"); assertIncludes(doc, "pikasTech/HWLAB#63", "m3 runbook"); assertIncludes(doc, "Do not promote SOURCE / LOCAL / DRY-RUN / fixture output to `DEV-LIVE`.", "m3 runbook"); + assertIncludes(doc, "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", "m3 runbook"); + assertIncludes(doc, "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", "m3 runbook"); + assertIncludes(doc, "liveOperation.status: \"not_run\"", "m3 runbook"); assertNotIncludes(boundary, "http://74.48.78.17:6666/", "m3 current boundary"); assertNotIncludes(boundary, "http://74.48.78.17:6667/", "m3 current boundary"); assertIncludes(boundary, "http://74.48.78.17:16666/", "m3 current boundary");