diff --git a/docs/cloud-api-runtime.md b/docs/cloud-api-runtime.md new file mode 100644 index 00000000..9314b8fc --- /dev/null +++ b/docs/cloud-api-runtime.md @@ -0,0 +1,42 @@ +# Cloud API Runtime + +The L1 `hwlab-cloud-api` runtime is a minimal local HTTP/JSON-RPC service. It +does not deploy to DEV or PROD and does not claim live database persistence. + +## Local Smoke + +Run: + +```sh +node scripts/cloud-api-runtime-smoke.mjs +``` + +The smoke starts `cmd/hwlab-cloud-api` on `127.0.0.1` with an ephemeral port +and verifies: + +- `GET /health` reports `status: "degraded"` when live DB persistence is not + connected. +- The DB contract reports required env names and redacted Secret refs without + exposing secret values. +- `gateway.session.register`, `box.resource.register`, + `box.capability.report`, `hardware.invoke.shell`, `audit.event.query`, and + `evidence.record.query` round-trip through the same runtime store. +- Shell invoke requests are accepted as recorded cloud operations but report + `dispatchStatus: "not_connected"` until a real gateway shell adapter exists. +- Audit and evidence records use the protocol schema field names. + +## Persistence Boundary + +The current runtime store is process-local memory: + +- `runtime.adapter: "memory"` +- `runtime.durable: false` +- `runtime.status: "degraded"` + +This is intentional for L1. If `HWLAB_CLOUD_DB_URL` and +`HWLAB_CLOUD_DB_SSL_MODE` are absent, health reports `db.status: "blocked"`. +If both env vars are present, health reports `db.status: "degraded"` and still +keeps `db.connected: false`, `db.ready: false`, and `db.connectionChecked: +false`; env presence is not live DB evidence. + +Fixture: `fixtures/cloud-api-runtime/minimal-runtime.json`. diff --git a/docs/dev-edge-health.md b/docs/dev-edge-health.md index 119eee2e..49fa621b 100644 --- a/docs/dev-edge-health.md +++ b/docs/dev-edge-health.md @@ -36,7 +36,9 @@ Required DEV DB config names: When the Secret/env is absent, the health JSON should keep `db.connected: false`, set `db.status: "blocked"`, and list the missing env -names so gate reports can identify `cloud-api-db-health-gate`. +names so gate reports can identify `cloud-api-db-health-gate`. When the env is +present but no live DB probe is connected, `db.status` remains `"degraded"` and +`db.ready` remains `false`; env presence alone is not live DB evidence. ## Route Classification diff --git a/docs/m1-local-smoke.md b/docs/m1-local-smoke.md index c12a82c3..8b34f2e3 100644 --- a/docs/m1-local-smoke.md +++ b/docs/m1-local-smoke.md @@ -1,7 +1,7 @@ # M1 Local Contract Smoke The M1 local smoke is a fast contract check for the current HWLAB MVP -skeleton. It runs only on the local machine, uses short-lived localhost +runtime. It runs only on the local machine, uses short-lived localhost processes, and does not deploy or call the frozen DEV endpoint. Run it from the repository root: @@ -20,9 +20,10 @@ npm run m1:smoke - Starts `hwlab-cloud-api` on `127.0.0.1` with an ephemeral port and checks `/health`, `/live`, `GET /v1`, and `POST /rpc`. -- Verifies the cloud-api skeleton returns `system.health`, describes the REST - and JSON-RPC adapter surface, and rejects `hardware.operation.request` as a - known skeleton response instead of dispatching real hardware work. +- Verifies the cloud-api runtime returns degraded `system.health` when DB + persistence is not connected, describes the REST and JSON-RPC adapter + surface, registers a gateway/box/capability, and records an accepted + `hardware.operation.request` in process-local memory. - Starts `hwlab-gateway-simu`, `hwlab-box-simu`, and `hwlab-patch-panel` on ephemeral localhost ports. - Checks simulator health and status payloads, confirms cross-device @@ -45,9 +46,9 @@ This smoke is not the future DEV e2e acceptance test. It intentionally does not: - replace the L5 deploy manifest work. The check is scoped to local M1 contract readiness: existing cloud-api, -patch-panel, simulator, and CLI dry-run skeletons can be started or parsed and -still agree on the frozen service IDs, DEV environment, JSON-RPC surface, and -patch-panel-only topology assumptions. +patch-panel, simulator, and CLI dry-run runtime paths can be started or parsed +and still agree on the frozen service IDs, DEV environment, JSON-RPC surface, +DB degradation contract, and patch-panel-only topology assumptions. ## Follow-On DEV e2e diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 1f969b02..3de4fb2b 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -111,7 +111,7 @@ reason. | Milestone | Operator Gate | Required Acceptance | | --- | --- | --- | | M0 contract audit | Source-only | `docs/m0-contract-audit.md`, protocol docs, schemas, deploy schema, and M0 examples agree on service IDs, JSON-RPC envelope, errors, audit fields, topology, and DEV-only deploy shape. | -| M1 local contract smoke | Local smoke | `npm run m1:smoke` or `node scripts/m1-contract-smoke.mjs` passes on localhost and confirms skeleton services, simulator contracts, patch-panel routing, CLI dry-run boundary, and no DEV/PROD changes. | +| M1 local contract smoke | Local smoke | `npm run m1:smoke` or `node scripts/m1-contract-smoke.mjs` passes on localhost and confirms the local cloud-api runtime, simulator contracts, patch-panel routing, CLI dry-run boundary, and no DEV/PROD changes. | | M2 DEV deploy/runtime smoke | Dry-run, then authorized DEV runtime smoke | Source/manifests and `docs/dev-acceptance-matrix.md` identify the DEV route through edge, `frp`, and D601 router. Real DEV observation is read-only and must not be performed without commander authorization. | | M3 hardware trusted loop | Local smoke, then MVP e2e | `node scripts/m3-hardware-loop-smoke.mjs` proves the local `box-simu-1 DO1 -> box-simu-2 DI1` patch-panel contract. Real DEV acceptance must preserve patch-panel ownership and produce trace, audit, and evidence records. | | M4 agent automation loop | Source-only or dry-run, then MVP e2e | Agent manager, worker, skills, session lifecycle, scoped workspace, trace events, cleanup, and evidence chain are observable in fixtures or dry-run before any DEV runtime action. | diff --git a/fixtures/cloud-api-runtime/minimal-runtime.json b/fixtures/cloud-api-runtime/minimal-runtime.json new file mode 100644 index 00000000..a5f8117b --- /dev/null +++ b/fixtures/cloud-api-runtime/minimal-runtime.json @@ -0,0 +1,64 @@ +{ + "contract": "cloud-api-runtime-smoke", + "environment": "dev", + "projectId": "prj_cloud-api-runtime-smoke", + "gatewaySession": { + "gatewaySessionId": "gws_cloud-api-runtime-smoke", + "projectId": "prj_cloud-api-runtime-smoke", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gtw_cloud-api-runtime-smoke", + "endpoint": "http://127.0.0.1:7101", + "status": "connected", + "environment": "dev", + "startedAt": "2026-05-21T00:00:00.000Z", + "lastSeenAt": "2026-05-21T00:00:00.000Z", + "labels": { + "fixture": "cloud-api-runtime" + } + }, + "resource": { + "resourceId": "res_cloud-api-runtime-smoke", + "projectId": "prj_cloud-api-runtime-smoke", + "gatewaySessionId": "gws_cloud-api-runtime-smoke", + "boxId": "box_cloud-api-runtime-smoke", + "resourceType": "board", + "name": "cloud api runtime smoke board", + "state": "available", + "environment": "dev", + "metadata": { + "ports": ["shell0"] + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + "capability": { + "capabilityId": "cap_cloud-api-runtime-smoke", + "resourceId": "res_cloud-api-runtime-smoke", + "projectId": "prj_cloud-api-runtime-smoke", + "name": "shell.exec", + "description": "Record a shell invoke request through the L1 cloud API runtime.", + "direction": "bidirectional", + "valueType": "object", + "constraints": { + "dispatch": "degraded-not-connected" + }, + "mutatesState": true, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + "expected": { + "healthStatus": "degraded", + "dbStatusWhenEnvMissing": "blocked", + "runtimeAdapter": "memory", + "runtimeDurable": false, + "shellDispatchStatus": "not_connected", + "auditActions": [ + "gateway.session.register", + "box.resource.register", + "box.capability.report", + "hardware.operation.request", + "hardware.invoke.shell" + ], + "evidenceKind": "trace" + } +} diff --git a/internal/audit/index.mjs b/internal/audit/index.mjs index 55670acb..76344147 100644 --- a/internal/audit/index.mjs +++ b/internal/audit/index.mjs @@ -1,4 +1,8 @@ -import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; +import { + ENVIRONMENT_DEV, + assertProtocolRecord, + isFrozenServiceId +} from "../protocol/index.mjs"; export const CLOUD_API_SERVICE_ID = "hwlab-cloud-api"; @@ -12,6 +16,19 @@ export const AUDIT_FIELD_NAMES = Object.freeze([ "timestamp" ]); +export const PROTOCOL_AUDIT_FIELD_NAMES = Object.freeze([ + "auditId", + "traceId", + "actorType", + "actorId", + "action", + "targetType", + "targetId", + "serviceId", + "environment", + "occurredAt" +]); + export function createAuditRecord(input = {}) { const record = { requestId: asString(input.requestId, "req_unassigned"), @@ -27,6 +44,34 @@ export function createAuditRecord(input = {}) { return record; } +export function createProtocolAuditEvent(input = {}) { + const event = { + auditId: asString(input.auditId, `aud_${cryptoRandomId()}`), + traceId: asString(input.traceId, `trc_${cryptoRandomId()}`), + actorType: normalizeActorType(input.actorType), + actorId: asString(input.actorId, `system_${CLOUD_API_SERVICE_ID}`), + action: asString(input.action, "cloud_api.request"), + targetType: asString(input.targetType, "cloud_api"), + targetId: asString(input.targetId, `svc_${CLOUD_API_SERVICE_ID}`), + serviceId: isFrozenServiceId(input.serviceId) ? input.serviceId : CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV, + occurredAt: asString(input.occurredAt, new Date().toISOString()) + }; + + copyOptional(event, input, [ + "projectId", + "gatewaySessionId", + "workerSessionId", + "operationId", + "outcome", + "reason", + "metadata" + ]); + + assertProtocolRecord("auditEvent", event); + return event; +} + export function deriveActorFromMeta(meta = {}) { return { type: meta.actorType || "system", @@ -34,6 +79,13 @@ export function deriveActorFromMeta(meta = {}) { }; } +export function deriveProtocolActorFromMeta(meta = {}, fallback = {}) { + return { + actorType: normalizeActorType(fallback.actorType || meta.actorType || "service"), + actorId: asString(fallback.actorId || meta.actorId, `system_${CLOUD_API_SERVICE_ID}`) + }; +} + export function assertAuditFields(record) { if (!record || typeof record !== "object" || Array.isArray(record)) { throw new Error("audit record must be an object"); @@ -143,3 +195,23 @@ function asString(value, fallback) { } return String(value); } + +function normalizeActorType(value) { + const actorType = asString(value, "system"); + if (["user", "service", "agent", "worker", "system"].includes(actorType)) { + return actorType; + } + return "system"; +} + +function copyOptional(target, source, fields) { + for (const field of fields) { + if (source[field] !== undefined && source[field] !== null) { + target[field] = source[field]; + } + } +} + +function cryptoRandomId() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} diff --git a/internal/audit/index.test.mjs b/internal/audit/index.test.mjs index ccd8ca0d..d44f5270 100644 --- a/internal/audit/index.test.mjs +++ b/internal/audit/index.test.mjs @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { AUDIT_FIELD_NAMES, assertAuditFields, createAuditRecord } from "./index.mjs"; +import { + AUDIT_FIELD_NAMES, + PROTOCOL_AUDIT_FIELD_NAMES, + assertAuditFields, + createAuditRecord, + createProtocolAuditEvent +} from "./index.mjs"; test("createAuditRecord exposes the L1 audit field names", () => { const record = createAuditRecord({ @@ -49,3 +55,28 @@ test("assertAuditFields rejects missing audit fields", () => { /audit.timestamp is required/ ); }); + +test("createProtocolAuditEvent emits audit-event schema field names", () => { + const event = createProtocolAuditEvent({ + auditId: "aud_01J00000000000000000000000", + traceId: "trc_01J00000000000000000000000", + actorType: "user", + actorId: "usr_01J00000000000000000000000", + action: "hardware.operation.request", + targetType: "hardware_operation", + targetId: "op_01J00000000000000000000000", + projectId: "prj_01J00000000000000000000000", + gatewaySessionId: "gws_01J00000000000000000000000", + operationId: "op_01J00000000000000000000000", + outcome: "accepted", + occurredAt: "2026-05-21T00:00:00.000Z" + }); + + for (const field of PROTOCOL_AUDIT_FIELD_NAMES) { + assert.ok(Object.hasOwn(event, field), `expected auditEvent.${field}`); + } + + assert.equal(event.serviceId, "hwlab-cloud-api"); + assert.equal(event.environment, "dev"); + assert.equal(event.action, "hardware.operation.request"); +}); diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index c9802a51..20021362 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -38,17 +38,20 @@ export function buildDbHealthContract(env = process.env) { }; }); const missingEnv = fields.filter((field) => !field.present).map((field) => field.name); - const ready = missingEnv.length === 0; + const configReady = missingEnv.length === 0; + const connected = false; + const connectionChecked = false; + const status = configReady ? "degraded" : "blocked"; return { contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, environment: DEV_DB_ENV_CONTRACT.environment, - connected: false, - connectionChecked: false, - configReady: ready, - ready, - status: ready ? "ready" : "blocked", - mode: ready ? "configured_pending_live_probe" : "not_configured", + connected, + connectionChecked, + configReady, + ready: connected, + status, + mode: configReady ? "configured_without_live_connection" : "not_configured", fields, missingEnv, secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({ @@ -65,7 +68,7 @@ export function buildDbHealthContract(env = process.env) { valuesRedacted: true, liveDbEvidence: false }, - evidence: ready ? "env_presence_only" : "env_contract_blocked" + evidence: configReady ? "env_presence_only_no_live_db" : "env_contract_blocked" }; } diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs index 72d9a8ec..4622bd2d 100644 --- a/internal/cloud/json-rpc.mjs +++ b/internal/cloud/json-rpc.mjs @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { ENVIRONMENT_DEV, ERROR_CODES, + HwlabProtocolError, JSON_RPC_VERSION, validateRequest, validateResponse @@ -14,19 +15,38 @@ import { deriveActorFromMeta } from "../audit/index.mjs"; import { buildDbHealthContract } from "./db-contract.mjs"; +import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; export const SUPPORTED_RPC_METHODS = Object.freeze([ "system.health", "cloud.adapter.describe", - "hardware.operation.request" + "gateway.session.register", + "box.resource.register", + "box.capability.report", + "hardware.operation.request", + "hardware.invoke.shell", + "audit.event.write", + "audit.event.query", + "evidence.record.write", + "evidence.record.query" ]); const rpcHandlers = new Map([ ["system.health", handleSystemHealth], ["cloud.adapter.describe", handleAdapterDescribe], - ["hardware.operation.request", handleHardwareOperationRequest] + ["gateway.session.register", (params, envelope, context) => getRuntimeStore(context).registerGatewaySession(params, envelope.meta)], + ["box.resource.register", (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)], + ["box.capability.report", (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)], + ["hardware.operation.request", (params, envelope, context) => getRuntimeStore(context).requestHardwareOperation(params, envelope.meta)], + ["hardware.invoke.shell", (params, envelope, context) => getRuntimeStore(context).invokeHardwareShell(params, envelope.meta)], + ["audit.event.write", (params, envelope, context) => getRuntimeStore(context).writeAuditEvent(params, envelope.meta)], + ["audit.event.query", (params, envelope, context) => getRuntimeStore(context).queryAuditEvents(params)], + ["evidence.record.write", (params, envelope, context) => getRuntimeStore(context).writeEvidenceRecord(params, envelope.meta)], + ["evidence.record.query", (params, envelope, context) => getRuntimeStore(context).queryEvidenceRecords(params)] ]); +const defaultRuntimeStore = createCloudRuntimeStore(); + export async function handleJsonRpcRequest(envelope, context = {}) { const requestMeta = extractMeta(envelope); const requestId = envelope?.id ?? "req_unassigned"; @@ -84,6 +104,26 @@ export async function handleJsonRpcRequest(envelope, context = {}) { requestMeta: envelope.meta }); } catch (error) { + if (error instanceof HwlabProtocolError) { + return createErrorEnvelope({ + id: envelope.id, + code: error.code, + message: error.message, + data: error.data, + requestMeta: envelope.meta, + context: { + ...context, + ...error.context, + operation: envelope.method, + target: { + type: "rpc_method", + id: envelope.method + }, + result: "rejected" + } + }); + } + return createErrorEnvelope({ id: envelope.id, code: ERROR_CODES.internalError, @@ -177,50 +217,34 @@ export function createResponseMeta(requestMeta = {}) { return meta; } -function handleSystemHealth() { +function handleSystemHealth(params, envelope, context) { return { - status: "ok", + status: healthStatus(), serviceId: CLOUD_API_SERVICE_ID, environment: ENVIRONMENT_DEV, - db: buildDbHealthContract() + db: buildDbHealthContract(), + runtime: getRuntimeStore(context).summary() }; } -function handleAdapterDescribe() { +function handleAdapterDescribe(params, envelope, context) { return { serviceId: CLOUD_API_SERVICE_ID, rpcEndpoint: "POST /rpc", restEndpoint: "POST /v1/rpc/{method}", methods: SUPPORTED_RPC_METHODS, auditFields: AUDIT_FIELD_NAMES, - db: buildDbHealthContract() + db: buildDbHealthContract(), + runtime: getRuntimeStore(context).summary() }; } -function handleHardwareOperationRequest(params, envelope, context) { - const projectId = params.projectId || "project_unspecified"; +function getRuntimeStore(context = {}) { + return context.runtimeStore || defaultRuntimeStore; +} - return { - accepted: false, - status: "skeleton", - reason: "cloud core L1 does not dispatch hardware operations yet", - audit: createAuditRecord({ - requestId: envelope.id, - actor: deriveActorFromMeta(envelope.meta), - source: { - serviceId: CLOUD_API_SERVICE_ID, - environment: ENVIRONMENT_DEV, - traceId: envelope.meta.traceId, - adapter: context.adapter || "json-rpc" - }, - operation: "hardware.operation.request", - target: { - type: "project", - id: projectId - }, - result: "rejected" - }) - }; +function healthStatus() { + return "degraded"; } function extractMeta(envelope) { diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs index d705f982..22439898 100644 --- a/internal/cloud/json-rpc.test.mjs +++ b/internal/cloud/json-rpc.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { AUDIT_FIELD_NAMES } from "../audit/index.mjs"; import { ERROR_CODES, validateResponse } from "../protocol/index.mjs"; +import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; import { handleJsonRpcRequest } from "./json-rpc.mjs"; test("unknown JSON-RPC method returns an error envelope with audit fields", async () => { @@ -36,33 +37,6 @@ test("unknown JSON-RPC method returns an error envelope with audit fields", asyn assert.equal(audit.result, "rejected"); }); -test("hardware.operation.request is a non-dispatching skeleton with visible audit fields", async () => { - const response = await handleJsonRpcRequest({ - jsonrpc: "2.0", - id: "req_01J00000000000000000000001", - method: "hardware.operation.request", - params: { - projectId: "prj_01J00000000000000000000000" - }, - meta: { - traceId: "trc_01J00000000000000000000001", - actorId: "usr_01J00000000000000000000000", - serviceId: "hwlab-cloud-web", - environment: "dev" - } - }); - - validateResponse(response); - assert.equal(response.result.accepted, false); - assert.equal(response.result.status, "skeleton"); - - for (const field of AUDIT_FIELD_NAMES) { - assert.ok(Object.hasOwn(response.result.audit, field), `expected audit.${field}`); - } - assert.equal(response.result.audit.operation, "hardware.operation.request"); - assert.equal(response.result.audit.target.id, "prj_01J00000000000000000000000"); -}); - test("system.health includes the redacted DEV DB env contract", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; @@ -83,8 +57,10 @@ test("system.health includes the redacted DEV DB env contract", async () => { }); validateResponse(response); + assert.equal(response.result.status, "degraded"); assert.equal(response.result.db.status, "blocked"); assert.equal(response.result.db.connected, false); + assert.equal(response.result.runtime.durable, false); assert.ok(response.result.db.missingEnv.includes("HWLAB_CLOUD_DB_URL")); assert.equal(response.result.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); assert.equal(response.result.db.secretRefs[0].secretKey, "database-url"); @@ -102,3 +78,122 @@ test("system.health includes the redacted DEV DB env contract", async () => { } } }); + +test("cloud-api runtime accepts register/report/invoke and exposes audit/evidence records", async () => { + const runtimeStore = createCloudRuntimeStore({ + now: () => "2026-05-21T00:00:00.000Z" + }); + const context = { runtimeStore }; + const meta = { + traceId: "trc_01J00000000000000000000010", + actorId: "usr_01J00000000000000000000010", + serviceId: "hwlab-cloud-web", + environment: "dev" + }; + + async function rpc(id, method, params = {}) { + const response = await handleJsonRpcRequest( + { + jsonrpc: "2.0", + id, + method, + params, + meta + }, + context + ); + validateResponse(response); + assert.equal(Object.hasOwn(response, "error"), false, response.error?.message); + return response.result; + } + + const gateway = await rpc("req_01J00000000000000000000010", "gateway.session.register", { + projectId: "prj_01J00000000000000000000010", + gatewaySessionId: "gws_01J00000000000000000000010", + gatewayId: "gtw_01J00000000000000000000010", + serviceId: "hwlab-gateway-simu", + endpoint: "http://127.0.0.1:7101" + }); + assert.equal(gateway.registered, true); + assert.equal(gateway.gatewaySession.status, "connected"); + assert.equal(gateway.auditEvent.action, "gateway.session.register"); + + const resource = await rpc("req_01J00000000000000000000011", "box.resource.register", { + projectId: "prj_01J00000000000000000000010", + gatewaySessionId: "gws_01J00000000000000000000010", + resourceId: "res_01J00000000000000000000010", + boxId: "box_01J00000000000000000000010", + resourceType: "board", + state: "available" + }); + assert.equal(resource.registered, true); + assert.equal(resource.resource.environment, "dev"); + + const capability = await rpc("req_01J00000000000000000000012", "box.capability.report", { + capabilityId: "cap_01J00000000000000000000010", + resourceId: "res_01J00000000000000000000010", + projectId: "prj_01J00000000000000000000010", + name: "shell.exec", + direction: "bidirectional", + valueType: "object", + mutatesState: true + }); + assert.equal(capability.reported, true); + assert.equal(capability.capabilities[0].name, "shell.exec"); + + const invoke = await rpc("req_01J00000000000000000000013", "hardware.invoke.shell", { + projectId: "prj_01J00000000000000000000010", + gatewaySessionId: "gws_01J00000000000000000000010", + resourceId: "res_01J00000000000000000000010", + capabilityId: "cap_01J00000000000000000000010", + input: { + command: "echo hwlab" + } + }); + assert.equal(invoke.accepted, true); + assert.equal(invoke.operation.status, "accepted"); + assert.equal(invoke.dispatch.shellExecuted, false); + assert.equal(invoke.dispatch.dispatchStatus, "not_connected"); + assert.equal(invoke.auditEvent.action, "hardware.invoke.shell"); + assert.equal(invoke.evidenceRecord.kind, "trace"); + assert.match(invoke.evidenceRecord.sha256, /^[a-f0-9]{64}$/); + + const audit = await rpc("req_01J00000000000000000000014", "audit.event.query", { + projectId: "prj_01J00000000000000000000010" + }); + assert.equal(audit.count, 5); + assert.ok(audit.events.some((event) => event.action === "hardware.operation.request")); + assert.ok(audit.events.some((event) => event.action === "hardware.invoke.shell")); + + const evidence = await rpc("req_01J00000000000000000000015", "evidence.record.query", { + projectId: "prj_01J00000000000000000000010" + }); + assert.equal(evidence.count, 1); + assert.equal(evidence.records[0].operationId, invoke.operationId); + assert.equal(evidence.records[0].serviceId, "hwlab-cloud-api"); +}); + +test("hardware.operation.request rejects missing registration instead of faking dispatch", async () => { + const response = await handleJsonRpcRequest({ + jsonrpc: "2.0", + id: "req_01J00000000000000000000020", + method: "hardware.operation.request", + params: { + projectId: "prj_01J00000000000000000000020", + gatewaySessionId: "gws_01J00000000000000000000020", + resourceId: "res_01J00000000000000000000020", + capabilityId: "cap_01J00000000000000000000020" + }, + meta: { + traceId: "trc_01J00000000000000000000020", + actorId: "usr_01J00000000000000000000020", + serviceId: "hwlab-cloud-web", + environment: "dev" + } + }); + + validateResponse(response); + assert.equal(response.error.code, ERROR_CODES.sessionNotFound); + assert.equal(response.error.data.audit.operation, "hardware.operation.request"); + assert.equal(response.error.data.audit.result, "rejected"); +}); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 58a6f0de..a76cac06 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -13,13 +13,15 @@ import { handleJsonRpcRequest } from "./json-rpc.mjs"; import { buildDbHealthContract } from "./db-contract.mjs"; +import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; export function createCloudApiServer(options = {}) { + const runtimeStore = options.runtimeStore || createCloudRuntimeStore(); return createServer(async (request, response) => { try { - await routeRequest(request, response, options); + await routeRequest(request, response, { ...options, runtimeStore }); } catch (error) { sendJson(response, 500, { error: { @@ -32,7 +34,7 @@ export function createCloudApiServer(options = {}) { }); } -export function buildHealthPayload() { +export function buildHealthPayload(options = {}) { const serviceId = CLOUD_API_SERVICE_ID; const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; const imageReference = process.env.HWLAB_IMAGE || "unknown"; @@ -42,7 +44,7 @@ export function buildHealthPayload() { return { serviceId, environment: ENVIRONMENT_DEV, - status: "ok", + status: db.status === "blocked" ? "degraded" : "degraded", service: { id: serviceId, role: "cloud-api", @@ -60,7 +62,8 @@ export function buildHealthPayload() { }, endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, observedAt: new Date().toISOString(), - db + db, + runtime: options.runtimeStore?.summary?.() ?? createCloudRuntimeStore().summary() }; } @@ -68,7 +71,7 @@ async function routeRequest(request, response, options) { const url = new URL(request.url || "/", "http://hwlab-cloud-api.local"); if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) { - sendJson(response, 200, buildHealthPayload()); + sendJson(response, 200, buildHealthPayload(options)); return; } @@ -90,7 +93,7 @@ async function routeRequest(request, response, options) { return; } - sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api skeleton", { + sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api runtime", { operation: `${request.method || "GET"} ${url.pathname}`, target: { type: "route", @@ -130,7 +133,8 @@ async function handleRpcHttpRequest(request, response, options) { } const rpcResponse = await handleJsonRpcRequest(envelope, { - adapter: "json-rpc" + adapter: "json-rpc", + runtimeStore: options.runtimeStore }); sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } @@ -140,17 +144,18 @@ async function handleRestAdapter(request, response, url, options) { sendJson(response, 200, { serviceId: CLOUD_API_SERVICE_ID, adapter: "rest", - status: "skeleton", + status: "degraded", rpcBridge: "POST /v1/rpc/{method}", methods: SUPPORTED_RPC_METHODS, auditFields: AUDIT_FIELD_NAMES, - db: buildDbHealthContract() + db: buildDbHealthContract(), + runtime: options.runtimeStore.summary() }); return; } if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) { - sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 skeleton", { + sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", { operation: `${request.method || "GET"} ${url.pathname}`, target: { type: "route", @@ -205,7 +210,8 @@ async function handleRestAdapter(request, response, url, options) { } }, { - adapter: "rest" + adapter: "rest", + runtimeStore: options.runtimeStore } ); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 1a9d3140..c7dadcd4 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -20,11 +20,14 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { const healthPayload = await health.json(); assert.equal(healthPayload.serviceId, "hwlab-cloud-api"); assert.equal(healthPayload.environment, "dev"); + assert.equal(healthPayload.status, "degraded"); assert.equal(healthPayload.commit.id.length > 0, true); assert.equal(healthPayload.image.reference.length > 0, true); assert.equal(healthPayload.service.id, "hwlab-cloud-api"); assert.equal(healthPayload.db.status, "blocked"); assert.equal(healthPayload.db.connected, false); + assert.equal(healthPayload.runtime.durable, false); + assert.equal(healthPayload.runtime.status, "degraded"); assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); assert.equal(healthPayload.db.secretRefs[0].redacted, true); @@ -33,13 +36,13 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLive.status, 200); const healthLivePayload = await healthLive.json(); assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api"); - assert.equal(healthLivePayload.status, "ok"); + assert.equal(healthLivePayload.status, "degraded"); assert.equal(healthLivePayload.commit.id.length > 0, true); assert.equal(healthLivePayload.db.ready, false); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(readiness.status, 200); - assert.equal((await readiness.json()).status, "ok"); + assert.equal((await readiness.json()).status, "degraded"); const live = await fetch(`http://127.0.0.1:${port}/live`); assert.equal(live.status, 200); @@ -74,11 +77,13 @@ test("cloud api health reports DB env presence without leaking values", async () const { port } = server.address(); const response = await fetch(`http://127.0.0.1:${port}/health`); const payload = await response.json(); - assert.equal(payload.db.status, "ready"); + assert.equal(payload.status, "degraded"); + assert.equal(payload.db.status, "degraded"); assert.equal(payload.db.connected, false); assert.equal(payload.db.connectionChecked, false); assert.equal(payload.db.configReady, true); - assert.equal(payload.db.evidence, "env_presence_only"); + assert.equal(payload.db.ready, false); + assert.equal(payload.db.evidence, "env_presence_only_no_live_db"); assert.deepEqual(payload.db.missingEnv, []); assert.equal(JSON.stringify(payload.db).includes("password"), false); assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false); diff --git a/internal/db/runtime-store.mjs b/internal/db/runtime-store.mjs new file mode 100644 index 00000000..b035a700 --- /dev/null +++ b/internal/db/runtime-store.mjs @@ -0,0 +1,623 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { + ENVIRONMENT_DEV, + ERROR_CODES, + HwlabProtocolError, + assertProtocolRecord +} from "../protocol/index.mjs"; +import { + CLOUD_API_SERVICE_ID, + createProtocolAuditEvent, + deriveProtocolActorFromMeta +} from "../audit/index.mjs"; + +export const RUNTIME_STORE_KIND = "memory"; + +export function createCloudRuntimeStore(options = {}) { + return new CloudRuntimeStore(options); +} + +export class CloudRuntimeStore { + constructor({ now = () => new Date().toISOString() } = {}) { + this.now = now; + this.gatewaySessions = new Map(); + this.boxResources = new Map(); + this.boxCapabilities = new Map(); + this.hardwareOperations = new Map(); + this.auditEvents = new Map(); + this.evidenceRecords = new Map(); + } + + summary() { + return { + adapter: RUNTIME_STORE_KIND, + durable: false, + status: "degraded", + reason: "live DB persistence is not connected; L1 runtime writes are process-local only", + counts: { + gatewaySessions: this.gatewaySessions.size, + boxResources: this.boxResources.size, + boxCapabilities: this.boxCapabilities.size, + hardwareOperations: this.hardwareOperations.size, + auditEvents: this.auditEvents.size, + evidenceRecords: this.evidenceRecords.size + } + }; + } + + registerGatewaySession(params = {}, requestMeta = {}) { + const now = this.now(); + const input = asObject(params.gatewaySession ?? params, "gatewaySession"); + const gatewaySession = pruneUndefined({ + gatewaySessionId: input.gatewaySessionId || makeId("gws"), + projectId: requireProtocolId(input, "projectId"), + serviceId: input.serviceId || "hwlab-gateway-simu", + gatewayId: input.gatewayId || makeId("gtw"), + endpoint: input.endpoint, + status: input.status || "connected", + environment: ENVIRONMENT_DEV, + startedAt: input.startedAt || now, + lastSeenAt: input.lastSeenAt || now, + stoppedAt: input.stoppedAt, + labels: normalizeJsonObject(input.labels) + }); + + assertProtocolRecord("gatewaySession", gatewaySession); + this.gatewaySessions.set(gatewaySession.gatewaySessionId, gatewaySession); + + const auditEvent = this.recordAuditEvent({ + requestMeta, + params, + action: "gateway.session.register", + targetType: "gateway_session", + targetId: gatewaySession.gatewaySessionId, + projectId: gatewaySession.projectId, + gatewaySessionId: gatewaySession.gatewaySessionId, + outcome: "accepted", + metadata: { + gatewayId: gatewaySession.gatewayId, + serviceId: gatewaySession.serviceId + } + }); + + return { + registered: true, + gatewaySession, + auditEvent, + persistence: this.summary() + }; + } + + registerBoxResource(params = {}, requestMeta = {}) { + const now = this.now(); + const input = asObject(params.resource ?? params.boxResource ?? params, "boxResource"); + const gatewaySessionId = requireProtocolId(input, "gatewaySessionId"); + const gatewaySession = this.requireGatewaySession(gatewaySessionId); + const projectId = input.projectId || gatewaySession.projectId; + const resource = pruneUndefined({ + resourceId: input.resourceId || makeId("res"), + projectId, + gatewaySessionId, + boxId: input.boxId || makeId("box"), + resourceType: input.resourceType || "simulator_endpoint", + name: input.name, + state: input.state || "available", + environment: ENVIRONMENT_DEV, + metadata: normalizeJsonObject(input.metadata), + createdAt: input.createdAt || now, + updatedAt: input.updatedAt || now + }); + + assertProtocolRecord("boxResource", resource); + this.boxResources.set(resource.resourceId, resource); + + const auditEvent = this.recordAuditEvent({ + requestMeta, + params, + action: "box.resource.register", + targetType: "box_resource", + targetId: resource.resourceId, + projectId: resource.projectId, + gatewaySessionId: resource.gatewaySessionId, + outcome: "accepted", + metadata: { + boxId: resource.boxId, + resourceType: resource.resourceType + } + }); + + return { + registered: true, + resource, + auditEvent, + persistence: this.summary() + }; + } + + reportBoxCapabilities(params = {}, requestMeta = {}) { + const now = this.now(); + const inputs = normalizeCapabilityInputs(params); + const capabilities = []; + + for (const input of inputs) { + const resourceId = requireProtocolId(input, "resourceId"); + const resource = this.requireBoxResource(resourceId); + const capability = pruneUndefined({ + capabilityId: input.capabilityId || makeId("cap"), + resourceId, + projectId: input.projectId || resource.projectId, + name: requireString(input, "name"), + description: input.description, + direction: input.direction || "bidirectional", + valueType: input.valueType || "object", + unit: input.unit, + constraints: normalizeJsonObject(input.constraints), + mutatesState: input.mutatesState ?? true, + createdAt: input.createdAt || now, + updatedAt: input.updatedAt || now + }); + + assertProtocolRecord("boxCapability", capability); + this.boxCapabilities.set(capability.capabilityId, capability); + capabilities.push(capability); + } + + const first = capabilities[0]; + const auditEvent = this.recordAuditEvent({ + requestMeta, + params, + action: "box.capability.report", + targetType: "box_capability", + targetId: first.capabilityId, + projectId: first.projectId, + gatewaySessionId: this.boxResources.get(first.resourceId)?.gatewaySessionId, + outcome: "accepted", + metadata: { + capabilityIds: capabilities.map((capability) => capability.capabilityId) + } + }); + + return { + reported: true, + capabilities, + auditEvent, + persistence: this.summary() + }; + } + + requestHardwareOperation(params = {}, requestMeta = {}) { + const refs = this.requireOperationRefs(params, requestMeta); + const now = this.now(); + const operation = pruneUndefined({ + operationId: params.operationId || makeId("op"), + projectId: refs.projectId, + gatewaySessionId: refs.gatewaySession.gatewaySessionId, + agentSessionId: params.agentSessionId, + workerSessionId: params.workerSessionId, + resourceId: refs.resource.resourceId, + capabilityId: refs.capability.capabilityId, + requestedBy: refs.actor.actorId, + input: normalizeJsonObject(params.input), + status: "accepted", + environment: ENVIRONMENT_DEV, + requestedAt: params.requestedAt || now, + updatedAt: now + }); + + assertProtocolRecord("hardwareOperation", operation); + this.hardwareOperations.set(operation.operationId, operation); + + const auditEvent = this.recordAuditEvent({ + requestMeta, + params, + action: "hardware.operation.request", + targetType: "hardware_operation", + targetId: operation.operationId, + projectId: operation.projectId, + gatewaySessionId: operation.gatewaySessionId, + workerSessionId: operation.workerSessionId, + operationId: operation.operationId, + outcome: "accepted", + metadata: { + resourceId: operation.resourceId, + capabilityId: operation.capabilityId + } + }); + + return { + accepted: true, + status: operation.status, + operationId: operation.operationId, + operation, + auditEvent, + persistence: this.summary() + }; + } + + invokeHardwareShell(params = {}, requestMeta = {}) { + const shellInput = normalizeShellInput(params); + const operationResult = this.requestHardwareOperation( + { + ...params, + input: { + ...normalizeJsonObject(params.input), + shell: shellInput + } + }, + requestMeta + ); + const operation = { + ...operationResult.operation, + output: { + shellExecuted: false, + dispatchStatus: "not_connected", + message: "cloud-api recorded the shell invoke request; no gateway shell adapter is connected in L1" + }, + updatedAt: this.now() + }; + assertProtocolRecord("hardwareOperation", operation); + this.hardwareOperations.set(operation.operationId, operation); + + const evidenceRecord = this.writeEvidenceRecord( + { + projectId: operation.projectId, + operationId: operation.operationId, + kind: "trace", + mimeType: "application/json", + serviceId: CLOUD_API_SERVICE_ID, + metadata: { + traceId: requestMeta.traceId, + gatewaySessionId: operation.gatewaySessionId, + resourceId: operation.resourceId, + capabilityId: operation.capabilityId, + shellExecuted: false, + dispatchStatus: "not_connected" + }, + content: { + operationId: operation.operationId, + shell: shellInput, + dispatchStatus: "not_connected" + } + }, + requestMeta + ).evidenceRecord; + + const auditEvent = this.recordAuditEvent({ + requestMeta, + params, + action: "hardware.invoke.shell", + targetType: "hardware_operation", + targetId: operation.operationId, + projectId: operation.projectId, + gatewaySessionId: operation.gatewaySessionId, + workerSessionId: operation.workerSessionId, + operationId: operation.operationId, + outcome: "accepted", + metadata: { + evidenceId: evidenceRecord.evidenceId, + shellExecuted: false, + dispatchStatus: "not_connected" + } + }); + + return { + accepted: true, + status: operation.status, + operationId: operation.operationId, + operation, + auditEvent, + evidenceRecord, + dispatch: operation.output, + persistence: this.summary() + }; + } + + writeAuditEvent(params = {}, requestMeta = {}) { + const eventInput = params.event ?? params.auditEvent ?? params; + const auditEvent = eventInput.auditId + ? pruneUndefined({ + ...eventInput, + environment: ENVIRONMENT_DEV + }) + : this.buildAuditEventFromInput(eventInput, requestMeta); + + assertProtocolRecord("auditEvent", auditEvent); + this.auditEvents.set(auditEvent.auditId, auditEvent); + + return { + written: true, + auditEvent, + persistence: this.summary() + }; + } + + queryAuditEvents(params = {}) { + const events = [...this.auditEvents.values()].filter((event) => matchesQuery(event, params, [ + "auditId", + "traceId", + "projectId", + "gatewaySessionId", + "operationId", + "action", + "targetId" + ])); + + return { + events: limitResults(events, params.limit), + count: events.length, + persistence: this.summary() + }; + } + + writeEvidenceRecord(params = {}, requestMeta = {}) { + const now = this.now(); + const input = asObject(params.record ?? params.evidenceRecord ?? params, "evidenceRecord"); + const content = input.content ?? input.metadata ?? {}; + const sha256 = input.sha256 || sha256Hex(content); + const evidenceId = input.evidenceId || makeId("evd"); + const evidenceRecord = pruneUndefined({ + evidenceId, + projectId: requireProtocolId(input, "projectId"), + operationId: requireProtocolId(input, "operationId"), + agentSessionId: input.agentSessionId, + workerSessionId: input.workerSessionId, + kind: input.kind || "trace", + uri: input.uri || `memory://hwlab/evidence/${evidenceId}`, + mimeType: input.mimeType, + sha256, + sizeBytes: input.sizeBytes ?? Buffer.byteLength(stableJson(content), "utf8"), + serviceId: input.serviceId || CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV, + metadata: { + ...normalizeJsonObject(input.metadata), + ...(requestMeta.traceId ? { traceId: requestMeta.traceId } : {}) + }, + createdAt: input.createdAt || now + }); + + assertProtocolRecord("evidenceRecord", evidenceRecord); + this.evidenceRecords.set(evidenceRecord.evidenceId, evidenceRecord); + + return { + written: true, + evidenceRecord, + persistence: this.summary() + }; + } + + queryEvidenceRecords(params = {}) { + const records = [...this.evidenceRecords.values()].filter((record) => matchesQuery(record, params, [ + "evidenceId", + "projectId", + "operationId", + "agentSessionId", + "workerSessionId", + "kind", + "serviceId" + ])); + + return { + records: limitResults(records, params.limit), + count: records.length, + persistence: this.summary() + }; + } + + recordAuditEvent(input) { + return this.writeAuditEvent(this.buildAuditEventFromInput(input, input.requestMeta), input.requestMeta).auditEvent; + } + + buildAuditEventFromInput(input = {}, requestMeta = {}) { + const actor = deriveProtocolActorFromMeta(requestMeta, input.params?.audit ?? input); + return createProtocolAuditEvent({ + auditId: input.auditId, + traceId: requestMeta.traceId || input.traceId, + actorType: actor.actorType, + actorId: actor.actorId, + action: input.action, + targetType: input.targetType, + targetId: input.targetId, + projectId: input.projectId, + gatewaySessionId: input.gatewaySessionId, + workerSessionId: input.workerSessionId, + operationId: input.operationId, + serviceId: CLOUD_API_SERVICE_ID, + outcome: input.outcome, + reason: input.reason || input.params?.audit?.reason, + metadata: input.metadata, + occurredAt: input.occurredAt + }); + } + + requireOperationRefs(params, requestMeta = {}) { + const projectId = requireProtocolId(params, "projectId"); + const gatewaySession = this.requireGatewaySession(requireProtocolId(params, "gatewaySessionId")); + const resource = this.requireBoxResource(requireProtocolId(params, "resourceId")); + const capability = this.requireBoxCapability(requireProtocolId(params, "capabilityId")); + + if (gatewaySession.projectId !== projectId || resource.projectId !== projectId || capability.projectId !== projectId) { + throw new HwlabProtocolError("hardware operation references must belong to one project", { + code: ERROR_CODES.invalidParams, + data: { + projectId, + gatewaySessionId: gatewaySession.gatewaySessionId, + resourceId: resource.resourceId, + capabilityId: capability.capabilityId + } + }); + } + if (resource.gatewaySessionId !== gatewaySession.gatewaySessionId) { + throw new HwlabProtocolError("resource is not registered under the requested gateway session", { + code: ERROR_CODES.resourceLocked, + data: { + gatewaySessionId: gatewaySession.gatewaySessionId, + resourceGatewaySessionId: resource.gatewaySessionId, + resourceId: resource.resourceId + } + }); + } + if (capability.resourceId !== resource.resourceId) { + throw new HwlabProtocolError("capability is not registered on the requested resource", { + code: ERROR_CODES.capabilityUnavailable, + data: { + resourceId: resource.resourceId, + capabilityId: capability.capabilityId + } + }); + } + + return { + projectId, + gatewaySession, + resource, + capability, + actor: deriveProtocolActorFromMeta(requestMeta, params.audit ?? {}) + }; + } + + requireGatewaySession(gatewaySessionId) { + const gatewaySession = this.gatewaySessions.get(gatewaySessionId); + if (!gatewaySession) { + throw new HwlabProtocolError(`gateway session ${gatewaySessionId} is not registered`, { + code: ERROR_CODES.sessionNotFound, + data: { gatewaySessionId } + }); + } + return gatewaySession; + } + + requireBoxResource(resourceId) { + const resource = this.boxResources.get(resourceId); + if (!resource) { + throw new HwlabProtocolError(`box resource ${resourceId} is not registered`, { + code: ERROR_CODES.capabilityUnavailable, + data: { resourceId } + }); + } + return resource; + } + + requireBoxCapability(capabilityId) { + const capability = this.boxCapabilities.get(capabilityId); + if (!capability) { + throw new HwlabProtocolError(`capability ${capabilityId} is not reported`, { + code: ERROR_CODES.capabilityUnavailable, + data: { capabilityId } + }); + } + return capability; + } +} + +function normalizeCapabilityInputs(params) { + const value = params.capabilities ?? params.capability ?? params; + if (Array.isArray(value)) { + if (value.length === 0) { + throw new HwlabProtocolError("capabilities must not be empty", { + code: ERROR_CODES.invalidParams + }); + } + return value.map((item) => asObject(item, "capability")); + } + return [asObject(value, "capability")]; +} + +function normalizeShellInput(params) { + const input = normalizeJsonObject(params.input); + const command = params.command ?? input.command; + if (command === undefined || command === null || command === "") { + throw new HwlabProtocolError("hardware.invoke.shell requires input.command or command", { + code: ERROR_CODES.invalidParams, + data: { + required: ["projectId", "gatewaySessionId", "resourceId", "capabilityId", "input.command"] + } + }); + } + if (Array.isArray(command)) { + return { + command: command.map((item) => String(item)), + cwd: input.cwd, + timeoutMs: input.timeoutMs + }; + } + return { + command: String(command), + cwd: input.cwd, + timeoutMs: input.timeoutMs + }; +} + +function asObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new HwlabProtocolError(`${label} must be a JSON object`, { + code: ERROR_CODES.invalidParams + }); + } + return value; +} + +function requireString(input, field) { + if (typeof input[field] !== "string" || input[field].trim() === "") { + throw new HwlabProtocolError(`${field} is required`, { + code: ERROR_CODES.invalidParams, + data: { field } + }); + } + return input[field]; +} + +function requireProtocolId(input, field) { + const value = requireString(input, field); + return value; +} + +function normalizeJsonObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return { ...value }; +} + +function pruneUndefined(input) { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); +} + +function makeId(prefix) { + return `${prefix}_${randomUUID()}`; +} + +function stableJson(value) { + return JSON.stringify(sortJson(value)); +} + +function sha256Hex(value) { + return createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function sortJson(value) { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); + } + return value; +} + +function matchesQuery(record, params, fields) { + for (const field of fields) { + if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) { + return false; + } + } + return true; +} + +function limitResults(records, limit) { + const parsed = Number.parseInt(limit ?? "", 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return records; + } + return records.slice(0, parsed); +} diff --git a/internal/db/schema.test.mjs b/internal/db/schema.test.mjs index 3b5306b5..68a540e4 100644 --- a/internal/db/schema.test.mjs +++ b/internal/db/schema.test.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { TABLES } from "../protocol/index.mjs"; +import { TABLES, assertProtocolRecord, assertProtocolRecords } from "../protocol/index.mjs"; import { CLOUD_CORE_MIGRATIONS, FROZEN_CLOUD_CORE_TABLES, @@ -34,3 +34,48 @@ test("initial migration skeleton declares every frozen table", async () => { assert.match(sql, /\bresult\b/); assert.match(sql, /\btimestamp\b/); }); + +test("protocol record guards catch schema drift before runtime writes", () => { + assertProtocolRecord("gatewaySession", { + gatewaySessionId: "gws_01J00000000000000000000000", + projectId: "prj_01J00000000000000000000000", + serviceId: "hwlab-gateway-simu", + gatewayId: "gtw_01J00000000000000000000000", + endpoint: "http://127.0.0.1:7101", + status: "connected", + environment: "dev", + startedAt: "2026-05-21T00:00:00.000Z", + lastSeenAt: "2026-05-21T00:00:00.000Z" + }); + + assertProtocolRecords("boxCapability", [ + { + capabilityId: "cap_01J00000000000000000000000", + resourceId: "res_01J00000000000000000000000", + projectId: "prj_01J00000000000000000000000", + name: "shell.exec", + direction: "bidirectional", + valueType: "object", + mutatesState: true, + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:00:00.000Z" + } + ]); + + assert.throws( + () => + assertProtocolRecord("evidenceRecord", { + evidenceId: "evd_01J00000000000000000000000", + projectId: "prj_01J00000000000000000000000", + operationId: "op_01J00000000000000000000000", + kind: "trace", + uri: "memory://hwlab/evidence/evd_01J00000000000000000000000", + sha256: "0".repeat(64), + serviceId: "hwlab-cloud-api", + environment: "dev", + createdAt: "2026-05-21T00:00:00.000Z", + evidenceType: "trace" + }), + /evidenceRecord.evidenceType is not part of the frozen protocol schema/ + ); +}); diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index 77b9ec1d..4ee09720 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -50,12 +50,264 @@ export const ERROR_CODES = Object.freeze({ auditRequired: -32040 }); +function freezeRecordShape({ required, allowed }) { + return Object.freeze({ + required: Object.freeze([...required]), + allowed: Object.freeze([...allowed]) + }); +} + +export const PROTOCOL_RECORD_SHAPES = Object.freeze({ + gatewaySession: freezeRecordShape({ + required: [ + "gatewaySessionId", + "projectId", + "serviceId", + "gatewayId", + "status", + "environment", + "startedAt" + ], + allowed: [ + "gatewaySessionId", + "projectId", + "serviceId", + "gatewayId", + "endpoint", + "status", + "environment", + "startedAt", + "lastSeenAt", + "stoppedAt", + "labels" + ] + }), + boxResource: freezeRecordShape({ + required: [ + "resourceId", + "projectId", + "gatewaySessionId", + "boxId", + "resourceType", + "state", + "environment", + "createdAt", + "updatedAt" + ], + allowed: [ + "resourceId", + "projectId", + "gatewaySessionId", + "boxId", + "resourceType", + "name", + "state", + "environment", + "metadata", + "createdAt", + "updatedAt" + ] + }), + boxCapability: freezeRecordShape({ + required: [ + "capabilityId", + "resourceId", + "projectId", + "name", + "direction", + "valueType", + "mutatesState", + "createdAt", + "updatedAt" + ], + allowed: [ + "capabilityId", + "resourceId", + "projectId", + "name", + "description", + "direction", + "valueType", + "unit", + "constraints", + "mutatesState", + "createdAt", + "updatedAt" + ] + }), + hardwareOperation: freezeRecordShape({ + required: [ + "operationId", + "projectId", + "gatewaySessionId", + "resourceId", + "capabilityId", + "requestedBy", + "status", + "environment", + "requestedAt", + "updatedAt" + ], + allowed: [ + "operationId", + "projectId", + "gatewaySessionId", + "agentSessionId", + "workerSessionId", + "resourceId", + "capabilityId", + "requestedBy", + "input", + "output", + "status", + "environment", + "requestedAt", + "startedAt", + "completedAt", + "updatedAt", + "error" + ] + }), + auditEvent: freezeRecordShape({ + required: [ + "auditId", + "traceId", + "actorType", + "actorId", + "action", + "targetType", + "targetId", + "serviceId", + "environment", + "occurredAt" + ], + allowed: [ + "auditId", + "traceId", + "actorType", + "actorId", + "action", + "targetType", + "targetId", + "projectId", + "gatewaySessionId", + "workerSessionId", + "operationId", + "serviceId", + "environment", + "outcome", + "reason", + "metadata", + "occurredAt" + ] + }), + evidenceRecord: freezeRecordShape({ + required: [ + "evidenceId", + "projectId", + "operationId", + "kind", + "uri", + "sha256", + "serviceId", + "environment", + "createdAt" + ], + allowed: [ + "evidenceId", + "projectId", + "operationId", + "agentSessionId", + "workerSessionId", + "kind", + "uri", + "mimeType", + "sha256", + "sizeBytes", + "serviceId", + "environment", + "metadata", + "createdAt" + ] + }) +}); + +export class HwlabProtocolError extends Error { + constructor(message, { code = ERROR_CODES.hwlabUnknown, data = {}, context = {} } = {}) { + super(message); + this.name = "HwlabProtocolError"; + this.code = code; + this.data = data; + this.context = context; + } +} + const methodPattern = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/; +const protocolIdPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/; +const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/; +const timestampFieldNames = new Set([ + "startedAt", + "lastSeenAt", + "stoppedAt", + "createdAt", + "updatedAt", + "requestedAt", + "completedAt", + "occurredAt" +]); +const idFieldNames = new Set([ + "gatewaySessionId", + "projectId", + "gatewayId", + "resourceId", + "boxId", + "capabilityId", + "operationId", + "agentSessionId", + "workerSessionId", + "requestedBy", + "auditId", + "traceId", + "actorId", + "targetId", + "evidenceId" +]); +const actorTypes = new Set(["user", "service", "agent", "worker", "system"]); +const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]); +const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]); +const gatewayStatuses = new Set(["starting", "connected", "degraded", "disconnected", "stopped"]); +const resourceStates = new Set(["available", "reserved", "running", "maintenance", "offline"]); +const operationStatuses = new Set([ + "requested", + "accepted", + "queued", + "running", + "succeeded", + "failed", + "rejected", + "canceled", + "timed_out" +]); +const resourceTypes = new Set([ + "board", + "power_supply", + "serial_port", + "network_port", + "relay", + "sensor", + "simulator_endpoint" +]); +const capabilityDirections = new Set(["input", "output", "bidirectional"]); +const capabilityValueTypes = new Set(["boolean", "integer", "number", "string", "object", "binary"]); export function isFrozenServiceId(serviceId) { return SERVICE_IDS.includes(serviceId); } +export function isProtocolId(value) { + return typeof value === "string" && protocolIdPattern.test(value); +} + export function validateMeta(meta) { if (!meta || typeof meta !== "object" || Array.isArray(meta)) { throw new Error("meta must be an object"); @@ -149,3 +401,96 @@ export function createRpcErrorBody({ code = ERROR_CODES.hwlabUnknown, message = error }; } + +export function assertProtocolRecord(kind, record) { + const shape = PROTOCOL_RECORD_SHAPES[kind]; + if (!shape) { + throw new Error(`unknown protocol record kind ${JSON.stringify(kind)}`); + } + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error(`${kind} must be an object`); + } + + for (const field of shape.required) { + if (!Object.hasOwn(record, field)) { + throw new Error(`${kind}.${field} is required`); + } + } + + const allowed = new Set(shape.allowed); + for (const [field, value] of Object.entries(record)) { + if (!allowed.has(field)) { + throw new Error(`${kind}.${field} is not part of the frozen protocol schema`); + } + assertProtocolField(kind, field, value); + } +} + +export function assertProtocolRecords(kind, records) { + if (!Array.isArray(records)) { + throw new Error(`${kind} records must be an array`); + } + for (const record of records) { + assertProtocolRecord(kind, record); + } +} + +function assertProtocolField(kind, field, value) { + if (idFieldNames.has(field) && !isProtocolId(value)) { + throw new Error(`${kind}.${field} must be a protocol id`); + } + if (timestampFieldNames.has(field) && Number.isNaN(Date.parse(value))) { + throw new Error(`${kind}.${field} must be an RFC 3339 timestamp`); + } + if (field === "environment" && value !== ENVIRONMENT_DEV) { + throw new Error(`${kind}.environment must be ${ENVIRONMENT_DEV}`); + } + if (field === "serviceId" && !isFrozenServiceId(value)) { + throw new Error(`${kind}.serviceId is not a frozen service id`); + } + if ((field === "action" || field === "name") && !actionPattern.test(value)) { + throw new Error(`${kind}.${field} must be a dotted lower-case action name`); + } + if (field === "actorType" && !actorTypes.has(value)) { + throw new Error(`${kind}.actorType is invalid`); + } + if (field === "outcome" && !auditOutcomes.has(value)) { + throw new Error(`${kind}.outcome is invalid`); + } + if (field === "kind" && !evidenceKinds.has(value)) { + throw new Error(`${kind}.kind is invalid`); + } + if (field === "status") { + assertStatusField(kind, value); + } + if (field === "state" && !resourceStates.has(value)) { + throw new Error(`${kind}.state is invalid`); + } + if (field === "resourceType" && !resourceTypes.has(value)) { + throw new Error(`${kind}.resourceType is invalid`); + } + if (field === "direction" && !capabilityDirections.has(value)) { + throw new Error(`${kind}.direction is invalid`); + } + if (field === "valueType" && !capabilityValueTypes.has(value)) { + throw new Error(`${kind}.valueType is invalid`); + } + if (field === "mutatesState" && typeof value !== "boolean") { + throw new Error(`${kind}.mutatesState must be boolean`); + } + if (field === "sha256" && !/^[a-f0-9]{64}$/.test(value)) { + throw new Error(`${kind}.sha256 must be lowercase sha256 hex`); + } +} + +function assertStatusField(kind, value) { + if (kind === "gatewaySession" && !gatewayStatuses.has(value)) { + throw new Error(`${kind}.status is invalid`); + } + if (kind === "boxResource" && !resourceStates.has(value)) { + throw new Error(`${kind}.status is invalid`); + } + if (kind === "hardwareOperation" && !operationStatuses.has(value)) { + throw new Error(`${kind}.status is invalid`); + } +} diff --git a/package.json b/package.json index 441cdbe4..a72ffe15 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", + "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", diff --git a/scripts/cloud-api-runtime-smoke.mjs b/scripts/cloud-api-runtime-smoke.mjs new file mode 100644 index 00000000..28ce1137 --- /dev/null +++ b/scripts/cloud-api-runtime-smoke.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createServer } from "node:net"; + +import { createCloudApiServer } from "../internal/cloud/server.mjs"; +import { ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; + +const previousEnv = { + HWLAB_CLOUD_DB_URL: process.env.HWLAB_CLOUD_DB_URL, + HWLAB_CLOUD_DB_SSL_MODE: process.env.HWLAB_CLOUD_DB_SSL_MODE +}; + +function logOk(name) { + process.stdout.write(`[cloud-api-runtime-smoke] ok ${name}\n`); +} + +async function freePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + "content-type": "application/json", + ...(options.headers ?? {}) + } + }); + const text = await response.text(); + return { + response, + body: text ? JSON.parse(text) : null + }; +} + +function rpcBody(id, method, params = {}) { + return JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params, + meta: { + traceId: `trc_${id}`, + actorId: "usr_cloud_api_runtime_smoke", + serviceId: "hwlab-cli", + environment: ENVIRONMENT_DEV + } + }); +} + +async function rpc(baseUrl, id, method, params = {}) { + const { response, body } = await requestJson(`${baseUrl}/rpc`, { + method: "POST", + body: rpcBody(id, method, params) + }); + assert.equal(response.status, 200, `${method} http status`); + assert.equal(body.jsonrpc, "2.0", `${method} JSON-RPC version`); + assert.equal(Object.hasOwn(body, "error"), false, body.error?.message); + return body.result; +} + +async function run() { + delete process.env.HWLAB_CLOUD_DB_URL; + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + + const port = await freePort(); + const server = createCloudApiServer(); + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + const health = await requestJson(`${baseUrl}/health`); + assert.equal(health.response.status, 200); + assert.equal(health.body.serviceId, "hwlab-cloud-api"); + assert.equal(health.body.environment, ENVIRONMENT_DEV); + assert.equal(health.body.status, "degraded"); + assert.equal(health.body.db.status, "blocked"); + assert.equal(health.body.db.connected, false); + assert.equal(health.body.db.ready, false); + assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); + assert.equal(health.body.runtime.durable, false); + assert.equal(health.body.runtime.status, "degraded"); + logOk("health degraded DB contract"); + + const v1 = await requestJson(`${baseUrl}/v1`); + assert.equal(v1.response.status, 200); + assert.equal(v1.body.status, "degraded"); + for (const method of [ + "gateway.session.register", + "box.resource.register", + "box.capability.report", + "hardware.operation.request", + "hardware.invoke.shell", + "audit.event.query", + "evidence.record.query" + ]) { + assert.ok(v1.body.methods.includes(method), `missing ${method}`); + } + logOk("REST adapter method contract"); + + const projectId = "prj_cloud-api-runtime-smoke"; + const gatewaySessionId = "gws_cloud-api-runtime-smoke"; + const resourceId = "res_cloud-api-runtime-smoke"; + const capabilityId = "cap_cloud-api-runtime-smoke"; + + const gateway = await rpc(baseUrl, "req_cloud_api_runtime_gateway", "gateway.session.register", { + projectId, + gatewaySessionId, + gatewayId: "gtw_cloud-api-runtime-smoke", + serviceId: "hwlab-gateway-simu", + endpoint: "http://127.0.0.1:7101" + }); + assert.equal(gateway.registered, true); + assert.equal(gateway.gatewaySession.gatewaySessionId, gatewaySessionId); + assert.equal(gateway.auditEvent.action, "gateway.session.register"); + + const resource = await rpc(baseUrl, "req_cloud_api_runtime_box", "box.resource.register", { + projectId, + gatewaySessionId, + resourceId, + boxId: "box_cloud-api-runtime-smoke", + resourceType: "board", + state: "available", + metadata: { + ports: ["shell0"] + } + }); + assert.equal(resource.registered, true); + assert.equal(resource.resource.resourceId, resourceId); + + const capability = await rpc(baseUrl, "req_cloud_api_runtime_capability", "box.capability.report", { + capabilityId, + resourceId, + projectId, + name: "shell.exec", + direction: "bidirectional", + valueType: "object", + mutatesState: true + }); + assert.equal(capability.reported, true); + assert.equal(capability.capabilities[0].capabilityId, capabilityId); + logOk("gateway/box/capability write path"); + + const invoke = await rpc(baseUrl, "req_cloud_api_runtime_shell", "hardware.invoke.shell", { + projectId, + gatewaySessionId, + resourceId, + capabilityId, + input: { + command: "echo hwlab" + } + }); + assert.equal(invoke.accepted, true); + assert.equal(invoke.operation.status, "accepted"); + assert.equal(invoke.dispatch.shellExecuted, false); + assert.equal(invoke.dispatch.dispatchStatus, "not_connected"); + assert.equal(invoke.evidenceRecord.kind, "trace"); + assert.equal(invoke.evidenceRecord.serviceId, "hwlab-cloud-api"); + assert.match(invoke.evidenceRecord.sha256, /^[a-f0-9]{64}$/); + logOk("hardware invoke shell degraded dispatch with evidence"); + + const audit = await rpc(baseUrl, "req_cloud_api_runtime_audit_query", "audit.event.query", { + projectId + }); + assert.ok(audit.count >= 5); + assert.ok(audit.events.every((event) => event.serviceId === "hwlab-cloud-api")); + assert.ok(audit.events.every((event) => event.environment === ENVIRONMENT_DEV)); + assert.ok(audit.events.some((event) => event.action === "hardware.operation.request")); + assert.ok(audit.events.some((event) => event.action === "hardware.invoke.shell")); + + const evidence = await rpc(baseUrl, "req_cloud_api_runtime_evidence_query", "evidence.record.query", { + projectId + }); + assert.equal(evidence.count, 1); + assert.equal(evidence.records[0].operationId, invoke.operationId); + assert.equal(evidence.records[0].metadata.dispatchStatus, "not_connected"); + logOk("audit/evidence query shape"); + + process.stdout.write("[cloud-api-runtime-smoke] passed\n"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + restoreEnv(); + } +} + +function restoreEnv() { + for (const [name, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } +} + +await run(); diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index ca4b4461..e344a574 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -151,9 +151,10 @@ async function smokeCloudApi() { assert.equal(health.response.status, 200); assert.equal(health.body.serviceId, "hwlab-cloud-api"); assert.equal(health.body.environment, ENVIRONMENT_DEV); - assert.equal(health.body.status, "ok"); + assert.equal(health.body.status, "degraded"); assert.equal(health.body.db.connected, false); assert.equal(health.body.db.status, "blocked"); + assert.equal(health.body.runtime.durable, false); assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); assert.equal(health.body.db.secretRefs[0].redacted, true); @@ -164,9 +165,13 @@ async function smokeCloudApi() { const adapter = await requestJson(`${baseUrl}/v1`); assert.equal(adapter.response.status, 200); - assert.equal(adapter.body.status, "skeleton"); + assert.equal(adapter.body.status, "degraded"); assert.ok(adapter.body.methods.includes("system.health")); + assert.ok(adapter.body.methods.includes("gateway.session.register")); + assert.ok(adapter.body.methods.includes("box.capability.report")); assert.ok(adapter.body.methods.includes("hardware.operation.request")); + assert.ok(adapter.body.methods.includes("audit.event.query")); + assert.ok(adapter.body.methods.includes("evidence.record.query")); const rpcHealth = await requestJson(`${baseUrl}/rpc`, { method: "POST", @@ -174,11 +179,75 @@ async function smokeCloudApi() { }); assert.equal(rpcHealth.response.status, 200); assert.equal(rpcHealth.body.jsonrpc, "2.0"); - assert.equal(rpcHealth.body.result.status, "ok"); + assert.equal(rpcHealth.body.result.status, "degraded"); assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api"); assert.equal(rpcHealth.body.result.db.status, "blocked"); assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api"); + const projectId = "prj_mvp_topology"; + const gatewaySessionId = "gws_m1_runtime"; + const resourceId = "res_m1_runtime"; + const capabilityId = "cap_m1_runtime"; + + const rpcGateway = await requestJson(`${baseUrl}/rpc`, { + method: "POST", + body: JSON.stringify( + rpcEnvelope({ + id: "req_m1_gateway", + method: "gateway.session.register", + params: { + projectId, + gatewaySessionId, + gatewayId: "gtw_m1_runtime", + serviceId: "hwlab-gateway-simu", + endpoint: `http://127.0.0.1:${port}/gateway` + } + }) + ) + }); + assert.equal(rpcGateway.response.status, 200); + assert.equal(rpcGateway.body.result.registered, true); + + const rpcBox = await requestJson(`${baseUrl}/rpc`, { + method: "POST", + body: JSON.stringify( + rpcEnvelope({ + id: "req_m1_box", + method: "box.resource.register", + params: { + projectId, + gatewaySessionId, + resourceId, + boxId: "box_m1_runtime", + resourceType: "board" + } + }) + ) + }); + assert.equal(rpcBox.response.status, 200); + assert.equal(rpcBox.body.result.registered, true); + + const rpcCapability = await requestJson(`${baseUrl}/rpc`, { + method: "POST", + body: JSON.stringify( + rpcEnvelope({ + id: "req_m1_capability", + method: "box.capability.report", + params: { + capabilityId, + resourceId, + projectId, + name: "shell.exec", + direction: "bidirectional", + valueType: "object", + mutatesState: true + } + }) + ) + }); + assert.equal(rpcCapability.response.status, 200); + assert.equal(rpcCapability.body.result.reported, true); + const rpcHardware = await requestJson(`${baseUrl}/rpc`, { method: "POST", body: JSON.stringify( @@ -186,17 +255,24 @@ async function smokeCloudApi() { id: "req_m1_hardware", method: "hardware.operation.request", params: { - projectId: "prj_mvp_topology" + projectId, + gatewaySessionId, + resourceId, + capabilityId, + input: { + command: "echo m1" + } } }) ) }); assert.equal(rpcHardware.response.status, 200); - assert.equal(rpcHardware.body.result.accepted, false); - assert.equal(rpcHardware.body.result.status, "skeleton"); - assert.equal(rpcHardware.body.result.audit.source.serviceId, "hwlab-cloud-api"); + assert.equal(rpcHardware.body.result.accepted, true); + assert.equal(rpcHardware.body.result.status, "accepted"); + assert.equal(rpcHardware.body.result.auditEvent.serviceId, "hwlab-cloud-api"); + assert.equal(rpcHardware.body.result.persistence.durable, false); - logOk("cloud-api health/live/rpc skeleton"); + logOk("cloud-api health/live/rpc runtime"); } async function smokePatchPanelAndSimulators() { @@ -283,7 +359,7 @@ async function smokePatchPanelAndSimulators() { observedAt: routed.body.deliveries[0].observedAt }); - logOk("patch-panel/sim status and route skeleton"); + logOk("patch-panel/sim status and route runtime"); } async function smokeCliDryRunFixture() { diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index c4b132c2..9e903f65 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -244,10 +244,11 @@ async function inspectContracts() { function inspectCloudApiDbContract(deploy) { const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; + const configReady = Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE); const db = { - status: env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE ? "ready" : "blocked", - ready: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE), - configReady: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE), + status: configReady ? "degraded" : "blocked", + ready: false, + configReady, connected: false, connectionChecked: false, fields: [