Merge main into durable runtime readiness PR
This commit is contained in:
@@ -99,7 +99,7 @@ async function runLocalContractSmoke() {
|
||||
assert.deepEqual(failed.error.missingCommands, ["codex"]);
|
||||
assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(failed.availability.status, "blocked");
|
||||
assert.equal(failed.availability.blocker, "凭证缺口");
|
||||
assert.match(failed.availability.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(failed.availability.reason, "provider_unavailable");
|
||||
assert.match(failed.availability.summary, /真实后端已接入/u);
|
||||
assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
@@ -205,13 +205,17 @@ function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStat
|
||||
}
|
||||
|
||||
const missingEnv = Array.isArray(payload?.error?.missingEnv) ? payload.error.missingEnv : [];
|
||||
if (payload?.status === "failed" && payload.error?.code === "provider_unavailable" && missingEnv.includes("OPENAI_API_KEY")) {
|
||||
if (
|
||||
payload?.status === "failed" &&
|
||||
payload.error?.code === "provider_unavailable" &&
|
||||
(missingEnv.includes("OPENAI_API_KEY") || missingEnv.includes("HWLAB_CODE_AGENT_OPENAI_BASE_URL"))
|
||||
) {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: "BLOCKED/credential",
|
||||
blocker: "credential",
|
||||
blocker: missingEnv.includes("OPENAI_API_KEY") ? "credential" : "provider-config",
|
||||
devLiveReplyPass: false,
|
||||
reason: "provider_unavailable with missing OPENAI_API_KEY means the provider credential is absent"
|
||||
reason: "provider_unavailable with missing Code Agent provider env means the provider contract is incomplete"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +279,7 @@ function usage() {
|
||||
notes: [
|
||||
"Default mode is local schema/readiness contract only and cannot pass #143 DEV-LIVE.",
|
||||
"--live posts a minimal chat prompt to real DEV and passes only on completed plus non-empty assistant reply.",
|
||||
"provider_unavailable with missing OPENAI_API_KEY is reported as BLOCKED/credential."
|
||||
"provider_unavailable with missing OPENAI_API_KEY or missing/forbidden HWLAB_CODE_AGENT_OPENAI_BASE_URL is reported as BLOCKED/provider-config."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ async function smokeCloudApi() {
|
||||
assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(health.body.db.secretRefs[0].redacted, true);
|
||||
assert.equal(health.body.codeAgent.status, "blocked");
|
||||
assert.equal(health.body.codeAgent.blocker, "凭证缺口");
|
||||
assert.match(health.body.codeAgent.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(health.body.codeAgent.reason, "provider_unavailable");
|
||||
assert.ok(health.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(health.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
@@ -228,7 +228,7 @@ async function smokeCloudApi() {
|
||||
assert.ok(adapter.body.methods.includes("evidence.record.query"));
|
||||
assert.equal(adapter.body.codeAgent.endpoint, "POST /v1/agent/chat");
|
||||
assert.equal(adapter.body.codeAgent.status, "blocked");
|
||||
assert.equal(adapter.body.codeAgent.blocker, "凭证缺口");
|
||||
assert.match(adapter.body.codeAgent.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(adapter.body.codeAgent.provider, "codex-cli");
|
||||
assert.equal(adapter.body.codeAgent.model, "gpt-m1-local");
|
||||
assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli");
|
||||
|
||||
@@ -4,6 +4,10 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
@@ -142,6 +146,7 @@ function validateSource(ctx, manifest) {
|
||||
const cli = servicesById.get("hwlab-cli");
|
||||
expectEqual(ctx, cloudApi?.env?.HWLAB_PUBLIC_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cloud-api.env.HWLAB_PUBLIC_ENDPOINT", "cloud API public endpoint env");
|
||||
validateCloudApiDbDnsSource(ctx, cloudApi?.env ?? {});
|
||||
validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {});
|
||||
expectEqual(ctx, cli?.env?.HWLAB_CLI_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cli.env.HWLAB_CLI_ENDPOINT", "CLI endpoint env");
|
||||
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_PUBLIC_PORT, String(expectedPorts.api), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_PUBLIC_PORT", "tunnel API public port env");
|
||||
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_WEB_PUBLIC_PORT, String(expectedPorts.frontend), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_WEB_PUBLIC_PORT", "tunnel frontend public port env");
|
||||
@@ -193,6 +198,28 @@ function validateCloudApiDbDnsSource(ctx, env) {
|
||||
expectEqual(ctx, env.HWLAB_CLOUD_DB_PORT, String(dns.port), "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API DB service port");
|
||||
}
|
||||
|
||||
function validateCloudApiCodeAgentSource(ctx, env) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_PROVIDER, contract.provider, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API Code Agent provider");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_MODEL, contract.model, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API Code Agent model");
|
||||
expectEqual(
|
||||
ctx,
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
contract.egress.defaultBaseUrl,
|
||||
"$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API Code Agent DEV egress/proxy base URL"
|
||||
);
|
||||
expect(
|
||||
ctx,
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl,
|
||||
"forbidden_direct_egress",
|
||||
"$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API Code Agent must not point directly at public api.openai.com",
|
||||
{ expected: contract.egress.target, actual: env.HWLAB_CODE_AGENT_OPENAI_BASE_URL }
|
||||
);
|
||||
expectEqual(ctx, env.OPENAI_API_KEY, codeAgentSecretRefPlaceholder(), "$.services.hwlab-cloud-api.env.OPENAI_API_KEY", "cloud API Code Agent OpenAI Secret reference");
|
||||
}
|
||||
|
||||
function renderPlan(source) {
|
||||
const proxyPorts = [...new Set(source.proxies.map((proxy) => Number(proxy.remotePort)))].sort((a, b) => a - b);
|
||||
return {
|
||||
@@ -291,6 +318,7 @@ function validateK3sArtifacts(ctx, source, rendered, services, workloads, health
|
||||
}
|
||||
|
||||
validateCloudApiDbDnsArtifacts(ctx, services, workloads);
|
||||
validateCloudApiCodeAgentArtifacts(ctx, workloads);
|
||||
|
||||
expectEqual(ctx, healthContract.data?.endpoint, source.endpoints.api?.url, "deploy/k8s/dev/health-contract.yaml.data.endpoint", "health contract endpoint");
|
||||
expectEqual(ctx, masterEdge.endpoint, source.endpoints.api?.url, "deploy/master-edge/health-contract.json.endpoint", "master edge endpoint");
|
||||
@@ -325,6 +353,32 @@ function validateCloudApiDbDnsArtifacts(ctx, services, workloads) {
|
||||
expectEqual(ctx, env.get("HWLAB_CLOUD_DB_PORT"), String(dns.port), "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API workload DB port");
|
||||
}
|
||||
|
||||
function validateCloudApiCodeAgentArtifacts(ctx, workloads) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0];
|
||||
const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry]));
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API workload Code Agent provider");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API workload Code Agent model");
|
||||
expectEqual(
|
||||
ctx,
|
||||
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value,
|
||||
contract.egress.defaultBaseUrl,
|
||||
"deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API workload Code Agent DEV egress/proxy base URL"
|
||||
);
|
||||
expect(
|
||||
ctx,
|
||||
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl,
|
||||
"forbidden_direct_egress",
|
||||
"deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||||
"cloud API workload Code Agent must not point directly at public api.openai.com",
|
||||
{ expected: contract.egress.target, actual: env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value }
|
||||
);
|
||||
expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.name, secretRef.secretName, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.name", "cloud API workload Code Agent Secret name");
|
||||
expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.key, secretRef.secretKey, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.key", "cloud API workload Code Agent Secret key");
|
||||
}
|
||||
|
||||
async function buildPlan() {
|
||||
const ctx = { checks: 0, diagnostics: [] };
|
||||
const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([
|
||||
|
||||
@@ -942,6 +942,12 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
});
|
||||
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const closed = await inspectMobileWorkbench(page, { explorerOpen: false });
|
||||
const workspaceScroll = await inspectWorkbenchScrollContract(page, { panelSelector: "#conversation-list" });
|
||||
await page.click('[data-route="help"]');
|
||||
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
|
||||
const help = await inspectHelpMarkdownRoute(page);
|
||||
await page.click('[data-route="workspace"]');
|
||||
await page.waitForFunction(() => document.querySelector('[data-view="workspace"]')?.hidden === false, null, { timeout: 8000 });
|
||||
await page.click("#explorer-toggle");
|
||||
const open = await inspectMobileWorkbench(page, { explorerOpen: true });
|
||||
await page.click('[data-side-tab-jump="wiring"]');
|
||||
@@ -960,6 +966,25 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
visibleText: closed.visibleText
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "mobile-outer-scroll-lock",
|
||||
status: workspaceScroll.outerScrollLocked && help.rootStillLocked && help.helpPanelScrolls ? "pass" : "blocked",
|
||||
summary: "390x844 page locks outer scrolling while the internal Markdown help pane owns overflow scrolling.",
|
||||
observations: {
|
||||
workspace: workspaceScroll,
|
||||
help: {
|
||||
rootStillLocked: help.rootStillLocked,
|
||||
helpPanelScrolls: help.helpPanelScrolls,
|
||||
helpScroll: help.helpScroll
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "mobile-help-markdown-route",
|
||||
status: help.nonDefaultMarkdownHelp && help.termsPresent ? "pass" : "blocked",
|
||||
summary: "Help opens as a non-default internal route rendered from help.md by the vendored Markdown renderer.",
|
||||
observations: help
|
||||
},
|
||||
{
|
||||
id: "mobile-default-hit-test",
|
||||
status: closed.primaryControlsReachable ? "pass" : "blocked",
|
||||
@@ -1197,6 +1222,130 @@ async function inspectMobileWorkbench(page, { explorerOpen }) {
|
||||
}, { explorerOpen });
|
||||
}
|
||||
|
||||
async function inspectWorkbenchScrollContract(page, { panelSelector }) {
|
||||
return page.evaluate(async ({ panelSelector }) => {
|
||||
function metrics(selector) {
|
||||
const element = selector === "html" ? document.documentElement : selector === "body" ? document.body : document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
selector,
|
||||
overflow: style.overflow,
|
||||
overflowY: style.overflowY,
|
||||
height: element.getBoundingClientRect().height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
window.scrollTo(0, 240);
|
||||
document.documentElement.scrollTop = 240;
|
||||
document.body.scrollTop = 240;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rootAfterScrollAttempt = {
|
||||
windowScrollY: window.scrollY,
|
||||
htmlScrollTop: document.documentElement.scrollTop,
|
||||
bodyScrollTop: document.body.scrollTop
|
||||
};
|
||||
|
||||
const panel = document.querySelector(panelSelector);
|
||||
const beforePanelScroll = panel?.scrollTop ?? null;
|
||||
if (panel) {
|
||||
panel.scrollTop = 0;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
const afterPanelScroll = panel?.scrollTop ?? null;
|
||||
const html = metrics("html");
|
||||
const body = metrics("body");
|
||||
const shell = metrics("[data-app-shell]");
|
||||
const panelMetrics = metrics(panelSelector);
|
||||
const rootScrollLocked =
|
||||
html?.overflow === "hidden" &&
|
||||
body?.overflow === "hidden" &&
|
||||
Math.abs((shell?.height ?? 0) - window.innerHeight) <= 2 &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2 &&
|
||||
window.scrollY === 0 &&
|
||||
document.documentElement.scrollTop === 0 &&
|
||||
document.body.scrollTop === 0;
|
||||
return {
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
outerScrollLocked: rootScrollLocked,
|
||||
panelScrolls: Boolean(panelMetrics && panelMetrics.scrollHeight > panelMetrics.clientHeight && afterPanelScroll > beforePanelScroll),
|
||||
rootAfterScrollAttempt,
|
||||
html,
|
||||
body,
|
||||
shell,
|
||||
panel: panelMetrics
|
||||
};
|
||||
}, { panelSelector });
|
||||
}
|
||||
|
||||
async function inspectHelpMarkdownRoute(page) {
|
||||
return page.evaluate(async () => {
|
||||
const workspace = document.querySelector('[data-view="workspace"]');
|
||||
const gate = document.querySelector('[data-view="gate"]');
|
||||
const help = document.querySelector('[data-view="help"]');
|
||||
const helpContent = document.querySelector("#help-content");
|
||||
const heading = helpContent?.querySelector("h1");
|
||||
const renderedLists = helpContent?.querySelectorAll("li").length ?? 0;
|
||||
const codeCount = helpContent?.querySelectorAll("code").length ?? 0;
|
||||
const text = helpContent?.textContent ?? "";
|
||||
const helpScroll = await (async () => {
|
||||
if (!helpContent) return null;
|
||||
helpContent.scrollTop = 0;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const before = helpContent.scrollTop;
|
||||
helpContent.scrollTop = helpContent.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
before,
|
||||
after: helpContent.scrollTop,
|
||||
clientHeight: helpContent.clientHeight,
|
||||
scrollHeight: helpContent.scrollHeight,
|
||||
overflowY: getComputedStyle(helpContent).overflowY
|
||||
};
|
||||
})();
|
||||
const rootStillLocked =
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2;
|
||||
return {
|
||||
hash: window.location.hash,
|
||||
helpState: helpContent?.dataset.helpState ?? null,
|
||||
workspaceHidden: workspace ? workspace.hidden : null,
|
||||
gateHidden: gate ? gate.hidden : null,
|
||||
helpHidden: help ? help.hidden : null,
|
||||
heading: heading?.textContent?.trim() ?? null,
|
||||
renderedLists,
|
||||
codeCount,
|
||||
hasRawMarkdownHeading: Boolean(helpContent?.textContent?.includes("# 云工作台内部使用说明")),
|
||||
termsPresent:
|
||||
["左侧资源与功能导航", "Agent 对话与工作清单", "same-origin", "/v1", ":16666"].every((term) => text.includes(term)) &&
|
||||
codeCount >= 8,
|
||||
helpPanelScrolls: Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before),
|
||||
rootStillLocked,
|
||||
helpScroll,
|
||||
nonDefaultMarkdownHelp:
|
||||
window.location.hash === "#help" &&
|
||||
workspace?.hidden === true &&
|
||||
gate?.hidden === true &&
|
||||
help?.hidden === false &&
|
||||
helpContent?.dataset.helpState === "ready" &&
|
||||
heading?.textContent?.trim() === "云工作台内部使用说明" &&
|
||||
renderedLists >= 8 &&
|
||||
codeCount >= 8 &&
|
||||
!helpContent?.textContent?.includes("# 云工作台内部使用说明") &&
|
||||
rootStillLocked &&
|
||||
Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_DB_ENV_CONTRACT, summarizeDbContract } from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
|
||||
@@ -17,9 +24,9 @@ const namespace = "hwlab-dev";
|
||||
const tcpPorts = [16667, 7000, 7402];
|
||||
const CODE_AGENT_PROVIDER_SECRET_CONTRACT = Object.freeze({
|
||||
sourceIssue: "pikasTech/HWLAB#143",
|
||||
env: "OPENAI_API_KEY",
|
||||
secretName: "hwlab-code-agent-provider",
|
||||
secretKey: "openai-api-key"
|
||||
env: DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].env,
|
||||
secretName: DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretName,
|
||||
secretKey: DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretKey
|
||||
});
|
||||
const runtimeServices = [
|
||||
{ serviceId: "hwlab-cloud-api", port: 6667 },
|
||||
@@ -347,6 +354,7 @@ function inspectCloudApiDbContract(deploy) {
|
||||
valuesRedacted: true,
|
||||
liveDbEvidence: false
|
||||
},
|
||||
readinessLayers: Object.fromEntries(DEV_DB_ENV_CONTRACT.readinessLayers.map((layer) => [layer, { status: "not_proven" }])),
|
||||
blocker: configReady
|
||||
? "Manifest declares DB env/Secret references but does not prove runtime env injection or live DB connection"
|
||||
: "Manifest is missing DB env/Secret references"
|
||||
@@ -363,17 +371,27 @@ function inspectCloudApiDbContract(deploy) {
|
||||
|
||||
function inspectCodeAgentProviderContract(deploy) {
|
||||
const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {};
|
||||
const expectedRef = `secretRef:${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretName}/${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretKey}`;
|
||||
const expectedRef = codeAgentSecretRefPlaceholder();
|
||||
const secretRefPresent = env[CODE_AGENT_PROVIDER_SECRET_CONTRACT.env] === expectedRef;
|
||||
const egressBaseUrl = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL;
|
||||
const egressReady = egressBaseUrl === DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl;
|
||||
const directPublicOpenAi = egressBaseUrl === DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl;
|
||||
return {
|
||||
status: secretRefPresent ? "degraded" : "blocked",
|
||||
status: secretRefPresent && egressReady ? "degraded" : "blocked",
|
||||
sourceIssue: CODE_AGENT_PROVIDER_SECRET_CONTRACT.sourceIssue,
|
||||
provider: DEV_CODE_AGENT_PROVIDER_CONTRACT.runtimeProvider,
|
||||
requiredEnv: [
|
||||
{
|
||||
name: CODE_AGENT_PROVIDER_SECRET_CONTRACT.env,
|
||||
present: Boolean(env[CODE_AGENT_PROVIDER_SECRET_CONTRACT.env]),
|
||||
redacted: true,
|
||||
source: "k8s-secret-ref"
|
||||
},
|
||||
{
|
||||
name: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.env,
|
||||
present: Boolean(egressBaseUrl),
|
||||
redacted: false,
|
||||
source: "runtime-env"
|
||||
}
|
||||
],
|
||||
secretRef: {
|
||||
@@ -387,9 +405,17 @@ function inspectCodeAgentProviderContract(deploy) {
|
||||
secretValueRead: false,
|
||||
redacted: true
|
||||
},
|
||||
egress: {
|
||||
env: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.env,
|
||||
target: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target,
|
||||
present: Boolean(egressBaseUrl),
|
||||
ready: egressReady,
|
||||
directPublicOpenAi,
|
||||
valueRedacted: true
|
||||
},
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
note: "Manifest-only Code Agent provider Secret reference; this is not runtime Secret injection or provider connectivity evidence."
|
||||
note: "Manifest-only Code Agent provider Secret reference and DEV egress/base-url contract; this is not runtime Secret injection or provider connectivity evidence."
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
buildDbHealthContract,
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
||||
import { probeRegistryCapabilities } from "./registry-capabilities.mjs";
|
||||
@@ -18,7 +22,7 @@ const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const defaultReportPath = "reports/dev-gate/dev-preflight-report.json";
|
||||
const issue = "pikasTech/HWLAB#34";
|
||||
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#35", "#39", "#43", "#48", "#49", "#57", "#66"].map(
|
||||
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#35", "#39", "#43", "#48", "#49", "#57", "#66", "#143", "#164"].map(
|
||||
(id) => `pikasTech/HWLAB${id}`
|
||||
);
|
||||
const forbiddenActions = [
|
||||
@@ -1241,6 +1245,7 @@ export async function runPreflight(argv) {
|
||||
|
||||
validateRegistryCapabilities(reporter, registryCapabilities);
|
||||
validateCloudApiDbContract(reporter, deploy, contracts[3], contracts[4]);
|
||||
validateCodeAgentProviderContract(reporter, deploy, contracts[3]);
|
||||
validateArtifactPublishReport(reporter, optionalReports.artifactPublish, artifactIdentity, targetShortCommit, targetCommit, args.targetRef);
|
||||
validateEdgeHealthReport(reporter, optionalReports.edgeHealth);
|
||||
await validateEdgeContracts(reporter, masterEdge);
|
||||
@@ -1257,3 +1262,121 @@ export async function runPreflight(argv) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCodeAgentProviderContract(reporter, deploy, workloads) {
|
||||
const staticContract = inspectCodeAgentProviderStaticContract(deploy, workloads);
|
||||
const evidence = [
|
||||
{
|
||||
staticContract,
|
||||
fixtureEvidence: false,
|
||||
providerConnected: false
|
||||
}
|
||||
];
|
||||
|
||||
if (staticContract.ready) {
|
||||
reporter.check(
|
||||
"code-agent-provider-env-contract",
|
||||
"agent",
|
||||
"pass",
|
||||
"cloud-api DEV Code Agent manifest declares the OpenAI provider Secret reference and DEV egress/proxy base URL without secret values.",
|
||||
evidence
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = [
|
||||
...staticContract.missingDeployEnv,
|
||||
...staticContract.missingK8sEnv,
|
||||
...staticContract.missingSecretRefs,
|
||||
...staticContract.missingEgressContract
|
||||
];
|
||||
reporter.check(
|
||||
"code-agent-provider-env-contract",
|
||||
"agent",
|
||||
"blocked",
|
||||
`cloud-api DEV Code Agent provider contract is missing ${missing.join(", ")}.`,
|
||||
evidence
|
||||
);
|
||||
reporter.block({
|
||||
type: "agent_blocker",
|
||||
scope: "code-agent-provider-env-contract",
|
||||
summary: `cloud-api DEV Code Agent provider contract is incomplete; missing ${missing.join(", ")}.`,
|
||||
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
|
||||
});
|
||||
}
|
||||
|
||||
function inspectCodeAgentProviderStaticContract(deploy, workloads) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const cloudApi = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api") ?? {};
|
||||
const deployEnv = cloudApi.env ?? {};
|
||||
const workloadEnv = getWorkloadEnvByServiceId(workloads, "hwlab-cloud-api");
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const expectedSecretRef = codeAgentSecretRefPlaceholder();
|
||||
const workloadSecretRef = workloadEnv.get(secretRef.env)?.valueFrom?.secretKeyRef;
|
||||
const fields = contract.requiredEnv.map((name) => ({
|
||||
name,
|
||||
manifestPresent: Object.hasOwn(deployEnv, name),
|
||||
k8sPresent: workloadEnv.has(name),
|
||||
redacted: name === secretRef.env,
|
||||
source: name === secretRef.env ? "k8s-secret-ref" : "runtime-env"
|
||||
}));
|
||||
const secretRefMatches =
|
||||
deployEnv[secretRef.env] === expectedSecretRef &&
|
||||
workloadSecretRef?.name === secretRef.secretName &&
|
||||
workloadSecretRef?.key === secretRef.secretKey;
|
||||
const deployBaseUrl = deployEnv[contract.egress.env];
|
||||
const workloadBaseUrl = workloadEnv.get(contract.egress.env)?.value;
|
||||
const egressChecks = [
|
||||
["deployEnv.HWLAB_CODE_AGENT_PROVIDER", deployEnv.HWLAB_CODE_AGENT_PROVIDER, contract.provider],
|
||||
["deployEnv.HWLAB_CODE_AGENT_MODEL", deployEnv.HWLAB_CODE_AGENT_MODEL, contract.model],
|
||||
["deployEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", deployBaseUrl, contract.egress.defaultBaseUrl],
|
||||
["workloadEnv.HWLAB_CODE_AGENT_PROVIDER", workloadEnv.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider],
|
||||
["workloadEnv.HWLAB_CODE_AGENT_MODEL", workloadEnv.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model],
|
||||
["workloadEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", workloadBaseUrl, contract.egress.defaultBaseUrl]
|
||||
];
|
||||
const missingEgressContract = egressChecks
|
||||
.filter(([, actualValue, expectedValue]) => !Object.is(actualValue, expectedValue))
|
||||
.map(([name, actualValue, expectedValue]) => `${name} expected ${expectedValue} got ${actualValue ?? "missing"}`);
|
||||
if (deployBaseUrl === contract.egress.forbiddenDirectBaseUrl || workloadBaseUrl === contract.egress.forbiddenDirectBaseUrl) {
|
||||
missingEgressContract.push("HWLAB_CODE_AGENT_OPENAI_BASE_URL must not point directly at public api.openai.com");
|
||||
}
|
||||
const missingDeployEnv = fields.filter((field) => !field.manifestPresent).map((field) => field.name);
|
||||
const missingK8sEnv = fields.filter((field) => !field.k8sPresent).map((field) => field.name);
|
||||
const missingSecretRefs = secretRefMatches ? [] : [`${secretRef.secretName}/${secretRef.secretKey}`];
|
||||
|
||||
return {
|
||||
contractVersion: contract.contractVersion,
|
||||
environment: contract.environment,
|
||||
provider: contract.runtimeProvider,
|
||||
model: contract.model,
|
||||
ready:
|
||||
missingDeployEnv.length === 0 &&
|
||||
missingK8sEnv.length === 0 &&
|
||||
missingSecretRefs.length === 0 &&
|
||||
missingEgressContract.length === 0,
|
||||
requiredEnv: fields,
|
||||
secretRef: {
|
||||
env: secretRef.env,
|
||||
secretName: secretRef.secretName,
|
||||
secretKey: secretRef.secretKey,
|
||||
present: secretRefMatches,
|
||||
redacted: true
|
||||
},
|
||||
egress: {
|
||||
env: contract.egress.env,
|
||||
target: contract.egress.target,
|
||||
deployPresent: Boolean(deployBaseUrl),
|
||||
workloadPresent: Boolean(workloadBaseUrl),
|
||||
directPublicOpenAi: deployBaseUrl === contract.egress.forbiddenDirectBaseUrl || workloadBaseUrl === contract.egress.forbiddenDirectBaseUrl,
|
||||
valueRedacted: true
|
||||
},
|
||||
missingDeployEnv,
|
||||
missingK8sEnv,
|
||||
missingSecretRefs,
|
||||
missingEgressContract,
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
providerConnected: false,
|
||||
fixtureEvidence: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
parseDbUrlContract
|
||||
} from "../internal/cloud/db-contract.mjs";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../internal/cloud/code-agent-contract.mjs";
|
||||
import {
|
||||
ENVIRONMENT_DEV,
|
||||
SERVICE_IDS,
|
||||
@@ -178,8 +182,21 @@ assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_HOST?.value, DEV_DB_ENV_CONTRACT
|
||||
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT?.value, String(DEV_DB_ENV_CONTRACT.dns.port), "cloud-api workload DB port");
|
||||
assertCloudApiDbDnsContract(k8sServices, cloudApi.env);
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5", "cloud-api Code Agent model");
|
||||
assert.equal(cloudApi.env.OPENAI_API_KEY, "secretRef:hwlab-code-agent-provider/openai-api-key", "cloud-api Code Agent OpenAI key must be a Secret reference placeholder");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api Code Agent model");
|
||||
assert.equal(
|
||||
cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl,
|
||||
"cloud-api Code Agent OpenAI base URL must use DEV egress/proxy"
|
||||
);
|
||||
assert.notEqual(
|
||||
cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl,
|
||||
"cloud-api Code Agent OpenAI base URL must not point directly at public api.openai.com"
|
||||
);
|
||||
assert.equal(cloudApi.env.OPENAI_API_KEY, codeAgentSecretRefPlaceholder(), "cloud-api Code Agent OpenAI key must be a Secret reference placeholder");
|
||||
assertCodeAgentProviderWorkloadContract(cloudApiWorkloadEnv);
|
||||
assertDbForbiddenInvalidHostContract();
|
||||
assertValidationDoesNotExposeSecretValues(cloudApi.env, cloudApiWorkloadEnv);
|
||||
|
||||
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
|
||||
|
||||
@@ -225,3 +242,36 @@ function assertCloudApiDbDnsContract(services, env) {
|
||||
const secretUrl = parseDbUrlContract(`postgres://${env.HWLAB_CLOUD_DB_HOST}:${env.HWLAB_CLOUD_DB_PORT}/hwlab`);
|
||||
assert.equal(secretUrl.ok, true, "cloud-api DB stable host/port must form a supported postgres URL");
|
||||
}
|
||||
|
||||
function assertCodeAgentProviderWorkloadContract(env) {
|
||||
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
||||
const secretRef = contract.secretRefs[0];
|
||||
const apiKey = env[secretRef.env]?.valueFrom?.secretKeyRef;
|
||||
assert.equal(env.HWLAB_CODE_AGENT_PROVIDER?.value, contract.provider, "cloud-api workload Code Agent provider");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_MODEL?.value, contract.model, "cloud-api workload Code Agent model");
|
||||
assert.equal(
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value,
|
||||
contract.egress.defaultBaseUrl,
|
||||
"cloud-api workload Code Agent OpenAI base URL must use DEV egress/proxy"
|
||||
);
|
||||
assert.notEqual(
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value,
|
||||
contract.egress.forbiddenDirectBaseUrl,
|
||||
"cloud-api workload Code Agent OpenAI base URL must not point directly at public api.openai.com"
|
||||
);
|
||||
assert.equal(apiKey?.name, secretRef.secretName, "cloud-api workload Code Agent OpenAI Secret name");
|
||||
assert.equal(apiKey?.key, secretRef.secretKey, "cloud-api workload Code Agent OpenAI Secret key");
|
||||
}
|
||||
|
||||
function assertDbForbiddenInvalidHostContract() {
|
||||
const invalidHost = parseDbUrlContract("postgres://user:password@hwlab-dev-db.invalid:5432/hwlab");
|
||||
assert.equal(invalidHost.ok, false, "DEV DB contract must reject .invalid runtime hosts");
|
||||
assert.equal(invalidHost.result, "forbidden_runtime_host", "DEV DB invalid host result");
|
||||
assert.equal(invalidHost.classification, "db_url_forbidden_invalid_host", "DEV DB invalid host classification");
|
||||
}
|
||||
|
||||
function assertValidationDoesNotExposeSecretValues(manifestEnv, workloadEnv) {
|
||||
const serialized = JSON.stringify({ manifestEnv, workloadEnv });
|
||||
assert.equal(/sk-[A-Za-z0-9._-]{16,}/u.test(serialized), false, "validation contract must not include OpenAI key material");
|
||||
assert.equal(/postgres:\/\/[^" ]+:[^" ]+@/u.test(serialized), false, "validation contract must not include DB URL credentials");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user