import test from "node:test"; import assert from "node:assert/strict"; import { execFile as execFileCallback } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { buildStatusReport, claimIfNeeded, createShellBridge, discoverShellyDevices, finalizePendingUpdateOnStartup, getAgentStatus, getRelayStatus, loadConfig, parseCliArgs, processPolledShellAction, processPolledCommand, runCli, runUpdate, setRelayState, startAgent, handleAgentCommand, verifyPendingUpdate, } from "../dist/agent.mjs"; const execFile = promisify(execFileCallback); const agentEntryPath = fileURLToPath(new URL("../dist/agent.mjs", import.meta.url)); function makeFetchResponse(body) { const bytes = Buffer.from(body); return { ok: true, async arrayBuffer() { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); }, }; } function sha256Hex(body) { return createHash("sha256").update(body).digest("hex"); } async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) { return; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Timed out waiting for ${description}`); } test("claimIfNeeded persists claimed gateway credentials", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-")); const configPath = path.join(tempDir, "config.json"); await writeFile(configPath, JSON.stringify({ apiUrl: "https://api.example.test", installToken: "claim-token", })); const requests = []; const fakeFetch = async (url, options) => { requests.push({ url, options }); return { ok: true, async json() { return { data: { gateway: { id: 9001 }, agent_token: "agent-token", release_channel: "stable", broker_url: "https://broker.example.test", }, }; }, }; }; const config = await claimIfNeeded({ apiUrl: "https://api.example.test", installToken: "claim-token", }, configPath, fakeFetch); assert.equal(requests.length, 1); assert.equal(config.gatewayId, 9001); assert.equal(config.agentToken, "agent-token"); assert.equal(config.brokerUrl, "https://broker.example.test"); await rm(tempDir, { recursive: true, force: true }); }); test("relay status and switch commands support both Shelly RPC and legacy endpoints", async () => { const fakeFetch = async (url) => { if (String(url).includes("Switch.GetStatus")) { return { ok: true, async json() { return { output: true }; }, }; } if (String(url).includes("Switch.Set")) { return { ok: true, async json() { return { output: false }; }, }; } throw new Error(`Unexpected URL: ${url}`); }; const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0 }, fakeFetch); const switched = await setRelayState({ localIp: "10.1.0.31", channel: 0, on: false }, fakeFetch); assert.equal(status.online, true); assert.equal(status.on, true); assert.equal(switched.on, false); }); test("batch relay commands return per-relay results without failing the whole batch", async () => { const fakeFetch = async (url) => { const value = String(url); if (value.includes("10.1.0.31")) { return { ok: true, async json() { return { output: true }; }, }; } throw new Error("relay offline"); }; const result = await handleAgentCommand({ commandType: "BATCH_SET_RELAY_STATE", payload: { batch_id: "batch-1", commands: [ { target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true }, { target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true }, ], }, }, { fetchImpl: fakeFetch }); assert.equal(result.batch_id, "batch-1"); assert.equal(result.results.length, 2); assert.equal(result.results[0].ok, true); assert.equal(result.results[0].target, "MACHINE"); assert.equal(result.results[1].ok, false); assert.match(result.results[1].error, /relay offline/); }); test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => { const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => { assert.equal(String(url), "http://192.168.1.2/shelly"); return { ok: true, async json() { return { id: "shelly1minig3-e4b3231f6410", mac: "E4B3231F6410", model: "S3SW-001X8EU", type: "Shelly 1 Mini Gen3", num_switches: 1, }; }, }; }); assert.equal(inventory.length, 1); assert.equal(inventory[0].device_id, "E4B3231F6410"); assert.equal(inventory[0].model, "S3SW-001X8EU"); assert.equal(inventory[0].capabilities.generation, 3); }); test("relay status reads use the legacy endpoint when the RPC status endpoint stalls", async () => { const urls = []; const fakeFetch = async (url) => { urls.push(String(url)); if (String(url).includes("Switch.GetStatus")) { return new Promise(() => {}); } if (String(url) === "http://10.1.0.31/relay/0") { return { ok: true, async json() { return { ison: false }; }, }; } throw new Error(`Unexpected URL: ${url}`); }; const startedAt = Date.now(); const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0, timeoutMs: 50 }, fakeFetch); assert.equal(status.online, true); assert.equal(status.on, false); assert.deepEqual(urls.sort(), [ "http://10.1.0.31/relay/0", "http://10.1.0.31/rpc/Switch.GetStatus?id=0", ]); assert.ok(Date.now() - startedAt < 250); }); test("relay switch commands fail quickly when the local Shelly request stalls", async () => { let calls = 0; const hangingFetch = async () => { calls += 1; return new Promise(() => {}); }; const startedAt = Date.now(); await assert.rejects( () => setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, timeoutMs: 50 }, hangingFetch), /HTTP request timed out after 50ms/ ); assert.equal(calls, 1); assert.ok(Date.now() - startedAt < 500); }); test("relay switch commands pass timer values to local Shelly APIs", async () => { const legacyUrls = []; const legacyFetch = async (url) => { legacyUrls.push(String(url)); return { ok: true, async json() { return { ison: true, has_timer: true, timer_duration: 3 }; }, }; }; await setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, toggleAfter: 3, deviceGeneration: 1, }, legacyFetch); assert.equal( legacyUrls[0], "http://10.1.0.31/relay/0?turn=on&timer=3" ); const gen3Urls = []; const gen3Fetch = async (url) => { gen3Urls.push(String(url)); return { ok: true, async json() { return { output: true }; }, }; }; await setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, toggle_after: 3, device_generation: 3, }, gen3Fetch); assert.equal( gen3Urls[0], "http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3" ); const fallbackUrls = []; const legacyFallbackFetch = async (url) => { fallbackUrls.push(String(url)); if (String(url).includes("/rpc/")) { throw new Error("RPC switch endpoint unsupported"); } return { ok: true, async json() { return { ison: true, has_timer: true, timer_duration: 3 }; }, }; }; await setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, toggle_after: 3, device_model: "S3SW-001X8EU", }, legacyFallbackFetch); assert.deepEqual(fallbackUrls, [ "http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3", "http://10.1.0.31/relay/0?turn=on&timer=3", ]); const cappedUrls = []; const cappedFetch = async (url) => { cappedUrls.push(String(url)); return { ok: true, async json() { return { output: true }; }, }; }; await setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, toggle_after: 999999999, device_generation: 3, }, cappedFetch); assert.equal( cappedUrls[0], "http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=5" ); }); test("runUpdate stages a pending verification restart after installing new artifacts", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-")); const configPath = path.join(tempDir, "config.json"); const liveConfig = { apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", installDir: tempDir, restartMode: "spawn", installedVersion: "1.0.0", targetVersion: "1.0.0", }; await writeFile(configPath, JSON.stringify(liveConfig, null, 2)); await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n"); await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2)); const execCalls = []; const fakeExecFile = async (command, args, options) => { execCalls.push({ command, args, options }); return { stdout: "{}" }; }; const agentBody = "// new agent\n"; const packageBody = JSON.stringify({ name: "new-edge-agent" }, null, 2); const fakeFetch = async (url) => { if (String(url).endsWith("/agent.mjs")) { return makeFetchResponse(agentBody); } if (String(url).endsWith("/package.json")) { return makeFetchResponse(packageBody); } throw new Error(`Unexpected URL: ${url}`); }; const result = await runUpdate({ artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs", sha256: sha256Hex(agentBody), packageUrl: "https://api.example.test/edge-agent/artifacts/package.json", packageSha256: sha256Hex(packageBody), targetVersion: "1.1.0", releaseChannel: "stable", restartMode: "spawn", }, fakeFetch, { configPath, config: liveConfig, liveConfig, execFileImpl: fakeExecFile, }); const persistedConfig = JSON.parse(await readFile(configPath, "utf8")); assert.equal(result.__agentCommandEnvelope, true); assert.equal(result.payload.verification_pending, true); assert.equal(result.payload.target_version, "1.1.0"); assert.equal(liveConfig.targetVersion, "1.1.0"); assert.equal(liveConfig.installedVersion, "1.0.0"); assert.equal(persistedConfig.pendingUpdate.targetVersion, "1.1.0"); assert.equal(persistedConfig.pendingUpdate.previousVersion, "1.0.0"); assert.match(persistedConfig.pendingUpdate.backupDir, /\.updates/); assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// new agent\n"); assert.equal(execCalls.length, 2); assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]); assert.deepEqual(execCalls[1].args, [path.join(tempDir, "agent.mjs"), "status", "--config", configPath]); await rm(tempDir, { recursive: true, force: true }); }); test("runUpdate rejects artifacts without required checksums", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-checksum-")); const configPath = path.join(tempDir, "config.json"); const liveConfig = { apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", installDir: tempDir, restartMode: "spawn", installedVersion: "1.0.0", targetVersion: "1.0.0", }; await writeFile(configPath, JSON.stringify(liveConfig, null, 2)); await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n"); let fetchCalled = false; await assert.rejects( runUpdate({ artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs", targetVersion: "1.1.0", }, async () => { fetchCalled = true; return makeFetchResponse("// new agent\n"); }, { configPath, config: liveConfig, liveConfig, execFileImpl: async () => ({ stdout: "{}" }), }), /Agent artifact checksum is required/ ); assert.equal(fetchCalled, false); assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n"); await rm(tempDir, { recursive: true, force: true }); }); test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-")); const configPath = path.join(tempDir, "config.json"); await writeFile(configPath, JSON.stringify({ apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", installToken: "install-token", brokerUrl: "https://broker.example.test", restartMode: "spawn", })); const result = await handleAgentCommand( { commandType: "UNINSTALL_AGENT", payload: { serviceName: "truckwash-edge-agent.service", }, }, { configPath, config: await loadConfig(configPath), liveConfig: null, } ); assert.equal(result.__agentCommandEnvelope, true); assert.equal(result.payload.uninstall_scheduled, true); assert.equal(result.followUp.type, "UNINSTALL_AGENT"); assert.equal(result.followUp.uninstallPlan.configPath, configPath); assert.equal(result.followUp.uninstallPlan.serviceName, "truckwash-edge-agent.service"); await rm(tempDir, { recursive: true, force: true }); }); test("processPolledCommand acknowledges uninstall before clearing credentials and exiting", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-")); const configPath = path.join(tempDir, "config.json"); const config = { apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", installToken: "install-token", brokerUrl: "https://broker.example.test", restartMode: "spawn", }; await writeFile(configPath, JSON.stringify(config, null, 2)); const commandResultPosts = []; const fakeFetch = async (url, options = {}) => { const body = options.body ? JSON.parse(options.body) : {}; if (String(url).endsWith("/commands/77/result")) { commandResultPosts.push({ url, body }); return { ok: true, async json() { return { data: { acknowledged: true } }; }, }; } throw new Error(`Unexpected URL: ${url}`); }; const exitCodes = []; const result = await processPolledCommand( { ...config, configPath, }, { id: 77, commandType: "UNINSTALL_AGENT", payload: { serviceName: "truckwash-edge-agent.service", }, }, fakeFetch, { waitImpl: async () => {}, exitProcessImpl: (code) => { exitCodes.push(code); }, } ); const persisted = JSON.parse(await readFile(configPath, "utf8")); assert.equal(result.ok, true); assert.equal(result.payload.uninstall_scheduled, true); assert.equal(commandResultPosts.length, 1); assert.equal(commandResultPosts[0].body.ok, true); assert.equal(commandResultPosts[0].body.payload.uninstall_scheduled, true); assert.deepEqual(exitCodes, [0]); assert.equal(persisted.gatewayId, null); assert.equal(persisted.agentToken, null); assert.equal(persisted.installToken, null); assert.equal(persisted.brokerUrl, null); assert.equal(persisted.lastUninstall.state, "SCHEDULED"); await rm(tempDir, { recursive: true, force: true }); }); test("finalizePendingUpdateOnStartup promotes the target version and clears the pending update marker", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-startup-")); const configPath = path.join(tempDir, "config.json"); const config = { apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", installDir: tempDir, installedVersion: "1.0.0", targetVersion: "1.1.0", pendingUpdate: { targetVersion: "1.1.0", previousVersion: "1.0.0", releaseChannel: "stable", backupDir: path.join(tempDir, ".updates", "backup"), }, }; await writeFile(configPath, JSON.stringify(config, null, 2)); const finalized = await finalizePendingUpdateOnStartup(config, configPath, { liveConfig: config, }); const persisted = JSON.parse(await readFile(configPath, "utf8")); assert.equal(finalized.installedVersion, "1.1.0"); assert.equal(finalized.pendingUpdate, null); assert.equal(finalized.lastUpdate.state, "COMPLETED"); assert.equal(persisted.installedVersion, "1.1.0"); assert.equal(persisted.pendingUpdate, null); assert.equal(persisted.lastUpdate.targetVersion, "1.1.0"); await rm(tempDir, { recursive: true, force: true }); }); test("verifyPendingUpdate rolls back the previous files and respawns the agent when startup verification times out", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-rollback-")); const updatesDir = path.join(tempDir, ".updates", "rollback-case"); const configPath = path.join(tempDir, "config.json"); await mkdir(updatesDir, { recursive: true }); await writeFile(path.join(tempDir, "agent.mjs"), "// broken new agent\n"); await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "broken-edge-agent" }, null, 2)); await writeFile(path.join(updatesDir, "agent.mjs"), "// old agent\n"); await writeFile(path.join(updatesDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2)); const config = { apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", installDir: tempDir, restartMode: "spawn", installedVersion: "1.0.0", targetVersion: "1.1.0", pendingUpdate: { targetVersion: "1.1.0", previousVersion: "1.0.0", releaseChannel: "stable", backupDir: updatesDir, installDir: tempDir, restartMode: "spawn", verificationTimeoutSeconds: 5, }, }; await writeFile(configPath, JSON.stringify(config, null, 2)); const execCalls = []; const fakeExecFile = async (command, args, options) => { execCalls.push({ command, args, options }); return { stdout: "{}" }; }; const spawnCalls = []; const fakeSpawn = (command, args, options) => { spawnCalls.push({ command, args, options }); return { unref() {}, }; }; const result = await verifyPendingUpdate(configPath, { execFileImpl: fakeExecFile, spawnImpl: fakeSpawn, timeoutMs: 1, waitImpl: async () => {}, verifyIntervalMs: 1, }); const persisted = JSON.parse(await readFile(configPath, "utf8")); assert.equal(result.verified, false); assert.equal(result.rolledBack, true); assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n"); assert.equal(persisted.pendingUpdate, null); assert.equal(persisted.lastUpdate.state, "ROLLED_BACK"); assert.equal(persisted.installedVersion, "1.0.0"); assert.equal(execCalls.length, 1); assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]); assert.equal(spawnCalls.length, 1); assert.equal(spawnCalls[0].command, process.execPath); assert.deepEqual(spawnCalls[0].args, [path.join(tempDir, "agent.mjs"), "--config", configPath]); await rm(tempDir, { recursive: true, force: true }); }); test("shell bridge proxies PTY output, input, resize, close, and dispose events", async () => { const messages = []; const createdPtys = []; const shell = createShellBridge( (message) => messages.push(message), { createPtyProcess: async (options) => { const listeners = { data: null, exit: null, }; const pty = { options, writes: [], resizes: [], killed: false, onData(callback) { listeners.data = callback; return { dispose() { listeners.data = null; }, }; }, onExit(callback) { listeners.exit = callback; return { dispose() { listeners.exit = null; }, }; }, write(data) { this.writes.push(data); }, resize(cols, rows) { this.resizes.push({ cols, rows }); }, kill() { this.killed = true; listeners.exit?.({ exitCode: 0 }); }, emitData(data) { listeners.data?.(data); }, }; createdPtys.push(pty); return pty; }, } ); await shell.open({ sessionId: "test-shell", shellCommand: "/bin/bash", shellArgs: ["-l"], cols: 90, rows: 24, cwd: "/tmp", }); createdPtys[0].emitData("root@pi:~# "); shell.input({ sessionId: "test-shell", data: "ls\r" }); shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 }); shell.dispose(); assert.equal(createdPtys.length, 1); assert.equal(createdPtys[0].options.command, "/bin/bash"); assert.deepEqual(createdPtys[0].options.args, ["-l"]); assert.equal(createdPtys[0].options.cols, 90); assert.equal(createdPtys[0].options.rows, 24); assert.equal(createdPtys[0].options.cwd, "/tmp"); assert.deepEqual(createdPtys[0].writes, ["ls\r"]); assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]); assert.equal(createdPtys[0].killed, true); assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell")); assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi"))); assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0)); }); test("shell bridge reports structured spawn failures", async () => { const messages = []; const shell = createShellBridge( (message) => messages.push(message), { createPtyProcess: async () => { throw new Error("node-pty unavailable"); }, } ); await shell.open({ sessionId: "spawn-failure-shell" }); assert.ok( messages.some( (message) => message.type === "SHELL_OUTPUT" && message.sessionId === "spawn-failure-shell" && /Failed to start root shell: node-pty unavailable/.test(message.data) ) ); assert.ok( messages.some( (message) => message.type === "SHELL_EXIT" && message.sessionId === "spawn-failure-shell" && message.code === 1 && message.reason === "shell_spawn_failed" && message.message === "node-pty unavailable" ) ); }); test("startAgent reports API polling metadata, executes polled commands, and uploads shell events", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-")); const configPath = path.join(tempDir, "config.json"); await writeFile(configPath, JSON.stringify({ apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", heartbeatIntervalSeconds: 0.05, commandPollTimeoutSeconds: 0, commandPollRetryDelayMs: 5, shellActionPollTimeoutSeconds: 0, shellActionPollRetryDelayMs: 5, enableShellAccess: true, })); const heartbeats = []; const commandResultPosts = []; const shellActionResults = []; const shellEventPosts = []; let polledCommandDelivered = false; let openActionDelivered = false; let closeActionDelivered = false; let metricSample = 0; const fakeFetch = async (url, options = {}) => { const body = options.body ? JSON.parse(options.body) : {}; if (String(url).endsWith("/heartbeat")) { heartbeats.push({ url, body }); return { ok: true, async json() { return { data: { ok: true } }; }, }; } if (String(url).endsWith("/commands/poll")) { if (!polledCommandDelivered) { polledCommandDelivered = true; return { ok: true, async json() { return { data: { id: 99, commandType: "DISCOVER_SHELLY", payload: { candidateIps: ["10.1.0.31"], }, }, }; }, }; } await new Promise((resolve) => setTimeout(resolve, 5)); return { ok: true, async json() { return { data: null }; }, }; } if (String(url).endsWith("/commands/99/result")) { commandResultPosts.push({ url, body }); return { ok: true, async json() { return { data: { acknowledged: true } }; }, }; } if (String(url).endsWith("/shell-actions/poll")) { if (!openActionDelivered) { openActionDelivered = true; return { ok: true, async json() { return { data: { id: 501, actionType: "OPEN", payload: { sessionId: 44, cols: 100, rows: 30, reason: "Investigate relay drift", }, }, }; }, }; } if (!closeActionDelivered) { closeActionDelivered = true; return { ok: true, async json() { return { data: { id: 502, actionType: "CLOSE", payload: { sessionId: 44, }, }, }; }, }; } await new Promise((resolve) => setTimeout(resolve, 5)); return { ok: true, async json() { return { data: null }; }, }; } if (/\/shell-actions\/\d+\/result$/.test(String(url))) { shellActionResults.push({ url, body }); return { ok: true, async json() { return { data: { acknowledged: true } }; }, }; } if (String(url).includes("/shell-sessions/44/events")) { shellEventPosts.push({ url, body }); return { ok: true, async json() { return { data: { acknowledged: true } }; }, }; } if (String(url) === "http://10.1.0.31/shelly") { return { ok: true, async json() { return { mac: "AA:BB:CC:DD:EE:FF", model: "Shelly Plus 1PM", num_switches: 1, }; }, }; } throw new Error(`Unexpected URL: ${url}`); }; const collectMetricsImpl = async ({ latencyMs }) => { metricSample += 1; return { cpuSnapshot: { idle: metricSample, total: metricSample + 10 }, metrics: { latency_ms: latencyMs, cpu_usage_pct: 27.5, memory_usage_pct: 48.2, memory_used_bytes: 2048, memory_total_bytes: 4096, disk_usage_pct: 61.4, disk_used_bytes: 8192, disk_total_bytes: 16384, disk_mount: "/", }, }; }; const shellCalls = []; const createShellBridgeImpl = (sendMessage) => ({ async open(payload) { shellCalls.push({ type: "open", payload }); sendMessage({ type: "SHELL_OPENED", sessionId: payload.sessionId }); sendMessage({ type: "SHELL_OUTPUT", sessionId: payload.sessionId, data: "root@pi:~# " }); }, input(payload) { shellCalls.push({ type: "input", payload }); }, resize(payload) { shellCalls.push({ type: "resize", payload }); }, close(payload) { shellCalls.push({ type: "close", payload }); sendMessage({ type: "SHELL_EXIT", sessionId: payload.sessionId, code: 0 }); }, dispose() { shellCalls.push({ type: "dispose" }); }, }); let agent = null; try { agent = await startAgent({ configPath, fetchImpl: fakeFetch, collectMetricsImpl, createShellBridgeImpl, }); assert.equal(heartbeats[0].body.status, "ONLINE"); assert.equal(heartbeats[0].body.metadata.command_transport, "API_POLLING"); assert.equal(heartbeats[0].body.metadata.shell_transport, "API_POLLING"); assert.equal(heartbeats[0].body.metadata.system_metrics.cpu_usage_pct, 27.5); assert.equal(heartbeats[0].body.metadata.system_metrics.memory_usage_pct, 48.2); assert.equal(heartbeats[0].body.metadata.system_metrics.disk_usage_pct, 61.4); assert.equal(heartbeats[0].body.metadata.system_metrics.latency_ms, null); assert.equal(heartbeats[0].body.metadata.broker_connected, false); assert.equal(heartbeats[0].body.metadata.broker_url, null); assert.equal("discovery_status" in heartbeats[0].body, false); await waitFor( () => commandResultPosts.length === 1 && shellActionResults.length === 2 && shellEventPosts.length > 0 && heartbeats.some( (heartbeat) => heartbeat.body.metadata.system_metrics && typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" ), { timeoutMs: 1000, description: "agent polling activity and follow-up heartbeat" } ); assert.equal(commandResultPosts.length, 1); assert.equal(commandResultPosts[0].body.ok, true); assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true); assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF"); assert.equal(commandResultPosts[0].body.payload.inventory[0].capabilities.generation, 2); assert.ok(shellCalls.some((call) => call.type === "open")); assert.ok(shellCalls.some((call) => call.type === "close")); assert.equal(shellActionResults.length, 2); assert.ok(shellActionResults.every((result) => result.body.ok === true)); assert.ok( shellEventPosts.some((request) => request.body.events.some((event) => event.type === "OPENED") ) ); assert.ok( shellEventPosts.some((request) => request.body.events.some((event) => event.type === "OUTPUT") ) ); assert.ok( shellEventPosts.some((request) => request.body.events.some((event) => event.type === "CLOSED") ) ); assert.ok( heartbeats.some( (heartbeat) => heartbeat.body.metadata.system_metrics && typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" ) ); } finally { await agent?.stop(); await rm(tempDir, { recursive: true, force: true }); } }); test("processPolledShellAction denies shell access when locally disabled", async () => { const submissions = []; const fakeFetch = async (url, options = {}) => { if (/\/shell-actions\/\d+\/result$/.test(String(url))) { submissions.push({ url, body: JSON.parse(options.body) }); return { ok: true, async json() { return { data: { acknowledged: true } }; }, }; } throw new Error(`Unexpected URL: ${url}`); }; const shell = { async open() { throw new Error("should not run"); }, input() { throw new Error("should not run"); }, resize() { throw new Error("should not run"); }, close() { throw new Error("should not run"); }, }; const result = await processPolledShellAction( { apiUrl: "https://api.example.test", gatewayId: 42, agentToken: "agent-token", enableShellAccess: false, }, { id: 501, actionType: "OPEN", payload: { sessionId: 44, }, }, shell, fakeFetch ); assert.equal(result.ok, false); assert.match(result.error, /Shell access is disabled/); assert.equal(submissions.length, 1); assert.equal(submissions[0].body.ok, false); }); test("status helpers report config without exposing the agent token", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-")); const configPath = path.join(tempDir, "config.json"); await writeFile(configPath, JSON.stringify({ apiUrl: "https://api.example.test", installToken: "install-token", gatewayId: 42, agentToken: "agent-token", installedVersion: "1.2.3", targetVersion: "1.2.4", releaseChannel: "stable", brokerUrl: "https://broker.example.test", heartbeatIntervalSeconds: 30, })); const report = await getAgentStatus(configPath); const built = buildStatusReport(configPath, { apiUrl: "https://api.example.test", installToken: "install-token", gatewayId: 42, agentToken: "agent-token", brokerUrl: "https://broker.example.test", }); assert.equal(report.command, "status"); assert.equal(report.configPath, configPath); assert.equal(report.claimed, true); assert.equal(report.state, "CLAIMED"); assert.equal(report.gatewayId, 42); assert.equal(report.transport, "HYBRID"); assert.equal(report.brokerUrl, "https://broker.example.test"); assert.equal(report.hasInstallToken, true); assert.equal("agentToken" in report, false); assert.equal(built.state, "CLAIMED"); assert.equal(built.transport, "HYBRID"); assert.equal(built.brokerUrl, "https://broker.example.test"); assert.equal("agentToken" in built, false); await rm(tempDir, { recursive: true, force: true }); }); test("parseCliArgs understands the status command and config flag", () => { assert.deepEqual( parseCliArgs(["status", "--config", "/tmp/config.json"]), { command: "status", configPath: "/tmp/config.json" } ); assert.deepEqual( parseCliArgs(["--config", "/tmp/config.json"]), { command: "start", configPath: "/tmp/config.json" } ); }); test("runCli emits a status report for the status command", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-cli-")); const configPath = path.join(tempDir, "config.json"); await writeFile(configPath, JSON.stringify({ apiUrl: "https://api.example.test", installToken: "install-token", })); let stdout = ""; const originalWrite = process.stdout.write.bind(process.stdout); process.stdout.write = ((chunk, encoding, callback) => { stdout += String(chunk); if (typeof encoding === "function") { encoding(); } else if (typeof callback === "function") { callback(); } return true; }); try { const report = await runCli(["status", "--config", configPath]); assert.equal(report.state, "PENDING_CLAIM"); const parsed = JSON.parse(stdout); assert.equal(parsed.command, "status"); assert.equal(parsed.claimed, false); assert.equal(parsed.transport, "API_POLLING"); assert.equal("brokerWsUrl" in parsed, false); } finally { process.stdout.write = originalWrite; } await rm(tempDir, { recursive: true, force: true }); }); test("agent CLI status command prints the report", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-process-")); const configPath = path.join(tempDir, "config.json"); await writeFile(configPath, JSON.stringify({ apiUrl: "https://api.example.test", installToken: "install-token", gatewayId: 7, agentToken: "secret-token", })); const { stdout } = await execFile( process.execPath, [agentEntryPath, "status", "--config", configPath], { windowsHide: true, encoding: "utf8" } ); const parsed = JSON.parse(stdout); assert.equal(parsed.command, "status"); assert.equal(parsed.gatewayId, 7); assert.equal(parsed.claimed, true); assert.equal(parsed.transport, "API_POLLING"); assert.equal("agentToken" in parsed, false); await rm(tempDir, { recursive: true, force: true }); });