feat: add cloud api runtime smoke

This commit is contained in:
HWLAB Code Queue
2026-05-21 18:33:01 +00:00
parent 95a6180439
commit eba24b72c0
19 changed files with 1749 additions and 105 deletions
+42
View File
@@ -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`.
+3 -1
View File
@@ -36,7 +36,9 @@ Required DEV DB config names:
When the Secret/env is absent, the health JSON should keep When the Secret/env is absent, the health JSON should keep
`db.connected: false`, set `db.status: "blocked"`, and list the missing env `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 ## Route Classification
+8 -7
View File
@@ -1,7 +1,7 @@
# M1 Local Contract Smoke # M1 Local Contract Smoke
The M1 local smoke is a fast contract check for the current HWLAB MVP 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. processes, and does not deploy or call the frozen DEV endpoint.
Run it from the repository root: 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 - Starts `hwlab-cloud-api` on `127.0.0.1` with an ephemeral port and checks
`/health`, `/live`, `GET /v1`, and `POST /rpc`. `/health`, `/live`, `GET /v1`, and `POST /rpc`.
- Verifies the cloud-api skeleton returns `system.health`, describes the REST - Verifies the cloud-api runtime returns degraded `system.health` when DB
and JSON-RPC adapter surface, and rejects `hardware.operation.request` as a persistence is not connected, describes the REST and JSON-RPC adapter
known skeleton response instead of dispatching real hardware work. 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 - Starts `hwlab-gateway-simu`, `hwlab-box-simu`, and `hwlab-patch-panel` on
ephemeral localhost ports. ephemeral localhost ports.
- Checks simulator health and status payloads, confirms cross-device - 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. - replace the L5 deploy manifest work.
The check is scoped to local M1 contract readiness: existing cloud-api, 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 patch-panel, simulator, and CLI dry-run runtime paths can be started or parsed
still agree on the frozen service IDs, DEV environment, JSON-RPC surface, and and still agree on the frozen service IDs, DEV environment, JSON-RPC surface,
patch-panel-only topology assumptions. DB degradation contract, and patch-panel-only topology assumptions.
## Follow-On DEV e2e ## Follow-On DEV e2e
+1 -1
View File
@@ -111,7 +111,7 @@ reason.
| Milestone | Operator Gate | Required Acceptance | | 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. | | 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. | | 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. | | 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. | | 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. |
@@ -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"
}
}
+73 -1
View File
@@ -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"; export const CLOUD_API_SERVICE_ID = "hwlab-cloud-api";
@@ -12,6 +16,19 @@ export const AUDIT_FIELD_NAMES = Object.freeze([
"timestamp" "timestamp"
]); ]);
export const PROTOCOL_AUDIT_FIELD_NAMES = Object.freeze([
"auditId",
"traceId",
"actorType",
"actorId",
"action",
"targetType",
"targetId",
"serviceId",
"environment",
"occurredAt"
]);
export function createAuditRecord(input = {}) { export function createAuditRecord(input = {}) {
const record = { const record = {
requestId: asString(input.requestId, "req_unassigned"), requestId: asString(input.requestId, "req_unassigned"),
@@ -27,6 +44,34 @@ export function createAuditRecord(input = {}) {
return record; 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 = {}) { export function deriveActorFromMeta(meta = {}) {
return { return {
type: meta.actorType || "system", 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) { export function assertAuditFields(record) {
if (!record || typeof record !== "object" || Array.isArray(record)) { if (!record || typeof record !== "object" || Array.isArray(record)) {
throw new Error("audit record must be an object"); throw new Error("audit record must be an object");
@@ -143,3 +195,23 @@ function asString(value, fallback) {
} }
return String(value); 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)}`;
}
+32 -1
View File
@@ -1,7 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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", () => { test("createAuditRecord exposes the L1 audit field names", () => {
const record = createAuditRecord({ const record = createAuditRecord({
@@ -49,3 +55,28 @@ test("assertAuditFields rejects missing audit fields", () => {
/audit.timestamp is required/ /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");
});
+11 -8
View File
@@ -38,17 +38,20 @@ export function buildDbHealthContract(env = process.env) {
}; };
}); });
const missingEnv = fields.filter((field) => !field.present).map((field) => field.name); 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 { return {
contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, contractVersion: DEV_DB_ENV_CONTRACT.contractVersion,
environment: DEV_DB_ENV_CONTRACT.environment, environment: DEV_DB_ENV_CONTRACT.environment,
connected: false, connected,
connectionChecked: false, connectionChecked,
configReady: ready, configReady,
ready, ready: connected,
status: ready ? "ready" : "blocked", status,
mode: ready ? "configured_pending_live_probe" : "not_configured", mode: configReady ? "configured_without_live_connection" : "not_configured",
fields, fields,
missingEnv, missingEnv,
secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({ secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({
@@ -65,7 +68,7 @@ export function buildDbHealthContract(env = process.env) {
valuesRedacted: true, valuesRedacted: true,
liveDbEvidence: false liveDbEvidence: false
}, },
evidence: ready ? "env_presence_only" : "env_contract_blocked" evidence: configReady ? "env_presence_only_no_live_db" : "env_contract_blocked"
}; };
} }
+54 -30
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { import {
ENVIRONMENT_DEV, ENVIRONMENT_DEV,
ERROR_CODES, ERROR_CODES,
HwlabProtocolError,
JSON_RPC_VERSION, JSON_RPC_VERSION,
validateRequest, validateRequest,
validateResponse validateResponse
@@ -14,19 +15,38 @@ import {
deriveActorFromMeta deriveActorFromMeta
} from "../audit/index.mjs"; } from "../audit/index.mjs";
import { buildDbHealthContract } from "./db-contract.mjs"; import { buildDbHealthContract } from "./db-contract.mjs";
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
export const SUPPORTED_RPC_METHODS = Object.freeze([ export const SUPPORTED_RPC_METHODS = Object.freeze([
"system.health", "system.health",
"cloud.adapter.describe", "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([ const rpcHandlers = new Map([
["system.health", handleSystemHealth], ["system.health", handleSystemHealth],
["cloud.adapter.describe", handleAdapterDescribe], ["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 = {}) { export async function handleJsonRpcRequest(envelope, context = {}) {
const requestMeta = extractMeta(envelope); const requestMeta = extractMeta(envelope);
const requestId = envelope?.id ?? "req_unassigned"; const requestId = envelope?.id ?? "req_unassigned";
@@ -84,6 +104,26 @@ export async function handleJsonRpcRequest(envelope, context = {}) {
requestMeta: envelope.meta requestMeta: envelope.meta
}); });
} catch (error) { } 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({ return createErrorEnvelope({
id: envelope.id, id: envelope.id,
code: ERROR_CODES.internalError, code: ERROR_CODES.internalError,
@@ -177,50 +217,34 @@ export function createResponseMeta(requestMeta = {}) {
return meta; return meta;
} }
function handleSystemHealth() { function handleSystemHealth(params, envelope, context) {
return { return {
status: "ok", status: healthStatus(),
serviceId: CLOUD_API_SERVICE_ID, serviceId: CLOUD_API_SERVICE_ID,
environment: ENVIRONMENT_DEV, environment: ENVIRONMENT_DEV,
db: buildDbHealthContract() db: buildDbHealthContract(),
runtime: getRuntimeStore(context).summary()
}; };
} }
function handleAdapterDescribe() { function handleAdapterDescribe(params, envelope, context) {
return { return {
serviceId: CLOUD_API_SERVICE_ID, serviceId: CLOUD_API_SERVICE_ID,
rpcEndpoint: "POST /rpc", rpcEndpoint: "POST /rpc",
restEndpoint: "POST /v1/rpc/{method}", restEndpoint: "POST /v1/rpc/{method}",
methods: SUPPORTED_RPC_METHODS, methods: SUPPORTED_RPC_METHODS,
auditFields: AUDIT_FIELD_NAMES, auditFields: AUDIT_FIELD_NAMES,
db: buildDbHealthContract() db: buildDbHealthContract(),
runtime: getRuntimeStore(context).summary()
}; };
} }
function handleHardwareOperationRequest(params, envelope, context) { function getRuntimeStore(context = {}) {
const projectId = params.projectId || "project_unspecified"; return context.runtimeStore || defaultRuntimeStore;
}
return { function healthStatus() {
accepted: false, return "degraded";
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 extractMeta(envelope) { function extractMeta(envelope) {
+122 -27
View File
@@ -3,6 +3,7 @@ import test from "node:test";
import { AUDIT_FIELD_NAMES } from "../audit/index.mjs"; import { AUDIT_FIELD_NAMES } from "../audit/index.mjs";
import { ERROR_CODES, validateResponse } from "../protocol/index.mjs"; import { ERROR_CODES, validateResponse } from "../protocol/index.mjs";
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
import { handleJsonRpcRequest } from "./json-rpc.mjs"; import { handleJsonRpcRequest } from "./json-rpc.mjs";
test("unknown JSON-RPC method returns an error envelope with audit fields", async () => { 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"); 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 () => { test("system.health includes the redacted DEV DB env contract", async () => {
const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; 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); validateResponse(response);
assert.equal(response.result.status, "degraded");
assert.equal(response.result.db.status, "blocked"); assert.equal(response.result.db.status, "blocked");
assert.equal(response.result.db.connected, false); 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.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].secretName, "hwlab-cloud-api-dev-db");
assert.equal(response.result.db.secretRefs[0].secretKey, "database-url"); 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");
});
+17 -11
View File
@@ -13,13 +13,15 @@ import {
handleJsonRpcRequest handleJsonRpcRequest
} from "./json-rpc.mjs"; } from "./json-rpc.mjs";
import { buildDbHealthContract } from "./db-contract.mjs"; import { buildDbHealthContract } from "./db-contract.mjs";
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
export function createCloudApiServer(options = {}) { export function createCloudApiServer(options = {}) {
const runtimeStore = options.runtimeStore || createCloudRuntimeStore();
return createServer(async (request, response) => { return createServer(async (request, response) => {
try { try {
await routeRequest(request, response, options); await routeRequest(request, response, { ...options, runtimeStore });
} catch (error) { } catch (error) {
sendJson(response, 500, { sendJson(response, 500, {
error: { error: {
@@ -32,7 +34,7 @@ export function createCloudApiServer(options = {}) {
}); });
} }
export function buildHealthPayload() { export function buildHealthPayload(options = {}) {
const serviceId = CLOUD_API_SERVICE_ID; const serviceId = CLOUD_API_SERVICE_ID;
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
const imageReference = process.env.HWLAB_IMAGE || "unknown"; const imageReference = process.env.HWLAB_IMAGE || "unknown";
@@ -42,7 +44,7 @@ export function buildHealthPayload() {
return { return {
serviceId, serviceId,
environment: ENVIRONMENT_DEV, environment: ENVIRONMENT_DEV,
status: "ok", status: db.status === "blocked" ? "degraded" : "degraded",
service: { service: {
id: serviceId, id: serviceId,
role: "cloud-api", role: "cloud-api",
@@ -60,7 +62,8 @@ export function buildHealthPayload() {
}, },
endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
observedAt: new Date().toISOString(), 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"); const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) { if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
sendJson(response, 200, buildHealthPayload()); sendJson(response, 200, buildHealthPayload(options));
return; return;
} }
@@ -90,7 +93,7 @@ async function routeRequest(request, response, options) {
return; 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}`, operation: `${request.method || "GET"} ${url.pathname}`,
target: { target: {
type: "route", type: "route",
@@ -130,7 +133,8 @@ async function handleRpcHttpRequest(request, response, options) {
} }
const rpcResponse = await handleJsonRpcRequest(envelope, { const rpcResponse = await handleJsonRpcRequest(envelope, {
adapter: "json-rpc" adapter: "json-rpc",
runtimeStore: options.runtimeStore
}); });
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
} }
@@ -140,17 +144,18 @@ async function handleRestAdapter(request, response, url, options) {
sendJson(response, 200, { sendJson(response, 200, {
serviceId: CLOUD_API_SERVICE_ID, serviceId: CLOUD_API_SERVICE_ID,
adapter: "rest", adapter: "rest",
status: "skeleton", status: "degraded",
rpcBridge: "POST /v1/rpc/{method}", rpcBridge: "POST /v1/rpc/{method}",
methods: SUPPORTED_RPC_METHODS, methods: SUPPORTED_RPC_METHODS,
auditFields: AUDIT_FIELD_NAMES, auditFields: AUDIT_FIELD_NAMES,
db: buildDbHealthContract() db: buildDbHealthContract(),
runtime: options.runtimeStore.summary()
}); });
return; return;
} }
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) { 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}`, operation: `${request.method || "GET"} ${url.pathname}`,
target: { target: {
type: "route", type: "route",
@@ -205,7 +210,8 @@ async function handleRestAdapter(request, response, url, options) {
} }
}, },
{ {
adapter: "rest" adapter: "rest",
runtimeStore: options.runtimeStore
} }
); );
+9 -4
View File
@@ -20,11 +20,14 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
const healthPayload = await health.json(); const healthPayload = await health.json();
assert.equal(healthPayload.serviceId, "hwlab-cloud-api"); assert.equal(healthPayload.serviceId, "hwlab-cloud-api");
assert.equal(healthPayload.environment, "dev"); assert.equal(healthPayload.environment, "dev");
assert.equal(healthPayload.status, "degraded");
assert.equal(healthPayload.commit.id.length > 0, true); assert.equal(healthPayload.commit.id.length > 0, true);
assert.equal(healthPayload.image.reference.length > 0, true); assert.equal(healthPayload.image.reference.length > 0, true);
assert.equal(healthPayload.service.id, "hwlab-cloud-api"); assert.equal(healthPayload.service.id, "hwlab-cloud-api");
assert.equal(healthPayload.db.status, "blocked"); assert.equal(healthPayload.db.status, "blocked");
assert.equal(healthPayload.db.connected, false); 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.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].secretName, "hwlab-cloud-api-dev-db");
assert.equal(healthPayload.db.secretRefs[0].redacted, true); 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); assert.equal(healthLive.status, 200);
const healthLivePayload = await healthLive.json(); const healthLivePayload = await healthLive.json();
assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api"); 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.commit.id.length > 0, true);
assert.equal(healthLivePayload.db.ready, false); assert.equal(healthLivePayload.db.ready, false);
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
assert.equal(readiness.status, 200); 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`); const live = await fetch(`http://127.0.0.1:${port}/live`);
assert.equal(live.status, 200); 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 { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/health`); const response = await fetch(`http://127.0.0.1:${port}/health`);
const payload = await response.json(); 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.connected, false);
assert.equal(payload.db.connectionChecked, false); assert.equal(payload.db.connectionChecked, false);
assert.equal(payload.db.configReady, true); 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.deepEqual(payload.db.missingEnv, []);
assert.equal(JSON.stringify(payload.db).includes("password"), false); assert.equal(JSON.stringify(payload.db).includes("password"), false);
assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false); assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false);
+623
View File
@@ -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);
}
+46 -1
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import test from "node:test"; import test from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { TABLES } from "../protocol/index.mjs"; import { TABLES, assertProtocolRecord, assertProtocolRecords } from "../protocol/index.mjs";
import { import {
CLOUD_CORE_MIGRATIONS, CLOUD_CORE_MIGRATIONS,
FROZEN_CLOUD_CORE_TABLES, 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, /\bresult\b/);
assert.match(sql, /\btimestamp\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/
);
});
+345
View File
@@ -50,12 +50,264 @@ export const ERROR_CODES = Object.freeze({
auditRequired: -32040 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 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) { export function isFrozenServiceId(serviceId) {
return SERVICE_IDS.includes(serviceId); return SERVICE_IDS.includes(serviceId);
} }
export function isProtocolId(value) {
return typeof value === "string" && protocolIdPattern.test(value);
}
export function validateMeta(meta) { export function validateMeta(meta) {
if (!meta || typeof meta !== "object" || Array.isArray(meta)) { if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
throw new Error("meta must be an object"); throw new Error("meta must be an object");
@@ -149,3 +401,96 @@ export function createRpcErrorBody({ code = ERROR_CODES.hwlabUnknown, message =
error 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`);
}
}
+2 -1
View File
@@ -5,8 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"validate": "node scripts/validate-contract.mjs", "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", "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", "m1:smoke": "node scripts/m1-contract-smoke.mjs",
"l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs", "l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs",
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
+208
View File
@@ -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();
+85 -9
View File
@@ -151,9 +151,10 @@ async function smokeCloudApi() {
assert.equal(health.response.status, 200); assert.equal(health.response.status, 200);
assert.equal(health.body.serviceId, "hwlab-cloud-api"); assert.equal(health.body.serviceId, "hwlab-cloud-api");
assert.equal(health.body.environment, ENVIRONMENT_DEV); 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.connected, false);
assert.equal(health.body.db.status, "blocked"); 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.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].secretName, "hwlab-cloud-api-dev-db");
assert.equal(health.body.db.secretRefs[0].redacted, true); assert.equal(health.body.db.secretRefs[0].redacted, true);
@@ -164,9 +165,13 @@ async function smokeCloudApi() {
const adapter = await requestJson(`${baseUrl}/v1`); const adapter = await requestJson(`${baseUrl}/v1`);
assert.equal(adapter.response.status, 200); 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("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("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`, { const rpcHealth = await requestJson(`${baseUrl}/rpc`, {
method: "POST", method: "POST",
@@ -174,11 +179,75 @@ async function smokeCloudApi() {
}); });
assert.equal(rpcHealth.response.status, 200); assert.equal(rpcHealth.response.status, 200);
assert.equal(rpcHealth.body.jsonrpc, "2.0"); 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.serviceId, "hwlab-cloud-api");
assert.equal(rpcHealth.body.result.db.status, "blocked"); assert.equal(rpcHealth.body.result.db.status, "blocked");
assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api"); 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`, { const rpcHardware = await requestJson(`${baseUrl}/rpc`, {
method: "POST", method: "POST",
body: JSON.stringify( body: JSON.stringify(
@@ -186,17 +255,24 @@ async function smokeCloudApi() {
id: "req_m1_hardware", id: "req_m1_hardware",
method: "hardware.operation.request", method: "hardware.operation.request",
params: { params: {
projectId: "prj_mvp_topology" projectId,
gatewaySessionId,
resourceId,
capabilityId,
input: {
command: "echo m1"
}
} }
}) })
) )
}); });
assert.equal(rpcHardware.response.status, 200); assert.equal(rpcHardware.response.status, 200);
assert.equal(rpcHardware.body.result.accepted, false); assert.equal(rpcHardware.body.result.accepted, true);
assert.equal(rpcHardware.body.result.status, "skeleton"); assert.equal(rpcHardware.body.result.status, "accepted");
assert.equal(rpcHardware.body.result.audit.source.serviceId, "hwlab-cloud-api"); 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() { async function smokePatchPanelAndSimulators() {
@@ -283,7 +359,7 @@ async function smokePatchPanelAndSimulators() {
observedAt: routed.body.deliveries[0].observedAt 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() { async function smokeCliDryRunFixture() {
+4 -3
View File
@@ -244,10 +244,11 @@ async function inspectContracts() {
function inspectCloudApiDbContract(deploy) { function inspectCloudApiDbContract(deploy) {
const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; 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 = { const db = {
status: env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE ? "ready" : "blocked", status: configReady ? "degraded" : "blocked",
ready: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE), ready: false,
configReady: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE), configReady,
connected: false, connected: false,
connectionChecked: false, connectionChecked: false,
fields: [ fields: [