Skip to content

Commit 42bb55f

Browse files
committed
Merge branch 'worktree-gh_issues'
2 parents febf8b7 + de65edc commit 42bb55f

13 files changed

Lines changed: 418 additions & 11 deletions

File tree

src/commands/status.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ function renderStatusUI(data: StatusData): Promise<void> {
8989
// Ensure we receive raw byte sequences (works even if readline keypress events are unavailable)
9090
readline.emitKeypressEvents(input);
9191
if (!wasRaw && typeof input.setRawMode === 'function') {
92-
input.setRawMode(true);
92+
try { input.setRawMode(true); } catch { /* TTY may be gone */ }
9393
}
9494
if (typeof input.setEncoding === 'function') {
9595
input.setEncoding('utf8');
@@ -175,7 +175,7 @@ function renderStatusUI(data: StatusData): Promise<void> {
175175
const cleanup = () => {
176176
input.off('data', handler);
177177
if (isTTY && !wasRaw && typeof input.setRawMode === 'function') {
178-
input.setRawMode(false);
178+
try { input.setRawMode(false); } catch { /* TTY may be gone */ }
179179
}
180180
if (wasPaused && typeof input.pause === 'function') {
181181
input.pause();

src/commands/sync.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function renderSyncUI(data: SyncData, ctx: SlashCommandContext): Promise<void> {
112112
if (isTTY) {
113113
readline.emitKeypressEvents(input);
114114
if (!wasRaw && typeof input.setRawMode === 'function') {
115-
input.setRawMode(true);
115+
try { input.setRawMode(true); } catch { /* TTY may be gone */ }
116116
}
117117
if (typeof input.setEncoding === 'function') {
118118
input.setEncoding('utf8');
@@ -198,7 +198,7 @@ function renderSyncUI(data: SyncData, ctx: SlashCommandContext): Promise<void> {
198198
const cleanup = () => {
199199
input.off('keypress', handler as any);
200200
if (isTTY && !wasRaw && typeof input.setRawMode === 'function') {
201-
input.setRawMode(false);
201+
try { input.setRawMode(false); } catch { /* TTY may be gone */ }
202202
}
203203
if (wasPaused && typeof input.pause === 'function') {
204204
input.pause();

src/feedback/FeedbackManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ export class FeedbackManager {
441441
const stdin = process.stdin;
442442
const wasRaw = stdin.isRaw;
443443

444-
stdin.setRawMode(true);
444+
try { stdin.setRawMode(true); } catch { /* TTY may be gone */ }
445445
stdin.resume();
446446
stdin.setEncoding('utf8');
447447

@@ -452,7 +452,7 @@ export class FeedbackManager {
452452

453453
const cleanup = () => {
454454
clearTimeout(timeout);
455-
stdin.setRawMode(wasRaw ?? false);
455+
try { stdin.setRawMode(wasRaw ?? false); } catch { /* TTY may be gone */ }
456456
stdin.removeListener('data', onData);
457457
};
458458

src/import/importers/CursorImporter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,13 @@ export class CursorImporter extends BaseImporter {
492492
messages: SessionMessage[];
493493
} | null> {
494494
// Lazy-load node:sqlite so the binary doesn't crash on runtimes that lack it (e.g. Bun)
495-
const { DatabaseSync } = await import('node:sqlite');
495+
let DatabaseSync: typeof import('node:sqlite').DatabaseSync;
496+
try {
497+
({ DatabaseSync } = await import('node:sqlite'));
498+
} catch {
499+
// node:sqlite is unavailable on this runtime (e.g. Bun) — skip SQLite-based import
500+
return null;
501+
}
496502
const db = new DatabaseSync(dbPath, { readOnly: true } as Record<string, unknown>);
497503

498504
try {

src/providers/errors.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,21 @@ function matchesAny(lower: string, patterns: readonly string[]): boolean {
269269
return patterns.some((p) => lower.includes(p));
270270
}
271271

272+
/**
273+
* Strip HTML tags from error bodies (e.g. nginx 502 Bad Gateway pages).
274+
* Returns the original string if it doesn't look like HTML.
275+
*/
276+
function stripHtmlFromBody(body: string): string {
277+
if (!/<[a-z/][\s\S]*>/i.test(body)) {
278+
return body;
279+
}
280+
// Remove tags, collapse whitespace, trim
281+
return body
282+
.replace(/<[^>]+>/g, ' ')
283+
.replace(/\s+/g, ' ')
284+
.trim();
285+
}
286+
272287
function makeError(
273288
code: ApiErrorCode,
274289
httpStatus: number,
@@ -277,8 +292,9 @@ function makeError(
277292
headers?: Headers,
278293
): ApiError {
279294
const friendlyMessage = FRIENDLY_MESSAGES[code];
280-
const message = rawBody
281-
? `${friendlyMessage}\n${rawBody}`
295+
const displayBody = rawBody ? stripHtmlFromBody(rawBody) : '';
296+
const message = displayBody
297+
? `${friendlyMessage}\n${displayBody}`
282298
: friendlyMessage;
283299

284300
const retryAfterMs = parseRetryAfter(headers) ?? inferRetryAfterFromBody(code, rawBody);

src/reporting/AutoReportManager.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import crypto from 'node:crypto';
1010
import type { AutohandConfig } from '../types.js';
1111
import type { ErrorReport } from './types.js';
1212
import { AutoReportClient } from './AutoReportClient.js';
13+
import { ApiError } from '../providers/errors.js';
14+
import type { ApiErrorCode } from '../providers/errors.js';
1315

1416
const isDebug = () => process.env.AUTOHAND_DEBUG === '1';
1517

@@ -31,6 +33,33 @@ export class AutoReportManager {
3133
return this.enabled;
3234
}
3335

36+
/**
37+
* API error codes that represent expected operational conditions, NOT bugs.
38+
* These should never be auto-reported as GitHub issues.
39+
*/
40+
private static readonly OPERATIONAL_API_ERROR_CODES: ReadonlySet<ApiErrorCode> = new Set([
41+
'rate_limited', // User hit rate limits — expected, handled by retry
42+
'cancelled', // User cancelled the request
43+
'timeout', // Provider too slow — expected for local inference
44+
'network_error', // Can't reach provider — user's network
45+
'server_error', // Provider is down — not our bug
46+
'auth_failed', // Bad API key — user config issue
47+
'payment_required', // Account billing issue
48+
'access_denied', // API key lacks permissions
49+
'model_not_found', // Wrong model name — user config issue
50+
]);
51+
52+
/**
53+
* Check if an error represents an expected operational condition
54+
* that should NOT be auto-reported as a bug.
55+
*/
56+
isOperationalError(error: Error): boolean {
57+
if (error instanceof ApiError) {
58+
return AutoReportManager.OPERATIONAL_API_ERROR_CODES.has(error.code);
59+
}
60+
return false;
61+
}
62+
3463
/**
3564
* Compute a simple hash from error name + message for in-session deduplication
3665
*/
@@ -47,6 +76,14 @@ export class AutoReportManager {
4776
try {
4877
if (!this.enabled) return;
4978

79+
// Skip expected operational errors — they are not bugs
80+
if (this.isOperationalError(error)) {
81+
if (isDebug()) {
82+
process.stderr.write(`[autohand:report] Skipping operational error: ${(error as ApiError).code}\n`);
83+
}
84+
return;
85+
}
86+
5087
const hash = this.computeHash(error);
5188
if (this.reportedHashes.has(hash)) {
5289
if (isDebug()) {

src/reporting/processErrorReporting.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,43 @@ function isIgnorableStdinReadError(err: unknown, _processRef: ProcessLike): bool
141141
return maybeError.code === 'EIO' && maybeError.syscall === 'read';
142142
}
143143

144+
/**
145+
* Filesystem errors that are expected operational conditions:
146+
* - EACCES on mkdir: user running CLI in a directory they can't write to
147+
* - EEXIST on mkdir: race condition when multiple processes create the same dir
148+
*/
149+
function isIgnorableFilesystemError(err: unknown): boolean {
150+
if (!err || typeof err !== 'object') return false;
151+
const maybeError = err as { code?: string; syscall?: string };
152+
if (maybeError.syscall !== 'mkdir') return false;
153+
return maybeError.code === 'EACCES' || maybeError.code === 'EEXIST';
154+
}
155+
156+
/**
157+
* Terminal/IO errors that are expected during shutdown or in non-standard terminals:
158+
* - setRawMode errno: TTY is dead (bad file descriptor during component unmount)
159+
* - Generator is executing: concurrent readline/shell operations (harmless race)
160+
* - node:sqlite resolution: runtime doesn't support node:sqlite (e.g. Bun)
161+
*/
162+
function isIgnorableTerminalOrRuntimeError(err: unknown): boolean {
163+
if (!err || typeof err !== 'object') return false;
164+
const message = (err as Error).message ?? '';
165+
if (/setRawMode.*errno/i.test(message)) return true;
166+
if (message === 'Generator is executing') return true;
167+
if (message.includes('node:sqlite')) return true;
168+
return false;
169+
}
170+
144171
function isIgnorableUnhandledRejection(reason: unknown, processRef: ProcessLike): boolean {
145172
if (reason && typeof reason === 'object' && (reason as { code?: string }).code === 'ERR_USE_AFTER_CLOSE') {
146173
return true;
147174
}
148175

149-
return isIgnorableStdinReadError(reason, processRef);
176+
if (isIgnorableStdinReadError(reason, processRef)) return true;
177+
if (isIgnorableFilesystemError(reason)) return true;
178+
if (isIgnorableTerminalOrRuntimeError(reason)) return true;
179+
180+
return false;
150181
}
151182

152183
function toReportableError(reason: unknown): Error {
@@ -198,7 +229,8 @@ export async function reportProcessError(reason: unknown, options: ProcessErrorC
198229
if (options.handler === 'unhandledRejection' && isIgnorableUnhandledRejection(reason, processRef)) {
199230
return;
200231
}
201-
if (options.handler === 'uncaughtException' && isIgnorableStdinReadError(reason, processRef)) {
232+
if (options.handler === 'uncaughtException' &&
233+
(isIgnorableStdinReadError(reason, processRef) || isIgnorableTerminalOrRuntimeError(reason))) {
202234
return;
203235
}
204236

@@ -240,6 +272,9 @@ export function installProcessErrorHandlers(options: InstallProcessErrorHandlers
240272
if (isIgnorableStdinReadError(error, processRef)) {
241273
return;
242274
}
275+
if (isIgnorableTerminalOrRuntimeError(error)) {
276+
return;
277+
}
243278

244279
captureLastError(error);
245280
logError(`${getLogPrefix(processRef)} Uncaught Exception:`, error);

tests/config/configParser.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,29 @@ describe('configParser – error handling (Issue #3)', () => {
229229
const result = await loadConfig(configPath);
230230
expect(result.provider).toBe('openrouter');
231231
});
232+
233+
// ─── EACCES / EEXIST handling ─────────────────────────────────────────────
234+
235+
it('throws a clear error when config dir is not writable (EACCES)', async () => {
236+
// Create a read-only dir and point config at a subdir
237+
const readonlyDir = path.join(testDir, 'readonly');
238+
await fse.ensureDir(readonlyDir);
239+
await fse.chmod(readonlyDir, 0o444);
240+
241+
const configPath = path.join(readonlyDir, 'subdir', 'config.json');
242+
const loadConfig = await importLoadConfig();
243+
244+
let caughtError: Error | null = null;
245+
try {
246+
await loadConfig(configPath);
247+
} catch (e) {
248+
caughtError = e as Error;
249+
}
250+
251+
// Restore permissions for cleanup
252+
await fse.chmod(readonlyDir, 0o755);
253+
254+
expect(caughtError).not.toBeNull();
255+
expect(caughtError!.message).toMatch(/permission denied|EACCES|Cannot create/i);
256+
});
232257
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Autohand AI LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*
6+
* Regression test: node:sqlite unavailable on Bun (Issue #43)
7+
* Verifies CursorImporter gracefully returns null when node:sqlite cannot load.
8+
*/
9+
import { describe, it, expect, vi, beforeEach } from 'vitest';
10+
import os from 'node:os';
11+
import path from 'node:path';
12+
13+
vi.mock('fs-extra', () => ({
14+
default: {
15+
pathExists: vi.fn().mockResolvedValue(false),
16+
readFile: vi.fn(),
17+
readdir: vi.fn().mockResolvedValue([]),
18+
readJson: vi.fn(),
19+
ensureDir: vi.fn().mockResolvedValue(undefined),
20+
writeJson: vi.fn().mockResolvedValue(undefined),
21+
writeFile: vi.fn().mockResolvedValue(undefined),
22+
copy: vi.fn().mockResolvedValue(undefined),
23+
},
24+
}));
25+
26+
// Simulate node:sqlite being unavailable (e.g. Bun runtime)
27+
vi.mock('node:sqlite', () => {
28+
throw new Error('Could not resolve: "node:sqlite". Maybe you need to "bun install"?');
29+
});
30+
31+
import fse from 'fs-extra';
32+
import { CursorImporter } from '../../src/import/importers/CursorImporter.js';
33+
34+
const HOME = os.homedir();
35+
const CURSOR_HOME = path.join(HOME, '.cursor');
36+
37+
describe('CursorImporter – node:sqlite unavailable (Issue #43)', () => {
38+
let importer: CursorImporter;
39+
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
importer = new CursorImporter();
43+
});
44+
45+
it('should not crash when importing sessions without node:sqlite', async () => {
46+
// Set up scan to detect sessions
47+
const sessionsDir = path.join(CURSOR_HOME, 'User', 'workspaceStorage');
48+
vi.mocked(fse.pathExists).mockImplementation(async (p: string) => {
49+
const s = String(p);
50+
if (s === CURSOR_HOME) return true;
51+
if (s === sessionsDir) return true;
52+
return false;
53+
});
54+
vi.mocked(fse.readdir).mockImplementation(async (p: string) => {
55+
if (String(p) === sessionsDir) {
56+
return [{ name: 'abc123', isDirectory: () => true }] as any;
57+
}
58+
return [];
59+
});
60+
61+
// Import should complete without throwing
62+
const result = await importer.import(['sessions']);
63+
64+
// Should have 0 imported sessions (since sqlite was unavailable)
65+
expect(result.imported).toBeDefined();
66+
});
67+
});

tests/providers/apiErrors.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,47 @@ describe('classifyApiError', () => {
391391
});
392392
});
393393

394+
// =========================================================================
395+
// HTML stripping in error messages (Issue #48)
396+
// =========================================================================
397+
describe('HTML stripping in error bodies', () => {
398+
it('strips HTML tags from 502 Bad Gateway response', () => {
399+
const htmlBody = '<html>\r\n<head><title>502 Bad Gateway</title></head>\r\n<body>\r\n<center><h1>502 Bad Gateway</h1></center>\r\n<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n';
400+
const err = classifyApiError(502, htmlBody);
401+
expect(err.code).toBe('server_error');
402+
expect(err.message).not.toContain('<html>');
403+
expect(err.message).not.toContain('<head>');
404+
expect(err.message).not.toContain('</h1>');
405+
expect(err.message).toContain('502 Bad Gateway');
406+
});
407+
408+
it('strips HTML from 503 Service Unavailable response', () => {
409+
const htmlBody = '<html><body><h1>503 Service Temporarily Unavailable</h1></body></html>';
410+
const err = classifyApiError(503, htmlBody);
411+
expect(err.message).not.toContain('<html>');
412+
expect(err.message).toContain('503 Service Temporarily Unavailable');
413+
});
414+
415+
it('preserves JSON error bodies as-is', () => {
416+
const jsonBody = '{"error":"model requires more system memory (9.9 GiB) than is available (3.7 GiB)"}';
417+
const err = classifyApiError(500, jsonBody);
418+
expect(err.message).toContain('model requires more system memory');
419+
});
420+
421+
it('preserves plain text error bodies as-is', () => {
422+
const textBody = 'Rate limit exceeded for model gpt-4o';
423+
const err = classifyApiError(429, textBody);
424+
expect(err.message).toContain('Rate limit exceeded for model gpt-4o');
425+
});
426+
427+
it('preserves rawDetail with original HTML for debugging', () => {
428+
const htmlBody = '<html><body>502 Bad Gateway</body></html>';
429+
const err = classifyApiError(502, htmlBody);
430+
// rawDetail should still have the original for debugging
431+
expect(err.rawDetail).toBe(htmlBody);
432+
});
433+
});
434+
394435
// =========================================================================
395436
// FRIENDLY_MESSAGES
396437
// =========================================================================

0 commit comments

Comments
 (0)