From 8efb254d49be3c55ac1bf1562ab4bcc1c8d4694a Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Thu, 1 Oct 2026 01:50:16 -0700 Subject: [PATCH] feat(runtime): require metered model execution Require hosted gateway credentials and the server output cap at startup, then remove the credential from the process environment. Verify the real SDK, WebSocket and restart paths through local acceptance fixtures, preserve required distribution inputs, and inspect compressed image layers without hiding prohibited earlier-layer files. --- Dockerfile.runtime | 2 +- docs/cloud-runtime.md | 9 +- packages/coding-agent-server/README.md | 9 +- packages/coding-agent-server/src/cli.ts | 19 +- .../test/gateway-runtime-process.test.ts | 203 ++++++++++++++++++ scripts/check-runtime-image.py | 2 +- scripts/fixtures/runtime-fixture.mjs | 89 +++----- scripts/fixtures/runtime-smoke.mjs | 10 +- scripts/runtime-image.test.mjs | 23 +- 9 files changed, 292 insertions(+), 74 deletions(-) create mode 100644 packages/coding-agent-server/test/gateway-runtime-process.test.ts diff --git a/Dockerfile.runtime b/Dockerfile.runtime index bf2275985..fb1de95b3 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -6,7 +6,7 @@ ARG SOURCE_COMMIT ENV DIFFEX_SOURCE_COMMIT=$SOURCE_COMMIT COPY package.json package-lock.json tsconfig.base.json ./ -COPY LICENSE ./ +COPY LICENSE AGENTS_release.md ./ COPY legal ./legal COPY docs/release/distribution-policy.json ./docs/release/distribution-policy.json COPY packages ./packages diff --git a/docs/cloud-runtime.md b/docs/cloud-runtime.md index 2626023bc..5ce3a5b17 100644 --- a/docs/cloud-runtime.md +++ b/docs/cloud-runtime.md @@ -19,8 +19,11 @@ The cloud relay preserves byte order, applies backpressure and limits, and does The WorkOS access token stays at the control-plane boundary. Provider credentials, sandbox identifiers, and internal runtime addresses never enter the Diffex wire protocol. -The production model credential is mounted through Daytona Secrets with a provider-host allowlist. -Only Daytona's opaque placeholder enters the sandbox environment, and the real provider credential is substituted into outbound HTTPS headers outside the sandbox. +Hosted inference uses the authenticated cloud model gateway. +The runtime receives a session-scoped gateway credential and URL, while provider credentials remain in the gateway host. +Every model call, including compaction, branch summaries, and child agents, uses the same gateway transport. +The transport saves a private operation UUID and request digest before sending, then disables automatic provider and agent retries after a failure. +Pending records survive restart for diagnosis and are never automatically regenerated or replayed. ## Runtime Image @@ -45,6 +48,8 @@ The production runtime service must: - Convert runtime events through the existing protocol adapters - Reject conflicting session mutations instead of silently queuing them - Shut down cleanly when the sandbox receives a termination signal +- Receive `DIFFEX_MODEL_GATEWAY_URL` and `DIFFEX_MODEL_GATEWAY_CREDENTIAL`, and reject direct provider-key bootstrap +- Persist private model-operation records under `DIFFEX_RUNTIME_STATE_DIR` ## Compatibility diff --git a/packages/coding-agent-server/README.md b/packages/coding-agent-server/README.md index 8bd5a3f84..2a20d0d3b 100644 --- a/packages/coding-agent-server/README.md +++ b/packages/coding-agent-server/README.md @@ -12,7 +12,12 @@ It does not own WorkOS authentication, billing, cloud policy, sandbox allocation The `diffex-runtime-server` executable starts the service with an authenticated WebSocket listener. The cloud bootstrap supplies `DIFFEX_SESSION_ID`, `DIFFEX_WORKSPACE`, and an internal `DIFFEX_RUNTIME_SHARED_SECRET` of at least 32 bytes. -It also supplies `DIFFEX_MODEL_PROVIDER`, `DIFFEX_MODEL_ID`, and `DIFFEX_MODEL_API_KEY` through the sandbox provider's secret-injection mechanism. -The environment value is an opaque Daytona placeholder, the real provider key never enters the sandbox, and the placeholder is removed from the runtime process environment after the in-memory model client is configured. +It also supplies `DIFFEX_MODEL_PROVIDER`, `DIFFEX_MODEL_ID`, `DIFFEX_MODEL_MAX_OUTPUT_TOKENS`, `DIFFEX_MODEL_GATEWAY_URL`, and `DIFFEX_MODEL_GATEWAY_CREDENTIAL`. +The gateway credential authorizes only this cloud session's model calls and is removed from the process environment after the in-memory transport is configured. +The process rejects a direct provider API key and uses a single configured Anthropic model. +Before each model request, the transport persists its operation UUID and request digest in a private directory under `DIFFEX_RUNTIME_STATE_DIR`. +Provider and agent retries are disabled for gateway failures, including timeouts and dropped streams, because automatically starting another generation could duplicate an already dispatched operation. +Pending operation metadata survives restart for diagnostics without replaying the original request. Completed model usage is written to a durable sandbox outbox before it is reported to `DIFFEX_USAGE_ENDPOINT`. +These sandbox usage reports are telemetry; the cloud gateway owns authoritative token measurements. Public WorkOS tokens and cloud attachment tickets are consumed by the control plane and are never passed to this process. diff --git a/packages/coding-agent-server/src/cli.ts b/packages/coding-agent-server/src/cli.ts index ec40a2658..9150566f9 100644 --- a/packages/coding-agent-server/src/cli.ts +++ b/packages/coding-agent-server/src/cli.ts @@ -19,8 +19,20 @@ const path = process.env.DIFFEX_RUNTIME_PATH ?? "/runtime"; const agentDir = process.env.DIFFEX_AGENT_DIR; const modelProvider = requiredEnvironment("DIFFEX_MODEL_PROVIDER"); const modelId = requiredEnvironment("DIFFEX_MODEL_ID"); -const modelApiKey = requiredEnvironment("DIFFEX_MODEL_API_KEY"); +const runtimeStateDirectory = requiredEnvironment("DIFFEX_RUNTIME_STATE_DIR"); +const gatewayUrl = requiredEnvironment("DIFFEX_MODEL_GATEWAY_URL"); +const gatewayCredential = requiredEnvironment("DIFFEX_MODEL_GATEWAY_CREDENTIAL"); +const maxOutputTokens = Number(requiredEnvironment("DIFFEX_MODEL_MAX_OUTPUT_TOKENS")); +if (process.env.DIFFEX_MODEL_API_KEY) throw new Error("Hosted runtime cannot receive a direct provider API key"); const modelRuntime = await ModelRuntime.create({ + hostedGateway: { + baseUrl: gatewayUrl, + credential: gatewayCredential, + provider: modelProvider, + modelId, + maxOutputTokens, + stateDirectory: runtimeStateDirectory, + }, ...(agentDir ? { authPath: join(agentDir, "auth.json"), @@ -29,10 +41,9 @@ const modelRuntime = await ModelRuntime.create({ : {}), refreshOnCreate: false, }); -await modelRuntime.setRuntimeApiKey(modelProvider, modelApiKey); -delete process.env.DIFFEX_MODEL_API_KEY; +await modelRuntime.refresh({ allowNetwork: false }); +delete process.env.DIFFEX_MODEL_GATEWAY_CREDENTIAL; delete process.env.DIFFEX_RUNTIME_SHARED_SECRET; -const runtimeStateDirectory = requiredEnvironment("DIFFEX_RUNTIME_STATE_DIR"); const usageReporter = createDurableUsageReporter({ endpoint: requiredEnvironment("DIFFEX_USAGE_ENDPOINT"), sharedSecret, diff --git a/packages/coding-agent-server/test/gateway-runtime-process.test.ts b/packages/coding-agent-server/test/gateway-runtime-process.test.ts new file mode 100644 index 000000000..ac24b0e59 --- /dev/null +++ b/packages/coding-agent-server/test/gateway-runtime-process.test.ts @@ -0,0 +1,203 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DiffexClient } from "@diffexai/diffex-client"; +import { createWebSocketTransportFactory } from "@diffexai/diffex-client/websocket"; +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; + +const sessionId = "2f1c9e00-aaaa-4000-8000-000000000001"; +const credential = "runtime-gateway-local-fixture"; +const secret = "runtime-protocol-local-fixture-32-bytes"; + +function completion(): string { + const events = [ + { + type: "message_start", + message: { + id: "msg_runtime_fixture", + type: "message", + role: "assistant", + model: "claude-sonnet-4-5", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "gateway-runtime-ok" } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, stop_details: null }, + usage: { output_tokens: 3 }, + }, + { type: "message_stop" }, + ]; + return events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""); +} + +describe("hosted gateway runtime process", () => { + test("uses gateway authentication over native WebSocket, preserves pending calls, and reopens without redispatch", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "gateway-runtime-process-"))); + for (const name of ["workspace", "agent", "state", "sessions"]) await mkdir(join(root, name)); + const requests: Array<{ operationId: string; body: Record }> = []; + let responseMode = "complete"; + const gateway = createServer(async (request, response) => { + try { + if (request.url === "/usage") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"accepted":true}'); + return; + } + expect(request.url).toBe(`/v1/runtime/sessions/${sessionId}/model/v1/messages`); + expect(request.headers["x-api-key"]).toBe(credential); + expect(request.headers.authorization).toBeUndefined(); + const operationId = String(request.headers["x-diffex-operation-id"]); + const record = JSON.parse( + await readFile(join(root, "state", "model-operations", `${operationId}.json`), "utf8"), + ); + expect(record.operationId).toBe(operationId); + let body = ""; + for await (const chunk of request) body += chunk; + requests.push({ operationId, body: JSON.parse(body) }); + if (responseMode === "complete") { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(completion()); + } else { + response.writeHead(503, { "content-type": "application/json" }); + response.end('{"type":"error","error":{"type":"api_error","message":"gateway_unresolved"}}'); + } + } catch (error) { + response.writeHead(500); + response.end(String(error)); + } + }); + gateway.listen(0, "127.0.0.1"); + await once(gateway, "listening"); + const gatewayAddress = gateway.address(); + if (!gatewayAddress || typeof gatewayAddress === "string") throw new Error("Missing gateway address"); + const reservation = createServer(); + reservation.listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const runtimeAddress = reservation.address(); + if (!runtimeAddress || typeof runtimeAddress === "string") throw new Error("Missing runtime address"); + await new Promise((resolve, reject) => reservation.close((error) => (error ? reject(error) : resolve()))); + let child: ChildProcess | undefined; + let exit: Promise | undefined; + const launch = async (): Promise => { + child = spawn(process.execPath, [fileURLToPath(new URL("../dist/cli.js", import.meta.url))], { + cwd: join(root, "workspace"), + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: process.env.PATH, + HOME: root, + DIFFEX_OFFLINE: "1", + HOST: "127.0.0.1", + PORT: String(runtimeAddress.port), + DIFFEX_SESSION_ID: sessionId, + DIFFEX_WORKSPACE: join(root, "workspace"), + DIFFEX_AGENT_DIR: join(root, "agent"), + DIFFEX_CODING_AGENT_DIR: join(root, "agent"), + DIFFEX_RUNTIME_STATE_DIR: join(root, "state"), + DIFFEX_SESSION_DIR: join(root, "sessions"), + DIFFEX_RUNTIME_SHARED_SECRET: secret, + DIFFEX_OWNER_USER_ID: "fixture-user", + DIFFEX_ORG_ID: "fixture-org", + DIFFEX_MODEL_PROVIDER: "anthropic", + DIFFEX_MODEL_ID: "claude-sonnet-4-5", + DIFFEX_MODEL_MAX_OUTPUT_TOKENS: "8192", + DIFFEX_MODEL_GATEWAY_URL: `http://127.0.0.1:${gatewayAddress.port}/v1/runtime/sessions/${sessionId}/model`, + DIFFEX_MODEL_GATEWAY_CREDENTIAL: credential, + DIFFEX_USAGE_ENDPOINT: `http://127.0.0.1:${gatewayAddress.port}/usage`, + }, + }); + exit = once(child, "exit"); + await new Promise((resolve, reject) => { + let output = ""; + child?.once("error", reject); + child?.once("exit", () => reject(new Error(`Runtime exited before readiness: ${output}`))); + child?.stderr?.on("data", (chunk) => { + output += chunk; + }); + child?.stdout?.on("data", (chunk) => { + output += chunk; + if (output.includes("Diffex runtime server listening")) resolve(); + }); + }); + }; + const stop = async (): Promise => { + child?.kill("SIGTERM"); + expect(await exit).toEqual([0, null]); + }; + const connect = (candidate: string) => + DiffexClient.connect({ + transportFactory: createWebSocketTransportFactory({ + url: `ws://127.0.0.1:${runtimeAddress.port}/runtime`, + webSocket: class extends WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols, { headers: { authorization: `Bearer ${candidate}` } }); + } + } as never, + }), + }); + try { + await launch(); + await expect(connect("wrong")).rejects.toThrow(); + const client = await connect(secret); + try { + await expect(client.attachSession("another-session")).rejects.toThrow(); + const session = await client.attachSession(sessionId); + await expect(session.prompt("Reply with fixture text")).resolves.toMatchObject({ + phase: "idle", + transcript: expect.arrayContaining([ + expect.objectContaining({ + role: "assistant", + content: [{ type: "text", text: "gateway-runtime-ok" }], + status: "complete", + usage: expect.objectContaining({ input: 10, output: 3 }), + }), + ]), + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.body).toMatchObject({ model: "claude-sonnet-4-5", stream: true, max_tokens: 8192 }); + responseMode = "unresolved"; + const failed = await session.prompt("A distinct second call"); + expect(failed.transcript.at(-1)).toMatchObject({ + status: "error", + errorMessage: expect.stringContaining("gateway_unresolved"), + }); + expect(requests).toHaveLength(2); + expect(requests[0]?.operationId).not.toBe(requests[1]?.operationId); + expect(await readdir(join(root, "state", "model-operations"))).toHaveLength(1); + await session.dispose(); + } finally { + await client.dispose(); + } + await stop(); + await launch(); + const restored = await connect(secret); + try { + const session = await restored.attachSession(sessionId); + expect(session.snapshot?.transcript.at(-1)).toMatchObject({ status: "error" }); + expect(requests).toHaveLength(2); + await session.dispose(); + } finally { + await restored.dispose(); + } + await stop(); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await exit; + } + await new Promise((resolve, reject) => gateway.close((error) => (error ? reject(error) : resolve()))); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/check-runtime-image.py b/scripts/check-runtime-image.py index 5a5acd03d..46816d244 100644 --- a/scripts/check-runtime-image.py +++ b/scripts/check-runtime-image.py @@ -27,7 +27,7 @@ def inspect_image(filename): if config['architecture'] != 'amd64' or config['os'] != 'linux': raise ValueError('Expected Linux amd64 runtime') for layer in manifest[0]['Layers']: - with tarfile.open(fileobj=image.extractfile(layer), mode='r|') as archive: + with tarfile.open(fileobj=image.extractfile(layer), mode='r|*') as archive: for entry in archive: name = entry.name.removeprefix('./') if not name.startswith('opt/diffex/') or not entry.isfile(): diff --git a/scripts/fixtures/runtime-fixture.mjs b/scripts/fixtures/runtime-fixture.mjs index 635c78ad6..e2262739b 100644 --- a/scripts/fixtures/runtime-fixture.mjs +++ b/scripts/fixtures/runtime-fixture.mjs @@ -1,8 +1,7 @@ // Mounted into acceptance containers only; never included in an image layer. -import { appendFileSync } from "node:fs"; +import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { Socket } from "node:net"; -import { ModelRuntime } from "/opt/diffex/packages/coding-agent/dist/index.js"; -import { createAssistantMessageEventStream } from "/opt/diffex/packages/ai/dist/index.js"; +import { join } from "node:path"; import "/opt/diffex/packages/coding-agent/dist/core/http-dispatcher.js"; const connect = Socket.prototype.connect; @@ -12,57 +11,35 @@ Socket.prototype.connect = function (...args) { if (host !== "127.0.0.1" && host !== "::1") throw new Error("Runtime smoke blocked external socket"); return connect.apply(this, args); }; -globalThis.fetch = async (input) => { - if (String(input) !== "http://127.0.0.1:1/usage") throw new Error("Runtime smoke blocked external fetch"); - appendFileSync(process.env.DIFFEX_SMOKE_EVENTS, "usage\n"); - return Response.json({ accepted: true }); -}; -const create = ModelRuntime.create.bind(ModelRuntime); -ModelRuntime.create = async (...args) => { - const runtime = await create(...args); - runtime.registerProvider("runtime-fixture", { - baseUrl: "https://fixture.invalid", - apiKey: "local-fixture", - api: "runtime-fixture", - models: [ - { - id: "fixture", - name: "Runtime fixture", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 128, - }, - ], - streamSimple(model) { - appendFileSync(process.env.DIFFEX_SMOKE_EVENTS, "inference\n"); - const stream = createAssistantMessageEventStream(); - const message = { - role: "assistant", - content: [{ type: "text", text: "runtime-fixture-ok" }], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - queueMicrotask(() => { - stream.push({ type: "start", partial: message }); - stream.push({ type: "text_delta", contentIndex: 0, delta: "runtime-fixture-ok", partial: message }); - stream.push({ type: "done", reason: "stop", message }); - stream.end(message); - }); - return stream; - }, - }); - return runtime; +globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + if (request.url === "http://127.0.0.1:1/usage") { + appendFileSync(process.env.DIFFEX_SMOKE_EVENTS, "usage\n"); + return Response.json({ accepted: true }); + } + if (request.url !== `${process.env.DIFFEX_MODEL_GATEWAY_URL}/v1/messages`) { + throw new Error("Runtime smoke blocked external fetch"); + } + if (request.headers.get("x-api-key") !== "runtime-local-gateway-fixture") { + throw new Error("Runtime smoke rejected gateway credential"); + } + const operationId = request.headers.get("x-diffex-operation-id"); + const pending = join(process.env.DIFFEX_RUNTIME_STATE_DIR, "model-operations", `${operationId}.json`); + if (!operationId || !existsSync(pending) || JSON.parse(readFileSync(pending, "utf8")).operationId !== operationId) { + throw new Error("Runtime smoke did not persist its operation before inference"); + } + appendFileSync(process.env.DIFFEX_SMOKE_EVENTS, "inference\n"); + const events = [ + { type: "message_start", message: { id: "msg_fixture", type: "message", role: "assistant", + model: process.env.DIFFEX_MODEL_ID, content: [], stop_reason: null, stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "runtime-fixture-ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null, stop_details: null }, + usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + return new Response(events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); }; diff --git a/scripts/fixtures/runtime-smoke.mjs b/scripts/fixtures/runtime-smoke.mjs index e9414a763..fc886a6b7 100644 --- a/scripts/fixtures/runtime-smoke.mjs +++ b/scripts/fixtures/runtime-smoke.mjs @@ -19,7 +19,7 @@ const env = { DIFFEX_OFFLINE: "1", HOST: "127.0.0.1", PORT: "8787", - DIFFEX_SESSION_ID: "runtime-smoke-session", + DIFFEX_SESSION_ID: "2f1c9e00-aaaa-4000-8000-000000000001", DIFFEX_WORKSPACE: join(root, "workspace"), DIFFEX_AGENT_DIR: join(root, "agent"), DIFFEX_CODING_AGENT_DIR: join(root, "agent"), @@ -28,9 +28,11 @@ const env = { DIFFEX_SESSION_DIR: join(root, "sessions"), DIFFEX_OWNER_USER_ID: "runtime-smoke-user", DIFFEX_ORG_ID: "runtime-smoke-org", - DIFFEX_MODEL_PROVIDER: "runtime-fixture", - DIFFEX_MODEL_ID: "fixture", - DIFFEX_MODEL_API_KEY: "local-unused-fixture", + DIFFEX_MODEL_PROVIDER: "anthropic", + DIFFEX_MODEL_ID: "claude-sonnet-4-5", + DIFFEX_MODEL_MAX_OUTPUT_TOKENS: "8192", + DIFFEX_MODEL_GATEWAY_URL: "http://127.0.0.1:1/v1/runtime/sessions/2f1c9e00-aaaa-4000-8000-000000000001/model", + DIFFEX_MODEL_GATEWAY_CREDENTIAL: "runtime-local-gateway-fixture", DIFFEX_USAGE_ENDPOINT: "http://127.0.0.1:1/usage", DIFFEX_SMOKE_EVENTS: events, }; diff --git a/scripts/runtime-image.test.mjs b/scripts/runtime-image.test.mjs index 5e286b660..effe44da3 100644 --- a/scripts/runtime-image.test.mjs +++ b/scripts/runtime-image.test.mjs @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; +import { gzipSync } from "node:zlib"; import { distributionDocs } from "./cli-distribution.mjs"; test("image inspection rejects separate NOTICE files and development documents in earlier layers", () => { @@ -29,15 +30,21 @@ test("image inspection rejects separate NOTICE files and development documents i config: { Labels: { "org.opencontainers.image.revision": "a".repeat(40) } }, }), ); - write("manifest.json", JSON.stringify([{ Config: "config.json", Layers: ["layer.tar", "cleanup.tar"] }])); write("cleanup/opt/diffex/packages/coding-agent/docs/.wh.development.md", ""); execFileSync("tar", ["-cf", join(directory, "cleanup.tar"), "-C", join(directory, "cleanup"), "opt"], { env: { ...process.env, COPYFILE_DISABLE: "1" }, }); - const inspect = () => { + const inspect = (compressed = false, malformed = false) => { execFileSync("tar", ["-cf", join(directory, "layer.tar"), "-C", join(directory, "layer"), "opt"], { env: { ...process.env, COPYFILE_DISABLE: "1" }, }); + const layerArchive = compressed ? "layer.tar.gz" : "layer.tar"; + const cleanupArchive = compressed ? "cleanup.tar.gz" : "cleanup.tar"; + if (compressed) { + write(layerArchive, gzipSync(malformed ? Buffer.from("invalid tar layer") : readFileSync(join(directory, "layer.tar")))); + write(cleanupArchive, gzipSync(readFileSync(join(directory, "cleanup.tar")))); + } + write("manifest.json", JSON.stringify([{ Config: "config.json", Layers: [layerArchive, cleanupArchive] }])); execFileSync( "tar", [ @@ -47,8 +54,8 @@ test("image inspection rejects separate NOTICE files and development documents i directory, "manifest.json", "config.json", - "layer.tar", - "cleanup.tar", + layerArchive, + cleanupArchive, ], { env: { ...process.env, COPYFILE_DISABLE: "1" } }, ); @@ -58,6 +65,11 @@ test("image inspection rejects separate NOTICE files and development documents i }; const valid = inspect(); assert.equal(valid.status, 0, valid.stderr); + const compressed = inspect(true); + assert.equal(compressed.status, 0, compressed.stderr); + const malformed = inspect(true, true); + assert.notEqual(malformed.status, 0); + assert.match(malformed.stderr, /ReadError/); write(`${workspace}dist/AGENTS.md`, "Private repository instructions"); const substituted = inspect(); assert.notEqual(substituted.status, 0); @@ -77,6 +89,9 @@ test("image inspection rejects separate NOTICE files and development documents i const invalid = inspect(); assert.notEqual(invalid.status, 0); assert.match(invalid.stderr, /unapproved distribution documentation/); + const compressedInvalid = inspect(true); + assert.notEqual(compressedInvalid.status, 0); + assert.match(compressedInvalid.stderr, /unapproved distribution documentation/); } finally { rmSync(directory, { recursive: true, force: true }); }