From 78459cdbdb85e71a05fc10d52bcb3ab51599b227 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 02:34:13 +0000 Subject: [PATCH] feat: add HWLAB fake echo provider --- config/agentrun.yaml | 18 + .../d518-v03/fake-echo.yaml | 18 + .../profile.fake-echo.d518-v03.yaml | 30 + .../runtime.d518-v03.yaml | 51 + .../secrets.d518-v03.yaml | 31 + config/hwlab-node-lanes.yaml | 8 +- .../cicd.fake-echo.d518-v03.yaml | 67 ++ .../prompt-set.fake-echo.yaml | 27 + .../public-exposure.fake-echo.d518-v03.yaml | 38 + .../scenarios.fake-echo.workbench.yaml | 58 ++ .../secrets.fake-echo.d518-v03.yaml | 35 + ...ench-fake-echo-session-invariance-10x.yaml | 19 + .../fake-responses-provider.Containerfile | 14 + scripts/fake-responses-provider-service.ts | 51 + scripts/fake-responses-provider-smoke.ts | 80 ++ .../src/fake-responses-provider-service.ts | 375 +++++++ scripts/src/hwlab-fake-model-provider.ts | 952 ++++++++++++++++++ scripts/src/hwlab-node-help.ts | 2 + scripts/src/hwlab-node/entry.ts | 6 +- 19 files changed, 1875 insertions(+), 5 deletions(-) create mode 100644 config/hwlab-fake-model-provider/d518-v03/fake-echo.yaml create mode 100644 config/hwlab-fake-model-provider/profile.fake-echo.d518-v03.yaml create mode 100644 config/hwlab-fake-model-provider/runtime.d518-v03.yaml create mode 100644 config/hwlab-fake-model-provider/secrets.d518-v03.yaml create mode 100644 config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml create mode 100644 config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml create mode 100644 config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml create mode 100644 config/hwlab-web-probe-sentinel/scenarios.fake-echo.workbench.yaml create mode 100644 config/hwlab-web-probe-sentinel/secrets.fake-echo.d518-v03.yaml create mode 100644 config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml create mode 100644 deploy/container/fake-responses-provider.Containerfile create mode 100644 scripts/fake-responses-provider-service.ts create mode 100644 scripts/fake-responses-provider-smoke.ts create mode 100644 scripts/src/fake-responses-provider-service.ts create mode 100644 scripts/src/hwlab-fake-model-provider.ts diff --git a/config/agentrun.yaml b/config/agentrun.yaml index bb8a0924..d25e4117 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -834,6 +834,24 @@ controlPlane: key: model-catalog.json providerCredential: profile: dsflash-go + - id: provider-fake-echo-auth-json + sourceMode: file + sourceRef: agentrun/d518-v02-provider-fake-echo-auth.json + targetRef: + namespace: agentrun-v02 + name: agentrun-v02-provider-fake-echo + key: auth.json + providerCredential: + profile: fake-echo + - id: provider-fake-echo-config + sourceMode: file + sourceRef: agentrun/d518-v02-provider-fake-echo-config.toml + targetRef: + namespace: agentrun-v02 + name: agentrun-v02-provider-fake-echo + key: config.toml + providerCredential: + profile: fake-echo - id: tool-github-pr-token sourceRef: /root/.config/unidesk/github.env sourceKey: GH_TOKEN diff --git a/config/hwlab-fake-model-provider/d518-v03/fake-echo.yaml b/config/hwlab-fake-model-provider/d518-v03/fake-echo.yaml new file mode 100644 index 00000000..f708d2dc --- /dev/null +++ b/config/hwlab-fake-model-provider/d518-v03/fake-echo.yaml @@ -0,0 +1,18 @@ +version: 1 +kind: HwlabFakeModelProvider +metadata: + id: d518-v03-fake-echo + owner: UniDesk + issue: 1190 +provider: + id: fake-echo + enabled: true + mode: responses-echo + target: + node: D518 + lane: v03 + agentrunLane: d518-v02 + configRefs: + runtime: config/hwlab-fake-model-provider/runtime.d518-v03.yaml#provider.runtime + secrets: config/hwlab-fake-model-provider/secrets.d518-v03.yaml#provider.secrets + profile: config/hwlab-fake-model-provider/profile.fake-echo.d518-v03.yaml#provider.profile diff --git a/config/hwlab-fake-model-provider/profile.fake-echo.d518-v03.yaml b/config/hwlab-fake-model-provider/profile.fake-echo.d518-v03.yaml new file mode 100644 index 00000000..79d6d6b2 --- /dev/null +++ b/config/hwlab-fake-model-provider/profile.fake-echo.d518-v03.yaml @@ -0,0 +1,30 @@ +version: 1 +kind: HwlabFakeModelProviderProfile +metadata: + id: d518-v03-fake-echo-profile + owner: UniDesk + issue: 1190 +provider: + profile: + name: fake-echo + agentrun: + node: D518 + lane: d518-v02 + configRef: config/agentrun.yaml#controlPlane.lanes.d518-v02 + providerCredential: + namespace: agentrun-v02 + secretName: agentrun-v02-provider-fake-echo + keys: + - auth.json + - config.toml + authJsonSourceRef: agentrun/d518-v02-provider-fake-echo-auth.json + configTomlSourceRef: agentrun/d518-v02-provider-fake-echo-config.toml + codexConfig: + model: fake-echo + modelProvider: fake + baseUrl: http://hwlab-fake-model-provider.agentrun-v02.svc.cluster.local:8080/v1 + wireApi: responses + requiresOpenaiAuth: true + modelContextWindow: 4096 + modelAutoCompactTokenLimit: 3500 + modelSupportsReasoningSummaries: false diff --git a/config/hwlab-fake-model-provider/runtime.d518-v03.yaml b/config/hwlab-fake-model-provider/runtime.d518-v03.yaml new file mode 100644 index 00000000..1d89d0fe --- /dev/null +++ b/config/hwlab-fake-model-provider/runtime.d518-v03.yaml @@ -0,0 +1,51 @@ +version: 1 +kind: HwlabFakeModelProviderRuntime +metadata: + id: d518-v03-fake-model-provider-runtime + owner: UniDesk + issue: 1190 +provider: + runtime: + target: + node: D518 + lane: v03 + agentrunLane: d518-v02 + namespace: agentrun-v02 + serviceAccountName: default + deploymentName: hwlab-fake-model-provider + serviceName: hwlab-fake-model-provider + configMapName: hwlab-fake-model-provider-source + secretName: hwlab-fake-model-provider-api-key + containerName: fake-responses-provider + listenHost: 0.0.0.0 + servicePort: 8080 + healthPath: /healthz + modelsPath: /v1/models + responsesPath: /v1/responses + image: + mode: configmap-bun-entrypoint + imageRef: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + containerfile: deploy/container/fake-responses-provider.Containerfile + source: + entrypoint: scripts/fake-responses-provider-service.ts + files: + - scripts/fake-responses-provider-service.ts + - scripts/src/fake-responses-provider-service.ts + config: + modelId: fake-echo + mode: echo + responseDelayMs: 0 + nonEchoPolicy: error + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + probes: + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/config/hwlab-fake-model-provider/secrets.d518-v03.yaml b/config/hwlab-fake-model-provider/secrets.d518-v03.yaml new file mode 100644 index 00000000..e7e796ca --- /dev/null +++ b/config/hwlab-fake-model-provider/secrets.d518-v03.yaml @@ -0,0 +1,31 @@ +version: 1 +kind: HwlabFakeModelProviderSecrets +metadata: + id: d518-v03-fake-model-provider-secrets + owner: UniDesk + issue: 1190 +provider: + secrets: + sources: + - purpose: provider-api-key + sourceRef: hwlab/fake-echo-provider.env + sourceKey: FAKE_ECHO_API_KEY + createIfMissing: + enabled: true + randomHexBytes: 24 + - purpose: sentinel-prompts + sourceRef: hwlab/web-probe-sentinel-fake-echo.env + sourceKey: FAKE_ECHO_SENTINEL_PROMPTS_JSON + createIfMissing: + enabled: true + runtimeSecrets: + - name: hwlab-fake-model-provider-api-key + namespace: agentrun-v02 + data: + - sourcePurpose: provider-api-key + targetKey: api-key + - name: hwlab-web-probe-sentinel-prompt-set + namespace: hwlab-v03 + data: + - sourcePurpose: sentinel-prompts + targetKey: prompts.json diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index fb8099fe..5cde454b 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -654,9 +654,9 @@ lanes: prometheusOperator: false webProbe: sentinels: - - id: workbench-dsflash-go-tool-call-10x + - id: workbench-fake-echo-session-invariance-10x enabled: true - configRef: config/hwlab-web-probe-sentinels/d518-v03/workbench-dsflash-go-tool-call-10x.yaml#sentinel + configRef: config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml#sentinel runtimeImageRewrites: - source: fatedier/frpc:v0.68.1 target: 127.0.0.1:5000/hwlab/frpc:v0.68.1 @@ -691,7 +691,7 @@ lanes: opencodeSourceKey: OPENCODE_API_KEY codeAgentRuntime: enabled: true - adapter: agentrun-v01 + adapter: agentrun-v02 managerUrl: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080 apiKeySecretName: hwlab-v03-master-server-admin-api-key apiKeySecretKey: api-key @@ -699,7 +699,7 @@ lanes: secretNamespace: agentrun-v02 repoUrlFrom: runtimeGitReadUrl providerIdFrom: runtimeNodeId - defaultProviderProfile: deepseek + defaultProviderProfile: fake-echo publicExposure: mode: pk01-caddy-frp publicBaseUrl: https://hwlab.pikapython.com diff --git a/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml b/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml new file mode 100644 index 00000000..4d5e1414 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml @@ -0,0 +1,67 @@ +version: 1 +kind: HwlabWebProbeSentinelCicd +metadata: + id: d518-v03-web-probe-sentinel-fake-echo-cicd + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1190 +sentinel: + cicd: + controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets[1] + source: + repository: pikasTech/unidesk + branch: master + gitSshUrl: ssh://git@ssh.github.com:443/pikasTech/unidesk.git + gitMirrorReadUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + buildContext: . + entrypoint: scripts/web-probe-sentinel-service.ts + checkoutPaths: + - scripts + - config + - config.json + - src + - package.json + - bun.lock + - bun.lockb + builder: + namespace: devops-infra + sourceMode: sparse-git-checkout + jobPrefix: web-probe-sentinel-publish + gitSshSecretName: git-mirror-github-ssh + dockerSocketPath: /var/run/docker.sock + activeDeadlineSeconds: 900 + ttlSecondsAfterFinished: 3600 + gitopsPath: deploy/gitops/node/d518/web-probe-sentinel + argo: + namespace: argocd + projectName: hwlab-d518 + applicationName: hwlab-web-probe-sentinel + repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git + targetRevision: v0.3-gitops + image: + repository: 127.0.0.1:5000/hwlab/web-probe-sentinel + tagSource: source-commit + baseImageRef: config/hwlab-node-control-plane.yaml#targets[1].tekton.toolsImage.output + envRecipeRef: config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml#sentinel.runtime + maintenance: + startCommand: sentinel maintenance start + stopCommand: sentinel maintenance stop + monitorWeb: + frontendStack: vue3-vendored-browser-build + runtimeMode: runner-served-bridge + assetRoot: scripts/assets/web-probe-sentinel-monitor-web + envReuse: + mode: docker-layer-and-ci-node-deps + nodeDepsPath: /opt/hwlab-ci-node-deps/node_modules + gitMirror: + source: source.gitMirrorReadUrl + preSync: required + postFlush: required + ciBudget: + maxSeconds: 120 + confirmWait: + maxSeconds: 120 + targetValidation: + scenarioId: workbench-fake-echo-session-invariance-10x + maxSeconds: 300 + serviceUnavailablePolicy: structured-failure diff --git a/config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml b/config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml new file mode 100644 index 00000000..ab28be21 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml @@ -0,0 +1,27 @@ +version: 1 +kind: HwlabWebProbeSentinelPromptSet +metadata: + id: d518-v03-web-probe-sentinel-fake-echo-prompt-set + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1190 +sentinel: + promptSet: + id: fake-echo-session-invariance-10x + providerProfile: fake-echo + providerProfileMode: exact + promptSourceRef: hwlab/web-probe-sentinel-fake-echo.env + promptSourceKey: FAKE_ECHO_SENTINEL_PROMPTS_JSON + promptCount: 10 + expectedMarkers: + - sentinel-01 + - sentinel-02 + - sentinel-03 + - sentinel-04 + - sentinel-05 + - sentinel-06 + - sentinel-07 + - sentinel-08 + - sentinel-09 + - sentinel-10 + redaction: hash-and-byte-count diff --git a/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml b/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml new file mode 100644 index 00000000..27f3803d --- /dev/null +++ b/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml @@ -0,0 +1,38 @@ +version: 1 +kind: HwlabWebProbeSentinelPublicExposure +metadata: + id: d518-v03-web-probe-sentinel-fake-echo-public-exposure + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1190 +sentinel: + publicExposure: + enabled: true + mode: pk01-caddy-frp + publicBaseUrl: https://monitor.pikapython.com/sentinels/d518-workbench-fake-echo-session-invariance-10x + hostname: monitor.pikapython.com + routePrefix: /sentinels/d518-workbench-fake-echo-session-invariance-10x + expectedA: 82.156.23.220 + frpc: + deploymentName: hwlab-web-probe-sentinel-frpc + image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + tokenSourceRef: platform-infra/pk01-frp.env + tokenSourceKey: FRP_TOKEN + secretName: hwlab-web-probe-sentinel-frpc + secretKey: frpc.toml + tokenKey: token + httpProxy: + name: hwlab-d518-v03-web-probe-sentinel + remotePort: 22093 + localIP: hwlab-web-probe-sentinel.hwlab-v03.svc.cluster.local + localPort: 8080 + caddy: + route: PK01 + configPath: /etc/caddy/Caddyfile + serviceName: caddy + email: ops@pikapython.com + tls: auto + responseHeaderTimeoutSeconds: 600 + managedBlockOwner: hwlab-web-probe-sentinel-d518-v03 diff --git a/config/hwlab-web-probe-sentinel/scenarios.fake-echo.workbench.yaml b/config/hwlab-web-probe-sentinel/scenarios.fake-echo.workbench.yaml new file mode 100644 index 00000000..6a7b02ae --- /dev/null +++ b/config/hwlab-web-probe-sentinel/scenarios.fake-echo.workbench.yaml @@ -0,0 +1,58 @@ +version: 1 +kind: HwlabWebProbeSentinelScenarios +metadata: + id: d518-v03-web-probe-sentinel-fake-echo-scenarios + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1190 +sentinel: + scenarios: + - id: workbench-fake-echo-session-invariance-10x + enabled: true + cadence: 10m + observeTargetPath: /workbench + sampleIntervalMs: 1000 + screenshotIntervalMs: 60000 + maxRunSeconds: 1200 + providerProfile: fake-echo + providerProfileMode: exact + promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml#sentinel.promptSet + reportViewRef: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews + commandSequence: + - type: newSession + - type: selectProvider + provider: fake-echo + - type: sendPrompt + promptSource: promptSet + repeat: 10 + sessionInvarianceChecks: + - id: after-round-1-navigation-invariance + afterRound: 1 + refreshCurrent: true + switchAwayAndBack: true + alternateSessionStrategy: existing-or-create + assertSessionInvariant: true + expectedSentinelRange: sentinel-01..sentinel-01 + findingId: workbench-message-order-user-clustered-after-navigation + severity: amber + blocking: false + - id: after-round-5-navigation-invariance + afterRound: 5 + refreshCurrent: true + switchAwayAndBack: true + alternateSessionStrategy: existing-or-create + assertSessionInvariant: true + requireComposerReady: true + expectedSentinelRange: sentinel-01..sentinel-05 + findingId: workbench-message-order-user-clustered-after-navigation + severity: amber + blocking: false + - id: after-round-10-refresh-invariance + afterRound: 10 + refreshCurrent: true + switchAwayAndBack: false + assertSessionInvariant: true + expectedSentinelRange: sentinel-01..sentinel-10 + findingId: workbench-message-order-user-clustered-after-navigation + severity: amber + blocking: false diff --git a/config/hwlab-web-probe-sentinel/secrets.fake-echo.d518-v03.yaml b/config/hwlab-web-probe-sentinel/secrets.fake-echo.d518-v03.yaml new file mode 100644 index 00000000..12872f27 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/secrets.fake-echo.d518-v03.yaml @@ -0,0 +1,35 @@ +version: 1 +kind: HwlabWebProbeSentinelSecrets +metadata: + id: d518-v03-web-probe-sentinel-fake-echo-secrets + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1190 +sentinel: + secrets: + sources: + - purpose: bootstrap-admin + sourceRef: hwlab/d518-v03-bootstrap-admin.env + sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD + - purpose: prompt-set + sourceRef: hwlab/web-probe-sentinel-fake-echo.env + sourceKey: FAKE_ECHO_SENTINEL_PROMPTS_JSON + - purpose: frp-token + sourceRef: platform-infra/pk01-frp.env + sourceKey: FRP_TOKEN + runtimeSecrets: + - name: hwlab-web-probe-sentinel-bootstrap + namespace: hwlab-v03 + data: + - sourcePurpose: bootstrap-admin + targetKey: bootstrap-admin-password + - name: hwlab-web-probe-sentinel-prompt-set + namespace: hwlab-v03 + data: + - sourcePurpose: prompt-set + targetKey: prompts.json + - name: hwlab-web-probe-sentinel-frpc + namespace: hwlab-v03 + data: + - sourcePurpose: frp-token + targetKey: token diff --git a/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml b/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml new file mode 100644 index 00000000..dcb68c59 --- /dev/null +++ b/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml @@ -0,0 +1,19 @@ +version: 1 +kind: HwlabWebProbeSentinel +metadata: + id: d518-v03-workbench-fake-echo-session-invariance-10x + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1190 +sentinel: + id: workbench-fake-echo-session-invariance-10x + enabled: true + mode: web-probe-observe-wrapper + configRefs: + runtime: config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml#sentinel.runtime + scenarios: config/hwlab-web-probe-sentinel/scenarios.fake-echo.workbench.yaml#sentinel.scenarios + promptSet: config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml#sentinel.promptSet + reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews + publicExposure: config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml#sentinel.publicExposure + cicd: config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml#sentinel.cicd + secrets: config/hwlab-web-probe-sentinel/secrets.fake-echo.d518-v03.yaml#sentinel.secrets diff --git a/deploy/container/fake-responses-provider.Containerfile b/deploy/container/fake-responses-provider.Containerfile new file mode 100644 index 00000000..644e2dea --- /dev/null +++ b/deploy/container/fake-responses-provider.Containerfile @@ -0,0 +1,14 @@ +FROM oven/bun:1.3.13-alpine + +WORKDIR /app +COPY package.json bun.lock ./ +COPY scripts/fake-responses-provider-service.ts scripts/fake-responses-provider-service.ts +COPY scripts/src/fake-responses-provider-service.ts scripts/src/fake-responses-provider-service.ts + +ENV NODE_ENV=production \ + LISTEN_HOST=0.0.0.0 \ + PORT=8080 \ + FAKE_RESPONSES_MODEL_ID=fake-echo + +EXPOSE 8080 +CMD ["bun", "scripts/fake-responses-provider-service.ts"] diff --git a/scripts/fake-responses-provider-service.ts b/scripts/fake-responses-provider-service.ts new file mode 100644 index 00000000..018a8c52 --- /dev/null +++ b/scripts/fake-responses-provider-service.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +// SPEC: issue-1190 fake Responses provider P0/P1. +// Responsibility: Bun entrypoint for the deterministic fake Responses provider. +import { createFakeResponsesProviderService, loadFakeResponsesProviderConfig } from "./src/fake-responses-provider-service"; + +const args = process.argv.slice(2); +const config = loadFakeResponsesProviderConfig({ + ...process.env, + ...(optionValue("--host") === undefined ? {} : { LISTEN_HOST: optionValue("--host") }), + ...(optionValue("--port") === undefined ? {} : { PORT: optionValue("--port") }), + ...(optionValue("--model") === undefined ? {} : { FAKE_RESPONSES_MODEL_ID: optionValue("--model") }), +}); +const service = createFakeResponsesProviderService(config); + +if (args.includes("--once")) { + console.log(JSON.stringify(service.health(), null, 2)); + process.exit(0); +} + +const server = Bun.serve({ + hostname: config.host, + port: config.port, + fetch: service.fetch, +}); + +console.log(JSON.stringify({ + ok: true, + command: "fake-responses-provider-service", + url: server.url.href, + health: service.health(), + valuesPrinted: false, +}, null, 2)); + +process.on("SIGTERM", () => { + server.stop(true); + process.exit(0); +}); +process.on("SIGINT", () => { + server.stop(true); + process.exit(0); +}); + +await new Promise(() => undefined); + +function optionValue(name: string): string | undefined { + const index = args.indexOf(name); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} diff --git a/scripts/fake-responses-provider-smoke.ts b/scripts/fake-responses-provider-smoke.ts new file mode 100644 index 00000000..7c4930ba --- /dev/null +++ b/scripts/fake-responses-provider-smoke.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +// SPEC: issue-1190 fake Responses provider P1. +// Responsibility: Local protocol smoke for fake Responses provider without Docker or k3s. +import { createFakeResponsesProviderService, loadFakeResponsesProviderConfig } from "./src/fake-responses-provider-service"; + +const config = loadFakeResponsesProviderConfig({ ...process.env, LISTEN_HOST: "127.0.0.1" }); +const service = createFakeResponsesProviderService(config); +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: service.fetch }); + +try { + const baseUrl = server.url.href.replace(/\/$/u, ""); + const health = await fetchJson(`${baseUrl}/healthz`); + const models = await fetchJson(`${baseUrl}/v1/models`); + const echo = await fetch(`${baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer smoke-redacted" }, + body: JSON.stringify({ + model: config.modelId, + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "ECHO sentinel-01" }] }], + }), + }); + const echoText = await echo.text(); + const events = parseSseEvents(echoText); + const nonEcho = await fetch(`${baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: config.modelId, stream: true, input: "hello" }), + }); + const ok = health.ok === true + && Array.isArray(models.data) + && echo.ok + && echo.headers.get("content-type")?.includes("text/event-stream") === true + && events.some((event) => event.event === "response.output_text.delta" && event.data.delta === "sentinel-01") + && events.some((event) => event.event === "response.completed") + && nonEcho.status === 400; + console.log(JSON.stringify({ + ok, + command: "fake-responses-provider-smoke", + baseUrl, + checks: { + health: health.ok === true, + models: Array.isArray(models.data), + echoStatus: echo.status, + echoDelta: events.find((event) => event.event === "response.output_text.delta")?.data.delta ?? null, + completed: events.some((event) => event.event === "response.completed"), + nonEchoStatus: nonEcho.status, + }, + valuesPrinted: false, + }, null, 2)); + process.exitCode = ok ? 0 : 1; +} finally { + server.stop(true); +} + +async function fetchJson(url: string): Promise> { + const response = await fetch(url); + const parsed = await response.json() as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : {}; +} + +function parseSseEvents(text: string): Array<{ event: string; data: Record }> { + return text + .split(/\n\n/u) + .map((chunk) => chunk.trim()) + .filter(Boolean) + .flatMap((chunk) => { + const event = /^event:\s*(.+)$/mu.exec(chunk)?.[1] ?? "message"; + const dataLine = /^data:\s*(.+)$/mu.exec(chunk)?.[1] ?? ""; + if (dataLine === "[DONE]" || dataLine.length === 0) return []; + try { + const parsed = JSON.parse(dataLine) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? [{ event, data: parsed as Record }] + : []; + } catch { + return []; + } + }); +} diff --git a/scripts/src/fake-responses-provider-service.ts b/scripts/src/fake-responses-provider-service.ts new file mode 100644 index 00000000..1b418ffc --- /dev/null +++ b/scripts/src/fake-responses-provider-service.ts @@ -0,0 +1,375 @@ +// SPEC: issue-1190 fake Responses provider P0/P1. +// Responsibility: deterministic OpenAI-compatible Responses HTTP provider for HWLAB sentinel ECHO runs. +import { createHash, randomUUID } from "node:crypto"; + +export interface FakeResponsesProviderConfig { + readonly serviceName: string; + readonly version: string; + readonly modelId: string; + readonly mode: "echo"; + readonly host: string; + readonly port: number; + readonly responseDelayMs: number; + readonly nonEchoPolicy: "error"; +} + +export interface FakeResponsesProviderService { + readonly config: FakeResponsesProviderConfig; + fetch(request: Request): Promise; + health(): Record; +} + +interface ResponseRequestContext { + readonly requestId: string; + readonly traceId: string | null; + readonly bodyBytes: number; + readonly bodySha256: string; + readonly model: string; + readonly stream: boolean; + readonly prompt: string | null; +} + +const DEFAULT_VERSION = "issue-1190-p1"; + +export function loadFakeResponsesProviderConfig(env: NodeJS.ProcessEnv = process.env): FakeResponsesProviderConfig { + const modelId = env.FAKE_RESPONSES_MODEL_ID || env.MODEL_ID || "fake-echo"; + const host = env.LISTEN_HOST || env.HOST || "0.0.0.0"; + const port = boundedPort(env.PORT, 8080); + const responseDelayMs = boundedInteger(env.FAKE_RESPONSES_DELAY_MS || env.RESPONSE_DELAY_MS, 0, 0, 30_000, "response delay"); + return { + serviceName: env.FAKE_RESPONSES_SERVICE_NAME || "hwlab-fake-responses-provider", + version: env.FAKE_RESPONSES_VERSION || DEFAULT_VERSION, + modelId, + mode: "echo", + host, + port, + responseDelayMs, + nonEchoPolicy: "error", + }; +} + +export function createFakeResponsesProviderService(config: FakeResponsesProviderConfig): FakeResponsesProviderService { + const service: FakeResponsesProviderService = { + config, + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/healthz") return jsonResponse(service.health()); + if (request.method === "GET" && url.pathname === "/v1/models") return jsonResponse(modelsPayload(config)); + if (request.method === "POST" && url.pathname === "/v1/responses") return await handleResponses(config, request); + return jsonResponse({ error: { type: "not_found", message: "not found" }, valuesPrinted: false }, 404); + }, + health(): Record { + return { + ok: true, + service: config.serviceName, + version: config.version, + mode: config.mode, + model: config.modelId, + valuesPrinted: false, + }; + }, + }; + return service; +} + +async function handleResponses(config: FakeResponsesProviderConfig, request: Request): Promise { + const requestId = request.headers.get("x-request-id") || `fake_${randomUUID()}`; + const traceId = request.headers.get("traceparent") || request.headers.get("x-trace-id"); + const rawBody = await request.text(); + const bodySha256 = sha256(rawBody); + let body: Record; + try { + const parsed = JSON.parse(rawBody) as unknown; + if (!isRecord(parsed)) throw new Error("body must be a JSON object"); + body = parsed; + } catch (error) { + logRequest({ requestId, traceId, bodyBytes: Buffer.byteLength(rawBody), bodySha256, model: config.modelId, stream: false, prompt: null }, 400, "invalid-json"); + return providerError("invalid_json", error instanceof Error ? error.message : String(error), requestId, 400); + } + + const model = typeof body.model === "string" && body.model.length > 0 ? body.model : config.modelId; + const stream = body.stream !== false; + const prompt = latestUserText(body); + const context: ResponseRequestContext = { + requestId, + traceId, + bodyBytes: Buffer.byteLength(rawBody), + bodySha256, + model, + stream, + prompt, + }; + const echo = parseEcho(prompt); + if (echo === null) { + logRequest(context, 400, "non-echo-prompt"); + return providerError("non_echo_prompt", "fake-echo only accepts latest user input matching /^ECHO\\s+([\\s\\S]*)$/", requestId, 400); + } + logRequest(context, 200, "echo"); + return stream ? streamResponse(config, context, echo) : jsonResponse(completedResponsePayload(context, echo)); +} + +function streamResponse(config: FakeResponsesProviderConfig, context: ResponseRequestContext, text: string): Response { + const responseId = responseIdFor(context); + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => controller.enqueue(encoder.encode(chunk)); + enqueue(sse("response.created", responseCreatedPayload(context, responseId))); + enqueue(sse("response.output_item.added", outputItemAddedPayload(responseId))); + enqueue(sse("response.content_part.added", contentPartAddedPayload(responseId))); + if (config.responseDelayMs > 0) await sleep(config.responseDelayMs); + enqueue(sse("response.output_text.delta", outputTextDeltaPayload(responseId, text))); + enqueue(sse("response.output_text.done", outputTextDonePayload(responseId, text))); + enqueue(sse("response.content_part.done", contentPartDonePayload(responseId, text))); + enqueue(sse("response.output_item.done", outputItemDonePayload(responseId, text))); + enqueue(sse("response.completed", { type: "response.completed", response: completedResponsePayload(context, text, responseId) })); + enqueue("data: [DONE]\n\n"); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + "x-request-id": context.requestId, + }, + }); +} + +function modelsPayload(config: FakeResponsesProviderConfig): Record { + return { + object: "list", + data: [{ + id: config.modelId, + object: "model", + created: 0, + owned_by: "unidesk-fake-provider", + }], + valuesPrinted: false, + }; +} + +function responseCreatedPayload(context: ResponseRequestContext, responseId: string): Record { + return { + type: "response.created", + response: { + id: responseId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "in_progress", + model: context.model, + output: [], + parallel_tool_calls: false, + }, + }; +} + +function outputItemAddedPayload(responseId: string): Record { + return { + type: "response.output_item.added", + response_id: responseId, + output_index: 0, + item: { id: "msg_echo_0", type: "message", status: "in_progress", role: "assistant", content: [] }, + }; +} + +function contentPartAddedPayload(responseId: string): Record { + return { + type: "response.content_part.added", + response_id: responseId, + item_id: "msg_echo_0", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "" }, + }; +} + +function outputTextDeltaPayload(responseId: string, text: string): Record { + return { + type: "response.output_text.delta", + response_id: responseId, + item_id: "msg_echo_0", + output_index: 0, + content_index: 0, + delta: text, + }; +} + +function outputTextDonePayload(responseId: string, text: string): Record { + return { + type: "response.output_text.done", + response_id: responseId, + item_id: "msg_echo_0", + output_index: 0, + content_index: 0, + text, + }; +} + +function contentPartDonePayload(responseId: string, text: string): Record { + return { + type: "response.content_part.done", + response_id: responseId, + item_id: "msg_echo_0", + output_index: 0, + content_index: 0, + part: { type: "output_text", text }, + }; +} + +function outputItemDonePayload(responseId: string, text: string): Record { + return { + type: "response.output_item.done", + response_id: responseId, + output_index: 0, + item: assistantMessage(text, "completed"), + }; +} + +function completedResponsePayload(context: ResponseRequestContext, text: string, explicitId?: string): Record { + const id = explicitId ?? responseIdFor(context); + return { + id, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: context.model, + output: [assistantMessage(text, "completed")], + output_text: text, + usage: usageFor(context.prompt ?? "", text), + parallel_tool_calls: false, + valuesPrinted: false, + }; +} + +function assistantMessage(text: string, status: "in_progress" | "completed"): Record { + return { + id: "msg_echo_0", + type: "message", + status, + role: "assistant", + content: [{ type: "output_text", text }], + }; +} + +function usageFor(prompt: string, output: string): Record { + return { + input_tokens: Math.max(1, Math.ceil(prompt.length / 4)), + output_tokens: Math.max(1, Math.ceil(output.length / 4)), + total_tokens: Math.max(2, Math.ceil((prompt.length + output.length) / 4)), + }; +} + +function providerError(code: string, message: string, requestId: string, status: number): Response { + return jsonResponse({ + error: { + type: "invalid_request_error", + code, + message, + }, + requestId, + valuesPrinted: false, + }, status, { "x-request-id": requestId }); +} + +function latestUserText(body: Record): string | null { + const inputText = latestUserTextFromInput(body.input); + if (inputText !== null) return inputText; + return latestUserTextFromInput(body.messages); +} + +function latestUserTextFromInput(input: unknown): string | null { + if (typeof input === "string") return input; + if (!Array.isArray(input)) return null; + for (let index = input.length - 1; index >= 0; index -= 1) { + const item = input[index]; + if (typeof item === "string") return item; + if (!isRecord(item)) continue; + const role = typeof item.role === "string" ? item.role : null; + if (role !== null && role !== "user") continue; + const direct = contentText(item.content); + if (direct !== null) return direct; + const text = contentText(item); + if (text !== null) return text; + } + return null; +} + +function contentText(value: unknown): string | null { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const parts = value.map(contentText).filter((item): item is string => item !== null); + return parts.length === 0 ? null : parts.join(""); + } + if (!isRecord(value)) return null; + for (const key of ["text", "input_text"]) { + const found = value[key]; + if (typeof found === "string") return found; + } + return null; +} + +function parseEcho(prompt: string | null): string | null { + if (prompt === null) return null; + const match = /^ECHO\s+([\s\S]*)$/u.exec(prompt); + return match?.[1] ?? null; +} + +function responseIdFor(context: ResponseRequestContext): string { + return `resp_fake_${sha256(`${context.requestId}\0${context.bodySha256}`).slice(0, 24)}`; +} + +function logRequest(context: ResponseRequestContext, status: number, outcome: string): void { + const payload = { + event: "fake-responses-provider.request", + requestId: context.requestId, + traceIdPresent: context.traceId !== null, + status, + outcome, + model: context.model, + stream: context.stream, + bodyBytes: context.bodyBytes, + bodySha256: `sha256:${context.bodySha256}`, + promptSha256: context.prompt === null ? null : `sha256:${sha256(context.prompt)}`, + valuesPrinted: false, + }; + console.error(JSON.stringify(payload)); +} + +function sse(event: string, data: Record): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function jsonResponse(payload: Record, status = 200, extraHeaders: Record = {}): Response { + return new Response(`${JSON.stringify(payload)}\n`, { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...extraHeaders, + }, + }); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function boundedPort(raw: string | undefined, fallback: number): number { + return boundedInteger(raw, fallback, 1, 65_535, "port"); +} + +function boundedInteger(raw: string | undefined, fallback: number, min: number, max: number, label: string): number { + if (raw === undefined || raw.length === 0) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer between ${min} and ${max}`); + return value; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/scripts/src/hwlab-fake-model-provider.ts b/scripts/src/hwlab-fake-model-provider.ts new file mode 100644 index 00000000..58b27ade --- /dev/null +++ b/scripts/src/hwlab-fake-model-provider.ts @@ -0,0 +1,952 @@ +// SPEC: pikasTech/unidesk#1190 fake Responses provider for HWLAB v0.3 / AgentRun v0.2. +// Responsibility: YAML-first fake model provider materialization, k3s apply, status, and smoke checks. +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { repoRoot, rootPath, type Config } from "./config"; +import { runCommand, type CommandResult } from "./command"; +import { resolveAgentRunLaneTarget, type AgentRunLaneSpec } from "./agentrun-lanes"; +import { resolveSecretSourceRoot } from "./agentrun/secrets"; +import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; +import { parseEnvFile, sha256Fingerprint, shQuote } from "./platform-infra-ops-library"; + +type FakeModelProviderAction = "plan" | "materialize" | "apply" | "status" | "smoke"; + +interface FakeModelProviderOptions { + action: FakeModelProviderAction; + node: string; + lane: HwlabRuntimeLane; + provider: string; + confirm: boolean; + dryRun: boolean; + timeoutSeconds: number; + full: boolean; +} + +interface FakeModelProviderState { + options: FakeModelProviderOptions; + spec: HwlabRuntimeLaneSpec; + rootPath: string; + root: Record; + runtimeRef: string; + secretsRef: string; + profileRef: string; + runtime: Record; + secrets: Record; + profile: Record; + agentrun: { + configPath: string; + spec: AgentRunLaneSpec; + secretSourceRoot: string; + }; +} + +interface SourceMaterial { + purpose: string; + sourceRef: string; + sourceKey: string | null; + sourcePath: string; + existsBefore: boolean; + mutation: boolean; + valueBytes: number; + fingerprint: string; + valuesPrinted: false; +} + +interface MaterializationResult { + ok: boolean; + mutation: boolean; + secretSourceRoot: string; + sources: SourceMaterial[]; + files: Array<{ + purpose: string; + sourceRef: string; + sourcePath: string; + existsBefore: boolean; + mutation: boolean; + byteCount: number; + fingerprint: string; + valuesPrinted: false; + }>; + providerApiKey: string; + valuesPrinted: false; +} + +export function fakeModelProviderHelp(): Record { + return { + ok: true, + command: "hwlab nodes fake-model-provider", + description: "YAML-first fake OpenAI-compatible Responses provider for HWLAB/AgentRun sentinel smoke.", + examples: [ + "bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo", + "bun scripts/cli.ts hwlab nodes fake-model-provider materialize --node D518 --lane v03 --provider fake-echo --confirm", + "bun scripts/cli.ts hwlab nodes fake-model-provider apply --node D518 --lane v03 --provider fake-echo --confirm", + "bun scripts/cli.ts hwlab nodes fake-model-provider status --node D518 --lane v03 --provider fake-echo", + "bun scripts/cli.ts hwlab nodes fake-model-provider smoke --node D518 --lane v03 --provider fake-echo", + ], + actions: { + plan: "Read YAML configRefs and show local source/materialization and target k3s objects without mutation.", + materialize: "Create/update local .state/secrets source files for provider auth, Codex config, and sentinel prompt set.", + apply: "Materialize local sources, then apply ConfigMap/Secret/Deployment/Service to the selected node k3s namespace.", + status: "Inspect remote Deployment/Service/Secret/ConfigMap/pod readiness and /healthz without printing values.", + smoke: "Exec a deterministic ECHO streaming and non-ECHO error check inside the fake provider pod.", + }, + notes: [ + "Mutation actions require --confirm; --dry-run is accepted for apply.", + "Secret values are never printed; output is limited to sourceRef, key names, byte counts, and fingerprints.", + ], + }; +} + +export async function runHwlabFakeModelProviderCommand(_config: Config, args: string[]): Promise> { + if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return fakeModelProviderHelp(); + const options = parseFakeModelProviderOptions(args); + const state = readFakeModelProviderState(options); + if (options.action === "plan") return planFakeModelProvider(state); + if (options.action === "materialize") { + if (!options.confirm) throw new Error("fake-model-provider materialize requires --confirm"); + const materialized = materializeFakeModelProviderSources(state); + return { + ok: materialized.ok, + command: "hwlab nodes fake-model-provider materialize", + node: options.node, + lane: options.lane, + provider: options.provider, + mutation: materialized.mutation, + materialized: redactMaterialization(materialized), + next: { + providerApply: `bun scripts/cli.ts hwlab nodes fake-model-provider apply --node ${options.node} --lane ${options.lane} --provider ${options.provider} --confirm`, + agentrunSecretSync: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${options.node} --lane ${state.agentrun.spec.lane} --confirm`, + }, + valuesPrinted: false, + }; + } + if (options.action === "apply") return applyFakeModelProvider(state); + if (options.action === "status") return remoteFakeModelProviderStatus(state); + if (options.action === "smoke") return remoteFakeModelProviderSmoke(state); + throw new Error(`unsupported fake-model-provider action: ${options.action}`); +} + +function parseFakeModelProviderOptions(args: string[]): FakeModelProviderOptions { + const [actionRaw] = args; + if (actionRaw !== "plan" && actionRaw !== "materialize" && actionRaw !== "apply" && actionRaw !== "status" && actionRaw !== "smoke") { + throw new Error(`fake-model-provider action must be plan, materialize, apply, status, or smoke; got ${actionRaw ?? ""}`); + } + const knownString = new Set(["--node", "--lane", "--provider", "--timeout-seconds"]); + const knownFlags = new Set(["--confirm", "--dry-run", "--full", "--raw"]); + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (knownString.has(arg)) { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); + index += 1; + continue; + } + if (knownFlags.has(arg)) continue; + throw new Error(`unsupported fake-model-provider option: ${arg}`); + } + const node = requiredOption(args, "--node"); + if (!/^[A-Z][A-Z0-9_-]*$/u.test(node)) throw new Error("--node must be a simple node id such as D518"); + const laneRaw = requiredOption(args, "--lane"); + if (!isHwlabRuntimeLane(laneRaw)) throw new Error(`--lane must be one of v02, v03; got ${laneRaw}`); + const provider = optionValue(args, "--provider") ?? "fake-echo"; + if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u.test(provider)) throw new Error("--provider must be a Kubernetes-safe id"); + const confirm = args.includes("--confirm"); + const dryRun = args.includes("--dry-run"); + if (confirm && dryRun) throw new Error("fake-model-provider accepts only one of --confirm or --dry-run"); + if (actionRaw === "apply" && !confirm && !dryRun) throw new Error("fake-model-provider apply requires --dry-run or --confirm"); + if (actionRaw !== "apply" && dryRun) throw new Error(`fake-model-provider ${actionRaw} does not accept --dry-run`); + if ((actionRaw === "status" || actionRaw === "smoke" || actionRaw === "plan") && confirm) throw new Error(`fake-model-provider ${actionRaw} is read-only and does not accept --confirm`); + return { + action: actionRaw, + node, + lane: laneRaw, + provider, + confirm, + dryRun, + timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 900), + full: args.includes("--full") || args.includes("--raw"), + }; +} + +function readFakeModelProviderState(options: FakeModelProviderOptions): FakeModelProviderState { + const spec = hwlabRuntimeLaneSpecForNode(options.lane, options.node); + const configPath = rootPath("config", "hwlab-fake-model-provider", `${options.node.toLowerCase()}-${options.lane}`, `${options.provider}.yaml`); + if (!existsSync(configPath)) throw new Error(`fake provider config is missing: ${repoRelative(configPath)}`); + const root = readYamlRecord(configPath, "HwlabFakeModelProvider"); + const provider = record(root.provider, "provider"); + if (stringAt(provider, "id") !== options.provider) throw new Error(`${repoRelative(configPath)} provider.id must be ${options.provider}`); + if (provider.enabled !== true) throw new Error(`${repoRelative(configPath)} provider.enabled must be true`); + const target = record(provider.target, "provider.target"); + if (stringAt(target, "node") !== options.node) throw new Error(`${repoRelative(configPath)} provider.target.node must be ${options.node}`); + if (stringAt(target, "lane") !== options.lane) throw new Error(`${repoRelative(configPath)} provider.target.lane must be ${options.lane}`); + const configRefs = record(provider.configRefs, "provider.configRefs"); + const runtimeRef = stringAt(configRefs, "runtime"); + const secretsRef = stringAt(configRefs, "secrets"); + const profileRef = stringAt(configRefs, "profile"); + const runtime = record(readConfigRefTarget(runtimeRef), runtimeRef); + const secrets = record(readConfigRefTarget(secretsRef), secretsRef); + const profile = record(readConfigRefTarget(profileRef), profileRef); + const runtimeTarget = record(runtime.target, "provider.runtime.target"); + const agentrunLane = stringAt(runtimeTarget, "agentrunLane"); + const agentrunTarget = resolveAgentRunLaneTarget({ node: options.node, lane: agentrunLane }); + return { + options, + spec, + rootPath: configPath, + root, + runtimeRef, + secretsRef, + profileRef, + runtime, + secrets, + profile, + agentrun: { + configPath: agentrunTarget.configPath, + spec: agentrunTarget.spec, + secretSourceRoot: resolveSecretSourceRoot(agentrunTarget.spec), + }, + }; +} + +function planFakeModelProvider(state: FakeModelProviderState): Record { + const runtime = state.runtime; + const profile = state.profile; + const materialization = plannedMaterialSources(state); + return { + ok: true, + command: "hwlab nodes fake-model-provider plan", + node: state.options.node, + lane: state.options.lane, + provider: state.options.provider, + configRefs: { + root: repoRelative(state.rootPath), + runtime: state.runtimeRef, + secrets: state.secretsRef, + profile: state.profileRef, + }, + runtime: { + namespace: stringAt(runtime, "namespace"), + deployment: stringAt(runtime, "deploymentName"), + service: stringAt(runtime, "serviceName"), + configMap: stringAt(runtime, "configMapName"), + secret: stringAt(runtime, "secretName"), + image: record(runtime.image, "provider.runtime.image").imageRef, + model: record(runtime.config, "provider.runtime.config").modelId, + endpoint: `http://${stringAt(runtime, "serviceName")}.${stringAt(runtime, "namespace")}.svc.cluster.local:${numberAt(runtime, "servicePort")}/v1`, + }, + agentrun: { + configPath: repoRelative(state.agentrun.configPath.startsWith("/") ? state.agentrun.configPath : rootPath(state.agentrun.configPath)), + node: state.agentrun.spec.nodeId, + lane: state.agentrun.spec.lane, + namespace: state.agentrun.spec.runtime.namespace, + providerCredential: record(profile.providerCredential, "provider.profile.providerCredential"), + secretSourceRoot: displayPath(state.agentrun.secretSourceRoot), + }, + localSources: materialization, + next: { + materialize: `bun scripts/cli.ts hwlab nodes fake-model-provider materialize --node ${state.options.node} --lane ${state.options.lane} --provider ${state.options.provider} --confirm`, + apply: `bun scripts/cli.ts hwlab nodes fake-model-provider apply --node ${state.options.node} --lane ${state.options.lane} --provider ${state.options.provider} --confirm`, + agentrunSecretSync: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${state.options.node} --lane ${state.agentrun.spec.lane} --confirm`, + sentinelPlan: `bun scripts/cli.ts web-probe sentinel plan --node ${state.options.node} --lane ${state.options.lane} --sentinel workbench-fake-echo-session-invariance-10x`, + }, + valuesPrinted: false, + }; +} + +function plannedMaterialSources(state: FakeModelProviderState): Record { + const sources = []; + const providerSource = sourceByPurpose(state.secrets, "provider-api-key"); + const promptSource = sourceByPurpose(state.secrets, "sentinel-prompts"); + for (const source of [providerSource, promptSource]) { + const sourceRef = stringAt(source, "sourceRef"); + const sourceKey = stringAt(source, "sourceKey"); + const sourcePath = secretSourcePath(state, sourceRef); + const values = existsSync(sourcePath) ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {}; + const value = values[sourceKey] ?? ""; + sources.push({ + purpose: stringAt(source, "purpose"), + sourceRef, + sourceKey, + sourcePath: displayPath(sourcePath), + exists: existsSync(sourcePath), + keyPresent: value.length > 0, + valueBytes: value.length > 0 ? Buffer.byteLength(value) : 0, + fingerprint: value.length > 0 ? sha256Fingerprint(value) : null, + valuesPrinted: false, + }); + } + const credential = record(state.profile.providerCredential, "provider.profile.providerCredential"); + const files = [stringAt(credential, "authJsonSourceRef"), stringAt(credential, "configTomlSourceRef")].map((sourceRef) => { + const sourcePath = secretSourcePath(state, sourceRef); + const value = existsSync(sourcePath) ? readFileSync(sourcePath, "utf8") : ""; + return { + sourceRef, + sourcePath: displayPath(sourcePath), + exists: existsSync(sourcePath), + byteCount: Buffer.byteLength(value), + fingerprint: value.length > 0 ? sha256Fingerprint(value) : null, + valuesPrinted: false, + }; + }); + return { + secretSourceRoot: displayPath(state.agentrun.secretSourceRoot), + sources, + files, + valuesPrinted: false, + }; +} + +function materializeFakeModelProviderSources(state: FakeModelProviderState): MaterializationResult { + const providerSource = sourceByPurpose(state.secrets, "provider-api-key"); + const promptSource = sourceByPurpose(state.secrets, "sentinel-prompts"); + const apiKey = ensureEnvSourceValue(state, providerSource, () => randomBytes(randomHexBytes(providerSource, 24)).toString("hex")); + const promptJson = JSON.stringify(fakeEchoPrompts()); + const prompts = ensureEnvSourceExactValue(state, promptSource, promptJson); + const credential = record(state.profile.providerCredential, "provider.profile.providerCredential"); + const authJsonRef = stringAt(credential, "authJsonSourceRef"); + const configTomlRef = stringAt(credential, "configTomlSourceRef"); + const authJson = `${JSON.stringify({ OPENAI_API_KEY: apiKey.value }, null, 2)}\n`; + const configToml = renderFakeEchoCodexConfig(state); + const authFile = writeSecretFileSource(state, authJsonRef, authJson, "provider-auth-json"); + const configFile = writeSecretFileSource(state, configTomlRef, configToml, "provider-config-toml"); + const mutation = apiKey.mutation || prompts.mutation || authFile.mutation || configFile.mutation; + return { + ok: true, + mutation, + secretSourceRoot: displayPath(state.agentrun.secretSourceRoot), + sources: [apiKey, prompts], + files: [authFile, configFile], + providerApiKey: apiKey.value, + valuesPrinted: false, + }; +} + +function redactMaterialization(materialized: MaterializationResult): Record { + return { + ok: materialized.ok, + mutation: materialized.mutation, + secretSourceRoot: materialized.secretSourceRoot, + sources: materialized.sources.map(({ value: _value, ...item }) => item), + files: materialized.files, + valuesPrinted: false, + }; +} + +function applyFakeModelProvider(state: FakeModelProviderState): Record { + if (state.options.dryRun) { + const preview = renderFakeModelProviderManifests(state, null); + return { + ok: true, + command: "hwlab nodes fake-model-provider apply --dry-run", + node: state.options.node, + lane: state.options.lane, + provider: state.options.provider, + mutation: false, + manifest: preview.summary, + localSources: plannedMaterialSources(state), + confirm: `bun scripts/cli.ts hwlab nodes fake-model-provider apply --node ${state.options.node} --lane ${state.options.lane} --provider ${state.options.provider} --confirm`, + valuesPrinted: false, + }; + } + if (!state.options.confirm) throw new Error("fake-model-provider apply requires --confirm"); + const materialized = materializeFakeModelProviderSources(state); + const rendered = renderFakeModelProviderManifests(state, materialized.providerApiKey); + const applyResult = runRemoteApply(state, rendered.yaml); + const applyPayload = parseJsonObject(applyResult.stdout); + const status = remoteFakeModelProviderStatus(state); + return { + ok: applyResult.exitCode === 0 && applyPayload.ok === true && status.ok === true, + command: "hwlab nodes fake-model-provider apply", + node: state.options.node, + lane: state.options.lane, + provider: state.options.provider, + mutation: true, + materialized: redactMaterialization(materialized), + manifest: rendered.summary, + apply: Object.keys(applyPayload).length > 0 ? applyPayload : compactCommand(applyResult, state.options.full), + status, + next: { + smoke: `bun scripts/cli.ts hwlab nodes fake-model-provider smoke --node ${state.options.node} --lane ${state.options.lane} --provider ${state.options.provider}`, + agentrunSecretSync: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${state.options.node} --lane ${state.agentrun.spec.lane} --confirm`, + sentinelTrigger: "bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node D518 --lane v03 --sentinel workbench-fake-echo-session-invariance-10x --confirm --wait", + }, + valuesPrinted: false, + }; +} + +function renderFakeModelProviderManifests(state: FakeModelProviderState, apiKey: string | null): { yaml: string; summary: Record } { + const runtime = state.runtime; + const namespace = stringAt(runtime, "namespace"); + const serviceName = stringAt(runtime, "serviceName"); + const deploymentName = stringAt(runtime, "deploymentName"); + const configMapName = stringAt(runtime, "configMapName"); + const secretName = stringAt(runtime, "secretName"); + const containerName = stringAt(runtime, "containerName"); + const servicePort = numberAt(runtime, "servicePort"); + const image = record(runtime.image, "provider.runtime.image"); + const config = record(runtime.config, "provider.runtime.config"); + const resources = record(runtime.resources, "provider.runtime.resources"); + const probes = record(runtime.probes, "provider.runtime.probes"); + const source = record(runtime.source, "provider.runtime.source"); + const files = arrayAt(source, "files").map((item) => { + if (typeof item !== "string" || item.length === 0 || item.startsWith("/") || item.includes("..")) throw new Error("provider.runtime.source.files entries must be relative safe paths"); + const path = rootPath(item); + if (!existsSync(path)) throw new Error(`fake provider source file is missing: ${item}`); + return { path: item, key: sourceConfigKey(item), content: readFileSync(path, "utf8") }; + }); + const labels = { + "app.kubernetes.io/name": serviceName, + "app.kubernetes.io/part-of": "hwlab-fake-model-provider", + "app.kubernetes.io/managed-by": "unidesk", + "unidesk.ai/node": state.options.node, + "unidesk.ai/lane": state.options.lane, + "unidesk.ai/provider": state.options.provider, + }; + const objects: Record[] = [{ + apiVersion: "v1", + kind: "Namespace", + metadata: { name: namespace }, + }, { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: configMapName, namespace, labels }, + data: Object.fromEntries(files.map((file) => [file.key, file.content])), + }]; + if (apiKey !== null) { + objects.push({ + apiVersion: "v1", + kind: "Secret", + metadata: { name: secretName, namespace, labels }, + type: "Opaque", + stringData: { "api-key": apiKey }, + }); + } + objects.push({ + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: deploymentName, namespace, labels }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": serviceName, "unidesk.ai/provider": state.options.provider } }, + template: { + metadata: { labels }, + spec: { + serviceAccountName: stringAt(runtime, "serviceAccountName"), + volumes: [{ name: "provider-source", configMap: { name: configMapName } }], + containers: [{ + name: containerName, + image: stringAt(image, "imageRef"), + imagePullPolicy: stringAt(image, "imagePullPolicy"), + command: ["/bin/sh", "-ec"], + args: [providerContainerCommand(files, stringAt(source, "entrypoint"))], + env: [ + { name: "LISTEN_HOST", value: stringAt(runtime, "listenHost") }, + { name: "PORT", value: String(servicePort) }, + { name: "FAKE_RESPONSES_MODEL_ID", value: stringAt(config, "modelId") }, + { name: "FAKE_RESPONSES_MODE", value: stringAt(config, "mode") }, + { name: "FAKE_RESPONSES_DELAY_MS", value: String(numberAt(config, "responseDelayMs")) }, + { name: "FAKE_RESPONSES_VERSION", value: sourceDigest(files).slice(0, 16) }, + { name: "FAKE_ECHO_API_KEY", valueFrom: { secretKeyRef: { name: secretName, key: "api-key", optional: true } } }, + ], + ports: [{ name: "http", containerPort: servicePort }], + resources, + readinessProbe: { + httpGet: { path: stringAt(runtime, "healthPath"), port: "http" }, + initialDelaySeconds: numberAt(probes, "initialDelaySeconds"), + periodSeconds: numberAt(probes, "periodSeconds"), + timeoutSeconds: numberAt(probes, "timeoutSeconds"), + failureThreshold: numberAt(probes, "failureThreshold"), + }, + livenessProbe: { + httpGet: { path: stringAt(runtime, "healthPath"), port: "http" }, + initialDelaySeconds: numberAt(probes, "initialDelaySeconds"), + periodSeconds: numberAt(probes, "periodSeconds"), + timeoutSeconds: numberAt(probes, "timeoutSeconds"), + failureThreshold: numberAt(probes, "failureThreshold"), + }, + volumeMounts: [{ name: "provider-source", mountPath: "/config/provider-source", readOnly: true }], + }], + }, + }, + }, + }, { + apiVersion: "v1", + kind: "Service", + metadata: { name: serviceName, namespace, labels }, + spec: { + type: "ClusterIP", + selector: { "app.kubernetes.io/name": serviceName, "unidesk.ai/provider": state.options.provider }, + ports: [{ name: "http", port: servicePort, targetPort: "http" }], + }, + }); + const yaml = `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; + return { + yaml, + summary: { + namespace, + objects: objects.map((item) => `${item.kind}/${record(item.metadata, "metadata").name}`), + manifestSha256: sha256Fingerprint(yaml), + sourceFiles: files.map((file) => ({ path: file.path, key: file.key, bytes: Buffer.byteLength(file.content), sha256: sha256Fingerprint(file.content) })), + secretIncluded: apiKey !== null, + valuesPrinted: false, + }, + }; +} + +function providerContainerCommand(files: Array<{ path: string; key: string }>, entrypoint: string): string { + if (entrypoint.startsWith("/") || entrypoint.includes("..")) throw new Error("provider.runtime.source.entrypoint must be a relative safe path"); + return [ + "set -eu", + "rm -rf /work", + "mkdir -p /work", + ...files.map((file) => { + const target = `/work/${file.path}`; + return `mkdir -p ${shQuote(dirname(target))}; cp ${shQuote(`/config/provider-source/${file.key}`)} ${shQuote(target)}`; + }), + "cd /work", + `exec bun ${shQuote(entrypoint)}`, + ].join("\n"); +} + +function runRemoteApply(state: FakeModelProviderState, manifestYaml: string): CommandResult { + const runtime = state.runtime; + const namespace = stringAt(runtime, "namespace"); + const deployment = stringAt(runtime, "deploymentName"); + const timeout = Math.min(state.options.timeoutSeconds, 300); + const script = [ + "set +e", + `namespace=${shQuote(namespace)}`, + `deployment=${shQuote(deployment)}`, + "tmp=$(mktemp -d)", + "trap 'rm -rf \"$tmp\"' EXIT", + "manifest=\"$tmp/fake-model-provider.yaml\"", + "cat >\"$manifest\"", + "kubectl apply --server-side --force-conflicts --field-manager=unidesk-hwlab-fake-model-provider -f \"$manifest\" >/tmp/fake-provider-apply.out 2>/tmp/fake-provider-apply.err", + "apply_rc=$?", + "rollout_rc=0", + "if [ \"$apply_rc\" -eq 0 ]; then", + ` kubectl -n "$namespace" rollout status deployment/"$deployment" --timeout=${timeout}s >/tmp/fake-provider-rollout.out 2>/tmp/fake-provider-rollout.err`, + " rollout_rc=$?", + "fi", + "python3 - \"$apply_rc\" \"$rollout_rc\" <<'PY'", + "import json, pathlib, sys", + "apply_rc = int(sys.argv[1]); rollout_rc = int(sys.argv[2])", + "def tail(path, n=4000):", + " try: return pathlib.Path(path).read_text(errors='replace')[-n:]", + " except FileNotFoundError: return ''", + "print(json.dumps({'ok': apply_rc == 0 and rollout_rc == 0, 'applyExitCode': apply_rc, 'rolloutExitCode': rollout_rc, 'applyStdoutTail': tail('/tmp/fake-provider-apply.out'), 'applyStderrTail': tail('/tmp/fake-provider-apply.err'), 'rolloutStdoutTail': tail('/tmp/fake-provider-rollout.out'), 'rolloutStderrTail': tail('/tmp/fake-provider-rollout.err'), 'valuesPrinted': False}, ensure_ascii=False))", + "PY", + ].join("\n"); + return runCommand([transPath(), state.spec.nodeKubeRoute, "sh", "--", script], repoRoot, { + input: manifestYaml, + timeoutMs: (state.options.timeoutSeconds + 30) * 1000, + }); +} + +function remoteFakeModelProviderStatus(state: FakeModelProviderState): Record { + const runtime = state.runtime; + const script = remoteStatusScript(runtime); + const result = runCommand([transPath(), state.spec.nodeKubeRoute, "sh", "--", script], repoRoot, { timeoutMs: state.options.timeoutSeconds * 1000 }); + const payload = parseJsonObject(result.stdout); + return { + ok: result.exitCode === 0 && payload.ok === true, + command: "hwlab nodes fake-model-provider status", + node: state.options.node, + lane: state.options.lane, + provider: state.options.provider, + target: { + route: state.spec.nodeKubeRoute, + namespace: stringAt(runtime, "namespace"), + deployment: stringAt(runtime, "deploymentName"), + service: stringAt(runtime, "serviceName"), + }, + ...(Object.keys(payload).length > 0 ? payload : { result: compactCommand(result, state.options.full) }), + valuesPrinted: false, + }; +} + +function remoteFakeModelProviderSmoke(state: FakeModelProviderState): Record { + const runtime = state.runtime; + const script = remoteSmokeScript(runtime); + const result = runCommand([transPath(), state.spec.nodeKubeRoute, "sh", "--", script], repoRoot, { timeoutMs: state.options.timeoutSeconds * 1000 }); + const payload = parseJsonObject(result.stdout); + return { + ok: result.exitCode === 0 && payload.ok === true, + command: "hwlab nodes fake-model-provider smoke", + node: state.options.node, + lane: state.options.lane, + provider: state.options.provider, + target: { + route: state.spec.nodeKubeRoute, + namespace: stringAt(runtime, "namespace"), + deployment: stringAt(runtime, "deploymentName"), + service: stringAt(runtime, "serviceName"), + }, + ...(Object.keys(payload).length > 0 ? payload : { result: compactCommand(result, state.options.full) }), + valuesPrinted: false, + }; +} + +function remoteStatusScript(runtime: Record): string { + return remoteCommonNodeScript(runtime, ` +const deploy = kubectlJson(['-n', ns, 'get', 'deployment', deployment, '-o', 'json']); +const svc = kubectlJson(['-n', ns, 'get', 'service', service, '-o', 'json']); +const cm = kubectlJson(['-n', ns, 'get', 'configmap', configMap, '-o', 'json']); +const secret = kubectlJson(['-n', ns, 'get', 'secret', secretName, '-o', 'json']); +const pods = kubectlJson(['-n', ns, 'get', 'pod', '-l', selector, '-o', 'json']); +const pod = selectPod(pods.value); +let health = { ok: false, skipped: pod === null, status: null, body: null, valuesPrinted: false }; +if (pod !== null) { + const js = "const r=await fetch('http://127.0.0.1:" + port + "/healthz'); const t=await r.text(); console.log(JSON.stringify({status:r.status, body:t, valuesPrinted:false}));"; + const out = run(['kubectl', '-n', ns, 'exec', pod.name, '-c', container, '--', 'bun', '-e', js]); + const parsed = parseJson(out.stdout); + health = { ok: out.status === 0 && parsed.status === 200, status: parsed.status ?? null, body: parseJson(parsed.body || ''), exitCode: out.status, stderrTail: out.stderr.slice(-1000), valuesPrinted: false }; +} +const available = Number(deploy.value?.status?.availableReplicas || 0); +const ready = Number(deploy.value?.status?.readyReplicas || 0); +const data = secret.value?.data && typeof secret.value.data === 'object' ? secret.value.data : {}; +const configData = cm.value?.data && typeof cm.value.data === 'object' ? cm.value.data : {}; +const ok = deploy.ok && svc.ok && cm.ok && secret.ok && available >= 1 && health.ok; +console.log(JSON.stringify({ + ok, + deployment: { exists: deploy.ok, availableReplicas: available, readyReplicas: ready, observedGeneration: deploy.value?.status?.observedGeneration ?? null }, + service: { exists: svc.ok, clusterIP: svc.value?.spec?.clusterIP ?? null, ports: (svc.value?.spec?.ports || []).map((item) => ({ name: item.name, port: item.port, targetPort: item.targetPort })) }, + configMap: { exists: cm.ok, keys: Object.keys(configData).sort(), valuesPrinted: false }, + secret: { exists: secret.ok, keys: Object.keys(data).sort(), valuesPrinted: false }, + pod, + health, + valuesPrinted: false +}, null, 0)); +`); +} + +function remoteSmokeScript(runtime: Record): string { + const model = stringAt(record(runtime.config, "provider.runtime.config"), "modelId"); + return remoteCommonNodeScript(runtime, ` +const pods = kubectlJson(['-n', ns, 'get', 'pod', '-l', selector, '-o', 'json']); +const pod = selectPod(pods.value); +if (pod === null) { + console.log(JSON.stringify({ ok: false, error: 'fake-provider-pod-missing', valuesPrinted: false })); + process.exit(0); +} +const js = ${JSON.stringify(` +const base = 'http://127.0.0.1:${numberAt(runtime, "servicePort")}'; +const body = { model: ${JSON.stringify(model)}, input: [{ role: 'user', content: [{ type: 'input_text', text: 'ECHO AGENTRUN_FAKE_OK' }] }], stream: true }; +const response = await fetch(base + '/v1/responses', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer test-redacted' }, body: JSON.stringify(body) }); +const text = await response.text(); +const bad = await fetch(base + '/v1/responses', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: ${JSON.stringify(model)}, input: 'non echo', stream: false }) }); +const badText = await bad.text(); +const ok = response.status === 200 && text.includes('AGENTRUN_FAKE_OK') && text.includes('response.completed') && bad.status === 400; +console.log(JSON.stringify({ ok, streamStatus: response.status, deltaSeen: text.includes('AGENTRUN_FAKE_OK'), completedSeen: text.includes('response.completed'), doneSeen: text.includes('[DONE]'), nonEchoStatus: bad.status, nonEchoStructuredError: badText.includes('non_echo_prompt'), valuesPrinted: false })); +`)}; +const out = run(['kubectl', '-n', ns, 'exec', pod.name, '-c', container, '--', 'bun', '-e', js]); +const parsed = parseJson(out.stdout); +console.log(JSON.stringify({ + ok: out.status === 0 && parsed.ok === true, + pod, + smoke: Object.keys(parsed).length > 0 ? parsed : null, + exec: { exitCode: out.status, stderrTail: out.stderr.slice(-1200), valuesPrinted: false }, + valuesPrinted: false +})); +`); +} + +function remoteCommonNodeScript(runtime: Record, body: string): string { + const namespace = stringAt(runtime, "namespace"); + const deployment = stringAt(runtime, "deploymentName"); + const service = stringAt(runtime, "serviceName"); + const configMap = stringAt(runtime, "configMapName"); + const secretName = stringAt(runtime, "secretName"); + const container = stringAt(runtime, "containerName"); + const port = numberAt(runtime, "servicePort"); + const selector = `app.kubernetes.io/name=${service},unidesk.ai/provider=fake-echo`; + return [ + "set +e", + `NS=${shQuote(namespace)} DEPLOYMENT=${shQuote(deployment)} SERVICE=${shQuote(service)} CONFIG_MAP=${shQuote(configMap)} SECRET_NAME=${shQuote(secretName)} CONTAINER=${shQuote(container)} PORT=${shQuote(String(port))} SELECTOR=${shQuote(selector)} node <<'NODE'`, + "const cp = require('node:child_process');", + "const ns = process.env.NS;", + "const deployment = process.env.DEPLOYMENT;", + "const service = process.env.SERVICE;", + "const configMap = process.env.CONFIG_MAP;", + "const secretName = process.env.SECRET_NAME;", + "const container = process.env.CONTAINER;", + "const port = process.env.PORT;", + "const selector = process.env.SELECTOR;", + "function run(argv) { return cp.spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); }", + "function parseJson(text) { try { return JSON.parse(String(text || '').trim()); } catch { const s=String(text||''); const a=s.indexOf('{'); const b=s.lastIndexOf('}'); if (a>=0&&b>a) { try { return JSON.parse(s.slice(a,b+1)); } catch {} } return {}; } }", + "function kubectlJson(args) { const out = run(['kubectl', ...args]); return { ok: out.status === 0, exitCode: out.status, value: out.status === 0 ? parseJson(out.stdout) : {}, stderrTail: out.stderr.slice(-1200) }; }", + "function selectPod(pods) { const items = Array.isArray(pods?.items) ? pods.items : []; const mapped = items.map((item) => { const statuses = Array.isArray(item.status?.containerStatuses) ? item.status.containerStatuses : []; const target = statuses.find((status) => status.name === container); return { name: item.metadata?.name || '', phase: item.status?.phase || null, ready: item.status?.phase === 'Running' && target?.ready === true, containerReady: target?.ready === true, restartCount: target?.restartCount ?? null }; }).filter((item) => item.name); return mapped.find((item) => item.ready) || mapped.find((item) => item.phase === 'Running') || mapped[0] || null; }", + body, + "NODE", + ].join("\n"); +} + +function sourceByPurpose(secrets: Record, purpose: string): Record { + const source = arrayAt(secrets, "sources").map((item) => record(item, "provider.secrets.sources[]")).find((item) => item.purpose === purpose); + if (source === undefined) throw new Error(`provider.secrets.sources is missing purpose=${purpose}`); + return source; +} + +function ensureEnvSourceValue(state: FakeModelProviderState, source: Record, createValue: () => string): SourceMaterial & { value: string } { + const purpose = stringAt(source, "purpose"); + const sourceRef = stringAt(source, "sourceRef"); + const sourceKey = stringAt(source, "sourceKey"); + const sourcePath = secretSourcePath(state, sourceRef); + const existsBefore = existsSync(sourcePath); + const existingText = existsBefore ? readFileSync(sourcePath, "utf8") : ""; + const values = parseEnvFile(existingText); + const existing = values[sourceKey]; + const value = existing && existing.length > 0 ? existing : createValue(); + let nextText = existingText; + let mutation = false; + if (!existing || existing.length === 0) { + nextText = upsertEnvLine(existingText, sourceKey, value); + writePrivateFile(sourcePath, nextText); + mutation = true; + } + return { + purpose, + sourceRef, + sourceKey, + sourcePath: displayPath(sourcePath), + existsBefore, + mutation, + value, + valueBytes: Buffer.byteLength(value), + fingerprint: sha256Fingerprint(value), + valuesPrinted: false, + }; +} + +function ensureEnvSourceExactValue(state: FakeModelProviderState, source: Record, value: string): SourceMaterial & { value: string } { + const purpose = stringAt(source, "purpose"); + const sourceRef = stringAt(source, "sourceRef"); + const sourceKey = stringAt(source, "sourceKey"); + const sourcePath = secretSourcePath(state, sourceRef); + const existsBefore = existsSync(sourcePath); + const existingText = existsBefore ? readFileSync(sourcePath, "utf8") : ""; + const values = parseEnvFile(existingText); + const existing = values[sourceKey] ?? ""; + const mutation = existing !== value; + if (mutation) writePrivateFile(sourcePath, upsertEnvLine(existingText, sourceKey, value)); + return { + purpose, + sourceRef, + sourceKey, + sourcePath: displayPath(sourcePath), + existsBefore, + mutation, + value, + valueBytes: Buffer.byteLength(value), + fingerprint: sha256Fingerprint(value), + valuesPrinted: false, + }; +} + +function writeSecretFileSource(state: FakeModelProviderState, sourceRef: string, content: string, purpose: string): MaterializationResult["files"][number] { + const sourcePath = secretSourcePath(state, sourceRef); + const existsBefore = existsSync(sourcePath); + const current = existsBefore ? readFileSync(sourcePath, "utf8") : ""; + const mutation = current !== content; + if (mutation) writePrivateFile(sourcePath, content); + return { + purpose, + sourceRef, + sourcePath: displayPath(sourcePath), + existsBefore, + mutation, + byteCount: Buffer.byteLength(content), + fingerprint: sha256Fingerprint(content), + valuesPrinted: false, + }; +} + +function renderFakeEchoCodexConfig(state: FakeModelProviderState): string { + const config = record(state.profile.codexConfig, "provider.profile.codexConfig"); + return [ + `model = ${tomlString(stringAt(config, "model"))}`, + `model_provider = ${tomlString(stringAt(config, "modelProvider"))}`, + `review_model = ${tomlString(stringAt(config, "model"))}`, + `model_context_window = ${numberAt(config, "modelContextWindow")}`, + `model_auto_compact_token_limit = ${numberAt(config, "modelAutoCompactTokenLimit")}`, + `model_supports_reasoning_summaries = ${config.modelSupportsReasoningSummaries === true ? "true" : "false"}`, + "", + `[model_providers.${stringAt(config, "modelProvider")}]`, + `name = ${tomlString(stringAt(config, "modelProvider"))}`, + `base_url = ${tomlString(stringAt(config, "baseUrl"))}`, + `wire_api = ${tomlString(stringAt(config, "wireApi"))}`, + `requires_openai_auth = ${config.requiresOpenaiAuth === true ? "true" : "false"}`, + "", + ].join("\n"); +} + +function fakeEchoPrompts(): string[] { + return Array.from({ length: 10 }, (_, index) => { + const marker = `sentinel-${String(index + 1).padStart(2, "0")}`; + return `ECHO ${marker} fake-echo session invariance round ${index + 1}`; + }); +} + +function randomHexBytes(source: Record, fallback: number): number { + const policy = record(source.createIfMissing, "createIfMissing"); + const value = policy.randomHexBytes; + return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 256 ? value : fallback; +} + +function secretSourcePath(state: FakeModelProviderState, sourceRef: string): string { + if (sourceRef.includes("..")) throw new Error(`secret sourceRef must not contain ..: ${sourceRef}`); + if (sourceRef.startsWith("/")) return sourceRef; + return join(state.agentrun.secretSourceRoot, ...sourceRef.split("/")); +} + +function sourceConfigKey(path: string): string { + return path.replace(/[^A-Za-z0-9_.-]+/gu, "__"); +} + +function sourceDigest(files: Array<{ path: string; content: string }>): string { + const hash = createHash("sha256"); + for (const file of files.slice().sort((a, b) => a.path.localeCompare(b.path))) { + hash.update(file.path); + hash.update("\0"); + hash.update(file.content); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function writePrivateFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, { mode: 0o600 }); + try { + chmodSync(path, 0o600); + } catch { + // chmod is best-effort on non-POSIX filesystems. + } +} + +function upsertEnvLine(text: string, key: string, value: string): string { + const quoted = `${key}=${envSingleQuote(value)}`; + const lines = text.length === 0 ? [] : text.replace(/\n?$/u, "").split(/\r?\n/u); + const index = lines.findIndex((line) => line.trim().startsWith(`${key}=`)); + if (index >= 0) lines[index] = quoted; + else lines.push(quoted); + return `${lines.join("\n")}\n`; +} + +function envSingleQuote(value: string): string { + if (value.includes("'")) throw new Error("fake provider source values containing single quotes are not supported by the current env parser"); + return `'${value}'`; +} + +function readConfigRefTarget(ref: string): unknown { + const [pathPart, selector] = ref.split("#"); + if (!pathPart || !selector) throw new Error(`configRef must use path#selector: ${ref}`); + const parsed = readYamlRecord(rootPath(pathPart)); + let current: unknown = parsed; + for (const rawPart of selector.split(".")) { + const part = rawPart.trim(); + if (part.length === 0) continue; + current = record(current, ref)[part]; + } + return current; +} + +function readYamlRecord(path: string, expectedKind?: string): Record { + const parsed = Bun.YAML.parse(readFileSync(path, "utf8")) as unknown; + const value = record(parsed, repoRelative(path)); + if (expectedKind !== undefined && value.kind !== expectedKind) throw new Error(`${repoRelative(path)} kind must be ${expectedKind}`); + return value; +} + +function optionValue(args: string[], name: string): string | null { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] ?? null : null; +} + +function requiredOption(args: string[], name: string): string { + const value = optionValue(args, name); + if (value === null || value.length === 0 || value.startsWith("--")) throw new Error(`${name} is required`); + return value; +} + +function positiveIntegerOption(args: string[], name: string, defaultValue: number, max: number): number { + const raw = optionValue(args, name); + if (raw === null) return defaultValue; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0 || value > max) throw new Error(`${name} must be an integer in 1..${max}`); + return value; +} + +function arrayAt(obj: Record, key: string): unknown[] { + const value = obj[key]; + if (!Array.isArray(value)) throw new Error(`${key} must be an array`); + return value; +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +} + +function stringAt(obj: Record, key: string): string { + const value = obj[key]; + if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`); + return value; +} + +function numberAt(obj: Record, key: string): number { + const value = obj[key]; + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`); + return value; +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function parseJsonObject(text: string): Record { + const trimmed = text.trim(); + if (trimmed.length > 0) { + try { + return record(JSON.parse(trimmed) as unknown, "json"); + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + return record(JSON.parse(trimmed.slice(start, end + 1)) as unknown, "json"); + } catch {} + } + } + } + return {}; +} + +function compactCommand(result: CommandResult, full = false): Record { + return { + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + stdoutTail: full || result.exitCode !== 0 ? result.stdout.trim().slice(-4000) : "", + stderrTail: full || result.exitCode !== 0 ? result.stderr.trim().slice(-4000) : "", + }; +} + +function transPath(): string { + return join(repoRoot, "scripts", "trans"); +} + +function repoRelative(path: string): string { + return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path; +} + +function displayPath(path: string): string { + if (path.startsWith(`${repoRoot}/`)) return path.slice(repoRoot.length + 1); + const marker = "/.state/secrets/"; + const index = path.indexOf(marker); + if (index >= 0) return `.state/secrets/${path.slice(index + marker.length)}`; + if (path.endsWith("/.state/secrets")) return ".state/secrets"; + return path; +} diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index fb9bec48..75d7985a 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -14,6 +14,7 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes control-plane status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", "bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes observability performance-summary --node D601 --lane v03", @@ -23,6 +24,7 @@ export function hwlabNodeHelp(): Record { "control-plane": "YAML-first node-local CI/CD, git-mirror, public exposure, runtime-image, Argo and PipelineRun operations.", "git-mirror": "Inspect or operate the selected node/lane source mirror.", "hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.", + "fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.", secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.", "test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.", observability: "Read runtime metrics and authenticated Web Performance summaries.", diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 0f9a7683..558a38af 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -27,6 +27,7 @@ import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary"; import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql"; import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport"; +import { runHwlabFakeModelProviderCommand } from "../hwlab-fake-model-provider"; import type { RenderedCliResult } from "../output"; import { nodeRuntimeControlPlaneRun } from "./cleanup"; @@ -501,6 +502,9 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom const { runHwlabNodeHwpodPreinstallCommand } = await import("../hwlab-node-hwpod-preinstall"); return runHwlabNodeHwpodPreinstallCommand(args.slice(1)); } + if (domain === "fake-model-provider") { + return runHwlabFakeModelProviderCommand(_config, args.slice(1)); + } if (domain === "web-probe") { return legacyHwlabNodeWebProbeUnsupported(args.slice(1)); } @@ -513,7 +517,7 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom return runNodeDelegatedDomain(_config, domain, args.slice(1)); } if (domain !== "secret") { - return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" }; + return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" }; } const options = parseSecretOptions(args.slice(1)); return runNodeSecret(options);