feat: add HWLAB fake echo provider
This commit is contained in:
@@ -834,6 +834,24 @@ controlPlane:
|
|||||||
key: model-catalog.json
|
key: model-catalog.json
|
||||||
providerCredential:
|
providerCredential:
|
||||||
profile: dsflash-go
|
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
|
- id: tool-github-pr-token
|
||||||
sourceRef: /root/.config/unidesk/github.env
|
sourceRef: /root/.config/unidesk/github.env
|
||||||
sourceKey: GH_TOKEN
|
sourceKey: GH_TOKEN
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -654,9 +654,9 @@ lanes:
|
|||||||
prometheusOperator: false
|
prometheusOperator: false
|
||||||
webProbe:
|
webProbe:
|
||||||
sentinels:
|
sentinels:
|
||||||
- id: workbench-dsflash-go-tool-call-10x
|
- id: workbench-fake-echo-session-invariance-10x
|
||||||
enabled: true
|
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:
|
runtimeImageRewrites:
|
||||||
- source: fatedier/frpc:v0.68.1
|
- source: fatedier/frpc:v0.68.1
|
||||||
target: 127.0.0.1:5000/hwlab/frpc:v0.68.1
|
target: 127.0.0.1:5000/hwlab/frpc:v0.68.1
|
||||||
@@ -691,7 +691,7 @@ lanes:
|
|||||||
opencodeSourceKey: OPENCODE_API_KEY
|
opencodeSourceKey: OPENCODE_API_KEY
|
||||||
codeAgentRuntime:
|
codeAgentRuntime:
|
||||||
enabled: true
|
enabled: true
|
||||||
adapter: agentrun-v01
|
adapter: agentrun-v02
|
||||||
managerUrl: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080
|
managerUrl: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080
|
||||||
apiKeySecretName: hwlab-v03-master-server-admin-api-key
|
apiKeySecretName: hwlab-v03-master-server-admin-api-key
|
||||||
apiKeySecretKey: api-key
|
apiKeySecretKey: api-key
|
||||||
@@ -699,7 +699,7 @@ lanes:
|
|||||||
secretNamespace: agentrun-v02
|
secretNamespace: agentrun-v02
|
||||||
repoUrlFrom: runtimeGitReadUrl
|
repoUrlFrom: runtimeGitReadUrl
|
||||||
providerIdFrom: runtimeNodeId
|
providerIdFrom: runtimeNodeId
|
||||||
defaultProviderProfile: deepseek
|
defaultProviderProfile: fake-echo
|
||||||
publicExposure:
|
publicExposure:
|
||||||
mode: pk01-caddy-frp
|
mode: pk01-caddy-frp
|
||||||
publicBaseUrl: https://hwlab.pikapython.com
|
publicBaseUrl: https://hwlab.pikapython.com
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+19
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const parsed = await response.json() as unknown;
|
||||||
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEvents(text: string): Array<{ event: string; data: Record<string, unknown> }> {
|
||||||
|
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<string, unknown> }]
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Response>;
|
||||||
|
health(): Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Response> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<Response> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<Uint8Array>({
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: "msg_echo_0",
|
||||||
|
type: "message",
|
||||||
|
status,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageFor(prompt: string, output: string): Record<string, unknown> {
|
||||||
|
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, unknown>): 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, unknown>): string {
|
||||||
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(payload: Record<string, unknown>, status = 200, extraHeaders: Record<string, string> = {}): 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<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
runtimeRef: string;
|
||||||
|
secretsRef: string;
|
||||||
|
profileRef: string;
|
||||||
|
runtime: Record<string, unknown>;
|
||||||
|
secrets: Record<string, unknown>;
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
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<string, unknown> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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 ?? "<missing>"}`);
|
||||||
|
}
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> } {
|
||||||
|
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<string, unknown>[] = [{
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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, unknown>): 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, unknown>): 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<string, unknown>, 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<string, unknown>, purpose: string): Record<string, unknown> {
|
||||||
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
|
||||||
|
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<string, unknown>, 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<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringAt(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
|||||||
"bun scripts/cli.ts hwlab nodes control-plane status --node D601 --lane v03",
|
"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 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 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 <secret>",
|
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name <secret>",
|
||||||
"bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03",
|
"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",
|
"bun scripts/cli.ts hwlab nodes observability performance-summary --node D601 --lane v03",
|
||||||
@@ -23,6 +24,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
|||||||
"control-plane": "YAML-first node-local CI/CD, git-mirror, public exposure, runtime-image, Argo and PipelineRun operations.",
|
"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.",
|
"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.",
|
"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.",
|
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.",
|
"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.",
|
observability: "Read runtime metrics and authenticated Web Performance summaries.",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from
|
|||||||
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
|
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
|
||||||
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
|
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
|
||||||
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
|
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
|
||||||
|
import { runHwlabFakeModelProviderCommand } from "../hwlab-fake-model-provider";
|
||||||
import type { RenderedCliResult } from "../output";
|
import type { RenderedCliResult } from "../output";
|
||||||
|
|
||||||
import { nodeRuntimeControlPlaneRun } from "./cleanup";
|
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");
|
const { runHwlabNodeHwpodPreinstallCommand } = await import("../hwlab-node-hwpod-preinstall");
|
||||||
return runHwlabNodeHwpodPreinstallCommand(args.slice(1));
|
return runHwlabNodeHwpodPreinstallCommand(args.slice(1));
|
||||||
}
|
}
|
||||||
|
if (domain === "fake-model-provider") {
|
||||||
|
return runHwlabFakeModelProviderCommand(_config, args.slice(1));
|
||||||
|
}
|
||||||
if (domain === "web-probe") {
|
if (domain === "web-probe") {
|
||||||
return legacyHwlabNodeWebProbeUnsupported(args.slice(1));
|
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));
|
return runNodeDelegatedDomain(_config, domain, args.slice(1));
|
||||||
}
|
}
|
||||||
if (domain !== "secret") {
|
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));
|
const options = parseSecretOptions(args.slice(1));
|
||||||
return runNodeSecret(options);
|
return runNodeSecret(options);
|
||||||
|
|||||||
Reference in New Issue
Block a user