Skip to content

Commit fd0a2c5

Browse files
committed
fix: bound network responses
1 parent d90bc39 commit fd0a2c5

10 files changed

Lines changed: 264 additions & 145 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@newtype-ai/nit",
3-
"version": "0.6.17",
3+
"version": "0.6.18",
44
"description": "Version control for agent cards",
55
"type": "module",
66
"bin": {

src/cli.ts

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type { AuthProvider, NitSkillSource } from './types.js';
4444
import { formatDiff } from './diff.js';
4545
import { autoUpdate, checkForUpdate, installNitVersion, manualInstallCommand, version as nitVersion } from './update-check.js';
4646
import { loadMachineHash } from './fingerprint.js';
47+
import { fetchWithTimeout, readResponseJson } from './http.js';
4748

4849
// ANSI color helpers
4950
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
@@ -841,17 +842,12 @@ async function cmdDoctor(args: string[]) {
841842

842843
if (checkRemote) {
843844
const healthUrl = new URL('/health', info.url).toString();
844-
const controller = new AbortController();
845-
const timeout = setTimeout(() => controller.abort(), 5_000);
846-
let res: Response;
847-
try {
848-
res = await fetch(healthUrl, {
849-
signal: controller.signal,
850-
headers: { accept: 'application/json' },
851-
});
852-
} finally {
853-
clearTimeout(timeout);
854-
}
845+
const res = await fetchWithTimeout(healthUrl, {
846+
headers: { accept: 'application/json' },
847+
}, {
848+
label: 'Remote health check',
849+
timeoutMs: 5_000,
850+
});
855851

856852
if (res.ok) {
857853
add('ok', 'remote health', `${healthUrl} HTTP ${res.status}`);
@@ -867,19 +863,14 @@ async function cmdDoctor(args: string[]) {
867863

868864
if (checkPublish) {
869865
try {
870-
const controller = new AbortController();
871-
const timeout = setTimeout(() => controller.abort(), 3_000);
872-
let res: Response;
873-
try {
874-
res = await fetch('https://registry.npmjs.org/@newtype-ai/nit/latest', {
875-
signal: controller.signal,
876-
headers: { accept: 'application/json' },
877-
});
878-
} finally {
879-
clearTimeout(timeout);
880-
}
866+
const res = await fetchWithTimeout('https://registry.npmjs.org/@newtype-ai/nit/latest', {
867+
headers: { accept: 'application/json' },
868+
}, {
869+
label: 'npm latest check',
870+
timeoutMs: 3_000,
871+
});
881872
if (res.ok) {
882-
const data = (await res.json()) as { version?: string };
873+
const data = await readResponseJson<{ version?: string }>(res, 'npm latest response', 16 * 1024);
883874
const latest = data.version ?? 'unknown';
884875
add(latest === nitVersion ? 'ok' : 'warn', 'npm latest', latest);
885876
} else {
@@ -1074,14 +1065,17 @@ async function cmdWallet() {
10741065
const config = await rc(nitDir);
10751066
const rpcUrl = config.rpc?.solana?.url || 'https://api.devnet.solana.com';
10761067
{
1077-
const res = await fetch(rpcUrl, {
1068+
const res = await fetchWithTimeout(rpcUrl, {
10781069
method: 'POST',
10791070
headers: { 'Content-Type': 'application/json' },
10801071
body: JSON.stringify({
10811072
jsonrpc: '2.0', id: 1, method: 'getBalance', params: [sol],
10821073
}),
1074+
}, {
1075+
label: 'Solana balance RPC',
1076+
timeoutMs: 5_000,
10831077
});
1084-
const data = await res.json() as { result?: { value?: number } };
1078+
const data = await readResponseJson<{ result?: { value?: number } }>(res, 'Solana balance response', 64 * 1024);
10851079
if (data.result?.value !== undefined) {
10861080
const lamports = data.result.value;
10871081
solBalance = (lamports / 1_000_000_000).toFixed(4) + ' SOL';

src/http.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// ---------------------------------------------------------------------------
2+
// nit — bounded HTTP helpers
3+
// ---------------------------------------------------------------------------
4+
5+
export const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
6+
export const DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024;
7+
8+
export interface FetchWithTimeoutOptions {
9+
timeoutMs?: number;
10+
fetchImpl?: typeof fetch;
11+
label?: string;
12+
}
13+
14+
export async function fetchWithTimeout(
15+
url: string,
16+
init: RequestInit = {},
17+
options: FetchWithTimeoutOptions = {},
18+
): Promise<Response> {
19+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
20+
const label = options.label ?? 'HTTP request';
21+
const fetchImpl = options.fetchImpl ?? fetch;
22+
const controller = new AbortController();
23+
let timedOut = false;
24+
const timeout = setTimeout(() => {
25+
timedOut = true;
26+
controller.abort();
27+
}, timeoutMs);
28+
29+
try {
30+
return await fetchImpl(url, { ...init, signal: controller.signal });
31+
} catch (err) {
32+
if (timedOut) {
33+
throw new Error(`${label} timed out after ${timeoutMs}ms`);
34+
}
35+
throw err;
36+
} finally {
37+
clearTimeout(timeout);
38+
}
39+
}
40+
41+
export async function readResponseText(
42+
res: Response,
43+
label: string,
44+
maxBytes = DEFAULT_MAX_RESPONSE_BYTES,
45+
): Promise<string> {
46+
const length = res.headers.get('content-length');
47+
const parsedLength = length ? Number.parseInt(length, 10) : NaN;
48+
if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
49+
throw new Error(`${label} exceeds ${maxBytes} bytes`);
50+
}
51+
52+
if (!res.body) {
53+
const text = await res.text();
54+
if (new TextEncoder().encode(text).byteLength > maxBytes) {
55+
throw new Error(`${label} exceeds ${maxBytes} bytes`);
56+
}
57+
return text;
58+
}
59+
60+
const reader = res.body.getReader();
61+
const chunks: Uint8Array[] = [];
62+
let total = 0;
63+
64+
while (true) {
65+
const { done, value } = await reader.read();
66+
if (done) break;
67+
if (!value) continue;
68+
total += value.byteLength;
69+
if (total > maxBytes) {
70+
await reader.cancel();
71+
throw new Error(`${label} exceeds ${maxBytes} bytes`);
72+
}
73+
chunks.push(value);
74+
}
75+
76+
const bytes = new Uint8Array(total);
77+
let offset = 0;
78+
for (const chunk of chunks) {
79+
bytes.set(chunk, offset);
80+
offset += chunk.byteLength;
81+
}
82+
return new TextDecoder().decode(bytes);
83+
}
84+
85+
export async function readResponseJson<T>(
86+
res: Response,
87+
label: string,
88+
maxBytes = DEFAULT_MAX_RESPONSE_BYTES,
89+
): Promise<T> {
90+
const text = await readResponseText(res, label, maxBytes);
91+
try {
92+
return JSON.parse(text) as T;
93+
} catch {
94+
throw new Error(`${label} is not valid JSON`);
95+
}
96+
}

src/index.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
validateRemoteName,
101101
validateRpcChainName,
102102
} from './validation.js';
103+
import { fetchWithTimeout } from './http.js';
103104

104105
// Re-export types and runtime validators for consumers
105106
export { assertAgentCardShape } from './types.js';
@@ -1298,17 +1299,12 @@ export async function remoteCheck(options?: {
12981299
};
12991300

13001301
try {
1301-
const controller = new AbortController();
1302-
const timeout = setTimeout(() => controller.abort(), 5_000);
1303-
let res: Response;
1304-
try {
1305-
res = await fetch(new URL('/health', apiBase).toString(), {
1306-
signal: controller.signal,
1307-
headers: { accept: 'application/json' },
1308-
});
1309-
} finally {
1310-
clearTimeout(timeout);
1311-
}
1302+
const res = await fetchWithTimeout(new URL('/health', apiBase).toString(), {
1303+
headers: { accept: 'application/json' },
1304+
}, {
1305+
label: 'Remote health check',
1306+
timeoutMs: 5_000,
1307+
});
13121308
result.health.status = res.status;
13131309
result.health.ok = res.ok || res.status === 404;
13141310
result.health.optional = res.status === 404;

src/remote.ts

Lines changed: 6 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -22,95 +22,15 @@ import { assertAgentCardShape, type AgentCard, type PushResult } from './types.j
2222
import { loadAgentId, signMessage, signChallenge } from './identity.js';
2323
import { version } from './update-check.js';
2424
import { validateBranchName, validateHttpUrl } from './validation.js';
25+
import { fetchWithTimeout, readResponseJson, readResponseText } from './http.js';
2526

2627
// Client-declared signals (server stores but treats as untrusted)
2728
const platformSignal = `${platform()}-${arch()}`;
2829
const hostnameHash = createHash('sha256').update(hostname()).digest('hex');
2930
const FETCH_TIMEOUT_MS = 10_000;
30-
const MAX_RESPONSE_BYTES = 256 * 1024;
3131
const MAX_ERROR_BYTES = 16 * 1024;
3232
const MAX_CHALLENGE_BYTES = 4096;
3333

34-
async function fetchWithTimeout(
35-
url: string,
36-
init: RequestInit = {},
37-
label = 'Remote request',
38-
): Promise<Response> {
39-
const controller = new AbortController();
40-
let timedOut = false;
41-
const timeout = setTimeout(() => {
42-
timedOut = true;
43-
controller.abort();
44-
}, FETCH_TIMEOUT_MS);
45-
46-
try {
47-
return await fetch(url, { ...init, signal: controller.signal });
48-
} catch (err) {
49-
if (timedOut) {
50-
throw new Error(`${label} timed out after ${FETCH_TIMEOUT_MS}ms`);
51-
}
52-
throw err;
53-
} finally {
54-
clearTimeout(timeout);
55-
}
56-
}
57-
58-
async function readResponseText(
59-
res: Response,
60-
label: string,
61-
maxBytes = MAX_RESPONSE_BYTES,
62-
): Promise<string> {
63-
const length = res.headers.get('content-length');
64-
if (length && Number.parseInt(length, 10) > maxBytes) {
65-
throw new Error(`${label} exceeds ${maxBytes} bytes`);
66-
}
67-
68-
if (!res.body) {
69-
const text = await res.text();
70-
if (new TextEncoder().encode(text).byteLength > maxBytes) {
71-
throw new Error(`${label} exceeds ${maxBytes} bytes`);
72-
}
73-
return text;
74-
}
75-
76-
const reader = res.body.getReader();
77-
const chunks: Uint8Array[] = [];
78-
let total = 0;
79-
80-
while (true) {
81-
const { done, value } = await reader.read();
82-
if (done) break;
83-
if (!value) continue;
84-
total += value.byteLength;
85-
if (total > maxBytes) {
86-
await reader.cancel();
87-
throw new Error(`${label} exceeds ${maxBytes} bytes`);
88-
}
89-
chunks.push(value);
90-
}
91-
92-
const bytes = new Uint8Array(total);
93-
let offset = 0;
94-
for (const chunk of chunks) {
95-
bytes.set(chunk, offset);
96-
offset += chunk.byteLength;
97-
}
98-
return new TextDecoder().decode(bytes);
99-
}
100-
101-
async function readResponseJson<T>(
102-
res: Response,
103-
label: string,
104-
maxBytes = MAX_RESPONSE_BYTES,
105-
): Promise<T> {
106-
const text = await readResponseText(res, label, maxBytes);
107-
try {
108-
return JSON.parse(text) as T;
109-
} catch {
110-
throw new Error(`${label} is not valid JSON`);
111-
}
112-
}
113-
11434
function parseRemoteBranchList(data: unknown): string[] {
11535
if (!data || typeof data !== 'object' || !Array.isArray((data as { branches?: unknown }).branches)) {
11636
throw new Error('Remote branch list has invalid shape');
@@ -223,7 +143,7 @@ export async function pushBranch(
223143
...authHeaders,
224144
},
225145
body,
226-
}, `Push branch "${branch}"`);
146+
}, { label: `Push branch "${branch}"`, timeoutMs: FETCH_TIMEOUT_MS });
227147

228148
if (!res.ok) {
229149
const text = await readResponseText(res, 'Push error response', MAX_ERROR_BYTES);
@@ -281,7 +201,7 @@ export async function listRemoteBranches(
281201

282202
const res = await fetchWithTimeout(`${apiBase}${path}`, {
283203
headers: authHeaders,
284-
}, 'List remote branches');
204+
}, { label: 'List remote branches', timeoutMs: FETCH_TIMEOUT_MS });
285205

286206
if (!res.ok) {
287207
throw new Error(`Failed to list remote branches: HTTP ${res.status}`);
@@ -307,7 +227,7 @@ export async function deleteRemoteBranch(
307227
const res = await fetchWithTimeout(`${apiBase}${path}`, {
308228
method: 'DELETE',
309229
headers: authHeaders,
310-
}, `Delete remote branch "${branch}"`);
230+
}, { label: `Delete remote branch "${branch}"`, timeoutMs: FETCH_TIMEOUT_MS });
311231

312232
if (!res.ok) {
313233
const text = await readResponseText(res, 'Delete error response', MAX_ERROR_BYTES);
@@ -348,7 +268,7 @@ export async function fetchBranchCard(
348268
url += `?branch=${encodeURIComponent(branch)}`;
349269
}
350270

351-
const res = await fetchWithTimeout(url, undefined, `Fetch branch "${branch}"`);
271+
const res = await fetchWithTimeout(url, undefined, { label: `Fetch branch "${branch}"`, timeoutMs: FETCH_TIMEOUT_MS });
352272

353273
// Main branch or already authorized
354274
if (res.ok) {
@@ -376,7 +296,7 @@ export async function fetchBranchCard(
376296
'X-Nit-Challenge': challengeData.challenge,
377297
'X-Nit-Signature': signature,
378298
},
379-
}, `Fetch branch "${branch}" after challenge`);
299+
}, { label: `Fetch branch "${branch}" after challenge`, timeoutMs: FETCH_TIMEOUT_MS });
380300

381301
if (!authRes.ok) {
382302
const body = await readResponseText(authRes, 'Challenge error response', MAX_ERROR_BYTES);

0 commit comments

Comments
 (0)