diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index aca5821e..83e5f527 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -142,7 +142,7 @@ test("device pod executor lists and isolates multiple logical device pods", asyn }); test("device-pod executor bounds gateway output text", async () => { - const longText = "z".repeat(25000); + const longText = "z".repeat(65000); const cloudApi = createServer(async (request, response) => { const body = await requestJson(request); response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); @@ -184,11 +184,11 @@ test("device-pod executor bounds gateway output text", async () => { }); assert.equal(jobResponse.status, 202); const output = await waitForJobStatus(service.port, "device-pod-test", "job_bounded_output_test", "completed"); - assert.equal(output.output.text.length, 24000); - assert.equal(output.text.length, 24000); - assert.equal(output.bytes, 24000); + assert.equal(output.output.text.length, 64000); + assert.equal(output.text.length, 64000); + assert.equal(output.bytes, 64000); assert.equal(output.truncation.truncated, true); - assert.equal(output.truncation.originalBytes, 25000); + assert.equal(output.truncation.originalBytes, 65000); assert.equal(output.output.omitted.reason, "device_job_output_truncated"); assert.equal(JSON.stringify(output).includes(longText), false); } finally { diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index e0ca69d0..7997fe81 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -15,7 +15,7 @@ const environment = process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PR const cloudApiInternalUrl = normalizeBaseUrl(process.env.HWLAB_CLOUD_API_INTERNAL_URL || process.env.HWLAB_CLOUD_API_URL); const internalToken = textOr(process.env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); const dispatchTimeoutMs = numberOr(process.env.HWLAB_DEVICE_POD_GATEWAY_DISPATCH_TIMEOUT_MS, 120000); -const DEVICE_JOB_OUTPUT_MAX_BYTES = 24000; +const DEVICE_JOB_OUTPUT_MAX_BYTES = 64000; const jobs = new Map(); listen(createServer(async (request, response) => { diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index e9487609..0a5d8e0f 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -2055,7 +2055,7 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe }); test("cloud api bounds device-pod job output payloads", async () => { - const longText = "x".repeat(25000); + const longText = "x".repeat(65000); const executor = createServer(async (request, response) => { const body = await requestJson(request); response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); @@ -2100,11 +2100,11 @@ test("cloud api bounds device-pod job output payloads", async () => { assert.equal(job.status, 200); const output = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${job.body.job.id}/output`, aliceLogin.cookie); assert.equal(output.status, 200); - assert.equal(output.body.bytes, 24000); - assert.equal(output.body.text.length, 24000); - assert.equal(output.body.output.text.length, 24000); + assert.equal(output.body.bytes, 64000); + assert.equal(output.body.text.length, 64000); + assert.equal(output.body.output.text.length, 64000); assert.equal(output.body.truncation.truncated, true); - assert.equal(output.body.truncation.originalBytes, 25000); + assert.equal(output.body.truncation.originalBytes, 65000); assert.equal(output.body.output.omitted.reason, "device_job_output_truncated"); assert.equal(JSON.stringify(output.body).includes(longText), false); } finally { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 962223c9..f3d4d77b 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -202,7 +202,7 @@ const DEVICE_JOB_ACTIONABLE_INTENTS = new Set([ "workspace.build", "debug.download" ]); -const DEVICE_JOB_OUTPUT_MAX_BYTES = 24000; +const DEVICE_JOB_OUTPUT_MAX_BYTES = 64000; const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu; const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u; diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 0c333e78..dabc86c5 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -290,6 +290,7 @@ test("device-pod-cli bootsharp creates a formal workspace bootsharp REST job", a }); test("device-pod-cli job output is compact by default and full with --full", async () => { + const longText = `${"x".repeat(30000)}FREQ_Controller_FW.axf${"y".repeat(30000)}FREQ_Controller_FW.hex${"z".repeat(30000)}`; const payload = { serviceId: "hwlab-cloud-api", contractVersion: "device-pod-job-v1", @@ -299,9 +300,9 @@ test("device-pod-cli job output is compact by default and full with --full", asy operationId: "op_job", job: { id: "job_1", status: "completed" }, output: { - text: "x".repeat(5000), + text: longText, dispatch: { command: "verbose command that should stay out of compact output" }, - bytes: 5000, + bytes: Buffer.byteLength(longText, "utf8"), summary: "gateway/device-host-cli dispatch completed" } }; @@ -313,7 +314,10 @@ test("device-pod-cli job output is compact by default and full with --full", asy ], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" }); assert.equal(compact.exitCode, 0); assert.equal(compact.payload.body.compacted, true); - assert.equal(compact.payload.body.text.length, 4000); + assert.equal(compact.payload.body.text.length, 64000); + assert.equal(compact.payload.body.text.includes("device-pod job output compacted"), true); + assert.equal(compact.payload.body.text.includes("FREQ_Controller_FW.hex"), true); + assert.equal(compact.payload.body.text.includes("FREQ_Controller_FW.axf"), true); assert.equal(JSON.stringify(compact.payload).includes("verbose command"), false); const full = await runAssembledDevicePodCli([ diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index 9a0e609a..be80cde8 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -766,9 +766,9 @@ function compactJobOutputBody(body: any) { if (!body || typeof body !== "object") return body; const output = body.output && typeof body.output === "object" ? body.output : {}; const textValue = typeof body.text === "string" ? body.text : typeof output.text === "string" ? output.text : ""; - const maxTextBytes = 4000; + const maxTextBytes = 64000; const textBuffer = Buffer.from(textValue, "utf8"); - const clippedText = textBuffer.length > maxTextBytes ? textBuffer.subarray(0, maxTextBytes).toString("utf8") : textValue; + const clippedText = compactTextPreservingTail(textBuffer, textValue, maxTextBytes); return clean({ serviceId: body.serviceId, contractVersion: body.contractVersion, @@ -800,3 +800,17 @@ function compactJobOutputBody(body: any) { } }); } + +function compactTextPreservingTail(textBuffer: Buffer, textValue: string, maxTextBytes: number) { + if (textBuffer.length <= maxTextBytes) return textValue; + const marker = "\n... device-pod job output compacted; middle omitted ...\n"; + const markerBuffer = Buffer.from(marker, "utf8"); + if (markerBuffer.length >= maxTextBytes) return clipUtf8(textBuffer, 0, maxTextBytes); + const headBytes = Math.floor((maxTextBytes - markerBuffer.length) / 2); + const tailBytes = maxTextBytes - markerBuffer.length - headBytes; + return `${clipUtf8(textBuffer, 0, headBytes)}${marker}${clipUtf8(textBuffer, textBuffer.length - tailBytes, textBuffer.length)}`; +} + +function clipUtf8(buffer: Buffer, start: number, end: number) { + return buffer.subarray(start, end).toString("utf8").replace(/\uFFFD$/u, "").replace(/^\uFFFD/u, ""); +}