Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export function ChatArea({
displayName={permission.displayName}
tier={permission.tier}
approvalScope={permission.approvalScope}
responseError={permission.responseError}
onRespond={onPermissionRespond}
/>
)}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/PermissionBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface Props {
displayName?: string;
tier?: ToolTier;
approvalScope?: 'session' | 'conversation';
responseError?: string;
expiresAt?: number;
questions?: UserQuestion[];
onRespond: (
Expand All @@ -36,6 +37,7 @@ export function PermissionBanner({
displayName,
tier,
approvalScope,
responseError,
questions,
expiresAt,
onRespond,
Expand Down Expand Up @@ -184,6 +186,11 @@ export function PermissionBanner({
)}
</div>
<div className="perm-banner-actions">
{responseError && (
<p className="perm-banner-response-error" role="alert">
{responseError}
</p>
)}
{questions ? (
<button
className="perm-banner-btn perm-banner-btn--once"
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/__tests__/PermissionBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ describe('PermissionBanner', () => {
expect(container.querySelector('.perm-banner-btn--deny')).toBeTruthy();
});

it('shows a retryable permission response error without dismissing the prompt', () => {
render(<PermissionBanner {...defaultProps} responseError="Review the prompt and try again." />);
expect(screen.getByRole('alert').textContent).toContain('Review the prompt and try again.');
expect(screen.getByText('Allow Once')).toBeTruthy();
});

it('adds perm-banner--visible class after mount', () => {
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -3312,6 +3312,12 @@ textarea:focus {
max-height: 72dvh;
overflow: hidden;
}
.perm-banner--elevated {
border-top-color: var(--warning);
}
.perm-banner--unknown {
border-top-color: var(--text-dim);
}
.perm-banner-heading {
display: flex;
justify-content: space-between;
Expand Down Expand Up @@ -3367,11 +3373,19 @@ textarea:focus {
}
.perm-banner-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
border-top: 1px solid var(--border);
padding-top: 14px;
}

.perm-banner-response-error {
flex-basis: 100%;
margin: 0;
color: var(--error, #f87171);
font-size: 0.8rem;
}
.perm-banner-btn {
flex: 1;
min-height: 44px;
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/types/ws-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ interface PermissionResolvedMsg {
sessionId?: string;
}

interface PermissionResponseRejectedMsg {
type: 'permission_response_rejected';
permId: string;
sessionId?: string;
error: string;
}

interface ErrorMsg {
type: 'error';
error: string;
Expand Down Expand Up @@ -246,6 +253,7 @@ export type ServerMessage =
| PermissionRequestMsg
| PermissionTimeoutMsg
| PermissionResolvedMsg
| PermissionResponseRejectedMsg
| ErrorMsg
| SessionTakeoverMsg
| ModeChangedMsg
Expand Down
16 changes: 16 additions & 0 deletions packages/client/__tests__/messages-slice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1703,3 +1703,19 @@ it('queues concurrent prompts and deduplicates reconnect replay', () => {
state = messagesReducer(state, { type: 'PERMISSION_TIMEOUT', permId: 'p2' });
expect(state.permission).toBeNull();
});

it('keeps streaming state and the prompt when a permission response is rejected', () => {
const permission = { permId: 'p1', toolName: 'Bash', toolInput: 'pwd' };
const state = messagesReducer(
{
...INITIAL_MESSAGES_STATE,
running: true,
current: { messageId: 'm1', blocks: new Map(), blockOrder: [] },
permission,
},
{ type: 'PERMISSION_REJECTED', permId: 'p1', error: 'Try again' },
);
expect(state.running).toBe(true);
expect(state.current?.messageId).toBe('m1');
expect(state.permission).toEqual({ ...permission, responseError: 'Try again' });
});
11 changes: 11 additions & 0 deletions packages/client/__tests__/protocol-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,17 @@ describe('permission events', () => {
);
expect(r.messagesActions).toEqual([{ type: 'PERMISSION_TIMEOUT', permId: 'p1' }]);
});
it('permission_response_rejected dispatches a nonterminal permission rejection', () => {
const r = parseServerMessage(
{ type: 'permission_response_rejected', permId: 'p1', error: 'Try again' },
makeState(),
makeCallbacks(),
POOL_KEY,
);
expect(r.messagesActions).toEqual([
{ type: 'PERMISSION_REJECTED', permId: 'p1', error: 'Try again' },
]);
});
});

// ─── Error handling ──────────────────────────────────────────────────────────
Expand Down
28 changes: 28 additions & 0 deletions packages/client/src/__tests__/sse-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,34 @@ describe('SseConnection', () => {
}
});

it('reports permission POST failures as nonterminal permission rejections', async () => {
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
const conn = new SseConnection(createConfig({ fetch }));
const listener = vi.fn();
conn.onMessage(listener);
conn.connect();
lastES()._emit('welcome', { type: 'welcome', protocolVersion: 2, connectionId: 'conn-abc' });
listener.mockClear();

conn.send({
type: 'permission_response',
sessionId: 'sess-1',
permId: 'perm-1',
decision: 'once',
answers: { question: [' '] },
});

await vi.waitFor(() =>
expect(listener).toHaveBeenCalledWith({
type: 'permission_response_rejected',
sessionId: 'sess-1',
permId: 'perm-1',
error: 'Could not submit the permission response (400). Review the prompt and try again.',
}),
);
expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});

it.each([
['switch_session', 'selected'],
['switch_session', null],
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/protocol-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ export function parseServerMessage(
});
break;

case 'permission_response_rejected':
result.messagesActions.push({
type: 'PERMISSION_REJECTED',
permId: msg.permId as string,
error: msg.error as string,
});
break;

case 'native_command_result':
result.messagesActions.push({
type: 'NATIVE_COMMAND_RESULT',
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/slices/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export type MessagesAction =
| { type: 'SESSION_STATE_CHANGED'; state: ClientSessionState }
| { type: 'CONNECTION_LOST' }
| { type: 'PERMISSION_REQUEST'; payload: PermissionRequest }
| { type: 'PERMISSION_REJECTED'; permId: string; error: string }
| { type: 'PERMISSION_TIMEOUT'; permId: string }
| { type: 'RESTORE'; messages: FinishedMessage[]; interrupted?: boolean }
| {
Expand Down Expand Up @@ -404,6 +405,18 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M
: { ...state, permission: action.payload };
}

case 'PERMISSION_REJECTED': {
const update = (permission: PermissionRequest) =>
permission.permId === action.permId
? { ...permission, responseError: action.error }
: permission;
return {
...state,
permission: state.permission ? update(state.permission) : null,
permissionQueue: state.permissionQueue?.map(update),
};
}

case 'PERMISSION_TIMEOUT': {
const queue = (state.permissionQueue ?? []).filter((p) => p.permId !== action.permId);
if (state.permission?.permId !== action.permId) return { ...state, permissionQueue: queue };
Expand Down
22 changes: 20 additions & 2 deletions packages/client/src/sse-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,14 +485,32 @@ export class SseConnection implements ChatConnection {
headers: { 'Content-Type': 'application/json', 'X-Connection-ID': this._connectionId },
body: JSON.stringify(body),
});
if (!res.ok)
if (!res.ok) {
if (endpoint === 'permission' && typeof body.permId === 'string') {
this.listener?.({
type: 'permission_response_rejected',
...scope,
permId: body.permId,
error: `Could not submit the permission response (${res.status}). Review the prompt and try again.`,
});
return;
}
this.listener?.({
type: 'error',
...scope,
error: `Could not ${endpoint} (${res.status}). Please retry.`,
});
}
} catch {
this.listener?.({ type: 'error', ...scope, error: `Could not ${endpoint}. Please retry.` });
if (endpoint === 'permission' && typeof body.permId === 'string')
this.listener?.({
type: 'permission_response_rejected',
...scope,
permId: body.permId,
error: 'Could not submit the permission response. Review the prompt and try again.',
});
else
this.listener?.({ type: 'error', ...scope, error: `Could not ${endpoint}. Please retry.` });
}
}

Expand Down
38 changes: 38 additions & 0 deletions packages/harness/__tests__/user-questions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,44 @@ it('shows complete approval arguments rather than the notification summary', asy
}
});

it('rejects approval input that cannot be displayed in full', async () => {
const { applyTierOverrides } = await import('../src/tool-tiers.js');
applyTierOverrides({ Bash: 'unknown' });
try {
const { handler, sent, abort } = setup();
const command = 'x'.repeat(20_000);
const result = handler('Bash', { command }, { signal: abort.signal, toolUseID: 'b-large' });
await expect(result).resolves.toMatchObject({
behavior: 'deny',
message: expect.stringContaining('too large to review safely'),
});
expect(sent).toEqual([]);
} finally {
applyTierOverrides({});
}
});

it('rejects approval input that cannot be serialized for review', async () => {
const { applyTierOverrides } = await import('../src/tool-tiers.js');
applyTierOverrides({ CustomWrite: 'unknown' });
try {
const { handler, sent, abort } = setup();
const circular: Record<string, unknown> = {};
circular.self = circular;
const result = handler('CustomWrite', circular, {
signal: abort.signal,
toolUseID: 'custom-large',
});
await expect(result).resolves.toMatchObject({
behavior: 'deny',
message: expect.stringContaining('too large to review safely'),
});
expect(sent).toEqual([]);
} finally {
applyTierOverrides({});
}
});

it('keeps an unresolved question replayable when a transport closes during send', async () => {
const { getPendingRequestsBySession } = await import('../src/permissions.js');
const { handler, registry, abort } = setup();
Expand Down
31 changes: 26 additions & 5 deletions packages/harness/src/permission-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ export const UserQuestionsSchema = z
.max(4)
.refine((questions) => new Set(questions.map((q) => q.question)).size === questions.length);

const PERMISSION_INPUT_MAX_CHARS = 10_000;

function permissionDisplayInput(
toolName: string,
input: Record<string, unknown>,
): string | undefined {
let full: string | undefined;
try {
full =
toolName === 'Bash' && typeof input.command === 'string'
? input.command
: JSON.stringify(input, null, 2);
} catch {
return undefined;
}
if (full === undefined) return undefined;
return full.length <= PERMISSION_INPUT_MAX_CHARS ? full : undefined;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: JSON.stringify() is typed as returning string | undefined, so full.length fails strict TypeScript compilation (full is possibly undefined). Handle an undefined serialization result before reading .length. [fixable]

}

function transportSend(transport: SessionTransport, data: Record<string, unknown>): void {
try {
if (transport.isOpen()) transport.send(data);
Expand Down Expand Up @@ -162,6 +181,12 @@ export function buildPermissionHandler(
};
}

const displayInput = questions ? '' : permissionDisplayInput(toolName, _toolInput);
if (displayInput === undefined)
return {
behavior: 'deny',
message: 'Tool input is too large to review safely. Split it into smaller operations.',
};
const inputSummary = summarizeToolInput(toolName, _toolInput);
const tier = getToolTier(toolName);

Expand Down Expand Up @@ -207,11 +232,7 @@ export function buildPermissionHandler(
const request: PermissionRequest = {
permId,
toolName,
toolInput: questions
? ''
: toolName === 'Bash' && typeof _toolInput.command === 'string'
? _toolInput.command
: JSON.stringify(_toolInput, null, 2),
toolInput: displayInput,
title: opts.title,
description: opts.description,
displayName: opts.displayName,
Expand Down
25 changes: 25 additions & 0 deletions packages/protocol/__tests__/ws-schemas-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,31 @@ describe('v2 interrupt / stop / permission_response / set_mode', () => {
expect(r.success).toBe(true);
});

it('rejects whitespace-only question answers', () => {
const r = V2PermissionResponseMessage.safeParse({
type: 'permission_response',
sessionId: 'sess-1',
permId: 'p1',
decision: 'once',
answers: { question: [' '] },
});
expect(r.success).toBe(false);
});

it('validates nonblank answers without changing provider keys or values', () => {
const opaqueId = 'x'.repeat(4001);
const answers = { ' ': [' option '], [opaqueId]: ['value'] };
const r = V2PermissionResponseMessage.safeParse({
type: 'permission_response',
sessionId: 'sess-1',
permId: 'p1',
decision: 'once',
answers,
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.answers).toEqual(answers);
});

it('accepts set_mode with sessionId', () => {
const r = V2SetModeMessage.safeParse({
type: 'set_mode',
Expand Down
Loading
Loading