diff --git a/.project-docs/30-worklog/tasks/20260831-diagnose-attachment-dns-7e41c9a2.md b/.project-docs/30-worklog/tasks/20260831-diagnose-attachment-dns-7e41c9a2.md new file mode 100644 index 0000000..51b1a34 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-diagnose-attachment-dns-7e41c9a2.md @@ -0,0 +1,54 @@ +# Task: Diagnose attachment DNS download failure + +## Identity + +- Task ID: 20260831-diagnose-attachment-dns-7e41c9a2 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/lwltAPI +- Base commit: 0da8b4f959032249b0e86f82db2aeff545fa7e82 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Inspect the user-supplied production control-plane log excerpt for the current internal AgentBus roster attachment failure. +- Correlate the structured attachment milestones and stack trace with `control-plane/src/input-attachment.ts`. +- Correct the pinned DNS `lookup` callback so it follows both Node single-address and `all: true` callback contracts. +- Add focused regression coverage and run the full repository gates. + +## Intent And Constraints + +- Preserve accepted internal-network behavior: private, reserved, localhost, and internal DNS attachment targets remain allowed. +- Preserve credential-free HTTPS, DNS resolution and pinning, redirect revalidation, timeout, byte limits, declared-size validation, optional SHA-256 verification, and privacy-safe diagnostics. +- Do not log or persist attachment URLs, hostnames, IP addresses, file bytes, message text, or roster values. +- Do not deploy, restart services, mutate Kubernetes, read secrets, access ERP, retry a live task, or send an external message. + +## Outcome + +- Confirmed from the supplied production log that the AgentBus frame was correlated correctly, one structured `.xls` attachment was accepted, metadata validation passed, and DNS resolution returned eight IPv4 addresses. The failure occurred only when the HTTPS connection consumed the selected pinned address. +- Root cause is the custom `lookup` callback shape, not private-network rejection or DNS failure. Node invokes connection lookup with `options.all = true`; the old callback returned `(null, addressString, family)`, while that mode requires `(null, [{ address, family }])`. Node consequently raised `ERR_INVALID_IP_ADDRESS` before any HTTP response. +- Reproduced the same failure locally with the bundled Node runtime: the request passed `{ all: true }`, the old scalar callback produced `ERR_INVALID_IP_ADDRESS`, and the stack matched the production failure boundary. +- Added `createPinnedAttachmentLookup()`. It returns a single-element array for `all: true` and the scalar address/family pair otherwise, retaining a single previously validated address as the only connection candidate. +- Replaced the unsafe `as never` callback cast with the typed lookup function and added regression coverage for both callback forms. +- No internal-address blocking was restored or added. No deployment, restart, Kubernetes mutation, secret read, ERP access, live-task retry, or external message occurred. + +## Verification + +- Focused input-attachment tests: 8/8 passed, including the new Node lookup callback-shape regression. +- `node --run check:repo`: 9/9 passed. +- `node --run check`: passed. +- `node --run test:control-plane`: 136/136 passed. +- `node --run test:legacy`: 255/255 passed. +- `node --run build`: passed. +- `check_project_docs.py`: passed. +- `git diff --check`: passed. + +## Follow-ups + +- The fix is not active on the server until a separately authorized image build/deployment/restart publishes the new revision. +- After deployment, verify a real internal roster attachment reaches `agentbus.attachment_http_response` and `agentbus.attachment_download_completed`; `DEPLOYMENT_REVISION` should no longer be `unknown`. + +## Promotion Candidates + +- None. This fixes an implementation defect while preserving the already accepted `NETWORK-001` behavior and security boundaries. diff --git a/control-plane/src/input-attachment.ts b/control-plane/src/input-attachment.ts index 892eb2d..7750b1a 100644 --- a/control-plane/src/input-attachment.ts +++ b/control-plane/src/input-attachment.ts @@ -1,6 +1,6 @@ import { lookup as dnsLookup } from 'node:dns/promises'; import { request as httpsRequest } from 'node:https'; -import { isIP } from 'node:net'; +import { isIP, type LookupFunction } from 'node:net'; import { basename } from 'node:path'; import { sha256Bytes } from './crypto.js'; import { diagnosticDurationMs } from './diagnostics.js'; @@ -207,6 +207,18 @@ export async function resolveAgentBusAttachmentAddresses( return addresses; } +export function createPinnedAttachmentLookup( + address: { address: string; family: number } +): LookupFunction { + return (_hostname, options, callback) => { + if (options.all) { + callback(null, [address]); + return; + } + callback(null, address.address, address.family); + }; +} + async function downloadPinnedHttps( url: URL, address: { address: string; family: number }, @@ -217,9 +229,7 @@ async function downloadPinnedHttps( method: 'GET', headers: { Accept: 'application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/octet-stream' }, timeout: DOWNLOAD_TIMEOUT_MS, - lookup: ((_hostname: string, _options: unknown, callback: (error: NodeJS.ErrnoException | null, address: string, family: number) => void) => { - callback(null, address.address, address.family); - }) as never + lookup: createPinnedAttachmentLookup(address) }, (response) => { const statusCode = Number(response.statusCode || 0); const location = Array.isArray(response.headers.location) ? response.headers.location[0] : response.headers.location; diff --git a/control-plane/test/input-attachment.test.ts b/control-plane/test/input-attachment.test.ts index c7459f0..ca10533 100644 --- a/control-plane/test/input-attachment.test.ts +++ b/control-plane/test/input-attachment.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { sha256Bytes } from '../src/crypto.js'; import { InputAttachmentError, + createPinnedAttachmentLookup, decodeInlineInputAttachment, downloadAgentBusInputAttachment, parseAgentBusInputAttachment, @@ -90,6 +91,35 @@ test('AgentBus attachment DNS accepts internal names and private literal address assert.ok(localhost.every((entry) => entry.family === 4 || entry.family === 6)); }); +test('AgentBus attachment DNS pinning follows the Node lookup callback shape', async () => { + const address = { address: '10.0.0.8', family: 4 }; + const lookup = createPinnedAttachmentLookup(address); + await new Promise((resolve, reject) => { + lookup('internal.example', { all: true }, (error, addresses, family) => { + try { + assert.ifError(error); + assert.deepEqual(addresses, [address]); + assert.equal(family, undefined); + resolve(); + } catch (assertionError) { + reject(assertionError); + } + }); + }); + await new Promise((resolve, reject) => { + lookup('internal.example', { all: false }, (error, resolvedAddress, family) => { + try { + assert.ifError(error); + assert.equal(resolvedAddress, address.address); + assert.equal(family, address.family); + resolve(); + } catch (assertionError) { + reject(assertionError); + } + }); + }); +}); + test('AgentBus attachment diagnostics expose stages and codes without URL data', async () => { const events: Array<{ event: string; metadata: Record }> = []; await assert.rejects(