Merge main into durable runtime readiness PR

This commit is contained in:
root
2026-05-22 18:07:49 +00:00
21 changed files with 812 additions and 51 deletions
+6
View File
@@ -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.
+1
View File
@@ -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"
}
},
+16
View File
@@ -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"
}
}
},
+4
View File
@@ -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": {
+16 -2
View File
@@ -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.
+26
View File
@@ -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
+45
View File
@@ -84,6 +84,49 @@ this agreement offline. A live pass still requires `/health/live` to report
`db.ready=true`, `db.connected=true`, and `liveDbEvidence=true` with secret
values redacted.
The DB readiness contract is layered:
| Layer | Required evidence |
| --- | --- |
| DNS | The configured target resolves from the `hwlab-cloud-api` runtime path and is not a `.invalid` placeholder. |
| TCP | A redacted TCP probe reaches the configured Postgres port. |
| Auth | Authenticated database access succeeds without printing the connection string. |
| Schema | Required HWLAB schema/migration checks pass without using fixture output as live evidence. |
`*.invalid` and `hwlab-dev-db.invalid` are forbidden DEV runtime targets. They
may appear only as negative test fixtures; source and runtime health must not
treat them as desired DEV DB endpoints.
## Code Agent Provider Contract
`hwlab-cloud-api` runs the DEV Code Agent provider through OpenAI Responses.
Source-controlled manifests must declare only env names, Secret references, and
non-secret egress settings:
| Field | Value |
| --- | --- |
| Provider env | `HWLAB_CODE_AGENT_PROVIDER=openai` |
| Model env | `HWLAB_CODE_AGENT_MODEL=gpt-5.5` |
| Provider Secret | `OPENAI_API_KEY` from `hwlab-code-agent-provider/openai-api-key` |
| DEV egress/base URL | `HWLAB_CODE_AGENT_OPENAI_BASE_URL=http://172.26.26.227:17680/v1/responses` |
DEV pods must not call `https://api.openai.com/v1/responses` directly. The base
URL must use the approved DEV egress/proxy path so provider reachability is a
repeatable deployment contract instead of a one-off runtime patch.
Reports, smokes, and health payloads may show Secret name/key presence,
`missingEnv`, provider status, model, trace IDs, and redacted egress status.
They must never print the OpenAI API key, bearer token, database URL password,
kubeconfig material, or other secret values.
## Hotfix To Source Rule
DEV runtime hotfixes are temporary recovery actions. After a hotfix proves the
minimal live path, the durable follow-up is source automation: update manifests,
contracts, docs, and tests so future rollouts reproduce the same env and safety
boundaries. The runner must not re-execute the live hotfix, restart services,
read Secrets, mutate PROD, or claim live evidence from source-only changes.
## Durable Runtime Readiness Contract
DB live readiness and durable runtime readiness are separate gates. A
@@ -132,5 +175,7 @@ M4, or M5 live evidence.
read-only k3s visibility command.
- [docs/dev-acceptance-matrix.md](../dev-acceptance-matrix.md): DEV endpoint
and health acceptance.
- [pikasTech/HWLAB#164](https://github.com/pikasTech/HWLAB/issues/164):
Code Agent provider and DB live hotfix follow-up contract.
- [pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61): manual
rollout review and automation requirements.
+29 -24
View File
@@ -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;
+137
View File
@@ -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 "";
}
+59 -1
View File
@@ -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 {
+42 -6
View File
@@ -49,6 +49,8 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
assert.equal(healthPayload.codeAgent.secretRefs[0].redacted, true);
assert.equal(healthPayload.codeAgent.egress.directPublicOpenAi, false);
assert.equal(healthPayload.codeAgent.safety.secretMaterialRead, false);
assert.equal(JSON.stringify(healthPayload.codeAgent).includes("sk-"), false);
assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
@@ -156,7 +158,7 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
const originalProbeDisabled = process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
process.env.HWLAB_CLOUD_DB_URL = "postgres://user:password@db.example.invalid:5432/hwlab";
process.env.HWLAB_CLOUD_DB_SSL_MODE = "require";
process.env.HWLAB_CLOUD_DB_PROBE_DISABLED = "1";
delete process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
const server = createCloudApiServer();
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -172,9 +174,12 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
assert.equal(payload.db.liveDbEvidence, false);
assert.equal(payload.db.safety.liveDbEvidence, false);
assert.equal(payload.db.ready, false);
assert.equal(payload.db.connectionAttempted, false);
assert.equal(payload.db.connectionResult, "not_attempted");
assert.equal(payload.db.evidence, "env_presence_only_probe_disabled");
assert.equal(payload.db.connectionAttempted, true);
assert.equal(payload.db.connectionResult, "forbidden_runtime_host");
assert.equal(payload.db.evidence, "live_db_tcp_connection_blocked");
assert.equal(payload.db.connection.networkAttempted, false);
assert.equal(payload.db.connection.classification, "db_url_forbidden_invalid_host");
assert.equal(payload.db.readinessLayers.dns.status, "not_proven");
assert.equal(JSON.stringify(payload.db).includes("password"), false);
assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false);
} finally {
@@ -220,14 +225,17 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses");
assert.equal(payload.codeAgent.mode, "openai");
assert.equal(payload.codeAgent.status, "blocked");
assert.equal(payload.codeAgent.blocker, "凭证缺口");
assert.match(payload.codeAgent.blocker, /DEV Code Agent OpenAI base URL is missing/u);
assert.equal(payload.codeAgent.reason, "provider_unavailable");
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY"]);
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]);
assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY");
assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
assert.equal(payload.codeAgent.secretRefs[0].redacted, true);
assert.equal(payload.codeAgent.ready, false);
assert.equal(payload.codeAgent.egress.present, false);
assert.equal(payload.codeAgent.egress.valueRedacted, true);
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
assert.match(payload.codeAgent.summary, /真实后端已接入/u);
assert.match(payload.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
assert.equal(JSON.stringify(payload).includes("sk-"), false);
@@ -238,6 +246,34 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
}
});
test("cloud api /v1 describes Code Agent egress blocker without leaking API key", async () => {
const server = createCloudApiServer({
env: {
OPENAI_API_KEY: "test-openai-key-material",
HWLAB_CODE_AGENT_PROVIDER: "openai",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses"
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/v1`);
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.codeAgent.status, "blocked");
assert.equal(payload.codeAgent.egress.directPublicOpenAi, true);
assert.match(payload.codeAgent.blocker, /public api\.openai\.com/u);
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api /v1/agent/chat returns structured completed Code Agent payload", async () => {
const server = createCloudApiServer({
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({
+1 -1
View File
@@ -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",
+9 -5
View File
@@ -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."
]
};
}
+2 -2
View File
@@ -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");
+54
View File
@@ -4,6 +4,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
import {
DEV_CODE_AGENT_PROVIDER_CONTRACT,
codeAgentSecretRefPlaceholder
} from "../../internal/cloud/code-agent-contract.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -142,6 +146,7 @@ function validateSource(ctx, manifest) {
const cli = servicesById.get("hwlab-cli");
expectEqual(ctx, cloudApi?.env?.HWLAB_PUBLIC_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cloud-api.env.HWLAB_PUBLIC_ENDPOINT", "cloud API public endpoint env");
validateCloudApiDbDnsSource(ctx, cloudApi?.env ?? {});
validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {});
expectEqual(ctx, cli?.env?.HWLAB_CLI_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cli.env.HWLAB_CLI_ENDPOINT", "CLI endpoint env");
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_PUBLIC_PORT, String(expectedPorts.api), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_PUBLIC_PORT", "tunnel API public port env");
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_WEB_PUBLIC_PORT, String(expectedPorts.frontend), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_WEB_PUBLIC_PORT", "tunnel frontend public port env");
@@ -193,6 +198,28 @@ function validateCloudApiDbDnsSource(ctx, env) {
expectEqual(ctx, env.HWLAB_CLOUD_DB_PORT, String(dns.port), "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API DB service port");
}
function validateCloudApiCodeAgentSource(ctx, env) {
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
expectEqual(ctx, env.HWLAB_CODE_AGENT_PROVIDER, contract.provider, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API Code Agent provider");
expectEqual(ctx, env.HWLAB_CODE_AGENT_MODEL, contract.model, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API Code Agent model");
expectEqual(
ctx,
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
contract.egress.defaultBaseUrl,
"$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
"cloud API Code Agent DEV egress/proxy base URL"
);
expect(
ctx,
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl,
"forbidden_direct_egress",
"$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
"cloud API Code Agent must not point directly at public api.openai.com",
{ expected: contract.egress.target, actual: env.HWLAB_CODE_AGENT_OPENAI_BASE_URL }
);
expectEqual(ctx, env.OPENAI_API_KEY, codeAgentSecretRefPlaceholder(), "$.services.hwlab-cloud-api.env.OPENAI_API_KEY", "cloud API Code Agent OpenAI Secret reference");
}
function renderPlan(source) {
const proxyPorts = [...new Set(source.proxies.map((proxy) => Number(proxy.remotePort)))].sort((a, b) => a - b);
return {
@@ -291,6 +318,7 @@ function validateK3sArtifacts(ctx, source, rendered, services, workloads, health
}
validateCloudApiDbDnsArtifacts(ctx, services, workloads);
validateCloudApiCodeAgentArtifacts(ctx, workloads);
expectEqual(ctx, healthContract.data?.endpoint, source.endpoints.api?.url, "deploy/k8s/dev/health-contract.yaml.data.endpoint", "health contract endpoint");
expectEqual(ctx, masterEdge.endpoint, source.endpoints.api?.url, "deploy/master-edge/health-contract.json.endpoint", "master edge endpoint");
@@ -325,6 +353,32 @@ function validateCloudApiDbDnsArtifacts(ctx, services, workloads) {
expectEqual(ctx, env.get("HWLAB_CLOUD_DB_PORT"), String(dns.port), "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API workload DB port");
}
function validateCloudApiCodeAgentArtifacts(ctx, workloads) {
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
const secretRef = contract.secretRefs[0];
const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0];
const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry]));
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API workload Code Agent provider");
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API workload Code Agent model");
expectEqual(
ctx,
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value,
contract.egress.defaultBaseUrl,
"deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
"cloud API workload Code Agent DEV egress/proxy base URL"
);
expect(
ctx,
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl,
"forbidden_direct_egress",
"deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
"cloud API workload Code Agent must not point directly at public api.openai.com",
{ expected: contract.egress.target, actual: env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value }
);
expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.name, secretRef.secretName, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.name", "cloud API workload Code Agent Secret name");
expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.key, secretRef.secretKey, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.key", "cloud API workload Code Agent Secret key");
}
async function buildPlan() {
const ctx = { checks: 0, diagnostics: [] };
const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([
@@ -942,6 +942,12 @@ export async function runDevCloudWorkbenchMobileSmoke() {
});
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
const closed = await inspectMobileWorkbench(page, { explorerOpen: false });
const workspaceScroll = await inspectWorkbenchScrollContract(page, { panelSelector: "#conversation-list" });
await page.click('[data-route="help"]');
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
const help = await inspectHelpMarkdownRoute(page);
await page.click('[data-route="workspace"]');
await page.waitForFunction(() => document.querySelector('[data-view="workspace"]')?.hidden === false, null, { timeout: 8000 });
await page.click("#explorer-toggle");
const open = await inspectMobileWorkbench(page, { explorerOpen: true });
await page.click('[data-side-tab-jump="wiring"]');
@@ -960,6 +966,25 @@ export async function runDevCloudWorkbenchMobileSmoke() {
visibleText: closed.visibleText
}
},
{
id: "mobile-outer-scroll-lock",
status: workspaceScroll.outerScrollLocked && help.rootStillLocked && help.helpPanelScrolls ? "pass" : "blocked",
summary: "390x844 page locks outer scrolling while the internal Markdown help pane owns overflow scrolling.",
observations: {
workspace: workspaceScroll,
help: {
rootStillLocked: help.rootStillLocked,
helpPanelScrolls: help.helpPanelScrolls,
helpScroll: help.helpScroll
}
}
},
{
id: "mobile-help-markdown-route",
status: help.nonDefaultMarkdownHelp && help.termsPresent ? "pass" : "blocked",
summary: "Help opens as a non-default internal route rendered from help.md by the vendored Markdown renderer.",
observations: help
},
{
id: "mobile-default-hit-test",
status: closed.primaryControlsReachable ? "pass" : "blocked",
@@ -1197,6 +1222,130 @@ async function inspectMobileWorkbench(page, { explorerOpen }) {
}, { explorerOpen });
}
async function inspectWorkbenchScrollContract(page, { panelSelector }) {
return page.evaluate(async ({ panelSelector }) => {
function metrics(selector) {
const element = selector === "html" ? document.documentElement : selector === "body" ? document.body : document.querySelector(selector);
if (!element) return null;
const style = getComputedStyle(element);
return {
selector,
overflow: style.overflow,
overflowY: style.overflowY,
height: element.getBoundingClientRect().height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop
};
}
window.scrollTo(0, 240);
document.documentElement.scrollTop = 240;
document.body.scrollTop = 240;
await new Promise((resolve) => requestAnimationFrame(resolve));
const rootAfterScrollAttempt = {
windowScrollY: window.scrollY,
htmlScrollTop: document.documentElement.scrollTop,
bodyScrollTop: document.body.scrollTop
};
const panel = document.querySelector(panelSelector);
const beforePanelScroll = panel?.scrollTop ?? null;
if (panel) {
panel.scrollTop = 0;
await new Promise((resolve) => requestAnimationFrame(resolve));
panel.scrollTop = panel.scrollHeight;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const afterPanelScroll = panel?.scrollTop ?? null;
const html = metrics("html");
const body = metrics("body");
const shell = metrics("[data-app-shell]");
const panelMetrics = metrics(panelSelector);
const rootScrollLocked =
html?.overflow === "hidden" &&
body?.overflow === "hidden" &&
Math.abs((shell?.height ?? 0) - window.innerHeight) <= 2 &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2 &&
window.scrollY === 0 &&
document.documentElement.scrollTop === 0 &&
document.body.scrollTop === 0;
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
outerScrollLocked: rootScrollLocked,
panelScrolls: Boolean(panelMetrics && panelMetrics.scrollHeight > panelMetrics.clientHeight && afterPanelScroll > beforePanelScroll),
rootAfterScrollAttempt,
html,
body,
shell,
panel: panelMetrics
};
}, { panelSelector });
}
async function inspectHelpMarkdownRoute(page) {
return page.evaluate(async () => {
const workspace = document.querySelector('[data-view="workspace"]');
const gate = document.querySelector('[data-view="gate"]');
const help = document.querySelector('[data-view="help"]');
const helpContent = document.querySelector("#help-content");
const heading = helpContent?.querySelector("h1");
const renderedLists = helpContent?.querySelectorAll("li").length ?? 0;
const codeCount = helpContent?.querySelectorAll("code").length ?? 0;
const text = helpContent?.textContent ?? "";
const helpScroll = await (async () => {
if (!helpContent) return null;
helpContent.scrollTop = 0;
await new Promise((resolve) => requestAnimationFrame(resolve));
const before = helpContent.scrollTop;
helpContent.scrollTop = helpContent.scrollHeight;
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
before,
after: helpContent.scrollTop,
clientHeight: helpContent.clientHeight,
scrollHeight: helpContent.scrollHeight,
overflowY: getComputedStyle(helpContent).overflowY
};
})();
const rootStillLocked =
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2;
return {
hash: window.location.hash,
helpState: helpContent?.dataset.helpState ?? null,
workspaceHidden: workspace ? workspace.hidden : null,
gateHidden: gate ? gate.hidden : null,
helpHidden: help ? help.hidden : null,
heading: heading?.textContent?.trim() ?? null,
renderedLists,
codeCount,
hasRawMarkdownHeading: Boolean(helpContent?.textContent?.includes("# 云工作台内部使用说明")),
termsPresent:
["左侧资源与功能导航", "Agent 对话与工作清单", "same-origin", "/v1", ":16666"].every((term) => text.includes(term)) &&
codeCount >= 8,
helpPanelScrolls: Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before),
rootStillLocked,
helpScroll,
nonDefaultMarkdownHelp:
window.location.hash === "#help" &&
workspace?.hidden === true &&
gate?.hidden === true &&
help?.hidden === false &&
helpContent?.dataset.helpState === "ready" &&
heading?.textContent?.trim() === "云工作台内部使用说明" &&
renderedLists >= 8 &&
codeCount >= 8 &&
!helpContent?.textContent?.includes("# 云工作台内部使用说明") &&
rootStillLocked &&
Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before)
};
});
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
+33 -7
View File
@@ -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."
};
}
+124 -1
View File
@@ -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
};
}
+52 -2
View File
@@ -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");
}
+2
View File
@@ -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 [
"左侧资源与功能导航",
+5
View File
@@ -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);
}