Skip to content

Commit 999fcdf

Browse files
voidstackloopclaude
andcommitted
fix(server): heartbeat no longer trusts client-supplied updatedAt; add github reporter to e2e
A compute node heartbeat's capturedAt is untrusted client input (an agent-reported reading), but both the in-memory and Postgres compute control stores were using it directly as the row's own updatedAt bookkeeping field -- letting a clock-skewed or malformed agent payload propagate straight into it, and in the Postgres store specifically risking a Zod validation failure on re-parse if capturedAt isn't a strictly-formed ISO datetime. Both now generate updatedAt from the store's own injected clock instead, matching every other write path in these two files; lastHeartbeatAt still legitimately reflects the agent's own capturedAt. Fixed a related SQL param-binding bug this exposed in the Postgres store's UPDATE (updated_at was bound to the same parameter as last_heartbeat_at). Also added the 'github' reporter to e2e/playwright.config.ts's CI reporter list -- without it a failing spec surfaces only as the job's bare 'Process completed with exit code 1', with no file/line/assertion in GitHub's own UI, forcing a full HTML-report artifact download just to find out what broke. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ff664d0 commit 999fcdf

3 files changed

Lines changed: 16 additions & 4 deletions

File tree

‎e2e/playwright.config.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export default defineConfig({
2323
// that's comfortably generous locally can still time out there. Give CI
2424
// more headroom uniformly rather than chasing each timeout one at a time.
2525
expect: { timeout: process.env.CI ? 20_000 : 10_000 },
26-
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
26+
// "github" surfaces the failing test's file/line/error directly as a
27+
// GitHub Actions annotation — without it, a CI failure here shows up
28+
// only as the job's own bare "Process completed with exit code 1",
29+
// forcing a full artifact download just to find out which spec failed.
30+
reporter: process.env.CI ? [["list"], ["github"], ["html", { open: "never" }]] : "list",
2731
use: {
2832
// Only kept for a run that actually failed — cheap on disk, and the
2933
// difference between "it broke somewhere" and "here's exactly what

‎server/src/store/compute-control-store.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@ export class InMemoryComputeControlStore implements ComputeControlStore {
9999
devices: clone(heartbeat.devices), inventoryVersion: heartbeat.inventoryVersion,
100100
lastHeartbeatAt: heartbeat.capturedAt,
101101
state: existing.state === "offline" ? "online" : existing.state,
102-
updatedAt: heartbeat.capturedAt,
102+
// Server-generated, not the agent-supplied capturedAt: updatedAt
103+
// is this row's own bookkeeping timestamp, and a heartbeat's
104+
// capturedAt is untrusted client input — a clock-skewed or
105+
// malformed agent payload must never propagate into it.
106+
updatedAt: this.iso(),
103107
};
104108
this.nodes.set(key, node);
105109
return clone(node);

‎server/src/store/postgres-compute-control-store.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,13 @@ export class PostgresComputeControlStore implements ComputeControlStore {
8484
const node = computeNodeSchema.parse({
8585
...existing, freeCpuThreads: Math.min(existing.cpuThreads, heartbeat.freeCpuThreads), freeRamMB: Math.min(existing.totalRamMB, heartbeat.freeRamMB),
8686
devices: heartbeat.devices, inventoryVersion: heartbeat.inventoryVersion, lastHeartbeatAt: heartbeat.capturedAt,
87-
state: existing.state === "offline" ? "online" : existing.state, updatedAt: heartbeat.capturedAt,
87+
// Server-generated, not the agent-supplied capturedAt: updatedAt
88+
// is this row's own bookkeeping timestamp, and a heartbeat's
89+
// capturedAt is untrusted client input — a clock-skewed or
90+
// malformed agent payload must never propagate into it.
91+
state: existing.state === "offline" ? "online" : existing.state, updatedAt: this.now().toISOString(),
8892
});
89-
await client.query("UPDATE compute_nodes SET state=$3,inventory_version=$4,last_heartbeat_at=$5,document=$6,updated_at=$5 WHERE organization_id=$1 AND id=$2", [organizationId, nodeId, node.state, node.inventoryVersion, node.lastHeartbeatAt, JSON.stringify(node)]);
93+
await client.query("UPDATE compute_nodes SET state=$3,inventory_version=$4,last_heartbeat_at=$5,document=$6,updated_at=$7 WHERE organization_id=$1 AND id=$2", [organizationId, nodeId, node.state, node.inventoryVersion, node.lastHeartbeatAt, JSON.stringify(node), node.updatedAt]);
9094
await client.query("DELETE FROM compute_accelerator_devices WHERE organization_id=$1 AND node_id=$2", [organizationId, nodeId]);
9195
for (const device of node.devices) await client.query(
9296
"INSERT INTO compute_accelerator_devices(id,organization_id,node_id,health,vendor,total_vram_mb,free_vram_mb,document) VALUES($1,$2,$3,$4,$5,$6,$7,$8)",

0 commit comments

Comments
 (0)