Skip to content

Commit f100e05

Browse files
committed
fix: clarify sandboxed local server failures
Fixes #168.
1 parent 017fedc commit f100e05

9 files changed

Lines changed: 165 additions & 19 deletions

File tree

packages/cli/src/annotation/runAnnotation.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createDeferred } from '@contextbridge/shared/testHelpers';
33
import { describe, expect, it } from 'bun:test';
44
import { annotationArgs, environment } from '#src/testFactories.ts';
55
import { createAnnotationDependencies, createStubContext } from '#src/testHelpers/index.ts';
6-
import { runAnnotation } from './runAnnotation.ts';
6+
import { AnnotationEnvironmentError, runAnnotation } from './runAnnotation.ts';
77

88
describe('runAnnotation', () => {
99
it('opens the browser and returns the submitted review', async () => {
@@ -68,6 +68,49 @@ describe('runAnnotation', () => {
6868
expect(analytics.captures.some((c) => c.event === 'plan_review_submitted')).toBe(false);
6969
});
7070

71+
it('maps the port-0 Bun bind failure to an annotation environment error', async () => {
72+
const { context } = createStubContext();
73+
const deps = createAnnotationDependencies();
74+
const cause = Object.assign(new Error('Failed to start server. Is port 0 in use?'), { code: 'EADDRINUSE' });
75+
deps.startReviewServer = () => {
76+
throw cause;
77+
};
78+
79+
const caught = await runAnnotation(context, annotationArgs.build(), deps).then(
80+
() => null,
81+
(e: unknown) => e,
82+
);
83+
84+
expect(caught).toBeInstanceOf(AnnotationEnvironmentError);
85+
expect((caught as AnnotationEnvironmentError).cause).toBe(cause);
86+
expect((caught as AnnotationEnvironmentError).message).toContain('network sandbox');
87+
expect(deps.closeCount).toBe(0);
88+
});
89+
90+
it('keeps explicit-port EADDRINUSE failures as regular runtime failures', () => {
91+
const { context } = createStubContext();
92+
const deps = createAnnotationDependencies();
93+
const cause = Object.assign(new Error('Failed to start server. Is port 3456 in use?'), { code: 'EADDRINUSE' });
94+
deps.startReviewServer = () => {
95+
throw cause;
96+
};
97+
98+
expect(runAnnotation(context, annotationArgs.build({ port: 3456 }), deps)).rejects.toBe(cause);
99+
expect(deps.closeCount).toBe(0);
100+
});
101+
102+
it('keeps CONTEXTBRIDGE_PORT EADDRINUSE failures as regular runtime failures', () => {
103+
const { context } = createStubContext({ env: environment.build({ CONTEXTBRIDGE_PORT: 3456 }) });
104+
const deps = createAnnotationDependencies();
105+
const cause = Object.assign(new Error('Failed to start server. Is port 3456 in use?'), { code: 'EADDRINUSE' });
106+
deps.startReviewServer = () => {
107+
throw cause;
108+
};
109+
110+
expect(runAnnotation(context, annotationArgs.build(), deps)).rejects.toBe(cause);
111+
expect(deps.closeCount).toBe(0);
112+
});
113+
71114
it('closes the server and rejects when SIGINT is received', async () => {
72115
const { context, analytics } = createStubContext();
73116
const result = createDeferred<AnnotationSubmission>();

packages/cli/src/annotation/runAnnotation.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
AnnotationSubmission,
88
ContentKind,
99
} from '@contextbridge/shared/annotationSchema';
10+
import { getErrorMessage, hasErrorCode } from '@contextbridge/shared/errors';
1011
import type { FrontendConfig } from '@contextbridge/shared/frontendConfigSchema';
1112
import { nowInstant } from '@contextbridge/shared/time';
1213
import type { UpdateNotice } from '@contextbridge/shared/updateNoticeSchema';
@@ -20,6 +21,16 @@ export class AnnotationInterruptedError extends Error {
2021
}
2122
}
2223

24+
export class AnnotationEnvironmentError extends Error {
25+
constructor(cause: unknown) {
26+
super(
27+
'ContextBridge could not start its local browser server. Your coding harness may be running contextbridge in a network sandbox that blocks localhost. Allow local network access for contextbridge or run it outside the sandbox, then try again.',
28+
{ cause },
29+
);
30+
this.name = 'AnnotationEnvironmentError';
31+
}
32+
}
33+
2334
export interface RunAnnotationArgs {
2435
content: string;
2536
contentKind: ContentKind;
@@ -80,13 +91,20 @@ export async function runAnnotation(
8091
const htmlPromise = deps.loadHtml();
8192
htmlPromise.catch((err: unknown) => logger.error({ err }, 'failed to load annotation UI bundle'));
8293

83-
server = deps.startReviewServer(ctx, {
84-
html: htmlPromise,
85-
payload,
86-
config: frontendConfig,
87-
port,
88-
checkForUpdate: () => updater.checkForUpdate().catch(() => null),
89-
});
94+
try {
95+
server = deps.startReviewServer(ctx, {
96+
html: htmlPromise,
97+
payload,
98+
config: frontendConfig,
99+
port,
100+
checkForUpdate: () => updater.checkForUpdate().catch(() => null),
101+
});
102+
} catch (err) {
103+
if (isLikelyLocalServerNetworkSandboxError(err, port ?? 0)) {
104+
throw new AnnotationEnvironmentError(err);
105+
}
106+
throw err;
107+
}
90108
removeSigintHandler = deps.registerSigintHandler(() => {
91109
if (sigintHandled) {
92110
return;
@@ -122,6 +140,14 @@ export async function runAnnotation(
122140
}
123141
}
124142

143+
export function isLikelyLocalServerNetworkSandboxError(err: unknown, port: number): boolean {
144+
if (hasErrorCode(err, 'EACCES') || hasErrorCode(err, 'EPERM')) {
145+
return true;
146+
}
147+
148+
return port === 0 && hasErrorCode(err, 'EADDRINUSE') && getErrorMessage(err).includes('port 0');
149+
}
150+
125151
const defaultAnnotationDependencies: AnnotationDependencies = {
126152
loadHtml: () => import('./bundledAnnotationHtml.ts').then((m) => m.bundledAnnotationHtml),
127153
startReviewServer: (ctx, { html, payload, config, port, checkForUpdate }) =>

packages/cli/src/commands/abort.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ import { CommanderError } from 'commander';
22
import type { CliContext } from '#src/context.ts';
33

44
/**
5-
* Log and throw a CommanderError for a user-recoverable ('input') or runtime
6-
* failure inside a subcommand handler.
5+
* Log and throw a CommanderError for a user-recoverable ('input' or 'environment')
6+
* or runtime failure inside a subcommand handler.
77
*/
8-
export function abort(ctx: CliContext, command: string, kind: 'input' | 'runtime', message: string): never {
8+
export function abort(
9+
ctx: CliContext,
10+
command: string,
11+
kind: 'input' | 'runtime' | 'environment',
12+
message: string,
13+
): never {
914
const { logger } = ctx;
10-
// 'input' is user-recoverable — logged at warn so Sentry's pinoIntegration
11-
// (error/fatal only) doesn't forward it. 'runtime' is a genuine failure.
12-
if (kind === 'input') {
15+
// 'input' and 'environment' are user-recoverable — logged at warn so Sentry's
16+
// pinoIntegration (error/fatal only) doesn't forward them. 'runtime' is a
17+
// genuine failure.
18+
if (kind === 'input' || kind === 'environment') {
1319
logger.warn(message);
1420
} else {
1521
logger.error(message);

packages/cli/src/commands/hookClaude.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { AnnotationSubmission } from '@contextbridge/shared/annotationSchema';
22
import { getErrorMessage } from '@contextbridge/shared/errors';
33
import { type Command, CommanderError } from 'commander';
4-
import { type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
4+
import { AnnotationEnvironmentError, type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
55
import type { CliContext } from '#src/context.ts';
66
import { type ClaudeHookResponse, claudeHookResponse } from '#src/formatters/plan/claudeHookResponse.ts';
7+
import { abort as abortCommand } from './abort.ts';
78
import { type ClaudeHookPayload, ClaudeHookPayloadSchema } from './claudeHookSchema.ts';
89

910
export interface HookClaudeDependencies {
@@ -97,6 +98,9 @@ async function handleExitPlanMode(
9798
try {
9899
submission = await runReview(ctx, { content: planContent, contentKind: 'plan', entrypoint: 'hook_claude' });
99100
} catch (err) {
101+
if (err instanceof AnnotationEnvironmentError) {
102+
abortCommand(ctx, 'hookClaude', 'environment', err.message);
103+
}
100104
abort(ctx, 'runtime', getErrorMessage(err));
101105
}
102106

packages/cli/src/commands/hookCodex.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@ import type { AnnotationSubmission } from '@contextbridge/shared/annotationSchem
22
import { annotationSubmission } from '@contextbridge/shared/testFactories';
33
import { describe, expect, it } from 'bun:test';
44
import { CommanderError } from 'commander';
5-
import { type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
5+
import { AnnotationEnvironmentError, type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
66
import type { CodexStopResponse } from '#src/formatters/plan/codexStopResponse.ts';
77
import {
88
annotationArgs,
99
codexStopHookPayload,
1010
codexTranscriptHookPromptLine,
1111
codexTranscriptPlanLine,
1212
} from '#src/testFactories.ts';
13-
import { createAnnotationDependencies, createStubContext, readErrorLogs } from '#src/testHelpers/index.ts';
13+
import {
14+
createAnnotationDependencies,
15+
createStubContext,
16+
readErrorLogs,
17+
readWarnLogs,
18+
} from '#src/testHelpers/index.ts';
1419
import type { CodexStopHookPayload } from './codexHookSchema.ts';
1520
import { type HookCodexDependencies, extractLatestPlanFromTranscript, runHookCodex } from './hookCodex.ts';
1621

@@ -100,6 +105,32 @@ describe('hookCodex handler', () => {
100105
expect(readErrorLogs(logs).some((r) => r.msg.includes('failed to read codex transcript'))).toBe(true);
101106
});
102107

108+
it('surfaces local network sandbox failures without error-level logs', async () => {
109+
const { context, io, logs } = createStubContext();
110+
const deps = createHookDependencies({
111+
transcript: transcriptWithPlan('# Plan\n'),
112+
runReview: () =>
113+
Promise.reject(
114+
new AnnotationEnvironmentError(
115+
Object.assign(new Error('Failed to start server. Is port 0 in use?'), { code: 'EADDRINUSE' }),
116+
),
117+
),
118+
});
119+
writeStopPayload(io, { transcript_path: '/tmp/codex-transcript.jsonl', last_assistant_message: null });
120+
121+
const caught = await runHookCodex(context, deps).then(
122+
() => null,
123+
(e: unknown) => e,
124+
);
125+
126+
expect(caught).toBeInstanceOf(CommanderError);
127+
expect((caught as CommanderError).code).toBe('contextbridge.hookCodex.environmentError');
128+
expect((caught as CommanderError).message).toContain('network sandbox');
129+
expect(io.stdout.text()).toBe('');
130+
expect(readWarnLogs(logs).some((r) => r.msg.includes('network sandbox'))).toBe(true);
131+
expect(readErrorLogs(logs)).toEqual([]);
132+
});
133+
103134
it('reviews an approved proposed_plan tag from last_assistant_message without reading the transcript', async () => {
104135
const { context, io } = createStubContext();
105136
const deps = createHookDependencies({

packages/cli/src/commands/hookCodex.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { getErrorMessage, toError } from '@contextbridge/shared/errors';
33
import { safeJsonParse } from '@contextbridge/shared/json';
44
import { type Command, CommanderError } from 'commander';
55
import { ResultAsync, err, ok } from 'neverthrow';
6-
import { type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
6+
import { AnnotationEnvironmentError, type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
77
import type { CliContext } from '#src/context.ts';
88
import { type CodexStopResponse, codexStopResponse } from '#src/formatters/plan/codexStopResponse.ts';
9+
import { abort as abortCommand } from './abort.ts';
910
import {
1011
type CodexStopHookPayload,
1112
CodexStopHookPayloadSchema,
@@ -109,7 +110,12 @@ async function handleStop(
109110
toError,
110111
).match(
111112
(submission: AnnotationSubmission) => codexStopResponse(submission, planContent),
112-
(e) => abort(ctx, 'runtime', getErrorMessage(e)),
113+
(e) => {
114+
if (e instanceof AnnotationEnvironmentError) {
115+
abortCommand(ctx, 'hookCodex', 'environment', e.message);
116+
}
117+
abort(ctx, 'runtime', getErrorMessage(e));
118+
},
113119
);
114120
}
115121

packages/cli/src/commands/open.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@contextbridge/shared/errors';
33
import { type Command, CommanderError } from 'commander';
44
import {
55
type AnnotationDependencies,
6+
AnnotationEnvironmentError,
67
AnnotationInterruptedError,
78
runAnnotation,
89
} from '#src/annotation/runAnnotation.ts';
@@ -56,6 +57,9 @@ export async function runOpen(ctx: CliContext, args: OpenArgs, deps?: Annotation
5657
logger.info('open session interrupted');
5758
throw new CommanderError(130, 'contextbridge.open.sigint', 'open session interrupted');
5859
}
60+
if (err instanceof AnnotationEnvironmentError) {
61+
abort(ctx, 'open', 'environment', err.message);
62+
}
5963
abort(ctx, 'open', 'runtime', getErrorMessage(err));
6064
}
6165
}

packages/cli/src/commands/plan.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ describe('plan handler', () => {
112112
expect(readErrorLogs(logs).some((r) => r.msg.includes('open failed'))).toBe(true);
113113
});
114114

115+
it('surfaces local network sandbox bind failures without error-level logs', async () => {
116+
const { context, io, logs } = createStubContext();
117+
const deps = createAnnotationDependencies();
118+
deps.startReviewServer = () => {
119+
throw Object.assign(new Error('Failed to start server. Is port 0 in use?'), { code: 'EADDRINUSE' });
120+
};
121+
io.stdin.write('# Plan\n');
122+
io.stdin.end();
123+
124+
const caught = await runPlan(context, {}, deps).then(
125+
() => null,
126+
(e: unknown) => e,
127+
);
128+
129+
expect(caught).toBeInstanceOf(CommanderError);
130+
expect((caught as CommanderError).code).toBe('contextbridge.plan.environmentError');
131+
expect((caught as CommanderError).message).toContain('network sandbox');
132+
expect(io.stdout.text()).toBe('');
133+
expect(readWarnLogs(logs).some((r) => r.msg.includes('network sandbox'))).toBe(true);
134+
expect(readErrorLogs(logs)).toEqual([]);
135+
});
136+
115137
it('closes the server and exits cleanly (without error-level logs) when SIGINT is received', async () => {
116138
const { context, io, logs } = createStubContext();
117139
const deps = createAnnotationDependencies({ result: createDeferred<AnnotationSubmission>().promise });

packages/cli/src/commands/plan.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getErrorMessage } from '@contextbridge/shared/errors';
22
import { type Command, CommanderError, InvalidArgumentError } from 'commander';
33
import {
44
type AnnotationDependencies,
5+
AnnotationEnvironmentError,
56
AnnotationInterruptedError,
67
runAnnotation,
78
} from '#src/annotation/runAnnotation.ts';
@@ -55,6 +56,9 @@ export async function runPlan(ctx: CliContext, args: PlanArgs, deps?: Annotation
5556
logger.info('plan review interrupted');
5657
throw new CommanderError(130, 'contextbridge.plan.sigint', 'plan review interrupted');
5758
}
59+
if (err instanceof AnnotationEnvironmentError) {
60+
abort(ctx, 'plan', 'environment', err.message);
61+
}
5862
abort(ctx, 'plan', 'runtime', getErrorMessage(err));
5963
}
6064
}

0 commit comments

Comments
 (0)